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
Key binding. The WIA cnf key must be the DPoP key (EC TS03 §2.2.1.1), so the sender-constrained token is bound to the wallet-provider-attested key. Today the engine generates and holds the DPoP key (generateDPoPKey in internal/engine/oid4vci.go) while the client signs the WIA PoP with its own key, so cnf ≠ DPoP key and a strict AS rejects the binding.
PoP freshness.requestClientAttestation asks the client for a WIA + PoP exactly once per flow and replays the same pair on the PAR request, the token request and the DPoP-nonce retry. An AS enforcing single-use jti rejects the replay.
Nothing merged or in flight closes either gap. The request_attestation action (#304, kotlin#154/#156, swift#122/#123) fixed the PoP audience problem only.
Design
Move DPoP signing to the client, on demand, over the existing sign_request channel. The client holds one key per wallet instance (or per flow, its choice) that is both the WIA cnf key and the DPoP key, and signs every DPoP proof and every attestation PoP when the engine asks. The backend never sees a DPoP private key.
New sign action: sign_client_auth
One round-trip per outbound request that needs client authentication. The engine says what it needs; the client returns exactly that.
Request (SignRequestParams, new fields marked +):
field
meaning
audience
AS issuer URL. Present ⇒ engine wants WIA + a fresh PoP with this aud.
issuer
Effective client_id, the PoP iss.
+ htm, htu
Present ⇒ engine wants a DPoP proof (RFC 9449 §4.2) for this method/URL.
+ dpop_nonce
Server-provided DPoP nonce to put in the proof, when the AS/issuer sent one.
+ ath
base64url(SHA-256(access_token)) for resource requests (credential, deferred, notification). Empty for the token endpoint.
+ key_id
On renewal: the dpop_key_id the original issuance returned. The client must sign with that key.
Response (SignResponseMessage, new fields marked +):
field
meaning
+ dpop_key_id
Client-chosen opaque identifier of the key it uses for DPoP in this flow. Required whenever the client supports the action, even if no proof was requested. Empty ⇒ client does not support the action (see fallback).
+ dpop_proof
The DPoP proof JWT, when htm/htu were given. Carries the public key in its jwk header as usual.
client_attestation, client_attestation_pop
WIA and fresh PoP, when audience was given and the client has a WIA. Optional: both empty ⇒ proceed without attestation, as today.
The DPoP-nonce retry loops already rebuild headers per attempt, so a retry is a second sign_client_auth with the new dpop_nonce, and the PoP is fresh on every attempt.
Renewal
flow_complete gains dpop_key_id, sent alongside refresh_token instead of dpop_jwk when the flow ran in client-held mode. The client stores it with the refresh token (privatedata S.credentialRefreshTokens).
flow_start gains dpop_key_id. When set on a renewal the engine runs client-held mode and passes it as key_id on every sign_client_auth. dpop_jwk keeps working for refresh tokens issued by older backends.
Mode selection and fallback
The engine decides per flow, lazily, at the first request that needs client auth:
flow_start carried client_attestation up front (a client that pre-resolved the WIA) → legacy mode, unchanged behaviour.
Renewal with dpop_jwk → legacy mode with that key. Renewal with dpop_key_id → client-held mode.
Otherwise send sign_client_auth. Non-empty dpop_key_id in the reply → client-held mode for the rest of the flow. Empty reply, unknown-action reply, or timeout → legacy mode: generate the engine DPoP key and do the single request_attestation as today.
The probe wait is bounded (15 s, package var like attestationRequestTimeout). Clients that already answer unknown actions with an empty response (both SDKs since kotlin#154 / swift#122) fall back instantly. Once client-held mode is chosen a later signing failure is a flow error, not a downgrade, because the token is already bound to the client's key. Backend first, clients after: an old client against a new backend behaves exactly as today.
Notification endpoint
notificationContext currently keeps the engine's DPoP private key for the §10 notification after flow completion. It will hold a DPoP signer instead: the local key in legacy mode, or a client-held signer that issues sign_client_auth on the completed flow's id (the sign channel is per session, not per flow, so this works on both transports).
Transports
WebSocket (default): fields above are plain additions to existing messages.
WMP (feat: WMP (Wallet Messaging Protocol) integration #163, open): the adapter marshals SignRequestMessage.Params into go-wmp's openid4x.SignSubFlowParams and unmarshals flow.complete results straight into SignResponseMessage, so the response side passes through untouched. go-wmp's SignSubFlowParams needs the five new request fields (htm, htu, dpop_nonce, ath, key_id), and feat: WMP (Wallet Messaging Protocol) integration #163's case *SignRequestMessage must copy them (it already drops issuer, which request_attestation needs; same fix). Tracked in a go-wmp PR linked below.
Client side (separate PRs)
Native SDKs already bind the WIA to a long-lived instance key in the WSCD, so the DPoP key is simply that instance key and dpop_key_id is its keystore id. No per-flow key needed, and cnf == DPoP key holds by construction. Both legacy and WMP sign paths answer sign_client_auth; the keystores gain a DPoP proof primitive; dpop_key_id is persisted with the refresh token and sent back on renewal.
wallet-frontend (wallet-frontend#282 itself) can keep its per-flow key model: mint the key on the first sign_client_auth of a flow, use it for the WIA cnf, the PoP and every DPoP proof, and persist it under dpop_key_id for renewal.
Out of scope
Any change to /wallet-provider/wia/generate: it binds whatever key signs the PoP.
Storing keys on the backend. Client-held mode removes the dpop_jwk private-key relay for new issuances; the legacy relay stays for compatibility.
Backend half of sirosfoundation/wallet-frontend#282. That issue names two gaps left by the WIA work in #304 / wallet-frontend#196:
cnfkey must be the DPoP key (EC TS03 §2.2.1.1), so the sender-constrained token is bound to the wallet-provider-attested key. Today the engine generates and holds the DPoP key (generateDPoPKeyininternal/engine/oid4vci.go) while the client signs the WIA PoP with its own key, socnf≠ DPoP key and a strict AS rejects the binding.requestClientAttestationasks the client for a WIA + PoP exactly once per flow and replays the same pair on the PAR request, the token request and the DPoP-nonce retry. An AS enforcing single-usejtirejects the replay.Nothing merged or in flight closes either gap. The
request_attestationaction (#304, kotlin#154/#156, swift#122/#123) fixed the PoP audience problem only.Design
Move DPoP signing to the client, on demand, over the existing
sign_requestchannel. The client holds one key per wallet instance (or per flow, its choice) that is both the WIAcnfkey and the DPoP key, and signs every DPoP proof and every attestation PoP when the engine asks. The backend never sees a DPoP private key.New sign action:
sign_client_authOne round-trip per outbound request that needs client authentication. The engine says what it needs; the client returns exactly that.
Request (
SignRequestParams, new fields marked +):audienceaud.issuerclient_id, the PoPiss.htm,htudpop_nonceathkey_iddpop_key_idthe original issuance returned. The client must sign with that key.Response (
SignResponseMessage, new fields marked +):dpop_key_iddpop_proofhtm/htuwere given. Carries the public key in itsjwkheader as usual.client_attestation,client_attestation_popaudiencewas given and the client has a WIA. Optional: both empty ⇒ proceed without attestation, as today.Which requests carry what:
audience)htm/htu)athset)The DPoP-nonce retry loops already rebuild headers per attempt, so a retry is a second
sign_client_authwith the newdpop_nonce, and the PoP is fresh on every attempt.Renewal
flow_completegainsdpop_key_id, sent alongsiderefresh_tokeninstead ofdpop_jwkwhen the flow ran in client-held mode. The client stores it with the refresh token (privatedataS.credentialRefreshTokens).flow_startgainsdpop_key_id. When set on a renewal the engine runs client-held mode and passes it askey_idon everysign_client_auth.dpop_jwkkeeps working for refresh tokens issued by older backends.Mode selection and fallback
The engine decides per flow, lazily, at the first request that needs client auth:
flow_startcarriedclient_attestationup front (a client that pre-resolved the WIA) → legacy mode, unchanged behaviour.dpop_jwk→ legacy mode with that key. Renewal withdpop_key_id→ client-held mode.sign_client_auth. Non-emptydpop_key_idin the reply → client-held mode for the rest of the flow. Empty reply, unknown-action reply, or timeout → legacy mode: generate the engine DPoP key and do the singlerequest_attestationas today.The probe wait is bounded (15 s, package var like
attestationRequestTimeout). Clients that already answer unknown actions with an empty response (both SDKs since kotlin#154 / swift#122) fall back instantly. Once client-held mode is chosen a later signing failure is a flow error, not a downgrade, because the token is already bound to the client's key. Backend first, clients after: an old client against a new backend behaves exactly as today.Notification endpoint
notificationContextcurrently keeps the engine's DPoP private key for the §10 notification after flow completion. It will hold a DPoP signer instead: the local key in legacy mode, or a client-held signer that issuessign_client_authon the completed flow's id (the sign channel is per session, not per flow, so this works on both transports).Transports
SignRequestMessage.Paramsinto go-wmp'sopenid4x.SignSubFlowParamsand unmarshalsflow.completeresults straight intoSignResponseMessage, so the response side passes through untouched. go-wmp'sSignSubFlowParamsneeds the five new request fields (htm,htu,dpop_nonce,ath,key_id), and feat: WMP (Wallet Messaging Protocol) integration #163'scase *SignRequestMessagemust copy them (it already dropsissuer, whichrequest_attestationneeds; same fix). Tracked in a go-wmp PR linked below.Client side (separate PRs)
dpop_key_idis its keystore id. No per-flow key needed, andcnf == DPoP keyholds by construction. Both legacy and WMP sign paths answersign_client_auth; the keystores gain a DPoP proof primitive;dpop_key_idis persisted with the refresh token and sent back on renewal.sign_client_authof a flow, use it for the WIAcnf, the PoP and every DPoP proof, and persist it underdpop_key_idfor renewal.Out of scope
/wallet-provider/wia/generate: it binds whatever key signs the PoP.dpop_jwkprivate-key relay for new issuances; the legacy relay stays for compatibility.