http-message-signatures
Version:
HTTP message signature implementation
420 lines • 20.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifyMessage = exports.signMessage = exports.augmentHeaders = exports.createSigningParameters = exports.formatSignatureBase = exports.createSignatureBase = exports.extractHeader = exports.deriveComponent = void 0;
const structured_headers_1 = require("structured-headers");
const structured_header_1 = require("../structured-header");
const types_1 = require("../types");
const errors_1 = require("../errors");
/**
* Components can be derived from requests or responses (which can also be bound to their request).
* The signature is essentially (component, params, signingSubject, supplementaryData)
*
* @todo - prefer pseudo-headers over parsed urls
*/
function deriveComponent(component, params, message, req) {
// switch the context of the signing data depending on if the `req` flag was passed
const context = params.has('req') ? req : message;
if (!context) {
throw new Error('Missing request in request-response bound component');
}
switch (component) {
case '@method':
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @method from response');
}
return [context.method.toUpperCase()];
case '@target-uri': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @target-uri on response');
}
return [context.url.toString()];
}
case '@authority': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @authority on response');
}
const { port, protocol, hostname } = typeof context.url === 'string' ? new URL(context.url) : context.url;
let authority = hostname.toLowerCase();
if (port && (protocol === 'http:' && port !== '80' || protocol === 'https:' && port !== '443')) {
authority += `:${port}`;
}
return [authority];
}
case '@scheme': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @scheme on response');
}
const { protocol } = typeof context.url === 'string' ? new URL(context.url) : context.url;
return [protocol.slice(0, -1)];
}
case '@request-target': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @request-target on response');
}
const { pathname, search } = typeof context.url === 'string' ? new URL(context.url) : context.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 [`${pathname}${search}`];
}
case '@path': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @scheme on response');
}
const { pathname } = typeof context.url === 'string' ? new URL(context.url) : context.url;
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-2.2.6
// empty path means use `/`
return [pathname || '/'];
}
case '@query': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @scheme on response');
}
const { search } = typeof context.url === 'string' ? new URL(context.url) : context.url;
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-2.2.7
// absent query params means use `?`
return [search || '?'];
}
case '@query-param': {
if (!(0, types_1.isRequest)(context)) {
throw new Error('Cannot derive @scheme on response');
}
const { searchParams } = typeof context.url === 'string' ? new URL(context.url) : context.url;
if (!params.has('name')) {
throw new Error('@query-param must have a named parameter');
}
const name = decodeURIComponent(params.get('name').toString());
if (!searchParams.has(name)) {
throw new Error(`Expected query parameter "${name}" not found`);
}
return searchParams.getAll(name).map((value) => encodeURIComponent(value));
}
case '@status': {
if ((0, types_1.isRequest)(context)) {
throw new Error('Cannot obtain @status component for requests');
}
return [context.status.toString()];
}
default:
throw new Error(`Unsupported component "${component}"`);
}
}
exports.deriveComponent = deriveComponent;
function extractHeader(header, params, { headers }, req) {
const context = params.has('req') ? req === null || req === void 0 ? void 0 : req.headers : headers;
if (!context) {
throw new Error('Missing request in request-response bound component');
}
const headerTuple = Object.entries(context).find(([name]) => name.toLowerCase() === header);
if (!headerTuple) {
throw new Error(`No header "${header}" found in headers`);
}
const values = (Array.isArray(headerTuple[1]) ? headerTuple[1] : [headerTuple[1]]);
if (params.has('bs') && (params.has('sf') || params.has('key'))) {
throw new Error('Cannot have both `bs` and (implicit) `sf` parameters');
}
if (params.has('sf') || params.has('key')) {
// strict encoding of field
const value = values.join(', ');
const parsed = (0, structured_header_1.parseHeader)(value);
if (params.has('key') && !(parsed instanceof structured_header_1.Dictionary)) {
throw new Error('Unable to parse header as dictionary');
}
if (params.has('key')) {
const key = params.get('key').toString();
if (!parsed.has(key)) {
throw new Error(`Unable to find key "${key}" in structured field`);
}
return [parsed.get(key)];
}
return [parsed.toString()];
}
if (params.has('bs')) {
return [values.map((val) => {
const encoded = Buffer.from(val.trim().replace(/\n\s*/gm, ' '));
return `:${encoded.toString('base64')}:`;
}).join(', ')];
}
// raw encoding
return [values.map((val) => val.trim().replace(/\n\s*/gm, ' ')).join(', ')];
}
exports.extractHeader = extractHeader;
function normaliseParams(params) {
const map = new Map;
params.forEach((value, key) => {
if (value instanceof structured_headers_1.ByteSequence) {
map.set(key, value.toBase64());
}
else if (value instanceof structured_headers_1.Token) {
map.set(key, value.toString());
}
else {
map.set(key, value);
}
});
return map;
}
function createSignatureBase(config, res, req) {
return (config.fields).reduce((base, fieldName) => {
var _a;
const [field, params] = (0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(fieldName));
const fieldParams = normaliseParams(params);
const lcFieldName = field.toLowerCase();
if (lcFieldName !== '@signature-params') {
let value = null;
if (config.componentParser) {
value = (_a = config.componentParser(lcFieldName, fieldParams, res, req)) !== null && _a !== void 0 ? _a : null;
}
if (value === null) {
value = field.startsWith('@') ? deriveComponent(lcFieldName, fieldParams, res, req) : extractHeader(lcFieldName, fieldParams, res, req);
}
base.push([(0, structured_headers_1.serializeItem)([field, params]), value]);
}
return base;
}, []);
}
exports.createSignatureBase = createSignatureBase;
function formatSignatureBase(base) {
return base.map(([key, value]) => {
const quotedKey = (0, structured_headers_1.serializeItem)((0, structured_headers_1.parseItem)((0, structured_header_1.quoteString)(key)));
return value.map((val) => `${quotedKey}: ${val}`).join('\n');
}).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': {
// if there is no alg, but it's listed as a required parameter, we should probably
// throw an error - the problem is that if it's in the default set of params, do we
// really want to throw if there's no keyid?
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);
}
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 augmentHeaders(headers, signature, signatureInput, name) {
let signatureHeaderName = 'Signature';
let signatureInputHeaderName = 'Signature-Input';
let signatureHeader = new Map();
let inputHeader = new Map();
// check to see if there are already signature/signature-input headers
// if there are we want to store the current (case-sensitive) name of the header
// and we want to parse out the current values so we can append our new signature
for (const header in headers) {
switch (header.toLowerCase()) {
case 'signature': {
signatureHeaderName = header;
signatureHeader = (0, structured_headers_1.parseDictionary)(Array.isArray(headers[header]) ? headers[header].join(', ') : headers[header]);
break;
}
case 'signature-input':
signatureInputHeaderName = header;
inputHeader = (0, structured_headers_1.parseDictionary)(Array.isArray(headers[header]) ? headers[header].join(', ') : headers[header]);
break;
}
}
// find a unique signature name for the header. Check if any existing headers already use
// the name we intend to use, if there are, add incrementing numbers to the signature name
// until we have a unique name to use
let signatureName = name !== null && name !== void 0 ? name : 'sig';
if (signatureHeader.has(signatureName) || inputHeader.has(signatureName)) {
let count = 0;
while (signatureHeader.has(`${signatureName}${count}`) || inputHeader.has(`${signatureName}${count}`)) {
count++;
}
signatureName += count.toString();
}
// append our signature and signature-inputs to the headers and return
signatureHeader.set(signatureName, [new structured_headers_1.ByteSequence(signature.toString('base64')), new Map()]);
inputHeader.set(signatureName, (0, structured_headers_1.parseList)(signatureInput)[0]);
return {
...headers,
[signatureHeaderName]: (0, structured_headers_1.serializeDictionary)(signatureHeader),
[signatureInputHeaderName]: (0, structured_headers_1.serializeDictionary)(inputHeader),
};
}
exports.augmentHeaders = augmentHeaders;
async function signMessage(config, message, req) {
var _a;
const signingParameters = createSigningParameters(config);
const signatureBase = createSignatureBase({
fields: (_a = config.fields) !== null && _a !== void 0 ? _a : [],
componentParser: config.componentParser,
}, message, req);
const signatureInput = (0, structured_headers_1.serializeList)([
[
signatureBase.map(([item]) => (0, structured_headers_1.parseItem)(item)),
signingParameters,
],
]);
signatureBase.push(['"@signature-params"', [signatureInput]]);
const base = formatSignatureBase(signatureBase);
// call sign
const signature = await config.key.sign(Buffer.from(base));
return {
...message,
headers: augmentHeaders({ ...message.headers }, signature, signatureInput, config.name),
};
}
exports.signMessage = signMessage;
async function verifyMessage(config, message, req) {
var _a, _b, _c, _d, _e;
const { signatures, signatureInputs } = Object.entries(message.headers).reduce((accum, [name, value]) => {
switch (name.toLowerCase()) {
case 'signature':
return Object.assign(accum, {
signatures: (0, structured_headers_1.parseDictionary)(Array.isArray(value) ? value.join(', ') : value),
});
case 'signature-input':
return Object.assign(accum, {
signatureInputs: (0, structured_headers_1.parseDictionary)(Array.isArray(value) ? value.join(', ') : value),
});
default:
return accum;
}
}, {});
// no signatures means an indeterminate result
if (!(signatures === null || signatures === void 0 ? void 0 : signatures.size) && !(signatureInputs === null || signatureInputs === void 0 ? void 0 : signatureInputs.size)) {
return null;
}
// a missing header means we can't verify the signatures
if (!(signatures === null || signatures === void 0 ? void 0 : signatures.size) || !(signatureInputs === null || signatureInputs === void 0 ? void 0 : signatureInputs.size)) {
throw new Error('Incomplete signature headers');
}
const now = Math.floor(Date.now() / 1000);
const tolerance = (_a = config.tolerance) !== null && _a !== void 0 ? _a : 0;
const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : (_b = config.notAfter) !== null && _b !== void 0 ? _b : now;
const maxAge = (_c = config.maxAge) !== null && _c !== void 0 ? _c : null;
const requiredParams = (_d = config.requiredParams) !== null && _d !== void 0 ? _d : [];
const requiredFields = (_e = config.requiredFields) !== null && _e !== void 0 ? _e : [];
return Array.from(signatureInputs.entries()).reduce(async (prev, [name, input]) => {
var _a;
const signatureParams = Array.from(input[1].entries()).reduce((params, [key, value]) => {
if (value instanceof structured_headers_1.ByteSequence) {
Object.assign(params, {
[key]: value.toBase64(),
});
}
else if (value instanceof structured_headers_1.Token) {
Object.assign(params, {
[key]: value.toString(),
});
}
else if (key === 'created' || key === 'expired') {
Object.assign(params, {
[key]: new Date(value * 1000),
});
}
else {
Object.assign(params, {
[key]: value,
});
}
return params;
}, {});
const [result, key] = await Promise.all([
prev.catch((e) => e),
config.keyLookup(signatureParams),
]);
// @todo - confirm this is all working as expected
if (config.all && !key) {
throw new errors_1.UnknownKeyError('Unknown key');
}
if (!key) {
if (result instanceof Error) {
throw result;
}
return result;
}
if (input[1].has('alg') && ((_a = key.algs) === null || _a === void 0 ? void 0 : _a.includes(input[1].get('alg'))) === false) {
throw new errors_1.UnsupportedAlgorithmError('Unsupported key algorithm');
}
if (!(0, structured_headers_1.isInnerList)(input)) {
throw new errors_1.MalformedSignatureError('Malformed signature input');
}
const hasRequiredParams = requiredParams.every((param) => input[1].has(param));
if (!hasRequiredParams) {
throw new errors_1.UnacceptableSignatureError('Missing required signature parameters');
}
// this could be tricky, what if we say "@method" but there is "@method;req"
const hasRequiredFields = requiredFields.every((field) => input[0].some(([fieldName]) => fieldName === field));
if (!hasRequiredFields) {
throw new errors_1.UnacceptableSignatureError('Missing required signed fields');
}
if (input[1].has('created')) {
const created = input[1].get('created') - tolerance;
// maxAge overrides expires.
// signature is older than maxAge
if ((maxAge && now - created > maxAge) || created > notAfter) {
throw new errors_1.ExpiredError('Signature is too old');
}
}
if (input[1].has('expires')) {
const expires = input[1].get('expires') + tolerance;
// expired signature
if (now > expires) {
throw new errors_1.ExpiredError('Signature has expired');
}
}
// now look to verify the signature! Build the expected "signing base" and verify it!
const fields = input[0].map((item) => (0, structured_headers_1.serializeItem)(item));
const signingBase = createSignatureBase({ fields, componentParser: config.componentParser }, message, req);
signingBase.push(['"@signature-params"', [(0, structured_headers_1.serializeList)([input])]]);
const base = formatSignatureBase(signingBase);
const signature = signatures.get(name);
if (!signature) {
throw new errors_1.MalformedSignatureError('No corresponding signature for input');
}
if (!(0, structured_headers_1.isByteSequence)(signature[0])) {
throw new errors_1.MalformedSignatureError('Malformed signature');
}
return key.verify(Buffer.from(base), Buffer.from(signature[0].toBase64(), 'base64'), signatureParams);
}, Promise.resolve(null));
}
exports.verifyMessage = verifyMessage;
//# sourceMappingURL=index.js.map