http-message-signatures
Advanced tools
Comparing version 0.1.2 to 1.0.0
/// <reference types="node" /> | ||
/// <reference types="node" /> | ||
import { BinaryLike, KeyLike, SignKeyObjectInput, SignPrivateKeyInput, VerifyKeyObjectInput, VerifyPublicKeyInput } from 'crypto'; | ||
export declare type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512'; | ||
export interface Signer { | ||
(data: BinaryLike): Promise<Buffer>; | ||
alg: Algorithm; | ||
} | ||
export interface Verifier { | ||
(data: BinaryLike, signature: BinaryLike): Promise<boolean>; | ||
alg: Algorithm; | ||
} | ||
export declare function createSigner(alg: Algorithm, key: BinaryLike | KeyLike | SignKeyObjectInput | SignPrivateKeyInput): Signer; | ||
export declare function createVerifier(alg: Algorithm, key: BinaryLike | KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput): Verifier; | ||
import { SigningKey, Algorithm, Verifier } from '../types'; | ||
/** | ||
* A helper method for easier consumption of the library. | ||
* | ||
* Consumers of the library can use this function to create a signer "out of the box" using a PEM | ||
* file they have access to. | ||
* | ||
* @todo - read the key and determine its type automatically to make usage even easier | ||
*/ | ||
export declare function createSigner(key: BinaryLike | KeyLike | SignKeyObjectInput | SignPrivateKeyInput, alg: Algorithm, id?: string): SigningKey; | ||
/** | ||
* A helper method for easier consumption of the library. | ||
* | ||
* Consumers of the library can use this function to create a verifier "out of the box" using a PEM | ||
* file they have access to. | ||
* | ||
* Verifiers are a little trickier as they will need to be produced "on demand" and the consumer will | ||
* need to implement some logic for looking up keys by id (or other aspects of the request if no keyid | ||
* is supplied) and then returning a validator | ||
* | ||
* @todo - attempt to look up algorithm automatically | ||
*/ | ||
export declare function createVerifier(key: BinaryLike | KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput, alg: Algorithm): Verifier; |
@@ -6,10 +6,19 @@ "use strict"; | ||
const constants_1 = require("constants"); | ||
function createSigner(alg, key) { | ||
let signer; | ||
const errors_1 = require("../errors"); | ||
/** | ||
* A helper method for easier consumption of the library. | ||
* | ||
* Consumers of the library can use this function to create a signer "out of the box" using a PEM | ||
* file they have access to. | ||
* | ||
* @todo - read the key and determine its type automatically to make usage even easier | ||
*/ | ||
function createSigner(key, alg, id) { | ||
const signer = { alg }; | ||
switch (alg) { | ||
case 'hmac-sha256': | ||
signer = async (data) => (0, crypto_1.createHmac)('sha256', key).update(data).digest(); | ||
signer.sign = async (data) => (0, crypto_1.createHmac)('sha256', key).update(data).digest(); | ||
break; | ||
case 'rsa-pss-sha512': | ||
signer = async (data) => (0, crypto_1.createSign)('sha512').update(data).sign({ | ||
signer.sign = async (data) => (0, crypto_1.createSign)('sha512').update(data).sign({ | ||
key, | ||
@@ -20,3 +29,3 @@ padding: constants_1.RSA_PKCS1_PSS_PADDING, | ||
case 'rsa-v1_5-sha256': | ||
signer = async (data) => (0, crypto_1.createSign)('sha256').update(data).sign({ | ||
signer.sign = async (data) => (0, crypto_1.createSign)('sha256').update(data).sign({ | ||
key, | ||
@@ -26,12 +35,41 @@ padding: constants_1.RSA_PKCS1_PADDING, | ||
break; | ||
case 'rsa-v1_5-sha1': | ||
// this is legacy for cavage | ||
signer.sign = async (data) => (0, crypto_1.createSign)('sha1').update(data).sign({ | ||
key, | ||
padding: constants_1.RSA_PKCS1_PADDING, | ||
}); | ||
break; | ||
case 'ecdsa-p256-sha256': | ||
signer = async (data) => (0, crypto_1.createSign)('sha256').update(data).sign(key); | ||
signer.sign = async (data) => (0, crypto_1.createSign)('sha256').update(data).sign(key); | ||
break; | ||
case 'ecdsa-p384-sha384': | ||
signer.sign = async (data) => (0, crypto_1.createSign)('sha384').update(data).sign(key); | ||
break; | ||
case 'ed25519': | ||
signer.sign = async (data) => (0, crypto_1.sign)(null, data, key); | ||
// signer.sign = async (data: Buffer) => createSign('ed25519').update(data).sign(key as KeyLike); | ||
break; | ||
default: | ||
throw new Error(`Unsupported signing algorithm ${alg}`); | ||
throw new errors_1.UnknownAlgorithmError(`Unsupported signing algorithm ${alg}`); | ||
} | ||
return Object.assign(signer, { alg }); | ||
if (id) { | ||
signer.id = id; | ||
} | ||
return signer; | ||
} | ||
exports.createSigner = createSigner; | ||
function createVerifier(alg, key) { | ||
/** | ||
* A helper method for easier consumption of the library. | ||
* | ||
* Consumers of the library can use this function to create a verifier "out of the box" using a PEM | ||
* file they have access to. | ||
* | ||
* Verifiers are a little trickier as they will need to be produced "on demand" and the consumer will | ||
* need to implement some logic for looking up keys by id (or other aspects of the request if no keyid | ||
* is supplied) and then returning a validator | ||
* | ||
* @todo - attempt to look up algorithm automatically | ||
*/ | ||
function createVerifier(key, alg) { | ||
let verifier; | ||
@@ -42,4 +80,3 @@ switch (alg) { | ||
const expected = (0, crypto_1.createHmac)('sha256', key).update(data).digest(); | ||
const sig = Buffer.from(signature); | ||
return sig.length === expected.length && (0, crypto_1.timingSafeEqual)(sig, expected); | ||
return signature.length === expected.length && (0, crypto_1.timingSafeEqual)(signature, expected); | ||
}; | ||
@@ -51,4 +88,10 @@ break; | ||
padding: constants_1.RSA_PKCS1_PSS_PADDING, | ||
}, Buffer.from(signature)); | ||
}, signature); | ||
break; | ||
case 'rsa-v1_5-sha1': | ||
verifier = async (data, signature) => (0, crypto_1.createVerify)('sha1').update(data).verify({ | ||
key, | ||
padding: constants_1.RSA_PKCS1_PADDING, | ||
}, signature); | ||
break; | ||
case 'rsa-v1_5-sha256': | ||
@@ -58,9 +101,15 @@ verifier = async (data, signature) => (0, crypto_1.createVerify)('sha256').update(data).verify({ | ||
padding: constants_1.RSA_PKCS1_PADDING, | ||
}, Buffer.from(signature)); | ||
}, signature); | ||
break; | ||
case 'ecdsa-p256-sha256': | ||
verifier = async (data, signature) => (0, crypto_1.createVerify)('sha256').update(data).verify(key, Buffer.from(signature)); | ||
verifier = async (data, signature) => (0, crypto_1.createVerify)('sha256').update(data).verify(key, signature); | ||
break; | ||
case 'ecdsa-p384-sha384': | ||
verifier = async (data, signature) => (0, crypto_1.createVerify)('sha384').update(data).verify(key, signature); | ||
break; | ||
case 'ed25519': | ||
verifier = async (data, signature) => (0, crypto_1.verify)(null, data, key, signature); | ||
break; | ||
default: | ||
throw new Error(`Unsupported signing algorithm ${alg}`); | ||
throw new errors_1.UnknownAlgorithmError(`Unsupported signing algorithm ${alg}`); | ||
} | ||
@@ -67,0 +116,0 @@ return Object.assign(verifier, { alg }); |
@@ -1,7 +0,14 @@ | ||
import { Component, HeaderExtractionOptions, Parameters, RequestLike, ResponseLike, SignOptions } from '../types'; | ||
export declare const defaultSigningComponents: Component[]; | ||
export declare function extractHeader({ headers }: RequestLike | ResponseLike, header: string, opts?: HeaderExtractionOptions): string; | ||
export declare function extractComponent(message: RequestLike | ResponseLike, component: string): string; | ||
export declare function buildSignedData(request: RequestLike, components: Component[], params: Parameters): string; | ||
export declare function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string; | ||
export declare function sign(request: RequestLike, opts: SignOptions): Promise<RequestLike>; | ||
import { Request, Response, SignConfig, VerifyConfig } from '../types'; | ||
/** | ||
* 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 | ||
*/ | ||
export declare function deriveComponent(component: string, message: Request | Response): string[]; | ||
export declare function extractHeader(header: string, { headers }: Request | Response): string[]; | ||
export declare function formatSignatureBase(base: [string, string[]][]): string; | ||
export declare function createSigningParameters(config: SignConfig): Map<string, string | number>; | ||
export declare function createSignatureBase(fields: string[], message: Request | Response, signingParameters: Map<string, string | number>): [string, string[]][]; | ||
export declare function signMessage<T extends Request | Response = Request | Response>(config: SignConfig, message: T): Promise<T>; | ||
export declare function verifyMessage(config: VerifyConfig, message: Request | Response): Promise<boolean | null>; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.sign = exports.buildSignatureInputString = exports.buildSignedData = exports.extractComponent = exports.extractHeader = exports.defaultSigningComponents = void 0; | ||
const url_1 = require("url"); | ||
exports.defaultSigningComponents = [ | ||
'@request-target', | ||
'content-type', | ||
'digest', | ||
'content-digest', | ||
]; | ||
function extractHeader({ headers }, header, opts) { | ||
var _a, _b; | ||
const lcHeader = header.toLowerCase(); | ||
const key = Object.keys(headers).find((name) => name.toLowerCase() === lcHeader); | ||
const allowMissing = (_a = opts === null || opts === void 0 ? void 0 : opts.allowMissing) !== null && _a !== void 0 ? _a : true; | ||
if (!allowMissing && !key) { | ||
throw new Error(`Unable to extract header "${header}" from message`); | ||
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; | ||
} | ||
let val = key ? (_b = headers[key]) !== null && _b !== void 0 ? _b : '' : ''; | ||
if (Array.isArray(val)) { | ||
val = val.join(', '); | ||
} | ||
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; | ||
} | ||
return val.toString().replace(/\s+/g, ' '); | ||
} | ||
exports.extractHeader = extractHeader; | ||
// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 | ||
function extractComponent(message, component) { | ||
switch (component) { | ||
/** | ||
* 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': { | ||
const { pathname, search } = new url_1.URL(message.url); | ||
return `${message.method.toLowerCase()} ${pathname}${search}`; | ||
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(`Unknown specialty component ${component}`); | ||
throw new Error(`Unsupported component "${component}"`); | ||
} | ||
} | ||
exports.extractComponent = extractComponent; | ||
const ALG_MAP = { | ||
'rsa-v1_5-sha256': 'rsa-sha256', | ||
}; | ||
function buildSignedData(request, components, params) { | ||
const payloadParts = {}; | ||
const paramNames = Object.keys(params); | ||
if (components.includes('@request-target')) { | ||
Object.assign(payloadParts, { | ||
'(request-target)': extractComponent(request, '@request-target'), | ||
}); | ||
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'); | ||
} | ||
if (paramNames.includes('created')) { | ||
Object.assign(payloadParts, { | ||
'(created)': params.created, | ||
}); | ||
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`); | ||
} | ||
if (paramNames.includes('expires')) { | ||
Object.assign(payloadParts, { | ||
'(expires)': params.expires, | ||
}); | ||
} | ||
components.forEach((name) => { | ||
if (!name.startsWith('@')) { | ||
Object.assign(payloadParts, { | ||
[name.toLowerCase()]: extractHeader(request, name), | ||
}); | ||
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(', ')}`); | ||
} | ||
}); | ||
return Object.entries(payloadParts).map(([name, value]) => { | ||
if (value instanceof Date) { | ||
return `${name}: ${Math.floor(value.getTime() / 1000)}`; | ||
} | ||
else { | ||
return `${name}: ${value.toString()}`; | ||
accum.push(`${key.toLowerCase()}: ${value.join(', ')}`); | ||
} | ||
}).join('\n'); | ||
return accum; | ||
}, []).join('\n'); | ||
} | ||
exports.buildSignedData = buildSignedData; | ||
function buildSignatureInputString(componentNames, parameters) { | ||
const params = Object.entries(parameters).reduce((normalised, [name, value]) => { | ||
var _a; | ||
switch (name.toLowerCase()) { | ||
case 'keyid': | ||
return Object.assign(normalised, { | ||
keyId: value, | ||
}); | ||
case 'alg': | ||
return Object.assign(normalised, { | ||
algorithm: (_a = ALG_MAP[value]) !== null && _a !== void 0 ? _a : value, | ||
}); | ||
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: | ||
return Object.assign(normalised, { | ||
[name]: value, | ||
}); | ||
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]; | ||
} | ||
} | ||
}, {}); | ||
const headers = []; | ||
const paramNames = Object.keys(params); | ||
if (componentNames.includes('@request-target')) { | ||
headers.push('(request-target)'); | ||
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); | ||
const signatureBase = createSignatureBase((_a = config.fields) !== null && _a !== void 0 ? _a : [], message, signingParameters); | ||
const base = formatSignatureBase(signatureBase); | ||
// call sign | ||
const signature = await config.key.sign(Buffer.from(base)); | ||
const headerNames = signatureBase.map(([key]) => key); | ||
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; | ||
} | ||
if (paramNames.includes('created')) { | ||
headers.push('(created)'); | ||
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'); | ||
} | ||
if (paramNames.includes('expires')) { | ||
headers.push('(expires)'); | ||
const baseParts = new Map(createSignatureBase(((_a = parsedHeader.get('headers')) !== null && _a !== void 0 ? _a : '').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; | ||
} | ||
componentNames.forEach((name) => { | ||
if (!name.startsWith('@')) { | ||
headers.push(name.toLowerCase()); | ||
} | ||
// 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)')); | ||
}); | ||
return `${Object.entries(params).map(([name, value]) => { | ||
if (typeof value === 'number') { | ||
return `${name}=${value}`; | ||
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; | ||
} | ||
else if (value instanceof Date) { | ||
return `${name}=${Math.floor(value.getTime() / 1000)}`; | ||
// created after the allowed time (ie: created in the future) | ||
if (created > notAfter) { | ||
return false; | ||
} | ||
else { | ||
return `${name}="${value.toString()}"`; | ||
} | ||
if (parsedHeader.has('expires')) { | ||
const expires = parsedHeader.get('expires') + tolerance; | ||
// expired signature | ||
if (expires > now) { | ||
return false; | ||
} | ||
}).join(',')},headers="${headers.join(' ')}"`; | ||
} | ||
// 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.buildSignatureInputString = buildSignatureInputString; | ||
// @todo - should be possible to sign responses too | ||
async function sign(request, opts) { | ||
var _a; | ||
const signingComponents = (_a = opts.components) !== null && _a !== void 0 ? _a : exports.defaultSigningComponents; | ||
const signingParams = { | ||
...opts.parameters, | ||
keyid: opts.keyId, | ||
alg: opts.signer.alg, | ||
}; | ||
const signatureInputString = buildSignatureInputString(signingComponents, signingParams); | ||
const dataToSign = buildSignedData(request, signingComponents, signingParams); | ||
const signature = await opts.signer(Buffer.from(dataToSign)); | ||
Object.assign(request.headers, { | ||
Signature: `${signatureInputString},signature="${signature.toString('base64')}"`, | ||
}); | ||
return request; | ||
} | ||
exports.sign = sign; | ||
exports.verifyMessage = verifyMessage; | ||
//# sourceMappingURL=index.js.map |
@@ -1,7 +0,20 @@ | ||
import { Component, HeaderExtractionOptions, Parameters, RequestLike, ResponseLike, SignOptions } from '../types'; | ||
export declare const defaultSigningComponents: Component[]; | ||
export declare function extractHeader({ headers }: RequestLike | ResponseLike, header: string, opts?: HeaderExtractionOptions): string; | ||
export declare function extractComponent(message: RequestLike | ResponseLike, component: string): string; | ||
export declare function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string; | ||
export declare function buildSignedData(request: RequestLike, components: Component[], signatureInputString: string): string; | ||
export declare function sign(request: RequestLike, opts: SignOptions): Promise<RequestLike>; | ||
/// <reference types="node" /> | ||
import { Parameters } from 'structured-headers'; | ||
import { Request, Response, SignConfig, VerifyConfig, CommonConfig } from '../types'; | ||
export declare function deriveComponent(component: string, params: Map<string, string | number | boolean>, res: Response, req?: Request): string[]; | ||
export declare function deriveComponent(component: string, params: Map<string, string | number | boolean>, req: Request): string[]; | ||
export declare function extractHeader(header: string, params: Map<string, string | number | boolean>, res: Response, req?: Request): string[]; | ||
export declare function extractHeader(header: string, params: Map<string, string | number | boolean>, req: Request): string[]; | ||
export declare function createSignatureBase(config: CommonConfig & { | ||
fields: string[]; | ||
}, res: Response, req?: Request): [string, string[]][]; | ||
export declare function createSignatureBase(config: CommonConfig & { | ||
fields: string[]; | ||
}, req: Request): [string, string[]][]; | ||
export declare function formatSignatureBase(base: [string, string[]][]): string; | ||
export declare function createSigningParameters(config: SignConfig): Parameters; | ||
export declare function augmentHeaders(headers: Record<string, string | string[]>, signature: Buffer, signatureInput: string, name?: string): Record<string, string | string[]>; | ||
export declare function signMessage<T extends Response = Response, U extends Request = Request>(config: SignConfig, res: T, req?: U): Promise<T>; | ||
export declare function signMessage<T extends Request = Request>(config: SignConfig, req: T): Promise<T>; | ||
export declare function verifyMessage(config: VerifyConfig, response: Response, request?: Request): Promise<boolean | null>; | ||
export declare function verifyMessage(config: VerifyConfig, request: Request): Promise<boolean | null>; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.sign = exports.buildSignedData = exports.buildSignatureInputString = exports.extractComponent = exports.extractHeader = exports.defaultSigningComponents = void 0; | ||
const url_1 = require("url"); | ||
exports.defaultSigningComponents = [ | ||
'@method', | ||
'@path', | ||
'@query', | ||
'@authority', | ||
'content-type', | ||
'digest', | ||
'content-digest', | ||
]; | ||
function extractHeader({ headers }, header, opts) { | ||
var _a, _b; | ||
const lcHeader = header.toLowerCase(); | ||
const key = Object.keys(headers).find((name) => name.toLowerCase() === lcHeader); | ||
const allowMissing = (_a = opts === null || opts === void 0 ? void 0 : opts.allowMissing) !== null && _a !== void 0 ? _a : true; | ||
if (!allowMissing && !key) { | ||
throw new Error(`Unable to extract header "${header}" from message`); | ||
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'); | ||
} | ||
let val = key ? (_b = headers[key]) !== null && _b !== void 0 ? _b : '' : ''; | ||
if (Array.isArray(val)) { | ||
val = val.join(', '); | ||
} | ||
return val.toString().replace(/\s+/g, ' '); | ||
} | ||
exports.extractHeader = extractHeader; | ||
function populateDefaultParameters(parameters) { | ||
return { | ||
created: new Date(), | ||
...parameters, | ||
}; | ||
} | ||
// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 | ||
function extractComponent(message, component) { | ||
switch (component) { | ||
case '@method': | ||
return message.method.toUpperCase(); | ||
case '@target-uri': | ||
return message.url; | ||
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': { | ||
const url = new url_1.URL(message.url); | ||
const port = url.port ? parseInt(url.port, 10) : null; | ||
return `${url.host}${port && ![80, 443].includes(port) ? `:${port}` : ''}`; | ||
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': { | ||
const { protocol } = new url_1.URL(message.url); | ||
return protocol.slice(0, -1); | ||
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': { | ||
const { pathname, search } = new url_1.URL(message.url); | ||
return `${pathname}${search}`; | ||
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': { | ||
const { pathname } = new url_1.URL(message.url); | ||
return pathname; | ||
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; | ||
return [decodeURI(pathname)]; | ||
} | ||
case '@query': { | ||
const { search } = new url_1.URL(message.url); | ||
return search; | ||
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 [decodeURI(search) || '?']; | ||
} | ||
case '@status': | ||
if (!message.status) { | ||
throw new Error(`${component} is only valid for responses`); | ||
case '@query-param': { | ||
if (!(0, types_1.isRequest)(context)) { | ||
throw new Error('Cannot derive @scheme on response'); | ||
} | ||
return message.status.toString(); | ||
case '@query-params': | ||
case '@request-response': | ||
throw new Error(`${component} is not implemented yet`); | ||
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 = params.get('name').toString(); | ||
if (!searchParams.has(name)) { | ||
throw new Error(`Expected query parameter "${name}" not found`); | ||
} | ||
return searchParams.getAll(name); | ||
} | ||
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(`Unknown specialty component ${component}`); | ||
throw new Error(`Unsupported component "${component}"`); | ||
} | ||
} | ||
exports.extractComponent = extractComponent; | ||
function buildSignatureInputString(componentNames, parameters) { | ||
const components = componentNames.map((name) => `"${name.toLowerCase()}"`).join(' '); | ||
return `(${components})${Object.entries(parameters).map(([parameter, value]) => { | ||
if (typeof value === 'number') { | ||
return `;${parameter}=${value}`; | ||
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'); | ||
} | ||
else if (value instanceof Date) { | ||
return `;${parameter}=${Math.floor(value.getTime() / 1000)}`; | ||
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)]; | ||
} | ||
else { | ||
return `;${parameter}="${value.toString()}"`; | ||
} | ||
}).join('')}`; | ||
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.buildSignatureInputString = buildSignatureInputString; | ||
function buildSignedData(request, components, signatureInputString) { | ||
const parts = components.map((component) => { | ||
let value; | ||
if (component.startsWith('@')) { | ||
value = extractComponent(request, component); | ||
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 { | ||
value = extractHeader(request, component); | ||
map.set(key, value); | ||
} | ||
return `"${component.toLowerCase()}": ${value}`; | ||
}); | ||
parts.push(`"@signature-params": ${signatureInputString}`); | ||
return parts.join('\n'); | ||
return map; | ||
} | ||
exports.buildSignedData = buildSignedData; | ||
// @todo - should be possible to sign responses too | ||
async function sign(request, opts) { | ||
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 signingComponents = (_a = opts.components) !== null && _a !== void 0 ? _a : exports.defaultSigningComponents; | ||
const signingParams = populateDefaultParameters({ | ||
...opts.parameters, | ||
keyid: opts.keyId, | ||
alg: opts.signer.alg, | ||
}); | ||
const signatureInputString = buildSignatureInputString(signingComponents, signingParams); | ||
const dataToSign = buildSignedData(request, signingComponents, signatureInputString); | ||
const signature = await opts.signer(Buffer.from(dataToSign)); | ||
Object.assign(request.headers, { | ||
'Signature': `sig1=:${signature.toString('base64')}:`, | ||
'Signature-Input': `sig1=${signatureInputString}`, | ||
}); | ||
return request; | ||
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.sign = sign; | ||
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 |
export * from './algorithm'; | ||
export * from './types'; | ||
export * from './errors'; | ||
export * as default from './httpbis'; | ||
export * as httpis from './httpbis'; | ||
export * as httpbis from './httpbis'; | ||
export * as cavage from './cavage'; |
@@ -29,8 +29,9 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.cavage = exports.httpis = exports.default = void 0; | ||
exports.cavage = exports.httpbis = exports.default = void 0; | ||
__exportStar(require("./algorithm"), exports); | ||
__exportStar(require("./types"), exports); | ||
__exportStar(require("./errors"), exports); | ||
exports.default = __importStar(require("./httpbis")); | ||
exports.httpis = __importStar(require("./httpbis")); | ||
exports.httpbis = __importStar(require("./httpbis")); | ||
exports.cavage = __importStar(require("./cavage")); | ||
//# sourceMappingURL=index.js.map |
/// <reference types="node" /> | ||
import { Signer, Verifier } from '../algorithm'; | ||
declare type HttpLike = { | ||
/// <reference types="node" /> | ||
export interface Request { | ||
method: string; | ||
url: string; | ||
headers: Record<string, { | ||
toString(): string; | ||
} | string | string[] | undefined>; | ||
body?: string | Buffer; | ||
}; | ||
export declare type RequestLike = HttpLike; | ||
export declare type ResponseLike = HttpLike & { | ||
url: string | URL; | ||
headers: Record<string, string | string[]>; | ||
} | ||
export interface Response { | ||
status: number; | ||
}; | ||
export declare type Parameter = 'created' | 'expires' | 'nonce' | 'alg' | 'keyid' | string; | ||
export declare type Component = '@method' | '@target-uri' | '@authority' | '@scheme' | '@request-target' | '@path' | '@query' | '@query-params' | string; | ||
export declare type ResponseComponent = '@status' | '@request-response' | Component; | ||
export declare type Parameters = { | ||
[name: Parameter]: string | number | Date | { | ||
[Symbol.toStringTag]: () => string; | ||
}; | ||
}; | ||
declare type CommonOptions = { | ||
format: 'httpbis' | 'cavage'; | ||
}; | ||
export declare type SignOptions = CommonOptions & { | ||
components?: Component[]; | ||
parameters?: Parameters; | ||
allowMissingHeaders?: boolean; | ||
keyId: string; | ||
signer: Signer; | ||
}; | ||
export declare type VerifyOptions = CommonOptions & { | ||
verifier: Verifier; | ||
}; | ||
export declare type HeaderExtractionOptions = { | ||
allowMissing: boolean; | ||
}; | ||
export {}; | ||
headers: Record<string, string | string[]>; | ||
} | ||
export type Signer = (data: Buffer) => Promise<Buffer>; | ||
export type Verifier = (data: Buffer, signature: Buffer, parameters?: SignatureParameters) => Promise<boolean | null>; | ||
export type VerifierFinder = (parameters: SignatureParameters) => Promise<VerifyingKey | null>; | ||
export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512' | string; | ||
export interface SigningKey { | ||
/** | ||
* The ID of this key | ||
*/ | ||
id?: string; | ||
/** | ||
* The algorithm to sign with | ||
*/ | ||
alg?: Algorithm; | ||
/** | ||
* The Signer function | ||
*/ | ||
sign: Signer; | ||
} | ||
export interface VerifyingKey { | ||
/** | ||
* The ID of this key | ||
*/ | ||
id?: string; | ||
/** | ||
* The supported algorithms for this key | ||
*/ | ||
algs?: Algorithm[]; | ||
/** | ||
* The Verify function | ||
*/ | ||
verify: Verifier; | ||
} | ||
/** | ||
* The signature parameters to include in signing | ||
*/ | ||
export interface SignatureParameters { | ||
/** | ||
* The created time for the signature. `null` indicates not to populate the `created` time | ||
* default: Date.now() | ||
*/ | ||
created?: Date | null; | ||
/** | ||
* The time the signature should be deemed to have expired | ||
* default: Date.now() + 5 mins | ||
*/ | ||
expires?: Date; | ||
/** | ||
* A nonce for the request | ||
*/ | ||
nonce?: string; | ||
/** | ||
* The algorithm the signature is signed with (overrides the alg provided by the signing key) | ||
*/ | ||
alg?: string; | ||
/** | ||
* The key id the signature is signed with (overrides the keyid provided by the signing key) | ||
*/ | ||
keyid?: string; | ||
/** | ||
* A tag parameter for the signature | ||
*/ | ||
tag?: string; | ||
[param: string]: Date | number | string | null | undefined; | ||
} | ||
/** | ||
* Default parameters to use when signing a request if none are supplied by the consumer | ||
*/ | ||
export declare const defaultParams: string[]; | ||
/** | ||
* A component parser supplied by the consumer to allow applications to define their own logic for | ||
* extracting components for use in the signature base. | ||
* | ||
* This can be useful in circumstances where the application has agreed a specific standard or way | ||
* of extracting components from messages and/or when new components are added to the specification | ||
* but not yet supported by the library. | ||
* | ||
* Return null to defer to internal logic | ||
*/ | ||
export type ComponentParser = (name: string, params: Map<string, string | number | boolean>, message: Request | Response, req?: Request) => string[] | null; | ||
export interface CommonConfig { | ||
/** | ||
* A component user supplied component parser | ||
*/ | ||
componentParser?: ComponentParser; | ||
} | ||
export interface SignConfig extends CommonConfig { | ||
key: SigningKey; | ||
/** | ||
* The name to try to use for the signature | ||
* Default: 'sig' | ||
*/ | ||
name?: string; | ||
/** | ||
* The parameters to add to the signature | ||
* Default: see defaultParams | ||
*/ | ||
params?: string[]; | ||
/** | ||
* The HTTP fields / derived component names to sign | ||
* Default: none | ||
*/ | ||
fields?: string[]; | ||
/** | ||
* Specified parameter values to use (eg: created time, expires time, etc) | ||
* This can be used by consumers to override the default expiration time or explicitly opt-out | ||
* of adding creation time (by setting `created: null`) | ||
*/ | ||
paramValues?: SignatureParameters; | ||
/** | ||
* A list of supported algorithms | ||
*/ | ||
algs?: Algorithm[]; | ||
} | ||
/** | ||
* Options when verifying signatures | ||
*/ | ||
export interface VerifyConfig extends CommonConfig { | ||
keyLookup: VerifierFinder; | ||
/** | ||
* A date that the signature can't have been marked as `created` after | ||
* Default: Date.now() + tolerance | ||
*/ | ||
notAfter?: Date | number; | ||
/** | ||
* The maximum age of the signature - this effectively overrides the `expires` value for the | ||
* signature (unless the expires age is less than the maxAge specified) | ||
* if provided | ||
*/ | ||
maxAge?: number; | ||
/** | ||
* A clock tolerance when verifying created/expires times | ||
* Default: 0 | ||
*/ | ||
tolerance?: number; | ||
/** | ||
* Any parameters that *must* be in the signature (eg: require a created time) | ||
* Default: [] | ||
*/ | ||
requiredParams?: string[]; | ||
/** | ||
* Any fields that *must* be in the signature (eg: Authorization, Digest, etc) | ||
* Default: [] | ||
*/ | ||
requiredFields?: string[]; | ||
/** | ||
* Verify every signature in the request. By default, only 1 signature will need to be valid | ||
* for the verification to pass. | ||
* Default: false | ||
*/ | ||
all?: boolean; | ||
} | ||
export declare function isRequest(obj: Request | Response): obj is Request; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.isRequest = exports.defaultParams = void 0; | ||
/** | ||
* Default parameters to use when signing a request if none are supplied by the consumer | ||
*/ | ||
exports.defaultParams = [ | ||
'keyid', | ||
'alg', | ||
'created', | ||
'expires', | ||
]; | ||
function isRequest(obj) { | ||
return !!obj.method; | ||
} | ||
exports.isRequest = isRequest; | ||
//# sourceMappingURL=index.js.map |
{ | ||
"name": "http-message-signatures", | ||
"version": "0.1.2", | ||
"version": "1.0.0", | ||
"description": "HTTP message signature implementation", | ||
@@ -10,5 +10,7 @@ "main": "lib/index.js", | ||
"lint": "eslint -c .eslintrc ./src/**/*.ts ./test/**/*.ts", | ||
"lint:fix": "npm run lint --silent -- --fix", | ||
"prepare": "npm run build", | ||
"preversion": "npm run lint", | ||
"test": "mocha -r ts-node/register test/**/*.ts" | ||
"test": "mocha -r ts-node/register -r test/bootstrap.ts test/**/*.ts", | ||
"test:coverage": "nyc --reporter=lcov --reporter=text-summary npm run test" | ||
}, | ||
@@ -34,14 +36,23 @@ "repository": { | ||
"devDependencies": { | ||
"@tsconfig/node12": "^1.0.11", | ||
"@types/chai": "^4.3.1", | ||
"@types/mocha": "^9.1.1", | ||
"@tsconfig/node12": "^12.1.0", | ||
"@types/chai": "^4.3.3", | ||
"@types/mocha": "^10.0.0", | ||
"@types/node": "^12.20.55", | ||
"@typescript-eslint/eslint-plugin": "^5.29.0", | ||
"@typescript-eslint/parser": "^5.29.0", | ||
"@types/sinon": "^10.0.13", | ||
"@types/sinon-chai": "^3.2.8", | ||
"@typescript-eslint/eslint-plugin": "^5.36.1", | ||
"@typescript-eslint/parser": "^5.36.1", | ||
"chai": "^4.3.6", | ||
"eslint": "^8.18.0", | ||
"mocha": "^9.2.2", | ||
"ts-node": "^10.8.1", | ||
"typescript": "^4.7.4" | ||
"eslint": "^8.24.0", | ||
"mocha": "^10.0.0", | ||
"mockdate": "^3.0.5", | ||
"nyc": "^15.1.0", | ||
"sinon": "^14.0.0", | ||
"sinon-chai": "^3.7.0", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^5.0.3" | ||
}, | ||
"dependencies": { | ||
"structured-headers": "^0.5.0" | ||
} | ||
} |
@@ -12,12 +12,58 @@ # HTTP Message Signatures | ||
1. [HTTPBIS](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#appendix-B.2) | ||
2. [Cavage](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12) | ||
1. [HTTPbis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures) | ||
2. [Cavage](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) and subsequent [RichAnna](https://datatracker.ietf.org/doc/html/draft-richanna-http-message-signatures) | ||
## Approach | ||
As the cavage specification is now expired and superseded by the HTTPBIS one, this library takes a | ||
"HTTPBIS-first" approach. This means that most support and maintenance will go into the HTTPBIS | ||
implementation and syntax. The syntax is then back-ported to the Cavage implementation as much as | ||
possible. | ||
As the Cavage/RichAnna specification is now expired and superseded by the HTTPbis one, this library takes a | ||
"HTTPbis-first" approach. This means that most support and maintenance will go into the HTTPbis | ||
implementation and syntax. The syntax is then back-ported to the as much as possible. | ||
## Caveats | ||
The Cavage/RichAnna specifications have changed over time, introducing new features. The aim is to support | ||
the [latest version of the specification](https://datatracker.ietf.org/doc/html/draft-richanna-http-message-signatures) | ||
and not to try to support each version in isolation. | ||
## Limitations in compliance with the specification | ||
As with many libraries and environments, HTTP Requests and Responses are abstracted away from the | ||
developer. This fact is noted in the specification. As such (in compliance with the specification), | ||
consumers of this library should take care to make sure that they are processing signatures that | ||
only cover fields/components whose values can be reliably resolved. Below is a list of limitations | ||
that you should be aware of when selecting a list of parameters to sign or accept. | ||
### Derived component limitations | ||
Many of the derived components are expected to be sourced from what are effectively http2 pseudo | ||
headers. However, if the application is not running in http2 mode or the message being signed is | ||
not being built as a http2 message, then some of these pseudo headers will not be available to the | ||
application and must be derived from a URL. | ||
#### @request-target | ||
The [`@request-target`](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-2.2.5) | ||
component is intended to be the equivalent to the "request target portion of the request line". | ||
See the specification for examples of what this means. In NodeJS, this line in requests is automatically | ||
constructed for consumers, so it's not possible to know for certainty what this will be. For incoming | ||
requests, it is possible to extract, but for simplicity’s sake this library does not process the raw | ||
headers for the incoming request and, as such, cannot calculate this value with certainty. It is | ||
recommended that this component is avoided. | ||
### Multiple message component contexts | ||
As described in [section 7.4.4](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-7.4.4) | ||
it is deemed that complex message context resolution is outside the scope of this library. | ||
This means that it is the responsibility of the consumer of this library to construct the equivalent | ||
message context for signatures that need to be reinterpreted based on other signer contexts. | ||
### Padding attacks | ||
As described in [section 7.5.7](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-13#section-7.5.7) | ||
it is expected that the NodeJS application has taken steps to ensure that headers are valid and not | ||
"garbage". For this library to take on that obligation would be to widen the scope of the library to | ||
a complete HTTP Message validator. | ||
## Examples | ||
@@ -24,0 +70,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
103280
45
1346
0
112
1
17
1
+ Addedstructured-headers@^0.5.0
+ Addedstructured-headers@0.5.0(transitive)