Skip to content

feat(auth): [T4] HAIP request signing — x509_hash Client Identifier over a signed JAR (#377 Phases A+B) - #393

Closed
EsTharian wants to merge 4 commits into
mainfrom
claude/haip-request-signing-yvs2k9
Closed

feat(auth): [T4] HAIP request signing — x509_hash Client Identifier over a signed JAR (#377 Phases A+B)#393
EsTharian wants to merge 4 commits into
mainfrom
claude/haip-request-signing-yvs2k9

Conversation

@EsTharian

Copy link
Copy Markdown
Member

Refs #377PR 1 of 2. Deliberately does not close the issue: this is Phase A + Phase B
(request signing). Phase C (encrypted direct_post.jwt response) is a separate PR and nothing
here touches it.

What lands

Phase A — the verifier's signing identity is provisionable. #298 shipped ES256 into
@qauth-labs/core-crypto and nothing outside that library could use it, because no operator
could provision a P-256 key. Six new variables fix that, each following the
JWT_RS256_PRIVATE_KEY / _PATH precedent:

  • OID4VP_VERIFIER_SIGNING_KEY / _PATH — ES256 (EC P-256) PKCS#8 PEM
  • OID4VP_VERIFIER_CERTIFICATE_CHAIN / _PATH — leaf first, anchor excluded
  • OID4VP_VERIFIER_TRUST_ANCHORS / _PATH — what the chain must terminate at

The two chain sources are a precedence (x5c is an ordered sequence, so interleaving two
independently authored chains produces one whose middle link does not issue the one after it);
the two anchor sources are a union (an anchor set is a set). That is the same asymmetry
resolveStatusListTrustAnchorPems already documents, applied per variable.

createVerifierSigningMaterial validates the chain at boot with the existing
resolveAnchoredSigningCertificate — no second DER walk — and additionally proves the key
belongs to the leaf. ProvisionedVerifierMaterial is threaded into createConfiguredProviders,
replacing the "deliberately not passed" comment, and deriveCryptoCapabilities' ES256 entry
becomes a real predicate over both halves (a key with no chain signs something no wallet can
attribute to anyone).

Phase B — signed requests. buildX509HashClientId computes base64url(SHA-256(DER(leaf)))
per OID4VP 1.0 §5.9.3. selectClientIdPrefix widens past the single unsigned prefix — the
comment at the old client_id line predicted the widening would break it, and the branch is
taken there. signOid4vpRequestObject produces an ES256 JAR whose x5c carries leaf +
intermediates with the anchor excluded, and GET /oid4vp/request/:handle serves it as
application/oauth-authz-req+jwt. Under a signed prefix encodeOid4vpRequestUri refuses to
emit the query-parameter form at all, so HAIP §5.1's mandate is unreachable-to-violate rather
than merely unpreferred. assertRequestSigningAllowed — fully implemented and previously
callerless — is routed into the signed path.

The mock wallet gains the other side, written against node:crypto and jose with no import
of the code under test
: it parses the request_uri reference, refuses one that also carries
the unsigned parameters, walks the x5c chain to an anchor it holds out of band, and checks
client_id against the digest of the leaf before verifying the signature.

Also repoints the two stale #233/#234 pointers in configured-providers.ts at #377.

Acceptance criteria — verified

  • A deployment that provisions an ES256 key and a non-self-signed chain reports ES256 in
    VerifierCryptoCapabilities.signingAlgs.
    Unit test on the pure deriveCryptoCapabilities
    (crypto-capabilities.test.ts), plus the two single-half cases that must NOT claim it.
  • client_id carries the x509_hash prefix, delivered as a request_uri reference to a
    signed object whose x5c contains leaf + intermediates and not the anchor, validated by
    the mock wallet against an out-of-band anchor.
    helpers/wallet-login-request.test.ts
    drives the app's own builder end to end and hands the JAR to the mock wallet, with a
    control asserting a wallet holding a different anchor refuses it.
  • The verifier's P-256 key never appears in GET /.well-known/jwks.json and cannot sign a
    QAuth access or ID token.
    verifier-key-isolation.integration.test.ts, on a deployment
    that HAS provisioned one — a suite that only inspected the default JWKS would pass for the
    wrong reason. Also asserts the JWT plugin refuses a P-256 PEM at admission, with an
    Ed25519 control.
  • haip-1.0 still refuses to boot, and the assertion names a specific count rather than
    matching any /haip-1\.0/ message.
    See the caveat below on which count.
  • oid4vp-1.0-base is unchanged. Asserted directly: the same builder call with and
    without provisioned material produces an equal request.

Two things to look at, both deliberate

1. The haip-1.0 refusal names responseEncryption, not keyStorageAssurance. The issue
asked for the refusal to land on the keyStorageAssurance count alone. It cannot, and the reason
is ordering rather than an omission: assertProfileWithinCryptoCapabilities runs before
assertKeyStorageAssuranceProvisioned in createConfiguredProviders, and it also owns the
responseEncryption: 'required' check — which is Phase C and untouched here. So after this PR
haip-1.0 refuses on the response-encryption count, and on key-storage assurance once Phase C
clears that.

Rather than assert something untrue, the suite asserts both halves:

  • wallet-federation-haip.integration.test.ts boots a deployment with the chain provisioned
    and asserts the refusal names the response-encryption count and explicitly not the
    certificate count or the ES256 count — the two this work cleared. Provisioning the chain is
    what stops the test passing for the state that existed before.
  • crypto-capabilities.test.ts hands the gate a descriptor whose responseEncryption is
    hypothetically true — the one member Phase C changes — and asserts the next refusal is
    keyStorageAssurance (feat: [T4] wire #308 key-storage assurance into #237's assurance policy (and gate subject resolution at boot) #379). The ordering is proven now rather than assumed.

2. request-object.ts signs with jose's SignJWT, not core-crypto's sign(). The key
still comes through core-crypto (importPrivateSigningKey), so the crypto-agility seam is
unbroken; only the claim shaping is local. sign() mandates iss, aud and exp because every
token QAuth issues has all three, but a request object's claim set is RFC 9101 §4's — the
authorization request parameters — and aud has no OID4VP-wide value, since a wallet is not an
OpenID Provider with an Issuer Identifier. Emitting a guessed audience produces requests a
conformant wallet rejects for audience mismatch, so aud is omitted by default and settable
by an ecosystem that mandates one. Happy to route it back through sign() with a required
audience if you'd rather.

Test-profile note

The signed path is exercised against a haip-1.0 posture with exactly two members relaxed —
responseModes: ['direct_post'] and responseEncryption: 'permitted' — because the builder still
refuses both, correctly, as Phase C. Every other mandate (clientIdPrefixes: ['x509_hash'],
requestSigning: 'required', signingAlgs: ['ES256']) is the shipped table's own, and a control
test asserts the literal haip-1.0 entry still fails on the response-mode count. When Phase C
lands, the table entry flows through this path unchanged.

Beyond the literal issue text

Three additions the work required, called out so they are reviewed rather than absorbed:

  • OID4VP_VERIFIER_TRUST_ANCHORS / _PATH. resolveAnchoredSigningCertificate needs an
    anchor set, and "the chain is non-self-signed" is only meaningful against something. A chain
    configured with no anchor is a boot failure naming the variable, rather than an opaque
    no-path-to-anchor.
  • OID4VP_REQUEST_OBJECT_RATE_LIMIT / _RATE_WINDOW. The new endpoint is unauthenticated;
    falling back to the global limit is the wrong shape. Its own budget rather than the response
    endpoint's, because the two are opposite halves of the exchange and a slow-wallet retry budget
    must not also be a state-guessing budget.
  • apps/auth-server/src/testing/x509-der.ts. mock-status-list.ts already carried an in-app
    DER encoder, and the E2E needed a second one for the verifier's PKI. The encoder moved into one
    module both build on; the app/lib independence that makes these interoperability tests is
    untouched. Certificate defaults are byte-identical to the old encoder, so the status-list E2E
    exercises exactly what it did before.

Three defects found and fixed on this branch

An adversarial pass over the first two commits found three, all fixed in the fourth commit:

  1. (high, a regression in a shipped path) deliverRequest chose the delivery form from
    capability.signingMaterial !== undefined, while signedness is decided independently by the
    builder from the profile's prefix. A deployment running oid4vp-1.0-base and provisioning a
    verifier identity — supported, since the boot validates that material whatever profile is
    selected — would emit an unsigned request, try to deliver it by request_uri, and be refused.
    Every wallet login on the base profile would break the moment an operator configured a
    chain.
    Fixed by exporting isSignedOid4vpRequest and asking it in both places. The
    regression test fails with the exact refusal a real deployment would hit if the branch is put
    back — verified by reintroducing it.
  2. (medium) openssl ecparam -name prime256v1 -genkey emits SEC1 (BEGIN EC PRIVATE KEY).
    createPrivateKey parses it, so the boot check passed; jose's importPKCS8 — what the
    signing path uses — refuses it. The deployment would start and fail every presentation. Fixed
    by normalising to PKCS#8 at boot and running the leaf match on those exact bytes, so what boot
    proved is a property of the value the signing path imports.
  3. (low, defensive) Key-to-leaf matching compared exported SPKI DER, which is sensitive to the
    EC point conversion form — a CA that encodes the leaf's point compressed (RFC 5480 §2.2 permits
    it) would have its legitimate key refused. Now compared as crv/x/y.

Verification

pnpm exec nx affected -t lint typecheck test build   # 5 projects, all green
pnpm exec vitest run --coverage                      # 218 files, 3961 passed, 8 skipped

Coverage is comfortably above the repo gate (70/55/65/70): 89.3% statements, 82.7% branches.
New modules: client-identifier.ts, request-object.ts, crypto-capabilities.ts,
verifier-identity.ts and routes/oid4vp/request-object.ts at 100% statements;
verifier-signing-material.ts 100% / 94% branches; authorization-request.ts 98% / 98%.

Docker was not available on the runner this was built on, so the container-backed suites did
not run — nx run auth-server:test-integration reports 9 passed | 30 skipped | 2 todo. The
9 that ran are this PR's own: the 8 key-isolation assertions and the haip-1.0 boot refusal,
none of which needs a container. The 30 skipped are the pre-existing #240 wallet-federation E2E
suites (testcontainers Postgres + Redis) — they are unverified here and need a CI run with a
Docker daemon. CI was left unset so requireDockerOrSkip() skipped rather than threw.

Still open on #377

wallet-federation-haip.integration.test.ts still contains two it.todos. Both are Phase C
(decrypting a direct_post.jwt response, and the full HAIP login); the third — signed
x509_hash requests with the anchor excluded from x5c — is gone, covered for real by
helpers/wallet-login-request.test.ts.


Generated by Claude Code

EsTharian and others added 4 commits August 25, 2026 10:31
Phase A of #377. QAuth-as-Verifier can now be given a signing identity: an
ES256 key and the certificate chain a wallet establishes that identity from.
Nothing signs anything yet — that is Phase B — but the boot gate stops
answering `false` on a question it could not previously be asked.

#298 landed ES256 in `@qauth-labs/core-crypto` and nothing outside that library
could use it, because no operator could provision a P-256 key: no env var, no
schema field, no code path. `deriveCryptoCapabilities` said so in a comment and
pinned `ES256: false`, which is what kept `haip-1.0` out on the crypto count.

What lands:

- `OID4VP_VERIFIER_SIGNING_KEY`, `OID4VP_VERIFIER_CERTIFICATE_CHAIN` and
  `OID4VP_VERIFIER_TRUST_ANCHORS`, each with a `_PATH` sibling, following the
  `JWT_RS256_PRIVATE_KEY` precedent. The two chain sources are a PRECEDENCE
  (`x5c` is an ordered sequence) and the two anchor sources are a UNION (an
  anchor set is a set) — the same asymmetry `resolveStatusListTrustAnchorPems`
  already documents, applied per variable rather than uniformly.
- `createVerifierSigningMaterial`, which validates the chain at boot with the
  existing `resolveAnchoredSigningCertificate` rather than a second DER walk,
  and additionally proves the key belongs to the leaf. Every failure it catches
  is an operator mistake whose only runtime symptom is a wallet rejecting 100%
  of requests with nothing naming the cause.
- `ProvisionedVerifierMaterial` threaded into `createConfiguredProviders`,
  replacing the "deliberately not passed" comment. Derived from the material
  that VALIDATED, never assembled by hand, so the marker set cannot claim a
  capability the chain did not earn.
- `deriveCryptoCapabilities`' `ES256` entry as a real predicate over both
  halves — a key with no chain signs something no wallet can attribute to
  anyone, so claiming ES256 on the key alone would lift the gate.

The two key sets stay separate, as #298's risk note requires. The verifier key
is published nowhere, never reaches the JWT plugin, and cannot sign an access or
ID token; `verifier-key-isolation.integration.test.ts` asserts all three against
a deployment that HAS provisioned one — a suite that only inspected the default
JWKS would pass for the wrong reason.

`responseEncryption` is untouched: it flips with Phase C.

Refs #377

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKAC2F5PayPstKTrF2NLp9
…equest_uri

Phase B of #377. QAuth-as-Verifier now signs an Authorization Request and lets a
wallet establish its identity from the certificate that signed it.

HAIP 1.0 §5.1 is a mandate on both halves: *"Signed Authorization Requests MUST
be used by utilizing JWT-Secured Authorization Request (JAR) [RFC9101] with the
`request_uri` parameter."* §5 is a mandate on the identifier: *"the Verifier MUST
use, and the Wallet MUST accept the Client Identifier Prefix `x509_hash`."* So
both are built, and the unsigned query-parameter form becomes UNREACHABLE for a
signed request rather than merely unpreferred.

What lands:

- `buildX509HashClientId` — `base64url(SHA-256(DER(leaf)))` per OID4VP 1.0
  §5.9.3. The leaf, the DER (not the PEM), and unpadded base64url are each a way
  to produce an identifier no wallet can match, so each has its own test.
- `selectClientIdPrefix` widened past the single unsigned prefix. The comment at
  the old `client_id` line predicted the widening would break it; it does, and
  the branch is taken there rather than routed around. `x509_san_dns` stays
  refused — HAIP mandates one signed identity, so #377 builds one.
- `signOid4vpRequestObject` — an ES256 JAR whose `x5c` carries leaf +
  intermediates with the trust anchor excluded. Anchor exclusion is a property of
  `VerifierSigningMaterial` rather than a rule this module remembers to apply.
- `GET /oid4vp/request/:handle`, serving the object as
  `application/oauth-authz-req+jwt`. It consumes nothing (a wallet may retry the
  fetch; single use is enforced at `state` redemption) and writes nothing (an
  anonymous GET must not be a write primitive). Every miss — unknown, expired,
  malformed, store unavailable — is the same bare 404.
- `assertRequestSigningAllowed` routed into the signed path. It was fully
  implemented with no production caller.

The mock wallet gains the other side of the exchange, written against
`node:crypto` and `jose` with no import of the code under test: it parses the
`request_uri` reference, refuses one that also carries the unsigned parameters,
walks the `x5c` chain to a trust anchor it holds OUT OF BAND, and checks
`client_id` against the digest of the leaf before verifying the signature.
`helpers/wallet-login-request.test.ts` drives that round trip through the app's
own builder, and asserts a wallet holding a different anchor refuses it.

`haip-1.0` still refuses to boot. The refusal now names the response-encryption
count specifically — the assertion used to match any `/haip-1\.0/` message, which
would have kept passing for the two counts this work cleared.

Also repoints the stale #233/#234 pointers in `configured-providers.ts` at #377.

Refs #377

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKAC2F5PayPstKTrF2NLp9
The stub-based route tests prove what the handler decides; they cannot prove
what goes on the wire, because a stub never serializes anything. That matters
here for one specific hazard: the Zod serializer compiler is installed globally
in `main.ts`, so a route that declared a `response` schema would emit a
JSON-QUOTED string instead of the compact JWS a wallet parses. This registers
the real route on a real Fastify with the production compilers and asserts the
body is the token verbatim.

It also corrects two claims the stub let stand. Fastify's router rejects a path
parameter past `maxParamLength` (100 by default) with 414 before the handler
runs, so the handler's own bound covers the band between 64 and 100 rather than
everything above 64 — and the 4096-character stub case exercised a path real
Fastify never reaches. The 414 is recorded rather than normalised to 404: it is
a function of the URL the caller sent and tells them nothing they did not
already know, so it is not the state oracle the uniform 404 exists to deny.

Refs #377

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKAC2F5PayPstKTrF2NLp9
… verifier key

Three defects found by review of the two commits above. The first is a
regression in a shipped path; the other two are boot checks that validated
something other than what the request path uses.

**1. Delivery was chosen from provisioned material, not from the request.**
`deliverRequest` branched on `capability.signingMaterial !== undefined`, but
whether a request is SIGNED is decided independently by
`buildOid4vpAuthorizationRequest`, from the profile's Client Identifier Prefix.
The two disagree in a supported configuration: a deployment may run
`oid4vp-1.0-base` — whose preferred prefix is the unsigned `redirect_uri` — AND
provision a verifier identity, since the boot validates that material whatever
profile is selected. The builder would emit an unsigned request, delivery would
try to hand it over by `request_uri`, and `encodeOid4vpRequestUri` would refuse.
Every wallet login on the base profile would break the moment an operator
configured a chain.

Fixed by exporting `isSignedOid4vpRequest` and asking it in both places, so one
rule is written down once and read off the value the wallet will actually act
on. The regression test fails with the exact refusal a real deployment would hit
if the branch is put back.

**2. A SEC1 key passed boot and failed at signing.** `openssl ecparam -name
prime256v1 -genkey` — the way most operators will produce a P-256 key — emits
SEC1 (`BEGIN EC PRIVATE KEY`). `createPrivateKey` parses it, so the boot check
passed; `jose`'s `importPKCS8`, which the signing path uses, refuses it with
`"pkcs8" must be PKCS#8 formatted string`. The deployment would start and then
fail every presentation, which is precisely the outcome this validation exists
to prevent.

Fixed by NORMALISING to PKCS#8 at boot and running the leaf match on those exact
bytes — so what boot proved is a property of the value the signing path imports,
rather than of the value the operator happened to write. A deliberate departure
from refuse-don't-repair, argued in the JSDoc: the two encodings are the same key
with no security difference and no ambiguity of intent.

**3. Key-to-leaf matching was sensitive to EC point encoding.** Comparing
exported SPKI DER refuses a genuinely matching key when the issuing CA encoded
the leaf's public point in compressed form, which RFC 5480 §2.2 permits. Now
compared as `crv`/`x`/`y`, which are the coordinates themselves. Defensive: not
reproduced against a CA that does this, but it costs nothing and removes a way
for a legitimate operator PKI to be refused for an encoding choice.

Refs #377

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKAC2F5PayPstKTrF2NLp9

Copy link
Copy Markdown
Member Author

Closing: this was opened from a wrongly-named branch. Superseded by #394, which carries the identical four commits (same head, 8e90473) on feat/377-haip-request-signing.

Nothing was reviewed here, so no discussion is lost. GitHub does not allow a pull request's head branch to be repointed, hence the replacement rather than an edit.


Generated by Claude Code

@EsTharian EsTharian closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant