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..f124304 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,9 +6089,8 @@ "version": "0.1.3", "license": "Apache-2.0", "dependencies": { - "http-message-sig": "^0.2.0", - "jsonwebkey-thumbprint": "^0.1.0", - "structured-headers": "2.0.3" + "fetch-message-signatures": "^0.1.0", + "jsonwebkey-thumbprint": "^0.1.0" } } } diff --git a/packages/web-bot-auth/package.json b/packages/web-bot-auth/package.json index f60afba..ccac316 100644 --- a/packages/web-bot-auth/package.json +++ b/packages/web-bot-auth/package.json @@ -55,8 +55,7 @@ }, "homepage": "https://github.com/cloudflare/web-bot-auth#readme", "dependencies": { - "http-message-sig": "^0.2.0", - "jsonwebkey-thumbprint": "^0.1.0", - "structured-headers": "2.0.3" + "fetch-message-signatures": "^0.1.0", + "jsonwebkey-thumbprint": "^0.1.0" } } 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/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( 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"]);