You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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.
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,}satisfiesRecord<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.
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):
no WRPAC / X.509 material → assertVerifierIdentityProvisioned
(profiles/verifier-identity.ts:117) — cleared by A;
signingAlgs: ['ES256'] unmet → assertProfileWithinCryptoCapabilities
(libs/fastify/plugins/federation/src/lib/configured-providers.ts:189-195) — cleared by A;
responseEncryption: 'required' unmet → same guard (:197-201) — cleared by C;
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:478resolveAnchoredSigningCertificate, 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).
Interop with a real EUDI wallet is blocked on an operator obtaining a QTSP-issued WRPAC — an operator obligation under CIR (EU) 2025/848 (applies 2026-12-24), confirmed
in decision: OID4VP profile sequencing, client-ID prefixes & subject identity (blocks epic #231) #296 Q4 as "documentation + configuration scope only". QAuth's code path is fully
testable against an operator-supplied test PKI; only the real-wallet pass waits.
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 assumedrequest_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 decryptJwepinned 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.
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.
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 differentkid and key.
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.
Summary
#298 is closed, and it shipped the HAIP crypto PRIMITIVES ONLY. ES256 signing,
ECDH-ES/A*GCMJWE and per-request ephemeral encryption keys all exist in@qauth-labs/core-cryptoand are tested there — and nothing outside that library callsany of them. The OID4VP-level wiring was never built: there is no signed Authorization
Request, no
x509_hashClient Identifier, no published encryption key inclient_metadata, and nodirect_post.jwtintake to decrypt at.This issue owns that wiring, which is the whole of what stands between
haip-1.0and aprofile a deployment can actually select. It is the second half of what #308 called
"ships second, after #298".
What #298 shipped, and who calls it
JwsAlgorithm = 'EdDSA' | 'RS256' | 'ES256'libs/core/crypto/src/lib/algorithms.ts:61crvpinnedgenerateSigningKeyPair,importPublicSigningJwklibs/core/crypto/src/lib/key-management.ts:124,:167sign(claims, key, 'ES256', …),verifylibs/core/crypto/src/lib/signing.ts:152,:258(+signing.es256.test.ts)JWE_KEY_AGREEMENT_ALGORITHMS = ['ECDH-ES'],JWE_CONTENT_ENCRYPTION_ALGORITHMS = ['A128GCM','A256GCM']libs/core/crypto/src/lib/algorithms.ts:86,:100encryptJwe,decryptJwelibs/core/crypto/src/lib/jwe.ts:172,:232generateEphemeralEncryptionKeyPair,exportEncryptionPublicJwk,exportEncryptionPrivateJwk,importEncryptionPublicJwk,importEncryptionPrivateJwk,isEphemeralEncryptionKeyPairExpired,EPHEMERAL_ENCRYPTION_KEY_MAX_AGE_SECONDSlibs/core/crypto/src/lib/encryption-keys.ts:95,:164,:188,:253,:281,:140,:126Callers outside
libs/core/crypto: none.grep -rn 'encryptJwe\|decryptJwe'acrosslibs/+apps/returns exactly two non-crypto hits —apps/auth-server/src/app/crypto-capabilities.ts:86-87and its test atcrypto-capabilities.test.ts:64— and both are comments stating that nothing callsthem. 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):The gap, precisely
A. Verifier key + certificate provisioning — does not exist at all
ProvisionedVerifierMaterialis a set of marker strings, andNO_VERIFIER_MATERIAListhe only value ever constructed —
libs/server/federation/src/profiles/verifier-identity.ts:46-54.apps/auth-server/src/app/app.ts:128-129deliberately does not pass it:"
provisionedVerifierMaterialis deliberately not passed: no certificate configurationsurface 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.(
libs/server/config/src/lib/schemas/federation.ts) ships exactlyWALLET_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-anchorvariable 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 fedfrom config.
B. Signed Authorization Request — not built
libs/server/federation/src/oid4vp/client-identifier.tsrenders one prefix.UNSIGNED_CLIENT_ID_PREFIX = 'redirect_uri'(:36); the only builders arebuildClientId(:44) andbuildRedirectUriClientId(:62). Nothing computes anx509_hashvalue — no certificate digest anywhere in the workspace.selectClientIdPrefix(oid4vp/authorization-request.ts:216) returnstypeof UNSIGNED_CLIENT_ID_PREFIXand throws for everything else (:239-243). Thebuilder hardcodes
assertRequestSigningPosture(profile, { signed: false })at:295.x5cheader assembly, norequest/request_uridelivery.encodeOid4vpRequestUri(:353) emits query parameters only, andits JSDoc says so (
:347-348).x509_hashappears 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.jwtresponse — not builtOid4vpAuthorizationRequest.response_modeis literal-typed todirect_post(
oid4vp/authorization-request.ts:87). The builder refuses a profile whoseresponseModesexcludesdirect_post(:274-278) and refusesresponseEncryption: 'required'outright (:282-286).VerifierClientMetadata(:66-71) carriesvp_formats_supportedandclient_nameandnothing else — no
jwkswith the per-requestuse: 'enc'key, noencrypted_response_enc_values_supported, both of which HAIP §5 requires a Verifier topublish.
oid4vpDirectPostRequestSchema(
apps/auth-server/src/app/schemas/oid4vp.ts:50-62) acceptsvp_token,state,error,error_description. There is noresponseparameter — the single JWE-carrying field ofdirect_post.jwt. Unknown fields are stripped (Zod default), so a wallet postingresponsetoday 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) storesresponse_modeas text (:80) but has no column for the ephemeral private key or itskid, so no instance other than the one that built the request could decrypt.D. The boot gate, and what this issue clears
haip-1.0is refused on four independent counts (enumerated atapps/auth-server/src/app/wallet-federation-haip.integration.test.ts:17-27):assertVerifierIdentityProvisioned(
profiles/verifier-identity.ts:117) — cleared by A;signingAlgs: ['ES256']unmet →assertProfileWithinCryptoCapabilities(
libs/fastify/plugins/federation/src/lib/configured-providers.ts:189-195) —cleared by A;
responseEncryption: 'required'unmet → same guard (:197-201) — cleared by C;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.tsalready 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:478resolveAnchoredSigningCertificate, 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 alreadyexists (
libs/server/federation/src/status/test/x509-fixtures.ts:244).Genuinely blocked:
haip-1.0becoming selectable is blocked on feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308 (count 4 above). Completing thisissue clears three of four; the profile still refuses to boot until feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308's attesting-issuer
registry / key-attestation anchors are provisionable. Plan the E2E accordingly.
WRPAC — an operator obligation under CIR (EU) 2025/848 (applies 2026-12-24), confirmed
in decision: OID4VP profile sequencing, client-ID prefixes & subject identity (blocks epic #231) #296 Q4 as "documentation + configuration scope only". QAuth's code path is fully
testable against an operator-supplied test PKI; only the real-wallet pass waits.
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) andHAIP 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
_PATHvariant, matching theJWT_RS256_PRIVATE_KEY/JWT_MLDSA_PRIVATE_KEYprecedent already in
libs/server/config/src/lib/schemas/jwt.ts; (b) a per-realm DB-backedstore, matching the planned
realms.verifier_profile.Recommendation: (a) env-first.
deriveCryptoCapabilitiesis deliberately a purefunction of provisioned env keys (
crypto-capabilities.ts:39-45) and the boot gate needsits 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: falseso the key never leaves the runtime; (b)persisted on the
oid4vp_request_statesrow, requiringextractable: trueand encryptionat rest.
Recommendation: (b), with (a) as a documented single-instance option. The
direct_post.jwtresponse arrives on a different HTTP request than the one that built thekey, 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
requestparameter vs.request_urireference. Thecodebase has already assumed
request_uri(
apps/auth-server/src/app/helpers/wallet-login-flow.ts:140-141: "under HAIP it is arequest_urireference to a signed JAR (#298)"), but nothing implements it and theassumption is unverified against the HAIP Final text.
Recommendation:
request_uri, because a signed JAR carryingdcql_query,client_metadataand anx5cchain will not fit a QR code. Confirm against HAIP §5.1 /OID4VP §5.10 before building, and add a
GETrequest-object endpoint if confirmed.Tasks
Phase A — verifier key and certificate provisioning (shared)
_PATHvariant, mirroringJWT_RS256_PRIVATE_KEY/_PATH) and its X.509 chain (leaf + intermediates, anchorexcluded). Validate the chain at boot with the existing
resolveAnchoredSigningCertificatemachinery rather than a new DER walk.ProvisionedVerifierMaterial.availablefrom that config and thread it intocreateConfiguredProviders({ provisionedVerifierMaterial })atapps/auth-server/src/app/app.ts:128, replacing the "deliberately not passed" comment.deriveCryptoCapabilities'ES256entry(
apps/auth-server/src/app/crypto-capabilities.ts:79) from the literalfalseto a realpredicate over the new config, and update the JSDoc that currently explains why it is
false.
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 accessor 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_hashx509_hashClient Identifier value from the provisioned leaf certificate andadd
buildX509HashClientIdalongsidebuildRedirectUriClientIdinoid4vp/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.
selectClientIdPrefix(oid4vp/authorization-request.ts:216) beyondtypeof UNSIGNED_CLIENT_ID_PREFIX. Its:315-317comment says the widening is meant tobreak the
client_idline — take that break and branch there rather than around it.x5cheadercarrying leaf + intermediates with the trust anchor excluded, and
assertRequestSigningPosture(profile, { signed: true })instead of the hardcoded{ signed: false }at:295.request_urirecommended), extendingencodeOid4vpRequestUrior adding a request-object endpoint. Underhaip-1.0theunsigned query-parameter form must become unreachable, not merely unpreferred.
assertRequestSigningAllowed(profiles/verifier-identity.ts:231) into the signedpath — it is fully implemented and currently has no production caller.
Phase C — encrypted
direct_post.jwtresponseECDH-ESP-256 pair per Authorization Request viagenerateEphemeralEncryptionKeyPairand publishexportEncryptionPublicJwkinclient_metadata, alongsideencrypted_response_enc_values_supportedlisting bothA128GCMandA256GCM(HAIP §5). ExtendVerifierClientMetadata(
oid4vp/authorization-request.ts:66-71).kid(and, per the decision above, the private half) with the requeststate — migration on
oid4vp_request_states(
libs/infra/db/src/lib/schema/oid4vp.ts:45-111), deleted at redemption or expiry, andaged with
isEphemeralEncryptionKeyPairExpired.Oid4vpAuthorizationRequest.response_mode(:87) past thedirect_postliteraland replace the two blanket refusals at
:274-278/:282-286with the profile-drivenbranch.
responseparameter inoid4vpDirectPostRequestSchema(
apps/auth-server/src/app/schemas/oid4vp.ts:50-62), bounded likevp_token, and decryptit with
decryptJwepinned to['ECDH-ES']/['A128GCM','A256GCM']beforeparseVpTokensees anything.stateredemption(
apps/auth-server/src/app/routes/oid4vp/response.ts:119), for the reason that routealready documents: "a replayed or expired state must be rejected before any work is done
on attacker-supplied bytes." Note
stateis inside the JWE underdirect_post.jwt, sothe correlation input has to be recovered without trusting the ciphertext — resolve this
explicitly rather than by accident.
OID4VP_REJECTION_DESCRIPTION(oid4vp/direct-post.ts:60) with the reason loggedserver-side only.
CryptoDecryptionErroralready returns one uniform message; do notleak
.detail.deriveCryptoCapabilities'responseEncryption(
crypto-capabilities.ts:95) to a real predicate.haip-1.0, unencrypteddirect_postmust be rejected, not tolerated — HAIP §5allows no cleartext fallback.
Cross-cutting
#233/#234pointers inlibs/fastify/plugins/federation/src/lib/configured-providers.ts:193,:199(both issuesare closed) at this issue.
it.todos inapps/auth-server/src/app/wallet-federation-haip.integration.test.ts:80-86with realtests, 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 +x5cverification against an anchor the header does not carry, and (b) response encryption to
the
client_metadatakey. Note the boundary:apps/auth-serverisscope:appandcannot import
scope:serverlibs (eslint.config.mjs:105-113), so the existingcertificate factory at
libs/server/federation/src/status/test/x509-fixtures.tsisunreachable from the E2E — either build a small
node:cryptoone underapps/auth-server/src/testing/(asmock-wallet.tswas built fromcore-crypto+jose)or promote the fixture into a
scope:corelib.haip-1.0must stillrefuse, now on the key-storage count alone, and the assertion should say so rather than
matching any
/haip-1\.0/message.Acceptance Criteria
ES256inVerifierCryptoCapabilities.signingAlgsandresponseEncryption: true, andassertProfileWithinCryptoCapabilitiesno longer refuseshaip-1.0. Verified by unittest on
deriveCryptoCapabilities, which is a pure function.haip-1.0still refuses to boot — on thekeyStorageAssurancecount 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.
buildOid4vpAuthorizationRequestunderhaip-1.0produces arequest whose
client_idcarries thex509_hashprefix, delivered as a signed requestobject whose
x5ccontains leaf + intermediates and not the anchor. An independentverifier (the mock wallet, built without importing the code under test) validates the
signature and the chain against an anchor it holds out of band.
client_metadatacarries anECDH-ESP-256 public JWK withuse: 'enc'and akid, plusencrypted_response_enc_values_supportedlisting bothA128GCMandA256GCM. A second request carries a differentkidand key.direct_post.jwtsubmission encrypted by the mock wallet to that key decrypts, parsesagainst the DCQL query, and flows into the existing feat: [T4] OID4VP Verifiable Presentation validation — SD-JWT VC (ADR-004) #234 validation seam unchanged.
response encrypted to another request's key; response encrypted with an
alg/encoutsidethe pins; a
zipheader; a response for an expired ephemeral key; an unencrypteddirect_postunderhaip-1.0.oid4vp-1.0-base, behaviour is bit-for-bit unchanged: the unsignedredirect_uripath, cleartextdirect_post, and the existing test: [T4] E2E integration tests — reference/mock wallet (ADR-004) #240 base E2E suite allpass untouched.
GET /.well-known/jwks.jsonand cannot signa QAuth access or ID token, proven by test.
apps/auth-server/src/app/wallet-federation-haip.integration.test.tscontains noit.todo.References
VerifierProfile) · decision: OID4VP profile sequencing, client-ID prefixes & subject identity (blocks epic #231) #296 Q2/Q4(prefix ordering, WRPAC as operator scope) · feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308 (key attestations — the remaining boot
blocker) · test: [T4] E2E integration tests — reference/mock wallet (ADR-004) #240 / PR test(auth): E2E wallet-federation suite with a reference mock wallet #375 (the E2E suite whose
it.todos this closes) · feat: [T4] OID4VP 1.0 authorization request generation + direct_post response intake (ADR-004) #233, feat: [T4] OID4VP Verifiable Presentation validation — SD-JWT VC (ADR-004) #234 (closed;their pointers in
configured-providers.tsare stale)