http-message-signatures
Version:
HTTP message signature implementation
315 lines • 14.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifyMessage = exports.signMessage = exports.createSignatureBase = exports.createSigningParameters = exports.formatSignatureBase = exports.extractHeader = exports.deriveComponent = void 0;
const structured_headers_1 = require("structured-headers");
const types_1 = require("../types");
const structured_header_1 = require("../structured-header");
function mapCavageAlgorithm(alg) {
switch (alg.toLowerCase()) {
case 'hs2019':
return 'rsa-pss-sha512';
case 'rsa-sha1':
return 'rsa-v1_5-sha1';
case 'rsa-sha256':
return 'rsa-v1_5-sha256';
case 'ecdsa-sha256':
return 'ecdsa-p256-sha256';
default:
return alg;
}
}
function mapHttpbisAlgorithm(alg) {
switch (alg.toLowerCase()) {
case 'rsa-pss-sha512':
return 'hs2019';
case 'rsa-v1_5-sha1':
return 'rsa-sha1';
case 'rsa-v1_5-sha256':
return 'rsa-sha256';
case 'ecdsa-p256-sha256':
return 'ecdsa-sha256';
default:
return alg;
}
}
/**
* Components can be derived from requests or responses (which can also be bound to their request).
* The signature is essentially (component, signingSubject, supplementaryData)
*
* @todo - Allow consumers to register their own component parser somehow
*/
function deriveComponent(component, message) {
const [componentName, params] = (0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(component));
if (params.size) {
throw new Error('Component parameters are not supported in cavage');
}
switch (componentName.toString().toLowerCase()) {
case '@request-target': {
if (!(0, types_1.isRequest)(message)) {
throw new Error('Cannot derive @request-target on response');
}
const { pathname, search } = typeof message.url === 'string' ? new URL(message.url) : message.url;
// this is really sketchy because the request-target is actually what is in the raw HTTP header
// so one should avoid signing this value as the application layer just can't know how this
// is formatted
return [`${message.method.toLowerCase()} ${pathname}${search}`];
}
default:
throw new Error(`Unsupported component "${component}"`);
}
}
exports.deriveComponent = deriveComponent;
function extractHeader(header, { headers }) {
const [headerName, params] = (0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(header));
if (params.size) {
throw new Error('Field parameters are not supported in cavage');
}
const lcHeaderName = headerName.toString().toLowerCase();
const headerTuple = Object.entries(headers).find(([name]) => name.toLowerCase() === lcHeaderName);
if (!headerTuple) {
throw new Error(`No header ${headerName} found in headers`);
}
return [(Array.isArray(headerTuple[1]) ? headerTuple[1] : [headerTuple[1]]).map((val) => val.trim().replace(/\n\s*/gm, ' ')).join(', ')];
}
exports.extractHeader = extractHeader;
function formatSignatureBase(base) {
return base.reduce((accum, [key, value]) => {
const [keyName] = (0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(key));
const lcKey = keyName.toLowerCase();
if (lcKey.startsWith('@')) {
accum.push(`(${lcKey.slice(1)}): ${value.join(', ')}`);
}
else {
accum.push(`${key.toLowerCase()}: ${value.join(', ')}`);
}
return accum;
}, []).join('\n');
}
exports.formatSignatureBase = formatSignatureBase;
function createSigningParameters(config) {
var _a;
const now = new Date();
return ((_a = config.params) !== null && _a !== void 0 ? _a : types_1.defaultParams).reduce((params, paramName) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
let value = '';
switch (paramName.toLowerCase()) {
case 'created':
// created is optional but recommended. If created is supplied but is null, that's an explicit
// instruction to *not* include the created parameter
if (((_a = config.paramValues) === null || _a === void 0 ? void 0 : _a.created) !== null) {
const created = (_c = (_b = config.paramValues) === null || _b === void 0 ? void 0 : _b.created) !== null && _c !== void 0 ? _c : now;
value = Math.floor(created.getTime() / 1000);
}
break;
case 'expires':
// attempt to obtain an explicit expires time, otherwise create one that is 300 seconds after
// creation. Don't add an expires time if there is no created time
if (((_d = config.paramValues) === null || _d === void 0 ? void 0 : _d.expires) || ((_e = config.paramValues) === null || _e === void 0 ? void 0 : _e.created) !== null) {
const expires = (_g = (_f = config.paramValues) === null || _f === void 0 ? void 0 : _f.expires) !== null && _g !== void 0 ? _g : new Date(((_j = (_h = config.paramValues) === null || _h === void 0 ? void 0 : _h.created) !== null && _j !== void 0 ? _j : now).getTime() + 300000);
value = Math.floor(expires.getTime() / 1000);
}
break;
case 'keyid': {
// attempt to obtain the keyid omit if missing
const kid = (_m = (_l = (_k = config.paramValues) === null || _k === void 0 ? void 0 : _k.keyid) !== null && _l !== void 0 ? _l : config.key.id) !== null && _m !== void 0 ? _m : null;
if (kid) {
value = kid.toString();
}
break;
}
case 'alg': {
const alg = (_q = (_p = (_o = config.paramValues) === null || _o === void 0 ? void 0 : _o.alg) !== null && _p !== void 0 ? _p : config.key.alg) !== null && _q !== void 0 ? _q : null;
if (alg) {
value = alg.toString();
}
break;
}
default:
if (((_r = config.paramValues) === null || _r === void 0 ? void 0 : _r[paramName]) instanceof Date) {
value = Math.floor(config.paramValues[paramName].getTime() / 1000).toString();
}
else if ((_s = config.paramValues) === null || _s === void 0 ? void 0 : _s[paramName]) {
value = config.paramValues[paramName];
}
}
if (value) {
params.set(paramName, value);
}
return params;
}, new Map());
}
exports.createSigningParameters = createSigningParameters;
function createSignatureBase(fields, message, signingParameters) {
return fields.reduce((base, fieldName) => {
const [field, params] = (0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(fieldName));
if (params.size) {
throw new Error('Field parameters are not supported');
}
const lcFieldName = field.toString().toLowerCase();
switch (lcFieldName) {
case '@created':
if (signingParameters.has('created')) {
base.push(['(created)', [signingParameters.get('created')]]);
}
break;
case '@expires':
if (signingParameters.has('expires')) {
base.push(['(expires)', [signingParameters.get('expires')]]);
}
break;
case '@request-target': {
if (!(0, types_1.isRequest)(message)) {
throw new Error('Cannot read target of response');
}
const { pathname, search } = typeof message.url === 'string' ? new URL(message.url) : message.url;
base.push(['(request-target)', [`${message.method.toLowerCase()} ${pathname}${search}`]]);
break;
}
default:
base.push([lcFieldName, extractHeader(lcFieldName, message)]);
}
return base;
}, []);
}
exports.createSignatureBase = createSignatureBase;
async function signMessage(config, message) {
var _a;
const signingParameters = createSigningParameters(config);
// NB: In spec versions 11 & 12 (the last 2), if no set of fields to sign has been provided, the default should be (created)
// other versions relied on the Date header - perhaps this should be configurable
const signatureBase = createSignatureBase((_a = config.fields) !== null && _a !== void 0 ? _a : ['@created'], message, signingParameters);
const base = formatSignatureBase(signatureBase);
// call sign
const signature = await config.key.sign(Buffer.from(base));
const headerNames = signatureBase.map(([key]) => key);
// there is a somewhat deliberate and intentional deviation from spec here:
// If no headers (config.fields) are specified, the spec allows for it to be *inferred*
// that the (created) value is used, I don't like that and would rather be explicit
const header = [
...Array.from(signingParameters.entries()).map(([name, value]) => {
if (name === 'alg') {
return `algorithm="${mapHttpbisAlgorithm(value)}"`;
}
if (name === 'keyid') {
return `keyId="${value}"`;
}
if (typeof value === 'number') {
return `${name}=${value}`;
}
return `${name}="${value.toString()}"`;
}),
`headers="${headerNames.join(' ')}"`,
`signature="${signature.toString('base64')}"`,
].join(',');
return {
...message,
headers: {
...message.headers,
Signature: header,
},
};
}
exports.signMessage = signMessage;
async function verifyMessage(config, message) {
var _a, _b, _c, _d, _e, _f, _g;
const header = Object.entries(message.headers).find(([name]) => name.toLowerCase() === 'signature');
if (!header) {
return null;
}
const parsedHeader = (Array.isArray(header[1]) ? header[1].join(', ') : header[1]).split(',').reduce((parts, value) => {
const [key, ...values] = value.trim().split('=');
if (parts.has(key)) {
throw new Error('Same parameter defined repeatedly');
}
const val = values.join('=').replace(/^"(.*)"$/, '$1');
switch (key.toLowerCase()) {
case 'created':
case 'expires':
parts.set(key, parseInt(val, 10));
break;
default:
parts.set(key, val);
}
return parts;
}, new Map());
if (!parsedHeader.has('signature')) {
throw new Error('Missing signature from header');
}
const baseParts = new Map(createSignatureBase(((_a = parsedHeader.get('headers')) !== null && _a !== void 0 ? _a : '(created)').split(' ').map((component) => {
return component.toLowerCase().replace(/^\((.*)\)$/, '@$1');
}), message, parsedHeader));
const base = formatSignatureBase(Array.from(baseParts.entries()));
const now = Math.floor(Date.now() / 1000);
const tolerance = (_b = config.tolerance) !== null && _b !== void 0 ? _b : 0;
const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : (_c = config.notAfter) !== null && _c !== void 0 ? _c : now;
const maxAge = (_d = config.maxAge) !== null && _d !== void 0 ? _d : null;
const requiredParams = (_e = config.requiredParams) !== null && _e !== void 0 ? _e : [];
const requiredFields = (_f = config.requiredFields) !== null && _f !== void 0 ? _f : [];
const hasRequiredParams = requiredParams.every((param) => baseParts.has(param));
if (!hasRequiredParams) {
return false;
}
// this could be tricky, what if we say "@method" but there is "@method;req"
const hasRequiredFields = requiredFields.every((field) => {
return parsedHeader.has(field.toLowerCase().replace(/^@(.*)/, '($1)'));
});
if (!hasRequiredFields) {
return false;
}
if (parsedHeader.has('created')) {
const created = parsedHeader.get('created') - tolerance;
// maxAge overrides expires.
// signature is older than maxAge
if (maxAge && created - now > maxAge) {
return false;
}
// created after the allowed time (ie: created in the future)
if (created > notAfter) {
return false;
}
}
if (parsedHeader.has('expires')) {
const expires = parsedHeader.get('expires') + tolerance;
// expired signature
if (expires > now) {
return false;
}
}
// now look to verify the signature! Build the expected "signing base" and verify it!
const params = Array.from(parsedHeader.entries()).reduce((params, [key, value]) => {
let keyName = key;
let val;
switch (key.toLowerCase()) {
case 'created':
case 'expires':
val = new Date(value * 1000);
break;
case 'signature':
case 'headers':
return params;
case 'algorithm':
keyName = 'alg';
val = mapCavageAlgorithm(value);
break;
case 'keyid':
keyName = 'keyid';
val = value;
break;
default: {
if (typeof value === 'string' || typeof value === 'number') {
val = value;
}
else {
val = value.toString();
}
}
}
return Object.assign(params, {
[keyName]: val,
});
}, {});
const key = await config.keyLookup(params);
return (_g = key === null || key === void 0 ? void 0 : key.verify(Buffer.from(base), Buffer.from(parsedHeader.get('signature'), 'base64'), params)) !== null && _g !== void 0 ? _g : null;
}
exports.verifyMessage = verifyMessage;
//# sourceMappingURL=index.js.map