Credential wallet: from a local presentation to HAIP

Copy Markdown View Source

AttestoClient implements the holder side of OpenID4VCI and OpenID4VP. It requests and verifies credentials, evaluates presentation queries, creates holder proofs and handles encrypted exchanges. Your application supplies the user interface, consent, secure key storage, credential persistence and trust policy. A protocol library does not establish that a key is hardware protected; that assurance comes from your wallet provider and key-attestation issuer.

For the encrypted examples, use these dependencies:

{:attesto, "~> 2.5"},
{:attesto_client, "~> 2.9"}

Add {:cbor, "~> 1.0"} when accepting or presenting mso_mdoc credentials. AttestoClient 2.9 uses the shared security boundaries introduced in Attesto 2.5. The wallet Livebook starts with a complete local SD-JWT VC selective-disclosure example and then encrypts its response.

1. Present one verified credential

Keep each verified credential with its original encoded value, verified claims and holder binding. For example, an SD-JWT VC received and verified by AttestoClient.Wallet has the following fields:

held = %{
  format: "dc+sd-jwt",
  credential: credential,
  claims: verified_claims,
  holder_binding: verified_claims["cnf"]
}

credential, verified_claims and holder_key in these examples come from a completed issuance flow. Never treat unverified JWT claims as a held credential.

Verify the incoming presentation request before displaying the verifier or requested claims. For a pre-registered verifier, pass its trusted verification key material:

{:ok, request} =
  AttestoClient.Wallet.PresentationRequest.from_uri(incoming_uri, verifier_jwks,
    client_id: verifier_client_id,
    audience: wallet_identifier,
    verifier_metadata: %{
      "vp_formats_supported" => %{
        "dc+sd-jwt" => %{
          "sd-jwt_alg_values" => ["ES256"],
          "kb-jwt_alg_values" => ["ES256"]
        }
      }
    }
  )

{:ok, proposed} =
  AttestoClient.Wallet.Presentation.select(request.dcql_query, [held])

select/2 proposes a selection; it does not obtain consent or send anything. For this pre-registered request, verifier_client_id and wallet_identifier are the expected client identifier and audience established by your registration, not values copied from an unverified request. HAIP uses the certificate-based verification described below instead. Pass verifier_metadata only from your trusted registration record. Its fields override signed metadata. Otherwise the verified request must carry a nonempty client_metadata.vp_formats_supported object. The wallet checks the selected format and advertised issuer/holder algorithms before generation and submission. Show the authenticated verifier, the credential format and the actual claims that would be disclosed. After the user approves, build and submit the selection with the keys corresponding to the DCQL query IDs:

{:ok, vp_token} =
  AttestoClient.Wallet.Presentation.build_vp_token(proposed, request,
    holder_keys: %{"pid" => holder_key}
  )

{:ok, response} =
  AttestoClient.Wallet.Presentation.submit(request, vp_token)

The example assumes the selected query ID is "pid". A selection for multiple query IDs needs a holder key for each. Explicit selections are checked against the DCQL constraints, including required credential sets and claim alternatives. When a query omits claims, return only mandatory presentation contents, without selectively disclosable claims. Claims embedded in the issuer's signed JWT cannot be removed by selective disclosure. An indivisible disclosure may itself contain several nested fields; show its actual contents rather than assuming every disclosure contains only one requested field. Both response modes map each query ID to a nonempty presentation array. For example, vp_token["pid"] is [presentation], and the verified result from Attesto.VpToken.verify/2 is also a list. Scalar plaintext responses are rejected.

For a consent preview or a transport you own, call build_response/3 instead of submit/3. It returns the form body without network access. Do not log the plaintext credentials or response body. If a successful response supplies a redirect_uri, open it in the user's browser according to application policy; do not fetch it from your backend.

2. Receive a credential offer and request issuance

Offers may be JSON, an openid-credential-offer:// link containing an offer, or a link containing an offer URI:

offer_result = AttestoClient.Wallet.CredentialOffer.parse(incoming_offer)

{:ok, offer} =
  case offer_result do
    {:ok, offer} -> {:ok, offer}
    {:fetch, uri} -> AttestoClient.Wallet.CredentialOffer.fetch(uri)
    {:error, reason} -> {:error, reason}
  end

Fetch and validate Credential Issuer metadata against offer.credential_issuer. Establish issuer trust separately; metadata or a remote JWKS alone is not a trust decision. Select an advertised credential configuration and its format. The following example uses a pre-authorized offer; supply tx_code: when the offer requires a transaction code:

{:ok, result} =
  AttestoClient.Wallet.request_credential(offer, holder_key,
    credential_issuer_metadata: validated_issuer_metadata,
    credential_configuration_id: "pid",
    token_endpoint: validated_as_metadata["token_endpoint"],
    client_id: wallet_client_id,
    client_auth: wallet_client_auth,
    format: "dc+sd-jwt",
    trusted: trusted_issuer_keys,
    dpop: dpop_key
  )

Endpoint options can be omitted when present in Credential Issuer metadata; conflicting overrides fail. The token endpoint comes from the selected authorization server's validated metadata. If an offer names an authorization server, require it to belong to the Credential Issuer's advertised authorization_servers. Keep exact issuer matching throughout discovery. When metadata includes credential_configurations_supported, the wallet checks the selected configuration and format before requesting issuance, then checks the returned SD-JWT vct or mdoc document type against that configuration. If you omit configuration metadata, your application must check the expected credential type itself. In both cases, your trust policy must authorize the issuer to issue that type of credential.

result.credentials contains verified held credentials, or pending markers when deferred polling is disabled or no deferred endpoint is supplied. Store successful credentials securely with the corresponding holder key. A batch may contain fewer credentials than requested; each returned credential is checked against the holder keys, and extra or duplicate key bindings fail.

3. Use authorization-code issuance with PAR and DPoP

For authorization-code offers, obtain the access token before calling Wallet.request_credential/3. Use an atomic transaction store and an opaque binding retained in the initiating browser's secure application session:

{:ok, pid} = AttestoClient.AuthorizationTransaction.Store.ETS.start_link()
store = {AttestoClient.AuthorizationTransaction.Store.ETS, pid}

{:ok, started} =
  AttestoClient.AuthorizationCode.start(store,
    protocol: :oauth,
    haip: true,
    issuer: authorization_server,
    metadata: validated_as_metadata,
    client_id: wallet_client_id,
    redirect_uri: callback_uri,
    browser_binding: browser_binding,
    scopes: [credential_scope],
    authorization_params: offer_authorization_params,
    par: true,
    dpop: dpop_key,
    client_auth: wallet_client_auth
  )

Redirect the browser to started.url. offer_authorization_params may include the offer's issuer_state and JSON-encoded authorization_details required by the issuer. Choose these from the validated offer and metadata. The library pins state, PKCE, client ID, redirect URI and the DPoP key; those parameters cannot be overridden. PAR requires HTTP 201 and a usable response, and a failed or expired start removes its stored transaction.

At the callback, pass the original URI or encoded form body so duplicate parameter names remain detectable:

{:ok, %{tokens: tokens, id_token_claims: nil}} =
  AttestoClient.AuthorizationCode.callback(store, original_callback,
    browser_binding: browser_binding,
    dpop: dpop_key,
    client_auth: wallet_client_auth
  )

Then supply access_token: tokens to the issuance example, with the same DPoP key. Plain OAuth mode does not request an OIDC ID Token. The protocol mode is retained in state; changing callback options cannot change it. haip: true (or fapi?: true for another FAPI client) requires authenticated PAR and token requests with DPoP. The transaction pins the profile, client and authentication key or secret identity. Callback and refresh must retain the same authentication method, identity and DPoP key. FAPI uses private-key JWT client authentication; this API does not attest an mTLS transport binding. The profile pins mandatory authorization-response iss checking, including when server metadata omits its support flag. A missing PAR endpoint or par: false fails locally. Both authorization-code and pre-authorized flows reject a Bearer token response when DPoP was requested, before credential HTTP. Their returned token sets retain a locally derived dpop_jkt; persist that thumbprint and use the same private key for refresh. State is single-use, including after a failed exchange. The default ETS store is for one node; use your own atomic store for a distributed deployment.

4. Enable encryption and bounded deferred polling

credential_encryption: :auto uses the issuer's advertised encryption capabilities. Set :required to require encrypted requests and responses. :disabled fails when the issuer requires encryption. Supported suites are ECDH-ES with P-256 and A128GCM or A256GCM. Request encryption is also required when requesting an encrypted response. The wallet generates a fresh response key unless you supply credential_response_encryption_key:; retain its private part securely for the operation.

Deferred responses use HTTP 202 and a positive integer interval in seconds. With an advertised deferred endpoint, polling defaults to ten attempts within 120 seconds. Customize deferred_max_attempts: and deferred_timeout: (milliseconds) for the issuer's expected delay. Polling will not wait past its deadline, even when an issuer returns a much longer interval. Set deferred_poll: false to return a pending marker. This marker is informational: it contains no polling interval, access token or generated encryption private key, and there is no public resume API. Prefer automatic polling for a complete exchange. Durable continuation requires an application-owned deferred transport and credential verification, a separately retained access token and sender-constraining key, an explicitly supplied response encryption key, and the issuer's polling timing.

5. Add the HAIP trust and attestation integration

HAIP presentation requests use x509_hash client identifiers and encrypted direct_post.jwt. Resolve the incoming request using explicitly trusted DER certificate anchors:

{:ok, request} =
  AttestoClient.Wallet.PresentationRequest.from_uri(incoming_uri, nil,
    haip: true,
    trusted_certificates: [trusted_verifier_root_der]
  )

The embedded certificate chain must validate to an anchor; it cannot appoint itself trusted. An optional certificate_trust: callback can impose additional policy after chain validation. Choose anchors through your trust framework, and plan how you distribute updates and apply certificate revocation policy. Do not derive trusted-authority identifiers from an unverified x5c header. The credential's authority_key_identifiers, when used to match a DCQL trusted authority, must come from a verified issuer chain.

When a leaf certificate declares KeyUsage, it must permit digitalSignature. RSA-PSS-constrained public keys are rejected throughout the selected path, including the used trust anchor, until the restrictions can be fully preserved and enforced. rsaEncryption parameters must be NULL; absent parameters are accepted for interoperability. Unrelated unsupported anchors do not invalidate another valid path. Ecosystem EKU requirements are a separate policy from permission to sign. Algorithm allowlists must be nonempty and can only narrow the supported wallet algorithms; callers cannot enable weak algorithms or remove the PS256 key-strength check. Select enforce_fapi_alg_policy: true when the ecosystem also requires FAPI's narrower signature algorithm set.

Signed presentation requests require typ: oauth-authz-req+jwt, an explicit supported response_mode, and URL-safe ASCII nonce and state values. The verified client_id binds the verifier; iss is ignored. Every advertised encryption key must have a unique nonempty kid of at most 256 bytes. Aggregate VP-token data and the encoded response are limited to 1 MiB. Certificate-bound requests must provide all verifier metadata in signed client_metadata, including vp_formats_supported. Caller-supplied verifier_metadata cannot fill omissions there. SD-JWT uses sd-jwt_alg_values and kb-jwt_alg_values; mdoc uses integer COSE identifiers in issuerauth_alg_values and deviceauth_alg_values. Advertised optional algorithm arrays must be nonempty. The current mdoc ES256/P-256 implementation matches either COSE -7 or the fully specified identifier -9.

By-reference request_uri and credential_offer_uri fetching sends only protocol-owned headers. Caller cookies, authentication, API keys, custom headers and routing options are not forwarded. Request URI responses require HTTP 200 and application/oauth-authz-req+jwt; credential offers require HTTP 200 and application/json. Presentation responses require HTTP 200 and a JSON object with application/json. Charset parameters are accepted, while malformed and duplicate JSON are rejected.

Enable the corresponding issuer-chain policy during HAIP issuance:

{:ok, result} =
  AttestoClient.Wallet.request_credential(offer, holder_key,
    access_token: tokens,
    credential_issuer_metadata: validated_issuer_metadata,
    credential_configuration_id: "pid",
    format: "dc+sd-jwt",
    haip: true,
    trusted_certificates: [trusted_issuer_root_der],
    dpop: dpop_key,
    key_attestation: provider_key_attestation
  )

Pass the complete TokenSet returned by the authenticated HAIP token flow; bare access-token strings lack the local profile and key-binding information. Retain profile, client_auth_binding, client_id, issuer and dpop_jkt along with the tokens when persisting them. These fields are set locally and are never accepted from token-endpoint JSON.

This mode requires an issuer certificate chain in the SD-JWT VC or mdoc, validates it to the supplied anchors, and verifies the credential with the leaf key. It does not fall back to fixed trusted: keys. Successful held credentials include the verified issuer_certificate_chain and authority_key_identifiers; failed signature or holder-binding checks never produce a held credential. Add certificate_trust: for further issuer policy. Certificate-identified SD-JWT credentials may omit iss; accepting those requires Attesto 2.5.0 or later. A present malformed issuer still fails.

Credential status remains application-owned. Before storing or presenting a credential, apply your revocation and freshness policy. For a Token Status List, safely fetch the status token, validate its certificate chain and signature, check its subject and freshness, and use Attesto.StatusList to read and enforce the credential's status index. Wallet does not automatically fetch status tokens, enforce revocation, or provide automated mdoc status handling.

For issuance, the wallet provider supplies a Client Attestation binding the wallet instance key, and the key-storage provider supplies key attestations for the holder keys. Configure client authentication as:

wallet_client_auth =
  {:client_attestation, provider_attestation, wallet_instance_key,
   [audience: authorization_server]}

The HTTP layer creates a fresh Client Attestation PoP for each request and supports the server's attestation-challenge retry. Keep this instance key distinct from the DPoP key and the credential holder key. Client Attestations built with AttestoClient.WalletAttestation.attestation/2 should include the trusted attester's issuer: identifier. Pass a provider-issued key attestation, or a callback producing one for the current keys and nonce, as key_attestation: during issuance. Generating a synthetic attestation with a test key is appropriate for a test harness, not evidence of secure storage. The PoP's audience: is the intended authorization server from trusted metadata; the provider attestation's issuer: identifies the attester.

If the authorization server advertises a challenge_endpoint, obtain its attestation challenge before starting PAR. Use the endpoint from the selected server's validated metadata:

{:ok, challenge} =
  AttestoClient.WalletAttestation.fetch_challenge(
    validated_as_metadata["challenge_endpoint"]
  )

wallet_client_auth =
  {:client_attestation, provider_attestation, wallet_instance_key,
   [audience: authorization_server, challenge: challenge.challenge]}

Pass client_auth: wallet_client_auth and dpop_nonce: challenge.dpop_nonce alongside the DPoP key in the subsequent PAR or token request. The helper makes an unauthenticated empty POST, validates the challenge response and preserves an optional DPoP nonce. Keep these values scoped to the server that issued them; use newly received values on later requests. It does not discover the endpoint or establish trust in an authorization server.

Wallet certification covers a versioned, runnable implementation in the selected OID4VCI/HAIP or OID4VP/HAIP profiles. The existing Attesto issuer and verifier certifications and AttestoClient's OIDC RP certification do not certify this wallet role. See the OpenID Foundation's VCI testing instructions and VP testing instructions.

Development and verification

When developing both libraries in sibling source checkouts, use ATTESTO_PATH=1 mix test to exercise the coordinated core. AttestoClient 2.9.0 requires Attesto 2.5.0 or later, including for unencrypted wallet operations, so installations use the shared JWS, DPoP, URI, and outbound-address policy.

Run conformance against one frozen source or released dependency set. Record the implementation versions and suite revision, and finish one complete plan per selected profile. Review the suite's manual evidence, including the credential format and actual displayed or disclosed contents, before filing.