From 628da39ca436a8049f0ea4b83ca92c04f01a74cc Mon Sep 17 00:00:00 2001 From: Daniel Hensby Date: Wed, 28 Sep 2022 23:22:09 +0100 Subject: [PATCH] Rewrite cavage/RichAnna spec --- README.md | 13 +- src/algorithm/index.ts | 9 +- src/cavage/new.ts | 452 +++++++++++++++++++++++++++++++++++ test/cavage/new.spec.ts | 504 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 973 insertions(+), 5 deletions(-) create mode 100644 src/cavage/new.ts create mode 100644 test/cavage/new.spec.ts diff --git a/README.md b/README.md index 1de3e66..dedf4a8 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,19 @@ of HTTP messages before being sent. Two specifications are supported by this library: 1. [HTTPbis](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures) -2. [Cavage](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) +2. [Cavage](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) and subsequent [RichAnna](https://datatracker.ietf.org/doc/html/draft-richanna-http-message-signatures) ## Approach -As the cavage specification is now expired and superseded by the HTTPbis one, this library takes a +As the Cavage/RichAnna specification is now expired and superseded by the HTTPbis one, this library takes a "HTTPbis-first" approach. This means that most support and maintenance will go into the HTTPbis -implementation and syntax. The syntax is then back-ported to the Cavage implementation as much as -possible. +implementation and syntax. The syntax is then back-ported to the as much as possible. + +## Caveats + +The Cavage/RichAnna specifications have changed over time, introducing new features. The aim is to support +the [latest version of the specification](https://datatracker.ietf.org/doc/html/draft-richanna-http-message-signatures) +and not to try to support each version in isolation. ## Examples diff --git a/src/algorithm/index.ts b/src/algorithm/index.ts index b1cf6cb..ce71611 100644 --- a/src/algorithm/index.ts +++ b/src/algorithm/index.ts @@ -12,7 +12,7 @@ import { } from 'crypto'; import { RSA_PKCS1_PADDING, RSA_PKCS1_PSS_PADDING } from 'constants'; -export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512'; +export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512' | string; export interface Signer { (data: BinaryLike): Promise, @@ -42,6 +42,13 @@ export function createSigner(alg: Algorithm, key: BinaryLike | KeyLike | SignKey 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({ + key, + padding: RSA_PKCS1_PADDING, + } as SignPrivateKeyInput); + break; case 'ecdsa-p256-sha256': signer = async (data: BinaryLike) => createSign('sha256').update(data).sign(key as KeyLike); break; diff --git a/src/cavage/new.ts b/src/cavage/new.ts new file mode 100644 index 0000000..450a5ee --- /dev/null +++ b/src/cavage/new.ts @@ -0,0 +1,452 @@ +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/test/cavage/new.spec.ts b/test/cavage/new.spec.ts new file mode 100644 index 0000000..2359d79 --- /dev/null +++ b/test/cavage/new.spec.ts @@ -0,0 +1,504 @@ +import * as cavage from '../../src/cavage/new'; +import { expect } from 'chai'; +import { describe } from 'mocha'; +import * as MockDate from 'mockdate'; +import { stub } from 'sinon'; + +describe('cavage', () => { + // test the spec as per https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures#section-2.2 + describe('.deriveComponent', () => { + describe('unbound components', () => { + it('derives @request-target', () => { + const req: cavage.Request = { + method: 'POST', + url: 'https://www.example.com/path?param=value', + headers: { + Host: 'www.example.com', + }, + }; + expect(cavage.deriveComponent('@request-target', req)).to.deep.equal([ + 'post /path?param=value', + ]); + }); + }); + }); + describe('.extractHeader', () => { + describe('raw headers', () => { + const request: cavage.Request = { + method: 'POST', + url: 'https://www.example.com/', + headers: { + 'Host': 'www.example.com', + 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', + 'X-OWS-Header': ' Leading and trailing whitespace. ', + 'X-Obs-Fold-Header': 'Obsolete\n line folding.', + 'Cache-Control': ['max-age=60', ' must-revalidate'], + 'Example-Dict': ' a=1, b=2;x=1;y=2, c=(a b c)', + 'X-Empty-Header': '', + }, + }; + it('parses raw fields', () => { + expect(cavage.extractHeader('host', request)).to.deep.equal(['www.example.com']); + expect(cavage.extractHeader('date', request)).to.deep.equal(['Tue, 20 Apr 2021 02:07:56 GMT']); + expect(cavage.extractHeader('X-OWS-Header', request)).to.deep.equal(['Leading and trailing whitespace.']); + expect(cavage.extractHeader('x-obs-fold-header', request)).to.deep.equal(['Obsolete line folding.']); + expect(cavage.extractHeader('cache-control', request)).to.deep.equal(['max-age=60, must-revalidate']); + expect(cavage.extractHeader('example-dict', request)).to.deep.equal(['a=1, b=2;x=1;y=2, c=(a b c)']); + expect(cavage.extractHeader('x-empty-header', request)).to.deep.equal(['']); + }); + }); + }); + describe('.createSignatureBase', () => { + describe('header fields', () => { + const request: cavage.Request = { + method: 'POST', + url: 'https://www.example.com/', + headers: { + 'Host': 'www.example.com', + 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', + 'X-OWS-Header': ' Leading and trailing whitespace. ', + 'X-Obs-Fold-Header': 'Obsolete\n line folding.', + 'Cache-Control': ['max-age=60', ' must-revalidate'], + 'Example-Dict': ' a=1, b=2;x=1;y=2, c=(a b c)', + 'X-Empty-Header': '', + }, + }; + it('creates a signature base from raw headers', () => { + expect(cavage.createSignatureBase([ + 'host', + 'date', + 'x-ows-header', + 'x-obs-fold-header', + 'cache-control', + 'example-dict', + ], request, new Map())).to.deep.equal([ + ['host', ['www.example.com']], + ['date', ['Tue, 20 Apr 2021 02:07:56 GMT']], + ['x-ows-header', ['Leading and trailing whitespace.']], + ['x-obs-fold-header', ['Obsolete line folding.']], + ['cache-control', ['max-age=60, must-revalidate']], + ['example-dict', ['a=1, b=2;x=1;y=2, c=(a b c)']], + ]); + }); + it('extracts an empty header', () => { + expect(cavage.createSignatureBase([ + 'X-Empty-Header', + ], request, new Map())).to.deep.equal([ + ['x-empty-header', ['']], + ]); + }); + }); + describe('derived components', () => { + const request: cavage.Request = { + method: 'post', + url: 'https://www.example.com/path?param=value', + headers: { + Host: 'www.example.com', + }, + }; + it('derives @request-target', () => { + expect(cavage.createSignatureBase(['@request-target'], request, new Map())).to.deep.equal([ + ['(request-target)', ['post /path?param=value']], + ]); + }); + }); + describe('full example', () => { + const request: cavage.Request = { + 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', + 'Content-Digest': 'sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:', + 'Content-Length': '18', + }, + }; + it('produces a signature base for a request', () => { + expect(cavage.createSignatureBase([ + '@request-target', + 'content-digest', + 'content-length', + 'content-type', + ], request, new Map())).to.deep.equal([ + ['(request-target)', ['post /foo?param=Value&Pet=dog']], + ['content-digest', ['sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:']], + ['content-length', ['18']], + ['content-type', ['application/json']], + ]); + }); + }); + }); + describe('.formatSignatureBase', () => { + it('derives @request-target', () => { + expect(cavage.formatSignatureBase([ + ['@request-target', ['post /path?param=value']], + ])).to.equal('(request-target): post /path?param=value'); + }); + it('formats many headers', () => { + expect(cavage.formatSignatureBase([ + ['host', ['www.example.com']], + ['date', ['Tue, 20 Apr 2021 02:07:56 GMT']], + ['x-ows-header', ['Leading and trailing whitespace.']], + ['x-obs-fold-header', ['Obsolete line folding.']], + ['cache-control', ['max-age=60, must-revalidate']], + ['example-dict', ['a=1, b=2;x=1;y=2, c=(a b c)']], + ['x-empty-header', ['']], + ])).to.equal('host: www.example.com\n' + + 'date: Tue, 20 Apr 2021 02:07:56 GMT\n' + + 'x-ows-header: Leading and trailing whitespace.\n' + + 'x-obs-fold-header: Obsolete line folding.\n' + + 'cache-control: max-age=60, must-revalidate\n' + + 'example-dict: a=1, b=2;x=1;y=2, c=(a b c)\n' + + 'x-empty-header: '); + }); + }); + describe('.createSigningParameters', () => { + before('mock date', () => { + MockDate.set(new Date('2022-09-27 08:34:12 GMT')); + }); + after('reset date', () => { + MockDate.reset(); + }); + describe('default params', () => { + it('creates a set of default parameters', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + alg: 'rsa123', + }, + }).entries())).to.deep.equal([ + ['keyid', '123'], + ['alg', 'rsa123'], + ['created', 1664267652], + ['expires', 1664267952], + ]); + }); + it('omits created if null passed', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + alg: 'rsa123', + }, + paramValues: { created: null }, + }, ).entries())).to.deep.equal([ + ['keyid', '123'], + ['alg', 'rsa123'], + ]); + }); + it('uses a custom expires if passed', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + alg: 'rsa123', + }, + paramValues: { expires: new Date(Date.now() + 600000) }, + }).entries())).to.deep.equal([ + ['keyid', '123'], + ['alg', 'rsa123'], + ['created', 1664267652], + ['expires', 1664268252], + ]); + }); + it('overrides the keyid', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + alg: 'rsa123', + }, + paramValues: { keyid: '321' }, + }).entries())).to.deep.equal([ + ['keyid', '321'], + ['alg', 'rsa123'], + ['created', 1664267652], + ['expires', 1664267952], + ]); + }); + it('overrides the alg', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + alg: 'rsa123', + }, + paramValues: { alg: 'rsa321' }, + }).entries())).to.deep.equal([ + ['keyid', '123'], + ['alg', 'rsa321'], + ['created', 1664267652], + ['expires', 1664267952], + ]); + }); + it('handles missing alg', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + sign: () => Promise.resolve(Buffer.from('')), + }, + }).entries())).to.deep.equal([ + ['keyid', '123'], + ['created', 1664267652], + ['expires', 1664267952], + ]); + }); + it('handles missing keyid', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + sign: () => Promise.resolve(Buffer.from('')), + }, + }).entries())).to.deep.equal([ + ['created', 1664267652], + ['expires', 1664267952], + ]); + }); + it('returns nothing if no data', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + sign: () => Promise.resolve(Buffer.from('')), + }, + paramValues: { created: null }, + }).entries())).to.deep.equal([]); + }); + }); + describe('specified params', () => { + it('returns specified params', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + alg: 'rsa', + sign: () => Promise.resolve(Buffer.from('')), + }, + params: ['created', 'keyid', 'alg'], + }).entries())).to.deep.equal([ + ['created', 1664267652], + ['keyid', '123'], + ['alg', 'rsa'], + ]); + }); + it('returns arbitrary params', () => { + expect(Array.from(cavage.createSigningParameters({ + key: { + id: '123', + alg: 'rsa', + sign: () => Promise.resolve(Buffer.from('')), + }, + params: ['created', 'keyid', 'alg', 'custom'], + paramValues: { custom: 'value' }, + }).entries())).to.deep.equal([ + ['created', 1664267652], + ['keyid', '123'], + ['alg', 'rsa'], + ['custom', 'value'], + ]); + }); + }); + }); + describe('.signMessage', () => { + describe('requests', () => { + const request: cavage.Request = { + method: 'post', + url: 'https://example.org/foo', + headers: { + 'Host': 'example.org', + 'Date': 'Tue, 07 Jun 2014 20:51:35 GMT', + 'Content-Type': 'application/json', + 'Digest': 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=', + 'Content-Length': '18', + }, + }; + let signer: cavage.SigningKey; + beforeEach('stub signer', () => { + signer = { + sign: stub().resolves(Buffer.from('a fake signature')), + }; + }); + it('signs a request', async () => { + const signed = await cavage.signMessage({ + key: signer, + params: [ + 'keyid', + 'alg', + 'created', + 'expires', + ], + fields: [ + '@request-target', + '@created', + '@expires', + 'host', + 'digest', + 'content-length', + ], + paramValues: { + keyid: 'rsa-key-1', + alg: 'hs2019', + created: new Date(1402170695 * 1000), + expires: new Date(1402170995 * 1000), + }, + }, request); + expect(signed.headers).to.deep.equal({ + 'Host': 'example.org', + 'Date': 'Tue, 07 Jun 2014 20:51:35 GMT', + 'Content-Type': 'application/json', + 'Digest': 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=', + 'Content-Length': '18', + 'Signature': 'keyId="rsa-key-1", algorithm="hs2019", created=1402170695, expires=1402170995, headers="(request-target) (created) (expires) host digest content-length", signature="YSBmYWtlIHNpZ25hdHVyZQ=="', + }); + expect(signer.sign).to.have.been.calledOnceWithExactly(Buffer.from( + '(request-target): post /foo\n' + + '(created): 1402170695\n' + + '(expires): 1402170995\n' + + 'host: example.org\n' + + 'digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + + 'content-length: 18' + )); + }); + }); + describe('responses', () => { + const response: cavage.Response = { + status: 503, + headers: { + 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', + 'Content-Type': 'application/json', + 'Content-Length': '62', + }, + }; + let signer: cavage.SigningKey; + beforeEach('stub signer', () => { + signer = { + sign: stub().resolves(Buffer.from('a fake signature')), + }; + }); + it('signs a response', async () => { + const signed = await cavage.signMessage({ + key: signer, + fields: ['content-length', 'content-type'], + params: ['created', 'keyid'], + paramValues: { + created: new Date(1618884479 * 1000), + keyid: 'test-key-ecc-p256', + }, + }, response); + expect(signed.headers).to.deep.equal({ + 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', + 'Content-Type': 'application/json', + 'Content-Length': '62', + 'Signature': 'created=1618884479, keyId="test-key-ecc-p256", headers="content-length content-type", signature="YSBmYWtlIHNpZ25hdHVyZQ=="', + }); + expect(signer.sign).to.have.been.calledOnceWithExactly(Buffer.from( + 'content-length: 62\n' + + 'content-type: application/json' + )); + }); + }); + describe('request bound responses', () => { + const request: cavage.Request = { + 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', + 'Content-Digest': 'sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:', + 'Content-Length': '18', + 'Signature-Input': 'sig1=("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884475;keyid="test-key-rsa-pss"', + 'Signature': 'sig1=:LAH8BjcfcOcLojiuOBFWn0P5keD3xAOuJRGziCLuD8r5MW9S0RoXXLzLSRfGY/3SF8kVIkHjE13SEFdTo4Af/fJ/Pu9wheqoLVdwXyY/UkBIS1M8Brc8IODsn5DFIrG0IrburbLi0uCc+E2ZIIb6HbUJ+o+jP58JelMTe0QE3IpWINTEzpxjqDf5/Df+InHCAkQCTuKsamjWXUpyOT1Wkxi7YPVNOjW4MfNuTZ9HdbD2Tr65+BXeTG9ZS/9SWuXAc+BZ8WyPz0QRz//ec3uWXd7bYYODSjRAxHqX+S1ag3LZElYyUKaAIjZ8MGOt4gXEwCSLDv/zqxZeWLj/PDkn6w==:', + }, + }; + const response: cavage.Response = { + status: 503, + headers: { + 'Date': 'Tue, 20 Apr 2021 02:07:56 GMT', + 'Content-Type': 'application/json', + 'Content-Length': '62', + }, + }; + let signer: cavage.SigningKey; + beforeEach('stub signer', () => { + signer = { + sign: stub().resolves(Buffer.from('a fake signature')), + }; + }); + }); + }); + describe('.verifyMessage', () => { + describe('requests', () => { + const request: cavage.Request = { + method: 'post', + url: 'https://example.com/foo?param=value&pet=dog', + headers: { + 'Host': 'example.com', + 'Date': 'Tue, 07 Jun 2014 20:51:35 GMT', + 'Content-Type': 'application/json', + 'Digest': 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=', + 'Content-Length': '18', + 'Signature': 'keyId="test-key-a", algorithm="hs2019", created=1402170695, headers="(request-target) (created) host date content-type digest content-length", signature="KXUj1H3ZOhv3Nk4xlRLTn4bOMlMOmFiud3VXrMa9MaLCxnVmrqOX5BulRvB65YW/wQp0oT/nNQpXgOYeY8ovmHlpkRyz5buNDqoOpRsCpLGxsIJ9cX8XVsM9jy+Q1+RIlD9wfWoPHhqhoXt35ZkasuIDPF/AETuObs9QydlsqONwbK+TdQguDK/8Va1Pocl6wK1uLwqcXlxhPEb55EmdYB9pddDyHTADING7K4qMwof2mC3t8Pb0yoLZoZX5a4Or4FrCCKK/9BHAhq/RsVk0dTENMbTB4i7cHvKQu+o9xuYWuxyvBa0Z6NdOb0di70cdrSDEsL5Gz7LBY5J2N9KdGg=="', + }, + }; + it('verifies a request', async () => { + const verifierStub = stub().resolves(true); + const valid = await cavage.verifyMessage({ + verifier: verifierStub, + }, request); + expect(valid).to.equal(true); + expect(verifierStub).to.have.callCount(1); + expect(verifierStub).to.have.been.calledOnceWithExactly( + Buffer.from( + '(request-target): post /foo?param=value&pet=dog\n' + + '(created): 1402170695\n' + + 'host: example.com\n' + + 'date: Tue, 07 Jun 2014 20:51:35 GMT\n' + + 'content-type: application/json\n' + + 'digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + + 'content-length: 18', + ), + Buffer.from('KXUj1H3ZOhv3Nk4xlRLTn4bOMlMOmFiud3VXrMa9MaLCxnVmrqOX5BulRvB65YW/wQp0oT/nNQpXgOYeY8ovmHlpkRyz5buNDqoOpRsCpLGxsIJ9cX8XVsM9jy+Q1+RIlD9wfWoPHhqhoXt35ZkasuIDPF/AETuObs9QydlsqONwbK+TdQguDK/8Va1Pocl6wK1uLwqcXlxhPEb55EmdYB9pddDyHTADING7K4qMwof2mC3t8Pb0yoLZoZX5a4Or4FrCCKK/9BHAhq/RsVk0dTENMbTB4i7cHvKQu+o9xuYWuxyvBa0Z6NdOb0di70cdrSDEsL5Gz7LBY5J2N9KdGg==', 'base64'), + { + created: new Date(1402170695 * 1000), + keyid: 'test-key-a', + alg: 'rsa-pss-sha512', + }, + ); + }); + }); + describe('responses', () => { + const response: cavage.Response = { + status: 200, + headers: { + 'Date': 'Tue, 07 Jun 2014 20:51:35 GMT', + 'Content-Type': 'application/json', + 'Digest': 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=', + 'Content-Length': '18', + 'Signature': 'keyId="test-key-a", algorithm="hs2019", created=1402170695, headers="(created) date content-type digest content-length", signature="KXUj1H3ZOhv3Nk4xlRLTn4bOMlMOmFiud3VXrMa9MaLCxnVmrqOX5BulRvB65YW/wQp0oT/nNQpXgOYeY8ovmHlpkRyz5buNDqoOpRsCpLGxsIJ9cX8XVsM9jy+Q1+RIlD9wfWoPHhqhoXt35ZkasuIDPF/AETuObs9QydlsqONwbK+TdQguDK/8Va1Pocl6wK1uLwqcXlxhPEb55EmdYB9pddDyHTADING7K4qMwof2mC3t8Pb0yoLZoZX5a4Or4FrCCKK/9BHAhq/RsVk0dTENMbTB4i7cHvKQu+o9xuYWuxyvBa0Z6NdOb0di70cdrSDEsL5Gz7LBY5J2N9KdGg=="', + }, + }; + it('verifies a response', async () => { + const verifierStub = stub().resolves(true); + const result = await cavage.verifyMessage({ + verifier: verifierStub, + }, response); + expect(result).to.equal(true); + expect(verifierStub).to.have.callCount(1); + expect(verifierStub).to.have.been.calledOnceWithExactly( + Buffer.from( + '(created): 1402170695\n' + + 'date: Tue, 07 Jun 2014 20:51:35 GMT\n' + + 'content-type: application/json\n' + + 'digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + + 'content-length: 18', + ), + Buffer.from('KXUj1H3ZOhv3Nk4xlRLTn4bOMlMOmFiud3VXrMa9MaLCxnVmrqOX5BulRvB65YW/wQp0oT/nNQpXgOYeY8ovmHlpkRyz5buNDqoOpRsCpLGxsIJ9cX8XVsM9jy+Q1+RIlD9wfWoPHhqhoXt35ZkasuIDPF/AETuObs9QydlsqONwbK+TdQguDK/8Va1Pocl6wK1uLwqcXlxhPEb55EmdYB9pddDyHTADING7K4qMwof2mC3t8Pb0yoLZoZX5a4Or4FrCCKK/9BHAhq/RsVk0dTENMbTB4i7cHvKQu+o9xuYWuxyvBa0Z6NdOb0di70cdrSDEsL5Gz7LBY5J2N9KdGg==', 'base64'), + { + created: new Date(1402170695 * 1000), + keyid: 'test-key-a', + alg: 'rsa-pss-sha512', + }, + ); + }); + }); + }); +});