Skip to content

Node reference verifier cannot discover the signing keys of a schema correct peer at its own declared version #225

Description

@vishkaty

What I ran into

The Node sample server declares UCP_VERSION 2026-04-08 (config.ts:15). At that pin, source/discovery/profile_schema.json $defs/base requires ucp and separately declares signing_keys as a top level sibling of ucp. That schema defines no keys field anywhere, nested or otherwise.

The server publishes signing_keys[] correctly at the top level, but also mirrors the same key into a nested ucp.keys[] that has no basis in the schema at any pin. Its own verifier, extractKeys() in signature.ts, reads only that nested field:

const ucp = "ucp" in doc ? doc["ucp"] : doc;
const value = (ucp as Record<string, unknown>)["keys"];

Every real profile document carries ucp (the schema requires it), so this reader always takes the "ucp" in doc branch and looks inside ucp for keys. It can never see a top level sibling field on any real document, whether that field is named signing_keys (the pin this server itself declares) or keys (the name ucp#566 gives it starting 2026-08-25). Concretely: a peer that publishes only the schema correct top level signing_keys[], with nothing extra, fails key discovery against this very server. The reference cannot verify a conformant counterpart at its own declared version.

Observed against main (00333a8)

Fresh clone, documented setup (npm ci && npm run build && npm test in rest/nodejs, mirroring .github/workflows/nodejs.yml). A direct call against the exported extractKeys confirms the read side:

extractKeys({ ucp: { version: "2026-04-08" }, signing_keys: [{ kid: "k" }] })
// => [] -- the schema correct shape returns nothing

extractKeys({ ucp: { keys: [{ kid: "k" }] } })
// => [{ kid: "k" }] -- only the nonstandard nested shape is read

The booted server confirms the write side matches (curl /.well-known/ucp): signing_keys[] at the top level, keys[] mirrored under ucp, both carrying the same webhook JWK.

Expected

signing_keys[] (the 2026-04-08 pin this server declares) published at the top level, and read from the top level. No field the schema does not define should be published, and the verifier should trust the field the server itself declares as canonical.

Where it comes from

This traces to two of our own merged PRs. #162 (request signature verification) introduced extractKeys reading only a nested keys[], justified in its comment as pre-adopting the rename: "keys[] is the canonical RFC 7517 JWK Set field per ucp#566, which removed the earlier signing_keys[]". ucp#566 does rename the field, but only for 2026-08-25 and later, and it places keys as a top level sibling of ucp, never nested inside it, so the pre-adoption got both the timing (this server never moved its declared version) and the location (nested rather than top level) wrong at once. #179 (Node webhook signing) then added the top level signing_keys[] publication and, to satisfy the #162 reader, the nested ucp.keys[] mirror. The Python reference reached the identical defect by the same two steps: #122 introduced _extract_keys reading only ucp.get("keys") with a near identical comment, and #169 added the matching publication plus mirror. This issue and its Node PR are scoped to the Node server only; the Python twin needs a companion fix.

Why CI did not catch it

Every test that exercises key discovery constructs its own profile document rather than reading the real served output through both the write and read paths at once. test/signing.test.ts and test/signature.test.ts built fixtures shaped { ucp: { keys: [...] } }, matching the actual (wrong) expectation of extractKeys rather than the schema. webhook_signing.test.ts asserted both signing_keys[] and ucp.keys[] were present without checking that the reader on this same server could resolve the schema correct one alone. No test ever fed the served top level signing_keys[] output back into extractKeys with nothing else present.

Two more discovery profile issues, same file, same PR

Fixing the same discovery.ts response surfaced two more corrections small enough to ship alongside the keys fix rather than as separate PRs.

extends is typed too narrowly. capability.json $defs/base declares extends as oneOf [reverse_domain_name, array<reverse_domain_name> (minItems 1)] at both 2026-04-08 and 2026-08-25 ("Use array for multi-parent extensions"), but the DiscoveryCapability type in discovery.ts declares extends?: string. Every entry that carries extends today has a single parent, so this has not broken anything yet, but the type would reject a real multi-parent extension the schema explicitly allows.

Three declared capabilities do not exist anywhere in the spec. dev.ucp.shopping.refund, .return, and .dispute are declared in the discovery.ts capability catalog with schema URLs (.../schemas/shopping/refund.json etc.), but no such file exists under source/schemas/shopping/ at 2026-04-08 or 2026-08-25 (checked both trees: the full list at either pin is buyer_consent, cart, catalog_lookup, catalog_search, checkout, discount, fulfillment, order, plus payment and ap2_mandate at 04-08 / permalink at 08-25; no refund, return, or dispute file at either). No route in src/api/ implements them. The Python reference discovery_profile.json (both upstream, unmodified, and our own 2026-08-25 golden reference server) never declared them either. A capability catalog entry with no schema behind it and no implementation is not a real capability; it should be removed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions