From 292c263c2cc44e26eb55ee6489620fa5108f5cb6 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 8 Aug 2026 15:59:22 +0200 Subject: [PATCH 1/2] Replace http-message-sig with fetch-message-signatures in web-bot-auth The packages/http-message-sig package is unchanged, and is still built, tested and published from this repo. This is a breaking change for consumers of web-bot-auth. Signer is now a key identifier paired with a signer factory rather than a sign callback, and verify() takes a verifier factory in place of Verify, which is removed along with SignerSync, SignOptions and SignSyncOptions. Directory.schema becomes optional. Every other export keeps its name, including signatureHeadersSync, directoryResponseHeaders, MediaType, Tag and HTTP_MESSAGE_SIGNATURES_DIRECTORY. One behaviour changes. RSA keys are validated for their digest as well as their algorithm, so verifier() rejects an RSA-PSS key created for SHA-256 rather than verifying with SHA-256 under a signature naming rsa-pss-sha512. Covered component matching is unchanged: both rules still match a field or derived component name whatever parameters the identifier carries. signatureHeadersSync composes createSignatureBase() and createSignatureFields(), neither of which returns a Promise. The IETF draft vectors now assert its Signature-Input byte for byte alongside the asynchronous path. --- examples/browser-extension/src/background.ts | 57 ++- examples/verification-workers/src/index.ts | 49 +-- package-lock.json | 37 +- packages/web-bot-auth/package.json | 2 +- packages/web-bot-auth/src/consts.ts | 10 + packages/web-bot-auth/src/crypto.ts | 225 +++++------- packages/web-bot-auth/src/directory.ts | 64 ++++ packages/web-bot-auth/src/index.ts | 352 +++++++++++-------- packages/web-bot-auth/test/index.test.ts | 26 ++ 9 files changed, 438 insertions(+), 384 deletions(-) create mode 100644 packages/web-bot-auth/src/consts.ts create mode 100644 packages/web-bot-auth/src/directory.ts diff --git a/examples/browser-extension/src/background.ts b/examples/browser-extension/src/background.ts index 2181ee7..db57ac3 100644 --- a/examples/browser-extension/src/background.ts +++ b/examples/browser-extension/src/background.ts @@ -1,5 +1,6 @@ import { Algorithm, + KeyedSigner, signatureHeadersSync, helpers, jwkToKeyID, @@ -18,39 +19,37 @@ const MAX_AGE_IN_MS = 1000 * 60 * 60; // 1 hour const SIGNATURE_AGENT = "https://http-message-signatures-example.research.cloudflare.com"; -class Ed25519Signer { - public alg: Algorithm = "ed25519"; - public keyid: string; - private privateKey: Uint8Array; +// libsodium signs synchronously, which is what a blocking listener needs. A signer may return the +// signature directly rather than a Promise of it, so no wrapper is involved. +function ed25519Signer(jwk: JsonWebKey): KeyedSigner { + const sodium = _sodium; - constructor(public jwk: JsonWebKey) { - const sodium = _sodium; + // Base64URL decode helper + const base64urlDecode = (str) => + sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING); - // Base64URL decode helper - const base64urlDecode = (str) => - sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING); + // Decode keys + const privateKey = base64urlDecode(jwk.d); // 32 bytes + const publicKey = base64urlDecode(jwk.x); // 32 bytes - // Decode keys - const privateKey = base64urlDecode(jwk.d); // 32 bytes - const publicKey = base64urlDecode(jwk.x); // 32 bytes - - // Build the full 64-byte secret key: privateKey || publicKey - const fullSecretKey = new Uint8Array(64); - fullSecretKey.set(privateKey); - fullSecretKey.set(publicKey, 32); - - this.privateKey = fullSecretKey; + // Build the full 64-byte secret key: privateKey || publicKey + const fullSecretKey = new Uint8Array(64); + fullSecretKey.set(privateKey); + fullSecretKey.set(publicKey, 32); + const alg: Algorithm = "ed25519"; + return { + alg, // NOTE: this MUST be computed from the public key bytes. It just so happen Chrome does not easily allow to perform a sha256 synchronously - this.keyid = KEY_ID; - } - - signSync(data: string): Uint8Array { - const sodium = _sodium; - const message = sodium.from_string(data); - const signedMessage = sodium.crypto_sign(message, this.privateKey); - return signedMessage.slice(0, sodium.crypto_sign_BYTES); - } + keyid: KEY_ID, + signer: () => ({ + alg, + sign: (data) => + sodium + .crypto_sign(data, fullSecretKey) + .slice(0, sodium.crypto_sign_BYTES), + }), + }; } chrome.webRequest.onBeforeSendHeaders.addListener( @@ -66,7 +65,7 @@ chrome.webRequest.onBeforeSendHeaders.addListener( headers: details.requestHeaders?.map((h) => [h.name, h.value!])!, }); const now = new Date(); - const headers = signatureHeadersSync(request, new Ed25519Signer(jwk), { + const headers = signatureHeadersSync(request, ed25519Signer(jwk), { components: recommendedComponents("sig1"), created: now, expires: new Date(now.getTime() + MAX_AGE_IN_MS), diff --git a/examples/verification-workers/src/index.ts b/examples/verification-workers/src/index.ts index 7cdda10..7b96cd8 100644 --- a/examples/verification-workers/src/index.ts +++ b/examples/verification-workers/src/index.ts @@ -19,7 +19,7 @@ import { Signer, SignatureAgentCard, SignatureAgentEntry, - VerificationParams, + VerifierFactory, directoryResponseHeaders, helpers, jwkToKeyID, @@ -34,7 +34,7 @@ import { generateDebugHTML } from "./debug-html"; import { invalidHTML, neutralHTML, validHTML } from "./index-html"; import { proxyDirectoryRequest } from "./proxy-directory"; import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" }; -import { Ed25519Signer } from "web-bot-auth/crypto"; +import { signerFromJWK, verifier } from "web-bot-auth/crypto"; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -164,39 +164,22 @@ async function fetchDirectory(entry: SignatureAgentEntry): Promise { } async function getSigner(): Promise { - return Ed25519Signer.fromJWK(jwk); + return signerFromJWK(jwk); } -function verifyEd25519( - directory: Directory -): ( - data: string, - signature: Uint8Array, - params: VerificationParams -) => Promise { - return async (data, signature, _params) => { - void _params; - const key = await crypto.subtle.importKey( - "jwk", - directory.keys[0], - { name: "Ed25519" }, - true, - ["verify"] - ); - - const encodedData = new TextEncoder().encode(data); - - const isValid = await crypto.subtle.verify( - { name: "Ed25519" }, - key, - signature, - encodedData - ); - - if (!isValid) { - throw new Error("invalid signature"); - } - }; +function verifyEd25519(directory: Directory): VerifierFactory { + // Awaited inside the factory, which may return a Promise so that key material can be resolved + // per signature rather than once up front. + return async (signature, context) => + verifier( + await crypto.subtle.importKey( + "jwk", + directory.keys[0], + { name: "Ed25519" }, + true, + ["verify"] + ) + )(signature, context); } const SignatureValidationStatus = { diff --git a/package-lock.json b/package-lock.json index f999dac..6cc782d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -250,7 +250,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -268,7 +267,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -286,7 +284,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -304,7 +301,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -322,7 +318,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -340,7 +335,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -358,7 +352,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -376,7 +369,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -394,7 +386,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -412,7 +403,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -430,7 +420,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -448,7 +437,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -466,7 +454,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -484,7 +471,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -502,7 +488,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -520,7 +505,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -538,7 +522,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -556,7 +539,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -574,7 +556,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -592,7 +573,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -610,7 +590,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -628,7 +607,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -646,7 +624,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -664,7 +641,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -682,7 +658,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -700,7 +675,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3544,6 +3518,15 @@ } } }, + "node_modules/fetch-message-signatures": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fetch-message-signatures/-/fetch-message-signatures-0.1.0.tgz", + "integrity": "sha512-BleqSnx2FHEoaOzQdgzrdaAkKZjJJGSwrNE8xvVEZzTNwvt1vhwnv1bNCxZTrE9H70xRq2KHFcuLfBO5ToKihA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -6106,7 +6089,7 @@ "version": "0.1.3", "license": "Apache-2.0", "dependencies": { - "http-message-sig": "^0.2.0", + "fetch-message-signatures": "^0.1.0", "jsonwebkey-thumbprint": "^0.1.0", "structured-headers": "2.0.3" } diff --git a/packages/web-bot-auth/package.json b/packages/web-bot-auth/package.json index f60afba..2adda42 100644 --- a/packages/web-bot-auth/package.json +++ b/packages/web-bot-auth/package.json @@ -55,7 +55,7 @@ }, "homepage": "https://github.com/cloudflare/web-bot-auth#readme", "dependencies": { - "http-message-sig": "^0.2.0", + "fetch-message-signatures": "^0.1.0", "jsonwebkey-thumbprint": "^0.1.0", "structured-headers": "2.0.3" } diff --git a/packages/web-bot-auth/src/consts.ts b/packages/web-bot-auth/src/consts.ts new file mode 100644 index 0000000..db1c6b3 --- /dev/null +++ b/packages/web-bot-auth/src/consts.ts @@ -0,0 +1,10 @@ +export const HTTP_MESSAGE_SIGNATURES_DIRECTORY = + "/.well-known/http-message-signatures-directory"; + +export enum MediaType { + HTTP_MESSAGE_SIGNATURES_DIRECTORY = "application/http-message-signatures-directory+json", +} + +export enum Tag { + HTTP_MESSAGE_SIGNAGURES_DIRECTORY = "http-message-signatures-directory", +} diff --git a/packages/web-bot-auth/src/crypto.ts b/packages/web-bot-auth/src/crypto.ts index dcbe47a..5a35a48 100644 --- a/packages/web-bot-auth/src/crypto.ts +++ b/packages/web-bot-auth/src/crypto.ts @@ -1,7 +1,6 @@ -import { type Algorithm, type Signer } from "http-message-sig"; +import * as FetchSig from "fetch-message-signatures"; import { jwkThumbprint as jwkToKeyID } from "jsonwebkey-thumbprint"; import { b64ToB64NoPadding, b64ToB64URL, u8ToB64 } from "./base64"; -import type { VerificationParams, Verify } from "./index"; export const helpers = { WEBCRYPTO_SHA256: (b: BufferSource) => crypto.subtle.digest("SHA-256", b), @@ -9,174 +8,118 @@ export const helpers = { b64ToB64URL(b64ToB64NoPadding(u8ToB64(new Uint8Array(u)))), }; -export class Ed25519Signer implements Signer { - public alg: Algorithm = "ed25519"; - public keyid: string; - private privateKey: CryptoKey; - - constructor(keyid: string, privateKey: CryptoKey) { - this.keyid = keyid; - this.privateKey = privateKey; - } - - static async fromJWK(jwk: JsonWebKey): Promise { - const key = await crypto.subtle.importKey( - "jwk", - jwk, - { name: "Ed25519" }, - true, - ["sign"] - ); - const keyid = await jwkToKeyID( - jwk, - helpers.WEBCRYPTO_SHA256, - helpers.BASE64URL_DECODE - ); - return new Ed25519Signer(keyid, key); - } - - async sign(data: string): Promise { - const message = new TextEncoder().encode(data); - const signature = await crypto.subtle.sign( - "ed25519", - this.privateKey, - message - ); - return new Uint8Array(signature); - } +/** A signer paired with the key identifier its signatures carry. */ +export interface KeyedSigner { + readonly alg: string; + readonly keyid: string; + readonly signer: FetchSig.SignerFactory; } -export class RSAPSSSHA512Signer implements Signer { - public alg: Algorithm = "rsa-pss-sha512"; - public keyid: string; - private privateKey: CryptoKey; - - constructor(keyid: string, privateKey: CryptoKey) { - this.keyid = keyid; - this.privateKey = privateKey; - } +async function keyIdFor(jwk: JsonWebKey): Promise { + return jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE); +} - static async fromJWK(jwk: JsonWebKey): Promise { - const key = await crypto.subtle.importKey( - "jwk", - jwk, - // restricting to RSA-PSS with SHA-512 as other SHA- algorithms are not registered - { name: "RSA-PSS", hash: { name: "SHA-512" } }, - true, - ["sign"] - ); - const keyid = await jwkToKeyID( - jwk, - helpers.WEBCRYPTO_SHA256, - helpers.BASE64URL_DECODE - ); - return new RSAPSSSHA512Signer(keyid, key); - } +export async function Ed25519Signer( + keyid: string, + privateKey: CryptoKey +): Promise { + return { alg: "ed25519", keyid, signer: FetchSig.ed25519Signer(privateKey) }; +} - async sign(data: string): Promise { - const message = new TextEncoder().encode(data); - const signature = await crypto.subtle.sign( - { name: "RSA-PSS", saltLength: 64 }, - this.privateKey, - message - ); - return new Uint8Array(signature); - } +export async function RSAPSSSHA512Signer( + keyid: string, + privateKey: CryptoKey +): Promise { + return { + alg: "rsa-pss-sha512", + keyid, + signer: FetchSig.rsaPssSha512Signer(privateKey), + }; } -export function signerFromJWK(jwk: JsonWebKey): Promise { +export async function signerFromJWK(jwk: JsonWebKey): Promise { switch (jwk.kty) { - case "OKP": - if (jwk.crv === "Ed25519") { - return Ed25519Signer.fromJWK(jwk); + case "OKP": { + if (jwk.crv !== "Ed25519") { + throw new Error(`Unsupported curve: ${jwk.crv}`); } - throw new Error(`Unsupported curve: ${jwk.crv}`); - case "RSA": - // Per RFC7517, the alg field is optional for RSA keys - // However, it's safer to check and mandate it - // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 - if (jwk.alg === "PS512") { - return RSAPSSSHA512Signer.fromJWK(jwk); + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "Ed25519" }, + true, + ["sign"] + ); + return Ed25519Signer(await keyIdFor(jwk), key); + } + case "RSA": { + if (jwk.alg !== "PS512") { + throw new Error(`Unsupported algorithm: ${jwk.alg}`); } - throw new Error(`Unsupported algorithm: ${jwk.alg}`); + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSA-PSS", hash: { name: "SHA-512" } }, + true, + ["sign"] + ); + return RSAPSSSHA512Signer(await keyIdFor(jwk), key); + } default: throw new Error(`Unsupported key type: ${jwk.kty}`); } } -export function verifier( - key: CryptoKey -): ( - data: string, - signature: Uint8Array, - params: VerificationParams -) => Promise { - return async ( - data: string, - signature: Uint8Array, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - params: VerificationParams - ) => { - const encodedData = new TextEncoder().encode(data); - - const cryptoParams: Parameters[0] = - key.algorithm; - switch (key.algorithm.name) { - case "Ed25519": - break; - case "RSA-PSS": - cryptoParams["saltLength"] = 64; - break; - default: - throw new Error(`Unsupported algorithm: ${key.algorithm.name}`); - } - - const isValid = await crypto.subtle.verify( - cryptoParams, - key, - signature, - encodedData - ); - - if (!isValid) { - throw new Error("invalid signature"); - } - }; +/** + * Builds a verifier factory bound to one already-trusted key. + * + * Synchronous, so it can be composed inside a caller's own factory without an await. + */ +export function verifier(key: CryptoKey): FetchSig.SynchronousVerifierFactory { + switch (key.algorithm.name) { + case "Ed25519": + return FetchSig.ed25519Verifier(key); + case "RSA-PSS": + return FetchSig.rsaPssSha512Verifier(key); + default: + throw new Error(`Unsupported algorithm: ${key.algorithm.name}`); + } } -export async function verifierFromJWK(jwk: JsonWebKey): Promise> { - let key: CryptoKey; +export async function verifierFromJWK( + jwk: JsonWebKey +): Promise { switch (jwk.kty) { - case "OKP": - if (jwk.crv === "Ed25519") { - key = await crypto.subtle.importKey( + case "OKP": { + if (jwk.crv !== "Ed25519") { + throw new Error(`Unsupported curve: ${jwk.crv}`); + } + // Only the public members, because the vectors carry private keys. + return verifier( + await crypto.subtle.importKey( "jwk", { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, { name: "Ed25519" }, true, ["verify"] - ); - break; + ) + ); + } + case "RSA": { + if (jwk.alg !== "PS512") { + throw new Error(`Unsupported algorithm: ${jwk.alg}`); } - throw new Error(`Unsupported curve: ${jwk.crv}`); - case "RSA": - // Per RFC7517, the alg field is optional for RSA keys - // However, it's safer to check and mandate it - // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 - if (jwk.alg === "PS512") { - key = await crypto.subtle.importKey( + return verifier( + await crypto.subtle.importKey( "jwk", { kty: jwk.kty, e: jwk.e, n: jwk.n }, - // restricting to RSA-PSS with SHA-512 as other SHA- algorithms are not registered { name: "RSA-PSS", hash: { name: "SHA-512" } }, true, ["verify"] - ); - break; - } - throw new Error(`Unsupported algorithm: ${jwk.alg}`); + ) + ); + } default: throw new Error(`Unsupported key type: ${jwk.kty}`); } - return verifier(key); } diff --git a/packages/web-bot-auth/src/directory.ts b/packages/web-bot-auth/src/directory.ts new file mode 100644 index 0000000..fc5c6ff --- /dev/null +++ b/packages/web-bot-auth/src/directory.ts @@ -0,0 +1,64 @@ +import * as FetchSig from "fetch-message-signatures"; + +import { Tag } from "./consts"; +import type { KeyedSigner } from "./crypto"; +import type { SignatureHeaders } from "./index"; + +/** + * A directory response is signed over the authority of the request that asked for it, so `;req` + * binds the covered value to the related request rather than the response. + */ +export const RESPONSE_COMPONENTS: FetchSig.ComponentIdentifier[] = [ + FetchSig.component("@authority", { req: true }), +]; + +export interface SignatureParams { + created: Date; + expires: Date; +} + +/** + * Signs a directory response once per key and returns the combined fields. + * + * Each signature is appended under its own label, which is how RFC 9421 Section 4.3 carries more + * than one signature on a message. + */ +export async function directoryResponseHeaders( + message: { request: Request; response: Response }, + signers: KeyedSigner[], + params: SignatureParams +): Promise { + if (params.created.getTime() > params.expires.getTime()) { + throw new Error("created should happen before expires"); + } + + const seen = new Set(); + let headers = new Headers(); + + for (const [index, signer] of signers.entries()) { + if (seen.has(signer.keyid)) { + throw new Error(`Duplicated signer with keyid ${signer.keyid}`); + } + seen.add(signer.keyid); + + const fields = await FetchSig.createSignature(message.response, { + request: message.request, + signer: signer.signer, + components: RESPONSE_COMPONENTS, + parameters: [ + ["created", params.created], + ["keyid", signer.keyid], + ["alg", signer.alg], + ["expires", params.expires], + ["tag", Tag.HTTP_MESSAGE_SIGNAGURES_DIRECTORY], + ], + label: `binding${index}`, + }); + headers = FetchSig.appendSignature(headers, fields); + } + + return { + "Signature-Input": headers.get("signature-input") ?? "", + Signature: headers.get("signature") ?? "", + }; +} diff --git a/packages/web-bot-auth/src/index.ts b/packages/web-bot-auth/src/index.ts index 8be5a08..7debb59 100644 --- a/packages/web-bot-auth/src/index.ts +++ b/packages/web-bot-auth/src/index.ts @@ -1,45 +1,59 @@ -import * as httpsig from "http-message-sig"; -export { - HTTP_MESSAGE_SIGNATURES_DIRECTORY, - type Algorithm, - MediaType, - type SignatureHeaders, - type Signer, - type SignerSync, - type SignOptions, - type SignSyncOptions, - Tag, - directoryResponseHeaders, -} from "http-message-sig"; +import * as FetchSig from "fetch-message-signatures"; export { jwkThumbprint as jwkToKeyID } from "jsonwebkey-thumbprint"; import { b64Tou8, u8ToB64 } from "./base64"; +import type { KeyedSigner } from "./crypto"; export { helpers } from "./crypto"; +export { HTTP_MESSAGE_SIGNATURES_DIRECTORY, MediaType, Tag } from "./consts"; +export { directoryResponseHeaders, RESPONSE_COMPONENTS } from "./directory"; + +// The registry these identifiers come from is extensible, so this covers the initial entries only. +export type Algorithm = + | "rsa-pss-sha512" + | "rsa-v1_5-sha256" + | "hmac-sha256" + | "ecdsa-p256-sha256" + | "ecdsa-p384-sha384" + | "ed25519"; + +export type { KeyedSigner, KeyedSigner as Signer } from "./crypto"; + +// The recipient contract callers have to satisfy, re-exported so that a consumer of verify() does +// not have to depend on fetch-message-signatures directly. +export type { + MessageSignature, + SynchronousVerifierFactory, + VerifierFactory, +} from "fetch-message-signatures"; export const HTTP_MESSAGE_SIGNATURE_TAG = "web-bot-auth"; export const SIGNATURE_AGENT_HEADER = "signature-agent"; -export const REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT: httpsig.Component[] = [ - "@authority", -]; -export const REQUEST_COMPONENTS: httpsig.Component[] = [ +export const REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT: FetchSig.ComponentIdentifier[] = + ["@authority"]; +export const REQUEST_COMPONENTS: FetchSig.ComponentIdentifier[] = [ "@authority", SIGNATURE_AGENT_HEADER, ]; export const NONCE_LENGTH_IN_BYTES = 64; +export interface SignatureHeaders { + "Signature-Input": string; + Signature: string; +} + export interface SignatureParams { created: Date; expires: Date; nonce?: string; key?: string; - components?: httpsig.Component[]; + components?: FetchSig.ComponentIdentifier[]; } export interface VerificationParams { keyid: string; created: Date; expires: Date; - tag: typeof HTTP_MESSAGE_SIGNATURE_TAG; + tag: string; nonce?: string; } @@ -59,173 +73,205 @@ export function validateNonce(nonce: string): boolean { export function recommendedComponents( signatureAgentKey?: string -): httpsig.Component[] { +): FetchSig.ComponentIdentifier[] { if (signatureAgentKey) { return [ "@authority", - { header: SIGNATURE_AGENT_HEADER, key: signatureAgentKey }, + FetchSig.component(SIGNATURE_AGENT_HEADER, { key: signatureAgentKey }), ]; } return ["@authority"]; } -function getSigningOptions< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, +function signatureAgentOf(message: Request | Response): string | null { + return message.headers.get(SIGNATURE_AGENT_HEADER); +} + +function signingComponents( + message: Request | Response, params: SignatureParams -): Omit { +): FetchSig.ComponentIdentifier[] { + const signatureAgent = signatureAgentOf(message); + if (!params.components) { + return signatureAgent + ? REQUEST_COMPONENTS + : REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT; + } + // findComponents rather than includesComponent: recommendedComponents() produces + // `"signature-agent";key="sig1"`, and the rule is about the field being bound at all. + if ( + signatureAgent && + FetchSig.findComponents(params.components, SIGNATURE_AGENT_HEADER) + .length === 0 + ) { + throw new Error( + `${SIGNATURE_AGENT_HEADER} is required in params.components when included as a header param` + ); + } + return params.components; +} + +interface ResolvedParams { + components: FetchSig.ComponentIdentifier[]; + label: string; + parameters: FetchSig.SignatureParameters; +} + +function resolveParams( + message: Request | Response, + signer: KeyedSigner, + params: SignatureParams +): ResolvedParams { if (params.created.getTime() > params.expires.getTime()) { throw new Error("created should happen before expires"); } - // Nonce should be a base64 encoded 64-byte array. We should check it let nonce = params.nonce; if (!nonce) { nonce = generateNonce(); - } else { - if (!validateNonce(nonce)) { - throw new Error("nonce is not a valid uint32"); - } - } - const signatureAgent = httpsig.extractHeader( - httpsig.resolveMessageKind(message), - SIGNATURE_AGENT_HEADER - ); - let components: httpsig.Component[]; - if (!params.components) { - // `extractHeader` returns "" instead of throwing or null when the header does not exist - if (!signatureAgent) { - components = REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT; - } else { - components = REQUEST_COMPONENTS; - } - } else { - if ( - signatureAgent && - !params.components.some((c) => { - if (typeof c === "string") { - return c === SIGNATURE_AGENT_HEADER; - } - if ("header" in c) { - return c.header === SIGNATURE_AGENT_HEADER; - } - return c.name === SIGNATURE_AGENT_HEADER; - }) - ) { - throw new Error( - `${SIGNATURE_AGENT_HEADER} is required in params.components when included as a header param` - ); - } - components = params.components; + } else if (!validateNonce(nonce)) { + throw new Error("nonce is not a valid uint32"); } return { - components, - created: params.created, - expires: params.expires, - nonce, - key: params.key, - tag: HTTP_MESSAGE_SIGNATURE_TAG, + components: signingComponents(message, params), + label: params.key ?? "sig1", + // Ordered, because RFC 9421 covers parameter order in the signature base. + parameters: [ + ["created", params.created], + ["keyid", signer.keyid], + ["alg", signer.alg], + ["expires", params.expires], + ["nonce", nonce], + ["tag", HTTP_MESSAGE_SIGNATURE_TAG], + ], }; } -export function signatureHeaders< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, - signer: httpsig.Signer, +export function signatureHeaders( + message: Request | Response, + signer: KeyedSigner, params: SignatureParams -): Promise { - return httpsig.signatureHeaders(message, { - signer, - keyid: signer.keyid, - ...getSigningOptions(message, params), - }); +): Promise { + // Resolved here so invalid arguments throw rather than rejecting. + const resolved = resolveParams(message, signer, params); + + return createFields(message, signer, resolved); } -export function signatureHeadersSync< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, - signer: httpsig.SignerSync, +/** + * The synchronous counterpart of {@link signatureHeaders}, for callers that cannot await. + * + * A blocking `chrome.webRequest.onBeforeSendHeaders` listener is the motivating case: it has to + * return the modified headers synchronously. createSignatureBase() and createSignatureFields() are + * the two halves of createSignature(), neither of which returns a Promise, so the same components + * and parameters go to both. + * + * The signer must be synchronous. Web Cryptography is not, so this needs a signer backed by a + * synchronous library. + */ +export function signatureHeadersSync( + message: Request | Response, + signer: KeyedSigner, params: SignatureParams -): httpsig.SignatureHeaders { - return httpsig.signatureHeadersSync(message, { +): SignatureHeaders { + const { components, label, parameters } = resolveParams( + message, signer, - keyid: signer.keyid, - ...getSigningOptions(message, params), + params + ); + + const base = FetchSig.createSignatureBase(message, { + components, + parameters, }); + const signature = signer.signer().sign(new TextEncoder().encode(base)); + if (!(signature instanceof Uint8Array)) { + throw new Error("signer is not synchronous"); + } + + const fields = FetchSig.createSignatureFields({ + signature, + components, + parameters, + label, + }); + + return { + "Signature-Input": fields.signatureInput, + Signature: fields.signatureField, + }; } -export type Verify = ( - data: string, - signature: Uint8Array, - params: VerificationParams -) => T | Promise; - -export function verify( - message: - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, - verifier: Verify -): Promise { - const signatureAgent = httpsig.extractHeader( - httpsig.resolveMessageKind(message), - SIGNATURE_AGENT_HEADER - ); - const v = ( - data: string, - signature: Uint8Array, - params: httpsig.Parameters, - components: httpsig.Component[] - ): T | Promise => { - if (params.tag !== HTTP_MESSAGE_SIGNATURE_TAG) { - throw new Error(`tag must be '${HTTP_MESSAGE_SIGNATURE_TAG}'`); - } - if (params.created.getTime() > Date.now()) { - throw new Error("created in the future"); - } - if (params.expires.getTime() < Date.now()) { - throw new Error("signature has expired"); - } - if (params.keyid === undefined) { - throw new Error("keyid MUST be defined"); - } - // A signature that covers no request target can be replayed against any - // endpoint. Require @authority or @target-uri, and signature-agent whenever - // the header is present. Mirrors crates/web-bot-auth/src/lib.rs. - const covered = components.map((c) => - (typeof c === "string" - ? c - : "header" in c - ? c.header - : c.name - ).toLowerCase() - ); - if (!covered.includes("@authority") && !covered.includes("@target-uri")) { - throw new Error("signature must cover @authority or @target-uri"); - } - if (signatureAgent && !covered.includes(SIGNATURE_AGENT_HEADER)) { - throw new Error( - `signature with ${SIGNATURE_AGENT_HEADER} header must cover ${SIGNATURE_AGENT_HEADER}` - ); - } - const vparams: VerificationParams = { - keyid: params.keyid, - created: params.created, - expires: params.expires, - tag: params.tag, - nonce: params.nonce, - }; - return verifier(data, signature, vparams); +async function createFields( + message: Request | Response, + signer: KeyedSigner, + resolved: ResolvedParams +): Promise { + const fields = await FetchSig.createSignature(message, { + signer: signer.signer, + components: resolved.components, + parameters: resolved.parameters, + label: resolved.label, + }); + + return { + "Signature-Input": fields.signatureInput, + Signature: fields.signatureField, }; - return httpsig.verify(message, v); } -export interface Directory extends httpsig.Directory { +export async function verify( + message: Request | Response, + verifier: FetchSig.VerifierFactory, + request?: Request +): Promise { + const signatureAgent = signatureAgentOf(message); + + await FetchSig.verify(message, { + verifier, + request, + policy: { + requiredComponents: [], + requiredParameters: ["keyid", "created", "expires", "tag"], + algorithms: ["ed25519", "rsa-pss-sha512"], + validate(signature) { + if ( + FetchSig.getSignatureParameter(signature, "tag") !== + HTTP_MESSAGE_SIGNATURE_TAG + ) { + throw new Error(`tag must be '${HTTP_MESSAGE_SIGNATURE_TAG}'`); + } + // A signature that covers no request target can be replayed against any + // endpoint. Require @authority or @target-uri, and signature-agent + // whenever the header is present. + // + // findComponents rather than includesComponent, because a response + // signature binds the request target as `"@authority";req`, which is a + // different identifier but the same rule. + const covered = signature.components; + if ( + FetchSig.findComponents(covered, "@authority").length === 0 && + FetchSig.findComponents(covered, "@target-uri").length === 0 + ) { + throw new Error("signature must cover @authority or @target-uri"); + } + if ( + signatureAgent && + FetchSig.findComponents(covered, SIGNATURE_AGENT_HEADER).length === 0 + ) { + throw new Error( + `signature with ${SIGNATURE_AGENT_HEADER} header must cover ${SIGNATURE_AGENT_HEADER}` + ); + } + }, + }, + }); +} + +export interface Directory { + keys: JsonWebKey[]; purpose: string; + schema?: string; } export { diff --git a/packages/web-bot-auth/test/index.test.ts b/packages/web-bot-auth/test/index.test.ts index 164b8b2..97438da 100644 --- a/packages/web-bot-auth/test/index.test.ts +++ b/packages/web-bot-auth/test/index.test.ts @@ -3,12 +3,14 @@ import { generateNonce, REQUEST_COMPONENTS, signatureHeaders, + signatureHeadersSync, validateNonce, NONCE_LENGTH_IN_BYTES, SIGNATURE_AGENT_HEADER, verify, recommendedComponents, } from "../src/index"; +import type { KeyedSigner } from "../src/crypto"; import { signerFromJWK, verifierFromJWK } from "../src/crypto"; import { b64Tou8, u8ToB64 } from "../src/base64"; @@ -41,6 +43,30 @@ describe.each(vectors)("Web-bot-auth-ed25519-Vector-%#", (v: Vectors) => { expect(signedHeaders["Signature-Input"]).toBe(v.signature_input); + // The synchronous path composes createSignatureBase() instead of createSignature(), so it has + // to reproduce the same Signature-Input. Only the signer differs: Web Crypto cannot sign + // synchronously, so a stub stands in for the bytes. + const syncSigner: KeyedSigner = { + alg: signer.alg, + keyid: signer.keyid, + signer: () => ({ + alg: signer.alg, + sign: (data) => new Uint8Array(data.length), + }), + }; + const syncHeaders = signatureHeadersSync(request, syncSigner, { + components: Object.hasOwnProperty.call(v, "signature_agent_key") + ? recommendedComponents(v["signature_agent_key"]) + : v.signature_agent + ? ["@authority", "signature-agent"] + : recommendedComponents(), + created: new Date(v.created_ms), + expires: new Date(v.expires_ms), + nonce: v.nonce, + key: v.label, + }); + expect(syncHeaders["Signature-Input"]).toBe(v.signature_input); + // Appending signed header to the request, given that's what the origin receives headers.append("Signature", signedHeaders["Signature"]); headers.append("Signature-Input", signedHeaders["Signature-Input"]); From e669545b1dfa099afea9633b21ac214b0c3f55f7 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 8 Aug 2026 15:59:52 +0200 Subject: [PATCH 2/2] Parse Signature-Agent with the RFC 9651 parser fetch-message-signatures exports the Structured Fields parser RFC 9421 is built on, so the separate structured-headers dependency is no longer needed here. packages/http-message-sig keeps its own. Values come back tagged rather than as raw JavaScript, so discoveryType reads a Token's text instead of relying on String() to produce it. --- package-lock.json | 3 +- packages/web-bot-auth/package.json | 3 +- packages/web-bot-auth/src/registry.ts | 45 ++++++++++++++++++--------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6cc782d..f124304 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6090,8 +6090,7 @@ "license": "Apache-2.0", "dependencies": { "fetch-message-signatures": "^0.1.0", - "jsonwebkey-thumbprint": "^0.1.0", - "structured-headers": "2.0.3" + "jsonwebkey-thumbprint": "^0.1.0" } } } diff --git a/packages/web-bot-auth/package.json b/packages/web-bot-auth/package.json index 2adda42..ccac316 100644 --- a/packages/web-bot-auth/package.json +++ b/packages/web-bot-auth/package.json @@ -56,7 +56,6 @@ "homepage": "https://github.com/cloudflare/web-bot-auth#readme", "dependencies": { "fetch-message-signatures": "^0.1.0", - "jsonwebkey-thumbprint": "^0.1.0", - "structured-headers": "2.0.3" + "jsonwebkey-thumbprint": "^0.1.0" } } diff --git a/packages/web-bot-auth/src/registry.ts b/packages/web-bot-auth/src/registry.ts index 8789672..14d023d 100644 --- a/packages/web-bot-auth/src/registry.ts +++ b/packages/web-bot-auth/src/registry.ts @@ -1,4 +1,5 @@ -import { parseDictionary, parseItem } from "structured-headers"; +import { parseStructuredField } from "fetch-message-signatures"; +import type { StructuredFieldBareItem } from "fetch-message-signatures"; export type SignatureAgentDiscoveryType = "directory" | "jwks_uri" | "cimd"; @@ -133,8 +134,21 @@ function triggerValue(value: unknown): "fetcher" | "crawler" | undefined { throw new Error("trigger must be fetcher or crawler"); } -function discoveryType(value: unknown): SignatureAgentDiscoveryType { - const typeName = value === undefined ? "directory" : String(value); +/** The text of a bare item that names a discovery type, which is a Token in practice. */ +function bareItemText(value: StructuredFieldBareItem): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "object" && value !== null && "type" in value) { + return String(value.value); + } + return String(value); +} + +function discoveryType( + value: StructuredFieldBareItem | undefined +): SignatureAgentDiscoveryType { + const typeName = value === undefined ? "directory" : bareItemText(value); if (typeName === "directory") { return "directory"; } @@ -161,33 +175,34 @@ export function parseSignatureAgentHeader( header: string ): SignatureAgentHeader { try { - const dictionary = parseDictionary(header); - if (dictionary.size === 0) { + const dictionary = parseStructuredField(header, "dictionary"); + if (dictionary.length === 0) { throw new Error("Signature-Agent header must not be empty"); } const entries: SignatureAgentEntry[] = []; - for (const [label, value] of dictionary) { - const [uri, params] = value; - if (typeof uri !== "string") { + for (const [label, member] of dictionary) { + if (member.type !== "item" || typeof member.value !== "string") { throw new Error("Signature-Agent values must be strings"); } - const type = discoveryType(params.get("type")); - validateDiscoveryURI(uri, type); - entries.push({ label, uri, type }); + const type = discoveryType( + member.parameters.find(([name]) => name === "type")?.[1] + ); + validateDiscoveryURI(member.value, type); + entries.push({ label, uri: member.value, type }); } return { kind: "current", entries }; } catch (dictionaryError) { try { - const [uri] = parseItem(header); - if (typeof uri !== "string") { + const item = parseStructuredField(header, "item"); + if (typeof item.value !== "string") { throw new Error("legacy Signature-Agent must be a string", { cause: dictionaryError, }); } - validateDiscoveryURI(uri, "directory"); + validateDiscoveryURI(item.value, "directory"); return { kind: "legacy", - entries: [{ label: "", uri, type: "directory" }], + entries: [{ label: "", uri: item.value, type: "directory" }], }; } catch (itemError) { throw new Error(