Skip to content

fix(wallet)!: mdoc signer checks on receipt, per-format issuer trust, key-derived AKI, request_uri POST - #711

Merged
osanderson merged 5 commits into
mainfrom
fix/wallet-trust-hardening
Oct 24, 2026
Merged

osanderson merged 5 commits into
mainfrom
fix/wallet-trust-hardening

Conversation

@osanderson

Copy link
Copy Markdown
Contributor
  • mdoc document-signer checks on receipt (breaking). wallet.VerifyIssuedCredential now runs mdoc.CheckDocumentSigner after mdoc.Verify, as verifiers do. The checks are:

    • the IACA and signer country/province match;
    • the MSO was signed within the signer's validity;
    • issuing_country and issuing_jurisdiction match 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.

    • New MdocIssuerRoots, MdocIssuerRootsByDocType (like the verifier's RootsByDocType) and MdocIssuerLeafPolicy.
    • An mdoc uses its doctype's roots, else the mdoc roots, else IssuerRoots.
    • Under mdoc-specific roots it gets MdocIssuerLeafPolicy or the default document-signer policy, never the SD-JWT IssuerLeafPolicy. So a wallet holding both formats can require each one's EKU, and an SD-JWT issuer CA can't sign an mDL.
    • The new pools are part of role separation. The issuer pools count as one role family and aren't compared with each other.
  • Matching mobile options. New config keys mdoc_issuer_roots, mdoc_issuer_roots_by_doctype and mdoc_issuer_require_eku, documented in ABI.md and mirrored in the Swift and Kotlin WalletConfiguration. They're additive, so the ABI stays 12. Mobile's role check treats both issuer formats as one family.

  • trusted_authorities anchors matched by key-derived identifiers (breaking). With Roots, 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.

    • The new Anchors []*x509.Certificate covers 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, CheckTrustedAuthorities refuses every match.
    • The passport-vdc verifier uses Anchors.
    • The wallet side is unchanged: it only decides what to offer, and the verifier's check decides trust.
  • walletflow honours request_uri_method=post (OpenID4VP 1.0 §5.10).

    • It POSTs with a fresh 256-bit wallet_nonce, and the Request Object must echo it.
    • The wallet_metadata sent is only what the wallet supports: vp_formats_supported and client_id_prefixes_supported: ["x509_hash"]. It sends no jwks or encryption values, which would ask for an encrypted Request Object.
    • GET stays the default. cmd/conformance-wallet-vp posts through wallet directly and is unchanged.

Tests

  • go vet and go test in the root, mobile/ (-tags mobiletest) and passport-vdc.
  • -race on wallet, walletflow, dcql and verifier.
  • The Kotlin library compiles and its unit tests pass, and the Android demo compiles.
  • The iOS demo builds.
  • Both OIDF suites are dispatched on this branch.

🤖 Generated with Claude Code

osanderson and others added 5 commits October 24, 2026 08:28
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
osanderson force-pushed the fix/wallet-trust-hardening branch from 50bd501 to 921b81c Compare October 24, 2026 00:29
@sonarqubecloud

Copy link
Copy Markdown

@osanderson
osanderson merged commit df6946d into main Oct 24, 2026
15 checks passed
@osanderson
osanderson deleted the fix/wallet-trust-hardening branch October 24, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant