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
Closed
feat(auth): [T4] HAIP request signing — x509_hash Client Identifier over a signed JAR (#377 Phases A+B)#393EsTharian wants to merge 4 commits into
EsTharian wants to merge 4 commits into
Conversation
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
5 tasks
Member
Author
|
Closing: this was opened from a wrongly-named branch. Superseded by #394, which carries the identical four commits (same head, 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #377 — PR 1 of 2. Deliberately does not close the issue: this is Phase A + Phase B
(request signing). Phase C (encrypted
direct_post.jwtresponse) is a separate PR and nothinghere touches it.
What lands
Phase A — the verifier's signing identity is provisionable. #298 shipped ES256 into
@qauth-labs/core-cryptoand nothing outside that library could use it, because no operatorcould provision a P-256 key. Six new variables fix that, each following the
JWT_RS256_PRIVATE_KEY/_PATHprecedent:OID4VP_VERIFIER_SIGNING_KEY/_PATH— ES256 (EC P-256) PKCS#8 PEMOID4VP_VERIFIER_CERTIFICATE_CHAIN/_PATH— leaf first, anchor excludedOID4VP_VERIFIER_TRUST_ANCHORS/_PATH— what the chain must terminate atThe two chain sources are a precedence (
x5cis an ordered sequence, so interleaving twoindependently 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
resolveStatusListTrustAnchorPemsalready documents, applied per variable.createVerifierSigningMaterialvalidates the chain at boot with the existingresolveAnchoredSigningCertificate— no second DER walk — and additionally proves the keybelongs to the leaf.
ProvisionedVerifierMaterialis threaded intocreateConfiguredProviders,replacing the "deliberately not passed" comment, and
deriveCryptoCapabilities'ES256entrybecomes a real predicate over both halves (a key with no chain signs something no wallet can
attribute to anyone).
Phase B — signed requests.
buildX509HashClientIdcomputesbase64url(SHA-256(DER(leaf)))per OID4VP 1.0 §5.9.3.
selectClientIdPrefixwidens past the single unsigned prefix — thecomment at the old
client_idline predicted the widening would break it, and the branch istaken there.
signOid4vpRequestObjectproduces an ES256 JAR whosex5ccarries leaf +intermediates with the anchor excluded, and
GET /oid4vp/request/:handleserves it asapplication/oauth-authz-req+jwt. Under a signed prefixencodeOid4vpRequestUrirefuses toemit the query-parameter form at all, so HAIP §5.1's mandate is unreachable-to-violate rather
than merely unpreferred.
assertRequestSigningAllowed— fully implemented and previouslycallerless — is routed into the signed path.
The mock wallet gains the other side, written against
node:cryptoandjosewith no importof the code under test: it parses the
request_urireference, refuses one that also carriesthe unsigned parameters, walks the
x5cchain to an anchor it holds out of band, and checksclient_idagainst the digest of the leaf before verifying the signature.Also repoints the two stale
#233/#234pointers inconfigured-providers.tsat #377.Acceptance criteria — verified
ES256inVerifierCryptoCapabilities.signingAlgs. Unit test on the purederiveCryptoCapabilities(
crypto-capabilities.test.ts), plus the two single-half cases that must NOT claim it.client_idcarries thex509_hashprefix, delivered as arequest_urireference to asigned object whose
x5ccontains leaf + intermediates and not the anchor, validated bythe mock wallet against an out-of-band anchor.
helpers/wallet-login-request.test.tsdrives 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.
GET /.well-known/jwks.jsonand cannot sign aQAuth access or ID token.
verifier-key-isolation.integration.test.ts, on a deploymentthat 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.0still refuses to boot, and the assertion names a specific count rather thanmatching any
/haip-1\.0/message. See the caveat below on which count.oid4vp-1.0-baseis unchanged. Asserted directly: the same builder call with andwithout provisioned material produces an equal request.
Two things to look at, both deliberate
1. The
haip-1.0refusal namesresponseEncryption, notkeyStorageAssurance. The issueasked for the refusal to land on the
keyStorageAssurancecount alone. It cannot, and the reasonis ordering rather than an omission:
assertProfileWithinCryptoCapabilitiesruns beforeassertKeyStorageAssuranceProvisionedincreateConfiguredProviders, and it also owns theresponseEncryption: 'required'check — which is Phase C and untouched here. So after this PRhaip-1.0refuses on the response-encryption count, and on key-storage assurance once Phase Cclears that.
Rather than assert something untrue, the suite asserts both halves:
wallet-federation-haip.integration.test.tsboots a deployment with the chain provisionedand 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.tshands the gate a descriptor whoseresponseEncryptionishypothetically
true— the one member Phase C changes — and asserts the next refusal iskeyStorageAssurance(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.tssigns withjose'sSignJWT, notcore-crypto'ssign(). The keystill comes through
core-crypto(importPrivateSigningKey), so the crypto-agility seam isunbroken; only the claim shaping is local.
sign()mandatesiss,audandexpbecause everytoken QAuth issues has all three, but a request object's claim set is RFC 9101 §4's — the
authorization request parameters — and
audhas no OID4VP-wide value, since a wallet is not anOpenID Provider with an Issuer Identifier. Emitting a guessed audience produces requests a
conformant wallet rejects for audience mismatch, so
audis omitted by default and settableby an ecosystem that mandates one. Happy to route it back through
sign()with a requiredaudience if you'd rather.
Test-profile note
The signed path is exercised against a
haip-1.0posture with exactly two members relaxed —responseModes: ['direct_post']andresponseEncryption: 'permitted'— because the builder stillrefuses both, correctly, as Phase C. Every other mandate (
clientIdPrefixes: ['x509_hash'],requestSigning: 'required',signingAlgs: ['ES256']) is the shipped table's own, and a controltest asserts the literal
haip-1.0entry still fails on the response-mode count. When Phase Clands, 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.resolveAnchoredSigningCertificateneeds ananchor 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.tsalready carried an in-appDER 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:
deliverRequestchose the delivery form fromcapability.signingMaterial !== undefined, while signedness is decided independently by thebuilder from the profile's prefix. A deployment running
oid4vp-1.0-baseand provisioning averifier 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
isSignedOid4vpRequestand asking it in both places. Theregression test fails with the exact refusal a real deployment would hit if the branch is put
back — verified by reintroducing it.
openssl ecparam -name prime256v1 -genkeyemits SEC1 (BEGIN EC PRIVATE KEY).createPrivateKeyparses it, so the boot check passed;jose'simportPKCS8— what thesigning 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.
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
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.tsandroutes/oid4vp/request-object.tsat 100% statements;verifier-signing-material.ts100% / 94% branches;authorization-request.ts98% / 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-integrationreports9 passed | 30 skipped | 2 todo. The9 that ran are this PR's own: the 8 key-isolation assertions and the
haip-1.0boot 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.
CIwas left unset sorequireDockerOrSkip()skipped rather than threw.Still open on #377
wallet-federation-haip.integration.test.tsstill contains twoit.todos. Both are Phase C(decrypting a
direct_post.jwtresponse, and the full HAIP login); the third — signedx509_hashrequests with the anchor excluded fromx5c— is gone, covered for real byhelpers/wallet-login-request.test.ts.Generated by Claude Code