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/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 8b9c8bcf..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"; @@ -15,6 +29,7 @@ import { CheckoutCompleteRequestSchema, OrderSchema, } from "./models"; +import { verifySignature } from "./utils/signature"; import { IdParamSchema, prettyValidation } from "./utils/validation"; const app = new Hono(); @@ -86,33 +101,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 +145,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 +160,7 @@ app.put( /* Testing endpoints */ app.post( "/testing/simulate-shipping/:id", + verifySignature, zValidator("param", IdParamSchema, prettyValidation), testingService.shipOrder ); 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 65444aca..e52c612c 100644 --- a/rest/nodejs/src/utils/config.ts +++ b/rest/nodejs/src/utils/config.ts @@ -1 +1,25 @@ +// 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 +// 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..7aed7cce --- /dev/null +++ b/rest/nodejs/src/utils/signature.ts @@ -0,0 +1,789 @@ +// 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"; +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/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 new file mode 100644 index 00000000..a5e10b0f --- /dev/null +++ b/rest/nodejs/test/signature.test.ts @@ -0,0 +1,524 @@ +// 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"; +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..33f9be1b --- /dev/null +++ b/rest/nodejs/test/signing.test.ts @@ -0,0 +1,931 @@ +// 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"; +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" }] } }), + [] + ); +}); 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";