diff --git a/src/algorithm/index.ts b/src/algorithm/index.ts index ce71611..7204d46 100644 --- a/src/algorithm/index.ts +++ b/src/algorithm/index.ts @@ -11,54 +11,66 @@ import { VerifyPublicKeyInput, } from 'crypto'; import { RSA_PKCS1_PADDING, RSA_PKCS1_PSS_PADDING } from 'constants'; +import { SigningKey, Algorithm, Verifier } from '../types'; -export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512' | string; - -export interface Signer { - (data: BinaryLike): Promise, - alg: Algorithm, -} - -export interface Verifier { - (data: BinaryLike, signature: BinaryLike): Promise, - alg: Algorithm, -} - -export function createSigner(alg: Algorithm, key: BinaryLike | KeyLike | SignKeyObjectInput | SignPrivateKeyInput): Signer { - let signer; +/** + * 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 function createSigner(key: BinaryLike | KeyLike | SignKeyObjectInput | SignPrivateKeyInput, alg: Algorithm, id?: string): SigningKey { + const signer = { alg } as SigningKey; switch (alg) { case 'hmac-sha256': - signer = async (data: BinaryLike) => createHmac('sha256', key as BinaryLike).update(data).digest(); + signer.sign = async (data: BinaryLike) => createHmac('sha256', key as BinaryLike).update(data).digest(); break; case 'rsa-pss-sha512': - signer = async (data: BinaryLike) => createSign('sha512').update(data).sign({ + signer.sign = async (data: BinaryLike) => createSign('sha512').update(data).sign({ key, padding: RSA_PKCS1_PSS_PADDING, } as SignPrivateKeyInput); break; case 'rsa-v1_5-sha256': - signer = async (data: BinaryLike) => createSign('sha256').update(data).sign({ + signer.sign = async (data: BinaryLike) => createSign('sha256').update(data).sign({ key, padding: RSA_PKCS1_PADDING, } as SignPrivateKeyInput); break; case 'rsa-v1_5-sha1': // this is legacy for cavage - signer = async (data: BinaryLike) => createSign('sha1').update(data).sign({ + signer.sign = async (data: BinaryLike) => createSign('sha1').update(data).sign({ key, padding: RSA_PKCS1_PADDING, } as SignPrivateKeyInput); break; case 'ecdsa-p256-sha256': - signer = async (data: BinaryLike) => createSign('sha256').update(data).sign(key as KeyLike); + signer.sign = async (data: BinaryLike) => createSign('sha256').update(data).sign(key as KeyLike); break; default: throw new Error(`Unsupported signing algorithm ${alg}`); } - return Object.assign(signer, { alg }); + if (id) { + signer.id = id; + } + return signer; } -export function createVerifier(alg: Algorithm, key: BinaryLike | KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput): Verifier { +/** + * 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 function createVerifier(key: BinaryLike | KeyLike | VerifyKeyObjectInput | VerifyPublicKeyInput, alg: Algorithm): Verifier { let verifier; switch (alg) { case 'hmac-sha256': @@ -74,6 +86,12 @@ export function createVerifier(alg: Algorithm, key: BinaryLike | KeyLike | Verif padding: RSA_PKCS1_PSS_PADDING, } as VerifyPublicKeyInput, Buffer.from(signature)); break; + case 'rsa-v1_5-sha1': + verifier = async (data: BinaryLike, signature: BinaryLike) => createVerify('sha1').update(data).verify({ + key, + padding: RSA_PKCS1_PADDING, + } as VerifyPublicKeyInput, Buffer.from(signature)); + break; case 'rsa-v1_5-sha256': verifier = async (data: BinaryLike, signature: BinaryLike) => createVerify('sha256').update(data).verify({ key, diff --git a/src/cavage/index.ts b/src/cavage/index.ts index 3625bd6..b5ef660 100644 --- a/src/cavage/index.ts +++ b/src/cavage/index.ts @@ -1,140 +1,300 @@ -import { - Component, - HeaderExtractionOptions, - Parameters, - RequestLike, - ResponseLike, SignOptions, -} from '../types'; -import { URL } from 'url'; +import { parseItem } from 'structured-headers'; +import { Algorithm, Request, Response, SignConfig, VerifyConfig, defaultParams, isRequest } from '../types'; +import { quoteString } from '../structured-header'; -export const defaultSigningComponents: Component[] = [ - '@request-target', - 'content-type', - 'digest', - 'content-digest', -]; - -export function extractHeader({ headers }: RequestLike | ResponseLike, header: string, opts?: HeaderExtractionOptions): string { - const lcHeader = header.toLowerCase(); - const key = Object.keys(headers).find((name) => name.toLowerCase() === lcHeader); - const allowMissing = opts?.allowMissing ?? true; - if (!allowMissing && !key) { - throw new Error(`Unable to extract header "${header}" from message`); +function mapCavageAlgorithm(alg: string): Algorithm { + 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 ? headers[key] ?? '' : ''; - if (Array.isArray(val)) { - val = val.join(', '); +} + +function mapHttpbisAlgorithm(alg: Algorithm): string { + 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, ' '); } -// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 -export function extractComponent(message: RequestLike | ResponseLike, component: string): string { - 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 + */ +export function deriveComponent(component: string, message: Request | Response): string[] { + const [componentName, params] = parseItem(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(message.url); - return `${message.method.toLowerCase()} ${pathname}${search}`; + if (!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}"`); } } -const ALG_MAP: { [name: string]: string } = { - 'rsa-v1_5-sha256': 'rsa-sha256', -}; - -export function buildSignedData(request: RequestLike, components: Component[], params: Parameters): string { - const payloadParts: Parameters = {}; - const paramNames = Object.keys(params); - if (components.includes('@request-target')) { - Object.assign(payloadParts, { - '(request-target)': extractComponent(request, '@request-target'), - }); +export function extractHeader(header: string, { headers }: Request | Response): string[] { + const [headerName, params] = parseItem(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, - }); - } - if (paramNames.includes('expires')) { - Object.assign(payloadParts, { - '(expires)': params.expires, - }); + 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`); } - components.forEach((name) => { - if (!name.startsWith('@')) { - Object.assign(payloadParts, { - [name.toLowerCase()]: extractHeader(request, name), - }); - } - }); - return Object.entries(payloadParts).map(([name, value]) => { - if (value instanceof Date) { - return `${name}: ${Math.floor(value.getTime() / 1000)}`; + return [(Array.isArray(headerTuple[1]) ? headerTuple[1] : [headerTuple[1]]).map((val) => val.trim().replace(/\n\s*/gm, ' ')).join(', ')]; +} + +export function formatSignatureBase(base: [string, string[]][]): string { + return base.reduce((accum, [key, value]) => { + const [keyName] = parseItem(quoteString(key)); + const lcKey = (keyName as string).toLowerCase(); + if (lcKey.startsWith('@')) { + accum.push(`(${lcKey.slice(1)}): ${value.join(', ')}`); } else { - return `${name}: ${value.toString()}`; + accum.push(`${key.toLowerCase()}: ${value.join(', ')}`); } - }).join('\n'); + return accum; + }, []).join('\n'); } -export function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string { - const params: Parameters = Object.entries(parameters).reduce((normalised, [name, value]) => { - switch (name.toLowerCase()) { - case 'keyid': - return Object.assign(normalised, { - keyId: value, - }); - case 'alg': - return Object.assign(normalised, { - algorithm: ALG_MAP[value as string] ?? value, - }); +export function createSigningParameters(config: SignConfig): Map { + const now = new Date(); + return (config.params ?? defaultParams).reduce>((params, paramName) => { + let value: string | number = ''; + 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 (config.paramValues?.created !== null) { + const created: Date = config.paramValues?.created ?? 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 (config.paramValues?.expires || config.paramValues?.created !== null) { + const expires = config.paramValues?.expires ?? new Date((config.paramValues?.created ?? now).getTime() + 300000); + value = Math.floor(expires.getTime() / 1000); + } + break; + case 'keyid': { + // attempt to obtain the keyid omit if missing + const kid = config.paramValues?.keyid ?? config.key.id ?? null; + if (kid) { + value = kid.toString(); + } + break; + } + case 'alg': { + const alg = config.paramValues?.alg ?? config.key.alg ?? null; + if (alg) { + value = alg.toString(); + } + break; + } default: - return Object.assign(normalised, { - [name]: value, - }); + if (config.paramValues?.[paramName] instanceof Date) { + value = Math.floor((config.paramValues[paramName] as Date).getTime() / 1000).toString(); + } else if (config.paramValues?.[paramName]) { + value = config.paramValues[paramName] as string; + } } - }, {}); - const headers = []; - const paramNames = Object.keys(params); - if (componentNames.includes('@request-target')) { - headers.push('(request-target)'); - } - if (paramNames.includes('created')) { - headers.push('(created)'); - } - if (paramNames.includes('expires')) { - headers.push('(expires)'); - } - componentNames.forEach((name) => { - if (!name.startsWith('@')) { - headers.push(name.toLowerCase()); + if (value) { + params.set(paramName, value); } - }); - return `${Object.entries(params).map(([name, value]) => { - if (typeof value === 'number') { - return `${name}=${value}`; - } else if (value instanceof Date) { - return `${name}=${Math.floor(value.getTime() / 1000)}`; - } else { - return `${name}="${value.toString()}"`; + return params; + }, new Map()); +} + +export function createSignatureBase(fields: string[], message: Request | Response, signingParameters: Map): [string, string[]][] { + return fields.reduce<[string, string[]][]>((base, fieldName) => { + const [field, params] = parseItem(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') as string]]); + } + break; + case '@expires': + if (signingParameters.has('expires')) { + base.push(['(expires)', [signingParameters.get('expires') as string]]); + } + break; + case '@request-target': { + if (!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)]); } - }).join(',')},headers="${headers.join(' ')}"` + return base; + }, []); } -// @todo - should be possible to sign responses too -export async function sign(request: RequestLike, opts: SignOptions): Promise { - const signingComponents: Component[] = opts.components ?? defaultSigningComponents; - const signingParams: Parameters = { - ...opts.parameters, - keyid: opts.keyId, - alg: opts.signer.alg, +export async function signMessage(config: SignConfig, message: T): Promise { + const signingParameters = createSigningParameters(config); + const signatureBase = createSignatureBase(config.fields ?? [], 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 as string)}"`; + } + 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, + }, }; - 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')}"`, +} + +export async function verifyMessage(config: VerifyConfig, message: Request | Response): Promise { + 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((parsedHeader.get('headers') ?? '').split(' ').map((component: string) => { + return component.toLowerCase().replace(/^\((.*)\)$/, '@$1'); + }), message, parsedHeader)); + const base = formatSignatureBase(Array.from(baseParts.entries())); + const now = Math.floor(Date.now() / 1000); + const tolerance = config.tolerance ?? 0; + const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : config.notAfter ?? now; + const maxAge = config.maxAge ?? null; + const requiredParams = config.requiredParams ?? []; + const requiredFields = config.requiredFields ?? []; + 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)')); }); - return request; + if (!hasRequiredFields) { + return false; + } + if (parsedHeader.has('created')) { + const created = parsedHeader.get('created') as number - 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') as number + tolerance; + // expired signature + if (expires > now) { + return false; + } + } + // now look to verify the signature! Build the expected "signing base" and verify it! + return config.verifier(Buffer.from(base), Buffer.from(parsedHeader.get('signature'), 'base64'), Array.from(parsedHeader.entries()).reduce((params, [key, value]) => { + let keyName = key; + let val: Date | number | string; + switch (key.toLowerCase()) { + case 'created': + case 'expires': + val = new Date((value as number) * 1000); + break; + case 'signature': + case 'headers': + return params; + case 'algorithm': + keyName = 'alg'; + val = mapCavageAlgorithm(value); + break; + case 'keyid': + keyName = 'keyid'; + val = value; + break; + // no break + default: { + if (typeof value === 'string' || typeof value=== 'number') { + val = value; + } else { + val = value.toString(); + } + } + } + return Object.assign(params, { + [keyName]: val, + }); + }, {})); } diff --git a/src/cavage/new.ts b/src/cavage/new.ts deleted file mode 100644 index 450a5ee..0000000 --- a/src/cavage/new.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { parseItem } from 'structured-headers'; -import { Algorithm } from '../algorithm'; - -export interface Request { - method: string; - url: string | URL; - headers: Record; -} - -export interface Response { - status: number; - headers: Record; -} - -export type Signer = (data: Buffer) => Promise; -export type Verifier = (data: Buffer, signature: Buffer, parameters: SignatureParameters) => Promise; - -export interface SigningKey { - id?: string; - alg?: string; - sign: Signer; -} - -/** - * 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 context parameter for the signature - */ - context?: string; - [param: string]: Date | number | string | null | undefined; -} - -/** - * Default parameters to use when signing a request if none are supplied by the consumer - */ -const defaultParams = [ - 'keyid', - 'alg', - 'created', - 'expires', -]; - -export interface SignConfig { - 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, -} - -/** - * Options when verifying signatures - */ -export interface VerifyConfig { - verifier: Verifier; - /** - * A maximum age for the signature - * Default: Date.now() + tolerance - */ - notAfter?: Date | number; - /** - * The maximum age of the signature - this overrides the `expires` value for the signature - * 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; -} - -function mapCavageAlgorithm(alg: string): Algorithm { - 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: Algorithm): string { - 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; - } -} - -function isRequest(obj: Request | Response): obj is Request { - return !!(obj as Request).method; -} - -/** - * This allows consumers of the library to supply field specifications that aren't - * strictly "structured fields". Really a string must start with a `"` but that won't - * tend to happen in our configs. - * - * @param {string} input - * @returns {string} - */ -function quoteString(input: string): string { - // if it's not quoted, attempt to quote - if (!input.startsWith('"')) { - // try to split the structured field - const [name, ...rest] = input.split(';'); - // no params, just quote the whole thing - if (!rest.length) { - return `"${name}"`; - } - // quote the first part and put the rest back as it was - return `"${name}";${rest.join(';')}`; - } - return input; -} - -/** - * 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 function deriveComponent(component: string, message: Request | Response): string[] { - const [componentName, params] = parseItem(quoteString(component)); - if (params.size) { - throw new Error('Component parameters are not supported in cavage'); - } - switch (componentName.toString().toLowerCase()) { - case '@request-target': { - if (!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}"`); - } -} - -export function extractHeader(header: string, { headers }: Request | Response): string[] { - const [headerName, params] = parseItem(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(', ')]; -} - -export function formatSignatureBase(base: [string, string[]][]): string { - return base.reduce((accum, [key, value]) => { - const [keyName] = parseItem(quoteString(key)); - const lcKey = (keyName as string).toLowerCase(); - if (lcKey.startsWith('@')) { - accum.push(`(${lcKey.slice(1)}): ${value.join(', ')}`); - } else { - accum.push(`${key.toLowerCase()}: ${value.join(', ')}`); - } - return accum; - }, []).join('\n'); -} - -export function createSigningParameters(config: SignConfig): Map { - const now = new Date(); - return (config.params ?? defaultParams).reduce>((params, paramName) => { - let value: string | number = ''; - 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 (config.paramValues?.created !== null) { - const created: Date = config.paramValues?.created ?? 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 (config.paramValues?.expires || config.paramValues?.created !== null) { - const expires = config.paramValues?.expires ?? new Date((config.paramValues?.created ?? now).getTime() + 300000); - value = Math.floor(expires.getTime() / 1000); - } - break; - case 'keyid': { - // attempt to obtain the keyid omit if missing - const kid = config.paramValues?.keyid ?? config.key.id ?? null; - if (kid) { - value = kid.toString(); - } - break; - } - case 'alg': { - const alg = config.paramValues?.alg ?? config.key.alg ?? null; - if (alg) { - value = alg.toString(); - } - break; - } - default: - if (config.paramValues?.[paramName] instanceof Date) { - value = Math.floor((config.paramValues[paramName] as Date).getTime() / 1000).toString(); - } else if (config.paramValues?.[paramName]) { - value = config.paramValues[paramName] as string; - } - } - if (value) { - params.set(paramName, value); - } - return params; - }, new Map()); -} - -export function createSignatureBase(fields: string[], message: Request | Response, signingParameters: Map): [string, string[]][] { - return fields.reduce<[string, string[]][]>((base, fieldName) => { - const [field, params] = parseItem(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') as string]]); - } - break; - case '@expires': - if (signingParameters.has('expires')) { - base.push(['(expires)', [signingParameters.get('expires') as string]]); - } - break; - case '@request-target': { - if (!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} ${pathname}${search}`]]); - break; - } - default: - base.push([lcFieldName, extractHeader(lcFieldName, message)]); - } - return base; - }, []); -} - -export async function signMessage(config: SignConfig, message: T): Promise { - const signingParameters = createSigningParameters(config); - const signatureBase = createSignatureBase(config.fields ?? [], 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 as string)}"`; - } - 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, - }, - }; -} - -export async function verifyMessage(config: VerifyConfig, message: Request | Response): Promise { - 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((parsedHeader.get('headers') ?? '').split(' ').map((component: string) => { - return component.toLowerCase().replace(/^\((.*)\)$/, '@$1'); - }), message, parsedHeader)); - const base = formatSignatureBase(Array.from(baseParts.entries())); - const now = Math.floor(Date.now() / 1000); - const tolerance = config.tolerance ?? 0; - const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : config.notAfter ?? now; - const maxAge = config.maxAge ?? null; - const requiredParams = config.requiredParams ?? []; - const requiredFields = config.requiredFields ?? []; - 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') as number - 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') as number + tolerance; - // expired signature - if (expires > now) { - return false; - } - } - // now look to verify the signature! Build the expected "signing base" and verify it! - return config.verifier(Buffer.from(base), Buffer.from(parsedHeader.get('signature'), 'base64'), Array.from(parsedHeader.entries()).reduce((params, [key, value]) => { - let keyName = key; - let val: Date | number | string; - switch (key.toLowerCase()) { - case 'created': - case 'expires': - val = new Date((value as number) * 1000); - break; - case 'signature': - case 'headers': - return params; - case 'algorithm': - keyName = 'alg'; - val = mapCavageAlgorithm(value); - break; - case 'keyid': - keyName = 'keyid'; - val = value; - break; - // no break - default: { - if (typeof value === 'string' || typeof value=== 'number') { - val = value; - } else { - val = value.toString(); - } - } - } - return Object.assign(params, { - [keyName]: val, - }); - }, {})); -} diff --git a/src/httpbis/index.ts b/src/httpbis/index.ts index 1830ea5..da66d6d 100644 --- a/src/httpbis/index.ts +++ b/src/httpbis/index.ts @@ -1,126 +1,400 @@ import { - Component, - HeaderExtractionOptions, + BareItem, + parseDictionary, + parseItem, + serializeItem, + serializeList, + Dictionary as DictionaryType, + ByteSequence, + serializeDictionary, + parseList, Parameters, - RequestLike, - ResponseLike, - SignOptions, -} from '../types'; -import { URL } from 'url'; + isInnerList, + isByteSequence, +} from 'structured-headers'; +import { Dictionary, parseHeader, quoteString } from '../structured-header'; +import { Request, Response, SignConfig, VerifyConfig, defaultParams, isRequest } from '../types'; -export const defaultSigningComponents: Component[] = [ - '@method', - '@path', - '@query', - '@authority', - 'content-type', - 'digest', - 'content-digest', -]; +export function deriveComponent(component: string, res: Response, req?: Request): string[]; +export function deriveComponent(component: string, req: Request): string[]; -export function extractHeader({ headers }: RequestLike | ResponseLike, header: string, opts?: HeaderExtractionOptions): string { - const lcHeader = header.toLowerCase(); - const key = Object.keys(headers).find((name) => name.toLowerCase() === lcHeader); - const allowMissing = opts?.allowMissing ?? true; - if (!allowMissing && !key) { - throw new Error(`Unable to extract header "${header}" from message`); +/** + * 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 function deriveComponent(component: string, message: Request | Response, req?: Request): string[] { + const [componentName, params] = parseItem(quoteString(component)); + // 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 ? headers[key] ?? '' : ''; - if (Array.isArray(val)) { - val = val.join(', '); - } - return val.toString().replace(/\s+/g, ' '); -} - -function populateDefaultParameters(parameters: Parameters) { - return { - created: new Date(), - ...parameters, - }; -} - -// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 -export function extractComponent(message: RequestLike | ResponseLike, component: string): string { - switch (component) { + switch (componentName.toString().toLowerCase()) { case '@method': - return message.method.toUpperCase(); - case '@target-uri': - return message.url; + if (!isRequest(context)) { + throw new Error('Cannot derive @method from response'); + } + return [context.method.toUpperCase()]; + case '@target-uri': { + if (!isRequest(context)) { + throw new Error('Cannot derive @target-url on response'); + } + return [context.url.toString()]; + } case '@authority': { - const url = new URL(message.url); - const port = url.port ? parseInt(url.port, 10) : null; - return `${url.host}${port && ![80, 443].includes(port) ? `:${port}` : ''}`; + if (!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(message.url); - return protocol.slice(0, -1); + if (!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(message.url); - return `${pathname}${search}`; + if (!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(message.url); - return pathname; + if (!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(message.url); - return search; + if (!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 (isRequest(context)) { + throw new Error('Cannot obtain @status component for requests'); + } + return [context.status.toString()]; } - case '@status': - if (!(message as ResponseLike).status) { - throw new Error(`${component} is only valid for responses`); + case '@query-param': { + if (!isRequest(context)) { + throw new Error('Cannot derive @scheme on response'); } - return (message as ResponseLike).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') as BareItem).toString(); + if (!searchParams.has(name)) { + throw new Error(`Expected query parameter "${name}" not found`); + } + return searchParams.getAll(name); + } default: - throw new Error(`Unknown specialty component ${component}`); + throw new Error(`Unsupported component "${component}"`); + } +} + +export function extractHeader(header: string, res: Response, req?: Request): string[]; +export function extractHeader(header: string, req: Request): string[]; + +export function extractHeader(header: string, { headers }: Request | Response, req?: Request): string[] { + const [headerName, params] = parseItem(quoteString(header)); + const context = params.has('req') ? req?.headers : headers; + if (!context) { + throw new Error('Missing request in request-response bound component'); + } + const lcHeaderName = headerName.toString().toLowerCase(); + const headerTuple = Object.entries(context).find(([name]) => name.toLowerCase() === lcHeaderName); + if (!headerTuple) { + throw new Error(`No header "${headerName}" found in headers`); + } + const values = (Array.isArray(headerTuple[1]) ? headerTuple[1] : [headerTuple[1]]); + if (params.has('bs') && params.has('sf')) { + throw new Error('Invalid combination of parameters'); } + if (params.has('sf') || params.has('key')) { + // strict encoding of field + const value = values.join(', '); + const parsed = parseHeader(value); + if (params.has('key') && !(parsed instanceof Dictionary)) { + throw new Error('Unable to parse header as dictionary'); + } + if (params.has('key')) { + const key = (params.get('key') as BareItem).toString(); + if (!(parsed as Dictionary).has(key)) { + throw new Error(`Unable to find key "${key}" in structured field`); + } + return [(parsed as Dictionary).get(key) as string]; + } + 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(', ')]; +} + +export function createSignatureBase(fields: string[], res: Response, req?: Request): [string, string[]][]; +export function createSignatureBase(fields: string[], req: Request): [string, string[]][]; + +export function createSignatureBase(fields: string[], res: Request | Response, req?: Request): [string, string[]][] { + return (fields).reduce<[string, string[]][]>((base, fieldName) => { + const [field, params] = parseItem(quoteString(fieldName)); + const lcFieldName = field.toString().toLowerCase(); + if (lcFieldName !== '@signature-params') { + const value = lcFieldName.startsWith('@') ? deriveComponent(fieldName, res as Response, req) : extractHeader(fieldName, res as Response, req); + base.push([serializeItem([lcFieldName, params]), value]); + } + return base; + }, []); } -export function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string { - const components = componentNames.map((name) => `"${name.toLowerCase()}"`).join(' '); - return `(${components})${Object.entries(parameters).map(([parameter, value]) => { - if (typeof value === 'number') { - return `;${parameter}=${value}`; - } else if (value instanceof Date) { - return `;${parameter}=${Math.floor(value.getTime() / 1000)}`; - } else { - return `;${parameter}="${value.toString()}"`; - } - }).join('')}` +export function formatSignatureBase(base: [string, string[]][]): string { + return base.map(([key, value]) => { + const quotedKey = serializeItem(parseItem(quoteString(key))); + return value.map((val) => `${quotedKey}: ${val}`).join('\n'); + }).join('\n'); } -export function buildSignedData(request: RequestLike, components: Component[], signatureInputString: string): string { - const parts = components.map((component) => { - let value; - if (component.startsWith('@')) { - value = extractComponent(request, component); - } else { - value = extractHeader(request, component); - } - return`"${component.toLowerCase()}": ${value}` - }); - parts.push(`"@signature-params": ${signatureInputString}`); - return parts.join('\n'); +export function createSigningParameters(config: SignConfig): Parameters { + const now = new Date(); + return (config.params ?? defaultParams).reduce((params, paramName) => { + let value: string | number = ''; + 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 (config.paramValues?.created !== null) { + const created: Date = config.paramValues?.created ?? 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 (config.paramValues?.expires || config.paramValues?.created !== null) { + const expires = config.paramValues?.expires ?? new Date((config.paramValues?.created ?? now).getTime() + 300000); + value = Math.floor(expires.getTime() / 1000); + } + break; + case 'keyid': { + // attempt to obtain the keyid omit if missing + const kid = config.paramValues?.keyid ?? config.key.id ?? null; + if (kid) { + value = kid.toString(); + } + break; + } + case 'alg': { + const alg = config.paramValues?.alg ?? config.key.alg ?? null; + if (alg) { + value = alg.toString(); + } + break; + } + default: + if (config.paramValues?.[paramName] instanceof Date) { + value = Math.floor((config.paramValues[paramName] as Date).getTime() / 1000).toString(); + } else if (config.paramValues?.[paramName]) { + value = config.paramValues[paramName] as string; + } + } + if (value) { + params.set(paramName, value); + } + return params; + }, new Map()); +} + +export function augmentHeaders(headers: Record, signature: Buffer, signatureInput: string, name?: string): Record { + let signatureHeaderName = 'Signature'; + let signatureInputHeaderName = 'Signature-Input'; + let signatureHeader: DictionaryType = new Map(); + let inputHeader: DictionaryType = 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 = parseDictionary(Array.isArray(headers[header]) ? (headers[header] as string[]).join(', ') : headers[header] as string); + break; + } + case 'signature-input': + signatureInputHeaderName = header; + inputHeader = parseDictionary(Array.isArray(headers[header]) ? (headers[header] as string[]).join(', ') : headers[header] as string); + 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 ?? '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 ByteSequence(signature.toString('base64')), new Map()]); + inputHeader.set(signatureName, parseList(signatureInput)[0]); + return { + ...headers, + [signatureHeaderName]: serializeDictionary(signatureHeader), + [signatureInputHeaderName]: serializeDictionary(inputHeader), + }; +} + +export async function signMessage(config: SignConfig, res: T, req?: U): Promise; +export async function signMessage(config: SignConfig, req: T): Promise; + +export async function signMessage(config: SignConfig, message: T, req?: U): Promise { + const signingParameters = createSigningParameters(config); + const signatureBase = createSignatureBase(config?.fields ?? [], message as Response, req); + const signatureInput = serializeList([ + [ + signatureBase.map(([item]) => 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), + }; } -// @todo - should be possible to sign responses too -export async function sign(request: RequestLike, opts: SignOptions): Promise { - const signingComponents: Component[] = opts.components ?? defaultSigningComponents; - const signingParams: Parameters = 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; +export async function verifyMessage(config: VerifyConfig, response: Response, request?: Request): Promise; +export async function verifyMessage(config: VerifyConfig, request: Request): Promise; + +export async function verifyMessage(config: VerifyConfig, message: Request | Response, req?: Request): Promise { + const { signatures, signatureInputs } = Object.entries(message.headers).reduce<{ signatures?: DictionaryType; signatureInputs?: DictionaryType }>((accum, [name, value]) => { + switch (name.toLowerCase()) { + case 'signature': + return Object.assign(accum, { + signatures: parseDictionary(Array.isArray(value) ? value.join(', ') : value), + }); + case 'signature-input': + return Object.assign(accum, { + signatureInputs: parseDictionary(Array.isArray(value) ? value.join(', ') : value), + }); + default: + return accum; + } + }, {}); + // no signatures means an indeterminate result + if (!signatures?.size && !signatureInputs?.size) { + return null; + } + // a missing header means we can't verify the signatures + if (!signatures?.size || !signatureInputs?.size) { + throw new Error('Incomplete signature headers'); + } + const now = Math.floor(Date.now() / 1000); + const tolerance = config.tolerance ?? 0; + const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : config.notAfter ?? now; + const maxAge = config.maxAge ?? null; + const requiredParams = config.requiredParams ?? []; + const requiredFields = config.requiredFields ?? []; + return Array.from(signatureInputs.entries()).reduce>(async (prev, [name, input]) => { + const result: Error | boolean | null = await prev.catch((e) => e); + if (!config.all && result === true) { + return result; + } + if (config.all && result !== true && result !== null) { + if (result instanceof Error) { + throw result; + } + return result; + } + if (!isInnerList(input)) { + throw new Error('Malformed signature input'); + } + const hasRequiredParams = requiredParams.every((param) => input[1].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) => input[0].some(([fieldName]) => fieldName === field)); + if (!hasRequiredFields) { + return false; + } + if (input[1].has('created')) { + const created = input[1].get('created') as number - 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 (input[1].has('expires')) { + const expires = input[1].get('expires') as number + tolerance; + // expired signature + if (expires > now) { + return false; + } + } + // now look to verify the signature! Build the expected "signing base" and verify it! + const signingBase = createSignatureBase(input[0].map((item) => serializeItem(item)), message as Response, req); + signingBase.push(['"@signature-params"', [serializeList([input])]]); + const base = formatSignatureBase(signingBase); + const signature = signatures.get(name); + if (!signature) { + throw new Error('No signature found for inputs'); + } + if (!isByteSequence(signature[0] as BareItem)) { + throw new Error('Malformed signature'); + } + return config.verifier(Buffer.from(base), Buffer.from((signature[0] as ByteSequence).toBase64(), 'base64'), Array.from(input[1].entries()).reduce((params, [key, value]) => { + let val: Date | number | string; + switch (key.toLowerCase()) { + case 'created': + case 'expires': + val = new Date((value as number) * 1000); + break; + default: { + if (typeof value === 'string' || typeof value=== 'number') { + val = value; + } else { + val = value.toString(); + } + } + } + return Object.assign(params, { + [key]: val, + }); + }, {})); + }, Promise.resolve(null)); } diff --git a/src/httpbis/new.ts b/src/httpbis/new.ts deleted file mode 100644 index b21f394..0000000 --- a/src/httpbis/new.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { - BareItem, - parseDictionary, - parseItem, - serializeItem, - serializeList, - Dictionary as DictionaryType, - ByteSequence, - serializeDictionary, - parseList, - Parameters, - isInnerList, - isByteSequence, -} from 'structured-headers'; -import { Dictionary, parseHeader } from '../structured-header'; - -export interface Request { - method: string; - url: string | URL; - headers: Record; -} - -export interface Response { - status: number; - headers: Record; -} - -export type Signer = (data: Buffer) => Promise; -export type Verifier = (data: Buffer, signature: Buffer, parameters: SignatureParameters) => Promise; - -export interface SigningKey { - id?: string; - alg?: string; - sign: Signer; -} - -export interface SignatureParameters { - created?: Date | null; - expires?: Date; - nonce?: string; - alg?: string; - keyid?: string; - context?: string; - [param: string]: Date | number | string | null | undefined; -} - -const defaultParams = [ - 'keyid', - 'alg', - 'created', - 'expires', -]; - -export interface SignConfig { - key: SigningKey; - name?: string; - params?: string[]; - fields?: string[]; - paramValues?: SignatureParameters, -} - -export interface VerifyConfig { - verifier: Verifier; - notAfter?: Date | number; - maxAge?: number; - tolerance?: number; - requiredParams?: string[]; - requiredFields?: string[]; - all?: boolean; -} - -function isRequest(obj: Request | Response): obj is Request { - return !!(obj as Request).method; -} - -/** - * This allows consumers of the library to supply field specifications that aren't - * strictly "structured fields". Really a string must start with a `"` but that won't - * tend to happen in our configs. - * - * @param {string} input - * @returns {string} - */ -function quoteString(input: string): string { - // if it's not quoted, attempt to quote - if (!input.startsWith('"')) { - // try to split the structured field - const [name, ...rest] = input.split(';'); - // no params, just quote the whole thing - if (!rest.length) { - return `"${name}"`; - } - // quote the first part and put the rest back as it was - return `"${name}";${rest.join(';')}`; - } - return input; -} - -export function deriveComponent(component: string, res: Response, req?: Request): string[]; -export function deriveComponent(component: string, req: Request): string[]; - -/** - * 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 function deriveComponent(component: string, message: Request | Response, req?: Request): string[] { - const [componentName, params] = parseItem(quoteString(component)); - // 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 (componentName.toString().toLowerCase()) { - case '@method': - if (!isRequest(context)) { - throw new Error('Cannot derive @method from response'); - } - return [context.method.toUpperCase()]; - case '@target-uri': { - if (!isRequest(context)) { - throw new Error('Cannot derive @target-url on response'); - } - return [context.url.toString()]; - } - case '@authority': { - if (!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 (!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 (!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 (!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': { - if (!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 (isRequest(context)) { - throw new Error('Cannot obtain @status component for requests'); - } - return [context.status.toString()]; - } - case '@query-param': { - if (!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 = (params.get('name') as BareItem).toString(); - if (!searchParams.has(name)) { - throw new Error(`Expected query parameter "${name}" not found`); - } - return searchParams.getAll(name); - } - default: - throw new Error(`Unsupported component "${component}"`); - } -} - -export function extractHeader(header: string, res: Response, req?: Request): string[]; -export function extractHeader(header: string, req: Request): string[]; - -export function extractHeader(header: string, { headers }: Request | Response, req?: Request): string[] { - const [headerName, params] = parseItem(quoteString(header)); - const context = params.has('req') ? req?.headers : headers; - if (!context) { - throw new Error('Missing request in request-response bound component'); - } - const lcHeaderName = headerName.toString().toLowerCase(); - const headerTuple = Object.entries(context).find(([name]) => name.toLowerCase() === lcHeaderName); - if (!headerTuple) { - throw new Error(`No header ${headerName} found in headers`); - } - const values = (Array.isArray(headerTuple[1]) ? headerTuple[1] : [headerTuple[1]]); - if (params.has('bs') && params.has('sf')) { - throw new Error('Invalid combination of parameters'); - } - if (params.has('sf') || params.has('key')) { - // strict encoding of field - // I think this is wrong as the values need to be combined first and then parsed, - // not parsed one-by-one - const value = values.join(', '); - const parsed = parseHeader(value); - if (params.has('key') && !(parsed instanceof Dictionary)) { - throw new Error('Unable to parse header as dictionary'); - } - if (params.has('key')) { - const key = (params.get('key') as BareItem).toString(); - if (!(parsed as Dictionary).has(key)) { - throw new Error(`Unable to find key "${key}" in structured field`); - } - return [(parsed as Dictionary).get(key) as string]; - } - 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(', ')]; -} - -export function createSignatureBase(fields: string[], res: Response, req?: Request): [string, string[]][]; -export function createSignatureBase(fields: string[], req: Request): [string, string[]][]; - -export function createSignatureBase(fields: string[], res: Request | Response, req?: Request): [string, string[]][] { - return (fields).reduce<[string, string[]][]>((base, fieldName) => { - const [field, params] = parseItem(quoteString(fieldName)); - const lcFieldName = field.toString().toLowerCase(); - if (lcFieldName !== '@signature-params') { - const value = lcFieldName.startsWith('@') ? deriveComponent(fieldName, res as Response, req) : extractHeader(fieldName, res as Response, req); - base.push([serializeItem([lcFieldName, params]), value]); - } - return base; - }, []); -} - -export function formatSignatureBase(base: [string, string[]][]): string { - return base.map(([key, value]) => { - const quotedKey = serializeItem(parseItem(quoteString(key))); - return value.map((val) => `${quotedKey}: ${val}`).join('\n'); - }).join('\n'); -} - -export function createSigningParameters(config: SignConfig): Parameters { - const now = new Date(); - return (config.params ?? defaultParams).reduce((params, paramName) => { - let value: string | number = ''; - switch (paramName) { - 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 (config.paramValues?.created !== null) { - const created: Date = config.paramValues?.created ?? 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 (config.paramValues?.expires || config.paramValues?.created !== null) { - const expires = config.paramValues?.expires ?? new Date((config.paramValues?.created ?? now).getTime() + 300000); - value = Math.floor(expires.getTime() / 1000); - } - break; - case 'keyid': { - // attempt to obtain the keyid omit if missing - const kid = config.paramValues?.keyid ?? config.key.id ?? null; - if (kid) { - value = kid.toString(); - } - break; - } - case 'alg': { - const alg = config.paramValues?.alg ?? config.key.alg ?? null; - if (alg) { - value = alg.toString(); - } - break; - } - default: - if (config.paramValues?.[paramName] instanceof Date) { - value = Math.floor((config.paramValues[paramName] as Date).getTime() / 1000).toString(); - } else if (config.paramValues?.[paramName]) { - value = config.paramValues[paramName] as string; - } - } - if (value) { - params.set(paramName, value); - } - return params; - }, new Map()); -} - -export function augmentHeaders(headers: Record, signature: Buffer, signatureInput: string, name?: string): Record { - let signatureHeaderName = 'Signature'; - let signatureInputHeaderName = 'Signature-Input'; - let signatureHeader: DictionaryType = new Map(); - let inputHeader: DictionaryType = 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 = parseDictionary(Array.isArray(headers[header]) ? (headers[header] as string[]).join(', ') : headers[header] as string); - break; - } - case 'signature-input': - signatureInputHeaderName = header; - inputHeader = parseDictionary(Array.isArray(headers[header]) ? (headers[header] as string[]).join(', ') : headers[header] as string); - 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 ?? '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 ByteSequence(signature.toString('base64')), new Map()]); - inputHeader.set(signatureName, parseList(signatureInput)[0]); - return { - ...headers, - [signatureHeaderName]: serializeDictionary(signatureHeader), - [signatureInputHeaderName]: serializeDictionary(inputHeader), - }; -} - -export async function signMessage(config: SignConfig, res: T, req?: U): Promise; -export async function signMessage(config: SignConfig, req: T): Promise; - -export async function signMessage(config: SignConfig, message: T, req?: U): Promise { - const signingParameters = createSigningParameters(config); - const signatureBase = createSignatureBase(config?.fields ?? [], message as Response, req); - const signatureInput = serializeList([ - [ - signatureBase.map(([item]) => 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), - }; -} - -export async function verifyMessage(config: VerifyConfig, response: Response, request?: Request): Promise; -export async function verifyMessage(config: VerifyConfig, request: Request): Promise; - -export async function verifyMessage(config: VerifyConfig, message: Request | Response, req?: Request): Promise { - const { signatures, signatureInputs } = Object.entries(message.headers).reduce<{ signatures?: DictionaryType; signatureInputs?: DictionaryType }>((accum, [name, value]) => { - switch (name.toLowerCase()) { - case 'signature': - return Object.assign(accum, { - signatures: parseDictionary(Array.isArray(value) ? value.join(', ') : value), - }); - case 'signature-input': - return Object.assign(accum, { - signatureInputs: parseDictionary(Array.isArray(value) ? value.join(', ') : value), - }); - default: - return accum; - } - }, {}); - // no signatures means an indeterminate result - if (!signatures?.size && !signatureInputs?.size) { - return null; - } - // a missing header means we can't verify the signatures - if (!signatures?.size || !signatureInputs?.size) { - throw new Error('Incomplete signature headers'); - } - const now = Math.floor(Date.now() / 1000); - const tolerance = config.tolerance ?? 0; - const notAfter = config.notAfter instanceof Date ? Math.floor(config.notAfter.getTime() / 1000) : config.notAfter ?? now; - const maxAge = config.maxAge ?? null; - const requiredParams = config.requiredParams ?? []; - const requiredFields = config.requiredFields ?? []; - return Array.from(signatureInputs.entries()).reduce>(async (prev, [name, input]) => { - const result: Error | boolean | null = await prev.catch((e) => e); - if (!config.all && result === true) { - return result; - } - if (config.all && result !== true && result !== null) { - if (result instanceof Error) { - throw result; - } - return result; - } - if (!isInnerList(input)) { - throw new Error('Malformed signature input'); - } - const hasRequiredParams = requiredParams.every((param) => input[1].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) => input[0].some(([fieldName]) => fieldName === field)); - if (!hasRequiredFields) { - return false; - } - if (input[1].has('created')) { - const created = input[1].get('created') as number - 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 (input[1].has('expires')) { - const expires = input[1].get('expires') as number + tolerance; - // expired signature - if (expires > now) { - return false; - } - } - // now look to verify the signature! Build the expected "signing base" and verify it! - const signingBase = createSignatureBase(input[0].map((item) => serializeItem(item)), message as Response, req); - signingBase.push(['"@signature-params"', [serializeList([input])]]); - const base = formatSignatureBase(signingBase); - const signature = signatures.get(name); - if (!signature) { - throw new Error('No signature found for inputs'); - } - if (!isByteSequence(signature[0] as BareItem)) { - throw new Error('Malformed signature'); - } - return config.verifier(Buffer.from(base), Buffer.from((signature[0] as ByteSequence).toBase64(), 'base64'), Array.from(input[1].entries()).reduce((params, [key, value]) => { - let val: Date | number | string; - switch (key.toLowerCase()) { - case 'created': - case 'expires': - val = new Date((value as number) * 1000); - break; - default: { - if (typeof value === 'string' || typeof value=== 'number') { - val = value; - } else { - val = value.toString(); - } - } - } - return Object.assign(params, { - [key]: val, - }); - }, {})); - }, Promise.resolve(null)); -} diff --git a/src/structured-header.ts b/src/structured-header.ts index 6121aa9..26c08e2 100644 --- a/src/structured-header.ts +++ b/src/structured-header.ts @@ -89,3 +89,26 @@ export function parseHeader(header: string): List | Dictionary | Item { } throw new Error('Unable to parse header as structured field'); } + +/** + * This allows consumers of the library to supply field specifications that aren't + * strictly "structured fields". Really a string must start with a `"` but that won't + * tend to happen in our configs. + * + * @param {string} input + * @returns {string} + */ +export function quoteString(input: string): string { + // if it's not quoted, attempt to quote + if (!input.startsWith('"')) { + // try to split the structured field + const [name, ...rest] = input.split(';'); + // no params, just quote the whole thing + if (!rest.length) { + return `"${name}"`; + } + // quote the first part and put the rest back as it was + return `"${name}";${rest.join(';')}`; + } + return input; +} diff --git a/src/types/index.ts b/src/types/index.ts index ce36d2c..46c0b4f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,42 +1,131 @@ -import { Signer, Verifier } from '../algorithm'; - -type HttpLike = { - method: string, - url: string, - headers: Record, +export interface Request { + method: string; + url: string | URL; + headers: Record; } -export type RequestLike = HttpLike; - -export type ResponseLike = HttpLike & { - status: number, +export interface Response { + status: number; + headers: Record; } -// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3.1 -export type Parameter = 'created' | 'expires' | 'nonce' | 'alg' | 'keyid' | string; +export type Signer = (data: Buffer) => Promise; +export type Verifier = (data: Buffer, signature: Buffer, parameters?: SignatureParameters) => Promise; -export type Component = '@method' | '@target-uri' | '@authority' | '@scheme' | '@request-target' | '@path' | '@query' | '@query-params' | string; +export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512' | string; -export type ResponseComponent = '@status' | '@request-response' | Component; - -export type Parameters = { [name: Parameter]: string | number | Date | { [Symbol.toStringTag]: () => string } }; +export interface SigningKey { + id?: string; + alg?: Algorithm; + sign: Signer; +} -type CommonOptions = { - format: 'httpbis' | 'cavage', +/** + * 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 context parameter for the signature + */ + context?: string; + [param: string]: Date | number | string | null | undefined; } -export type SignOptions = CommonOptions & { - components?: Component[], - parameters?: Parameters, - allowMissingHeaders?: boolean, - keyId: string, - signer: Signer, -}; +/** + * Default parameters to use when signing a request if none are supplied by the consumer + */ +export const defaultParams = [ + 'keyid', + 'alg', + 'created', + 'expires', +]; -export type VerifyOptions = CommonOptions & { - verifier: Verifier, +export interface SignConfig { + 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, } -export type HeaderExtractionOptions = { - allowMissing: boolean, -}; +/** + * Options when verifying signatures + */ +export interface VerifyConfig { + verifier: Verifier; + /** + * A maximum age for the signature + * Default: Date.now() + tolerance + */ + notAfter?: Date | number; + /** + * The maximum age of the signature - this overrides the `expires` value for the signature + * 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 function isRequest(obj: Request | Response): obj is Request { + return !!(obj as Request).method; +} diff --git a/test/algorithm/ecdsa-p256-sha256.ts b/test/algorithm/ecdsa-p256-sha256.ts index f451764..8f16dd1 100644 --- a/test/algorithm/ecdsa-p256-sha256.ts +++ b/test/algorithm/ecdsa-p256-sha256.ts @@ -23,19 +23,18 @@ describe('ecdsa-p256-sha256', () => { }); describe('signing', () => { it('signs a payload', async () => { - const signer = createSigner('ecdsa-p256-sha256', ecdsaKeyPair.privateKey); - const data = 'some random data'; - const sig = await signer(data); + const signer = createSigner(ecdsaKeyPair.privateKey, 'ecdsa-p256-sha256'); + const data = Buffer.from('some random data'); + const sig = await signer.sign(data); expect(signer.alg).to.equal('ecdsa-p256-sha256'); - expect(sig).to.satisfy((arg: Buffer) => verify('sha256', Buffer.from(data), ecdsaKeyPair.publicKey, arg)); + expect(sig).to.satisfy((arg: Buffer) => verify('sha256', data, ecdsaKeyPair.publicKey, arg)); }); }); describe('verifying', () => { it('verifies a signature', async () => { - const verifier = createVerifier('ecdsa-p256-sha256', ecdsaKeyPair.publicKey); - const data = 'some random data'; - const sig = sign('sha512', Buffer.from(data), ecdsaKeyPair.privateKey); - expect(verifier.alg).to.equal('ecdsa-p256-sha256'); + const verifier = createVerifier(ecdsaKeyPair.publicKey, 'ecdsa-p256-sha256'); + const data = Buffer.from('some random data'); + const sig = sign('sha512', data, ecdsaKeyPair.privateKey); expect(sig).to.satisfy((arg: Buffer) => verifier(data, arg)); }); }); @@ -46,20 +45,20 @@ describe('ecdsa-p256-sha256', () => { ecKeyPem = (await promisify(readFile)(join(__dirname, '../etc/ecdsa-p256.pem'))).toString(); }); describe('response signing', () => { - const data = '"content-type": application/json\n' + + const data = Buffer.from('"content-type": application/json\n' + '"digest": SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + '"content-length": 18\n' + - '"@signature-params": ("content-type" "digest" "content-length");created=1618884475;keyid="test-key-ecc-p256"'; + '"@signature-params": ("content-type" "digest" "content-length");created=1618884475;keyid="test-key-ecc-p256"'); it('successfully signs a payload', async () => { - const sig = await (createSigner('ecdsa-p256-sha256', ecKeyPem)(data)); - expect(sig).to.satisfy((arg: Buffer) => verify('sha256', Buffer.from(data), ecKeyPem, arg)); + const sig = await (createSigner(ecKeyPem, 'ecdsa-p256-sha256').sign(data)); + expect(sig).to.satisfy((arg: Buffer) => verify('sha256', data, ecKeyPem, arg)); }); // seems to be broken in node - Error: error:0D07209B:asn1 encoding routines:ASN1_get_object:too long // could be to do with https://stackoverflow.com/a/39575576 it.skip('successfully verifies a signature', async () => { const sig = Buffer.from('n8RKXkj0iseWDmC6PNSQ1GX2R9650v+lhbb6rTGoSrSSx18zmn6fPOtBx48/WffYLO0n1RHHf9scvNGAgGq52Q==', 'base64'); expect(sig).to.satisfy((arg: Buffer) => verify('sha256', Buffer.from(data), ecKeyPem, arg)); - expect(await (createVerifier('ecdsa-p256-sha256', ecKeyPem)(data, sig))).to.equal(true); + expect(await (createVerifier(ecKeyPem, 'ecdsa-p256-sha256')(data, sig))).to.equal(true); }); }); }); diff --git a/test/algorithm/hmac-sha256.ts b/test/algorithm/hmac-sha256.ts index cd47d89..93f511c 100644 --- a/test/algorithm/hmac-sha256.ts +++ b/test/algorithm/hmac-sha256.ts @@ -4,16 +4,15 @@ import { expect } from 'chai'; describe('hmac-sha256', () => { // examples from wikipedia https://en.wikipedia.org/w/index.php?title=HMAC&oldid=1046955366#Examples describe('internal tests', () => { - const data = 'The quick brown fox jumps over the lazy dog'; + const data = Buffer.from('The quick brown fox jumps over the lazy dog'); const sig = Buffer.from('f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8', 'hex'); it('signs a payload correctly', async () => { - const hmac = createSigner('hmac-sha256', 'key'); + const hmac = createSigner('key', 'hmac-sha256'); expect(hmac.alg).to.equal('hmac-sha256'); - expect(await hmac(data)).to.deep.equal(sig); + expect(await hmac.sign(data)).to.deep.equal(sig); }); it('verifies a payload correctly', async () => { - const hmac = createVerifier('hmac-sha256', 'key'); - expect(hmac.alg).to.equal('hmac-sha256'); + const hmac = createVerifier('key', 'hmac-sha256'); expect(await hmac(data, sig)).to.equal(true); }); }); @@ -21,19 +20,18 @@ describe('hmac-sha256', () => { describe('specification examples', () => { const testSharedSecret = Buffer.from('uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==', 'base64'); const expectedSig = Buffer.from('fN3AMNGbx0V/cIEKkZOvLOoC3InI+lM2+gTv22x3ia8=', 'base64'); - const signatureInput = '"@authority": example.com\n' + + const signatureInput = Buffer.from('"@authority": example.com\n' + '"date": Tue, 20 Apr 2021 02:07:55 GMT\n' + '"content-type": application/json\n' + - '"@signature-params": ("@authority" "date" "content-type");created=1618884475;keyid="test-shared-secret"'; + '"@signature-params": ("@authority" "date" "content-type");created=1618884475;keyid="test-shared-secret"'); it('generates an expected hmac', async () => { - const hmac = createSigner('hmac-sha256', testSharedSecret); - const sig = await hmac(signatureInput); + const hmac = createSigner(testSharedSecret, 'hmac-sha256'); + const sig = await hmac.sign(signatureInput); expect(hmac.alg).to.equal('hmac-sha256'); expect(sig).to.deep.equal(expectedSig); }); it('verifies a provided signature', async () => { - const hmac = createVerifier('hmac-sha256', testSharedSecret); - expect(hmac.alg).to.equal('hmac-sha256'); + const hmac = createVerifier(testSharedSecret, 'hmac-sha256'); expect(await hmac(signatureInput, expectedSig)).to.equal(true); }); }); diff --git a/test/algorithm/rsa-pkcs1-sha1.ts b/test/algorithm/rsa-pkcs1-sha1.ts new file mode 100644 index 0000000..00a0ad5 --- /dev/null +++ b/test/algorithm/rsa-pkcs1-sha1.ts @@ -0,0 +1,40 @@ +import { createSign, generateKeyPair, publicDecrypt } from 'crypto'; +import { promisify } from 'util'; +import { createSigner, createVerifier } from '../../src'; +import { expect } from 'chai'; +import { RSA_PKCS1_PADDING } from 'constants'; + +describe('rsa-v1_5-sha1', () => { + let rsaKeyPair: { publicKey: string, privateKey: string }; + before('generate key pair', async () => { + rsaKeyPair = await promisify(generateKeyPair)('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + }); + describe('signing', () => { + it('signs a payload', async () => { + const signer = createSigner(rsaKeyPair.privateKey, 'rsa-v1_5-sha1'); + const data = Buffer.from('some random data'); + const sig = await signer.sign(data); + expect(signer.alg).to.equal('rsa-v1_5-sha1'); + expect(sig).to.satisfy((arg: Buffer) => publicDecrypt({ key: rsaKeyPair.publicKey, padding: RSA_PKCS1_PADDING }, arg)); + }); + }); + describe('verifying', () => { + it('verifies a signature', async () => { + const verifier = createVerifier(rsaKeyPair.publicKey, 'rsa-v1_5-sha1'); + const data = Buffer.from('some random data'); + const sig = createSign('sha1').update(data).sign({ key: rsaKeyPair.privateKey, padding: RSA_PKCS1_PADDING }); + const verified = await verifier(data, sig); + expect(verified).to.equal(true); + }); + }); +}); diff --git a/test/algorithm/rsa-pkcs1-sha256.ts b/test/algorithm/rsa-pkcs1-sha256.ts index 7acd9a1..ac51bd3 100644 --- a/test/algorithm/rsa-pkcs1-sha256.ts +++ b/test/algorithm/rsa-pkcs1-sha256.ts @@ -1,4 +1,4 @@ -import { generateKeyPair, privateEncrypt, publicDecrypt } from 'crypto'; +import { createSign, generateKeyPair, publicDecrypt } from 'crypto'; import { promisify } from 'util'; import { createSigner, createVerifier } from '../../src'; import { expect } from 'chai'; @@ -21,20 +21,19 @@ describe('rsa-v1_5-sha256', () => { }); describe('signing', () => { it('signs a payload', async () => { - const signer = createSigner('rsa-v1_5-sha256', rsaKeyPair.privateKey); - const data = 'some random data'; - const sig = await signer(data); + const signer = createSigner(rsaKeyPair.privateKey, 'rsa-v1_5-sha256'); + const data = Buffer.from('some random data'); + const sig = await signer.sign(data); expect(signer.alg).to.equal('rsa-v1_5-sha256'); expect(sig).to.satisfy((arg: Buffer) => publicDecrypt({ key: rsaKeyPair.publicKey, padding: RSA_PKCS1_PADDING }, arg)); }); }); describe('verifying', () => { - it.skip('verifies a signature', async () => { - const verifier = createVerifier('rsa-v1_5-sha256', rsaKeyPair.publicKey); - const data = 'some random data'; - const sig = privateEncrypt({ key: rsaKeyPair.privateKey, padding: RSA_PKCS1_PADDING }, Buffer.from(data)); + it('verifies a signature', async () => { + const verifier = createVerifier(rsaKeyPair.publicKey, 'rsa-v1_5-sha256'); + const data = Buffer.from('some random data'); + const sig = createSign('sha256').update(data).sign({ key: rsaKeyPair.privateKey, padding: RSA_PKCS1_PADDING }); const verified = await verifier(data, sig); - expect(verifier.alg).to.equal('rsa-v1_5-sha256'); expect(verified).to.equal(true); }); }); diff --git a/test/algorithm/rsa-pss-sha512.ts b/test/algorithm/rsa-pss-sha512.ts index 6002791..1206f8f 100644 --- a/test/algorithm/rsa-pss-sha512.ts +++ b/test/algorithm/rsa-pss-sha512.ts @@ -24,11 +24,11 @@ describe('rsa-pss-sha512', () => { }); describe('signing', () => { it('signs a payload', async () => { - const signer = createSigner('rsa-pss-sha512', rsaKeyPair.privateKey); - const data = 'some random data'; - const sig = await signer(data); + const signer = createSigner(rsaKeyPair.privateKey, 'rsa-pss-sha512'); + const data = Buffer.from('some random data'); + const sig = await signer.sign(data); expect(signer.alg).to.equal('rsa-pss-sha512'); - expect(sig).to.satisfy((arg: Buffer) => verify('sha512', Buffer.from(data), { + expect(sig).to.satisfy((arg: Buffer) => verify('sha512', data, { key: rsaKeyPair.publicKey, padding: RSA_PKCS1_PSS_PADDING, }, arg)); @@ -36,13 +36,12 @@ describe('rsa-pss-sha512', () => { }); describe('verifying', () => { it('verifies a signature', async () => { - const verifier = createVerifier('rsa-pss-sha512', rsaKeyPair.publicKey); - const data = 'some random data'; - const sig = sign('sha512', Buffer.from(data), { + const verifier = createVerifier(rsaKeyPair.publicKey, 'rsa-pss-sha512'); + const data = Buffer.from('some random data'); + const sig = sign('sha512', data, { key: rsaKeyPair.privateKey, padding: RSA_PKCS1_PSS_PADDING, }); - expect(verifier.alg).to.equal('rsa-pss-sha512'); expect(sig).to.satisfy((arg: Buffer) => verifier(data, arg)); }); }); @@ -53,9 +52,9 @@ describe('rsa-pss-sha512', () => { rsaKeyPem = (await promisify(readFile)(join(__dirname, '../etc/rsa-pss.pem'))).toString(); }); describe('minimal example', () => { - const data = '"@signature-params": ();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"'; + const data = Buffer.from('"@signature-params": ();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"'); it('successfully signs a payload', async () => { - const sig = await createSigner('rsa-pss-sha512', rsaKeyPem)(data); + const sig = await createSigner(rsaKeyPem, 'rsa-pss-sha512').sign(data); expect(sig).to.satisfy((arg: Buffer) => verify('sha512', Buffer.from(data), { key: rsaKeyPem, padding: RSA_PKCS1_PSS_PADDING, @@ -68,15 +67,15 @@ describe('rsa-pss-sha512', () => { 'cZgLxVwialuH5VnqJS4JN8PHD91XLfkjMscTo4jmVMpFd3iLVe0hqVFl7MDt6TMkw' + 'IyVFnEZ7B/VIQofdShO+C/7MuupCSLVjQz5xA+Zs6Hw+W9ESD/6BuGs6LF1TcKLxW' + '+5K+2zvDY/Cia34HNpRW5io7Iv9/b7iQ==', 'base64'); - expect(await createVerifier('rsa-pss-sha512', rsaKeyPem)(data, sig)).to.equal(true); + expect(await createVerifier(rsaKeyPem, 'rsa-pss-sha512')(data, sig)).to.equal(true); }); }); describe('selective example', () => { - const data = '"@authority": example.com\n' + + const data = Buffer.from('"@authority": example.com\n' + '"content-type": application/json\n' + - '"@signature-params": ("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"'; + '"@signature-params": ("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"'); it('successfully signs a payload', async () => { - const sig = await createSigner('rsa-pss-sha512', rsaKeyPem)(data); + const sig = await createSigner(rsaKeyPem, 'rsa-pss-sha512').sign(data); expect(sig).to.satisfy((arg: Buffer) => verify('sha512', Buffer.from(data), { key: rsaKeyPem, padding: RSA_PKCS1_PSS_PADDING, @@ -89,11 +88,11 @@ describe('rsa-pss-sha512', () => { 'uy2SfZJUhsJqZyEWRk4204x7YEB3VxDAAlVgGt8ewilWbIKKTOKp3ymUeQIwptqYw' + 'v0l8mN404PPzRBTpB7+HpClyK4CNp+SVv46+6sHMfJU4taz10s/NoYRmYCGXyadzY' + 'YDj0BYnFdERB6NblI/AOWFGl5Axhhmjg==', 'base64'); - expect(await createVerifier('rsa-pss-sha512', rsaKeyPem)(data, sig)).to.equal(true); + expect(await createVerifier(rsaKeyPem, 'rsa-pss-sha512')(data, sig)).to.equal(true); }); }); describe('full example', () => { - const data = '"date": Tue, 20 Apr 2021 02:07:56 GMT\n' + + const data = Buffer.from('"date": Tue, 20 Apr 2021 02:07:56 GMT\n' + '"@method": POST\n' + '"@path": /foo\n' + '"@query": ?param=value&pet=dog\n' + @@ -101,9 +100,9 @@ describe('rsa-pss-sha512', () => { '"content-type": application/json\n' + '"digest": SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + '"content-length": 18\n' + - '"@signature-params": ("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"'; + '"@signature-params": ("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"'); it('successfully signs a payload', async () => { - const sig = await createSigner('rsa-pss-sha512', rsaKeyPem)(data); + const sig = await createSigner(rsaKeyPem, 'rsa-pss-sha512').sign(data); expect(sig).to.satisfy((arg: Buffer) => verify('sha512', Buffer.from(data), { key: rsaKeyPem, padding: RSA_PKCS1_PSS_PADDING, @@ -116,7 +115,7 @@ describe('rsa-pss-sha512', () => { 'T/oBtxPtAn1eFjUyIKyA+XD7kYph82I+ahvm0pSgDPagu917SlqUjeaQaNnlZzO03' + 'Iy1RZ5XpgbNeDLCqSLuZFVID80EohC2CQ1cL5svjslrlCNstd2JCLmhjL7xV3NYXe' + 'rLim4bqUQGRgDwNJRnqobpS6C1NBns/Q==', 'base64'); - expect(await createVerifier('rsa-pss-sha512', rsaKeyPem)(data, sig)).to.equal(true); + expect(await createVerifier(rsaKeyPem, 'rsa-pss-sha512')(data, sig)).to.equal(true); }); }); }); diff --git a/test/cavage/cavage.ts b/test/cavage/cavage.ts index dad02d5..7e45529 100644 --- a/test/cavage/cavage.ts +++ b/test/cavage/cavage.ts @@ -1,26 +1,11 @@ -import { RequestLike } from '../../src'; -import { buildSignatureInputString, buildSignedData } from '../../src/cavage'; +import { Request } from '../../src'; +import { createSignatureBase, formatSignatureBase } from '../../src/cavage'; import { expect } from 'chai'; describe('cavage', () => { - describe('.buildSignatureInputString', () => { - describe('specification tests', () => { - it('creates an input string', () => { - const inputString = buildSignatureInputString(['@request-target', 'Host', 'Date', 'Digest', 'Content-Length'], { - keyid: 'rsa-key-1', - alg: 'hs2019', - created: new Date(1402170695000), - expires: new Date(1402170995000), - }); - expect(inputString).to.equal('keyId="rsa-key-1",algorithm="hs2019",' + - 'created=1402170695,expires=1402170995,' + - 'headers="(request-target) (created) (expires) host date digest content-length"') - }); - }); - }); describe('.buildSignedData', () => { describe('specification examples', () => { - const testRequest: RequestLike = { + const testRequest: Request = { method: 'GET', url: 'https://example.org/foo', headers: { @@ -32,16 +17,15 @@ describe('cavage', () => { }, }; it('builds the signed data payload', () => { - const payload = buildSignedData(testRequest, [ + const payload = formatSignatureBase(createSignatureBase([ '@request-target', + '@created', 'host', 'date', 'cache-control', 'x-emptyheader', 'x-example', - ], { - created: new Date(1402170695000), - }); + ], testRequest, new Map([['created', 1402170695]]))); expect(payload).to.equal('(request-target): get /foo\n' + '(created): 1402170695\n' + 'host: example.org\n' + diff --git a/test/cavage/new.spec.ts b/test/cavage/new.spec.ts index 59f18e6..620f63c 100644 --- a/test/cavage/new.spec.ts +++ b/test/cavage/new.spec.ts @@ -1,4 +1,5 @@ -import * as cavage from '../../src/cavage/new'; +import * as cavage from '../../src/cavage'; +import { Request, Response, SigningKey } from '../../src'; import { expect } from 'chai'; import { describe } from 'mocha'; import * as MockDate from 'mockdate'; @@ -9,7 +10,7 @@ describe('cavage', () => { describe('.deriveComponent', () => { describe('unbound components', () => { it('derives @request-target', () => { - const req: cavage.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -24,7 +25,7 @@ describe('cavage', () => { }); describe('.extractHeader', () => { describe('raw headers', () => { - const request: cavage.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -50,7 +51,7 @@ describe('cavage', () => { }); describe('.createSignatureBase', () => { describe('header fields', () => { - const request: cavage.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -89,7 +90,7 @@ describe('cavage', () => { }); }); describe('derived components', () => { - const request: cavage.Request = { + const request: Request = { method: 'post', url: 'https://www.example.com/path?param=value', headers: { @@ -103,7 +104,7 @@ describe('cavage', () => { }); }); describe('full example', () => { - const request: cavage.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -299,7 +300,7 @@ describe('cavage', () => { }); describe('.signMessage', () => { describe('requests', () => { - const request: cavage.Request = { + const request: Request = { method: 'post', url: 'https://example.org/foo', headers: { @@ -310,7 +311,7 @@ describe('cavage', () => { 'Content-Length': '18', }, }; - let signer: cavage.SigningKey; + let signer: SigningKey; beforeEach('stub signer', () => { signer = { sign: stub().resolves(Buffer.from('a fake signature')), @@ -359,7 +360,7 @@ describe('cavage', () => { }); }); describe('responses', () => { - const response: cavage.Response = { + const response: Response = { status: 503, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', @@ -367,7 +368,7 @@ describe('cavage', () => { 'Content-Length': '62', }, }; - let signer: cavage.SigningKey; + let signer: SigningKey; beforeEach('stub signer', () => { signer = { sign: stub().resolves(Buffer.from('a fake signature')), @@ -398,7 +399,7 @@ describe('cavage', () => { }); describe('.verifyMessage', () => { describe('requests', () => { - const request: cavage.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=value&pet=dog', headers: { @@ -437,7 +438,7 @@ describe('cavage', () => { }); }); describe('responses', () => { - const response: cavage.Response = { + const response: Response = { status: 200, headers: { 'Date': 'Tue, 07 Jun 2014 20:51:35 GMT', diff --git a/test/httpbis/httpbis.ts b/test/httpbis/httpbis.ts index 3dd5fc4..b7aac22 100644 --- a/test/httpbis/httpbis.ts +++ b/test/httpbis/httpbis.ts @@ -1,5 +1,8 @@ -import { Component, Parameters, RequestLike } from '../../src'; -import { buildSignatureInputString, buildSignedData, extractComponent, extractHeader } from '../../src/httpbis'; +import { Request } from '../../src'; +import { + deriveComponent, + extractHeader, +} from '../../src/httpbis'; import { expect } from 'chai'; describe('httpbis', () => { @@ -13,178 +16,75 @@ describe('httpbis', () => { }; Object.entries(headers).forEach(([headerName, expectedValue]) => { it(`successfully extracts a matching header (${headerName})`, () => { - expect(extractHeader({ headers } as unknown as RequestLike, headerName)).to.equal(expectedValue); + expect(extractHeader( headerName, { headers } as unknown as Request)).to.deep.equal([expectedValue]); }); it(`successfully extracts a lower cased header (${headerName})`, () => { - expect(extractHeader({ headers } as unknown as RequestLike, headerName.toLowerCase())).to.equal(expectedValue); + expect(extractHeader( headerName.toLowerCase(), { headers } as unknown as Request)).to.deep.equal([expectedValue]); }); it(`successfully extracts an upper cased header (${headerName})`, () => { - expect(extractHeader({ headers } as unknown as RequestLike, headerName.toUpperCase())).to.equal(expectedValue); + expect(extractHeader( headerName.toUpperCase(), { headers } as unknown as Request)).to.deep.equal([expectedValue]); }); }); - it('allows missing headers to return by default', () => { - expect(extractHeader({ headers } as unknown as RequestLike, 'missing')).to.equal(''); - }); it('throws on missing headers', () => { - expect(() => extractHeader({ headers } as unknown as RequestLike, 'missing', { allowMissing: false })).to.throw(Error, 'Unable to extract header "missing" from message'); - }); - it('does not throw on missing headers', () => { - expect(extractHeader({ headers } as unknown as RequestLike, 'missing', { allowMissing: true })).to.equal(''); + expect(() => extractHeader('missing', { headers } as unknown as Request)).to.throw(Error, 'No header "missing" found in headers'); }); }); - describe('.extractComponent', () => { + describe('.deriveComponent', () => { it('correctly extracts the @method', () => { - const result = extractComponent({ + const result = deriveComponent('@method', { method: 'POST', url: 'https://www.example.com/path?param=value', - } as unknown as RequestLike, '@method'); - expect(result).to.equal('POST'); + } as unknown as Request); + expect(result).to.deep.equal(['POST']); }); it('correctly extracts the @target-uri', () => { - const result = extractComponent({ + const result = deriveComponent('@target-uri', { method: 'POST', url: 'https://www.example.com/path?param=value', - } as unknown as RequestLike, '@target-uri'); - expect(result).to.equal('https://www.example.com/path?param=value'); + } as unknown as Request); + expect(result).to.deep.equal(['https://www.example.com/path?param=value']); }); it('correctly extracts the @authority', () => { - const result = extractComponent({ + const result = deriveComponent('@authority', { method: 'POST', url: 'https://www.example.com/path?param=value', - } as unknown as RequestLike, '@authority'); - expect(result).to.equal('www.example.com'); + } as unknown as Request); + expect(result).to.deep.equal(['www.example.com']); }); it('correctly extracts the @scheme', () => { - const result = extractComponent({ + const result = deriveComponent('@scheme', { method: 'POST', url: 'http://www.example.com/path?param=value', - } as unknown as RequestLike, '@scheme'); - expect(result).to.equal('http'); + } as unknown as Request); + expect(result).to.deep.equal(['http']); }); it('correctly extracts the @request-target', () => { - const result = extractComponent({ + const result = deriveComponent('@request-target', { method: 'POST', url: 'https://www.example.com/path?param=value', - } as unknown as RequestLike, '@request-target'); - expect(result).to.equal('/path?param=value'); + } as unknown as Request); + expect(result).to.deep.equal(['/path?param=value']); }); it('correctly extracts the @path', () => { - const result = extractComponent({ + const result = deriveComponent('@path', { method: 'POST', url: 'https://www.example.com/path?param=value', - } as unknown as RequestLike, '@path'); - expect(result).to.equal('/path'); + } as unknown as Request); + expect(result).to.deep.equal(['/path']); }); it('correctly extracts the @query', () => { - const result = extractComponent({ + const result = deriveComponent('@query', { method: 'POST', url: 'https://www.example.com/path?param=value&foo=bar&baz=batman', - } as unknown as RequestLike, '@query'); - expect(result).to.equal('?param=value&foo=bar&baz=batman'); + } as unknown as Request); + expect(result).to.deep.equal(['?param=value&foo=bar&baz=batman']); }); it('correctly extracts the @query', () => { - const result = extractComponent({ + const result = deriveComponent('@query', { method: 'POST', url: 'https://www.example.com/path?queryString', - } as unknown as RequestLike, '@query'); - expect(result).to.equal('?queryString'); - }); - it.skip('correctly extracts the @query-params', () => { - const result = extractComponent({ - method: 'POST', - url: 'https://www.example.com/path?param=value&foo=bar&baz=batman&qux=', - } as unknown as RequestLike, '@query-params'); - expect(result).to.equal(''); - }); - }); - describe('.buildSignatureInputString', () => { - describe('specification test cases', () => { - it('constructs minimal example', () => { - const components: Component[] = []; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: 'test-key-rsa-pss', - alg: 'rsa-pss-sha512', - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal('();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"'); - }); - it('constructs selective example', () => { - const components: Component[] = ['@authority', 'Content-Type']; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: 'test-key-rsa-pss', - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal('("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"'); - }); - it('constructs full example', () => { - const components: Component[] = [ - 'Date', - '@method', - '@path', - '@query', - '@authority', - 'Content-Type', - 'Digest', - 'Content-Length', - ]; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: 'test-key-rsa-pss', - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal('("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"'); - }); - }); - }); - describe('.buildSignedData', () => { - const testRequest: RequestLike = { - method: 'POST', - url: 'https://example.com/foo?param=value&pet=dog', - headers: { - 'Host': 'example.com', - 'Date': 'Tue, 20 Apr 2021 02:07:55 GMT', - 'Content-Type': 'application/json', - 'Digest': 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=', - 'Content-Length': '18', - }, - }; - it('constructs minimal example', () => { - const components: Component[] = []; - const data = buildSignedData(testRequest, components, '();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"'); - expect(data).to.equal('"@signature-params": ();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"'); - }); - it('constructs selective example', () => { - const components: Component[] = ['@authority', 'Content-Type']; - const data = buildSignedData(testRequest, components, '("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"'); - expect(data).to.equal('"@authority": example.com\n' + - '"content-type": application/json\n' + - '"@signature-params": ("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"') - }); - it('constructs full example', () => { - const components: Component[] = [ - 'Date', - '@method', - '@path', - '@query', - '@authority', - 'Content-Type', - 'Digest', - 'Content-Length', - ]; - const data = buildSignedData(testRequest, components, '("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"'); - expect(data).to.equal('"date": Tue, 20 Apr 2021 02:07:55 GMT\n' + - '"@method": POST\n' + - '"@path": /foo\n' + - '"@query": ?param=value&pet=dog\n' + - '"@authority": example.com\n' + - '"content-type": application/json\n' + - '"digest": SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + - '"content-length": 18\n' + - '"@signature-params": ("date" "@method" "@path" "@query" ' + - '"@authority" "content-type" "digest" "content-length")' + - ';created=1618884475;keyid="test-key-rsa-pss"'); + } as unknown as Request); + expect(result).to.deep.equal(['?queryString']); }); }); }); diff --git a/test/httpbis/new.spec.ts b/test/httpbis/new.spec.ts index 58e8aba..35759bf 100644 --- a/test/httpbis/new.spec.ts +++ b/test/httpbis/new.spec.ts @@ -1,4 +1,5 @@ -import * as httpbis from '../../src/httpbis/new'; +import * as httpbis from '../../src/httpbis'; +import { Request, Response, SigningKey } from '../../src'; import { expect } from 'chai'; import { describe } from 'mocha'; import * as MockDate from 'mockdate'; @@ -9,7 +10,7 @@ describe('httpbis', () => { describe('.deriveComponent', () => { describe('unbound components', () => { it('derives @method component', () => { - const req: httpbis.Request = { + const req: Request = { method: 'get', headers: {}, url: 'https://example.com/test', @@ -22,7 +23,7 @@ describe('httpbis', () => { })).to.deep.equal(['POST']); }); it('derives @target-uri', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -34,7 +35,7 @@ describe('httpbis', () => { ]); }); it('derives @authority', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -66,7 +67,7 @@ describe('httpbis', () => { })).to.deep.equal(['www.example.com:80']); }); it('derives @scheme', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -80,7 +81,7 @@ describe('httpbis', () => { })).to.deep.equal(['http']); }); it('derives @request-target', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -97,7 +98,7 @@ describe('httpbis', () => { ]); }); it('derives @path', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value', headers: { @@ -109,7 +110,7 @@ describe('httpbis', () => { ]); }); it('derives @query', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value&foo=bar&baz=batman', headers: { @@ -133,7 +134,7 @@ describe('httpbis', () => { ]); }); it('derives @query-param', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value&foo=bar&baz=batman&qux=', headers: { @@ -158,14 +159,14 @@ describe('httpbis', () => { ]); }); it('derives @status', () => { - const req: httpbis.Request = { + const req: Request = { method: 'POST', url: 'https://www.example.com/path?param=value&foo=bar&baz=batman&qux=', headers: { Host: 'www.example.com', }, }; - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -173,7 +174,7 @@ describe('httpbis', () => { }); }); describe('request-response bound components', () => { - const req: httpbis.Request = { + const req: Request = { method: 'get', headers: { Host: 'www.example.com', @@ -181,7 +182,7 @@ describe('httpbis', () => { url: 'https://www.example.com/path?param=value', }; it('derives @method component', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -193,7 +194,7 @@ describe('httpbis', () => { })).to.deep.equal(['POST']); }); it('derives @target-uri', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -202,7 +203,7 @@ describe('httpbis', () => { ]); }); it('derives @authority', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -231,7 +232,7 @@ describe('httpbis', () => { })).to.deep.equal(['www.example.com:80']); }); it('derives @scheme', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -242,7 +243,7 @@ describe('httpbis', () => { })).to.deep.equal(['http']); }); it('derives @request-target', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -256,7 +257,7 @@ describe('httpbis', () => { ]); }); it('derives @path', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -265,7 +266,7 @@ describe('httpbis', () => { ]); }); it('derives @query', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -286,7 +287,7 @@ describe('httpbis', () => { ]); }); it('derives @query-param', () => { - const res: httpbis.Response = { + const res: Response = { status: 200, headers: {}, }; @@ -320,7 +321,7 @@ describe('httpbis', () => { }); describe('.extractHeader', () => { describe('raw headers', () => { - const request: httpbis.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -344,7 +345,7 @@ describe('httpbis', () => { }); }); describe('sf headers', () => { - const request: httpbis.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -362,7 +363,7 @@ describe('httpbis', () => { }); }); describe('key from structured header', () => { - const request: httpbis.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -384,7 +385,7 @@ describe('httpbis', () => { }); }); describe('bs from header', () => { - const request: httpbis.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -404,7 +405,7 @@ describe('httpbis', () => { }); }); describe('request-response bound header', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -417,7 +418,7 @@ describe('httpbis', () => { 'Signature': 'sig1=:LAH8BjcfcOcLojiuOBFWn0P5keD3xAOuJRGziCLuD8r5MW9S0RoXXLzLSRfGY/3SF8kVIkHjE13SEFdTo4Af/fJ/Pu9wheqoLVdwXyY/UkBIS1M8Brc8IODsn5DFIrG0IrburbLi0uCc+E2ZIIb6HbUJ+o+jP58JelMTe0QE3IpWINTEzpxjqDf5/Df+InHCAkQCTuKsamjWXUpyOT1Wkxi7YPVNOjW4MfNuTZ9HdbD2Tr65+BXeTG9ZS/9SWuXAc+BZ8WyPz0QRz//ec3uWXd7bYYODSjRAxHqX+S1ag3LZElYyUKaAIjZ8MGOt4gXEwCSLDv/zqxZeWLj/PDkn6w==:', }, }; - const response: httpbis.Response = { + const response: Response = { status: 503, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', @@ -434,7 +435,7 @@ describe('httpbis', () => { }); describe('.createSignatureBase', () => { describe('header fields', () => { - const request: httpbis.Request = { + const request: Request = { method: 'POST', url: 'https://www.example.com/', headers: { @@ -505,7 +506,7 @@ describe('httpbis', () => { headers: { 'Example-Header': ['value, with, lots', 'of, commas'], }, - } as httpbis.Request)).to.deep.equal([ + } as Request)).to.deep.equal([ ['"example-header";bs', [':dmFsdWUsIHdpdGgsIGxvdHM=:, :b2YsIGNvbW1hcw==:']], ]); expect(httpbis.createSignatureBase([ @@ -515,13 +516,13 @@ describe('httpbis', () => { headers: { 'Example-Header': ['value, with, lots, of, commas'], }, - } as httpbis.Request)).to.deep.equal([ + } as Request)).to.deep.equal([ ['"example-header";bs', [':dmFsdWUsIHdpdGgsIGxvdHMsIG9mLCBjb21tYXM=:']], ]); }); }); describe('derived components', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://www.example.com/path?param=value', headers: { @@ -608,7 +609,7 @@ describe('httpbis', () => { }); }); describe('full example', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -901,7 +902,7 @@ describe('httpbis', () => { }); describe('.signMessage', () => { describe('requests', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -912,7 +913,7 @@ describe('httpbis', () => { 'Content-Length': '18', }, }; - let signer: httpbis.SigningKey; + let signer: SigningKey; beforeEach('stub signer', () => { signer = { sign: stub().resolves(Buffer.from('a fake signature')), @@ -959,7 +960,7 @@ describe('httpbis', () => { }); }); describe('responses', () => { - const response: httpbis.Response = { + const response: Response = { status: 503, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', @@ -967,7 +968,7 @@ describe('httpbis', () => { 'Content-Length': '62', }, }; - let signer: httpbis.SigningKey; + let signer: SigningKey; beforeEach('stub signer', () => { signer = { sign: stub().resolves(Buffer.from('a fake signature')), @@ -999,7 +1000,7 @@ describe('httpbis', () => { }); }); describe('request bound responses', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -1012,7 +1013,7 @@ describe('httpbis', () => { 'Signature': 'sig1=:LAH8BjcfcOcLojiuOBFWn0P5keD3xAOuJRGziCLuD8r5MW9S0RoXXLzLSRfGY/3SF8kVIkHjE13SEFdTo4Af/fJ/Pu9wheqoLVdwXyY/UkBIS1M8Brc8IODsn5DFIrG0IrburbLi0uCc+E2ZIIb6HbUJ+o+jP58JelMTe0QE3IpWINTEzpxjqDf5/Df+InHCAkQCTuKsamjWXUpyOT1Wkxi7YPVNOjW4MfNuTZ9HdbD2Tr65+BXeTG9ZS/9SWuXAc+BZ8WyPz0QRz//ec3uWXd7bYYODSjRAxHqX+S1ag3LZElYyUKaAIjZ8MGOt4gXEwCSLDv/zqxZeWLj/PDkn6w==:', }, }; - const response: httpbis.Response = { + const response: Response = { status: 503, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', @@ -1020,7 +1021,7 @@ describe('httpbis', () => { 'Content-Length': '62', }, }; - let signer: httpbis.SigningKey; + let signer: SigningKey; beforeEach('stub signer', () => { signer = { sign: stub().resolves(Buffer.from('a fake signature')), @@ -1058,7 +1059,7 @@ describe('httpbis', () => { }); describe('.verifyMessage', () => { describe('requests', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -1096,7 +1097,7 @@ describe('httpbis', () => { }); }); describe('responses', () => { - const response: httpbis.Response = { + const response: Response = { status: 200, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', @@ -1130,7 +1131,7 @@ describe('httpbis', () => { }); }); describe('request bound responses', () => { - const request: httpbis.Request = { + const request: Request = { method: 'post', url: 'https://example.com/foo?param=Value&Pet=dog', headers: { @@ -1143,7 +1144,7 @@ describe('httpbis', () => { 'Signature': 'sig1=:LAH8BjcfcOcLojiuOBFWn0P5keD3xAOuJRGziCLuD8r5MW9S0RoXXLzLSRfGY/3SF8kVIkHjE13SEFdTo4Af/fJ/Pu9wheqoLVdwXyY/UkBIS1M8Brc8IODsn5DFIrG0IrburbLi0uCc+E2ZIIb6HbUJ+o+jP58JelMTe0QE3IpWINTEzpxjqDf5/Df+InHCAkQCTuKsamjWXUpyOT1Wkxi7YPVNOjW4MfNuTZ9HdbD2Tr65+BXeTG9ZS/9SWuXAc+BZ8WyPz0QRz//ec3uWXd7bYYODSjRAxHqX+S1ag3LZElYyUKaAIjZ8MGOt4gXEwCSLDv/zqxZeWLj/PDkn6w==:', }, }; - const response: httpbis.Response = { + const response: Response = { status: 503, headers: { 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT',