Attesto.VpToken (Attesto v2.6.1)

Copy Markdown View Source

OID4VP vp_token verification for SD-JWT VC (dc+sd-jwt) and ISO mdoc (mso_mdoc) presentations (OID4VP §7).

The response is a map from credential-query IDs to non-empty arrays of presentations. Verification is conn-free. SD-JWT VC entries delegate issuer signature, disclosure, VC claim, and holder Key Binding JWT checks to Attesto.SdJwtVc and Attesto.SdJwt; mso_mdoc entries delegate Device- Response and device-signature checks to Attesto.Mdoc.verify_device_response/4.

Holder key binding is mandatory for SD-JWT VC: a credential without a cnf.jwk or a valid Key Binding JWT cannot satisfy an OID4VP request. mdoc presentations carry their own device-signature binding instead.

Format dispatch

Each query ID's format is resolved via the optional :formats option — a map of query ID to "dc+sd-jwt" or "mso_mdoc" — falling back to format detection when a query ID is absent from :formats (or the option itself is omitted): a ~-delimited string is SD-JWT VC, a plain base64url string is mso_mdoc. Prefer :formats when the DCQL query is known ahead of time; detection exists for callers that don't thread it through.

mdoc context

An mso_mdoc entry additionally needs :response_uri — the OID4VP response_uri the wallet's DeviceResponse was bound to via its OpenID4VPHandover SessionTranscript. :audience doubles as the handover's client_id and the shared :nonce as its nonce. Only unencrypted direct_post is supported (the handover's JWK thumbprint is always nil). SD-JWT-VC-only callers never need :response_uri; it is required only when the vp_token actually contains an mso_mdoc entry.

Summary

Functions

Derive per-query-id verification constraints from a DCQL query, for the :query_constraints option of verify/2.

Verify an OID4VP vp_token carrying SD-JWT VC and/or mso_mdoc presentations.

Types

mdoc_safe_result()

@type mdoc_safe_result() :: %{
  doc_type: String.t(),
  namespaces: map(),
  device_namespaces: map(),
  validity: map()
}

safe_result()

@type safe_result() :: %{
  vct: String.t(),
  iss: String.t(),
  claims: map(),
  cnf: map() | nil
}

verify_result()

@type verify_result() :: {:ok, map()} | {:error, term()}

Functions

constraints_from_dcql(dcql_query)

@spec constraints_from_dcql(map()) :: %{optional(String.t()) => map()}

Derive per-query-id verification constraints from a DCQL query, for the :query_constraints option of verify/2.

The query is first normalized with Attesto.PresentationRequest.dcql_query/1, so malformed empty constraints raise ArgumentError instead of being weakened. For each credential query this function extracts the requested format, cardinality, accepted credential type (vct_values for dc+sd-jwt, doctype_value/doctype_values for mso_mdoc), claims, and claim_sets. It also embeds the query's required credential-set options in every entry so the returned map is sufficient to enforce the complete DCQL response plan. verify/2 enforces these after signature and holder binding. Query IDs absent from the returned map are unconstrained.

verify(vp_token, opts \\ [])

@spec verify(
  term(),
  keyword()
) :: verify_result()

Verify an OID4VP vp_token carrying SD-JWT VC and/or mso_mdoc presentations.

Required options are :nonce, :audience, and exactly one issuer trust source: :issuer_jwks for static issuer keys or :resolve_issuer for a callback receiving the presentation's (unverified) issuer identity. The optional :now value is passed to both the VC/mdoc and holder-binding verifiers. See the moduledoc for :formats and :response_uri. :accepted_algs and :enforce_fapi_alg_policy are forwarded to SD-JWT issuer verification only, independently of the holder Key Binding JWT. :expected_query_ids identifies individually required entries; :permitted_query_ids can include optional requested entries. :required_credential_sets carries DCQL's AND-of-ORs response rule as a list of required sets, each containing one or more complete credential-ID options. For example, [[["pid"], ["mdl"]], [["address"]]] requires either pid or mdl, and also requires address. An explicit empty list represents a DCQL query whose credential sets are all optional and is the only policy that permits an empty VP Token. constraints_from_dcql/1 embeds this policy in its result, so callers already storing that result as :query_constraints do not need to pass a second option. When omitted, the permitted set is derived from expected IDs, constraint keys, and required-set IDs. Entries outside that set are rejected before format detection or issuer lookup. Only low-level calls omitting all query-policy options accept arbitrary non-empty query IDs. Constraints remain enforced on optional entries when they are present. A query constraint permits multiple presentations only when its :multiple or "multiple" member is explicitly true; omission applies DCQL's default of one presentation. Malformed response maps and values return {:error, :invalid_vp_token}; malformed trusted options still raise ArgumentError.

:resolve_issuer receives UNVERIFIED issuer material

For SD-JWT VC, the callback is handed the iss peeked from the still- unverified issuer JWT (verifying the signature requires the key, which requires iss — so the lookup is unavoidably ahead of verification). For mso_mdoc, it is handed the docType peeked from the still-unverified DeviceResponse in the same way. The signature is then checked against whatever keys the callback returns, so forged issuer material cannot forge a credential — it only misdirects the key lookup. But the callback MUST NOT make a network request derived from this value without an allow-list: the presenter controls it, so a naive fetch is an SSRF sink. Resolve from a trusted issuer registry, not by dereferencing the peeked value.

Each query ID must contain a non-empty list of presentation strings and produces a list of safe results, as required by OID4VP 1.0. The explicit :legacy_scalar_values option (default false) also accepts a non-empty presentation string and returns one safe result for that legacy entry. Raw JWTs and raw DeviceResponse bytes are never returned.

:max_presentations_per_query bounds verification work for every query ID, including low-level calls without DCQL constraints. It defaults to 16 and must be a positive integer. This cap still applies when DCQL multiple is explicitly true.