From e42155d6c64fd8661c40881cfc7401741b87a74b Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Mon, 3 Aug 2026 12:38:37 -0400 Subject: [PATCH 1/2] fix(rest/nodejs): verify request signatures per RFC 9421 instead of accepting everything The Node reference server accepted every inbound request without any signature handling, while signatures.md mandates asymmetric RFC 9421 signatures (ES256 baseline) with key discovery from the UCP-Agent profile. The Python reference server gained this verification in #122; this change brings the Node server to behavioral parity. src/utils/signature.ts is the twin of the Python ucp_signing module: RFC 9421 signature base construction with the UCP covered component set, RFC 9530 Content-Digest over the raw body bytes, ES256 with fixed width raw r||s signatures (never ASN.1/DER, per the spec MUST) plus Ed25519, signer key discovery from the UCP-Agent profile keys[] with a 300 second cache and an SSRF guard, and a Hono middleware applying the same enforcement semantics as the Python verify_signature dependency. Only node:crypto is used; the RFC 8941 structured field subset is hand rolled, so no new dependencies are added. Compatibility is preserved by default: - REQUIRE_SIGNATURES (default false) and ALLOW_INSECURE_PROFILE_URLS (default false) follow the env var pattern the server already uses. - With enforcement off, signatures are still verified when present and the outcome logged, but unsigned or invalid requests are allowed. No profile fetch occurs unless a Signature-Input header is present, so unsigned traffic incurs no extra work. - With enforcement on, the spec error codes are returned: 401 signature_missing / signature_invalid / key_not_found, 400 digest_mismatch / algorithm_unsupported / invalid_profile_url, 424 profile_unreachable, 422 profile_malformed. Every business route (checkout sessions, orders, testing) verifies; the discovery profile stays public, matching the Python server. test/signing.test.ts anchors the module to the RFC 9421 Appendix B and RFC 9530 published vectors, including a byte exact Ed25519 B.2.6 check and an explicit DER rejection test. test/signature.test.ts proves both modes end to end against a localhost profile server discovered through the UCP-Agent header, mirroring the scenarios of the Python signature_integration_test.py. --- rest/nodejs/README.md | 34 ++ rest/nodejs/src/index.ts | 14 + rest/nodejs/src/utils/config.ts | 10 + rest/nodejs/src/utils/signature.ts | 775 ++++++++++++++++++++++++ rest/nodejs/test/signature.test.ts | 510 ++++++++++++++++ rest/nodejs/test/signing.test.ts | 917 +++++++++++++++++++++++++++++ 6 files changed, 2260 insertions(+) create mode 100644 rest/nodejs/src/utils/signature.ts create mode 100644 rest/nodejs/test/signature.test.ts create mode 100644 rest/nodejs/test/signing.test.ts diff --git a/rest/nodejs/README.md b/rest/nodejs/README.md index a99fd563..436bf73e 100644 --- a/rest/nodejs/README.md +++ b/rest/nodejs/README.md @@ -81,6 +81,40 @@ endpoint at: http://localhost:3000/.well-known/ucp ``` +## Request Signatures (RFC 9421) + +The server verifies UCP request signatures as defined in the specification's +[`signatures.md`](https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/signatures.md): +[RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) HTTP Message Signatures +with an [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530.html) `Content-Digest` +over the raw body. The signer's public key is discovered from the profile URL in +the `UCP-Agent` header (its `keys[]`). `ES256` (fixed-width raw `r||s`, not +ASN.1/DER) is the baseline; `Ed25519` is also supported. The behaviour mirrors +the Python reference server (`rest/python/server`). + +Behaviour is controlled by two environment variables: + +| Variable | Default | Effect | +| ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `REQUIRE_SIGNATURES` | `false` | Reject requests whose signature is missing or invalid. When `false`, a present signature is still verified and the result logged, but unsigned or invalid requests are allowed — so existing clients keep working. | +| `ALLOW_INSECURE_PROFILE_URLS` | `false` | Permit `http` and loopback/private profile URLs when resolving keys. For localhost demos and CI only; it disables SSRF protections and must never be enabled in production. | + +When verification fails under enforcement, the server returns the spec's error +code: `401 signature_missing` / `signature_invalid` / `key_not_found`, +`400 digest_mismatch` / `algorithm_unsupported` / `invalid_profile_url`, +`424 profile_unreachable`, or `422 profile_malformed`. + +To reject anything unsigned, start the server with enforcement on: + +```bash +REQUIRE_SIGNATURES=true npm run dev +``` + +Each verified request logs +`RFC 9421 signature verified (keyid=..., profile=...)`. The discovery profile +at `/.well-known/ucp` stays unverified: it is the public document a platform +must read before it can sign anything. + ## Running Conformance Tests To verify that this server implementation complies with the UCP specifications, diff --git a/rest/nodejs/src/index.ts b/rest/nodejs/src/index.ts index 8b9c8bcf..f2bb1456 100644 --- a/rest/nodejs/src/index.ts +++ b/rest/nodejs/src/index.ts @@ -15,6 +15,7 @@ import { CheckoutCompleteRequestSchema, OrderSchema, } from "./models"; +import { verifySignature } from "./utils/signature"; import { IdParamSchema, prettyValidation } from "./utils/validation"; const app = new Hono(); @@ -86,33 +87,43 @@ app.use(async (c: Context, next: () => Promise) => { }); /* Discovery endpoints */ +// The discovery profile is served unverified: it is the public document a +// platform must be able to read before it can sign anything. app.get("/.well-known/ucp", discoveryService.getMerchantProfile); /* Checkout Capability endpoints */ +// Every business endpoint below verifies RFC 9421 request signatures via +// verifySignature (enforced when REQUIRE_SIGNATURES=true, verify-and-log +// otherwise), mirroring the Python reference server. app.post( "/checkout-sessions", + verifySignature, zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), checkoutService.createCheckout ); app.get( "/checkout-sessions/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), checkoutService.getCheckout ); app.put( "/checkout-sessions/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), checkoutService.updateCheckout ); app.post( "/checkout-sessions/:id/complete", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), zValidator("json", CheckoutCompleteRequestSchema, prettyValidation), checkoutService.completeCheckout ); app.post( "/checkout-sessions/:id/cancel", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), checkoutService.cancelCheckout ); @@ -120,11 +131,13 @@ app.post( /* Order Capability endpoints */ app.get( "/orders/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), orderService.getOrder ); app.put( "/orders/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), zValidator("json", OrderSchema, prettyValidation), orderService.updateOrder @@ -133,6 +146,7 @@ app.put( /* Testing endpoints */ app.post( "/testing/simulate-shipping/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), testingService.shipOrder ); diff --git a/rest/nodejs/src/utils/config.ts b/rest/nodejs/src/utils/config.ts index 65444aca..54e35c42 100644 --- a/rest/nodejs/src/utils/config.ts +++ b/rest/nodejs/src/utils/config.ts @@ -1 +1,11 @@ export const UCP_VERSION = "2026-04-08"; + +// RFC 9421 request-signature behaviour, sourced from the environment like +// SIMULATION_SECRET in api/testing.ts. Both default to false: signatures are +// verified when present but unsigned or invalid requests are only logged, and +// profile URLs must be HTTPS on non-private hosts. Mutable so tests can toggle +// enforcement, mirroring the Python server's config.FLAGS. +export const signatureConfig = { + requireSignatures: process.env.REQUIRE_SIGNATURES === "true", + allowInsecureProfileUrls: process.env.ALLOW_INSECURE_PROFILE_URLS === "true", +}; diff --git a/rest/nodejs/src/utils/signature.ts b/rest/nodejs/src/utils/signature.ts new file mode 100644 index 00000000..cff89829 --- /dev/null +++ b/rest/nodejs/src/utils/signature.ts @@ -0,0 +1,775 @@ +import crypto, { type KeyObject } from "node:crypto"; +import dns from "node:dns/promises"; +import net from "node:net"; +import { type Context, type MiddlewareHandler } from "hono"; +import { type ContentfulStatusCode } from "hono/utils/http-status"; + +import { signatureConfig } from "./config"; + +// RFC 9421 HTTP Message Signatures for UCP, using the default UCP profile. +// This is the Node twin of the Python server's ucp_signing.py: RFC 9421 +// signature-base construction with the UCP covered-component set, RFC 9530 +// Content-Digest over the raw body bytes (sha-256), ES256 (ECDSA P-256 with +// fixed-width raw r||s signatures, never ASN.1/DER) plus Ed25519, and signer +// key discovery from the UCP-Agent profile's keys[]. Only node:crypto is used; +// the RFC 8941 structured-field subset is hand-rolled, no new dependencies. + +// Public keys resolved from a signer profile are cached for this long. +const KEY_CACHE_TTL_MS = 300_000; +const keyCache = new Map(); + +// P-256 coordinate width. ES384 is intentionally omitted: the spec lists it as +// OPTIONAL and the baseline every verifier MUST support is ES256. +const ES256_COORD_BYTES = 32; + +// A signature or profile-resolution failure with UCP wire semantics: the UCP +// error code and the HTTP status the spec maps it to. +export class SignatureError extends Error { + constructor( + readonly code: string, + readonly statusCode: number, + message: string + ) { + super(message); + } +} + +export type Jwk = { + kid?: string; + kty?: string; + crv?: string; + x?: string; + y?: string; + use?: string; + key_ops?: string[]; + alg?: string; + [member: string]: unknown; +}; + +export type SignatureInputMember = { + raw: string; + components: string[]; + params: Record; +}; + +// Returns the RFC 9530 Content-Digest value (sha-256 over the raw body bytes). +export function contentDigest(body: Uint8Array): string { + const digest = crypto.createHash("sha256").update(body).digest("base64"); + return `sha-256=:${digest}:`; +} + +// Whether a Content-Digest header covers the given body. Only the sha-256 +// member is inspected, matching the spec's requirement to use sha-256. +export function contentDigestMatches( + headerValue: string, + body: Uint8Array +): boolean { + const want = crypto.createHash("sha256").update(body).digest(); + for (const member of sfSplit(headerValue, ",")) { + const eq = member.indexOf("="); + const key = (eq === -1 ? member : member.slice(0, eq)).trim(); + if (key !== "sha-256") continue; + const value = (eq === -1 ? "" : member.slice(eq + 1)).trim(); + if (!value.startsWith(":") || !value.endsWith(":")) return false; + const encoded = value.slice(1, -1); + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + encoded + ) + ) + return false; + return want.equals(Buffer.from(encoded, "base64")); + } + return false; +} + +// Splits a structured-field string on top-level separators. Quoted strings and +// inner lists are opaque -- enough of RFC 8941 to parse the signature headers. +export function sfSplit(value: string, seps: string): string[] { + const out: string[] = []; + let cur = ""; + let depth = 0; + let quote = false; + for (let i = 0; i < value.length; i++) { + const c = value[i]!; + if (quote) { + cur += c; + if (c === "\\" && i + 1 < value.length) { + cur += value[i + 1]; + i += 1; + } else if (c === '"') { + quote = false; + } + } else if (c === '"') { + quote = true; + cur += c; + } else if (c === "(") { + depth += 1; + cur += c; + } else if (c === ")") { + depth -= 1; + cur += c; + } else if (seps.includes(c) && depth === 0) { + out.push(cur.trim()); + cur = ""; + } else { + cur += c; + } + } + const tail = cur.trim(); + if (tail) out.push(tail); + return out; +} + +// Parses a Signature-Input header into per-label descriptors: the member value +// verbatim (what @signature-params must echo), the unquoted component +// identifiers, and the parameters. Null on malformed input. +export function parseSignatureInput( + value: string +): Record | null { + if (typeof value !== "string" || !value.trim()) return null; + const out: Record = {}; + for (const member of sfSplit(value, ",")) { + const eq = member.indexOf("="); + const label = (eq === -1 ? member : member.slice(0, eq)).trim(); + const val = (eq === -1 ? "" : member.slice(eq + 1)).trim(); + if (eq === -1 || !label || !val.startsWith("(")) return null; + let depth = 0; + let end = 0; + for (let index = 0; index < val.length; index++) { + const char = val[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + end = index; + break; + } + } + } + const inner = val.slice(1, end); + const rest = val.slice(end + 1); + const components: string[] = []; + for (const tok of sfSplit(inner, " ")) { + if (!tok.startsWith('"') || !tok.endsWith('"') || tok.includes(";")) { + return null; + } + components.push(tok.slice(1, -1)); + } + const params: Record = {}; + for (const part of sfSplit(rest, ";")) { + if (!part) continue; + const pEq = part.indexOf("="); + const k = (pEq === -1 ? part : part.slice(0, pEq)).trim(); + let v = pEq === -1 ? "" : part.slice(pEq + 1); + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + params[k] = v; + } + out[label] = { raw: val, components, params }; + } + return Object.keys(out).length ? out : null; +} + +// Parses a Signature header into {label: raw signature bytes}, or null on +// malformed input. +export function parseSignature(value: string): Record | null { + if (typeof value !== "string" || !value.trim()) return null; + const out: Record = {}; + for (const member of sfSplit(value, ",")) { + const eq = member.indexOf("="); + const label = (eq === -1 ? member : member.slice(0, eq)).trim(); + const val = (eq === -1 ? "" : member.slice(eq + 1)).trim(); + if (eq === -1 || !val.startsWith(":") || !val.endsWith(":")) return null; + const encoded = val.slice(1, -1); + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + encoded + ) + ) + return null; + out[label] = Buffer.from(encoded, "base64"); + } + return Object.keys(out).length ? out : null; +} + +// Constructs the RFC 9421 signature base. The raw Signature-Input member value +// is echoed verbatim on the @signature-params line. Null if any covered +// component cannot be resolved for this message. +export function buildSignatureBase( + components: string[], + rawParams: string, + resolve: (name: string) => string | undefined | null +): Buffer | null { + const lines: string[] = []; + for (const name of components) { + const value = resolve(name); + if (value === undefined || value === null) return null; + lines.push(`"${name}": ${value}`); + } + lines.push(`"@signature-params": ${rawParams}`); + return Buffer.from(lines.join("\n"), "utf-8"); +} + +// The components a UCP request signature MUST cover (the verification-side +// coverage gate from signatures.md). Coverage keys on header presence, not on +// the method. signature-agent is a WBA-shape component this default-UCP +// verifier does not parse, so it is deliberately outside the gate. +export function requiredComponents( + _method: string, + hasQuery: boolean, + headers: Record, + hasBody: boolean +): string[] { + const required = ["@method", "@authority", "@path"]; + if (hasQuery) required.push("@query"); + if (hasBody) required.push("content-digest", "content-type"); + if ("idempotency-key" in headers) required.push("idempotency-key"); + if ("ucp-agent" in headers) required.push("ucp-agent"); + return required; +} + +// Normalises an authority per RFC 9421 Section 2.2.3: lowercase, scheme +// default port stripped, so host:443 added by an intermediary reconstructs to +// the value the signer used. +export function normalizeAuthority(host: string): string { + let authority = host.toLowerCase(); + if (authority.endsWith(":443")) { + authority = authority.slice(0, -":443".length); + } else if (authority.endsWith(":80")) { + authority = authority.slice(0, -":80".length); + } + return authority; +} + +// Exports a node:crypto public key as a UCP JWK (used by tests and demos). +export function jwkFromPublicKey(publicKey: KeyObject, kid: string): Jwk { + const jwk = publicKey.export({ format: "jwk" }) as Jwk; + const alg = jwk.kty === "OKP" ? "EdDSA" : "ES256"; + return { kid, ...jwk, use: "sig", alg }; +} + +// Builds a node:crypto public key from a UCP JWK. The supported set is what +// UCP verifiers must support (EC P-256) plus Ed25519; anything else is +// algorithm_unsupported. +function publicKeyFromJwk(jwk: Jwk): KeyObject { + const { kty, crv } = jwk; + if ( + (kty === "EC" && crv === "P-256") || + (kty === "OKP" && crv === "Ed25519") + ) { + try { + return crypto.createPublicKey({ + key: jwk as crypto.JsonWebKey, + format: "jwk", + }); + } catch (e) { + throw new SignatureError("signature_invalid", 401, `Malformed JWK: ${e}`); + } + } + throw new SignatureError( + "algorithm_unsupported", + 400, + `Unsupported key type/curve: kty=${JSON.stringify(kty)} crv=${JSON.stringify(crv)}` + ); +} + +// Verifies a signature base against a JWK public key. The algorithm is derived +// from the key's kty/crv -- never from a Signature-Input alg parameter, which +// UCP forbids. ECDSA signatures MUST be fixed-width raw r||s (64 bytes for +// P-256); ASN.1/DER is rejected before any verification is attempted. +export function verifyRawSignature( + jwk: Jwk, + base: Buffer, + signature: Buffer +): void { + const publicKey = publicKeyFromJwk(jwk); + if (publicKey.asymmetricKeyType === "ed25519") { + if (!crypto.verify(null, base, publicKey, signature)) { + throw new SignatureError( + "signature_invalid", + 401, + "Ed25519 signature verification failed" + ); + } + return; + } + if (signature.length !== 2 * ES256_COORD_BYTES) { + throw new SignatureError( + "signature_invalid", + 401, + `ECDSA signature must be ${2 * ES256_COORD_BYTES}-byte raw r||s, got ` + + `${signature.length} bytes (ASN.1/DER is not permitted)` + ); + } + let ok = false; + try { + ok = crypto.verify( + "sha256", + base, + { key: publicKey, dsaEncoding: "ieee-p1363" }, + signature + ); + } catch { + ok = false; + } + if (!ok) { + throw new SignatureError( + "signature_invalid", + 401, + "ES256 signature verification failed" + ); + } +} + +// Whether a JWK is usable for signature verification. A profile's keys[] JWK +// Set may carry non-signature keys (RFC 7517 Sections 4.2, 4.3): a key marked +// use:"enc", or whose key_ops is present but omits "verify", is skipped. +function sigCapable(jwk: Jwk): boolean { + if (jwk.use === "enc") return false; + const keyOps = jwk.key_ops; + return keyOps === undefined || keyOps.includes("verify"); +} + +// Verifies the signatures on an inbound UCP request. A request is accepted +// when at least one carried signature fully verifies; it is rejected only when +// every candidate signature is skipped or fails. Returns the keyid that +// verified. +export function verifyRequest( + method: string, + authority: string, + path: string, + query: string, + headers: Record, + body: Uint8Array, + keys: Jwk[] +): string { + const sigInput = parseSignatureInput(headers["signature-input"] ?? ""); + const sigs = parseSignature(headers["signature"] ?? ""); + if (!sigInput || !sigs) { + throw new SignatureError( + "signature_missing", + 401, + "Missing Signature-Input or Signature header" + ); + } + + const hasBody = body.length > 0; + if (hasBody) { + const digestHeader = headers["content-digest"]; + if (!digestHeader || !contentDigestMatches(digestHeader, body)) { + throw new SignatureError( + "digest_mismatch", + 400, + "Content-Digest does not match the body" + ); + } + } + + const required = requiredComponents(method, query !== "", headers, hasBody); + const keysByKid = new Map(); + for (const key of keys) { + if (sigCapable(key)) keysByKid.set(key.kid, key); + } + + const resolve = (name: string): string | undefined => { + if (name === "@method") return method.toUpperCase(); + if (name === "@authority") return normalizeAuthority(authority); + if (name === "@path") return path || "/"; // RFC 9421 2.2.6: empty is "/" + if (name === "@query") return "?" + query; + const value = headers[name]; + // RFC 9421 Section 2.1: a covered field value is OWS-trimmed. + return typeof value === "string" ? value.trim() : value; + }; + + let lastError = new SignatureError( + "signature_invalid", + 401, + "No valid signature found" + ); + for (const [label, desc] of Object.entries(sigInput)) { + const signature = sigs[label]; + if (signature === undefined) continue; + if ("alg" in desc.params) { + lastError = new SignatureError( + "signature_invalid", + 401, + "Signature-Input MUST NOT carry an 'alg' parameter" + ); + continue; + } + const missing = required.filter((c) => !desc.components.includes(c)); + if (missing.length) { + lastError = new SignatureError( + "signature_invalid", + 401, + `Signature does not cover required components: ${missing.sort().join(", ")}` + ); + continue; + } + const keyid = desc.params["keyid"]; + const jwk = keysByKid.get(keyid); + if (jwk === undefined) { + lastError = new SignatureError( + "key_not_found", + 401, + `No published key with kid=${JSON.stringify(keyid)}` + ); + continue; + } + const base = buildSignatureBase(desc.components, desc.raw, resolve); + if (base === null) { + lastError = new SignatureError( + "signature_invalid", + 401, + "Unresolvable signed component" + ); + continue; + } + try { + verifyRawSignature(jwk, base, signature); + } catch (e) { + if (!(e instanceof SignatureError)) throw e; + lastError = e; + continue; + } + return keyid ?? ""; + } + throw lastError; +} + +// Signs a raw signature base, emitting fixed-width raw r||s for ECDSA. +function rawSign(privateKey: KeyObject, base: Buffer): Buffer { + if (privateKey.asymmetricKeyType === "ed25519") { + return crypto.sign(null, base, privateKey); + } + return crypto.sign("sha256", base, { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); +} + +// Signs a UCP request and returns the headers to add: Content-Digest (when a +// body is present), Signature-Input, and Signature covering exactly the UCP +// required-component set. Used by the tests as the client side of the loop. +export function signRequest( + privateKey: KeyObject, + kid: string, + method: string, + url: string, + headers: Record, + body: Uint8Array, + created?: number +): Record { + const target = new URL(url); + const additions: Record = {}; + const merged: Record = {}; + for (const [k, v] of Object.entries(headers)) merged[k.toLowerCase()] = v; + const hasBody = body.length > 0; + if (hasBody) { + const digest = contentDigest(body); + additions["Content-Digest"] = digest; + merged["content-digest"] = digest; + // The signer must fix and cover Content-Type: a value the transport adds + // after signing would not be part of the signature base. + if (!("content-type" in merged)) { + merged["content-type"] = "application/json"; + additions["Content-Type"] = "application/json"; + } + } + + const query = target.search.slice(1); + const components = requiredComponents(method, query !== "", merged, hasBody); + const createdAt = created ?? Math.floor(Date.now() / 1000); + const rawParams = + "(" + + components.map((c) => `"${c}"`).join(" ") + + `);created=${createdAt};keyid="${kid}"`; + + const resolve = (name: string): string | undefined => { + if (name === "@method") return method.toUpperCase(); + if (name === "@authority") return normalizeAuthority(target.host); + if (name === "@path") return target.pathname || "/"; + if (name === "@query") return "?" + query; + return merged[name]; + }; + + const base = buildSignatureBase(components, rawParams, resolve); + if (base === null) { + const missing = components.filter((c) => resolve(c) === undefined); + throw new SignatureError( + "signature_invalid", + 401, + `Cannot sign; missing components: ${missing.join(", ")}` + ); + } + const signature = rawSign(privateKey, base); + additions["Signature-Input"] = `sig1=${rawParams}`; + additions["Signature"] = `sig1=:${signature.toString("base64")}:`; + return additions; +} + +// Empties the resolved-key cache (used by tests). +export function clearKeyCache(): void { + keyCache.clear(); +} + +function isDisallowedV4(address: string): boolean { + const octets = address.split(".").map(Number); + const [a = 0, b = 0, c = 0] = octets; + return ( + a === 0 || // "this network" + a === 10 || // RFC 1918 + a === 127 || // loopback + (a === 100 && b >= 64 && b < 128) || // shared address space + (a === 169 && b === 254) || // link-local (incl. cloud metadata) + (a === 172 && b >= 16 && b < 32) || // RFC 1918 + (a === 192 && b === 0 && c === 0) || // IETF protocol assignments + (a === 192 && b === 0 && c === 2) || // documentation + (a === 192 && b === 168) || // RFC 1918 + (a === 198 && (b === 18 || b === 19)) || // benchmarking + (a === 198 && b === 51 && c === 100) || // documentation + (a === 203 && b === 0 && c === 113) || // documentation + a >= 224 // multicast and reserved + ); +} + +// Whether an address is special-use (loopback, private, link-local, reserved, +// multicast, unspecified). IPv6 is vetted by allowing only current global +// unicast (2000::/3, minus documentation space); v4-mapped addresses are +// classified by their embedded IPv4 address. +function isDisallowedAddress(address: string): boolean { + if (net.isIP(address) === 4) return isDisallowedV4(address); + const lower = address.toLowerCase(); + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower); + if (mapped) return isDisallowedV4(mapped[1]!); + if (lower.startsWith("2001:db8:") || lower === "2001:db8") return true; + const firstGroup = parseInt(lower.split(":")[0] || "0", 16); + return firstGroup < 0x2000 || firstGroup >= 0x4000; +} + +// Rejects profile URLs that violate the spec's transport and SSRF rules. +// NOTE: like the Python reference, this check is vulnerable to DNS rebinding +// (TOCTOU): the IP is validated here but resolved again by fetch. A production +// implementation should resolve once, validate, and pin the connection. +export async function assertProfileUrlAllowed( + url: string, + allowInsecure: boolean +): Promise { + let target: URL; + try { + target = new URL(url); + } catch { + throw new SignatureError( + "invalid_profile_url", + 400, + `Profile URL is malformed: ${JSON.stringify(url)}` + ); + } + if ( + target.protocol !== "https:" && + !(allowInsecure && target.protocol === "http:") + ) { + throw new SignatureError( + "invalid_profile_url", + 400, + `Profile URL must be HTTPS: ${JSON.stringify(url)}` + ); + } + if (target.username || target.password) { + throw new SignatureError( + "invalid_profile_url", + 400, + "Profile URL must not carry credentials" + ); + } + const host = target.hostname.replace(/^\[|\]$/g, ""); + if (!host) { + throw new SignatureError( + "invalid_profile_url", + 400, + `Profile URL has no host: ${JSON.stringify(url)}` + ); + } + if (allowInsecure) return; + let addresses: string[]; + if (net.isIP(host)) { + addresses = [host]; + } else { + try { + const results = await dns.lookup(host, { all: true }); + addresses = results.map((r) => r.address); + } catch { + throw new SignatureError( + "profile_unreachable", + 424, + `Cannot resolve profile host: ${host}` + ); + } + } + for (const address of addresses) { + if (isDisallowedAddress(address)) { + throw new SignatureError( + "invalid_profile_url", + 400, + `Profile URL resolves to a disallowed address: ${address}` + ); + } + } +} + +// Pulls the signing keys out of a profile document. keys[] is the canonical +// RFC 7517 JWK Set field per ucp#566, which removed the earlier +// signing_keys[]; this reference verifier reads only keys[]. +export function extractKeys(document: unknown): Jwk[] { + if (typeof document !== "object" || document === null) return []; + const doc = document as Record; + const ucp = "ucp" in doc ? doc["ucp"] : doc; + if (typeof ucp !== "object" || ucp === null || Array.isArray(ucp)) return []; + const value = (ucp as Record)["keys"]; + return Array.isArray(value) && value.length ? (value as Jwk[]) : []; +} + +// Fetches and caches a signer's published signing keys from its UCP profile +// (the keys[] of the document behind the UCP-Agent profile URL). +export async function fetchSigningKeys( + profileUrl: string, + options: { allowInsecure?: boolean } = {} +): Promise { + const cached = keyCache.get(profileUrl); + if (cached && cached.expires > Date.now()) return cached.keys; + + await assertProfileUrlAllowed(profileUrl, options.allowInsecure ?? false); + let response: globalThis.Response; + try { + response = await fetch(profileUrl, { + redirect: "manual", + signal: AbortSignal.timeout(5000), + }); + } catch (e) { + throw new SignatureError( + "profile_unreachable", + 424, + `Profile fetch failed: ${e}` + ); + } + if (response.status >= 300) { + throw new SignatureError( + "profile_unreachable", + 424, + `Profile fetch returned HTTP ${response.status}` + ); + } + let document: unknown; + try { + document = await response.json(); + } catch { + throw new SignatureError( + "profile_malformed", + 422, + "Profile is not valid JSON" + ); + } + const keys = extractKeys(document); + if (!keys.length) { + throw new SignatureError( + "profile_malformed", + 422, + "Profile publishes no signing keys" + ); + } + keyCache.set(profileUrl, { expires: Date.now() + KEY_CACHE_TTL_MS, keys }); + return keys; +} + +function signatureErrorResponse(c: Context, exc: SignatureError): Response { + // The same wire shape as the Python server's error envelope, inside the + // { detail } wrapper this server already uses for its 4xx responses. + return c.json( + { + detail: { + status: "error", + errors: [ + { code: exc.code, message: exc.message, severity: "critical" }, + ], + }, + }, + exc.statusCode as ContentfulStatusCode + ); +} + +// Hono middleware verifying an inbound request's RFC 9421 signature per the +// UCP spec. The signer's keys are discovered from the UCP-Agent header's +// profile URL (its keys[]). Behaviour depends on signatureConfig: +// +// * requireSignatures: a missing or invalid signature is rejected with the +// spec's error code (401 signature_missing / signature_invalid / +// key_not_found, 400 digest_mismatch / algorithm_unsupported, etc.). +// * Otherwise (the default), signatures are still verified when present and +// the outcome is logged, but unsigned or invalid requests are allowed. This +// keeps the sample interoperable with clients that do not yet sign. +// +// No profile fetch occurs unless a Signature-Input header is present, so +// unsigned traffic incurs no extra work. +export const verifySignature: MiddlewareHandler = async (c, next) => { + const enforcing = signatureConfig.requireSignatures; + const headers: Record = {}; + c.req.raw.headers.forEach((value, key) => { + headers[key] = value; + }); + + if (!("signature-input" in headers) || !("signature" in headers)) { + if (enforcing) { + return signatureErrorResponse( + c, + new SignatureError( + "signature_missing", + 401, + "Request signature is required" + ) + ); + } + c.var.logger.debug("No request signature present; skipping verification"); + return next(); + } + + const match = /profile="([^"]+)"/.exec(headers["ucp-agent"] ?? ""); + if (!match) { + const exc = new SignatureError( + "signature_invalid", + 401, + "UCP-Agent profile URL is required to resolve the signing key" + ); + if (enforcing) return signatureErrorResponse(c, exc); + c.var.logger.warn(`Cannot verify signature: ${exc.message}`); + return next(); + } + + const body = Buffer.from(await c.req.arrayBuffer()); + const url = new URL(c.req.url); + try { + const keys = await fetchSigningKeys(match[1]!, { + allowInsecure: signatureConfig.allowInsecureProfileUrls, + }); + const keyid = verifyRequest( + c.req.method, + url.host, + url.pathname, + url.search.slice(1), + headers, + body, + keys + ); + c.var.logger.info( + `RFC 9421 signature verified (keyid=${keyid}, profile=${match[1]})` + ); + } catch (e) { + if (!(e instanceof SignatureError)) throw e; + if (enforcing) return signatureErrorResponse(c, e); + c.var.logger.warn( + `Request signature verification failed (${e.code}: ${e.message}); ` + + "allowing because REQUIRE_SIGNATURES is not set" + ); + } + return next(); +}; diff --git a/rest/nodejs/test/signature.test.ts b/rest/nodejs/test/signature.test.ts new file mode 100644 index 00000000..1a523d88 --- /dev/null +++ b/rest/nodejs/test/signature.test.ts @@ -0,0 +1,510 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import http from "node:http"; +import { after, afterEach, before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { OrderService } from "../src/api/order"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; +import { signatureConfig } from "../src/utils/config"; +import { + buildSignatureBase, + clearKeyCache, + contentDigest, + jwkFromPublicKey, + parseSignatureInput, + signRequest, + verifySignature, +} from "../src/utils/signature"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +// End-to-end twin of the Python server's signature_integration_test.py: the +// permissive default leaves unsigned clients untouched while still verifying +// real signatures, and enforcement returns the spec's error code for every +// failure mode. Signer keys are discovered from a localhost profile server via +// the UCP-Agent header, as in the official conformance harness topology. + +const AUTHORITY = "merchant.test"; +const ORIGIN = `http://${AUTHORITY}`; + +type ErrorEnvelope = { + detail: { status: string; errors: Array<{ code: string; message: string }> }; +}; + +// A minimal app wired like src/index.ts (verifySignature ahead of validation) +// but without the pino middleware, following the lifecycle.test.ts convention. +function buildApp() { + const checkoutService = new CheckoutService(); + const orderService = new OrderService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", quietLogger as unknown as typeof console); + await next(); + }); + app.post( + "/checkout-sessions", + verifySignature, + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + checkoutService.createCheckout + ); + app.get( + "/checkout-sessions/:id", + verifySignature, + zValidator("param", IdParamSchema, prettyValidation), + checkoutService.getCheckout + ); + app.get( + "/orders/:id", + verifySignature, + zValidator("param", IdParamSchema, prettyValidation), + orderService.getOrder + ); + return app; +} + +// Captures verification log lines so tests can assert on them, and keeps the +// validation hook's payload dumps out of the test output. +const logLines: string[] = []; +const quietLogger = { + info: (msg: string) => logLines.push(String(msg)), + warn: (msg: string) => logLines.push(String(msg)), + debug: (msg: string) => logLines.push(String(msg)), +}; + +let app: ReturnType; +let profileServer: http.Server; +let profileHits: string[] = []; +let profileUrl: string; +let keylessUrl: string; +let port: number; + +const agentKeys = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); +const AGENT_KID = "test-agent-key"; +const edKeys = crypto.generateKeyPairSync("ed25519"); +const ED_KID = "test-agent-ed25519"; + +before(async () => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 100); + + const agentJwk = jwkFromPublicKey(agentKeys.publicKey, AGENT_KID); + const edJwk = jwkFromPublicKey(edKeys.publicKey, ED_KID); + // A deliberately unsupported (RSA) JWK to exercise algorithm_unsupported. + const rsaJwk = { kid: "rsa-key", kty: "RSA", n: "abc", e: "AQAB" }; + const good = JSON.stringify({ + ucp: { keys: [agentJwk, edJwk, rsaJwk] }, + }); + const keyless = JSON.stringify({ ucp: {} }); + + profileServer = http.createServer((req, res) => { + profileHits.push(req.url ?? ""); + const body = req.url === "/profile.json" ? good : keyless; + if (req.url !== "/profile.json" && req.url !== "/keyless.json") { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { "content-type": "application/json" }).end(body); + }); + await new Promise((resolve) => + profileServer.listen(0, "127.0.0.1", resolve) + ); + const address = profileServer.address(); + port = typeof address === "object" && address ? address.port : 0; + profileUrl = `http://127.0.0.1:${port}/profile.json`; + keylessUrl = `http://127.0.0.1:${port}/keyless.json`; + + app = buildApp(); +}); + +after(() => { + profileServer.close(); +}); + +afterEach(() => { + signatureConfig.requireSignatures = false; + signatureConfig.allowInsecureProfileUrls = false; + clearKeyCache(); + logLines.length = 0; + profileHits = []; +}); + +function enforce() { + signatureConfig.requireSignatures = true; + signatureConfig.allowInsecureProfileUrls = true; +} + +function permissive() { + signatureConfig.requireSignatures = false; + signatureConfig.allowInsecureProfileUrls = true; +} + +function checkoutBody(): string { + return JSON.stringify({ + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, + }); +} + +type SignedOptions = { + key?: crypto.KeyObject; + kid?: string; + profile?: string; + created?: number; + coverUcpAgent?: boolean; +}; + +function signedHeaders( + method: string, + path: string, + body: string, + options: SignedOptions = {} +): Record { + const key = options.key ?? agentKeys.privateKey; + const kid = options.kid ?? AGENT_KID; + const profile = options.profile ?? profileUrl; + const headers: Record = { + "UCP-Agent": `profile="${profile}"`, + "Idempotency-Key": crypto.randomUUID(), + "Request-Id": crypto.randomUUID(), + }; + const signHeaders: Record = { ...headers }; + if (options.coverUcpAgent === false) delete signHeaders["UCP-Agent"]; + const additions = signRequest( + key, + kid, + method, + `${ORIGIN}${path}`, + signHeaders, + Buffer.from(body), + options.created + ); + return { ...headers, ...additions }; +} + +async function postCheckout(headers: Record, body: string) { + return app.request(`${ORIGIN}/checkout-sessions`, { + method: "POST", + headers, + body, + }); +} + +async function assertError( + response: Response, + status: number, + code: string +): Promise { + assert.equal(response.status, status, await response.clone().text()); + const envelope = (await response.json()) as ErrorEnvelope; + assert.equal(envelope.detail.status, "error"); + assert.equal(envelope.detail.errors[0]?.code, code); +} + +/* Permissive (default) mode: existing clients keep working. */ + +test("permissive: an unsigned request skips verification entirely", async () => { + permissive(); + const body = checkoutBody(); + // The checkout handler itself fetches the profile for webhook resolution, + // so a raw hit count cannot isolate the middleware; the skip branch (which + // never fetches keys) is asserted through its log line instead. + const response = await postCheckout( + { + "UCP-Agent": `profile="${profileUrl}"`, + "request-signature": "test", + "idempotency-key": crypto.randomUUID(), + "request-id": "1", + "content-type": "application/json", + }, + body + ); + assert.equal(response.status, 201, await response.clone().text()); + assert.ok( + logLines.some((line) => + line.includes("No request signature present; skipping verification") + ), + logLines.join("\n") + ); +}); + +test("permissive: omitting Request-Signature entirely still succeeds", async () => { + permissive(); + const response = await postCheckout( + { + "UCP-Agent": 'profile="https://agent.example/profile"', + "idempotency-key": crypto.randomUUID(), + "request-id": "1", + "content-type": "application/json", + }, + checkoutBody() + ); + assert.equal(response.status, 201, await response.clone().text()); +}); + +test("permissive: a signed request without profile= is allowed", async () => { + permissive(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["UCP-Agent"] = 'version="2026-04-08"'; + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); +}); + +test("permissive: a valid signature is verified and logged", async () => { + permissive(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); + assert.ok( + logLines.some((line) => line.includes("RFC 9421 signature verified")), + logLines.join("\n") + ); +}); + +test("permissive: an invalid signature is allowed but warned about", async () => { + permissive(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + const response = await postCheckout(headers, body + " "); + assert.equal(response.status, 201, await response.clone().text()); + assert.ok( + logLines.some((line) => line.includes("verification failed")), + logLines.join("\n") + ); +}); + +/* Enforcement on: every failure mode returns its spec error code. */ + +test("enforced: a correctly signed request is accepted", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); +}); + +test("enforced: an Ed25519-signed request is accepted", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + key: edKeys.privateKey, + kid: ED_KID, + }); + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); +}); + +test("enforced: an unsigned request is rejected with signature_missing", async () => { + enforce(); + const response = await postCheckout( + { + "UCP-Agent": `profile="${profileUrl}"`, + "idempotency-key": crypto.randomUUID(), + "request-id": "1", + "content-type": "application/json", + }, + checkoutBody() + ); + await assertError(response, 401, "signature_missing"); +}); + +test("enforced: an unsigned GET on the order route is rejected", async () => { + enforce(); + const response = await app.request(`${ORIGIN}/orders/order_1`, { + headers: { "UCP-Agent": `profile="${profileUrl}"` }, + }); + await assertError(response, 401, "signature_missing"); +}); + +test("enforced: a signed GET on the checkout route verifies end to end", async () => { + enforce(); + const headers = signedHeaders("GET", "/checkout-sessions/nonexistent", ""); + const response = await app.request( + `${ORIGIN}/checkout-sessions/nonexistent`, + { + headers, + } + ); + // Verification passed; the 404 comes from the handler, not the signature. + assert.equal(response.status, 404, await response.clone().text()); +}); + +test("enforced: a signature without a profile= is signature_invalid", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["UCP-Agent"] = 'version="2026-04-08"'; + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: a tampered body yields digest_mismatch", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + const response = await postCheckout(headers, body + " "); + await assertError(response, 400, "digest_mismatch"); +}); + +test("enforced: a garbage signature value yields signature_invalid", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["Signature"] = `sig1=:${crypto.randomBytes(64).toString("base64")}:`; + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: a signature from an unpublished key is signature_invalid", async () => { + enforce(); + const other = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + key: other.privateKey, + }); + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: a keyid not in the published set is key_not_found", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + kid: "nonexistent", + }); + const response = await postCheckout(headers, body); + await assertError(response, 401, "key_not_found"); +}); + +test("enforced: a DER-encoded signature on the wire is signature_invalid", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + // Re-sign the exact base as DER to violate the raw-r||s requirement. + const parsed = parseSignatureInput(headers["Signature-Input"]!); + assert.ok(parsed); + const member = parsed["sig1"]!; + const digest = contentDigest(Buffer.from(body)); + const values: Record = { + "@method": "POST", + "@authority": AUTHORITY, + "@path": "/checkout-sessions", + "content-digest": digest, + "content-type": "application/json", + "idempotency-key": headers["Idempotency-Key"]!, + "ucp-agent": headers["UCP-Agent"]!, + }; + const base = buildSignatureBase( + member.components, + member.raw, + (name) => values[name] + ); + assert.ok(base); + const der = crypto.sign("sha256", base, agentKeys.privateKey); + headers["Signature"] = `sig1=:${der.toString("base64")}:`; + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: omitting a required covered component is signature_invalid", async () => { + enforce(); + const body = checkoutBody(); + // Sign WITHOUT ucp-agent in the covered set, then send the header anyway. + const headers = signedHeaders("POST", "/checkout-sessions", body, { + coverUcpAgent: false, + }); + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: an alg parameter yields signature_invalid", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["Signature-Input"] = headers["Signature-Input"]!.replace( + ";created", + ';alg="ecdsa-p256-sha256";created' + ); + const response = await postCheckout(headers, body); + await assertError(response, 401, "signature_invalid"); +}); + +test("enforced: a keyid selecting an RSA key is algorithm_unsupported", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["Signature-Input"] = headers["Signature-Input"]!.replace( + `keyid="${AGENT_KID}"`, + 'keyid="rsa-key"' + ); + const response = await postCheckout(headers, body); + await assertError(response, 400, "algorithm_unsupported"); +}); + +test("enforced: a dead profile port yields profile_unreachable", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + profile: "http://127.0.0.1:1/profile.json", + }); + const response = await postCheckout(headers, body); + await assertError(response, 424, "profile_unreachable"); +}); + +test("enforced: a keyless profile yields profile_malformed", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + profile: keylessUrl, + }); + const response = await postCheckout(headers, body); + await assertError(response, 422, "profile_malformed"); +}); + +test("enforced: an http profile URL without the carve-out is rejected", async () => { + enforce(); + signatureConfig.allowInsecureProfileUrls = false; + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + const response = await postCheckout(headers, body); + await assertError(response, 400, "invalid_profile_url"); +}); + +test("enforced: one bad and one valid signature is accepted", async () => { + enforce(); + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body); + headers["Signature-Input"] += + `, sig2=("@method");created=1;keyid="${AGENT_KID}"`; + headers["Signature"] += ", sig2=:AAAA:"; + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); +}); + +test("enforced: created far in the past or future is still accepted", async () => { + // The created parameter is OPTIONAL per signatures.md: replay protection is + // handled at the business layer through idempotency keys, so no created + // window is enforced -- mirroring the Python reference verifier. + enforce(); + for (const skew of [-100_000, 100_000]) { + const body = checkoutBody(); + const headers = signedHeaders("POST", "/checkout-sessions", body, { + created: Math.floor(Date.now() / 1000) + skew, + }); + const response = await postCheckout(headers, body); + assert.equal(response.status, 201, await response.clone().text()); + } +}); diff --git a/rest/nodejs/test/signing.test.ts b/rest/nodejs/test/signing.test.ts new file mode 100644 index 00000000..13bd6122 --- /dev/null +++ b/rest/nodejs/test/signing.test.ts @@ -0,0 +1,917 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import http from "node:http"; +import { after, before, test } from "node:test"; + +import { + SignatureError, + assertProfileUrlAllowed, + buildSignatureBase, + clearKeyCache, + contentDigest, + contentDigestMatches, + extractKeys, + fetchSigningKeys, + jwkFromPublicKey, + normalizeAuthority, + parseSignature, + parseSignatureInput, + requiredComponents, + sfSplit, + signRequest, + verifyRawSignature, + verifyRequest, +} from "../src/utils/signature"; + +// RFC 9421 Appendix B.1.4 test-key-ed25519 (JWK coordinates, verbatim). +const RFC_ED25519_JWK = { + kty: "OKP", + crv: "Ed25519", + kid: "test-key-ed25519", + x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs", +}; +const RFC_ED25519_D = "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU"; + +// RFC 9421 Appendix B.2.6 signature base and signature (byte-exact oracle). +const RFC_B26_BASE = Buffer.from( + [ + '"date": Tue, 20 Apr 2021 02:07:55 GMT', + '"@method": POST', + '"@path": /foo', + '"@authority": example.com', + '"content-type": application/json', + '"content-length": 18', + '"@signature-params": ("date" "@method" "@path" "@authority" ' + + '"content-type" "content-length");created=1618884473' + + ';keyid="test-key-ed25519"', + ].join("\n") +); +const RFC_B26_SIGNATURE = Buffer.from( + "wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4" + + "A0w6vuQv5lIp5WPpBKRCw==", + "base64" +); + +function es256KeyPair() { + return crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); +} + +function ed25519KeyPair() { + return crypto.generateKeyPairSync("ed25519"); +} + +function assertSignatureError(fn: () => unknown, code: string) { + try { + fn(); + } catch (e) { + assert.ok(e instanceof SignatureError, `expected SignatureError, got ${e}`); + assert.equal(e.code, code); + return e; + } + assert.fail(`expected SignatureError(${code}), nothing thrown`); +} + +async function assertSignatureErrorAsync( + fn: () => Promise, + code: string +) { + try { + await fn(); + } catch (e) { + assert.ok(e instanceof SignatureError, `expected SignatureError, got ${e}`); + assert.equal(e.code, code); + return e; + } + assert.fail(`expected SignatureError(${code}), nothing thrown`); +} + +/* RFC 9530 Content-Digest generation and matching. */ + +test("content digest matches the RFC 9530 LF body vector", () => { + assert.equal( + contentDigest(Buffer.from('{"hello": "world"}\n')), + "sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=:" + ); +}); + +test("content digest matches the RFC 9421 no-LF body vector", () => { + assert.equal( + contentDigest(Buffer.from('{"hello": "world"}')), + "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:" + ); +}); + +test("content digest matching accepts the right body and rejects others", () => { + const body = Buffer.from('{"a": 1}'); + const header = contentDigest(body); + assert.equal(contentDigestMatches(header, body), true); + assert.equal(contentDigestMatches(header, Buffer.from('{"a": 2}')), false); +}); + +test("content digest matching rejects malformed header forms", () => { + const body = Buffer.from("x"); + assert.equal(contentDigestMatches("sha-256=abc", body), false); + assert.equal(contentDigestMatches("sha-256=:@@@:", body), false); + assert.equal(contentDigestMatches("md5=:AA==:", body), false); +}); + +test("unpadded base64 is malformed, matching the Python verifier", () => { + // Python parses with base64.b64decode(validate=True), which raises on + // incorrect padding; both references must agree on the same wire bytes. + const body = Buffer.from("x"); + const digest = crypto.createHash("sha256").update(body).digest("base64"); + const unpadded = digest.replace(/=+$/, ""); + if (unpadded !== digest) { + assert.equal(contentDigestMatches(`sha-256=:${unpadded}:`, body), false); + } + assert.equal(parseSignature("sig1=:QUJDRA:"), null); + assert.equal(parseSignature("sig1=:A:"), null); +}); + +/* RFC 9421 signature-base construction. */ + +test("signature base reconstructs the RFC B.2.6 bytes exactly", () => { + const components = [ + "date", + "@method", + "@path", + "@authority", + "content-type", + "content-length", + ]; + const raw = + '("date" "@method" "@path" "@authority" "content-type" ' + + '"content-length");created=1618884473;keyid="test-key-ed25519"'; + const values: Record = { + date: "Tue, 20 Apr 2021 02:07:55 GMT", + "@method": "POST", + "@path": "/foo", + "@authority": "example.com", + "content-type": "application/json", + "content-length": "18", + }; + const base = buildSignatureBase(components, raw, (name) => values[name]); + assert.ok(base); + assert.deepEqual(base, RFC_B26_BASE); +}); + +test("signature base echoes @signature-params verbatim", () => { + const raw = '("@method");created=5;keyid="k"'; + const base = buildSignatureBase(["@method"], raw, () => "GET"); + assert.ok(base); + assert.ok(base.toString().endsWith(`"@signature-params": ${raw}`)); +}); + +test("signature base aborts on an unresolvable component", () => { + assert.equal( + buildSignatureBase(["x-missing"], "()", () => undefined), + null + ); +}); + +/* Byte-exact Ed25519 and verify-direction ES256 against Appendix B. */ + +test("the RFC published Ed25519 signature verifies", () => { + verifyRawSignature(RFC_ED25519_JWK, RFC_B26_BASE, RFC_B26_SIGNATURE); +}); + +test("Ed25519: signRequest accepts the key; the primitive matches the RFC vector", () => { + const key = crypto.createPrivateKey({ + key: { ...RFC_ED25519_JWK, d: RFC_ED25519_D }, + format: "jwk", + }); + const additions = signRequest( + key, + "test-key-ed25519", + "GET", + "https://example.com/", + {}, + Buffer.alloc(0) + ); + assert.ok(additions["Signature"]); + // Determinism check via a direct signature over the RFC base: Ed25519 has no + // nonce, so the signature must equal the RFC bytes. + const sig = crypto.sign(null, RFC_B26_BASE, key); + assert.deepEqual(sig, RFC_B26_SIGNATURE); +}); + +test("a tampered base no longer verifies against the RFC signature", () => { + assertSignatureError( + () => + verifyRawSignature( + RFC_ED25519_JWK, + Buffer.concat([RFC_B26_BASE, Buffer.from(" ")]), + RFC_B26_SIGNATURE + ), + "signature_invalid" + ); +}); + +test("an ES256 signature we produce verifies with the derived JWK", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k"); + const additions = signRequest( + privateKey, + "k", + "GET", + "https://m.example/p", + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"], + signature: additions["Signature"], + }; + const keyid = verifyRequest( + "GET", + "m.example", + "/p", + "", + headers, + Buffer.alloc(0), + [jwk] + ); + assert.equal(keyid, "k"); +}); + +test("an Ed25519-signed request verifies through the full verifyRequest", () => { + const { publicKey, privateKey } = ed25519KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "ed-k"); + const additions = signRequest( + privateKey, + "ed-k", + "GET", + "https://m.example/p", + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"], + signature: additions["Signature"], + }; + const keyid = verifyRequest( + "GET", + "m.example", + "/p", + "", + headers, + Buffer.alloc(0), + [jwk] + ); + assert.equal(keyid, "ed-k"); +}); + +/* The UCP raw-r||s ECDSA requirement (spec MUST). */ + +test("a DER-encoded ECDSA signature is rejected as non-conformant", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k"); + const der = crypto.sign("sha256", RFC_B26_BASE, privateKey); + assertSignatureError( + () => verifyRawSignature(jwk, RFC_B26_BASE, der), + "signature_invalid" + ); +}); + +test("a well-formed 64-byte raw signature verifies", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k"); + const sig = crypto.sign("sha256", RFC_B26_BASE, { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); + assert.equal(sig.length, 64); + verifyRawSignature(jwk, RFC_B26_BASE, sig); +}); + +test("signatures that are not 64 bytes are rejected before verification", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k"); + const sig = crypto.sign("sha256", RFC_B26_BASE, { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); + for (const bad of [ + sig.subarray(0, sig.length - 1), + Buffer.concat([sig, Buffer.from([0])]), + ]) { + assertSignatureError( + () => verifyRawSignature(jwk, RFC_B26_BASE, bad), + "signature_invalid" + ); + } +}); + +/* RFC 8941 subset parsing of Signature-Input and Signature. */ + +test("a well-formed member yields components and parameters", () => { + const parsed = parseSignatureInput( + 'sig1=("@method" "content-digest");created=1;keyid="abc"' + ); + assert.ok(parsed); + assert.deepEqual(parsed["sig1"]?.components, ["@method", "content-digest"]); + assert.equal(parsed["sig1"]?.params["keyid"], "abc"); +}); + +test("multiple comma-separated members are all parsed", () => { + const parsed = parseSignatureInput( + 'a=("@method");keyid="x", b=("@path");keyid="y"' + ); + assert.ok(parsed); + assert.deepEqual(Object.keys(parsed).sort(), ["a", "b"]); +}); + +test("a Signature member decodes to raw bytes", () => { + const raw = Buffer.from("hello").toString("base64"); + const parsed = parseSignature(`sig1=:${raw}:`); + assert.ok(parsed); + assert.deepEqual(parsed["sig1"], Buffer.from("hello")); +}); + +test("malformed inputs parse to null rather than throwing", () => { + assert.equal(parseSignatureInput("not a signature input"), null); + assert.equal(parseSignature(""), null); + assert.equal(parseSignatureInput("sig1"), null); + assert.equal(parseSignatureInput("sig1=(@method)"), null); + assert.equal(parseSignature("sig1"), null); + assert.equal(parseSignature("sig1=abc"), null); + assert.equal(parseSignature("sig1=:@@@:"), null); + assert.equal(parseSignatureInput(""), null); +}); + +test("the splitter honours backslash escapes inside quoted strings", () => { + const parts = sfSplit(String.raw`"a\"b,c" , "d"`, ","); + assert.deepEqual(parts, [String.raw`"a\"b,c"`, '"d"']); +}); + +test("a trailing separator does not emit an empty final segment", () => { + assert.deepEqual(sfSplit("a,", ","), ["a"]); + assert.deepEqual(sfSplit("", ","), []); +}); + +test("nested and unbalanced parens degrade safely", () => { + const parsed = parseSignatureInput('sig1=("@method" "@path");created=1'); + assert.ok(parsed); + assert.deepEqual(parsed["sig1"]?.components, ["@method", "@path"]); + const embedded = parseSignatureInput('sig1=("a(b" "c");created=1'); + assert.ok(embedded === null || embedded["sig1"]?.components.length === 0); + const unclosed = parseSignatureInput('sig1=("a";created=1'); + assert.ok(unclosed === null || unclosed["sig1"]?.components.length === 0); +}); + +/* The UCP required-component coverage table. */ + +test("a bodyless GET requires only the target components", () => { + assert.deepEqual(requiredComponents("GET", false, {}, false), [ + "@method", + "@authority", + "@path", + ]); +}); + +test("a bodied request must cover content-digest and content-type", () => { + const required = requiredComponents("POST", false, {}, true); + assert.ok(required.includes("content-digest")); + assert.ok(required.includes("content-type")); +}); + +test("a query string adds @query", () => { + assert.ok(requiredComponents("GET", true, {}, false).includes("@query")); +}); + +test("coverage keys on header presence, not on the method", () => { + const required = requiredComponents( + "GET", + false, + { "idempotency-key": "x" }, + false + ); + assert.ok(required.includes("idempotency-key")); +}); + +test("ucp-agent must be covered; signature-agent is out of scope", () => { + const required = requiredComponents( + "GET", + false, + { "ucp-agent": "a", "signature-agent": "b" }, + false + ); + assert.ok(required.includes("ucp-agent")); + assert.ok(!required.includes("signature-agent")); +}); + +test("a signature carrying an alg parameter is rejected (spec MUST NOT)", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k"); + const additions = signRequest( + privateKey, + "k", + "GET", + "https://h/p", + { "ucp-agent": 'profile="https://a/p"' }, + Buffer.alloc(0) + ); + const headers = { + "ucp-agent": 'profile="https://a/p"', + "signature-input": additions["Signature-Input"]!.replace( + ";created", + ';alg="ecdsa-p256-sha256";created' + ), + signature: additions["Signature"]!, + }; + assertSignatureError( + () => verifyRequest("GET", "h", "/p", "", headers, Buffer.alloc(0), [jwk]), + "signature_invalid" + ); +}); + +/* Verify-side normalization must match the signer's canonical base. */ + +function signedGet(url: string) { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const additions = signRequest( + privateKey, + "k1", + "GET", + url, + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"]!, + signature: additions["Signature"]!, + }; + return { jwk, headers }; +} + +test("the default port is stripped per RFC 9421 Section 2.2.3", () => { + const { jwk, headers } = signedGet("https://merchant.example/p"); + const keyid = verifyRequest( + "GET", + "merchant.example:443", + "/p", + "", + headers, + Buffer.alloc(0), + [jwk] + ); + assert.equal(keyid, "k1"); +}); + +test("an empty path is normalized to / on both sides", () => { + const { jwk, headers } = signedGet("https://merchant.example/"); + const keyid = verifyRequest( + "GET", + "merchant.example", + "", + "", + headers, + Buffer.alloc(0), + [jwk] + ); + assert.equal(keyid, "k1"); +}); + +test("host:80 normalises to host", () => { + assert.equal(normalizeAuthority("Host.Example:80"), "host.example"); +}); + +test("covered field values are OWS-trimmed per RFC 9421 Section 2.1", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const body = Buffer.from('{"x":1}'); + const additions = signRequest( + privateKey, + "k1", + "POST", + "https://m.example/o", + { "content-type": "application/json" }, + body + ); + const headers = { + "content-type": " application/json ", + "content-digest": additions["Content-Digest"]!, + "signature-input": additions["Signature-Input"]!, + signature: additions["Signature"]!, + }; + const keyid = verifyRequest("POST", "m.example", "/o", "", headers, body, [ + jwk, + ]); + assert.equal(keyid, "k1"); +}); + +test("a signed request with a query string verifies", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const additions = signRequest( + privateKey, + "k1", + "GET", + "https://m.example/p?a=1", + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"]!, + signature: additions["Signature"]!, + }; + const keyid = verifyRequest( + "GET", + "m.example", + "/p", + "a=1", + headers, + Buffer.alloc(0), + [jwk] + ); + assert.equal(keyid, "k1"); +}); + +test("a signature covering a header absent at verify time is invalid", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const raw = + '("@method" "@authority" "@path" "x-custom");created=1;keyid="k1"'; + const table: Record = { + "@method": "GET", + "@authority": "m.example", + "@path": "/p", + "x-custom": "v", + }; + const base = buildSignatureBase( + ["@method", "@authority", "@path", "x-custom"], + raw, + (name) => table[name] + ); + assert.ok(base); + const sig = crypto.sign("sha256", base, { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); + const headers = { + "signature-input": `sig1=${raw}`, + signature: `sig1=:${sig.toString("base64")}:`, + }; + assertSignatureError( + () => + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "signature_invalid" + ); +}); + +/* verify_request handles a present-but-unusable signature header set. */ + +test("a malformed Signature-Input yields signature_missing", () => { + const headers = { "signature-input": "garbage", signature: "sig1=:AA==:" }; + assertSignatureError( + () => + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), []), + "signature_missing" + ); +}); + +test("a label with no matching Signature member is skipped and fails", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const additions = signRequest( + privateKey, + "k1", + "GET", + "https://m.example/p", + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"]!.replace("sig1=", "sig2="), + signature: additions["Signature"]!, + }; + assert.throws(() => + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [jwk]) + ); +}); + +test("a bodied request without Content-Digest is digest_mismatch", () => { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = jwkFromPublicKey(publicKey, "k1"); + const body = Buffer.from('{"x":1}'); + const additions = signRequest( + privateKey, + "k1", + "POST", + "https://m.example/o", + { "content-type": "application/json" }, + body + ); + const headers = { + "content-type": "application/json", + "signature-input": additions["Signature-Input"]!, + signature: additions["Signature"]!, + }; + assertSignatureError( + () => verifyRequest("POST", "m.example", "/o", "", headers, body, [jwk]), + "digest_mismatch" + ); +}); + +/* Signature-capable key filtering: use / key_ops (RFC 7517 4.2, 4.3). */ + +function signedWithJwkExtra(extra: Record) { + const { publicKey, privateKey } = es256KeyPair(); + const jwk = { ...jwkFromPublicKey(publicKey, "k1"), ...extra }; + const additions = signRequest( + privateKey, + "k1", + "GET", + "https://m.example/p", + {}, + Buffer.alloc(0) + ); + const headers = { + "signature-input": additions["Signature-Input"]!, + signature: additions["Signature"]!, + }; + return { jwk, headers }; +} + +test('a key marked use:"sig" verifies', () => { + const { jwk, headers } = signedWithJwkExtra({ use: "sig" }); + assert.equal( + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "k1" + ); +}); + +test("a key with no use member verifies (use is OPTIONAL)", () => { + const { jwk, headers } = signedWithJwkExtra({}); + delete (jwk as Record)["use"]; + assert.equal( + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "k1" + ); +}); + +test('a use:"enc" key with the matching kid is key_not_found', () => { + const { jwk, headers } = signedWithJwkExtra({ use: "enc" }); + assertSignatureError( + () => + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "key_not_found" + ); +}); + +test('a key whose key_ops omits "verify" is skipped', () => { + const { jwk, headers } = signedWithJwkExtra({ + key_ops: ["encrypt", "decrypt"], + }); + assertSignatureError( + () => + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "key_not_found" + ); +}); + +test('a key whose key_ops includes "verify" is capable', () => { + const { jwk, headers } = signedWithJwkExtra({ key_ops: ["verify"] }); + assert.equal( + verifyRequest("GET", "m.example", "/p", "", headers, Buffer.alloc(0), [ + jwk, + ]), + "k1" + ); +}); + +/* public key construction maps malformed / unsupported keys to spec codes. */ + +test("an EC JWK missing its y coordinate is signature_invalid", () => { + assertSignatureError( + () => + verifyRawSignature( + { kty: "EC", crv: "P-256", x: "AA" }, + RFC_B26_BASE, + Buffer.alloc(64) + ), + "signature_invalid" + ); +}); + +test("an RSA JWK is algorithm_unsupported", () => { + assertSignatureError( + () => + verifyRawSignature( + { kty: "RSA", n: "AA", e: "AQAB" }, + RFC_B26_BASE, + Buffer.alloc(64) + ), + "algorithm_unsupported" + ); +}); + +/* Profile-URL transport and SSRF guards. */ + +test("plain http is rejected unless the insecure carve-out is set", async () => { + await assertSignatureErrorAsync( + () => assertProfileUrlAllowed("http://example.com/p", false), + "invalid_profile_url" + ); +}); + +test("the cloud metadata address is rejected", async () => { + await assertSignatureErrorAsync( + () => assertProfileUrlAllowed("https://169.254.169.254/latest", false), + "invalid_profile_url" + ); +}); + +test("loopback and RFC 1918 hosts are rejected without the carve-out", async () => { + for (const url of ["https://127.0.0.1/p", "https://10.0.0.5/p"]) { + await assertSignatureErrorAsync( + () => assertProfileUrlAllowed(url, false), + "invalid_profile_url" + ); + } +}); + +test("a URL carrying userinfo is rejected", async () => { + await assertSignatureErrorAsync( + () => assertProfileUrlAllowed("https://u:p@example.com/p", false), + "invalid_profile_url" + ); +}); + +test("the carve-out permits http loopback for localhost demos", async () => { + await assertProfileUrlAllowed("http://127.0.0.1:8285/p", true); +}); + +test("a DNS failure on the profile host is profile_unreachable", async () => { + await assertSignatureErrorAsync( + () => + assertProfileUrlAllowed("https://nonexistent.invalid.example./x", false), + "profile_unreachable" + ); +}); + +test("a host resolving to a public address passes the SSRF guard", async () => { + // A literal public IP exercises the address vetting without a DNS mock. + await assertProfileUrlAllowed("https://93.184.216.34/.well-known/ucp", false); +}); + +/* Key discovery from a signer profile, against a local profile server. */ + +let profileServer: http.Server; +let profilePort: number; +let profileResponses: Record< + string, + { status: number; body: string; location?: string } +>; +let profileHits: string[]; + +before(async () => { + profileResponses = {}; + profileHits = []; + profileServer = http.createServer((req, res) => { + profileHits.push(req.url ?? ""); + const entry = profileResponses[req.url ?? ""]; + if (!entry) { + res.writeHead(404).end(); + return; + } + const headers: Record = { + "content-type": "application/json", + }; + if (entry.location) headers["location"] = entry.location; + res.writeHead(entry.status, headers); + res.end(entry.body); + }); + await new Promise((resolve) => + profileServer.listen(0, "127.0.0.1", resolve) + ); + const address = profileServer.address(); + profilePort = typeof address === "object" && address ? address.port : 0; +}); + +after(() => { + profileServer.close(); +}); + +function profileUrl(path: string): string { + return `http://127.0.0.1:${profilePort}${path}`; +} + +test("keys[] is read from the ucp envelope", async () => { + clearKeyCache(); + profileResponses["/envelope.json"] = { + status: 200, + body: JSON.stringify({ ucp: { keys: [{ kid: "a" }] } }), + }; + const keys = await fetchSigningKeys(profileUrl("/envelope.json"), { + allowInsecure: true, + }); + assert.equal(keys[0]?.kid, "a"); +}); + +test("a top-level keys[] array (no ucp wrapper) is read", async () => { + clearKeyCache(); + profileResponses["/top.json"] = { + status: 200, + body: JSON.stringify({ keys: [{ kid: "b" }] }), + }; + const keys = await fetchSigningKeys(profileUrl("/top.json"), { + allowInsecure: true, + }); + assert.equal(keys[0]?.kid, "b"); +}); + +test("a profile with only the removed signing_keys[] is profile_malformed", async () => { + clearKeyCache(); + profileResponses["/legacy.json"] = { + status: 200, + body: JSON.stringify({ ucp: { signing_keys: [{ kid: "old" }] } }), + }; + await assertSignatureErrorAsync( + () => fetchSigningKeys(profileUrl("/legacy.json"), { allowInsecure: true }), + "profile_malformed" + ); +}); + +test("a 3xx response is treated as unreachable (no redirects allowed)", async () => { + clearKeyCache(); + profileResponses["/redirect.json"] = { + status: 302, + body: "", + location: "https://x/y", + }; + await assertSignatureErrorAsync( + () => + fetchSigningKeys(profileUrl("/redirect.json"), { allowInsecure: true }), + "profile_unreachable" + ); +}); + +test("a non-JSON body yields profile_malformed", async () => { + clearKeyCache(); + profileResponses["/notjson.json"] = { status: 200, body: "not json" }; + await assertSignatureErrorAsync( + () => + fetchSigningKeys(profileUrl("/notjson.json"), { allowInsecure: true }), + "profile_malformed" + ); +}); + +test("a profile with no keys yields profile_malformed", async () => { + clearKeyCache(); + profileResponses["/keyless.json"] = { + status: 200, + body: JSON.stringify({ ucp: {} }), + }; + await assertSignatureErrorAsync( + () => + fetchSigningKeys(profileUrl("/keyless.json"), { allowInsecure: true }), + "profile_malformed" + ); +}); + +test("a second fetch within the TTL is served from the cache", async () => { + clearKeyCache(); + profileResponses["/cached.json"] = { + status: 200, + body: JSON.stringify({ ucp: { keys: [{ kid: "c" }] } }), + }; + await fetchSigningKeys(profileUrl("/cached.json"), { allowInsecure: true }); + const hitsAfterFirst = profileHits.filter((p) => p === "/cached.json").length; + await fetchSigningKeys(profileUrl("/cached.json"), { allowInsecure: true }); + const hitsAfterSecond = profileHits.filter( + (p) => p === "/cached.json" + ).length; + assert.equal(hitsAfterFirst, 1); + assert.equal(hitsAfterSecond, 1); +}); + +/* extractKeys reads keys[] (canonical per ucp#566) and tolerates junk. */ + +test("a non-object profile yields no keys, not an error", () => { + assert.deepEqual(extractKeys(["not", "a", "dict"]), []); +}); + +test("keys[] under the ucp envelope is the canonical source", () => { + assert.deepEqual(extractKeys({ ucp: { keys: [{ kid: "k" }] } }), [ + { kid: "k" }, + ]); +}); + +test("the removed signing_keys[] field is not read (ucp#566)", () => { + assert.deepEqual( + extractKeys({ ucp: { signing_keys: [{ kid: "old" }] } }), + [] + ); +}); From 3988e1b91293bfc220b1fd85d97d2690612f65c0 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Tue, 4 Aug 2026 07:56:41 +0000 Subject: [PATCH 2/2] style: add copyright headers to Node.js sample files --- rest/nodejs/src/api/checkout.ts | 14 ++++++++++++++ rest/nodejs/src/api/discovery.ts | 14 ++++++++++++++ rest/nodejs/src/api/order.ts | 14 ++++++++++++++ rest/nodejs/src/api/testing.ts | 14 ++++++++++++++ rest/nodejs/src/data/db.ts | 14 ++++++++++++++ rest/nodejs/src/data/index.ts | 14 ++++++++++++++ rest/nodejs/src/data/inventory.ts | 14 ++++++++++++++ rest/nodejs/src/data/products.ts | 14 ++++++++++++++ rest/nodejs/src/data/transactions.ts | 14 ++++++++++++++ rest/nodejs/src/index.ts | 14 ++++++++++++++ rest/nodejs/src/models/index.ts | 14 ++++++++++++++ rest/nodejs/src/utils/config.ts | 14 ++++++++++++++ rest/nodejs/src/utils/signature.ts | 14 ++++++++++++++ rest/nodejs/src/utils/validation.ts | 14 ++++++++++++++ rest/nodejs/test/discount.test.ts | 14 ++++++++++++++ rest/nodejs/test/discovery.test.ts | 14 ++++++++++++++ rest/nodejs/test/fulfillment.test.ts | 14 ++++++++++++++ rest/nodejs/test/idempotency.test.ts | 14 ++++++++++++++ rest/nodejs/test/lifecycle.test.ts | 14 ++++++++++++++ rest/nodejs/test/signature.test.ts | 14 ++++++++++++++ rest/nodejs/test/signing.test.ts | 14 ++++++++++++++ rest/nodejs/test/validation.test.ts | 14 ++++++++++++++ rest/nodejs/test/validation_flow.test.ts | 14 ++++++++++++++ rest/nodejs/test/webhook.test.ts | 14 ++++++++++++++ 24 files changed, 336 insertions(+) diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index fff57256..b0e68267 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { createHash } from "crypto"; import { type Context } from "hono"; import { v4 as uuidv4 } from "uuid"; diff --git a/rest/nodejs/src/api/discovery.ts b/rest/nodejs/src/api/discovery.ts index 98559e63..4811b3fb 100644 --- a/rest/nodejs/src/api/discovery.ts +++ b/rest/nodejs/src/api/discovery.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { type Context } from "hono"; import { UCP_VERSION } from "../utils/config"; diff --git a/rest/nodejs/src/api/order.ts b/rest/nodejs/src/api/order.ts index fe8cecd6..819fe04e 100644 --- a/rest/nodejs/src/api/order.ts +++ b/rest/nodejs/src/api/order.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { getOrder, logRequest, saveOrder } from "../data"; import { type Order } from "../models"; import { type IdParamContext } from "../utils/validation"; diff --git a/rest/nodejs/src/api/testing.ts b/rest/nodejs/src/api/testing.ts index c08bb065..872308b8 100644 --- a/rest/nodejs/src/api/testing.ts +++ b/rest/nodejs/src/api/testing.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { type IdParamContext } from "../utils/validation"; import { CheckoutService } from "./checkout"; diff --git a/rest/nodejs/src/data/db.ts b/rest/nodejs/src/data/db.ts index e4afb70d..0c4d1099 100644 --- a/rest/nodejs/src/data/db.ts +++ b/rest/nodejs/src/data/db.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import Database from "better-sqlite3"; let productsDb: Database.Database | null = null; diff --git a/rest/nodejs/src/data/index.ts b/rest/nodejs/src/data/index.ts index 4d5e0429..e8288bcf 100644 --- a/rest/nodejs/src/data/index.ts +++ b/rest/nodejs/src/data/index.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + /** * @fileoverview Exports the data access layer for the UCP SDK Node.js server. * This module provides functions to interact with the products, inventory, and diff --git a/rest/nodejs/src/data/inventory.ts b/rest/nodejs/src/data/inventory.ts index 63574fc0..7db5b578 100644 --- a/rest/nodejs/src/data/inventory.ts +++ b/rest/nodejs/src/data/inventory.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { getTransactionsDb } from "./db"; /** diff --git a/rest/nodejs/src/data/products.ts b/rest/nodejs/src/data/products.ts index 88cbe140..c90af212 100644 --- a/rest/nodejs/src/data/products.ts +++ b/rest/nodejs/src/data/products.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { getProductsDb } from "./db"; /** diff --git a/rest/nodejs/src/data/transactions.ts b/rest/nodejs/src/data/transactions.ts index 9a9b55a7..d6e4dff5 100644 --- a/rest/nodejs/src/data/transactions.ts +++ b/rest/nodejs/src/data/transactions.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { type ExtendedCheckoutResponse, type Order } from "../models"; import { getTransactionsDb } from "./db"; diff --git a/rest/nodejs/src/index.ts b/rest/nodejs/src/index.ts index f2bb1456..b04c8926 100644 --- a/rest/nodejs/src/index.ts +++ b/rest/nodejs/src/index.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { serve } from "@hono/node-server"; import { zValidator } from "@hono/zod-validator"; import { type Context, Hono } from "hono"; diff --git a/rest/nodejs/src/models/index.ts b/rest/nodejs/src/models/index.ts index 572faade..cae5e0ef 100644 --- a/rest/nodejs/src/models/index.ts +++ b/rest/nodejs/src/models/index.ts @@ -1 +1,15 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + export * from "@ucp-js/sdk"; diff --git a/rest/nodejs/src/utils/config.ts b/rest/nodejs/src/utils/config.ts index 54e35c42..e52c612c 100644 --- a/rest/nodejs/src/utils/config.ts +++ b/rest/nodejs/src/utils/config.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + export const UCP_VERSION = "2026-04-08"; // RFC 9421 request-signature behaviour, sourced from the environment like diff --git a/rest/nodejs/src/utils/signature.ts b/rest/nodejs/src/utils/signature.ts index cff89829..7aed7cce 100644 --- a/rest/nodejs/src/utils/signature.ts +++ b/rest/nodejs/src/utils/signature.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import crypto, { type KeyObject } from "node:crypto"; import dns from "node:dns/promises"; import net from "node:net"; diff --git a/rest/nodejs/src/utils/validation.ts b/rest/nodejs/src/utils/validation.ts index 7d82a632..143c293c 100644 --- a/rest/nodejs/src/utils/validation.ts +++ b/rest/nodejs/src/utils/validation.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import { type Context, type Env } from "hono"; import * as z from "zod"; diff --git a/rest/nodejs/test/discount.test.ts b/rest/nodejs/test/discount.test.ts index bc975c47..b75b4a65 100644 --- a/rest/nodejs/test/discount.test.ts +++ b/rest/nodejs/test/discount.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { test, before } from "node:test"; diff --git a/rest/nodejs/test/discovery.test.ts b/rest/nodejs/test/discovery.test.ts index bdb23039..4ebeec1a 100644 --- a/rest/nodejs/test/discovery.test.ts +++ b/rest/nodejs/test/discovery.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { test } from "node:test"; diff --git a/rest/nodejs/test/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts index 2065ebe9..3fa0ad1a 100644 --- a/rest/nodejs/test/fulfillment.test.ts +++ b/rest/nodejs/test/fulfillment.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { before, test } from "node:test"; diff --git a/rest/nodejs/test/idempotency.test.ts b/rest/nodejs/test/idempotency.test.ts index 1afb75c6..fc84c110 100644 --- a/rest/nodejs/test/idempotency.test.ts +++ b/rest/nodejs/test/idempotency.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { before, test } from "node:test"; diff --git a/rest/nodejs/test/lifecycle.test.ts b/rest/nodejs/test/lifecycle.test.ts index 7148b4c7..1e143995 100644 --- a/rest/nodejs/test/lifecycle.test.ts +++ b/rest/nodejs/test/lifecycle.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { before, test } from "node:test"; diff --git a/rest/nodejs/test/signature.test.ts b/rest/nodejs/test/signature.test.ts index 1a523d88..a5e10b0f 100644 --- a/rest/nodejs/test/signature.test.ts +++ b/rest/nodejs/test/signature.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import crypto from "node:crypto"; import http from "node:http"; diff --git a/rest/nodejs/test/signing.test.ts b/rest/nodejs/test/signing.test.ts index 13bd6122..33f9be1b 100644 --- a/rest/nodejs/test/signing.test.ts +++ b/rest/nodejs/test/signing.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import crypto from "node:crypto"; import http from "node:http"; diff --git a/rest/nodejs/test/validation.test.ts b/rest/nodejs/test/validation.test.ts index be4cc020..b8766f89 100644 --- a/rest/nodejs/test/validation.test.ts +++ b/rest/nodejs/test/validation.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { test } from "node:test"; diff --git a/rest/nodejs/test/validation_flow.test.ts b/rest/nodejs/test/validation_flow.test.ts index b07fd9f3..f923dc07 100644 --- a/rest/nodejs/test/validation_flow.test.ts +++ b/rest/nodejs/test/validation_flow.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { before, test } from "node:test"; diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts index e5d62a76..b8d71808 100644 --- a/rest/nodejs/test/webhook.test.ts +++ b/rest/nodejs/test/webhook.test.ts @@ -1,3 +1,17 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + import assert from "node:assert/strict"; import { test, before } from "node:test";