diff --git a/examples/browser-extension/src/background.ts b/examples/browser-extension/src/background.ts index 2181ee7..03b433a 100644 --- a/examples/browser-extension/src/background.ts +++ b/examples/browser-extension/src/background.ts @@ -1,33 +1,38 @@ -import { - Algorithm, - signatureHeadersSync, - helpers, - jwkToKeyID, - recommendedComponents, -} from "web-bot-auth"; +import { jwkToKeyID, signSync, type WebBotSignerSync } from "web-bot-auth"; import _sodium from "libsodium-wrappers"; import jwk from "../../rfc9421-keys/ed25519.json" assert { type: "json" }; -// THIS IS DETERMINISTIC AND BASED ON THE KEY MATERIAL -let KEY_ID = "not-set-yet"; -jwkToKeyID(jwk, helpers.WEBCRYPTO_SHA256, helpers.BASE64URL_DECODE).then( - (kid) => (KEY_ID = kid) +function base64Url(buffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +await _sodium.ready; +const KEY_ID = await jwkToKeyID( + jwk, + (value) => crypto.subtle.digest("SHA-256", value), + base64Url ); 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; +class Ed25519Signer implements WebBotSignerSync { + public readonly algorithm = "ed25519"; + public readonly keyid = KEY_ID; private privateKey: Uint8Array; - constructor(public jwk: JsonWebKey) { + constructor(jwk: JsonWebKey) { const sodium = _sodium; + if (jwk.d === undefined || jwk.x === undefined) { + throw new Error("Ed25519 JWK must contain d and x"); + } // Base64URL decode helper - const base64urlDecode = (str) => + const base64urlDecode = (str: string) => sodium.from_base64(str, sodium.base64_variants.URLSAFE_NO_PADDING); // Decode keys @@ -40,15 +45,11 @@ class Ed25519Signer { fullSecretKey.set(publicKey, 32); this.privateKey = fullSecretKey; - - // 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 { + sign(data: Uint8Array): Uint8Array { const sodium = _sodium; - const message = sodium.from_string(data); - const signedMessage = sodium.crypto_sign(message, this.privateKey); + const signedMessage = sodium.crypto_sign(data, this.privateKey); return signedMessage.slice(0, sodium.crypto_sign_BYTES); } } @@ -60,25 +61,28 @@ chrome.webRequest.onBeforeSendHeaders.addListener( value: `sig1="${SIGNATURE_AGENT}";type=directory`, }); + const headers = new Headers(); + for (const header of details.requestHeaders ?? []) { + if (header.value !== undefined) headers.append(header.name, header.value); + } const request = new Request(details.url, { method: details.method, - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - headers: details.requestHeaders?.map((h) => [h.name, h.value!])!, + headers, }); const now = new Date(); - const headers = signatureHeadersSync(request, new Ed25519Signer(jwk), { - components: recommendedComponents("sig1"), + const signature = signSync(request, { + signer: new Ed25519Signer(jwk), created: now, expires: new Date(now.getTime() + MAX_AGE_IN_MS), }); details.requestHeaders?.push({ name: "Signature", - value: headers["Signature"], + value: signature.signature, }); details.requestHeaders?.push({ name: "Signature-Input", - value: headers["Signature-Input"], + value: signature.signatureInput, }); return { requestHeaders: details.requestHeaders }; diff --git a/examples/verification-workers/src/index.ts b/examples/verification-workers/src/index.ts index 7cdda10..cca1482 100644 --- a/examples/verification-workers/src/index.ts +++ b/examples/verification-workers/src/index.ts @@ -13,28 +13,35 @@ // limitations under the License. import { - Directory, HTTP_MESSAGE_SIGNATURES_DIRECTORY, - MediaType, - Signer, SignatureAgentCard, SignatureAgentEntry, - VerificationParams, - directoryResponseHeaders, - helpers, - jwkToKeyID, parseRegistry, parseSignatureAgentCard, - parseSignatureAgentHeader, - recommendedComponents, - signatureHeaders, + sign, verify, + type WebBotSigner, } from "web-bot-auth"; +import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto"; +import { + component, + createSignature, + type FieldOccurrence, + type RequestDescriptor, + type ResponseDescriptor, +} from "http-message-sig"; 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"; + +const DIRECTORY_MEDIA_TYPE = + "application/http-message-signatures-directory+json"; + +interface Directory { + readonly keys: readonly JsonWebKey[]; + readonly purpose: string; +} function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -57,6 +64,7 @@ function jsonWebKeyFromUnknown(value: unknown): JsonWebKey { d: typeof value.d === "string" ? value.d : undefined, e: typeof value.e === "string" ? value.e : undefined, kty: value.kty, + kid: typeof value.kid === "string" ? value.kid : undefined, n: typeof value.n === "string" ? value.n : undefined, x: typeof value.x === "string" ? value.x : undefined, y: typeof value.y === "string" ? value.y : undefined, @@ -87,12 +95,9 @@ function directoryFromUnknown(value: unknown): Directory { } async function getExampleDirectory(): Promise { + const signer = await getSigner(); const key = { - kid: await jwkToKeyID( - jwk, - helpers.WEBCRYPTO_SHA256, - helpers.BASE64URL_DECODE - ), + kid: signer.keyid, kty: jwk.kty, crv: jwk.crv, x: jwk.x, @@ -115,14 +120,21 @@ function getSignatureAgentCard(env: Env): SignatureAgentCard { client_uri: origin, logo_uri: `${origin}/favicon.png`, contacts: [], - "expected-user-agent": "Mozilla/5.0 ExampleBot", - "rfc9309-product-token": "ExampleBot", - "rfc9309-compliance": ["User-Agent", "Allow", "Disallow", "Content-Usage"], - trigger: "fetcher", - purpose: "example", - "rate-control": "429", jwks_uri: `${origin}${HTTP_MESSAGE_SIGNATURES_DIRECTORY}`, ips_uri: `${origin}/ips.json`, + web_bot_auth: { + "expected-user-agent": "Mozilla/5.0 ExampleBot", + "rfc9309-product-token": "ExampleBot", + "rfc9309-compliance": [ + "User-Agent", + "Allow", + "Disallow", + "Content-Usage", + ], + trigger: "fetcher", + purpose: "example", + "rate-control": "429", + }, }); } @@ -163,40 +175,64 @@ async function fetchDirectory(entry: SignatureAgentEntry): Promise { return directoryFromUnknown(await fetchJSON(card.jwks_uri)); } -async function getSigner(): Promise { - return Ed25519Signer.fromJWK(jwk); +async function getSigner(): Promise { + 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"] - ); +async function resolveVerifier(directory: Directory, keyid: string) { + const key = directory.keys.find((candidate) => candidate.kid === keyid); + if (key === undefined) throw new Error(`unknown key ${keyid}`); + return verifierFromJWK(key); +} - const encodedData = new TextEncoder().encode(data); +function fields(headers: Headers): FieldOccurrence[] { + const output: FieldOccurrence[] = []; + headers.forEach((value, name) => output.push({ name, value })); + return output; +} - const isValid = await crypto.subtle.verify( - { name: "Ed25519" }, - key, - signature, - encodedData - ); +function base64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} - if (!isValid) { - throw new Error("invalid signature"); - } +async function signDirectoryResponse( + request: Request, + response: Response, + signer: WebBotSigner +) { + const digest = await crypto.subtle.digest( + "SHA-256", + await response.clone().arrayBuffer() + ); + response.headers.set( + "content-digest", + `sha-256=:${base64(new Uint8Array(digest))}:` + ); + const requestDescriptor: RequestDescriptor = { + kind: "request", + method: request.method, + targetUri: request.url, + fields: fields(request.headers), + }; + const responseDescriptor: ResponseDescriptor = { + kind: "response", + status: response.status, + fields: fields(response.headers), + request: requestDescriptor, }; + const created = Math.floor(Date.now() / 1000); + return createSignature(responseDescriptor, { + label: "binding0", + signer, + components: [component("@authority", { req: true }), "content-digest"], + parameters: { + created, + expires: created + 300, + keyid: signer.keyid, + alg: signer.algorithm, + tag: "http-message-signatures-directory", + }, + }); } const SignatureValidationStatus = { @@ -214,35 +250,25 @@ async function verifySignature( return SignatureValidationStatus.NEUTRAL; } - const signatureAgent = request.headers.get("Signature-Agent"); - let directory: Directory; - try { - if (signatureAgent) { - const parsed = parseSignatureAgentHeader(signatureAgent); - const entry = parsed.entries[0]; - if (entry === undefined) { - throw new Error("Signature-Agent header has no entries"); - } - if (new URL(entry.uri).origin === new URL(env.SIGNATURE_AGENT).origin) { - directory = await getExampleDirectory(); - } else { - directory = await fetchDirectory(entry); - } - } else { - directory = await getExampleDirectory(); - } - } catch (e) { - return SignatureValidationStatus.INVALID(errorMessage(e)); - } - try { - await verify(request, verifyEd25519(directory)); + await verify(request, { + async resolver(candidate) { + const entry = candidate.signatureAgent; + const directory = + entry === undefined || + new URL(entry.uri).origin === new URL(env.SIGNATURE_AGENT).origin + ? await getExampleDirectory() + : await fetchDirectory(entry); + return resolveVerifier(directory, candidate.keyid); + }, + }); } catch (e) { return SignatureValidationStatus.INVALID(errorMessage(e)); } console.log("Signature verified successfully"); - if (signatureAgent) { + const signatureAgent = request.headers.get("Signature-Agent"); + if (signatureAgent !== null) { console.log(`Signature-Agent: "${signatureAgent}"`); } @@ -273,17 +299,17 @@ export default { const directory = await getExampleDirectory(); const response = new Response(JSON.stringify(directory), { headers: { - "content-type": MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY, + "content-type": DIRECTORY_MEDIA_TYPE, }, }); - const signedHeaders = await directoryResponseHeaders( - { request, response }, - [await getSigner()], - { created: new Date(), expires: new Date(Date.now() + 300_000) } + const signedHeaders = await signDirectoryResponse( + request, + response, + await getSigner() ); - response.headers.set("Signature", signedHeaders.Signature); - response.headers.set("Signature-Input", signedHeaders["Signature-Input"]); + response.headers.set("Signature", signedHeaders.signature); + response.headers.set("Signature-Input", signedHeaders.signatureInput); return response; } @@ -324,15 +350,16 @@ export default { const request = new Request(env.TARGET_URL, { headers }); const created = new Date(ctx.scheduledTime); const expires = new Date(created.getTime() + 300_000); - const signedHeaders = await signatureHeaders(request, await getSigner(), { - components: recommendedComponents("sig1"), + const signedHeaders = await sign(request, { + signer: await getSigner(), created, expires, }); await fetch( new Request(request.url, { headers: { - ...signedHeaders, + Signature: signedHeaders.signature, + "Signature-Input": signedHeaders.signatureInput, ...headers, }, }) diff --git a/package-lock.json b/package-lock.json index 57d21d1..3795acd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5710,7 +5710,7 @@ } }, "packages/http-message-sig": { - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "structured-headers": "2.0.3" @@ -5721,10 +5721,10 @@ "license": "Apache-2.0" }, "packages/web-bot-auth": { - "version": "0.1.3", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "http-message-sig": "^0.2.0", + "http-message-sig": "^0.3.0", "jsonwebkey-thumbprint": "^0.1.0", "structured-headers": "2.0.3" } diff --git a/packages/http-message-sig/README.md b/packages/http-message-sig/README.md index 76f1c61..37045f6 100644 --- a/packages/http-message-sig/README.md +++ b/packages/http-message-sig/README.md @@ -1,48 +1,95 @@ # http-message-sig -![License](https://img.shields.io/npm/l/http-message-sig.svg) -[![crates.io](https://img.shields.io/npm/v/http-message-sig.svg)][npm] +Core [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) HTTP Message +Signatures for TypeScript. + +## Capabilities + +| Capability | Support | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| Native `Request` and `Response` | Yes, using Fetch-observable values | +| Final request/response descriptors | Yes, with ordered field occurrences and optional trailers | +| Related request components (`req`) | Yes, on response descriptors | +| Ordinary field components | Yes | +| Structured Field dictionary member (`key`) | Yes, strict RFC 8941 parsing | +| Derived components | `@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@status` | +| Multiple signatures | Yes | +| Async signing and verification | Yes | +| Synchronous signing | Yes | +| Explicit verification policy | Required | +| WebCrypto providers | Ed25519 and RSA-PSS with SHA-512 | + +## Signing -[npm]: https://www.npmjs.com/package/http-message-sig - -HTTP Message Signatures defined by [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html). - -Forked from [ltonetwork/http-message-signatures](https://github.com/ltonetwork/http-message-signatures). +```typescript +import { appendSignature, createSignature } from "http-message-sig"; -## Tables of Content +const fields = await createSignature(request, { + label: "sig1", + components: ["@method", "@authority", "@path", "content-digest"], + parameters: { created: 1_700_000_000, alg: signer.algorithm, keyid: "key-1" }, + signer, +}); -- [Features](#features) -- [Usage](#usage) -- [Security Considerations](#security-considerations) -- [License](#license) +const headers = appendSignature(request.headers, fields); +``` -## Features +Signers consume UTF-8 signature-base bytes and expose `readonly algorithm`. +`appendSignature` accepts `Headers` only, returns a clone, and atomically parses +and merges both signature dictionaries. -- HTTP Message Signatures constructions -- Signing synchoronously and asynchronously -- Verifying synchronously and asynchronously -- TypeScript types +Use `component("example-dict", { key: "member" })` for parameterized +components. `componentIdentity` returns the exact serialized component +identifier. -## Usage +## Verification ```typescript -import { sign, verify } from "http-message-sig"; +import { verifySignature } from "http-message-sig"; + +const verified = await verifySignature(request, { + policy: { + algorithms: ["ed25519"], + requiredComponents: ["@method", "@authority", "@path"], + requiredParameters: ["created", "keyid"], + maxAge: 300, + clockSkew: 5, + }, + resolveVerifier(untrustedCandidate, context) { + return lookupVerifier(untrustedCandidate.parameters.keyid, context); + }, +}); ``` -## Security Considerations +The resolver candidate is untrusted until cryptographic verification succeeds. +The optional policy `validate` callback receives authenticated data and runs +only after successful cryptographic verification. -This software has not been audited. Please use at your sole discretion. +## Limitations -## License +- `sf`, `bs`, `tr`, `@query-param`, and unknown derived components are rejected + with `UnsupportedFeature`. +- RFC 9651 dates and display strings are rejected in signature fields. Signature + metadata is limited to RFC 8941 bare items. +- Fetch does not expose raw field occurrences or trailers. Use descriptors when + those values matter. +- `Response` has no related request. Use a response descriptor for `req`. +- Cryptographic algorithms and key discovery are supplied by the caller. +- The former directory helpers and draft-era `signatureHeaders`, `verify`, and + `RequestLike` APIs were removed. Directory behavior belongs in the consuming + package and must migrate to `createSignature` plus `appendSignature`. + +## Errors -This project is under the Apache-2.0 license. +Library validation failures use `SignatureError`, stable `SignatureErrorCode` +values, and `isSignatureError`. -### Contribution +## Security -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be Apache-2.0 licensed as above, without any additional terms or conditions. +This software has not been audited. Applications must define policy appropriate +to their protocol, including algorithm, component, parameter, and freshness +requirements. -### Forks +## License -This project is forked from [ltonetwork/http-message-signatures](https://github.com/ltonetwork/http-message-signatures). -It has been forked to allow for customization and extension of the library's functionality. -It is may be rewritten from scratch down the line, as the original project is not fully implementing the RFC. +Apache-2.0. diff --git a/packages/http-message-sig/package.json b/packages/http-message-sig/package.json index 34f898d..02d264d 100644 --- a/packages/http-message-sig/package.json +++ b/packages/http-message-sig/package.json @@ -1,6 +1,6 @@ { "name": "http-message-sig", - "version": "0.2.0", + "version": "0.3.0", "description": "HTTP Message Signatures defined by RFC 9421", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/http-message-sig/src/build.ts b/packages/http-message-sig/src/build.ts deleted file mode 100644 index 0a2a047..0000000 --- a/packages/http-message-sig/src/build.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - Component, - ComponentParameters, - ComponentWithParameters, - Parameters, - RequestLike, - ResponseLike, - ResponseRequestPair, - StructuredFieldDictionaryComponent, -} from "./types"; -import { - isInnerList, - parseDictionary, - serializeInnerList, - serializeItem, -} from "structured-headers"; - -/** - * Extract a value from a dictionary-style header by key. - * - * The selected member value is serialized per RFC 8941, as required by - * RFC 9421 section 2.1.2. - */ -export function extractStructuredFieldDictionaryHeader( - r: RequestLike | ResponseLike, - component: StructuredFieldDictionaryComponent -): string { - const headerValue = extractHeader(r, component.header); - if (!headerValue) return headerValue; - - const dictionary = parseDictionary(headerValue); - const item = dictionary.get(component.key); - if (!item) { - throw new Error( - `Header ${component.header} does not contain dictionary key ${component.key}` - ); - } - - return isInnerList(item) ? serializeInnerList(item) : serializeItem(item); -} - -export function extractHeader( - { headers }: RequestLike | ResponseLike, - header: string -): string { - if (typeof headers.get === "function") return headers.get(header) ?? ""; - - const lcHeader = header.toLowerCase(); - const key = Object.keys(headers).find( - (name) => name.toLowerCase() === lcHeader - ); - // eslint-disable-next-line security/detect-object-injection - let val = key ? (headers[key] ?? "") : ""; - if (Array.isArray(val)) { - val = val.join(", "); - } - return val.toString().replace(/\s+/g, " "); -} - -export function getUrl( - message: RequestLike | ResponseLike, - component: string -): URL { - if ("url" in message && "protocol" in message) { - const host = extractHeader(message, "host"); - const protocol = message.protocol || "http"; - const baseUrl = `${protocol}://${host}`; - return new URL(message.url, baseUrl); - } - if (!(message as RequestLike).url) - throw new Error(`${component} is only valid for requests`); - return new URL((message as RequestLike).url); -} - -// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 -export function extractComponent( - message: RequestLike | ResponseLike, - component: string -): string { - switch (component) { - case "@method": - if (!(message as RequestLike).method) - throw new Error(`${component} is only valid for requests`); - return (message as RequestLike).method.toUpperCase(); - case "@target-uri": - if (!(message as RequestLike).url) - throw new Error(`${component} is only valid for requests`); - return (message as RequestLike).url; - case "@authority": - // URL.host omits only the scheme's default port, per RFC 9421 section 2.2.3. - return getUrl(message, component).host; - case "@scheme": - return getUrl(message, component).protocol.slice(0, -1); - case "@request-target": { - const { pathname, search } = getUrl(message, component); - return `${pathname}${search}`; - } - case "@path": - return getUrl(message, component).pathname; - case "@query": - return getUrl(message, component).search; - case "@status": - if (!(message as ResponseLike).status) - throw new Error(`${component} is only valid for responses`); - return (message as ResponseLike).status.toString(); - case "@query-params": - throw new Error(`${component} is not implemented yet`); - default: - throw new Error(`Unknown specialty component ${component}`); - } -} - -export function isStructuredFieldDictionaryComponent( - component: Component -): component is StructuredFieldDictionaryComponent { - return typeof component === "object" && "header" in component; -} - -function structuredFieldComponentParameters( - cwp: StructuredFieldDictionaryComponent -): ComponentParameters { - if (!cwp.parameters) { - return new Map([["key", cwp.key]]); - } - - const key = cwp.parameters.get("key"); - if (key === cwp.key) { - return cwp.parameters; - } - - if (key !== undefined) { - throw new Error( - `Structured field component key mismatch ${key.toString()} !== ${cwp.key}` - ); - } - - return new Map([["key", cwp.key], ...cwp.parameters]); -} - -export function serializeComponent(cwp: Component): string { - if (typeof cwp === "string") { - return `"${cwp.toLowerCase()}"`; - } - - if (isStructuredFieldDictionaryComponent(cwp)) { - const parameters = structuredFieldComponentParameters(cwp); - return serializeItem(`${cwp.header.toLowerCase()}`, parameters); - } - - return serializeItem(`${cwp.name.toLowerCase()}`, cwp.parameters); -} - -export function isRawMessage( - message: RequestLike | ResponseLike | ResponseRequestPair -): message is RequestLike | ResponseLike { - return ( - (message as ResponseRequestPair).response === undefined && - (message as ResponseRequestPair).request === undefined - ); -} - -export function componentHasParameters( - component: Component -): component is ComponentWithParameters | StructuredFieldDictionaryComponent { - return ( - typeof component === "object" && - "parameters" in component && - component.parameters !== undefined - ); -} - -export function resolveMessageKind( - message: RequestLike | ResponseLike | ResponseRequestPair, - cwp?: Component -): RequestLike | ResponseLike { - let requiresReq = false; - if (cwp !== undefined && componentHasParameters(cwp)) { - requiresReq = cwp.parameters.has("req"); - } - - if (isRawMessage(message)) { - if (requiresReq) { - throw new Error( - "`req` component parameter can only be used with ResponseRequestPair message types" - ); - } - - return message; - } - - if (requiresReq) { - return message.request; - } - - return message.response; -} - -export function buildSignatureInputString( - componentNames: Component[], - parameters: Parameters -): string { - const components = componentNames.map(serializeComponent).join(" "); - const values = Object.entries(parameters) - .map(([parameter, value]) => { - if (typeof value === "number") return `;${parameter}=${value}`; - if (value instanceof Date) - return `;${parameter}=${Math.floor(value.getTime() / 1000)}`; - return `;${parameter}="${value.toString()}"`; - }) - .join(""); - - return `(${components})${values}`; -} - -export function buildSignedData( - message: RequestLike | ResponseLike | ResponseRequestPair, - components: Component[], - signatureInputString: string -): string { - const parts = components.map((component) => { - const messageToUse = resolveMessageKind(message, component); - let value: string; - - if (typeof component === "string") { - value = component.startsWith("@") - ? extractComponent(messageToUse, component) - : extractHeader(messageToUse, component); - } else if (isStructuredFieldDictionaryComponent(component)) { - value = extractStructuredFieldDictionaryHeader(messageToUse, component); - } else { - const componentName = component.name; - value = componentName.startsWith("@") - ? extractComponent(messageToUse, componentName) - : extractHeader(messageToUse, componentName); - } - - return `${serializeComponent(component)}: ${value}`; - }); - parts.push(`"@signature-params": ${signatureInputString}`); - return parts.join("\n"); -} diff --git a/packages/http-message-sig/src/consts.ts b/packages/http-message-sig/src/consts.ts deleted file mode 100644 index db1c6b3..0000000 --- a/packages/http-message-sig/src/consts.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/http-message-sig/src/core.ts b/packages/http-message-sig/src/core.ts new file mode 100644 index 0000000..0fd8c14 --- /dev/null +++ b/packages/http-message-sig/src/core.ts @@ -0,0 +1,1107 @@ +import { + DisplayString, + isInnerList, + parseDictionary, + serializeDictionary, + serializeInnerList, + serializeItem, + type BareItem, + type Dictionary, + type InnerList, + type Item, + type Parameters, +} from "structured-headers"; +import { SignatureError, SignatureErrorCode } from "./errors"; +import type { + ComponentDescriptor, + ComponentParameters, + CreateSignatureOptions, + CreateSignatureSyncOptions, + FieldOccurrence, + RequestDescriptor, + Rfc8941BareItem, + SignatureComponent, + SignatureFields, + SignatureMessage, + SignatureParameters, + UntrustedSignatureCandidate, + VerifiedSignature, + Verifier, + VerificationPolicy, + VerifySignatureOptions, +} from "./types"; + +const encoder = new TextEncoder(); +const fieldNamePattern = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const sfKeyPattern = /^[a-z*][a-z0-9_.*-]*$/; +const derivedComponents = new Set([ + "@method", + "@target-uri", + "@authority", + "@scheme", + "@request-target", + "@path", + "@query", + "@status", +]); +const unsupportedParameters = new Set(["sf", "bs", "tr"]); + +interface RequestSnapshot { + readonly kind: "request"; + readonly method: string; + readonly targetUri: string; + readonly requestTarget?: string; + readonly fields: readonly FieldOccurrence[]; + readonly trailers: readonly FieldOccurrence[]; +} + +interface ResponseSnapshot { + readonly kind: "response"; + readonly status: number; + readonly fields: readonly FieldOccurrence[]; + readonly trailers: readonly FieldOccurrence[]; + readonly request?: RequestSnapshot; +} + +type MessageSnapshot = RequestSnapshot | ResponseSnapshot; + +interface ParsedSignatureInput { + readonly value: InnerList; + readonly components: readonly ComponentDescriptor[]; + readonly parameters: SignatureParameters; +} + +function fail( + code: (typeof SignatureErrorCode)[keyof typeof SignatureErrorCode], + message: string, + cause?: unknown +): never { + throw new SignatureError(code, message, cause); +} + +function cloneBareItem(value: Rfc8941BareItem): Rfc8941BareItem { + if (value instanceof ArrayBuffer) return value.slice(0); + return value; +} + +function validateParameterName(name: string): void { + if (!sfKeyPattern.test(name)) { + fail(SignatureErrorCode.MalformedField, `Invalid parameter name ${name}`); + } +} + +function validateSerializableBareItem( + value: Rfc8941BareItem, + location: string +): void { + try { + serializeItem(value); + } catch (error) { + fail(SignatureErrorCode.MalformedField, `Invalid ${location}`, error); + } +} + +function isRfc8941BareItem(value: BareItem): value is Rfc8941BareItem { + return !(value instanceof Date) && !(value instanceof DisplayString); +} + +function validateBareItem(value: BareItem, location: string): Rfc8941BareItem { + if (!isRfc8941BareItem(value)) { + return fail( + SignatureErrorCode.UnsupportedFeature, + `${location} uses an RFC 9651-only value` + ); + } + return cloneBareItem(value); +} + +function copyParameters( + input: Readonly<{ readonly [name: string]: Rfc8941BareItem | undefined }> +): SignatureParameters { + const output: Record = {}; + for (const [name, value] of Object.entries(input)) { + validateParameterName(name); + if (value !== undefined) { + validateSerializableBareItem(value, `parameter ${name}`); + output[name] = cloneBareItem(value); + } + } + validateKnownParameters(output); + return Object.freeze(output); +} + +function validateKnownParameters( + parameters: Readonly> +): void { + for (const name of ["created", "expires"]) { + const value = parameters[name]; + if ( + value !== undefined && + (!Number.isInteger(value) || typeof value !== "number") + ) { + fail(SignatureErrorCode.MalformedField, `${name} must be an integer`); + } + } + for (const name of ["nonce", "alg", "keyid", "tag"]) { + const value = parameters[name]; + if (value !== undefined && typeof value !== "string") { + fail(SignatureErrorCode.MalformedField, `${name} must be a string`); + } + } +} + +function parametersToMap(parameters: SignatureParameters): Parameters { + const output: Parameters = new Map(); + for (const [name, value] of Object.entries(parameters)) { + if (value !== undefined) output.set(name, cloneBareItem(value)); + } + return output; +} + +function parametersFromMap(parameters: Parameters): SignatureParameters { + const output: Record = {}; + for (const [name, value] of parameters) { + output[name] = validateBareItem(value, `Parameter ${name}`); + } + validateKnownParameters(output); + return Object.freeze(output); +} + +export function component( + name: string, + parameters: ComponentParameters = {} +): ComponentDescriptor { + const normalizedName = normalizeComponentName(name); + const copied = copyComponentParameters(parameters); + validateComponent({ name: normalizedName, parameters: copied }); + return Object.freeze({ name: normalizedName, parameters: copied }); +} + +function copyComponentParameters( + parameters: ComponentParameters +): ComponentParameters { + const output: Record = {}; + for (const [name, value] of Object.entries(parameters)) { + validateParameterName(name); + if (value !== undefined) { + validateSerializableBareItem(value, `component parameter ${name}`); + output[name] = cloneBareItem(value); + } + } + return Object.freeze(output); +} + +function normalizeComponentName(name: string): string { + const normalized = name.toLowerCase(); + if (normalized.startsWith("@")) { + if (normalized === "@query-param") { + return fail( + SignatureErrorCode.UnsupportedFeature, + "@query-param is not supported" + ); + } + if (!derivedComponents.has(normalized)) { + return fail( + SignatureErrorCode.UnsupportedFeature, + `Unknown derived component ${normalized}` + ); + } + } else if (!fieldNamePattern.test(normalized)) { + return fail( + SignatureErrorCode.InvalidComponent, + `Invalid field component ${name}` + ); + } + return normalized; +} + +function normalizeComponent(input: SignatureComponent): ComponentDescriptor { + return typeof input === "string" + ? component(input) + : component(input.name, input.parameters); +} + +function validateComponent(value: ComponentDescriptor): void { + const entries = Object.entries(value.parameters); + for (const [name, parameterValue] of entries) { + if (unsupportedParameters.has(name)) { + fail(SignatureErrorCode.UnsupportedFeature, `${name} is not supported`); + } + if (name !== "req" && name !== "key") { + fail( + SignatureErrorCode.UnsupportedFeature, + `Component parameter ${name} is not supported` + ); + } + if (name === "req" && parameterValue !== true) { + fail(SignatureErrorCode.InvalidComponent, "req must be true"); + } + if (name === "key" && typeof parameterValue !== "string") { + fail(SignatureErrorCode.InvalidComponent, "key must be a string"); + } + } + if (value.name.startsWith("@") && value.parameters.key !== undefined) { + fail( + SignatureErrorCode.InvalidComponent, + `key is invalid on derived component ${value.name}` + ); + } +} + +function componentMap(value: ComponentDescriptor): Parameters { + const output: Parameters = new Map(); + for (const [name, parameterValue] of Object.entries(value.parameters)) { + if (parameterValue !== undefined) output.set(name, parameterValue); + } + return output; +} + +export function componentIdentity(input: SignatureComponent): string { + const value = normalizeComponent(input); + return serializeItem(value.name, componentMap(value)); +} + +function equivalentIdentity(input: ComponentDescriptor): string { + const ordered = Object.entries(input.parameters) + .filter((entry) => entry[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + const parameters: Parameters = new Map(); + for (const [name, value] of ordered) { + if (value !== undefined) parameters.set(name, value); + } + return serializeItem(input.name, parameters); +} + +function normalizeComponents( + inputs: readonly SignatureComponent[] +): readonly ComponentDescriptor[] { + const output: ComponentDescriptor[] = []; + const identities = new Set(); + for (const input of inputs) { + const value = normalizeComponent(input); + const identity = equivalentIdentity(value); + if (identities.has(identity)) { + fail( + SignatureErrorCode.DuplicateComponent, + `Duplicate component ${identity}` + ); + } + identities.add(identity); + output.push(value); + } + return Object.freeze(output); +} + +function fieldsFromHeaders(headers: Headers): readonly FieldOccurrence[] { + const fields: FieldOccurrence[] = []; + headers.forEach((value, name) => fields.push(Object.freeze({ name, value }))); + return Object.freeze(fields); +} + +function copyFields( + fields: readonly FieldOccurrence[] +): readonly FieldOccurrence[] { + return Object.freeze( + fields.map(({ name, value }) => { + if (!fieldNamePattern.test(name)) { + fail(SignatureErrorCode.MalformedField, `Invalid field name ${name}`); + } + const normalized = normalizeFieldValue(value); + validateAscii(normalized, `Field ${name}`); + return Object.freeze({ name: name.toLowerCase(), value: normalized }); + }) + ); +} + +function isNativeRequest( + message: SignatureMessage | Request | RequestDescriptor +): message is Request { + return typeof Request !== "undefined" && message instanceof Request; +} + +function isNativeResponse(message: SignatureMessage): message is Response { + return typeof Response !== "undefined" && message instanceof Response; +} + +function snapshotRequest( + message: Request | RequestDescriptor +): RequestSnapshot { + if (isNativeRequest(message)) { + return Object.freeze({ + kind: "request", + method: message.method, + targetUri: message.url, + fields: fieldsFromHeaders(message.headers), + trailers: Object.freeze([]), + }); + } + if (!fieldNamePattern.test(message.method)) { + fail(SignatureErrorCode.InvalidComponent, "Invalid request method"); + } + validateAscii(message.targetUri, "Target URI"); + if (message.requestTarget !== undefined) { + validateAscii(message.requestTarget, "Request target"); + } + return Object.freeze({ + kind: "request", + method: message.method, + targetUri: message.targetUri, + requestTarget: message.requestTarget, + fields: copyFields(message.fields), + trailers: copyFields(message.trailers ?? []), + }); +} + +function snapshotMessage(message: SignatureMessage): MessageSnapshot { + if (isNativeRequest(message)) { + return snapshotRequest(message); + } + if (isNativeResponse(message)) { + return Object.freeze({ + kind: "response", + status: message.status, + fields: fieldsFromHeaders(message.headers), + trailers: Object.freeze([]), + }); + } + if (message.kind === "request") return snapshotRequest(message); + if ( + !Number.isInteger(message.status) || + message.status < 100 || + message.status > 999 + ) { + fail(SignatureErrorCode.InvalidComponent, "Invalid response status"); + } + return Object.freeze({ + kind: "response", + status: message.status, + fields: copyFields(message.fields), + trailers: copyFields(message.trailers ?? []), + request: + message.request === undefined + ? undefined + : snapshotRequest(message.request), + }); +} + +function normalizeFieldValue(value: string): string { + return value.replace(/\r\n[\t ]+/g, " ").replace(/^[\t ]+|[\t ]+$/g, ""); +} + +function validateAscii(value: string, location: string): void { + for (const character of value) { + const code = character.charCodeAt(0); + if (code > 0x7e || code < 0x20 || code === 0x7f) { + if (code === 0x09) continue; + fail( + SignatureErrorCode.MalformedField, + `${location} is not ASCII field content` + ); + } + } +} + +function extractField( + snapshot: RequestSnapshot | ResponseSnapshot, + name: string, + trailers: boolean +): string { + const source = trailers ? snapshot.trailers : snapshot.fields; + const values = source + .filter((field) => field.name.toLowerCase() === name) + .map((field) => normalizeFieldValue(field.value)); + if (values.length === 0) { + return fail(SignatureErrorCode.MissingField, `Missing field ${name}`); + } + return values.join(", "); +} + +function requestForComponent( + snapshot: MessageSnapshot, + value: ComponentDescriptor +): RequestSnapshot | ResponseSnapshot { + if (value.parameters.req === undefined) return snapshot; + if (snapshot.kind !== "response" || snapshot.request === undefined) { + return fail( + SignatureErrorCode.InvalidComponent, + "req requires a response with a related request" + ); + } + return snapshot.request; +} + +function requestUrl(snapshot: RequestSnapshot, name: string): URL { + try { + return new URL(snapshot.targetUri); + } catch (error) { + return fail( + SignatureErrorCode.InvalidComponent, + `${name} requires an absolute target URI`, + error + ); + } +} + +function requestTarget( + snapshot: RequestSnapshot, + name: string +): { + readonly url: URL; + readonly path: string; + readonly query: string; +} { + const url = requestUrl(snapshot, name); + if (url.hash !== "") { + return fail( + SignatureErrorCode.InvalidComponent, + `${name} target URI cannot contain a fragment` + ); + } + const schemeEnd = snapshot.targetUri.indexOf("://"); + if (schemeEnd === -1) { + return fail( + SignatureErrorCode.InvalidComponent, + `${name} requires an HTTP target URI` + ); + } + const pathStart = snapshot.targetUri.indexOf("/", schemeEnd + 3); + const queryStart = snapshot.targetUri.indexOf("?", schemeEnd + 3); + const start = + pathStart !== -1 && (queryStart === -1 || pathStart < queryStart) + ? pathStart + : queryStart; + const pathEnd = queryStart === -1 ? snapshot.targetUri.length : queryStart; + return Object.freeze({ + url, + path: + start === -1 || start === queryStart + ? "/" + : snapshot.targetUri.slice(start, pathEnd), + query: queryStart === -1 ? "?" : snapshot.targetUri.slice(queryStart), + }); +} + +function extractDerived( + snapshot: RequestSnapshot | ResponseSnapshot, + name: string +): string { + if (name === "@status") { + if (snapshot.kind !== "response") { + return fail( + SignatureErrorCode.InvalidComponent, + "@status requires a response" + ); + } + return snapshot.status.toString(); + } + if (snapshot.kind !== "request") { + return fail( + SignatureErrorCode.InvalidComponent, + `${name} requires a request` + ); + } + if (name === "@method") return snapshot.method; + const target = requestTarget(snapshot, name); + switch (name) { + case "@target-uri": + return snapshot.targetUri; + case "@authority": + return target.url.host; + case "@scheme": + return target.url.protocol.slice(0, -1); + case "@request-target": + if (snapshot.requestTarget === undefined) { + return fail( + SignatureErrorCode.UnsupportedFeature, + "@request-target requires a descriptor requestTarget" + ); + } + return snapshot.requestTarget; + case "@path": + return target.path; + case "@query": + return target.query; + default: + return fail( + SignatureErrorCode.UnsupportedFeature, + `Unknown derived component ${name}` + ); + } +} + +function extractComponentValue( + snapshot: MessageSnapshot, + value: ComponentDescriptor +): string { + const selected = requestForComponent(snapshot, value); + if (value.name.startsWith("@")) return extractDerived(selected, value.name); + const fieldValue = extractField(selected, value.name, false); + const key = value.parameters.key; + if (key === undefined) return fieldValue; + if (typeof key !== "string") { + return fail(SignatureErrorCode.InvalidComponent, "key must be a string"); + } + const dictionary = parseRfc8941Dictionary(fieldValue, `field ${value.name}`); + const member = dictionary.get(key); + if (member === undefined) { + return fail( + SignatureErrorCode.MissingField, + `Field ${value.name} has no dictionary member ${key}` + ); + } + return isInnerList(member) + ? serializeInnerList(member) + : serializeItem(member); +} + +function componentsToInnerList( + components: readonly ComponentDescriptor[], + parameters: SignatureParameters +): InnerList { + const items: Item[] = components.map((value) => [ + value.name, + componentMap(value), + ]); + return [items, parametersToMap(parameters)]; +} + +function buildSignatureBase( + snapshot: MessageSnapshot, + components: readonly ComponentDescriptor[], + signatureInput: InnerList +): Uint8Array { + const lines = components.map( + (value) => + `${serializeItem(value.name, componentMap(value))}: ${extractComponentValue(snapshot, value)}` + ); + lines.push(`"@signature-params": ${serializeInnerList(signatureInput)}`); + return encoder.encode(lines.join("\n")); +} + +function assertAlgorithm( + claimed: Rfc8941BareItem | undefined, + actual: string +): void { + if (claimed !== undefined && claimed !== actual) { + fail( + SignatureErrorCode.AlgorithmMismatch, + `Claimed algorithm ${String(claimed)} does not match ${actual}` + ); + } +} + +function createInput( + message: SignatureMessage, + label: string, + componentsInput: readonly SignatureComponent[], + parametersInput: SignatureParameters, + algorithm: string +): { + readonly base: Uint8Array; + readonly signatureInput: string; +} { + if (!sfKeyPattern.test(label)) { + fail(SignatureErrorCode.MalformedField, `Invalid signature label ${label}`); + } + const snapshot = snapshotMessage(message); + const components = normalizeComponents(componentsInput); + const parameters = copyParameters(parametersInput); + assertAlgorithm(parameters.alg, algorithm); + const innerList = componentsToInnerList(components, parameters); + const dictionary: Dictionary = new Map([[label, innerList]]); + return Object.freeze({ + base: buildSignatureBase(snapshot, components, innerList), + signatureInput: serializeDictionary(dictionary), + }); +} + +function signatureDictionary(label: string, signature: Uint8Array): string { + const bytes = Uint8Array.from(signature).buffer; + const item: Item = [bytes, new Map()]; + const dictionary: Dictionary = new Map([[label, item]]); + return serializeDictionary(dictionary); +} + +export async function createSignature( + message: SignatureMessage, + options: CreateSignatureOptions +): Promise { + const label = options.label ?? "sig1"; + const input = createInput( + message, + label, + options.components, + options.parameters, + options.signer.algorithm + ); + const signature = await options.signer.sign(input.base.slice()); + return Object.freeze({ + signature: signatureDictionary(label, signature), + signatureInput: input.signatureInput, + }); +} + +export function createSignatureSync( + message: SignatureMessage, + options: CreateSignatureSyncOptions +): SignatureFields { + const label = options.label ?? "sig1"; + const input = createInput( + message, + label, + options.components, + options.parameters, + options.signer.algorithm + ); + return Object.freeze({ + signature: signatureDictionary( + label, + options.signer.sign(input.base.slice()) + ), + signatureInput: input.signatureInput, + }); +} + +function topLevelMemberCount(input: string): number { + let count = input.trim() === "" ? 0 : 1; + let depth = 0; + let quoted = false; + let escaped = false; + for (const character of input) { + if (escaped) { + escaped = false; + continue; + } + if (quoted && character === "\\") { + escaped = true; + continue; + } + if (character === '"') quoted = !quoted; + if (quoted) continue; + if (character === "(") depth += 1; + if (character === ")") depth -= 1; + if (character === "," && depth === 0) count += 1; + } + return count; +} + +function rawParameterCount(input: string): number { + let count = 0; + let quoted = false; + let escaped = false; + for (const character of input) { + if (escaped) { + escaped = false; + continue; + } + if (quoted && character === "\\") { + escaped = true; + continue; + } + if (character === '"') quoted = !quoted; + if (!quoted && character === ";") count += 1; + } + return count; +} + +function parsedParameterCount(dictionary: Dictionary): number { + let count = 0; + for (const member of dictionary.values()) { + count += member[1].size; + if (isInnerList(member)) { + for (const item of member[0]) count += item[1].size; + } + } + return count; +} + +function assertNoRfc9651Syntax(input: string, name: string): void { + let quoted = false; + let escaped = false; + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + if (escaped) { + escaped = false; + continue; + } + if (quoted && character === "\\") { + escaped = true; + continue; + } + if (character === '"') { + quoted = !quoted; + continue; + } + if ( + !quoted && + (character === "@" || (character === "%" && input[index + 1] === '"')) + ) { + fail( + SignatureErrorCode.UnsupportedFeature, + `${name} uses an RFC 9651-only value` + ); + } + } +} + +function parseRfc8941Dictionary(input: string, name: string): Dictionary { + assertNoRfc9651Syntax(input, name); + let dictionary: Dictionary; + try { + dictionary = parseDictionary(input); + } catch (error) { + return fail(SignatureErrorCode.MalformedField, `Malformed ${name}`, error); + } + assertRfc8941Dictionary(dictionary, name); + return dictionary; +} + +function parseStrictDictionary(input: string, name: string): Dictionary { + const dictionary = parseRfc8941Dictionary(input, name); + if (topLevelMemberCount(input) !== dictionary.size) { + return fail( + SignatureErrorCode.DuplicateLabel, + `${name} has duplicate labels` + ); + } + if (rawParameterCount(input) !== parsedParameterCount(dictionary)) { + return fail( + SignatureErrorCode.MalformedField, + `${name} has duplicate parameters` + ); + } + return dictionary; +} + +function assertRfc8941Dictionary(dictionary: Dictionary, name: string): void { + for (const member of dictionary.values()) { + if (isInnerList(member)) { + for (const [value, parameters] of member[0]) { + validateBareItem(value, name); + for (const parameter of parameters.values()) + validateBareItem(parameter, name); + } + } else { + validateBareItem(member[0], name); + } + for (const parameter of member[1].values()) + validateBareItem(parameter, name); + } +} + +function parseSignatureDictionary(input: string): Dictionary { + const dictionary = parseStrictDictionary(input, "Signature"); + for (const member of dictionary.values()) { + if ( + isInnerList(member) || + !(member[0] instanceof ArrayBuffer) || + member[1].size !== 0 + ) { + fail( + SignatureErrorCode.MalformedField, + "Signature members must be unparameterized byte sequences" + ); + } + } + return dictionary; +} + +function parseSignatureInputDictionary(input: string): Dictionary { + const dictionary = parseStrictDictionary(input, "Signature-Input"); + for (const member of dictionary.values()) { + if (!isInnerList(member)) { + fail( + SignatureErrorCode.MalformedField, + "Signature-Input members must be inner lists" + ); + } + } + return dictionary; +} + +function matchingLabels(left: Dictionary, right: Dictionary): boolean { + if (left.size !== right.size) return false; + for (const label of left.keys()) if (!right.has(label)) return false; + return true; +} + +function selectedLabel( + signatures: Dictionary, + inputs: Dictionary, + requested: string | undefined +): string { + if (!matchingLabels(signatures, inputs)) { + return fail( + SignatureErrorCode.LabelMismatch, + "Signature and Signature-Input label sets differ" + ); + } + if (requested !== undefined) { + if (!signatures.has(requested) || !inputs.has(requested)) { + return fail( + SignatureErrorCode.LabelMismatch, + `Label ${requested} is not present in both signature fields` + ); + } + return requested; + } + if (signatures.size !== 1) { + return fail( + SignatureErrorCode.LabelRequired, + "An explicit label is required for multiple or mismatched signatures" + ); + } + const iterator = signatures.keys().next(); + if (iterator.done) { + return fail(SignatureErrorCode.MissingField, "Signature fields are empty"); + } + return iterator.value; +} + +function parsedSignatureInput(member: InnerList): ParsedSignatureInput { + const components: ComponentDescriptor[] = []; + for (const [name, parameters] of member[0]) { + if (typeof name !== "string") { + return fail( + SignatureErrorCode.MalformedField, + "Component identifiers must be strings" + ); + } + if (name !== name.toLowerCase()) { + return fail( + SignatureErrorCode.MalformedField, + `Component identifier ${name} must be lowercase` + ); + } + const values: Record = {}; + for (const [parameterName, value] of parameters) { + values[parameterName] = validateBareItem( + value, + `Component parameter ${parameterName}` + ); + } + components.push(component(name, values)); + } + const normalized = normalizeComponents(components); + return Object.freeze({ + value: member, + components: normalized, + parameters: parametersFromMap(member[1]), + }); +} + +function signatureBytes(member: Item | InnerList): Uint8Array { + if (isInnerList(member) || !(member[0] instanceof ArrayBuffer)) { + return fail(SignatureErrorCode.MalformedField, "Invalid Signature member"); + } + return new Uint8Array(member[0].slice(0)); +} + +export function appendSignature( + headers: Headers, + fields: SignatureFields +): Headers { + const newSignatures = parseSignatureDictionary(fields.signature); + const newInputs = parseSignatureInputDictionary(fields.signatureInput); + if (newSignatures.size === 0 || newInputs.size === 0) { + fail(SignatureErrorCode.MalformedField, "New signature fields are empty"); + } + if (!matchingLabels(newSignatures, newInputs)) { + fail(SignatureErrorCode.LabelMismatch, "New signature label sets differ"); + } + + const signatureHeader = headers.get("signature"); + const inputHeader = headers.get("signature-input"); + if ((signatureHeader === null) !== (inputHeader === null)) { + fail( + SignatureErrorCode.LabelMismatch, + "Existing signature label sets differ" + ); + } + const signatures = + signatureHeader === null + ? new Map() + : parseSignatureDictionary(signatureHeader); + const inputs = + inputHeader === null + ? new Map() + : parseSignatureInputDictionary(inputHeader); + if (!matchingLabels(signatures, inputs)) { + fail( + SignatureErrorCode.LabelMismatch, + "Existing signature label sets differ" + ); + } + for (const label of newSignatures.keys()) { + if (signatures.has(label) || inputs.has(label)) { + fail( + SignatureErrorCode.DuplicateLabel, + `Duplicate signature label ${label}` + ); + } + } + for (const [label, value] of newSignatures) signatures.set(label, value); + for (const [label, value] of newInputs) inputs.set(label, value); + + const output = new Headers(headers); + output.set("signature", serializeDictionary(signatures)); + output.set("signature-input", serializeDictionary(inputs)); + return output; +} + +function getRequiredField(snapshot: MessageSnapshot, name: string): string { + return extractField(snapshot, name, false); +} + +function assertPolicyCoverage( + components: readonly ComponentDescriptor[], + parameters: SignatureParameters, + policy: VerificationPolicy, + now: number +): void { + const present = new Set(components.map(equivalentIdentity)); + for (const required of policy.requiredComponents) { + const identity = equivalentIdentity(normalizeComponent(required)); + if (!present.has(identity)) { + fail( + SignatureErrorCode.PolicyViolation, + `Required component ${identity} is absent` + ); + } + } + for (const required of policy.requiredParameters) { + if (parameters[required] === undefined) { + fail( + SignatureErrorCode.PolicyViolation, + `Required parameter ${required} is absent` + ); + } + } + const skew = policy.clockSkew ?? 0; + if (!Number.isFinite(skew) || skew < 0) { + fail(SignatureErrorCode.PolicyViolation, "clockSkew must be non-negative"); + } + const created = parameters.created; + const expires = parameters.expires; + if (created !== undefined && created > now + skew) { + fail( + SignatureErrorCode.PolicyViolation, + "Signature was created in the future" + ); + } + if (expires !== undefined && expires < now - skew) { + fail(SignatureErrorCode.PolicyViolation, "Signature has expired"); + } + if (policy.maxAge !== undefined) { + if (!Number.isFinite(policy.maxAge) || policy.maxAge < 0) { + fail(SignatureErrorCode.PolicyViolation, "maxAge must be non-negative"); + } + if (created === undefined) { + fail(SignatureErrorCode.PolicyViolation, "maxAge requires created"); + } + if (now - created > policy.maxAge + skew) { + fail(SignatureErrorCode.PolicyViolation, "Signature is too old"); + } + } +} + +export async function verifySignature( + message: SignatureMessage, + options: VerifySignatureOptions +): Promise> { + const snapshot = snapshotMessage(message); + const signatureHeader = getRequiredField(snapshot, "signature"); + const inputHeader = getRequiredField(snapshot, "signature-input"); + const signatures = parseSignatureDictionary(signatureHeader); + const inputs = parseSignatureInputDictionary(inputHeader); + const label = selectedLabel(signatures, inputs, options.label); + const inputMember = inputs.get(label); + const signatureMember = signatures.get(label); + if ( + inputMember === undefined || + !isInnerList(inputMember) || + signatureMember === undefined + ) { + return fail( + SignatureErrorCode.LabelMismatch, + `Incomplete signature ${label}` + ); + } + const parsed = parsedSignatureInput(inputMember); + const signature = signatureBytes(signatureMember); + const base = buildSignatureBase(snapshot, parsed.components, parsed.value); + const now = options.policy.now ?? Math.floor(Date.now() / 1000); + if (!Number.isFinite(now)) { + return fail(SignatureErrorCode.PolicyViolation, "now must be finite"); + } + assertPolicyCoverage( + parsed.components, + parsed.parameters, + options.policy, + now + ); + const claimedAlgorithm = parsed.parameters.alg; + if ( + claimedAlgorithm !== undefined && + !options.policy.algorithms.includes(claimedAlgorithm) + ) { + return fail( + SignatureErrorCode.PolicyViolation, + `Algorithm ${claimedAlgorithm} is not allowed` + ); + } + + const untrustedCandidate: UntrustedSignatureCandidate = Object.freeze({ + label, + algorithm: claimedAlgorithm, + components: parsed.components, + parameters: copyParameters(parsed.parameters), + signature: signature.slice(), + }); + let verifier: V; + try { + verifier = await options.resolveVerifier( + untrustedCandidate, + Object.freeze({ now }) + ); + } catch (error) { + return fail( + SignatureErrorCode.ResolverFailed, + "Verifier resolution failed", + error + ); + } + if (!options.policy.algorithms.includes(verifier.algorithm)) { + return fail( + SignatureErrorCode.PolicyViolation, + `Algorithm ${verifier.algorithm} is not allowed` + ); + } + assertAlgorithm(claimedAlgorithm, verifier.algorithm); + const valid = await verifier.verify(base.slice(), signature.slice()); + if (!valid) { + return fail( + SignatureErrorCode.VerificationFailed, + "Signature verification failed" + ); + } + + const verified: VerifiedSignature = Object.freeze({ + verifier, + label, + algorithm: verifier.algorithm, + components: parsed.components, + parameters: parsed.parameters, + signature: signature.slice(), + }); + if (options.policy.validate !== undefined) { + const accepted = await options.policy.validate(verified); + if (accepted === false) { + return fail( + SignatureErrorCode.PolicyViolation, + "Signature rejected by policy" + ); + } + } + return verified; +} diff --git a/packages/http-message-sig/src/directory.ts b/packages/http-message-sig/src/directory.ts deleted file mode 100644 index 42cd252..0000000 --- a/packages/http-message-sig/src/directory.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Tag } from "./consts"; -import { signatureHeaders } from "./sign"; -import { - Component, - ResponseRequestPair, - SignatureHeaders, - Signer, -} from "./types"; - -export const RESPONSE_COMPONENTS: Component[] = [ - { - name: "@authority", - parameters: new Map([["req", true]]), - }, - "content-digest", -]; - -export interface SignatureParams { - created: Date; - expires: Date; -} - -export async function directoryResponseHeaders( - message: ResponseRequestPair, - signers: Signer[], - params: SignatureParams -): Promise { - if (params.created.getTime() > params.expires.getTime()) { - throw new Error("created should happen before expires"); - } - - // TODO: consider validating the directory structure, and confirm we have one signer per key - const headers = new Map(); - - for (let i = 0; i < signers.length; i += 1) { - // eslint-disable-next-line security/detect-object-injection - const signer = signers[i]; - if (headers.has(signer.keyid)) { - throw new Error(`Duplicated signer with keyid ${signer.keyid}`); - } - - headers.set( - signer.keyid, - await signatureHeaders(message, { - signer, - components: RESPONSE_COMPONENTS, - created: params.created, - expires: params.expires, - keyid: signer.keyid, - key: `binding${i}`, - tag: Tag.HTTP_MESSAGE_SIGNAGURES_DIRECTORY, - }) - ); - } - - const SF_SEPARATOR = ", "; - // Providing multiple signature as described in Section 4.3 of RFC 9421 - // https://datatracker.ietf.org/doc/html/rfc9421#name-multiple-signatures - return { - Signature: Array.from(headers.values()) - .map((h) => h.Signature) - .join(SF_SEPARATOR), - "Signature-Input": Array.from(headers.values()) - .map((h) => h["Signature-Input"]) - .join(SF_SEPARATOR), - }; -} diff --git a/packages/http-message-sig/src/errors.ts b/packages/http-message-sig/src/errors.ts new file mode 100644 index 0000000..4825482 --- /dev/null +++ b/packages/http-message-sig/src/errors.ts @@ -0,0 +1,33 @@ +export const SignatureErrorCode = Object.freeze({ + MissingField: "MissingField", + MalformedField: "MalformedField", + LabelRequired: "LabelRequired", + LabelMismatch: "LabelMismatch", + DuplicateLabel: "DuplicateLabel", + InvalidComponent: "InvalidComponent", + DuplicateComponent: "DuplicateComponent", + UnsupportedFeature: "UnsupportedFeature", + AlgorithmMismatch: "AlgorithmMismatch", + PolicyViolation: "PolicyViolation", + VerificationFailed: "VerificationFailed", + ResolverFailed: "ResolverFailed", +}); + +export type SignatureErrorCode = + (typeof SignatureErrorCode)[keyof typeof SignatureErrorCode]; + +export class SignatureError extends Error { + readonly code: SignatureErrorCode; + readonly cause?: unknown; + + constructor(code: SignatureErrorCode, message: string, cause?: unknown) { + super(message); + this.name = "SignatureError"; + this.code = code; + this.cause = cause; + } +} + +export function isSignatureError(error: unknown): error is SignatureError { + return error instanceof SignatureError; +} diff --git a/packages/http-message-sig/src/index.ts b/packages/http-message-sig/src/index.ts index 48f3610..ff71cf2 100644 --- a/packages/http-message-sig/src/index.ts +++ b/packages/http-message-sig/src/index.ts @@ -1,13 +1,11 @@ -export * as base64 from "./base64"; -export { - extractHeader, - resolveMessageKind, - isRawMessage, - componentHasParameters, -} from "./build"; -export * from "./consts"; -export * from "./directory"; -export { parseAcceptSignatureHeader as parseAcceptSignature } from "./parse"; -export * from "./sign"; +export { Token } from "structured-headers"; +import { createWebCryptoSigner, createWebCryptoVerifier } from "./webcrypto"; + +export const webcrypto = Object.freeze({ + signer: createWebCryptoSigner, + verifier: createWebCryptoVerifier, +}); + +export * from "./core"; +export * from "./errors"; export * from "./types"; -export * from "./verify"; diff --git a/packages/http-message-sig/src/parse.ts b/packages/http-message-sig/src/parse.ts deleted file mode 100644 index 758805c..0000000 --- a/packages/http-message-sig/src/parse.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Component, - ComponentParameters, - HeaderValue, - Parameter, - Parameters, -} from "./types"; -import { decode as base64Decode } from "./base64"; -import { parseDictionary, isInnerList } from "structured-headers"; - -function parseSfvDictionary( - name: string, - header: HeaderValue -): { key: string; components: Component[]; parameters: Parameters } { - let dictionary; - try { - dictionary = parseDictionary(header.toString()); - } catch (error) { - throw new Error( - `Invalid ${name} header; failed to parse as RFC 8941 dictionary: ${errorMessage(error)}`, - { cause: error } - ); - } - - if (dictionary.size > 1) { - throw new Error(`Multiple signatures is not supported`); - } - - const entry = dictionary.entries().next(); - - if (!entry.value) { - throw new Error(`Invalid ${name} header. Invalid value`); - } - - const [key, innerlist] = entry.value; - if (!isInnerList(innerlist)) { - throw new Error(`Invalid ${name} header. Missing components`); - } - - // innerlist is [Item[], Map] where each Item is [string, Map] - const [cwp, params] = innerlist; - - const parameters: Parameters = Object.fromEntries(params) as Record< - Parameter, - string | number | Date - >; - if (typeof parameters.created === "number") - parameters.created = new Date(parameters.created * 1000); - if (typeof parameters.expires === "number") - parameters.expires = new Date(parameters.expires * 1000); - - const components: Component[] = cwp.map(([component, componentParams]) => { - if (typeof component !== "string") { - throw new Error( - `Failed to parse component ${component} in component list: type is not string` - ); - } - - if (componentParams.size === 0) { - return component; - } - - const parameters: ComponentParameters = new Map(); - let key: string | undefined; - for (const [paramName, paramValue] of componentParams.entries()) { - if (typeof paramValue !== "string" && typeof paramValue !== "boolean") { - throw new Error( - `Failed to parse parameter ${paramName} on ${component}: type is neither string nor boolean` - ); - } - - parameters.set(paramName, paramValue); - if (paramName === "key" && typeof paramValue === "string") { - key = paramValue; - } - } - - if (key !== undefined) { - return { - header: component, - key, - parameters, - }; - } - - return { - name: component, - parameters, - }; - }); - - return { key, components, parameters }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function parseSignatureInputHeader(header: HeaderValue): { - key: string; - components: Component[]; - parameters: Parameters; -} { - return parseSfvDictionary("Signature-Input", header); -} - -export function parseAcceptSignatureHeader(header: HeaderValue): { - key: string; - components: Component[]; - parameters: Parameters; -} { - return parseSfvDictionary("Accept-Signature", header); -} - -export function parseSignatureHeader( - key: string, - header: HeaderValue -): Uint8Array { - const signatureMatch = header - .toString() - .match(/^([\w-]+)=:([A-Za-z0-9+/=]+):$/); - if (!signatureMatch) throw new Error("Invalid Signature header"); - - const [, signatureKey, signature] = signatureMatch; - if (signatureKey !== key) - throw new Error( - `Invalid Signature header. Key mismatch ${signatureKey} !== ${key}` - ); - - return base64Decode(signature); -} diff --git a/packages/http-message-sig/src/sign.ts b/packages/http-message-sig/src/sign.ts deleted file mode 100644 index a4ad270..0000000 --- a/packages/http-message-sig/src/sign.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - Component, - Parameters, - RequestLike, - ResponseLike, - ResponseRequestPair, - SignatureHeaders, - SignOptions, - SignSyncOptions, -} from "./types"; -import { - buildSignatureInputString, - buildSignedData, - resolveMessageKind, -} from "./build"; -import { encode as base64Encode } from "./base64"; - -const defaultRequestComponents: Component[] = [ - "@method", - "@path", - "@query", - "@authority", - "content-type", - "digest", -]; - -const defaultResponseComponents: Component[] = [ - "@status", - "content-type", - "digest", -]; - -export async function signatureHeaders< - T extends RequestLike | ResponseLike | ResponseRequestPair, ->(message: T, opts: SignOptions): Promise { - const { signer, components: _components, key: _key, ...params } = opts; - - const components = - _components ?? - ("status" in resolveMessageKind(message) - ? defaultResponseComponents - : defaultRequestComponents); - const key = _key ?? "sig1"; - - const signParams: Parameters = { - created: new Date(), - keyid: signer.keyid, - alg: signer.alg, - ...(params as Parameters), - }; - - const signatureInputString = buildSignatureInputString( - components, - signParams - ); - const dataToSign = buildSignedData(message, components, signatureInputString); - - const signature = await signer.sign(dataToSign); - const sigBase64 = base64Encode(signature); - - return { - Signature: `${key}=:${sigBase64}:`, - "Signature-Input": `${key}=${signatureInputString}`, - }; -} - -export function signatureHeadersSync< - T extends RequestLike | ResponseLike | ResponseRequestPair, ->(message: T, opts: SignSyncOptions): SignatureHeaders { - const { signer, components: _components, key: _key, ...params } = opts; - - const components = - _components ?? - ("status" in resolveMessageKind(message) - ? defaultResponseComponents - : defaultRequestComponents); - const key = _key ?? "sig1"; - - const signParams: Parameters = { - created: new Date(), - keyid: signer.keyid, - alg: signer.alg, - ...(params as Parameters), - }; - - const signatureInputString = buildSignatureInputString( - components, - signParams - ); - const dataToSign = buildSignedData(message, components, signatureInputString); - - const signature = signer.signSync(dataToSign); - const sigBase64 = base64Encode(signature); - - return { - Signature: `${key}=:${sigBase64}:`, - "Signature-Input": `${key}=${signatureInputString}`, - }; -} diff --git a/packages/http-message-sig/src/types.ts b/packages/http-message-sig/src/types.ts index 0542f94..9129d4b 100644 --- a/packages/http-message-sig/src/types.ts +++ b/packages/http-message-sig/src/types.ts @@ -1,144 +1,126 @@ -// HTTP Message Signatures Algorithms Registry at IANA -// https://www.iana.org/assignments/http-message-signature/http-message-signature.xhtml#signature-algorithms -export type Algorithm = - | "rsa-pss-sha512" - | "rsa-v1_5-sha256" - | "hmac-sha256" - | "ecdsa-p256-sha256" - | "ecdsa-p384-sha384" - | "ed25519"; - -export interface Signer { - sign: (data: string) => Uint8Array | Promise; - keyid: string; - alg: Algorithm; +import type { Token } from "structured-headers"; + +export type Rfc8941BareItem = string | number | boolean | Token | ArrayBuffer; + +export type SignatureParameters = Readonly< + { + readonly created?: number; + readonly expires?: number; + readonly nonce?: string; + readonly alg?: string; + readonly keyid?: string; + readonly tag?: string; + } & { readonly [name: string]: Rfc8941BareItem | undefined } +>; + +export type ComponentParameters = Readonly<{ + readonly [name: string]: Rfc8941BareItem | undefined; +}>; + +export interface ComponentDescriptor { + readonly name: string; + readonly parameters: ComponentParameters; } -export interface SignerSync { - signSync: (data: string) => Uint8Array; - keyid: string; - alg: Algorithm; +export type SignatureComponent = string | ComponentDescriptor; + +export interface FieldOccurrence { + readonly name: string; + readonly value: string; } -export type Verify = ( - data: string, - signature: Uint8Array, - params: Parameters, - components: Component[] -) => T | Promise; +export interface RequestDescriptor { + readonly kind: "request"; + readonly method: string; + readonly targetUri: string; + readonly requestTarget?: string; + readonly fields: readonly FieldOccurrence[]; + readonly trailers?: readonly FieldOccurrence[]; +} -interface HeadersMap { - get(name: string): string | null; - set(name: string, value: string): void; +export interface ResponseDescriptor { + readonly kind: "response"; + readonly status: number; + readonly fields: readonly FieldOccurrence[]; + readonly trailers?: readonly FieldOccurrence[]; + readonly request?: Request | RequestDescriptor; } -type Headers = Record | HeadersMap; +export type MessageDescriptor = RequestDescriptor | ResponseDescriptor; +export type SignatureMessage = Request | Response | MessageDescriptor; -export type HeaderValue = { toString(): string } | string | string[]; +export interface Signer { + readonly algorithm: string; + sign(data: Uint8Array): Uint8Array | Promise; +} -export interface RequestLike { - method: string; - url: string; - protocol?: string; - headers: Headers; +export interface SignerSync { + readonly algorithm: string; + sign(data: Uint8Array): Uint8Array; } -export interface ResponseLike { - status: number; - headers: Headers; +export interface Verifier { + readonly algorithm: string; + verify(data: Uint8Array, signature: Uint8Array): boolean | Promise; } -// Allows usage of the req parameter. -export interface ResponseRequestPair { - response: ResponseLike; - request: RequestLike; +export interface SignatureFields { + readonly signature: string; + readonly signatureInput: string; } -// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3.1 -export type Parameter = - "created" | "expires" | "nonce" | "alg" | "keyid" | string; +export interface CreateSignatureOptions { + readonly label?: string; + readonly components: readonly SignatureComponent[]; + readonly parameters: SignatureParameters; + readonly signer: Signer; +} -export interface StructuredFieldDictionaryComponent { - header: string; - key: string; - parameters?: ComponentParameters; +export interface CreateSignatureSyncOptions { + readonly label?: string; + readonly components: readonly SignatureComponent[]; + readonly parameters: SignatureParameters; + readonly signer: SignerSync; } -export type Component = - | "@method" - | "@target-uri" - | "@authority" - | "@scheme" - | "@request-target" - | "@path" - | "@query" - | "@query-param" - | "@status" - | string - | ComponentWithParameters - | StructuredFieldDictionaryComponent; - -export interface ComponentWithParameters { - name: string; - parameters: ComponentParameters; +export interface UntrustedSignatureCandidate { + readonly label: string; + readonly algorithm?: string; + readonly components: readonly ComponentDescriptor[]; + readonly parameters: SignatureParameters; + readonly signature: Uint8Array; } -export type ComponentParameters = Map; +export interface VerificationContext { + readonly now: number; +} -interface StandardParameters { - expires?: Date; - created?: Date; - nonce?: string; - alg?: string; - keyid?: string; - tag?: string; +export interface VerifiedSignature { + readonly verifier: V; + readonly label: string; + readonly algorithm: string; + readonly components: readonly ComponentDescriptor[]; + readonly parameters: SignatureParameters; + readonly signature: Uint8Array; } -export type Parameters = StandardParameters & - Record< - Parameter, - string | number | true | Date | { [Symbol.toStringTag]: () => string } - >; - -export type SignOptions = StandardParameters & { - components?: Component[]; - key?: string; - signer: Signer; - [name: Parameter]: - | Component[] - | ComponentWithParameters[] - | StructuredFieldDictionaryComponent[] - | Signer - | string - | number - | true - | Date - | { [Symbol.toStringTag]: () => string } - | undefined; -}; - -export type SignSyncOptions = StandardParameters & { - components?: Component[]; - key?: string; - signer: SignerSync; - [name: Parameter]: - | Component[] - | ComponentWithParameters[] - | StructuredFieldDictionaryComponent[] - | SignerSync - | string - | number - | true - | Date - | { [Symbol.toStringTag]: () => string } - | undefined; -}; - -export interface SignatureHeaders { - Signature: string; - "Signature-Input": string; +export interface VerificationPolicy { + readonly algorithms: readonly string[]; + readonly requiredComponents: readonly SignatureComponent[]; + readonly requiredParameters: readonly string[]; + readonly maxAge?: number; + readonly clockSkew?: number; + readonly now?: number; + readonly validate?: ( + signature: VerifiedSignature + ) => boolean | void | Promise; } -export interface Directory { - keys: JsonWebKey[]; +export interface VerifySignatureOptions { + readonly label?: string; + readonly policy: VerificationPolicy; + readonly resolveVerifier: ( + untrustedCandidate: UntrustedSignatureCandidate, + context: VerificationContext + ) => V | Promise; } diff --git a/packages/http-message-sig/src/verify.ts b/packages/http-message-sig/src/verify.ts deleted file mode 100644 index 356d794..0000000 --- a/packages/http-message-sig/src/verify.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - RequestLike, - ResponseLike, - ResponseRequestPair, - Verify, -} from "./types"; -import { parseSignatureHeader, parseSignatureInputHeader } from "./parse"; -import { buildSignedData, extractHeader, resolveMessageKind } from "./build"; - -export async function verify( - message: RequestLike | ResponseLike | ResponseRequestPair, - verifier: Verify -): Promise { - const signatureInputHeader = extractHeader( - resolveMessageKind(message), - "signature-input" - ); - if (!signatureInputHeader) - throw new Error("Message does not contain Signature-Input header"); - const { key, components, parameters } = - parseSignatureInputHeader(signatureInputHeader); - - if (parameters.expires && parameters.expires < new Date()) - throw new Error("Signature expired"); - - const signatureHeader = extractHeader( - resolveMessageKind(message), - "signature" - ); - if (!signatureHeader) - throw new Error("Message does not contain Signature header"); - const signature = parseSignatureHeader(key, signatureHeader); - - const signatureInputString = signatureInputHeader - .toString() - .replace(/^[^=]+=/, ""); - const signedData = buildSignedData(message, components, signatureInputString); - - return verifier(signedData, signature, parameters, components); -} diff --git a/packages/http-message-sig/src/webcrypto.ts b/packages/http-message-sig/src/webcrypto.ts new file mode 100644 index 0000000..e6a9434 --- /dev/null +++ b/packages/http-message-sig/src/webcrypto.ts @@ -0,0 +1,65 @@ +import { SignatureError, SignatureErrorCode } from "./errors"; +import type { Signer, Verifier } from "./types"; + +function rsaHashName(algorithm: KeyAlgorithm): string | undefined { + if (!("hash" in algorithm)) return undefined; + const hash = algorithm.hash; + if (typeof hash !== "object" || hash === null || !("name" in hash)) { + return undefined; + } + return typeof hash.name === "string" ? hash.name : undefined; +} + +function signatureAlgorithm(key: CryptoKey): string { + if (key.algorithm.name === "Ed25519") return "ed25519"; + if ( + key.algorithm.name === "RSA-PSS" && + rsaHashName(key.algorithm) === "SHA-512" + ) { + return "rsa-pss-sha512"; + } + throw new SignatureError( + SignatureErrorCode.UnsupportedFeature, + `Unsupported WebCrypto algorithm ${key.algorithm.name}` + ); +} + +function cryptoParameters(key: CryptoKey): AlgorithmIdentifier | RsaPssParams { + return key.algorithm.name === "RSA-PSS" + ? { name: "RSA-PSS", saltLength: 64 } + : { name: key.algorithm.name }; +} + +export function createWebCryptoSigner(key: CryptoKey): Signer { + if (key.type !== "private" || !key.usages.includes("sign")) { + throw new SignatureError( + SignatureErrorCode.PolicyViolation, + "Signing requires a private key with sign usage" + ); + } + const algorithm = signatureAlgorithm(key); + return Object.freeze({ + algorithm, + async sign(data: Uint8Array): Promise { + return new Uint8Array( + await crypto.subtle.sign(cryptoParameters(key), key, data) + ); + }, + }); +} + +export function createWebCryptoVerifier(key: CryptoKey): Verifier { + if (key.type !== "public" || !key.usages.includes("verify")) { + throw new SignatureError( + SignatureErrorCode.PolicyViolation, + "Verification requires a public key with verify usage" + ); + } + const algorithm = signatureAlgorithm(key); + return Object.freeze({ + algorithm, + verify(data: Uint8Array, signature: Uint8Array): Promise { + return crypto.subtle.verify(cryptoParameters(key), key, signature, data); + }, + }); +} diff --git a/packages/http-message-sig/test/build.spec.ts b/packages/http-message-sig/test/build.spec.ts deleted file mode 100644 index d18b6b1..0000000 --- a/packages/http-message-sig/test/build.spec.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { Component, Parameters, RequestLike } from "../src"; -import { - buildSignatureInputString, - buildSignedData, - extractComponent, - extractHeader, - getUrl, -} from "../src/build"; - -describe("build", () => { - describe("extractHeader", () => { - const headers = { - testheader: "test", - "test-header-1": "test1", - "Test-Header-2": "test2", - "test-Header-3": "test3", - "TEST-HEADER-4": "test4", - }; - - Object.entries(headers).forEach(([headerName, expectedValue]) => { - it(`successfully extracts a matching header (${headerName})`, () => { - expect( - extractHeader({ headers } as unknown as RequestLike, headerName) - ).to.equal(expectedValue); - }); - it(`successfully extracts a lower cased header (${headerName})`, () => { - expect( - extractHeader( - { headers } as unknown as RequestLike, - headerName.toLowerCase() - ) - ).to.equal(expectedValue); - }); - it(`successfully extracts an upper cased header (${headerName})`, () => { - expect( - extractHeader( - { headers } as unknown as RequestLike, - headerName.toUpperCase() - ) - ).to.equal(expectedValue); - }); - }); - - it("returns an empty string for a missing header", () => { - expect( - extractHeader({ headers } as unknown as RequestLike, "missing") - ).to.equal(""); - }); - }); - - describe("extractComponent", () => { - it("correctly extracts the @method", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value", - } as unknown as RequestLike, - "@method" - ); - expect(result).to.equal("POST"); - }); - - it("correctly extracts the @target-uri", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value", - } as unknown as RequestLike, - "@target-uri" - ); - expect(result).to.equal("https://www.example.com/path?param=value"); - }); - - it("correctly extracts the @authority", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value", - } as unknown as RequestLike, - "@authority" - ); - expect(result).to.equal("www.example.com"); - }); - - it.each([ - ["http://www.example.com:443/", "www.example.com:443"], - ["https://www.example.com:80/", "www.example.com:80"], - ["http://www.example.com:80/", "www.example.com"], - ])("normalizes @authority for %s", (url, expected) => { - const result = extractComponent( - { method: "GET", url } as unknown as RequestLike, - "@authority" - ); - expect(result).to.equal(expected); - }); - - it("correctly extracts the @scheme", () => { - const result = extractComponent( - { - method: "POST", - url: "http://www.example.com/path?param=value", - } as unknown as RequestLike, - "@scheme" - ); - expect(result).to.equal("http"); - }); - - it("correctly extracts the @request-target", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value", - } as unknown as RequestLike, - "@request-target" - ); - expect(result).to.equal("/path?param=value"); - }); - - it("correctly extracts the @path", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value", - } as unknown as RequestLike, - "@path" - ); - expect(result).to.equal("/path"); - }); - - it.each([ - ["https://www.example.com/%7epath", "/%7epath"], - ["https://www.example.com/%zz", "/%zz"], - ])("does not percent-decode @path for %s", (url, expected) => { - // RFC 9421 section 2.2.6 uses values before percent-decoding. - const result = extractComponent( - { method: "GET", url } as unknown as RequestLike, - "@path" - ); - expect(result).to.equal(expected); - }); - - it("correctly extracts the @query", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value&foo=bar&baz=batman", - } as unknown as RequestLike, - "@query" - ); - expect(result).to.equal("?param=value&foo=bar&baz=batman"); - }); - - it("does not percent-decode @query", () => { - // RFC 9421 section 2.2.7 requires percent-encoded octets to remain encoded. - const result = extractComponent( - { - method: "GET", - url: "https://www.example.com/path?param=value&foo=bar&baz=bat%2Dman", - } as unknown as RequestLike, - "@query" - ); - expect(result).to.equal("?param=value&foo=bar&baz=bat%2Dman"); - }); - - it("correctly extracts the @query string", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?queryString", - } as unknown as RequestLike, - "@query" - ); - expect(result).to.equal("?queryString"); - }); - - it.skip("correctly extracts the @query-params", () => { - const result = extractComponent( - { - method: "POST", - url: "https://www.example.com/path?param=value&foo=bar&baz=batman&qux=", - } as unknown as RequestLike, - "@query-params" - ); - expect(result).to.equal(""); - }); - }); - - describe("buildSignatureInputString", () => { - describe("specification test cases", () => { - it("constructs minimal example", () => { - const components: Component[] = []; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: "test-key-rsa-pss", - alg: "rsa-pss-sha512", - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal( - '();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"' - ); - }); - it("constructs selective example", () => { - const components: Component[] = ["@authority", "Content-Type"]; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: "test-key-rsa-pss", - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal( - '("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - it("constructs full example", () => { - const components: Component[] = [ - "Date", - "@method", - "@path", - "@query", - "@authority", - "Content-Type", - "Digest", - "Content-Length", - ]; - const parameters: Parameters = { - created: new Date(1618884475000), - keyid: "test-key-rsa-pss", - }; - const inputString = buildSignatureInputString(components, parameters); - expect(inputString).to.equal( - '("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - }); - }); - - describe("buildSignedData", () => { - const testRequest: RequestLike = { - method: "POST", - url: "https://example.com/foo?param=value&pet=dog", - headers: { - Host: "example.com", - Date: "Tue, 20 Apr 2021 02:07:55 GMT", - "Content-Type": "application/json", - Digest: "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=", - "Content-Length": "18", - "Test-Structured-Field": - 'one-key="random", test-key="test-value", another-key=42', - }, - }; - - it("constructs minimal example", () => { - const components: Component[] = []; - const data = buildSignedData( - testRequest, - components, - '();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"' - ); - expect(data).to.equal( - '"@signature-params": ();created=1618884475;keyid="test-key-rsa-pss";alg="rsa-pss-sha512"' - ); - }); - - it("constructs selective example", () => { - const components: Component[] = ["@authority", "Content-Type"]; - const data = buildSignedData( - testRequest, - components, - '("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"' - ); - expect(data).to.equal( - '"@authority": example.com\n' + - '"content-type": application/json\n' + - '"@signature-params": ("@authority" "content-type");created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - - it("constructs structured-field dictionary example", () => { - const components: Component[] = [ - { header: "Test-Structured-Field", key: "test-key" }, - ]; - const data = buildSignedData( - testRequest, - components, - '("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"' - ); - expect(data).to.equal( - '"test-structured-field";key="test-key": "test-value"\n' + - '"@signature-params": ("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - - it("constructs structured-field dictionary value with a comma", () => { - const request: RequestLike = { - ...testRequest, - headers: { - ...testRequest.headers, - "Test-Structured-Field": 'test-key="test,value", other-key="other"', - }, - }; - const components: Component[] = [ - { header: "Test-Structured-Field", key: "test-key" }, - ]; - const data = buildSignedData( - request, - components, - '("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"' - ); - expect(data).to.equal( - '"test-structured-field";key="test-key": "test,value"\n' + - '"@signature-params": ("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - - it("constructs structured-field dictionary with req parameter", () => { - const response: ResponseLike = { - status: 200, - headers: {}, - }; - const components: Component[] = [ - { - header: "Test-Structured-Field", - key: "test-key", - parameters: new Map([["req", true]]), - }, - ]; - const data = buildSignedData( - { request: testRequest, response }, - components, - '("test-structured-field";key="test-key";req);created=1618884475;keyid="test-key-rsa-pss"' - ); - expect(data).to.equal( - '"test-structured-field";key="test-key";req: "test-value"\n' + - '"@signature-params": ("test-structured-field";key="test-key";req);created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - - it("constructs full example", () => { - const components: Component[] = [ - "Date", - "@method", - "@path", - "@query", - "@authority", - "Content-Type", - "Digest", - "Content-Length", - ]; - const data = buildSignedData( - testRequest, - components, - '("date" "@method" "@path" "@query" "@authority" "content-type" "digest" "content-length");created=1618884475;keyid="test-key-rsa-pss"' - ); - expect(data).to.equal( - '"date": Tue, 20 Apr 2021 02:07:55 GMT\n' + - '"@method": POST\n' + - '"@path": /foo\n' + - '"@query": ?param=value&pet=dog\n' + - '"@authority": example.com\n' + - '"content-type": application/json\n' + - '"digest": SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\n' + - '"content-length": 18\n' + - '"@signature-params": ("date" "@method" "@path" "@query" ' + - '"@authority" "content-type" "digest" "content-length")' + - ';created=1618884475;keyid="test-key-rsa-pss"' - ); - }); - }); - - describe("getUrl", () => { - it("should correctly construct a full URL from a RequestLike object with protocol and host", () => { - const message: RequestLike = { - method: "GET", - url: "/path", - protocol: "https", - headers: { - host: "www.example.com", - }, - }; - const result = getUrl(message, "@target-uri"); - expect(result.toString()).to.equal("https://www.example.com/path"); - }); - - it("should throw an error if the message does not contain a URL", () => { - const message = {} as RequestLike; - expect(() => getUrl(message, "@target-uri")).to.throw( - "@target-uri is only valid for requests" - ); - }); - }); -}); diff --git a/packages/http-message-sig/test/core.spec.ts b/packages/http-message-sig/test/core.spec.ts new file mode 100644 index 0000000..0148227 --- /dev/null +++ b/packages/http-message-sig/test/core.spec.ts @@ -0,0 +1,829 @@ +import { describe, expect, it } from "vitest"; +import { + appendSignature, + component, + componentIdentity, + createSignature, + createSignatureSync, + isSignatureError, + SignatureErrorCode, + Token, + verifySignature, + webcrypto, + type RequestDescriptor, + type SignatureFields, + type SignatureMessage, + type Verifier, + type VerificationPolicy, +} from "../src"; + +const request: RequestDescriptor = { + kind: "request", + method: "POST", + targetUri: "https://example.com/foo?param=Value&Pet=dog", + requestTarget: "/foo?param=Value&Pet=dog", + fields: [ + { name: "Host", value: "example.com" }, + { name: "Date", value: "Tue, 20 Apr 2021 02:07:55 GMT" }, + { name: "Content-Type", value: "application/json" }, + { + name: "Content-Digest", + value: "sha-512=:U0dWc2JHOGdkMjl5YkdRPTo=: ", + }, + { name: "Content-Length", value: "18" }, + ], +}; + +const permittedPolicy: VerificationPolicy = { + algorithms: ["test-alg"], + requiredComponents: [], + requiredParameters: [], + now: 1_618_884_500, +}; + +function descriptorWithSignature( + message: RequestDescriptor, + fields: SignatureFields +): RequestDescriptor { + return { + ...message, + fields: [ + ...message.fields, + { name: "Signature", value: fields.signature }, + { name: "Signature-Input", value: fields.signatureInput }, + ], + }; +} + +function replacingFields( + message: RequestDescriptor, + signature: string, + signatureInput: string +): RequestDescriptor { + return { + ...message, + fields: [ + ...message.fields.filter( + ({ name }) => + name.toLowerCase() !== "signature" && + name.toLowerCase() !== "signature-input" + ), + { name: "signature", value: signature }, + { name: "signature-input", value: signatureInput }, + ], + }; +} + +async function signed( + message: SignatureMessage = request, + components = ["@method", "@authority", "@path", "content-type"] +): Promise<{ + readonly fields: SignatureFields; + readonly base: Uint8Array; +}> { + let base = new Uint8Array(); + const fields = await createSignature(message, { + components, + parameters: { created: 1_618_884_475, alg: "test-alg", keyid: "key" }, + signer: { + algorithm: "test-alg", + sign(data) { + base = data.slice(); + return new Uint8Array([1, 2, 3]); + }, + }, + }); + return { fields, base }; +} + +function verifierFor(expectedBase: Uint8Array): Verifier { + return { + algorithm: "test-alg", + verify(data, signature) { + expect(data).toEqual(expectedBase); + expect(signature).toEqual(new Uint8Array([1, 2, 3])); + return true; + }, + }; +} + +describe("createSignature", () => { + it("builds the applicable RFC 9421 Appendix B request base", async () => { + let base = ""; + const fields = await createSignature(request, { + label: "sig-b24", + components: [ + "@method", + "@authority", + "@path", + "content-digest", + "content-length", + "content-type", + ], + parameters: { created: 1_618_884_475, keyid: "test-key-rsa-pss" }, + signer: { + algorithm: "rsa-pss-sha512", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array([1, 2, 3]); + }, + }, + }); + + expect(base).toBe( + [ + '"@method": POST', + '"@authority": example.com', + '"@path": /foo', + '"content-digest": sha-512=:U0dWc2JHOGdkMjl5YkdRPTo=:', + '"content-length": 18', + '"content-type": application/json', + '"@signature-params": ("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884475;keyid="test-key-rsa-pss"', + ].join("\n") + ); + expect(fields).toEqual({ + signature: "sig-b24=:AQID:", + signatureInput: + 'sig-b24=("@method" "@authority" "@path" "content-digest" "content-length" "content-type");created=1618884475;keyid="test-key-rsa-pss"', + }); + }); + + it("supports native Request observable values", async () => { + const native = new Request("https://example.com/resource", { + method: "PATCH", + headers: { "x-empty": "" }, + }); + let base = ""; + await createSignature(native, { + components: ["@method", "@target-uri", "x-empty"], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + }); + expect(base).toBe( + '"@method": PATCH\n"@target-uri": https://example.com/resource\n"x-empty": \n"@signature-params": ("@method" "@target-uri" "x-empty")' + ); + }); + + it("uses the last duplicate Structured Field member and parameter", async () => { + let base = ""; + await createSignature( + { + ...request, + fields: [ + ...request.fields, + { name: "Example", value: "a=1, a=2, b=3;p=old;p=new" }, + ], + }, + { + components: [ + component("example", { key: "a" }), + component("example", { key: "b" }), + ], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + + expect(base).toBe( + [ + '"example";key="a": 2', + '"example";key="b": 3;p=new', + '"@signature-params": ("example";key="a" "example";key="b")', + ].join("\n") + ); + }); + + it.each(["a=@1, a=2", "a=1;p=@1;p=2", 'a=%"old", a=2'])( + "rejects overwritten RFC 9651 syntax in %s", + async (value) => { + await expect( + createSignature( + { + ...request, + fields: [...request.fields, { name: "Example", value }], + }, + { + components: [component("example", { key: "a" })], + parameters: {}, + signer: { + algorithm: "test-alg", + sign: () => new Uint8Array(), + }, + } + ) + ).rejects.toMatchObject({ code: SignatureErrorCode.UnsupportedFeature }); + } + ); + + it("does not confuse RFC 8941 strings and tokens with extensions", async () => { + await expect( + createSignature( + { + ...request, + fields: [ + ...request.fields, + { name: "Example", value: 'a="user@example.com", b=percent%token' }, + ], + }, + { + components: [ + component("example", { key: "a" }), + component("example", { key: "b" }), + ], + parameters: {}, + signer: { + algorithm: "test-alg", + sign: () => new Uint8Array(), + }, + } + ) + ).resolves.toBeDefined(); + }); + + it("preserves descriptor method case and uses ? for an absent query", async () => { + let base = ""; + await createSignature( + { + kind: "request", + method: "cUsToM", + targetUri: "https://example.com/path", + fields: [], + }, + { + components: ["@method", "@query", "@target-uri"], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + expect(base).toContain('"@method": cUsToM'); + expect(base).toContain('"@query": ?'); + expect(base).toContain('"@target-uri": https://example.com/path'); + }); + + it("preserves descriptor target URI and request target without normalization", async () => { + let base = ""; + await createSignature( + { + kind: "request", + method: "GET", + targetUri: "https://example.com/a/../b/%7e?q=%2f", + requestTarget: "/a/../b/%7e?q=%2f", + fields: [], + }, + { + components: ["@target-uri", "@path", "@query", "@request-target"], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + expect(base).toContain( + '"@target-uri": https://example.com/a/../b/%7e?q=%2f' + ); + expect(base).toContain('"@path": /a/../b/%7e'); + expect(base).toContain('"@query": ?q=%2f'); + expect(base).toContain('"@request-target": /a/../b/%7e?q=%2f'); + }); + + it("rejects @request-target when Fetch cannot provide it", async () => { + await expect( + createSignature(new Request("https://example.com/path"), { + components: ["@request-target"], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.UnsupportedFeature }); + }); + + it("rejects descriptor injection and invalid status values", async () => { + await expect( + createSignature( + { ...request, fields: [{ name: "x", value: 'ok\n"@method": DELETE' }] }, + { + components: ["x"], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + } + ) + ).rejects.toMatchObject({ code: SignatureErrorCode.MalformedField }); + + await expect( + createSignature( + { kind: "response", status: 42, fields: [] }, + { + components: ["@status"], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + } + ) + ).rejects.toMatchObject({ code: SignatureErrorCode.InvalidComponent }); + }); + + it("distinguishes an empty field from a missing field", async () => { + await expect( + createSignature(request, { + components: ["x-empty"], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.MissingField }); + + let base = "not called"; + await createSignature( + { + ...request, + fields: [...request.fields, { name: "x-empty", value: "" }], + }, + { + components: ["x-empty"], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + expect(base).toContain('"x-empty": \n'); + }); + + it("combines ordered field occurrences and selects an SF dictionary member", async () => { + let base = ""; + await createSignature( + { + ...request, + fields: [ + ...request.fields, + { name: "x-list", value: "first" }, + { name: "X-List", value: "second" }, + { name: "example-dict", value: 'a=1, b=("x" 2);p' }, + ], + }, + { + components: ["x-list", component("example-dict", { key: "b" })], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + expect(base).toContain('"x-list": first, second'); + expect(base).toContain('"example-dict";key="b": ("x" 2);p'); + }); + + it("supports response and related request components", async () => { + let base = ""; + await createSignature( + { + kind: "response", + status: 201, + fields: [{ name: "content-type", value: "text/plain" }], + request, + }, + { + components: ["@status", component("@authority", { req: true })], + parameters: {}, + signer: { + algorithm: "test-alg", + sign(data) { + base = new TextDecoder().decode(data); + return new Uint8Array(); + }, + }, + } + ); + expect(base).toContain('"@status": 201'); + expect(base).toContain('"@authority";req: example.com'); + }); + + it("provides the straightforward synchronous API", () => { + const result = createSignatureSync(request, { + components: ["@method"], + parameters: { alg: "test-alg" }, + signer: { algorithm: "test-alg", sign: () => new Uint8Array([1]) }, + }); + expect(result.signature).toBe("sig1=:AQ==:"); + }); + + it("binds a claimed algorithm to the signer", async () => { + await expect( + createSignature(request, { + components: [], + parameters: { alg: "other" }, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.AlgorithmMismatch }); + }); +}); + +describe("webcrypto", () => { + it("creates algorithm-bound Ed25519 providers", async () => { + const keys = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]); + const signer = webcrypto.signer(keys.privateKey); + const verifier = webcrypto.verifier(keys.publicKey); + const data = new Uint8Array([1, 2, 3]); + const signature = await signer.sign(data); + + expect(signer.algorithm).toBe("ed25519"); + expect(verifier.algorithm).toBe("ed25519"); + await expect(verifier.verify(data, signature)).resolves.toBe(true); + }); +}); + +describe("component identities", () => { + it("returns the exact serialized component identity", () => { + expect( + componentIdentity(component("Example", { key: "a", req: true })) + ).toBe('"example";key="a";req'); + }); + + it("rejects equivalent duplicates regardless of parameter order", async () => { + await expect( + createSignature( + { + kind: "response", + status: 200, + fields: [], + request, + }, + { + components: [ + component("x", { key: "a", req: true }), + component("X", { req: true, key: "a" }), + ], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array() }, + } + ) + ).rejects.toMatchObject({ code: SignatureErrorCode.DuplicateComponent }); + }); + + it("rejects component confusion and unsupported features", () => { + expect(() => component("@authority", { key: "host" })).toThrowError( + expect.objectContaining({ code: SignatureErrorCode.InvalidComponent }) + ); + for (const value of [ + () => component("x", { sf: true }), + () => component("x", { bs: true }), + () => component("x", { tr: true }), + () => component("@query-param", { name: "a" }), + () => component("@unknown"), + ]) { + expect(value).toThrowError( + expect.objectContaining({ code: SignatureErrorCode.UnsupportedFeature }) + ); + } + }); +}); + +describe("appendSignature", () => { + it("clones and merges multiple signatures", async () => { + const first = await signed(); + const second = await createSignature(request, { + label: "sig2", + components: ["@path"], + parameters: {}, + signer: { algorithm: "test-alg", sign: () => new Uint8Array([4]) }, + }); + const original = new Headers({ untouched: "yes" }); + const once = appendSignature(original, first.fields); + const twice = appendSignature(once, second); + + expect(original.has("signature")).toBe(false); + expect(twice.get("signature")).toBe("sig1=:AQID:, sig2=:BA==:"); + expect(twice.get("signature-input")).toContain("sig1="); + expect(twice.get("signature-input")).toContain("sig2="); + }); + + it("rejects duplicate labels", async () => { + const value = await signed(); + const headers = appendSignature(new Headers(), value.fields); + expect(() => appendSignature(headers, value.fields)).toThrowError( + expect.objectContaining({ code: SignatureErrorCode.DuplicateLabel }) + ); + }); + + it("is atomic when an existing dictionary is malformed", async () => { + const value = await signed(); + const headers = new Headers({ + signature: "sig1=not-bytes", + "signature-input": 'sig1=("@method")', + }); + expect(() => appendSignature(headers, value.fields)).toThrowError( + expect.objectContaining({ code: SignatureErrorCode.MalformedField }) + ); + expect(headers.get("signature")).toBe("sig1=not-bytes"); + expect(headers.get("signature-input")).toBe('sig1=("@method")'); + }); +}); + +describe("verifySignature", () => { + it("returns authenticated data and the selected verifier", async () => { + const value = await signed(); + const verifier = verifierFor(value.base); + const result = await verifySignature( + descriptorWithSignature(request, value.fields), + { + policy: { + ...permittedPolicy, + requiredComponents: ["@method", "content-type"], + requiredParameters: ["created", "keyid"], + maxAge: 60, + }, + resolveVerifier(untrustedCandidate, context) { + expect(untrustedCandidate.label).toBe("sig1"); + expect(untrustedCandidate.algorithm).toBe("test-alg"); + expect(context).toEqual({ now: 1_618_884_500 }); + return verifier; + }, + } + ); + expect(result.verifier).toBe(verifier); + expect(result.label).toBe("sig1"); + expect(result.algorithm).toBe("test-alg"); + expect(result.parameters.keyid).toBe("key"); + expect(result.signature).toEqual(new Uint8Array([1, 2, 3])); + expect(Object.keys(result)).not.toContain("snapshot"); + }); + + it("requires a label for multiple signatures", async () => { + const first = await signed(); + const second = await createSignature(request, { + label: "sig2", + components: ["@path"], + parameters: { alg: "test-alg" }, + signer: { algorithm: "test-alg", sign: () => new Uint8Array([4]) }, + }); + const headers = appendSignature( + appendSignature(new Headers(), first.fields), + second + ); + const message = { + ...request, + fields: [ + ...request.fields, + { name: "signature", value: headers.get("signature") ?? "" }, + { + name: "signature-input", + value: headers.get("signature-input") ?? "", + }, + ], + }; + await expect( + verifySignature(message, { + policy: permittedPolicy, + resolveVerifier: () => verifierFor(first.base), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.LabelRequired }); + }); + + it("rejects mismatched label sets even with an explicit common label", async () => { + const value = await signed(); + const message = replacingFields( + descriptorWithSignature(request, value.fields), + `${value.fields.signature}, extra=:BA==:`, + value.fields.signatureInput + ); + await expect( + verifySignature(message, { + policy: permittedPolicy, + resolveVerifier: () => verifierFor(value.base), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.LabelMismatch }); + + await expect( + verifySignature(message, { + label: "sig1", + policy: permittedPolicy, + resolveVerifier: () => verifierFor(value.base), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.LabelMismatch }); + }); + + it("rejects non-lowercase parsed component identifiers", async () => { + const value = await signed(); + await expect( + verifySignature( + replacingFields( + descriptorWithSignature(request, value.fields), + value.fields.signature, + 'sig1=("@METHOD");alg="test-alg"' + ), + { + label: "sig1", + policy: permittedPolicy, + resolveVerifier: () => verifierFor(value.base), + } + ) + ).rejects.toMatchObject({ code: SignatureErrorCode.MalformedField }); + }); + + it("strictly rejects duplicate labels, duplicate parameters, and RFC 9651 values", async () => { + const value = await signed(); + const baseMessage = descriptorWithSignature(request, value.fields); + for (const message of [ + replacingFields( + baseMessage, + "sig1=:AQID:, sig1=:BA==:", + value.fields.signatureInput + ), + replacingFields( + baseMessage, + value.fields.signature, + 'sig1=("@method");created=1;created=2;alg="test-alg"' + ), + replacingFields( + baseMessage, + value.fields.signature, + 'sig1=("@method");created=1;x=@2;alg="test-alg"' + ), + ]) { + await expect( + verifySignature(message, { + label: "sig1", + policy: permittedPolicy, + resolveVerifier: () => verifierFor(value.base), + }) + ).rejects.toSatisfy(isSignatureError); + } + }); + + it("rejects malformed component parameters and equivalent parsed duplicates", async () => { + const value = await signed(); + for (const signatureInput of [ + 'sig1=("@authority";key="host");alg="test-alg"', + 'sig1=("@method";req=?0);alg="test-alg"', + 'sig1=("X";key="a";req "x";req;key="a");alg="test-alg"', + ]) { + await expect( + verifySignature( + replacingFields( + descriptorWithSignature(request, value.fields), + value.fields.signature, + signatureInput + ), + { + label: "sig1", + policy: permittedPolicy, + resolveVerifier: () => verifierFor(value.base), + } + ) + ).rejects.toSatisfy(isSignatureError); + } + }); + + it("binds the claimed algorithm to the selected verifier", async () => { + const value = await signed(); + await expect( + verifySignature(descriptorWithSignature(request, value.fields), { + policy: { ...permittedPolicy, algorithms: ["test-alg", "other"] }, + resolveVerifier: () => ({ algorithm: "other", verify: () => true }), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.AlgorithmMismatch }); + }); + + it("captures message data before awaiting the resolver", async () => { + const mutableFields = request.fields.map(({ name, value }) => ({ + name, + value, + })); + const mutableMessage: RequestDescriptor = { + ...request, + fields: mutableFields, + }; + const value = await signed(mutableMessage); + mutableFields.push( + { name: "signature", value: value.fields.signature }, + { name: "signature-input", value: value.fields.signatureInput } + ); + + await expect( + verifySignature(mutableMessage, { + policy: permittedPolicy, + async resolveVerifier() { + mutableFields[2].value = "text/plain"; + await Promise.resolve(); + return verifierFor(value.base); + }, + }) + ).resolves.toMatchObject({ label: "sig1" }); + }); + + it("rejects a false verifier result and does not run validate", async () => { + const value = await signed(); + let validated = false; + await expect( + verifySignature(descriptorWithSignature(request, value.fields), { + policy: { + ...permittedPolicy, + validate() { + validated = true; + }, + }, + resolveVerifier: () => ({ + algorithm: "test-alg", + verify: () => false, + }), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.VerificationFailed }); + expect(validated).toBe(false); + }); + + it("enforces timing and coverage before cryptography", async () => { + const value = await signed(); + let resolved = false; + await expect( + verifySignature(descriptorWithSignature(request, value.fields), { + policy: { + algorithms: ["test-alg"], + requiredComponents: ["@status"], + requiredParameters: ["created"], + maxAge: 1, + now: 1_618_884_500, + }, + resolveVerifier() { + resolved = true; + return verifierFor(value.base); + }, + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.PolicyViolation }); + expect(resolved).toBe(false); + }); + + it("runs custom policy validation only after cryptographic success", async () => { + const value = await signed(); + const order: string[] = []; + await expect( + verifySignature(descriptorWithSignature(request, value.fields), { + policy: { + ...permittedPolicy, + validate(authenticated) { + order.push(`validate:${authenticated.label}`); + return false; + }, + }, + resolveVerifier: () => ({ + algorithm: "test-alg", + verify() { + order.push("verify"); + return true; + }, + }), + }) + ).rejects.toMatchObject({ code: SignatureErrorCode.PolicyViolation }); + expect(order).toEqual(["verify", "validate:sig1"]); + }); + + it("preserves unknown RFC 8941 signature metadata", async () => { + const value = await createSignature(request, { + components: ["@method"], + parameters: { + alg: "test-alg", + extension: new Token("custom-token"), + bytes: new Uint8Array([4, 5]).buffer, + }, + signer: { algorithm: "test-alg", sign: () => new Uint8Array([1, 2, 3]) }, + }); + const result = await verifySignature( + descriptorWithSignature(request, value), + { + policy: permittedPolicy, + resolveVerifier: () => ({ algorithm: "test-alg", verify: () => true }), + } + ); + expect(String(result.parameters.extension)).toBe("custom-token"); + expect(result.parameters.bytes).toBeInstanceOf(ArrayBuffer); + }); +}); diff --git a/packages/http-message-sig/test/directory.spec.ts b/packages/http-message-sig/test/directory.spec.ts deleted file mode 100644 index 710b1d8..0000000 --- a/packages/http-message-sig/test/directory.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { directoryResponseHeaders, Signer } from "../src"; - -describe("directoryResponseHeaders", () => { - it("binds the response body to the request authority", async () => { - const created = new Date(1735689600000); - const expires = new Date(4889289600000); - const signature = new Uint8Array([1, 2, 3]); - const signer: Signer = { - keyid: "test-key", - alg: "ed25519", - async sign(data) { - expect(data).toBe( - [ - '"@authority";req: signature-agent.test', - '"content-digest": sha-256=:test-digest:', - '"@signature-params": ("@authority";req "content-digest");created=1735689600;keyid="test-key";alg="ed25519";expires=4889289600;tag="http-message-signatures-directory"', - ].join("\n") - ); - return signature; - }, - }; - - const headers = await directoryResponseHeaders( - { - request: { - method: "GET", - url: "https://signature-agent.test/.well-known/http-message-signatures-directory", - headers: {}, - }, - response: { - status: 200, - headers: { "content-digest": "sha-256=:test-digest:" }, - }, - }, - [signer], - { created, expires } - ); - - expect(headers["Signature-Input"]).toBe( - 'binding0=("@authority";req "content-digest");created=1735689600;keyid="test-key";alg="ed25519";expires=4889289600;tag="http-message-signatures-directory"' - ); - expect(headers.Signature).toBe("binding0=:AQID:"); - }); -}); diff --git a/packages/http-message-sig/test/parse.spec.ts b/packages/http-message-sig/test/parse.spec.ts deleted file mode 100644 index 331da85..0000000 --- a/packages/http-message-sig/test/parse.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseSignatureInputHeader, parseSignatureHeader } from "../src/parse"; - -describe("parse.ts", () => { - describe("parseParametersHeader", () => { - it("should parse a valid Signature-Input header", () => { - const header = - 'sig1=("@method" "@path" "@authority" "digest");created=1618884475;expires=1618888075;foo="ba;ra";zod'; - const result = parseSignatureInputHeader(header); - - expect(result).to.deep.equal({ - key: "sig1", - components: ["@method", "@path", "@authority", "digest"], - parameters: { - created: new Date(1618884475 * 1000), - expires: new Date(1618888075 * 1000), - foo: "ba;ra", - zod: true, - }, - }); - }); - - it("should parse a Signature-Input header with base64 encoded nonce", () => { - // SGVsbG8gd29ybGQ= is "Hello world" in base64 - const header = - 'sig1=("@authority");created=1618884475;expires=1618888075;nonce="SGVsbG8gd29ybGQ="'; - const result = parseSignatureInputHeader(header); - - expect(result).to.deep.equal({ - key: "sig1", - components: ["@authority"], - parameters: { - created: new Date(1618884475 * 1000), - expires: new Date(1618888075 * 1000), - nonce: "SGVsbG8gd29ybGQ=", - }, - }); - }); - - it("should parse a structured-field component with req", () => { - const header = - 'sig1=("@status" "signature-agent";key="agent2";req);created=1618884475'; - const result = parseSignatureInputHeader(header); - - expect(result).to.deep.equal({ - key: "sig1", - components: [ - "@status", - { - header: "signature-agent", - key: "agent2", - parameters: new Map([ - ["key", "agent2"], - ["req", true], - ]), - }, - ], - parameters: { - created: new Date(1618884475 * 1000), - }, - }); - }); - - it("should throw an error on an invalid components string", () => { - const header = "sig1=(@method, @path, @authority, digest);invalid=foo"; - expect(() => parseSignatureInputHeader(header)).to.throw( - "Invalid Signature-Input header; failed to parse as RFC 8941 dictionary" - ); - }); - }); - - describe("parseSignatureHeader", () => { - it("should parse a valid Signature header", () => { - const key = "sig1"; - const header = "sig1=:YWJjMTIzZGVmNDU2:"; - - const result = parseSignatureHeader(key, header); - expect(result).to.deep.equal( - new Uint8Array([97, 98, 99, 49, 50, 51, 100, 101, 102, 52, 53, 54]) - ); - }); - - it("should throw an error on a key mismatch", () => { - const key = "sig1"; - const header = "wrong-key=:YWJjMTIzZGVmNDU2:"; - - expect(() => parseSignatureHeader(key, header)).to.throw( - "Invalid Signature header. Key mismatch wrong-key !== sig1" - ); - }); - }); -}); diff --git a/packages/http-message-sig/test/sign.spec.ts b/packages/http-message-sig/test/sign.spec.ts deleted file mode 100644 index 6f49a34..0000000 --- a/packages/http-message-sig/test/sign.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { signatureHeaders, Signer, RequestLike, ResponseLike } from "../src"; -import { encode as base64Encode } from "../src/base64"; - -const sampleRequest: RequestLike = { - method: "POST", - url: "https://example.com/path?query=string", - headers: { - "Content-Type": "application/json", - Digest: "SHA-256=abcdef", - }, -}; -const sampleResponse: ResponseLike = { - status: 200, - headers: { - "Content-Type": "text/plain", - Digest: "SHA-256=abcdef", - "X-Total": "200", - }, -}; -const created = new Date(1681004344000); - -describe("sign", () => { - const expectedHash = new Uint8Array([ - 227, 176, 196, 66, 152, 252, 28, 20, 154, 251, 244, 200, 153, 111, 185, 36, - 39, 174, 65, 228, 100, 155, 147, 76, 164, 149, 153, 27, 120, 82, 184, 85, - ]); - const expectedHashBase64 = base64Encode(expectedHash); - - describe("request", () => { - it("should apply default components", async () => { - const expectedData = [ - '"@method": POST', - '"@path": /path', - '"@query": ?query=string', - '"@authority": example.com', - '"content-type": application/json', - '"digest": SHA-256=abcdef', - '"@signature-params": ("@method" "@path" "@query" "@authority" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedRequest = await signatureHeaders(sampleRequest, { - signer, - created, - }); - expect(signedRequest).to.deep.equal({ - Signature: `sig1=:${expectedHashBase64}:`, - "Signature-Input": - 'sig1=("@method" "@path" "@query" "@authority" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - - it("should apply custom components", async () => { - const components = ["@authority", "@method", "@path", "digest"]; - const expectedData = [ - '"@authority": example.com', - '"@method": POST', - '"@path": /path', - '"digest": SHA-256=abcdef', - '"@signature-params": ("@authority" "@method" "@path" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedRequest = await signatureHeaders(sampleRequest, { - signer, - components, - created, - }); - expect(signedRequest).to.deep.equal({ - Signature: `sig1=:${expectedHashBase64}:`, - "Signature-Input": - 'sig1=("@authority" "@method" "@path" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - - it("should apply the key name", async () => { - const components = ["@authority"]; - const expectedData = [ - '"@authority": example.com', - '"@signature-params": ("@authority");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedRequest = await signatureHeaders(sampleRequest, { - components, - signer, - created, - key: "foo", - }); - expect(signedRequest).to.deep.equal({ - Signature: `foo=:${expectedHashBase64}:`, - "Signature-Input": - 'foo=("@authority");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - }); - - describe("response", () => { - it("should apply default components", async () => { - const expectedData = [ - '"@status": 200', - '"content-type": text/plain', - '"digest": SHA-256=abcdef', - '"@signature-params": ("@status" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedResponse = await signatureHeaders(sampleResponse, { - signer, - created, - }); - expect(signedResponse).to.deep.equal({ - Signature: `sig1=:${expectedHashBase64}:`, - "Signature-Input": - 'sig1=("@status" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - - it("should apply custom components", async () => { - const components = ["@status", "digest", "x-total"]; - const expectedData = [ - '"@status": 200', - '"digest": SHA-256=abcdef', - '"x-total": 200', - '"@signature-params": ("@status" "digest" "x-total");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedResponse = await signatureHeaders(sampleResponse, { - signer, - components, - created, - }); - expect(signedResponse).to.deep.equal({ - Signature: `sig1=:${expectedHashBase64}:`, - "Signature-Input": - 'sig1=("@status" "digest" "x-total");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - - it("should honor the `req` parameter", async () => { - const components = [ - "@status", - "digest", - "x-total", - { - name: "@authority", - parameters: new Map([["req", true]]), - }, - ]; - const expectedData = [ - '"@status": 200', - '"digest": SHA-256=abcdef', - '"x-total": 200', - '"@authority";req: example.com', - '"@signature-params": ("@status" "digest" "x-total" "@authority";req);created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const signer: Signer = { - keyid: "test-key", - alg: "hmac-sha256", - async sign(data) { - expect(data).to.equal(expectedData); - return expectedHash; - }, - }; - - const signedResponse = await signatureHeaders( - { - response: sampleResponse, - request: sampleRequest, - }, - { - signer, - components, - created, - } - ); - expect(signedResponse).to.deep.equal({ - Signature: `sig1=:${expectedHashBase64}:`, - "Signature-Input": - 'sig1=("@status" "digest" "x-total" "@authority";req);created=1681004344;keyid="test-key";alg="hmac-sha256"', - }); - }); - }); -}); diff --git a/packages/http-message-sig/test/verify.spec.ts b/packages/http-message-sig/test/verify.spec.ts deleted file mode 100644 index fdf48c8..0000000 --- a/packages/http-message-sig/test/verify.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { RequestLike, ResponseLike, verify, Verify } from "../src"; -import { encode as base64Encode } from "../src/base64"; - -const hash = new Uint8Array([ - 227, 176, 196, 66, 152, 252, 28, 20, 154, 251, 244, 200, 153, 111, 185, 36, - 39, 174, 65, 228, 100, 155, 147, 76, 164, 149, 153, 27, 120, 82, 184, 85, -]); -const hashBase64 = base64Encode(hash); - -const sampleRequest: RequestLike = { - method: "POST", - url: "https://example.com/path?query=string", - headers: { - "content-type": "application/json", - digest: "SHA-256=abcdef", - signature: `sig1=:${hashBase64}:`, - "signature-input": - 'sig1=("@method" "@path" "@query" "@authority" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }, -}; -const sampleResponse: ResponseLike = { - status: 200, - headers: { - "content-type": "text/plain", - "x-Total": "200", - digest: "SHA-256=abcdef", - signature: `sig1=:${hashBase64}:`, - "signature-input": - 'sig1=("@status" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - }, -}; - -describe("verify", () => { - describe("request", () => { - const expectedData = [ - '"@method": POST', - '"@path": /path', - '"@query": ?query=string', - '"@authority": example.com', - '"content-type": application/json', - '"digest": SHA-256=abcdef', - '"@signature-params": ("@method" "@path" "@query" "@authority" "content-type" "digest");created=1681004344;keyid="test-key";alg="hmac-sha256"', - ].join("\n"); - - const verifySignature: Verify = async (data, signature, params) => { - expect(data).to.equal(expectedData); - expect(signature).to.deep.equal(hash); - expect(params).to.deep.equal({ - alg: "hmac-sha256", - keyid: "test-key", - created: new Date(1681004344 * 1000), - }); - return "success"; - }; - - it("should verify a request", async () => { - const result = await verify(sampleRequest, verifySignature); - expect(result).to.eq("success"); - }); - - it("should throw an error if the request is not signed", async () => { - delete (sampleRequest.headers as Record).signature; - - try { - await verify(sampleRequest, verifySignature); - expect.fail("Expected an error to be thrown"); - } catch (err) { - expect(err.message).to.eq("Message does not contain Signature header"); - } - }); - - it("should throw an error on a key mismatch", async () => { - sampleRequest.headers["signature"] = `sig2=:${hashBase64}:`; - - try { - await verify(sampleRequest, verifySignature); - expect.fail("Expected an error to be thrown"); - } catch (err) { - expect(err.message).to.eq( - "Invalid Signature header. Key mismatch sig2 !== sig1" - ); - } - }); - - it("should throw an error if Signature-Input header is missing", async () => { - delete sampleRequest.headers["signature-input"]; - - try { - await verify(sampleRequest, verifySignature); - expect.fail("Expected an error to be thrown"); - } catch (err) { - expect(err.message).to.eq( - "Message does not contain Signature-Input header" - ); - } - }); - }); - - describe("response", () => { - const verifySignature: Verify = async (_, signature, params) => { - expect(signature).to.deep.equal(hash); - expect(params).to.deep.equal({ - alg: "hmac-sha256", - keyid: "test-key", - created: new Date(1681004344 * 1000), - }); - return "success"; - }; - - it("should verify a response", async () => { - const result = await verify(sampleResponse, verifySignature); - expect(result).to.eq("success"); - }); - }); -}); diff --git a/packages/web-bot-auth/README.md b/packages/web-bot-auth/README.md index 926aab6..6e61ec1 100644 --- a/packages/web-bot-auth/README.md +++ b/packages/web-bot-auth/README.md @@ -16,8 +16,8 @@ TypeScript helpers for Web Bot Auth, as described in [draft-meunier-webbotauth-h ## Features -- JWK Thumbprint pre-compute -- JWK Thumbprint when passing a hash and encoding function +- Web Bot Auth request signing and verification policy +- Ed25519 and RSA-PSS/SHA-512 JWK signers and verifiers - `Signature-Agent`, registry, and Signature Agent Card parsers - TypeScript types @@ -33,7 +33,7 @@ To help debug `web-both-auth` HTTPS requests, Cloudflare Research provides a tes ### Signing ```typescript -import { recommendedComponents, signatureHeaders } from "web-bot-auth"; +import { generateNonce, sign } from "web-bot-auth"; import { signerFromJWK } from "web-bot-auth/crypto"; const signatureAgent = 'sig1="https://signature-agent.test";type=directory'; @@ -46,28 +46,26 @@ const request = new Request("https://example.com", { const RFC_9421_ED25519_TEST_KEY = { kty: "OKP", crv: "Ed25519", + alg: "EdDSA", kid: "test-key-ed25519", d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU", x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", }; const now = new Date(); -const headers = await signatureHeaders( - request, - await signerFromJWK(RFC_9421_ED25519_TEST_KEY), - { - created: now, - components: recommendedComponents("sig1"), - expires: new Date(now.getTime() + 300_000), // now + 5 min - } -); +const fields = await sign(request, { + signer: await signerFromJWK(RFC_9421_ED25519_TEST_KEY), + created: now, + expires: new Date(now.getTime() + 300_000), + nonce: generateNonce(), +}); // Et voila! Here is our signed request. const signedRequest = new Request("https://example.com", { headers: { - Signature: headers["Signature"], + Signature: fields.signature, "Signature-Agent": signatureAgent, - "Signature-Input": headers["Signature-Input"], + "Signature-Input": fields.signatureInput, }, }); ``` @@ -82,6 +80,7 @@ import { verifierFromJWK } from "web-bot-auth/crypto"; const RFC_9421_ED25519_TEST_KEY = { kty: "OKP", crv: "Ed25519", + alg: "EdDSA", kid: "test-key-ed25519", x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", }; @@ -89,13 +88,23 @@ const RFC_9421_ED25519_TEST_KEY = { // Reusing the incoming request signed in the above section const signedRequest = new Request("https://example.com", { headers: { - Signature: headers["Signature"], + Signature: fields.signature, "Signature-Agent": signatureAgent, - "Signature-Input": headers["Signature-Input"], + "Signature-Input": fields.signatureInput, }, }); -await verify(signedRequest, await verifierFromJWK(RFC_9421_ED25519_TEST_KEY)); +const verifier = await verifierFromJWK(RFC_9421_ED25519_TEST_KEY); +const authenticated = await verify(signedRequest, { + // Resolve only from trusted local configuration. The candidate is untrusted. + resolver: (candidate) => { + if (candidate.keyid !== verifier.keyid) throw new Error("unknown key"); + return verifier; + }, + validate: ({ nonce }) => { + // Atomically reject a nonce already present in your replay cache. + }, +}); ``` ## Security Considerations diff --git a/packages/web-bot-auth/package.json b/packages/web-bot-auth/package.json index 7edfd00..67ffc3b 100644 --- a/packages/web-bot-auth/package.json +++ b/packages/web-bot-auth/package.json @@ -1,6 +1,6 @@ { "name": "web-bot-auth", - "version": "0.1.3", + "version": "0.2.0", "description": "Web Bot Authentication using HTTP Message Signatures", "main": "./dist/index.cjs", "module": "./dist/index.mjs", @@ -33,7 +33,7 @@ }, "scripts": { "build": "tsdown src/index.ts src/crypto.ts --format esm,cjs --target es2021", - "generate-test-vectors": "npm run build -w http-message-sig && npm run build && node scripts/test-vectors.ts test/test_data", + "generate-test-vectors": "npm run build -w http-message-sig -w jsonwebkey-thumbprint && npm run build && node scripts/test-vectors.ts test/test_data", "prepublishOnly": "npm run build", "test": "vitest", "watch": "npm run build -- --watch src" @@ -55,7 +55,7 @@ }, "homepage": "https://github.com/cloudflare/web-bot-auth#readme", "dependencies": { - "http-message-sig": "^0.2.0", + "http-message-sig": "^0.3.0", "jsonwebkey-thumbprint": "^0.1.0", "structured-headers": "2.0.3" } diff --git a/packages/web-bot-auth/scripts/test-vectors.ts b/packages/web-bot-auth/scripts/test-vectors.ts index 5e5f8a0..7b32b5f 100644 --- a/packages/web-bot-auth/scripts/test-vectors.ts +++ b/packages/web-bot-auth/scripts/test-vectors.ts @@ -4,14 +4,17 @@ /// /// It takes one positional argument: [directory] which is where the vectors should be written in JSON -import type { Signer } from "http-message-sig"; +import type { + RequestDescriptor, + ResponseDescriptor, + Signer, +} from "http-message-sig"; -const { recommendedComponents, signatureHeaders } = - await import("../dist/index.mjs"); +const { sign } = await import("../dist/index.mjs"); const { signerFromJWK } = await import("../dist/crypto.mjs"); -const { directoryResponseHeaders } = await import("http-message-sig"); +const { component, createSignature } = await import("http-message-sig"); const crypto = await import("crypto"); const fs = await import("fs"); @@ -104,12 +107,12 @@ async function generateTestVectors(jwk: JsonWebKey): Promise { const label = "sig1"; const nonce = nonceFor(jwk, label); let request = new Request(ORIGIN_URL); - const signedHeaders = await signatureHeaders(request, signer, { - components: recommendedComponents(), + const signedHeaders = await sign(request, { + signer, created, expires, nonce, - key: label, + label, }); const labelWithAgent = "sig2"; @@ -120,12 +123,13 @@ async function generateTestVectors(jwk: JsonWebKey): Promise { "Signature-Agent": `${signatureAgentKey}="${SIGNATURE_AGENT_HEADER}"`, }, }); - const signedHeadersWithAgent = await signatureHeaders(request, signer, { - components: recommendedComponents(signatureAgentKey), + const signedHeadersWithAgent = await sign(request, { + signer, created, expires, nonce: nonceWithAgent, - key: labelWithAgent, + label: labelWithAgent, + signatureAgentKey, }); return [ @@ -136,8 +140,8 @@ async function generateTestVectors(jwk: JsonWebKey): Promise { expires_ms: expires.getTime(), nonce, label, - signature: signedHeaders["Signature"], - signature_input: signedHeaders["Signature-Input"], + signature: signedHeaders.signature, + signature_input: signedHeaders.signatureInput, }, { key: jwk, @@ -146,9 +150,9 @@ async function generateTestVectors(jwk: JsonWebKey): Promise { expires_ms: expires.getTime(), nonce: nonceWithAgent, label: labelWithAgent, - signature: signedHeadersWithAgent["Signature"], - signature_input: signedHeadersWithAgent["Signature-Input"], - signature_agent: request.headers.get("Signature-Agent"), + signature: signedHeadersWithAgent.signature, + signature_input: signedHeadersWithAgent.signatureInput, + signature_agent: request.headers.get("Signature-Agent") ?? undefined, signature_agent_key: signatureAgentKey, }, ]; @@ -172,36 +176,49 @@ async function generateDirectoryResponseVector( const body = JSON.stringify({ keys: [publicKey] }); const contentDigest = `sha-256=:${crypto.createHash("sha256").update(body).digest("base64")}:`; const request = { + kind: "request", method: "GET", - url: "https://signature-agent.test/.well-known/http-message-signatures-directory", - headers: { - accept: "application/http-message-signatures-directory+json", - }, - }; + targetUri: + "https://signature-agent.test/.well-known/http-message-signatures-directory", + fields: [ + { + name: "accept", + value: "application/http-message-signatures-directory+json", + }, + ], + } satisfies RequestDescriptor; const response = { + kind: "response", status: 200, - headers: { - "content-type": "application/http-message-signatures-directory+json", - "content-digest": contentDigest, - }, - }; + fields: [ + { + name: "content-type", + value: "application/http-message-signatures-directory+json", + }, + { name: "content-digest", value: contentDigest }, + ], + request, + } satisfies ResponseDescriptor; let signatureBase: string | undefined; const capturingSigner: Signer = { - keyid: signer.keyid, - alg: signer.alg, + algorithm: signer.algorithm, async sign(data) { - signatureBase = data; + signatureBase = new TextDecoder().decode(data); return signer.sign(data); }, }; - const signatureHeaders = await directoryResponseHeaders( - { request, response }, - [capturingSigner], - { - created: new Date(1735689600000), - expires: new Date(4889289600000), - } - ); + const signatureHeaders = await createSignature(response, { + label: "binding0", + signer: capturingSigner, + components: [component("@authority", { req: true }), "content-digest"], + parameters: { + created: 1735689600, + keyid: signer.keyid, + alg: signer.algorithm, + expires: 4889289600, + tag: "http-message-signatures-directory", + }, + }); if (signatureBase === undefined) { throw new Error("directory response was not signed"); } @@ -211,12 +228,17 @@ async function generateDirectoryResponseVector( public_key: publicKey, request: { method: "GET", - target_url: request.url, - headers: request.headers, + target_url: request.targetUri, + headers: { accept: request.fields[0].value }, }, response: { status: 200, - headers: { ...response.headers, ...signatureHeaders }, + headers: { + "content-type": response.fields[0].value, + "content-digest": response.fields[1].value, + Signature: signatureHeaders.signature, + "Signature-Input": signatureHeaders.signatureInput, + }, body, }, signature_base: signatureBase, diff --git a/packages/web-bot-auth/src/crypto.ts b/packages/web-bot-auth/src/crypto.ts index dcbe47a..be09e67 100644 --- a/packages/web-bot-auth/src/crypto.ts +++ b/packages/web-bot-auth/src/crypto.ts @@ -1,182 +1,92 @@ -import { type Algorithm, type Signer } from "http-message-sig"; +import { webcrypto } from "http-message-sig"; import { jwkThumbprint as jwkToKeyID } from "jsonwebkey-thumbprint"; -import { b64ToB64NoPadding, b64ToB64URL, u8ToB64 } from "./base64"; -import type { VerificationParams, Verify } from "./index"; +import type { WebBotAlgorithm, WebBotSigner, WebBotVerifier } from "./index"; -export const helpers = { - WEBCRYPTO_SHA256: (b: BufferSource) => crypto.subtle.digest("SHA-256", b), - BASE64URL_DECODE: (u: ArrayBuffer) => - 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); - } +function base64Url(buffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); } -export class RSAPSSSHA512Signer implements Signer { - public alg: Algorithm = "rsa-pss-sha512"; - public keyid: string; - private privateKey: CryptoKey; +async function keyid(jwk: JsonWebKey): Promise { + return jwkToKeyID( + jwk, + (value) => crypto.subtle.digest("SHA-256", value), + base64Url + ); +} - constructor(keyid: string, privateKey: CryptoKey) { - this.keyid = keyid; - this.privateKey = privateKey; +function jwkAlgorithm(jwk: JsonWebKey): WebBotAlgorithm { + if (jwk.kty === "OKP" && jwk.crv === "Ed25519") { + if (jwk.alg !== undefined && jwk.alg !== "EdDSA") { + throw new Error("Ed25519 JWK alg must be EdDSA when present"); + } + return "ed25519"; } - - 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); + if (jwk.kty === "RSA" && jwk.alg === "PS512") { + return "rsa-pss-sha512"; } + throw new Error("JWK must be Ed25519 or RSA-PSS/SHA-512"); +} - 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); - } +function importAlgorithm( + algorithm: WebBotAlgorithm +): AlgorithmIdentifier | RsaHashedImportParams { + return algorithm === "ed25519" + ? { name: "Ed25519" } + : { name: "RSA-PSS", hash: "SHA-512" }; } -export function signerFromJWK(jwk: JsonWebKey): Promise { - switch (jwk.kty) { - case "OKP": - if (jwk.crv === "Ed25519") { - return Ed25519Signer.fromJWK(jwk); - } - 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); - } - throw new Error(`Unsupported algorithm: ${jwk.alg}`); - default: - throw new Error(`Unsupported key type: ${jwk.kty}`); +function publicJwk(jwk: JsonWebKey, algorithm: WebBotAlgorithm): JsonWebKey { + if (algorithm === "ed25519") { + return { kty: "OKP", crv: "Ed25519", alg: "EdDSA", x: jwk.x }; } + return { kty: "RSA", alg: "PS512", e: jwk.e, n: jwk.n }; } -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"); - } - }; +export async function signerFromJWK(jwk: JsonWebKey): Promise { + const algorithm = jwkAlgorithm(jwk); + const key = await crypto.subtle.importKey( + "jwk", + jwk, + importAlgorithm(algorithm), + false, + ["sign"] + ); + const signer = webcrypto.signer(key); + if (signer.algorithm !== algorithm) { + throw new Error("imported CryptoKey algorithm does not match JWK"); + } + return Object.freeze({ + algorithm, + keyid: await keyid(jwk), + sign(data: Uint8Array) { + return signer.sign(data); + }, + }); } -export async function verifierFromJWK(jwk: JsonWebKey): Promise> { - let key: CryptoKey; - switch (jwk.kty) { - case "OKP": - if (jwk.crv === "Ed25519") { - key = await crypto.subtle.importKey( - "jwk", - { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, - { name: "Ed25519" }, - true, - ["verify"] - ); - break; - } - 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( - "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}`); +export async function verifierFromJWK( + jwk: JsonWebKey +): Promise { + const algorithm = jwkAlgorithm(jwk); + const key = await crypto.subtle.importKey( + "jwk", + publicJwk(jwk, algorithm), + importAlgorithm(algorithm), + false, + ["verify"] + ); + const verifier = webcrypto.verifier(key); + if (verifier.algorithm !== algorithm) { + throw new Error("imported CryptoKey algorithm does not match JWK"); } - return verifier(key); + return Object.freeze({ + algorithm, + keyid: await keyid(jwk), + verify(data: Uint8Array, signature: Uint8Array) { + return verifier.verify(data, signature); + }, + }); } diff --git a/packages/web-bot-auth/src/index.ts b/packages/web-bot-auth/src/index.ts index 8be5a08..1c26a90 100644 --- a/packages/web-bot-auth/src/index.ts +++ b/packages/web-bot-auth/src/index.ts @@ -1,241 +1,414 @@ -import * as httpsig from "http-message-sig"; -export { - HTTP_MESSAGE_SIGNATURES_DIRECTORY, - type Algorithm, - MediaType, - type SignatureHeaders, +import { + component, + createSignature, + createSignatureSync, + SignatureError, + SignatureErrorCode, + verifySignature, + type ComponentDescriptor, + type RequestDescriptor, + type SignatureComponent, + type SignatureFields, type Signer, type SignerSync, - type SignOptions, - type SignSyncOptions, - Tag, - directoryResponseHeaders, + type UntrustedSignatureCandidate, + type VerifiedSignature, + type Verifier, } from "http-message-sig"; -export { jwkThumbprint as jwkToKeyID } from "jsonwebkey-thumbprint"; +import { jwkThumbprint as jwkToKeyID } from "jsonwebkey-thumbprint"; +import { + parseRegistry, + parseSignatureAgentCard, + parseSignatureAgentHeader, + type JSONWebKeySet, + type SignatureAgentCard, + type SignatureAgentDiscoveryType, + type SignatureAgentEntry, + type SignatureAgentHeader, + type WebBotAuthMetadata, +} from "./registry"; -import { b64Tou8, u8ToB64 } from "./base64"; -export { helpers } from "./crypto"; +export { jwkToKeyID }; +export { + parseRegistry, + parseSignatureAgentCard, + parseSignatureAgentHeader, + type JSONWebKeySet, + type SignatureAgentCard, + type SignatureAgentDiscoveryType, + type SignatureAgentEntry, + type SignatureAgentHeader, + type WebBotAuthMetadata, +}; +export type { RequestDescriptor, SignatureFields } from "http-message-sig"; 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[] = [ - "@authority", - SIGNATURE_AGENT_HEADER, -]; +export const HTTP_MESSAGE_SIGNATURES_DIRECTORY = + "/.well-known/http-message-signatures-directory"; export const NONCE_LENGTH_IN_BYTES = 64; -export interface SignatureParams { - created: Date; - expires: Date; - nonce?: string; - key?: string; - components?: httpsig.Component[]; +export type WebBotAlgorithm = "ed25519" | "rsa-pss-sha512"; + +export interface WebBotSigner extends Signer { + readonly algorithm: WebBotAlgorithm; + readonly keyid: string; } -export interface VerificationParams { - keyid: string; - created: Date; - expires: Date; - tag: typeof HTTP_MESSAGE_SIGNATURE_TAG; - nonce?: string; +export interface WebBotSignerSync extends SignerSync { + readonly algorithm: WebBotAlgorithm; + readonly keyid: string; } -export function generateNonce(): string { - const nonceBytes = new Uint8Array(NONCE_LENGTH_IN_BYTES); - crypto.getRandomValues(nonceBytes); - return u8ToB64(nonceBytes); +export interface WebBotVerifier extends Verifier { + readonly algorithm: WebBotAlgorithm; + readonly keyid: string; +} + +export interface SignOptions { + readonly signer: WebBotSigner; + readonly expires: Date; + readonly created?: Date; + readonly nonce?: string; + readonly label?: string; + readonly signatureAgentKey?: string; + readonly target?: "@authority" | "@target-uri"; + readonly additionalComponents?: readonly SignatureComponent[]; +} + +export interface SignSyncOptions extends Omit { + readonly signer: WebBotSignerSync; +} + +export interface UntrustedWebBotSignatureCandidate { + readonly keyid: string; + readonly algorithm: WebBotAlgorithm; + readonly signatureAgent?: SignatureAgentEntry; +} + +export interface VerifiedWebBotSignature< + V extends WebBotVerifier = WebBotVerifier, +> extends VerifiedSignature { + readonly keyid: string; + readonly created: Date; + readonly expires: Date; + readonly tag: typeof HTTP_MESSAGE_SIGNATURE_TAG; + readonly nonce?: string; + readonly signatureAgent?: SignatureAgentEntry; +} + +export interface VerifyOptions { + readonly resolver: ( + candidate: UntrustedWebBotSignatureCandidate + ) => V | Promise; + readonly algorithms?: readonly WebBotAlgorithm[]; + readonly label?: string; + readonly maxAge?: number; + readonly clockSkew?: number; + readonly now?: Date; + readonly validate?: ( + signature: VerifiedWebBotSignature + ) => boolean | void | Promise; +} + +function policyError(message: string): never { + throw new SignatureError(SignatureErrorCode.PolicyViolation, message); +} + +function seconds(date: Date, name: string): number { + const milliseconds = date.getTime(); + if (!Number.isFinite(milliseconds)) { + return policyError(`${name} must be a valid date`); + } + return Math.floor(milliseconds / 1000); +} + +function base64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +function base64Url(bytes: Uint8Array): string { + return base64(bytes) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); } -export function validateNonce(nonce: string): boolean { +function decodeBase64(value: string): Uint8Array | undefined { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const remainder = normalized.length % 4; + if (remainder === 1) return undefined; + const padded = normalized + "=".repeat((4 - remainder) % 4); try { - return b64Tou8(nonce).length === NONCE_LENGTH_IN_BYTES; + return Uint8Array.from(atob(padded), (character) => + character.charCodeAt(0) + ); } catch { - return false; - } -} - -export function recommendedComponents( - signatureAgentKey?: string -): httpsig.Component[] { - if (signatureAgentKey) { - return [ - "@authority", - { header: SIGNATURE_AGENT_HEADER, key: signatureAgentKey }, - ]; - } - return ["@authority"]; -} - -function getSigningOptions< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, - params: SignatureParams -): Omit { - 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"); - } + return undefined; } - const signatureAgent = httpsig.extractHeader( - httpsig.resolveMessageKind(message), - SIGNATURE_AGENT_HEADER +} + +export function generateNonce(): string { + const nonce = new Uint8Array(NONCE_LENGTH_IN_BYTES); + crypto.getRandomValues(nonce); + return base64(nonce); +} + +export function validateNonce(nonce: unknown): nonce is string { + if (typeof nonce !== "string") return false; + const decoded = decodeBase64(nonce); + return ( + decoded !== undefined && + decoded.length === NONCE_LENGTH_IN_BYTES && + (base64(decoded) === nonce || base64Url(decoded) === nonce) ); - 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` - ); +} + +function requestHeader( + request: Request | RequestDescriptor, + name: string +): string | undefined { + if ("kind" in request) { + const values = request.fields + .filter((field) => field.name.toLowerCase() === name) + .map((field) => field.value.trim()); + return values.length === 0 ? undefined : values.join(", "); + } + return request.headers.get(name) ?? undefined; +} + +interface SignatureAgentSelection { + readonly component: SignatureComponent; +} + +function signingSignatureAgent( + request: Request | RequestDescriptor, + key: string +): SignatureAgentSelection | undefined { + const header = requestHeader(request, SIGNATURE_AGENT_HEADER); + if (header === undefined) return undefined; + const parsed = parseSignatureAgentHeader(header); + if (parsed.kind === "legacy") { + const entry = parsed.entries[0]; + if (entry === undefined) return policyError("Signature-Agent is empty"); + return Object.freeze({ component: SIGNATURE_AGENT_HEADER }); + } + const entry = parsed.entries.find((candidate) => candidate.label === key); + if (entry === undefined) { + return policyError(`Signature-Agent has no member ${key}`); + } + return Object.freeze({ + component: component(SIGNATURE_AGENT_HEADER, { key }), + }); +} + +function hasExactBareTarget( + components: readonly ComponentDescriptor[] +): boolean { + return components.some( + ({ name, parameters }) => + (name === "@authority" || name === "@target-uri") && + Object.keys(parameters).length === 0 + ); +} + +function verifiedSignatureAgent( + request: Request | RequestDescriptor, + components: readonly ComponentDescriptor[] +): SignatureAgentEntry | undefined { + const header = requestHeader(request, SIGNATURE_AGENT_HEADER); + if (header === undefined) return undefined; + const parsed = parseSignatureAgentHeader(header); + if (parsed.kind === "legacy") { + const covered = components.some( + ({ name, parameters }) => + name === SIGNATURE_AGENT_HEADER && Object.keys(parameters).length === 0 + ); + if (!covered) { + return policyError(`signature must cover ${SIGNATURE_AGENT_HEADER}`); } - components = params.components; + const entry = parsed.entries[0]; + if (entry === undefined) return policyError("Signature-Agent is empty"); + return Object.freeze({ ...entry }); + } + const keys = components.flatMap(({ name, parameters }) => { + const entries = Object.entries(parameters); + return name === SIGNATURE_AGENT_HEADER && + entries.length === 1 && + typeof parameters.key === "string" + ? [parameters.key] + : []; + }); + if (keys.length !== 1) { + return policyError( + `signature must cover exactly one ${SIGNATURE_AGENT_HEADER} member` + ); + } + const entry = parsed.entries.find((candidate) => candidate.label === keys[0]); + if (entry === undefined) { + return policyError(`Signature-Agent has no member ${keys[0]}`); } + return Object.freeze({ ...entry }); +} +function signingOptions( + request: Request | RequestDescriptor, + options: Omit & { + readonly signer: Pick; + } +) { + if ( + options.signer.algorithm !== "ed25519" && + options.signer.algorithm !== "rsa-pss-sha512" + ) { + return policyError("signer algorithm is unsupported"); + } + if (options.signer.keyid === "") { + return policyError("signer keyid must not be empty"); + } + const label = options.label ?? "sig1"; + const created = seconds(options.created ?? new Date(), "created"); + const expires = seconds(options.expires, "expires"); + if (created > expires) + return policyError("created must not be after expires"); + if (options.nonce !== undefined && !validateNonce(options.nonce)) { + return policyError( + "nonce must be canonical base64 or unpadded base64url encoding of 64 bytes" + ); + } + const agent = signingSignatureAgent( + request, + options.signatureAgentKey ?? label + ); + const components: SignatureComponent[] = [options.target ?? "@authority"]; + if (agent !== undefined) { + components.push(agent.component); + } + components.push(...(options.additionalComponents ?? [])); return { + label, components, - created: params.created, - expires: params.expires, - nonce, - key: params.key, - tag: HTTP_MESSAGE_SIGNATURE_TAG, + parameters: { + created, + keyid: options.signer.keyid, + alg: options.signer.algorithm, + expires, + nonce: options.nonce, + tag: HTTP_MESSAGE_SIGNATURE_TAG, + }, }; } -export function signatureHeaders< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, - signer: httpsig.Signer, - params: SignatureParams -): Promise { - return httpsig.signatureHeaders(message, { - signer, - keyid: signer.keyid, - ...getSigningOptions(message, params), +export async function sign( + request: Request | RequestDescriptor, + options: SignOptions +): Promise { + return createSignature(request, { + ...signingOptions(request, options), + signer: options.signer, }); } -export function signatureHeadersSync< - T extends - httpsig.RequestLike | httpsig.ResponseLike | httpsig.ResponseRequestPair, ->( - message: T, - signer: httpsig.SignerSync, - params: SignatureParams -): httpsig.SignatureHeaders { - return httpsig.signatureHeadersSync(message, { - signer, - keyid: signer.keyid, - ...getSigningOptions(message, params), +export function signSync( + request: Request | RequestDescriptor, + options: SignSyncOptions +): SignatureFields { + return createSignatureSync(request, { + ...signingOptions(request, options), + signer: options.signer, }); } -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() +function profileCandidate( + request: Request | RequestDescriptor, + candidate: UntrustedSignatureCandidate +): UntrustedWebBotSignatureCandidate { + const { algorithm, parameters, components } = candidate; + if (algorithm !== "ed25519" && algorithm !== "rsa-pss-sha512") { + return policyError("signed algorithm is missing or unsupported"); + } + const keyid = parameters.keyid; + if (typeof keyid !== "string" || keyid === "") { + return policyError("keyid must be a non-empty string"); + } + if (parameters.tag !== HTTP_MESSAGE_SIGNATURE_TAG) { + return policyError(`tag must be '${HTTP_MESSAGE_SIGNATURE_TAG}'`); + } + const created = parameters.created; + const expires = parameters.expires; + if (typeof created !== "number" || typeof expires !== "number") { + return policyError("created and expires must be integers"); + } + if (created > expires) + return policyError("created must not be after expires"); + if (parameters.nonce !== undefined && !validateNonce(parameters.nonce)) { + return policyError( + "nonce must be canonical base64 or unpadded base64url encoding of 64 bytes" ); - 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); - }; - return httpsig.verify(message, v); + } + if (!hasExactBareTarget(components)) { + return policyError( + "signature must cover bare @authority or bare @target-uri" + ); + } + const agent = verifiedSignatureAgent(request, components); + return Object.freeze({ keyid, algorithm, signatureAgent: agent }); } -export interface Directory extends httpsig.Directory { - purpose: string; +export async function verify( + request: Request | RequestDescriptor, + options: VerifyOptions +): Promise> { + const algorithms = options.algorithms ?? ["ed25519", "rsa-pss-sha512"]; + const now = seconds(options.now ?? new Date(), "now"); + let selected: UntrustedWebBotSignatureCandidate | undefined; + const verified = await verifySignature(request, { + label: options.label, + policy: { + algorithms, + requiredComponents: [], + requiredParameters: ["created", "expires", "keyid", "alg", "tag"], + maxAge: options.maxAge ?? 86_400, + clockSkew: options.clockSkew ?? 0, + now, + }, + async resolveVerifier(candidate) { + const profile = profileCandidate(request, candidate); + selected = profile; + const verifier = await options.resolver(profile); + if (verifier.keyid !== profile.keyid) { + return policyError( + "resolved verifier keyid does not match signed keyid" + ); + } + return verifier; + }, + }); + if (selected === undefined) { + return policyError("signature candidate was not resolved"); + } + const authenticatedCreated = verified.parameters.created; + const authenticatedExpires = verified.parameters.expires; + if ( + typeof authenticatedCreated !== "number" || + typeof authenticatedExpires !== "number" + ) { + return policyError("authenticated signature lacks timestamps"); + } + const result: VerifiedWebBotSignature = Object.freeze({ + ...verified, + keyid: selected.keyid, + created: new Date(authenticatedCreated * 1000), + expires: new Date(authenticatedExpires * 1000), + tag: HTTP_MESSAGE_SIGNATURE_TAG, + nonce: verified.parameters.nonce, + signatureAgent: selected.signatureAgent, + }); + if ( + options.validate !== undefined && + (await options.validate(result)) === false + ) { + return policyError("signature rejected by profile validation"); + } + return result; } - -export { - parseRegistry, - parseSignatureAgentCard, - parseSignatureAgentHeader, - type JSONWebKeySet, - type SignatureAgentCard, - type SignatureAgentDiscoveryType, - type SignatureAgentEntry, - type SignatureAgentHeader, - type WebBotAuthMetadata, -} from "./registry"; diff --git a/packages/web-bot-auth/test/directory-response.test.ts b/packages/web-bot-auth/test/directory-response.test.ts index 4f802ba..1ca21ad 100644 --- a/packages/web-bot-auth/test/directory-response.test.ts +++ b/packages/web-bot-auth/test/directory-response.test.ts @@ -1,12 +1,11 @@ +import { component, verifySignature } from "http-message-sig"; import { describe, expect, it } from "vitest"; -import { verify } from "http-message-sig"; - import { u8ToB64 } from "../src/base64"; import { verifierFromJWK } from "../src/crypto"; import vectors from "./test_data/web_bot_auth_directory_response_v1.json"; describe.each(vectors)("directory response vector: $name", (vector) => { - it("has a valid content digest and signature", async () => { + it("has a valid content digest and core signature", async () => { const digest = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(vector.response.body) @@ -16,17 +15,38 @@ describe.each(vectors)("directory response vector: $name", (vector) => { ); const request = { + kind: "request", method: vector.request.method, - url: vector.request.target_url, - headers: new Headers(vector.request.headers), - }; + targetUri: vector.request.target_url, + fields: Object.entries(vector.request.headers).map(([name, value]) => ({ + name, + value, + })), + } satisfies import("http-message-sig").RequestDescriptor; const response = { + kind: "response", status: vector.response.status, - headers: new Headers(vector.response.headers), - }; - + fields: Object.entries(vector.response.headers).map(([name, value]) => ({ + name, + value, + })), + request, + } satisfies import("http-message-sig").ResponseDescriptor; + const verifier = await verifierFromJWK(vector.public_key); await expect( - verify({ request, response }, await verifierFromJWK(vector.public_key)) - ).resolves.toBeUndefined(); + verifySignature(response, { + label: "binding0", + policy: { + algorithms: ["ed25519"], + requiredComponents: [ + component("@authority", { req: true }), + "content-digest", + ], + requiredParameters: ["created", "expires", "keyid", "alg", "tag"], + now: 1_735_689_600, + }, + resolveVerifier: () => verifier, + }) + ).resolves.toMatchObject({ verifier, label: "binding0" }); }); }); diff --git a/packages/web-bot-auth/test/index.test.ts b/packages/web-bot-auth/test/index.test.ts index 164b8b2..aba98f4 100644 --- a/packages/web-bot-auth/test/index.test.ts +++ b/packages/web-bot-auth/test/index.test.ts @@ -1,228 +1,451 @@ -import { vi, describe, it, expect } from "vitest"; +import { createSignature, component } from "http-message-sig"; +import { describe, expect, it, vi } from "vitest"; import { generateNonce, - REQUEST_COMPONENTS, - signatureHeaders, + sign, + signSync, validateNonce, - NONCE_LENGTH_IN_BYTES, - SIGNATURE_AGENT_HEADER, verify, - recommendedComponents, + type SignatureFields, + type WebBotSigner, + type WebBotSignerSync, + type WebBotVerifier, } from "../src/index"; import { signerFromJWK, verifierFromJWK } from "../src/crypto"; -import { b64Tou8, u8ToB64 } from "../src/base64"; - import vectors1 from "./test_data/web_bot_auth_architecture_v1.json"; import vectors2 from "./test_data/web_bot_auth_architecture_v2.json"; const vectors = [...vectors1, ...vectors2]; -type Vectors = (typeof vectors)[number]; - -describe.each(vectors)("Web-bot-auth-ed25519-Vector-%#", (v: Vectors) => { - it("should pass IETF draft test vectors", async () => { - const signer = await signerFromJWK(v.key); +const created = new Date("2025-01-01T00:00:00Z"); +const expires = new Date("2025-01-01T01:00:00Z"); +const now = new Date("2025-01-01T00:30:00Z"); +const base64Nonce = + "yT+sZR1glKOTemVLbmPDFwPScbB1Zj/sMNPEFZcjwJW5jK/taa7HviOXovVwiZOfrrLHS2SbLFUQBxPYZChf7g=="; +const base64UrlNonce = base64Nonce + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +const ed25519Vector = vectors.find((vector) => vector.key.kty === "OKP"); +if (ed25519Vector === undefined) throw new Error("missing Ed25519 vector"); +const ed25519Jwk: JsonWebKey = ed25519Vector.key; + +function withSignature(request: Request, fields: SignatureFields): Request { + const headers = new Headers(request.headers); + headers.set("signature", fields.signature); + headers.set("signature-input", fields.signatureInput); + return new Request(request, { headers }); +} + +async function signedRequest( + request: Request, + signer: WebBotSigner, + overrides: Partial<{ + readonly created: Date; + readonly expires: Date; + readonly nonce: string; + readonly label: string; + }> = {} +): Promise { + return withSignature( + request, + await sign(request, { + signer, + created: overrides.created ?? created, + expires: overrides.expires ?? expires, + nonce: overrides.nonce, + label: overrides.label, + }) + ); +} + +describe.each(vectors)("architecture vector: $label/$key.kty", (vector) => { + it("signs and verifies", async () => { + const signer = await signerFromJWK(vector.key); const headers = new Headers(); - if (v.signature_agent) { - headers.append(SIGNATURE_AGENT_HEADER, v.signature_agent); + if (vector.signature_agent !== undefined) { + headers.set("signature-agent", vector.signature_agent); } - const request = new Request(v.target_url, { headers }); - const signedHeaders = await signatureHeaders(request, signer, { - 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(signedHeaders["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"]); - const signedRequest = new Request(request.url, { - headers, - }); - - vi.setSystemTime(new Date(v.created_ms)); - expect( - await verify(signedRequest, await verifierFromJWK(v.key)) - ).toBeUndefined(); - vi.useRealTimers(); - }); -}); - -describe("custom components", () => { - const ed25519Key = - vectors.find((v) => v.key.kty === "OKP")?.key ?? vectors[0].key; - - it("should sign with custom components including additional headers", async () => { - const signer = await signerFromJWK(ed25519Key); - - const headers = new Headers(); - headers.append(SIGNATURE_AGENT_HEADER, "https://example.bot.com"); - headers.append("accept", "text/html"); - const request = new Request("https://example.com", { headers }); - - const signedHeaders = await signatureHeaders(request, signer, { - created: new Date(1735689600000), - expires: new Date(1735693200000), - components: [...REQUEST_COMPONENTS, "accept"], + const signatureAgentKey = + "signature_agent_key" in vector && + typeof vector.signature_agent_key === "string" + ? vector.signature_agent_key + : undefined; + const request = new Request(vector.target_url, { headers }); + const fields = await sign(request, { + signer, + created: new Date(vector.created_ms), + expires: new Date(vector.expires_ms), + nonce: vector.nonce, + label: vector.label, + signatureAgentKey, }); + expect(fields.signatureInput).toBe(vector.signature_input); - // Verify that the Signature-Input includes the custom component - expect(signedHeaders["Signature-Input"]).toContain('"accept"'); - expect(signedHeaders["Signature-Input"]).toContain('"@authority"'); - expect(signedHeaders["Signature-Input"]).toContain('"signature-agent"'); - }); - - it("should reject custom components missing signature-agent when header is present", async () => { - const signer = await signerFromJWK(ed25519Key); + const verifier = await verifierFromJWK(vector.key); + const result = await verify(withSignature(request, fields), { + now: new Date(vector.created_ms), + resolver: () => verifier, + }); + expect(result.keyid).toBe(signer.keyid); + if (signatureAgentKey !== undefined) { + expect(result.signatureAgent?.label).toBe(signatureAgentKey); + } - const headers = new Headers(); - headers.append(SIGNATURE_AGENT_HEADER, "https://example.bot.com"); - const request = new Request("https://example.com", { headers }); - - expect(() => - signatureHeaders(request, signer, { - created: new Date(1735689600000), - expires: new Date(1735693200000), - components: ["@authority"], // missing signature-agent + const vectorFields: SignatureFields = { + signature: vector.signature, + signatureInput: vector.signature_input, + }; + await expect( + verify(withSignature(request, vectorFields), { + now: new Date(vector.created_ms), + resolver: () => verifier, }) - ).toThrow(`${SIGNATURE_AGENT_HEADER} is required in params.component`); + ).resolves.toMatchObject({ keyid: signer.keyid }); }); +}); - it("should allow custom components without signature-agent when header is absent", async () => { - const signer = await signerFromJWK(ed25519Key); - - const request = new Request("https://example.com"); - - const signedHeaders = await signatureHeaders(request, signer, { - created: new Date(1735689600000), - expires: new Date(1735693200000), - components: ["@authority"], +describe("nonce", () => { + it("is absent when omitted", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const fields = await sign(new Request("https://example.com"), { + signer, + created, + expires, }); + expect(fields.signatureInput).not.toContain(";nonce="); + }); - expect(signedHeaders["Signature-Input"]).toContain('"@authority"'); - expect(signedHeaders["Signature-Input"]).not.toContain('"signature-agent"'); + it("generates canonical base64", () => { + const nonce = generateNonce(); + expect(nonce).toMatch(/^[A-Za-z0-9+/]{86}==$/); + expect(validateNonce(nonce)).toBe(true); }); -}); -describe("covered component enforcement (GHSA-x9cc-346q-g27m)", () => { - const ed25519Key = - vectors.find((v) => v.key.kty === "OKP")?.key ?? vectors[0].key; - const created = new Date(1735689600000); - const expires = new Date(1735693200000); + it("accepts canonical base64 and base64url only", () => { + expect(validateNonce(base64Nonce)).toBe(true); + expect(validateNonce(base64UrlNonce)).toBe(true); + expect(validateNonce(`${base64UrlNonce}==`)).toBe(false); + expect(validateNonce(base64Nonce.slice(0, -2))).toBe(false); + expect(validateNonce(base64Nonce.replace("+", "-"))).toBe(false); + }); - async function signedRequestWith(components: string[]): Promise { - const signer = await signerFromJWK(ed25519Key); - const request = new Request("https://example.com/public"); - const signedHeaders = await signatureHeaders(request, signer, { - created, - expires, - components, - }); - const headers = new Headers(); - headers.append("Signature", signedHeaders["Signature"]); - headers.append("Signature-Input", signedHeaders["Signature-Input"]); - return new Request(request.url, { headers }); - } - - it("rejects a signature that covers no request components", async () => { - const signedRequest = await signedRequestWith([]); - vi.setSystemTime(created); + it("signs and verifies base64url compatibility nonces", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const verifier = await verifierFromJWK(ed25519Jwk); + const request = await signedRequest( + new Request("https://example.com"), + signer, + { nonce: base64UrlNonce } + ); await expect( - verify(signedRequest, await verifierFromJWK(ed25519Key)) - ).rejects.toThrow("signature must cover @authority or @target-uri"); - vi.useRealTimers(); + verify(request, { resolver: () => verifier, now }) + ).resolves.toMatchObject({ nonce: base64UrlNonce }); }); - it("accepts a signature covering @authority", async () => { - const signedRequest = await signedRequestWith(["@authority"]); - vi.setSystemTime(created); + it.each([ + "", + "abc", + "A".repeat(85), + "A".repeat(85) + "=", + "A".repeat(85) + "+", + ])("rejects malformed nonce %s", async (nonce) => { + const signer = await signerFromJWK(ed25519Jwk); await expect( - verify(signedRequest, await verifierFromJWK(ed25519Key)) - ).resolves.toBeUndefined(); - vi.useRealTimers(); + sign(new Request("https://example.com"), { + signer, + created, + expires, + nonce, + }) + ).rejects.toThrow("canonical base64 or unpadded base64url"); }); +}); - it("accepts a signature covering @target-uri", async () => { - const signedRequest = await signedRequestWith(["@target-uri"]); - vi.setSystemTime(created); - await expect( - verify(signedRequest, await verifierFromJWK(ed25519Key)) - ).resolves.toBeUndefined(); - vi.useRealTimers(); +describe("synchronous signing", () => { + it("applies the same profile as asynchronous signing", () => { + const signer: WebBotSignerSync = { + algorithm: "ed25519", + keyid: "test-key", + sign: () => new Uint8Array(64), + }; + const request = new Request("https://example.com", { + headers: { + "signature-agent": 'sig1="https://bot.example";type=directory', + }, + }); + const fields = signSync(request, { signer, created, expires }); + expect(fields.signatureInput).toContain( + '("@authority" "signature-agent";key="sig1")' + ); + expect(fields.signatureInput).toContain(';alg="ed25519"'); + expect(fields.signature).toMatch(/^sig1=:/); }); }); -describe("nonce", () => { - describe("generateNonce", () => { - it("should generate a base64 string", () => { - const nonce = generateNonce(); - expect(typeof nonce).toBe("string"); - // Base64 regex pattern - expect(() => b64Tou8(nonce)).not.toThrowError(); +describe("Signature-Agent coverage", () => { + it("signs and authenticates the exact signature label member", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const verifier = await verifierFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { + "signature-agent": 'sig1="https://bot.example/card";type=cimd', + }, }); + const fields = await sign(request, { signer, created, expires }); + expect(fields.signatureInput).toContain('"signature-agent";key="sig1"'); + const resolver = vi.fn(() => verifier); + const result = await verify(withSignature(request, fields), { + resolver, + now, + }); + expect(resolver).toHaveBeenCalledWith({ + keyid: signer.keyid, + algorithm: "ed25519", + signatureAgent: { + label: "sig1", + uri: "https://bot.example/card", + type: "cimd", + }, + }); + expect(result.signatureAgent?.uri).toBe("https://bot.example/card"); + }); - it("should generate nonce with correct length when decoded", () => { - const nonce = generateNonce(); - const decoded = b64Tou8(nonce); - expect(decoded.length).toBe(NONCE_LENGTH_IN_BYTES); + it("rejects a missing matching header member while signing", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { "signature-agent": 'other="https://bot.example"' }, }); + await expect(sign(request, { signer, created, expires })).rejects.toThrow( + "Signature-Agent has no member sig1" + ); + }); - it("should generate unique nonces", () => { - const nonce1 = generateNonce(); - const nonce2 = generateNonce(); - const nonce3 = generateNonce(); - expect(nonce1).not.toBe(nonce2); - expect(nonce2).not.toBe(nonce3); - expect(nonce1).not.toBe(nonce3); + it("rejects whole-field-only coverage before resolution", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { "signature-agent": 'sig1="https://bot.example"' }, }); + const fields = await createSignature(request, { + signer, + components: ["@authority", "signature-agent"], + parameters: { + created: Math.floor(created.getTime() / 1000), + expires: Math.floor(expires.getTime() / 1000), + keyid: signer.keyid, + alg: signer.algorithm, + tag: "web-bot-auth", + }, + }); + const resolver = vi.fn(() => verifierFromJWK(ed25519Jwk)); + await expect( + verify(withSignature(request, fields), { resolver, now }) + ).rejects.toMatchObject({ + message: "Verifier resolution failed", + cause: { + message: "signature must cover exactly one signature-agent member", + }, + }); + expect(resolver).not.toHaveBeenCalled(); }); - describe("validateNonce", () => { - it("should validate correctly generated nonces", () => { - const nonce = generateNonce(); - expect(validateNonce(nonce)).toBe(true); + it("authenticates a member key independent from the signature label", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { + "signature-agent": + 'sig1="https://one.example", sig2="https://two.example"', + }, + }); + const fields = await createSignature(request, { + signer, + components: ["@authority", component("signature-agent", { key: "sig2" })], + parameters: { + created: Math.floor(created.getTime() / 1000), + expires: Math.floor(expires.getTime() / 1000), + keyid: signer.keyid, + alg: signer.algorithm, + tag: "web-bot-auth", + }, }); + const verifier = await verifierFromJWK(ed25519Jwk); + const resolver = vi.fn(() => verifier); + await expect( + verify(withSignature(request, fields), { resolver, now }) + ).resolves.toMatchObject({ + signatureAgent: { + label: "sig2", + uri: "https://two.example", + }, + }); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + signatureAgent: expect.objectContaining({ label: "sig2" }), + }) + ); + }); - it("should reject invalid base64 strings", () => { - expect(validateNonce("not-base64!@#$")).toBe(false); + it("rejects req component confusion", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { "signature-agent": 'sig1="https://bot.example"' }, }); + const requestDescriptor = { + kind: "request", + method: "GET", + targetUri: request.url, + fields: [ + { name: "signature-agent", value: 'sig1="https://bot.example"' }, + ], + } satisfies import("http-message-sig").RequestDescriptor; + const response = { + kind: "response", + status: 200, + fields: [], + request: requestDescriptor, + } satisfies import("http-message-sig").ResponseDescriptor; + const fields = await createSignature(response, { + signer, + components: [ + component("@authority", { req: true }), + component("signature-agent", { req: true, key: "sig1" }), + ], + parameters: { + created: Math.floor(created.getTime() / 1000), + expires: Math.floor(expires.getTime() / 1000), + keyid: signer.keyid, + alg: signer.algorithm, + tag: "web-bot-auth", + }, + }); + const resolver = vi.fn(() => verifierFromJWK(ed25519Jwk)); + await expect( + verify(withSignature(request, fields), { resolver, now }) + ).rejects.toThrow("req requires a response"); + expect(resolver).not.toHaveBeenCalled(); + }); - it("should reject empty string", () => { - expect(validateNonce("")).toBe(false); + it("signs and verifies legacy whole-field form", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const verifier = await verifierFromJWK(ed25519Jwk); + const request = new Request("https://example.com", { + headers: { "signature-agent": '"https://bot.example"' }, }); + const fields = await sign(request, { signer, created, expires }); + expect(fields.signatureInput).toContain('"signature-agent"'); + expect(fields.signatureInput).not.toContain('"signature-agent";key='); + await expect( + verify(withSignature(request, fields), { + resolver: () => verifier, + now, + }) + ).resolves.toMatchObject({ + signatureAgent: { uri: "https://bot.example", type: "directory" }, + }); + }); +}); - it("should reject nonces of incorrect length", () => { - // Create a small base64 string - const shortNonce = btoa("too short"); - expect(validateNonce(shortNonce)).toBe(false); +describe("verification policy", () => { + it("rejects algorithm mismatch", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = await signedRequest( + new Request("https://example.com"), + signer + ); + const mismatched: WebBotVerifier = { + algorithm: "rsa-pss-sha512", + keyid: signer.keyid, + verify: () => true, + }; + await expect( + verify(request, { resolver: () => mismatched, now }) + ).rejects.toThrow("does not match"); + }); - // Create a long base64 string - const longArray = new Uint8Array(NONCE_LENGTH_IN_BYTES + 10); - crypto.getRandomValues(longArray); - const longNonce = u8ToB64(longArray); - expect(validateNonce(longNonce)).toBe(false); + it("rejects resolved keyid mismatch before crypto", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const request = await signedRequest( + new Request("https://example.com"), + signer + ); + const verifier: WebBotVerifier = { + algorithm: "ed25519", + keyid: "wrong-key", + verify: vi.fn(() => true), + }; + await expect( + verify(request, { resolver: () => verifier, now }) + ).rejects.toMatchObject({ + message: "Verifier resolution failed", + cause: { message: "resolved verifier keyid does not match signed keyid" }, }); + expect(verifier.verify).not.toHaveBeenCalled(); + }); - it.each([[null], [undefined], [123], [{}], [[]], [true]])( - "should handle invalid input type: %s", - (invalidInput: unknown) => { - expect(validateNonce(invalidInput as string)).toBe(false); - } + it.each([ + { + name: "future", + signedCreated: new Date("2025-01-01T00:31:00Z"), + signedExpires: expires, + maxAge: 3600, + error: "future", + }, + { + name: "expired", + signedCreated: created, + signedExpires: new Date("2025-01-01T00:29:59Z"), + maxAge: 3600, + error: "expired", + }, + { + name: "too old", + signedCreated: created, + signedExpires: expires, + maxAge: 60, + error: "too old", + }, + ])( + "rejects $name signatures", + async ({ signedCreated, signedExpires, maxAge, error }) => { + const signer = await signerFromJWK(ed25519Jwk); + const verifier = await verifierFromJWK(ed25519Jwk); + const request = await signedRequest( + new Request("https://example.com"), + signer, + { + created: signedCreated, + expires: signedExpires, + } + ); + await expect( + verify(request, { resolver: () => verifier, now, maxAge }) + ).rejects.toThrow(error); + } + ); + + it("runs replay validation after authentication", async () => { + const signer = await signerFromJWK(ed25519Jwk); + const verifier = await verifierFromJWK(ed25519Jwk); + const nonce = generateNonce(); + const request = await signedRequest( + new Request("https://example.com"), + signer, + { nonce } + ); + const validate = vi.fn(() => false); + await expect( + verify(request, { resolver: () => verifier, now, validate }) + ).rejects.toThrow("profile validation"); + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ nonce, verifier }) ); + }); +}); - it("should validate multiple generated nonces", () => { - for (let i = 0; i < 10; i++) { - const nonce = generateNonce(); - expect(validateNonce(nonce)).toBe(true); - } - }); +describe("JWK binding", () => { + it("rejects conflicting JWK alg", async () => { + await expect( + signerFromJWK({ ...ed25519Jwk, alg: "PS512" }) + ).rejects.toThrow("Ed25519 JWK alg"); }); });