Skip to content

Engine-requested DPoP signing: bind WIA cnf to the DPoP key and stop replaying the attestation PoP #317

Description

@leifj

Backend half of sirosfoundation/wallet-frontend#282. That issue names two gaps left by the WIA work in #304 / wallet-frontend#196:

  1. 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.
  2. 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.

Which requests carry what:

request attestation (audience) DPoP (htm/htu)
PAR yes no
token (every grant, each nonce attempt) yes yes
credential, deferred, notification (each nonce attempt) no yes (ath set)

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions