Skip to content

feat: [T4] HAIP profile wiring — signed x509_hash requests and encrypted direct_post.jwt responses #377

Description

@EsTharian

Summary

#298 is closed, and it shipped the HAIP crypto PRIMITIVES ONLY. ES256 signing,
ECDH-ES/A*GCM JWE and per-request ephemeral encryption keys all exist in
@qauth-labs/core-crypto and are tested there — and nothing outside that library calls
any of them
. The OID4VP-level wiring was never built: there is no signed Authorization
Request, no x509_hash Client Identifier, no published encryption key in
client_metadata, and no direct_post.jwt intake to decrypt at.

This issue owns that wiring, which is the whole of what stands between haip-1.0 and a
profile a deployment can actually select. It is the second half of what #308 called
"ships second, after #298".

Investigated on test/240-wallet-e2e (PR #375) @ 84dab703 — the integrated T4 state.
None of the code cited below exists on main.

What #298 shipped, and who calls it

Primitive Symbol Location
ES256 in the JWS union JwsAlgorithm = 'EdDSA' | 'RS256' | 'ES256' libs/core/crypto/src/lib/algorithms.ts:61
ES256 keygen + JWK import with crv pinned generateSigningKeyPair, importPublicSigningJwk libs/core/crypto/src/lib/key-management.ts:124, :167
ES256 sign / verify sign(claims, key, 'ES256', …), verify libs/core/crypto/src/lib/signing.ts:152, :258 (+ signing.es256.test.ts)
JWE algorithm allowlists JWE_KEY_AGREEMENT_ALGORITHMS = ['ECDH-ES'], JWE_CONTENT_ENCRYPTION_ALGORITHMS = ['A128GCM','A256GCM'] libs/core/crypto/src/lib/algorithms.ts:86, :100
JWE encrypt / decrypt encryptJwe, decryptJwe libs/core/crypto/src/lib/jwe.ts:172, :232
Per-request ephemeral keys generateEphemeralEncryptionKeyPair, exportEncryptionPublicJwk, exportEncryptionPrivateJwk, importEncryptionPublicJwk, importEncryptionPrivateJwk, isEphemeralEncryptionKeyPairExpired, EPHEMERAL_ENCRYPTION_KEY_MAX_AGE_SECONDS libs/core/crypto/src/lib/encryption-keys.ts:95, :164, :188, :253, :281, :140, :126

Callers outside libs/core/crypto: none. grep -rn 'encryptJwe\|decryptJwe' across
libs/ + apps/ returns exactly two non-crypto hits —
apps/auth-server/src/app/crypto-capabilities.ts:86-87 and its test at
crypto-capabilities.test.ts:64 — and both are comments stating that nothing calls
them
. The ephemeral-key helpers have zero importers outside their own module and test.
#235's report is confirmed, not stale.

The deployment says so itself, in code rather than prose
(apps/auth-server/src/app/crypto-capabilities.ts:79, :95):

    // FALSE, and not because the crypto layer cannot compute an ES256 signature
    // — #298 landed `sign(..., 'ES256', ...)` and it works. There is no way for
    // an operator to provision a P-256 signing key: no env var, no schema field,
    // no JWKS entry, and no federation code path that would sign an OID4VP
    // Authorization Request with one.
    ES256: false,
  } satisfies Record<JwsAlgorithm, boolean>;
  
    responseEncryption: false,

The gap, precisely

A. Verifier key + certificate provisioning — does not exist at all

  • ProvisionedVerifierMaterial is a set of marker strings, and NO_VERIFIER_MATERIAL is
    the only value ever constructed — libs/server/federation/src/profiles/verifier-identity.ts:46-54.
  • apps/auth-server/src/app/app.ts:128-129 deliberately does not pass it:
    "provisionedVerifierMaterial is deliberately not passed: no certificate configuration
    surface exists until feat: [T4] OID4VP 1.0 authorization request generation + direct_post response intake (ADR-004) #233"
    . keyStorageAssuranceProvisioned (feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308) is likewise unpassed.
  • No env var exists for a verifier ES256 key or an X.509 chain. The federation schema
    (libs/server/config/src/lib/schemas/federation.ts) ships exactly
    WALLET_FEDERATION_ENABLED, OID4VP_VERIFIER_PROFILE, OID4VP_REQUESTED_VCT,
    OID4VP_SUBJECT_RESOLUTION, OID4VP_SUBJECT_BINDING_CLAIMS, OID4VP_SUBJECT_CLAIM,
    OID4VP_SUBJECT_CLAIM_ISSUERS, OID4VP_WALLET_INVOCATION_ENDPOINT. No X.509 trust-anchor
    variable exists anywhere either — createX509TrustAnchors
    (libs/server/federation/src/x509/anchored-chain.ts:150) has two callers
    (status/status-list-chain.ts:88, attestation/key-attestation.ts:149) and neither is fed
    from config.

B. Signed Authorization Request — not built

  • libs/server/federation/src/oid4vp/client-identifier.ts renders one prefix.
    UNSIGNED_CLIENT_ID_PREFIX = 'redirect_uri' (:36); the only builders are
    buildClientId (:44) and buildRedirectUriClientId (:62). Nothing computes an
    x509_hash value
    — no certificate digest anywhere in the workspace.
  • selectClientIdPrefix (oid4vp/authorization-request.ts:216) returns
    typeof UNSIGNED_CLIENT_ID_PREFIX and throws for everything else (:239-243). The
    builder hardcodes assertRequestSigningPosture(profile, { signed: false }) at :295.
  • There is no request object at all: no JWT, no x5c header assembly, no request /
    request_uri delivery. encodeOid4vpRequestUri (:353) emits query parameters only, and
    its JSDoc says so (:347-348).
  • x509_hash appears in the codebase only in the profile table, its types and tests
    (profiles/verifier-profiles.ts:103, :131; verifier-profile.types.ts:60, :125) —
    never in protocol code.

C. Encrypted direct_post.jwt response — not built

  • Oid4vpAuthorizationRequest.response_mode is literal-typed to direct_post
    (oid4vp/authorization-request.ts:87). The builder refuses a profile whose
    responseModes excludes direct_post (:274-278) and refuses
    responseEncryption: 'required' outright (:282-286).
  • VerifierClientMetadata (:66-71) carries vp_formats_supported and client_name and
    nothing else — no jwks with the per-request use: 'enc' key, no
    encrypted_response_enc_values_supported
    , both of which HAIP §5 requires a Verifier to
    publish.
  • The intake schema oid4vpDirectPostRequestSchema
    (apps/auth-server/src/app/schemas/oid4vp.ts:50-62) accepts vp_token, state, error,
    error_description. There is no response parameter — the single JWE-carrying field of
    direct_post.jwt. Unknown fields are stripped (Zod default), so a wallet posting response
    today would be answered by the non-enumerating rejection with the body silently discarded.
  • parseVpToken (oid4vp/direct-post.ts:168) reads the cleartext form field.
  • oid4vp_request_states (libs/infra/db/src/lib/schema/oid4vp.ts:45-111) stores
    response_mode as text (:80) but has no column for the ephemeral private key or its
    kid
    , so no instance other than the one that built the request could decrypt.

D. The boot gate, and what this issue clears

haip-1.0 is refused on four independent counts (enumerated at
apps/auth-server/src/app/wallet-federation-haip.integration.test.ts:17-27):

  1. no WRPAC / X.509 material → assertVerifierIdentityProvisioned
    (profiles/verifier-identity.ts:117) — cleared by A;
  2. signingAlgs: ['ES256'] unmet → assertProfileWithinCryptoCapabilities
    (libs/fastify/plugins/federation/src/lib/configured-providers.ts:189-195) —
    cleared by A;
  3. responseEncryption: 'required' unmet → same guard (:197-201) — cleared by C;
  4. keyStorageAssurance: 'required'assertKeyStorageAssuranceProvisioned
    NOT cleared here; that is feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308, which is still open.

Both refusal messages in configured-providers.ts already name the landing point:
"ES256 signing exists in the crypto layer (#298) but no key for it is provisionable until
#233"
and "The JWE primitives exist in the crypto layer (#298) but the encrypted-response
path lands with #233/#234."
#233 and #234 are closed and shipped only the base-profile
path — so those pointers are now dangling and should be repointed at this issue.

Blocked vs. merely unbuilt

Merely unbuilt — no external dependency, start today: every item in A, B and C. The
primitives exist (#298), the anchored-chain path validator exists
(x509/anchored-chain.ts:478 resolveAnchoredSigningCertificate, already shared by #297 and
#308), the profile table already declares the whole HAIP posture as data
(profiles/verifier-profiles.ts:101-134), and a P-256 test-certificate factory already
exists (libs/server/federation/src/status/test/x509-fixtures.ts:244).

Genuinely blocked:

Everything else is unbuilt, not blocked.

Decisions for the implementer

Important

Decision — split this issue in two? The two halves share only the profile gate and the
boot-capability flip; A+B (request signing) and C (response encryption) touch disjoint
files, and the #298 risk note asks for ES256 and JWE to "land as separate reviewed commits
from any wallet-federation logic."

Recommendation: split into HAIP request signing (A + B) and HAIP response encryption
(C), with A shared and landing in the first.
The task list below is already grouped so the
cut is clean. Filed as one issue so the gap is recorded in one place; split before starting
if the owner agrees.

Note

Decision — where the verifier's ES256 key and chain come from. Options: (a) env PEM
with a _PATH variant, matching the JWT_RS256_PRIVATE_KEY / JWT_MLDSA_PRIVATE_KEY
precedent already in libs/server/config/src/lib/schemas/jwt.ts; (b) a per-realm DB-backed
store, matching the planned realms.verifier_profile.
Recommendation: (a) env-first. deriveCryptoCapabilities is deliberately a pure
function of provisioned env keys (crypto-capabilities.ts:39-45) and the boot gate needs
its answer before any realm row is read. Per-realm material can be layered later at the same
seam that will carry per-realm profiles.

Note

Decision — lifetime of the ephemeral encryption private key. Options: (a) in-memory map
keyed by request id, keeping extractable: false so the key never leaves the runtime; (b)
persisted on the oid4vp_request_states row, requiring extractable: true and encryption
at rest.
Recommendation: (b), with (a) as a documented single-instance option. The
direct_post.jwt response arrives on a different HTTP request than the one that built the
key, the app already ships a DB-backed request-state store, and core-crypto anticipates
exactly this trade-off in GenerateEphemeralEncryptionKeyPairOptions.extractable
(encryption-keys.ts:73-84). (b) needs a migration; (a) does not.

Note

Decision — JAR delivery: inline request parameter vs. request_uri reference. The
codebase has already assumed request_uri
(apps/auth-server/src/app/helpers/wallet-login-flow.ts:140-141: "under HAIP it is a
request_uri reference to a signed JAR (#298)"
), but nothing implements it and the
assumption is unverified against the HAIP Final text.
Recommendation: request_uri, because a signed JAR carrying dcql_query,
client_metadata and an x5c chain will not fit a QR code. Confirm against HAIP §5.1 /
OID4VP §5.10 before building, and add a GET request-object endpoint if confirmed.

Tasks

Phase A — verifier key and certificate provisioning (shared)

  • Env surface for the verifier's ES256 signing key (PEM + _PATH variant, mirroring
    JWT_RS256_PRIVATE_KEY / _PATH) and its X.509 chain (leaf + intermediates, anchor
    excluded). Validate the chain at boot with the existing
    resolveAnchoredSigningCertificate machinery rather than a new DER walk.
  • Populate ProvisionedVerifierMaterial.available from that config and thread it into
    createConfiguredProviders({ provisionedVerifierMaterial }) at
    apps/auth-server/src/app/app.ts:128, replacing the "deliberately not passed" comment.
  • Flip deriveCryptoCapabilities' ES256 entry
    (apps/auth-server/src/app/crypto-capabilities.ts:79) from the literal false to a real
    predicate over the new config, and update the JSDoc that currently explains why it is
    false.
  • Keep the verifier key off the token-issuance path. The verifier's P-256 key must
    never reach fastify.jwtUtils.getJwks() (libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts:328,
    published at GET /.well-known/jwks.json) and must not be usable to sign a QAuth access
    or ID token. feat: [T4] Crypto prerequisites for HAIP — ES256 + JWE (ECDH-ES/A*GCM) #298's risk note: "the two key sets must not be interchangeable." Prove it
    with a test.

Phase B — signed Authorization Request with x509_hash

  • Compute the x509_hash Client Identifier value from the provisioned leaf certificate and
    add buildX509HashClientId alongside buildRedirectUriClientId in
    oid4vp/client-identifier.ts. Verify the exact digest and encoding against OID4VP 1.0
    §5.9 Final before implementing
    — nothing in this repo computes it today, so there is no
    prior art to copy.
  • Widen selectClientIdPrefix (oid4vp/authorization-request.ts:216) beyond
    typeof UNSIGNED_CLIENT_ID_PREFIX. Its :315-317 comment says the widening is meant to
    break the client_id line — take that break and branch there rather than around it.
  • Build the signed request object: ES256 JWT over the request parameters, x5c header
    carrying leaf + intermediates with the trust anchor excluded, and
    assertRequestSigningPosture(profile, { signed: true }) instead of the hardcoded
    { signed: false } at :295.
  • Deliver it per the JAR decision above (request_uri recommended), extending
    encodeOid4vpRequestUri or adding a request-object endpoint. Under haip-1.0 the
    unsigned query-parameter form must become unreachable, not merely unpreferred.
  • Route assertRequestSigningAllowed (profiles/verifier-identity.ts:231) into the signed
    path — it is fully implemented and currently has no production caller.

Phase C — encrypted direct_post.jwt response

  • Generate one ephemeral ECDH-ES P-256 pair per Authorization Request via
    generateEphemeralEncryptionKeyPair and publish exportEncryptionPublicJwk in
    client_metadata, alongside encrypted_response_enc_values_supported listing both
    A128GCM and A256GCM (HAIP §5). Extend VerifierClientMetadata
    (oid4vp/authorization-request.ts:66-71).
  • Persist the pair's kid (and, per the decision above, the private half) with the request
    state — migration on oid4vp_request_states
    (libs/infra/db/src/lib/schema/oid4vp.ts:45-111), deleted at redemption or expiry, and
    aged with isEphemeralEncryptionKeyPairExpired.
  • Widen Oid4vpAuthorizationRequest.response_mode (:87) past the direct_post literal
    and replace the two blanket refusals at :274-278 / :282-286 with the profile-driven
    branch.
  • Accept the response parameter in oid4vpDirectPostRequestSchema
    (apps/auth-server/src/app/schemas/oid4vp.ts:50-62), bounded like vp_token, and decrypt
    it with decryptJwe pinned to ['ECDH-ES'] / ['A128GCM','A256GCM'] before
    parseVpToken sees anything.
  • Order the intake so decryption happens after single-use state redemption
    (apps/auth-server/src/app/routes/oid4vp/response.ts:119), for the reason that route
    already documents: "a replayed or expired state must be rejected before any work is done
    on attacker-supplied bytes."
    Note state is inside the JWE under direct_post.jwt, so
    the correlation input has to be recovered without trusting the ciphertext — resolve this
    explicitly rather than by accident.
  • Every decryption failure must produce the existing non-enumerating
    OID4VP_REJECTION_DESCRIPTION (oid4vp/direct-post.ts:60) with the reason logged
    server-side only. CryptoDecryptionError already returns one uniform message; do not
    leak .detail.
  • Flip deriveCryptoCapabilities' responseEncryption
    (crypto-capabilities.ts:95) to a real predicate.
  • Under haip-1.0, unencrypted direct_post must be rejected, not tolerated — HAIP §5
    allows no cleartext fallback.

Cross-cutting

  • Repoint the two stale #233/#234 pointers in
    libs/fastify/plugins/federation/src/lib/configured-providers.ts:193, :199 (both issues
    are closed) at this issue.
  • Replace the three it.todos in
    apps/auth-server/src/app/wallet-federation-haip.integration.test.ts:80-86 with real
    tests, and update the "What to write when feat: [T4] Crypto prerequisites for HAIP — ES256 + JWE (ECDH-ES/A*GCM) #298 lands" note (:37-43). The mock wallet
    (apps/auth-server/src/testing/mock-wallet.ts) gains (a) signed-request + x5c
    verification against an anchor the header does not carry, and (b) response encryption to
    the client_metadata key. Note the boundary: apps/auth-server is scope:app and
    cannot import scope:server libs (eslint.config.mjs:105-113), so the existing
    certificate factory at libs/server/federation/src/status/test/x509-fixtures.ts is
    unreachable from the E2E — either build a small node:crypto one under
    apps/auth-server/src/testing/ (as mock-wallet.ts was built from core-crypto + jose)
    or promote the fixture into a scope:core lib.
  • Keep the boot-refusal test in that suite green while feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308 is open: haip-1.0 must still
    refuse, now on the key-storage count alone, and the assertion should say so rather than
    matching any /haip-1\.0/ message.

Acceptance Criteria

  • A deployment that provisions an ES256 key and a non-self-signed chain reports
    ES256 in VerifierCryptoCapabilities.signingAlgs and responseEncryption: true, and
    assertProfileWithinCryptoCapabilities no longer refuses haip-1.0. Verified by unit
    test on deriveCryptoCapabilities, which is a pure function.
  • haip-1.0 still refuses to boot — on the keyStorageAssurance count only (feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308).
    A test asserts the refusal names that count and not the crypto or certificate ones.
  • Given a provisioned chain, buildOid4vpAuthorizationRequest under haip-1.0 produces a
    request whose client_id carries the x509_hash prefix, delivered as a signed request
    object whose x5c contains leaf + intermediates and not the anchor. An independent
    verifier (the mock wallet, built without importing the code under test) validates the
    signature and the chain against an anchor it holds out of band.
  • The same request's client_metadata carries an ECDH-ES P-256 public JWK with
    use: 'enc' and a kid, plus encrypted_response_enc_values_supported listing both
    A128GCM and A256GCM. A second request carries a different kid and key.
  • A direct_post.jwt submission encrypted by the mock wallet to that key decrypts, parses
    against the DCQL query, and flows into the existing feat: [T4] OID4VP Verifiable Presentation validation — SD-JWT VC (ADR-004) #234 validation seam unchanged.
  • Negative cases, each with its own test, all producing the identical non-enumerating 400:
    response encrypted to another request's key; response encrypted with an alg/enc outside
    the pins; a zip header; a response for an expired ephemeral key; an unencrypted
    direct_post under haip-1.0.
  • Under oid4vp-1.0-base, behaviour is bit-for-bit unchanged: the unsigned
    redirect_uri path, cleartext direct_post, and the existing test: [T4] E2E integration tests — reference/mock wallet (ADR-004) #240 base E2E suite all
    pass untouched.
  • The verifier's ES256 key does not appear in GET /.well-known/jwks.json and cannot sign
    a QAuth access or ID token, proven by test.
  • apps/auth-server/src/app/wallet-federation-haip.integration.test.ts contains no
    it.todo.

References

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Backlog

Relationships

None yet

Development

No branches or pull requests

Issue actions