diff --git a/httpsig/README.md b/httpsig/README.md index ace0f3e..3d5d29c 100644 --- a/httpsig/README.md +++ b/httpsig/README.md @@ -119,6 +119,15 @@ interface HttpSigFetchOptions extends RequestInit { label?: string // Signature label (default: 'sig') components?: string[] // Override default components + // Content-Digest coverage for requests with a body (default: 'auto') + // 'auto' - cover content-digest when the body's exact bytes are + // available to hash (string, Uint8Array, ArrayBuffer, Buffer); + // streaming bodies (ReadableStream, FormData, Blob) are + // signed without it + // 'require' - like 'auto', but throw on a body that cannot be digested + // 'omit' - never auto-append content-digest + contentDigest?: 'auto' | 'require' | 'omit' + // Testing mode dryRun?: boolean // Return headers without fetching (still returns Promise) } @@ -258,9 +267,13 @@ interface VerifyOptions { // JWKS caching jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000) - // AAuth profile enforcement - strictAAuth?: boolean // Enforce AAuth profile requirements (default: true) - // When true, requires signature-key in covered components + // Algorithms this verifier accepts (default: SUPPORTED_ALGORITHMS) + supportedAlgorithms?: SignatureAlgorithm[] + + // AAuth HTTPSig profile (Section 10.3) enforcement: when true, a request + // with a body fails verification unless the signature covers + // content-digest and the digest validates against the body + requireContentDigest?: boolean } ``` @@ -512,14 +525,23 @@ Signature-Input: sig=("@method" "@authority" "@path" "signature-key");created=17 Signature-Input: sig=("@method" "@authority" "@path" "content-type" "signature-key");created=1730217600 ``` -**Optional: Content-Digest** +**Content-Digest (automatic since 2.2.0)** -If you want body integrity verification, you can add `content-digest` to your components list. When included, the `content-digest` header is computed as: +Per the AAuth HTTPSig profile (Section 10.3), a request carrying a body to a +PS or AS endpoint MUST also cover `content-digest` (RFC 9530). `fetch()` +appends `content-digest` to the covered components automatically whenever the +body's exact bytes are available to hash — a string, Uint8Array, ArrayBuffer, +or Buffer. A body serialized by the fetch implementation (ReadableStream, +FormData, Blob) is signed without it; pass `contentDigest: 'require'` to +throw instead, or `contentDigest: 'omit'` to never auto-append. The header is +computed as: ``` Content-Digest: sha-256=:BASE64(SHA256(body)): ``` +A verifier enforces coverage with `requireContentDigest: true`. + ### Overriding Default Components You can override the default components using the `components` parameter. The library exports helpful constants: @@ -544,10 +566,10 @@ import { // ['@method', '@authority', '@path', 'content-type', 'signature-key'] ``` -**Example - Adding content-digest for body integrity:** +**Example - Custom components:** ```typescript -// Add content-digest if you need body integrity verification +// Add the date header to the covered components await fetch('https://api.example.com/data', { method: 'POST', headers: { @@ -563,8 +585,8 @@ await fetch('https://api.example.com/data', { '@path', 'date', // Include date header 'content-type', - 'content-digest', // Add for body integrity 'signature-key', + // content-digest is appended automatically for a digestible body ], }) ``` diff --git a/httpsig/package.json b/httpsig/package.json index cfab502..1ef16ae 100644 --- a/httpsig/package.json +++ b/httpsig/package.json @@ -1,6 +1,6 @@ { "name": "@hellocoop/httpsig", - "version": "2.1.0", + "version": "2.2.0", "description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support", "repository": { "type": "git", diff --git a/httpsig/src/fetch.ts b/httpsig/src/fetch.ts index 48fd1e9..314fb0b 100644 --- a/httpsig/src/fetch.ts +++ b/httpsig/src/fetch.ts @@ -61,6 +61,21 @@ function getContentTypeFromBody(body: any): string | null { return 'application/octet-stream' } +/** + * Whether generateContentDigest can hash this body: the digest must be + * computed over the exact bytes that go on the wire, so only bodies whose + * bytes are available here qualify. A ReadableStream, FormData, or Blob is + * serialized by the fetch implementation, not by us. + */ +function isDigestibleBody(body: any): boolean { + return ( + typeof body === 'string' || + body instanceof Uint8Array || + body instanceof ArrayBuffer || + Buffer.isBuffer(body) + ) +} + /** * Validate component names */ @@ -122,6 +137,7 @@ export async function fetch( signatureKey, label = 'sig', components: customComponents, + contentDigest = 'auto', dryRun = false, returnSent = false, method = 'GET', @@ -175,6 +191,25 @@ export async function fetch( : [...DEFAULT_COMPONENTS_GET] } + // Per AAuth Section 10.3, a request carrying a body MUST cover + // content-digest. Cover it whenever the body's exact bytes are available + // to hash. 'require' refuses to sign a body that cannot be digested, + // rather than sending a request the server must reject; 'omit' restores + // the pre-2.2 behavior for callers that opt out. + if (body !== undefined && body !== null && contentDigest !== 'omit') { + const digestible = isDigestibleBody(body) + if (!digestible && contentDigest === 'require') { + throw new Error( + 'contentDigest is "require" but the body cannot be digested: ' + + 'only string, Uint8Array, ArrayBuffer, and Buffer bodies ' + + 'have their exact bytes available to hash', + ) + } + if (digestible && !components.includes('content-digest')) { + components.push('content-digest') + } + } + const componentValues = new Map() // Handle body-related headers if body exists diff --git a/httpsig/src/types.ts b/httpsig/src/types.ts index 443a1de..8f70a16 100644 --- a/httpsig/src/types.ts +++ b/httpsig/src/types.ts @@ -66,6 +66,24 @@ export interface HttpSigFetchOptions extends RequestInit { label?: string // Signature label (default: 'sig') components?: string[] // Override default components + /** + * Content-Digest coverage for requests with a body, per the AAuth HTTPSig + * profile (Section 10.3): a request carrying a body to a PS or AS + * endpoint MUST cover `content-digest` (RFC 9530). + * + * - `'auto'` (default): cover `content-digest` when the body's exact + * bytes are available to hash here -- a string, Uint8Array, + * ArrayBuffer, or Buffer. A body whose bytes are produced by the fetch + * implementation (ReadableStream, FormData, Blob) is signed without it. + * - `'require'`: like `'auto'`, but throw on a body that cannot be + * digested instead of silently dropping the component. PS and AS + * callers use this: refusing to sign beats sending a request the + * server must reject. + * - `'omit'`: never auto-append `content-digest` (the pre-2.2 behavior). + * It is still covered when listed explicitly in `components`. + */ + contentDigest?: 'auto' | 'require' | 'omit' + // Testing mode dryRun?: boolean // Return headers without fetching (still returns Promise) @@ -122,6 +140,17 @@ export interface VerifyOptions { * for example to accept Ed25519 only, or to refuse RSASSA-PKCS1-v1_5. */ supportedAlgorithms?: SignatureAlgorithm[] + + /** + * When true, a request with a body fails verification unless the + * signature covers `content-digest` (and the digest validates against + * the body). This is how a PS or AS enforces the AAuth HTTPSig profile + * (Section 10.3) -- without it the digest is validated only when the + * signer chose to cover it, which enforces nothing. Resources are exempt + * from the profile rule and declare their needs via + * `additional_signature_components` in resource metadata. + */ + requireContentDigest?: boolean } // Note: the strictAAuth option was removed in 2.0. Covering `signature-key` is diff --git a/httpsig/src/utils/signature.ts b/httpsig/src/utils/signature.ts index 181c22d..fa8d72d 100644 --- a/httpsig/src/utils/signature.ts +++ b/httpsig/src/utils/signature.ts @@ -221,8 +221,15 @@ export async function generateContentDigest(body: BodyInit): Promise { } else if (Buffer.isBuffer(body)) { bytes = new Uint8Array(body) } else { - // For other types (ReadableStream, etc.), convert to string - bytes = new TextEncoder().encode(String(body)) + // Refuse other types (ReadableStream, FormData, Blob, ...). Falling + // through to String(body) here produced a valid signature over the + // SHA-256 of literal text like "[object ReadableStream]" -- bytes + // that never go on the wire and that no verifier can reproduce. + throw new Error( + `Cannot generate content-digest for body type: ${ + (body as any)?.constructor?.name ?? typeof body + }`, + ) } const hash = await sha256(bytes) diff --git a/httpsig/src/verify.ts b/httpsig/src/verify.ts index ef60adc..b76f85e 100644 --- a/httpsig/src/verify.ts +++ b/httpsig/src/verify.ts @@ -423,6 +423,7 @@ export async function verify( maxClockSkew = 60, jwksCacheTtl = 3600000, // 1 hour supportedAlgorithms, + requireContentDigest = false, } = options // The set this verifier accepts. Reported in Accept-Signature-Alg on an @@ -601,6 +602,29 @@ export async function verify( componentValues.set('@path', request.path) componentValues.set('@query', request.query || '') + // Enforce the AAuth HTTPSig profile (Section 10.3): a request with a + // body must cover content-digest. Opt-in, because only a PS or AS is + // bound by the profile rule -- and without this check the digest + // below is validated only when the signer chose to cover it, which + // enforces nothing. + if ( + requireContentDigest && + request.body !== undefined && + !components.includes('content-digest') + ) { + throw invalidInput( + 'content-digest must be a covered component on a request with a body', + [ + '@method', + '@authority', + '@path', + 'content-digest', + 'content-type', + 'signature-key', + ], + ) + } + // Validate content-digest if body is present if ( request.body !== undefined && diff --git a/httpsig/tests/test-content-digest.ts b/httpsig/tests/test-content-digest.ts new file mode 100644 index 0000000..f1f6346 --- /dev/null +++ b/httpsig/tests/test-content-digest.ts @@ -0,0 +1,463 @@ +/** + * Tests for content-digest coverage per the AAuth HTTPSig profile (Section 10.3) + * + * A request carrying a body to a PS or AS endpoint MUST cover content-digest + * (RFC 9530). The signer covers it automatically whenever the body's exact + * bytes are available to hash (contentDigest: 'auto', the default), refuses + * to sign a non-digestible body under 'require', and leaves it off under + * 'omit'. The verifier enforces coverage with requireContentDigest. + */ + +import { test } from 'node:test' +import assert from 'node:assert' +import { fetch, verify } from '../src/index.js' +import { generateContentDigest } from '../src/utils/signature.js' + +/** + * Generate an Ed25519 key pair as JWK + */ +async function generateEd25519KeyPair() { + const keyPair = (await crypto.subtle.generateKey( + { + name: 'Ed25519', + }, + true, + ['sign', 'verify'], + )) as CryptoKeyPair + + const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) + + // alg is REQUIRED on a JWK; WebCrypto does not set it. + privateJwk.alg = 'Ed25519' + publicJwk.alg = 'Ed25519' + + return { privateJwk, publicJwk } +} + +test('auto: string body is covered by content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = JSON.stringify({ foo: 'bar' }) + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok( + headers.get('content-digest'), + 'content-digest header should be set', + ) + assert.ok( + headers.get('signature-input')!.includes('"content-digest"'), + 'signature-input should cover content-digest', + ) + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body, + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, true, 'Signature should verify') +}) + +test('auto: Uint8Array body is covered by content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = new TextEncoder().encode('binary payload') + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok(headers.get('content-digest')) + assert.ok(headers.get('signature-input')!.includes('"content-digest"')) + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body, + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, true) +}) + +test('auto: ArrayBuffer body is covered by content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const bytes = new TextEncoder().encode('buffer payload') + const body = bytes.buffer.slice(0, bytes.byteLength) as ArrayBuffer + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok(headers.get('content-digest')) + assert.ok(headers.get('signature-input')!.includes('"content-digest"')) + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body: new Uint8Array(body), + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, true) +}) + +test('auto: Buffer body is covered by content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = Buffer.from('node buffer payload') + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok(headers.get('content-digest')) + assert.ok(headers.get('signature-input')!.includes('"content-digest"')) + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body, + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, true) +}) + +test('auto: ReadableStream body is signed without content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('streamed')) + controller.close() + }, + }) + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.strictEqual( + headers.get('content-digest'), + null, + 'content-digest header should not be set for a stream', + ) + assert.ok( + !headers.get('signature-input')!.includes('content-digest'), + 'signature-input should not cover content-digest for a stream', + ) +}) + +test('auto: FormData body is signed without content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = new FormData() + body.append('field', 'value') + + // FormData gets no content-type here (the fetch implementation generates + // the multipart boundary), so the default body components cannot apply. + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + components: ['@method', '@authority', '@path', 'signature-key'], + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.strictEqual(headers.get('content-digest'), null) + assert.ok(!headers.get('signature-input')!.includes('content-digest')) +}) + +test('auto: Blob body is signed without content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = new Blob(['blob payload'], { type: 'text/plain' }) + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.strictEqual(headers.get('content-digest'), null) + assert.ok(!headers.get('signature-input')!.includes('content-digest')) +}) + +test('require: digestible body is covered by content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = 'payload' + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + contentDigest: 'require', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok(headers.get('content-digest')) + assert.ok(headers.get('signature-input')!.includes('"content-digest"')) +}) + +test('require: non-digestible bodies throw', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + const formData = new FormData() + formData.append('field', 'value') + const blob = new Blob(['blob payload'], { type: 'text/plain' }) + + for (const body of [stream, formData, blob]) { + await assert.rejects( + fetch('https://api.example.com/data', { + method: 'POST', + body, + contentDigest: 'require', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + }), + /cannot be digested/, + `${body.constructor.name} should be refused under 'require'`, + ) + } +}) + +test('omit: string body is signed without content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = 'payload' + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + contentDigest: 'omit', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.strictEqual( + headers.get('content-digest'), + null, + 'omit should not add content-digest', + ) + assert.ok(!headers.get('signature-input')!.includes('content-digest')) + + // Pre-2.2 behavior: the signature still verifies when the verifier does + // not require coverage. + const result = await verify({ + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body, + }) + assert.strictEqual(result.verified, true) +}) + +test('omit: explicit content-digest component is still covered', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = 'payload' + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + contentDigest: 'omit', + components: [ + '@method', + '@authority', + '@path', + 'content-type', + 'content-digest', + 'signature-key', + ], + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + assert.ok( + headers.get('content-digest'), + 'explicitly listed content-digest should still be generated', + ) + assert.ok(headers.get('signature-input')!.includes('"content-digest"')) +}) + +test('generateContentDigest: hashes the four digestible types', async () => { + const text = 'digest me' + const expected = await generateContentDigest(text) + + assert.match(expected, /^sha-256=:[A-Za-z0-9+/=]+:$/) + + const bytes = new TextEncoder().encode(text) + assert.strictEqual(await generateContentDigest(bytes), expected) + assert.strictEqual( + await generateContentDigest( + bytes.buffer.slice(0, bytes.byteLength) as ArrayBuffer, + ), + expected, + ) + assert.strictEqual(await generateContentDigest(Buffer.from(text)), expected) +}) + +test('generateContentDigest: throws on unhandled body types', async () => { + // Before 2.2.0 these fell through to String(body), producing a valid + // signature over the SHA-256 of literal text like + // "[object ReadableStream]" -- bytes that never go on the wire. + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + const formData = new FormData() + const blob = new Blob(['x']) + + for (const body of [stream, formData, blob]) { + await assert.rejects( + generateContentDigest(body as any), + /Cannot generate content-digest/, + `${body.constructor.name} should be refused`, + ) + } +}) + +test('requireContentDigest: fails when signature does not cover content-digest', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const body = 'payload' + + // Sign without content-digest coverage + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body, + contentDigest: 'omit', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body, + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, false) + assert.strictEqual(result.signatureError?.error, 'invalid_input') + assert.ok( + result.signatureError?.required_input?.includes('content-digest'), + 'required_input should name content-digest', + ) +}) + +test('requireContentDigest: fails when the digest does not match the body', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'POST', + body: 'original body', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + const result = await verify( + { + method: 'POST', + path: '/data', + authority: 'api.example.com', + headers, + body: 'tampered body', + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, false) + assert.ok( + result.error?.includes('content-digest'), + 'error should name content-digest', + ) +}) + +test('requireContentDigest: passes on a request without a body', async () => { + const { privateJwk } = await generateEd25519KeyPair() + + const { headers } = (await fetch('https://api.example.com/data', { + method: 'GET', + signingKey: privateJwk, + signatureKey: { type: 'hwk' }, + dryRun: true, + })) as { headers: Headers } + + const result = await verify( + { + method: 'GET', + path: '/data', + authority: 'api.example.com', + headers, + }, + { requireContentDigest: true }, + ) + + assert.strictEqual(result.verified, true) +})