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
Types
Functions
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.
@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.