Repository navigation
fix(wallet)!: mdoc signer checks on receipt, per-format issuer trust, key-derived AKI, request_uri POST - #711
Merged
Merged
Conversation
VerifyIssuedCredential verified an issued mdoc's IssuerAuth certificate path but not ISO/IEC 18013-5's document signer checks a verifier runs (mdoc.CheckDocumentSigner: the signer's countryName and stateOrProvinceName match its IACA's, the MSO was signed within the signer's validity, and a disclosed issuing_country or issuing_jurisdiction matches the signer's subject). A wallet could therefore store and display an mDL naming another country than the one whose IACA vouches for its signer, which every verifier would then refuse. The mdoc path now keeps the verified certificate paths, narrows them to the ones the leaf policy approves on its own (certchain.ApprovedChains, as proximity's reader does), and runs CheckDocumentSigner on them after mdoc.Verify. BREAKING CHANGE: wallet.VerifyIssuedCredential, and so walletflow, now refuse an issued mdoc that fails mdoc.CheckDocumentSigner, as verifiers already do: among others one whose IACA or document signer certificate has no countryName, or whose issuing_country doesn't match its signer. Issue mdocs from an IACA and document signer that name their country. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Config had one IssuerRoots pool and one IssuerLeafPolicy for SD-JWT VC issuers and mdoc document signers alike. The two carry different extended key usages, so a wallet receiving both formats couldn't require either, and any IssuerRoots anchor, an SD-JWT VC issuer's CA say, could sign an mDL the wallet stored and displayed. Config gains three optional fields, used for "mso_mdoc" credentials on receipt and for their status lists: - MdocIssuerRoots, the IACAs mdocs chain to in place of IssuerRoots; - MdocIssuerRootsByDocType, each document type's own IACAs, as verifier.X5ChainIssuerKeyResolver's RootsByDocType: a type named with a nil pool is refused; - MdocIssuerLeafPolicy, replacing IssuerLeafPolicy for mdocs. Unset, an mdoc under IssuerRoots keeps IssuerLeafPolicy, and one under mdoc-specific roots gets mdoc.DefaultDocumentSignerPolicy. With none of them set, both formats share IssuerRoots and IssuerLeafPolicy as before. IssuerRoots is no longer required for a wallet that receives only mdocs and sets the mdoc roots. The production role-separation check covers the new pools. The issuer pools of both formats are one role and aren't compared with each other, since one ecosystem CA may certify both kinds of signer; each is compared with the Verifier, registrar, metadata and mdoc reader pools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NewWallet's configuration gains mdoc_issuer_roots, mdoc_issuer_roots_by_doctype and mdoc_issuer_require_eku, mapped to walletflow.Config's MdocIssuerRoots, MdocIssuerRootsByDocType and MdocIssuerLeafPolicy, so a wallet receiving SD-JWT VCs and mdocs can trust each format's own anchors and require each its own extended key usage. An empty mdoc_issuer_roots_by_doctype entry names its type with no roots, refusing it. The role check treats the issuer roots of both formats as one role: they aren't checked against each other, and the mdoc ones are checked against the other roles with mdoc_issuer_require_eku, as is issuer_roots when mdocs are received under it with that key usage. The keys are additive and optional, so ABIVersion stays 12. The Swift WalletConfiguration and Kotlin WalletConfiguration get matching mdocIssuerRoots, mdocIssuerRootsByDocType and mdocIssuerRequireEKU fields, appended with defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AKITrustedAuthoritiesChecker with Roots matched a trust anchor by the
Subject Key Identifier the anchor declares about itself. A second
trusted root declaring another root's identifier then passed that
root's "aki" trusted_authorities filter, so a Verifier asking only for
credentials under one authority accepted another's.
With Roots, every CA on the verified path, the anchor included, now
matches only by the identifiers derived from its public key (RFC 5280
section 4.2.1.2 method 1, RFC 7093 method 1), which no certificate for
another key can carry. Most CAs' identifiers are derived this way.
For an anchor whose identifier is made another way, the checker gains
Anchors, an alternative to Roots holding the trust anchors as
certificates: issuerChain is verified against them, and an anchor's
declared identifier matches too, as long as no anchor with another key
declares the same one or has it as a derived identifier. The new
Validate method reports such a conflict (and Roots and Anchors both
set, or a nil anchor); CheckTrustedAuthorities refuses every match
while one is configured. An anchor reissued for the same key keeps its
identifier and isn't a conflict.
verifier.VerifyResponse accepts a checker with Anchors as a verifying
one. The Roots-less checker a wallet uses to choose what to present
is unchanged: it reads what the certificates declare, which only
narrows what is offered. The passport-vdc verifier uses Anchors and
validates them at startup.
BREAKING CHANGE: with AKITrustedAuthoritiesChecker.Roots, a trust
anchor whose Subject Key Identifier isn't derived from its key (SHA-1,
or SHA-256 truncated to 160 bits, of its subjectPublicKey) no longer
matches an "aki" value naming that identifier. Set Anchors to the
anchor certificates in place of Roots, e.g.
dcql.AKITrustedAuthoritiesChecker{Anchors: issuerCAs}, and call
Validate at startup to catch anchors claiming the same identifier.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StartPresentation fetched every request_uri by GET, even for a link saying request_uri_method=post (OpenID4VP 1.0 section 5.10), which a Verifier that accepts only POST refuses, and which skips the wallet_nonce binding the POST exists for. A link with request_uri_method=post now goes through wallet.FetchAuthorizationRequestPost with a fresh 256-bit wallet_nonce from Dependencies.Random, and the Request Object must carry that wallet_nonce back, so one fetched for another request is refused. GET stays the default, for a link with request_uri_method=get or none. The wallet_metadata sent declares only what the wallet does: the dc+sd-jwt and mso_mdoc formats, with the issuer signature algorithms it verifies on receipt (ES256, EdDSA; COSE -7, -8) and the ES256 Key Binding JWT and DeviceSignature it signs, and the x509_hash Client Identifier Prefix, the only one it accepts. It sends no jwks or encryption parameters, which would ask for an encrypted Request Object the wallet can't decrypt; response encryption still follows the Verifier's client_metadata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
osanderson
force-pushed
the
fix/wallet-trust-hardening
branch
from
October 24, 2026 00:29
50bd501 to
921b81c
Compare
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



mdoc document-signer checks on receipt (breaking).
wallet.VerifyIssuedCredentialnow runsmdoc.CheckDocumentSigneraftermdoc.Verify, as verifiers do. The checks are:issuing_countryandissuing_jurisdictionmatch the signer.It runs on the paths the leaf policy approves. A wallet would otherwise store and display an mDL that every verifier refuses.
Per-format issuer trust in walletflow.
MdocIssuerRoots,MdocIssuerRootsByDocType(like the verifier'sRootsByDocType) andMdocIssuerLeafPolicy.IssuerRoots.MdocIssuerLeafPolicyor the default document-signer policy, never the SD-JWTIssuerLeafPolicy. So a wallet holding both formats can require each one's EKU, and an SD-JWT issuer CA can't sign an mDL.Matching mobile options. New config keys
mdoc_issuer_roots,mdoc_issuer_roots_by_doctypeandmdoc_issuer_require_eku, documented inABI.mdand mirrored in the Swift and KotlinWalletConfiguration. They're additive, so the ABI stays 12. Mobile's role check treats both issuer formats as one family.trusted_authoritiesanchors matched by key-derived identifiers (breaking). WithRoots, every CA, including the anchor, now matches only by an identifier derived from its key (RFC 5280 or RFC 7093 method 1). A second root declaring another root's SubjectKeyIdentifier can no longer pass that root's filter.Anchors []*x509.Certificatecovers anchors whose SKI isn't key-derived. Their declared SKI counts only when no anchor with another key claims it.Validate()reports conflicts, and while one exists,CheckTrustedAuthoritiesrefuses every match.Anchors.walletflow honours
request_uri_method=post(OpenID4VP 1.0 §5.10).wallet_nonce, and the Request Object must echo it.wallet_metadatasent is only what the wallet supports:vp_formats_supportedandclient_id_prefixes_supported: ["x509_hash"]. It sends nojwksor encryption values, which would ask for an encrypted Request Object.cmd/conformance-wallet-vpposts throughwalletdirectly and is unchanged.Tests
go vetandgo testin the root,mobile/(-tags mobiletest) and passport-vdc.-raceon wallet, walletflow, dcql and verifier.🤖 Generated with Claude Code