Digital Credentials API — features and limitations
Status: skeleton. Each section is filled from recorded matrix runs; claims marked [verified] carry a matrix cell behind them, claims marked [expected] await a run.
Executive summary
To be written last.
Test environment
| Component | Implementation | Where |
|---|---|---|
| Issuer (OID4VCI) | idp-pf-vcs services/issuer |
Railway |
| Connector AS / verifier | idp-pf-vcs services/verifier (+ PingFederate for the OIDC RP flow) |
Railway |
| Demo RP surface | this repo, site/ |
Railway |
| iOS wallet | mydigitalid (rewrite/foundation), IdentityDocumentServices provider |
iPhone, iOS 26 |
| Android wallet | this repo, android/wallet (Multipaz-based) |
Emulator, API 35+ Play image |
| Browsers | iOS Safari 26, Android Chrome (emulator), desktop Chrome + Safari (macOS) | — |
Why a custom wallet, not Apple/Google Wallet: no Australian licence is in Apple Wallet or Google Wallet (verified against both vendors' jurisdiction lists, Sep 2026 — QLD's live 18013-5 mDL sits in the QLD government app; NSW is in limited early access in Service NSW). For Australian credentials, a third-party wallet is currently the only thing that can answer a DC API request — the demo's architecture is the deployable architecture, not a stand-in.
The capability matrix
Mirrors the live matrix page; snapshot inserted at write-up time.
Findings by dimension
1. "The DC API" is two incompatible protocols
iOS Safari speaks org-iso-mdoc (ISO 18013-7 Annex C: HPKE-encrypted CBOR, ReaderAuth-signed
DeviceRequest). Android Chrome speaks openid4vp-v1-* (OID4VP Appendix A: JOSE — JWS request,
JWE response). They share nothing on the wire — different query languages, different encryption,
different session transcripts (dcapi vs OpenID4VPDCAPIHandover) that fail closed against each
other. A verifier (or connector) MUST implement and maintain both. [expected — verify per cell]
2. Format support is asymmetric by platform
iOS third-party wallet extensions can present mdocs only (the IdentityDocumentServices provider surface has no SD-JWT scene as of iOS 26). Android Credential Manager handles both mdoc and SD-JWT VC. Anything SD-JWT-shaped excludes iPhone holders. [expected]
3. Request signing and expected_origins
Unsigned requests (openid4vp-v1-unsigned) omit client_id entirely — the wallet knows the
verifier only by browser-authenticated Origin. Signed requests carry an x5c chain and MUST pin
expected_origins; a request replayed from an unlisted origin is refused by the wallet. HAIP
requires signed; Chrome's primary mode is unsigned. Deliberate-mismatch test: to run.
[expected]
4. Response encryption
dc_api.jwt (JWE to a per-request ephemeral key) vs dc_api (cleartext). Deployment policy
(settled 2026-09-03): unsigned requests permitted, responses stay encrypted — a start request
asking for dc_api is refused 403 unencrypted_responses_disabled. That refusal is itself the
demonstration, and a positive one: as the explainer analysis puts it, a protocol that quietly
degrades when one party misconfigures itself will be misconfigured in production and nobody will
notice — this one stops. [verified — policy + live 403] Whether wallets accept the
unencrypted mode at all remains untested by design.
5. Invocation constraints
- User gesture required for
navigator.credentials.get— no background/redirect-chain invocation. - Secure context (HTTPS) required.
- Continuation stays in the invoking tab — a genuine positive for redirect-based connector UX.
- Iframe behaviour is governed by the
digital-credentials-getPermissions-Policy; cross-origin iframe test: to run. Embedded webviews (in-app browsers): to run. [expected]
6. Cross-device
Desktop Chrome offers a QR/CTAP-hybrid handoff to a phone wallet. With no physical Android device this is expected untestable in this rig (emulators lack the hybrid transport) and iPhones do not answer Chrome's hybrid flow — itself a deployment-relevant finding. Desktop Safari → iPhone: to run. [expected]
7. Error surfaces
What the page receives on user-cancel vs no-matching-credential vs no-wallet-installed — and whether they are distinguishable (privacy says they largely should not be). Recorded per platform in the matrix log. [expected]
8. Transcript and holder binding differences
Annex C binds the response to sha256(cbor([EncryptionInfo-as-string, origin])); OID4VP DC API
binds to sha256(cbor([origin, nonce, jwkThumbprint|null])) and makes the KB-JWT audience
origin:<origin> even for signed requests. Both verified against spec bytes in the idp-pf-vcs
conformance suite (G-32). [verified — conformance]
9. App-to-app
iOS exposes no native verifier-side DC API — a native verifier hosts the web requester in
ASWebAuthenticationSession. Android exposes GetDigitalCredentialOption to native apps
directly. [expected]
Android app-to-app, run empirically (emulator, GMS 24.23!): the loop is mechanically
complete — the system picker matched the wallet's registered mDL via the registry matcher and
displayed exactly the three requested claims; the wallet's consent sheet flagged the native
caller as "Unknown requester" (an app, unlike a browser, brings no authenticated web origin);
the response was built, JWE-decrypted by the verifier, and the mdoc's issuer chain verified. It
failed at precisely one point: the OpenID4VPDCAPIHandover origin — the wallet bound the
response to the calling app's identity while the verifier had pinned its web origin. The
browser hides the single-origin constraint; app-to-app exposes it. A native verifier must arm
the session with the app-identity origin string (apk-key-hash form) and the verifier must
allowlist it. Notably all of this worked on a mid-2024 GMS — the registry/picker plumbing is
older and more stable than the browser DC API surface. [verified — matrix, 2026-09-02]
9a. "No eligible IDs" on iOS — the reader leaf's AuthorityKeyIdentifier
A silent-failure class worth stating on its own, because it cost most of a day and had two distinct causes in sequence — a third recurrence should be treated as a new problem, not a regression of either.
For iOS Safari to offer a wallet document to a DC API request, the request's reader-auth leaf certificate must carry an AuthorityKeyIdentifier that iOS can match against an identifier the wallet registered for that document. Our deployed reader PKI was a pre-fix artifact whose leaf had no AKI, so every request carried nothing for iOS to match — the sheet read "Cannot Verify Identity — there are no eligible IDs available on your device", with no error anywhere in the chain. It was invisible because everything that does surface an error was fine: the credential was held, the reader root was trusted, the request was well-formed.
Correcting our own first diagnosis, because the wrong reason survives precisely when the fix works anyway: we initially attributed this to the reader root lacking a SubjectKeyIdentifier (root → nil keyIdentifier → dropped from the registered set). That inference was wrong — the wallet falls back to RFC 5280 §4.2.1.2 method (1), SHA-1 of the subjectPublicKey, when the SKI extension is absent, so the anchor was registered all along (a long-passing test proved it). The real cause is the leaf's missing AKI on the request side, not the root's missing SKI on the registration side. The reissue fixes both descriptions identically, which is exactly why the wrong one could have lived in a code comment for years.
The other, earlier cause of the same "no eligible IDs" string: the wallet only registered with iOS from a developer diagnostics screen, so a fresh install was never registered at all (fixed by registering on unlock and on save). Two real, distinct causes; one symptom.
Fix: reissue the reader PKI with SKI on the root and SKI+AKI on the leaf (both present in the current generator), set on the verifier, and re-pin the new root in the wallet's anchors. The verifying check is that the request leaf's AKI chains to the reader root's SKI. [verified — production verifier serves the fixed PKI; on-device picker retry pending]
10. Can the wallet be told who is REALLY asking? (connector-mediated flows)
The connector pattern makes the downstream RP invisible to the wallet — the consent screen shows the connector's origin. Whether the protocols can carry the downstream RP's identity distinctly:
- OID4VP (
openid4vp-v1-*):verifier_info(§5.1; supported over the DC API per §A.2) is the designed slot — signed attestations about the verifier, i.e. the EUDI registration-certificate machinery. It attests the CALLING verifier, not a distinct downstream party; no standard field says "on behalf of Acme". An ecosystem could profile a registration certificate to carry the downstream RP and a cooperating wallet could render "Acme, via ConnectID" — but the spec makes wallet use ofverifier_infodiscretionary, and the wallet MUST validate its signature and binding if it uses it. Partially yes, by profiling — demo-able on Android with our wallet. (transaction_datais the other rendered-by-wallet slot, but it binds transaction detail, not RP identity, and wallets that don't support it MUST reject requests carrying it.) org-iso-mdoc(Annex C, iOS): the wire carries only DeviceRequest + EncryptionInfo; the verifier's identity IS the ReaderAuth certificate. There is no field for a second party. iOS hands the provider extension the browser-authenticated origin, and mydigitalid's consent screen leads with exactly that (DcApiConsentView: "the origin is the single most important thing on this screen"). Hard no on today's iOS path short of abusing reader-certificate subject fields. [verified — code; on-device screenshot pending]
Architecture correction (Dave, 2026-09-03): ConnectID is an OpenID Federation Trust
Controller, not an exchange. The NASCAR sits with the RP; federation signing is what lets the
RP authenticate to the Connector; the Connector invokes the Trust Controller to get the DCQL
request signed for the requesting RP. So on Android the signed request names the RP (its
federation entity id under the openid_federation prefix, chain anchored at ConnectID) while the
Connector merely runs the ceremony — the RP is named to the wallet by the designed mechanism, and
ConnectID signs the request without ever seeing the response. This supersedes the ad-hoc
verifier_info idea above. iOS remains origin-only — the platform contrast stands.
Demo milestone: Trust Controller stub + federation-signed DC API requests + wallet chain
validation rendering "Wattlebrook Cellars · via ConnectID".
Demonstrated on device (2026-09-03). The stand-in Trust Controller signs the DCQL Request
Object for Wattlebrook with the RP's federation key (client_id = openid_federation:<rp>, chain [RP EC → Subordinate Statement → TA EC] in the JWS
trust_chain header, plus an x5c bridge because x509-first wallet stacks verify the JWS
against a leaf before any trust resolution). The verifier enforces parameter integrity on the
returned JAR — nonce, DCQL, origins, response mode and the whole client_metadata compared
canonically, so the delegated naming authority cannot redirect or unbind the ceremony. The
Android wallet walks the chain to the pinned anchor and its consent sheet renders
"Wattlebrook Cellars · via ConnectID (demo Trust Controller)" — "the application requesting
this data is trusted" — with the connector appearing nowhere. Caveat preserved on screen:
demo wallet, cooperating by construction; a platform wallet renders none of this today.
Same-run contrast: the identical ceremony under a connector-self-signed x509_hash request
renders "Unknown requester". The response leg on the app-to-app surface fails on the known
app-identity origin binding (§9) — the browser surface is the verifying path.
[verified — matrix, screenshots]
11. Minimal disclosure is the point, and the consent screen proves it
The sharpest thing the DC API + selective disclosure buys is visible on one screen: a proof-of-age
credential answers one bit — age_over_18: true — and the wallet's consent sheet shows exactly
that one line, no name, no date of birth, no document number. Beside it, the mDL cell asks for
three attributes because a licence check genuinely needs them. Same protocol, same wallet, two
consent screens: one line vs three fields, and the difference is the whole argument for the
technology.
The failure mode worth naming, because it's invisible on the wire and easy to reach for: a verifier
CAN request age_over_18 and given_name/family_name from the same credential (the catalogue
declares them), and it would build a valid request and a full response. But a verifier that asks for
the bit and the name has re-identified the holder while keeping the branding of not having done
so — the consent screen would then show three lines and the privacy claim would be theatre. The
demo deliberately requests only the boolean (REQUESTED_CLAIMS['age-over-18-sd-jwt'] = ['age_over_18']).
Minimal disclosure is a choice the verifier makes on every request, not a property the credential
enforces — which is exactly why it belongs on the consent screen where the holder can see it.
(Platform note: this credential is SD-JWT, so per finding #2 it is presentable via the DC API on
Android but not iOS — the iOS provider surface is mdoc-only. The equivalent iOS demonstration needs
the age-over-18-mdoc variant.) [verified — verifier requests only the boolean; Android
presentation in progress]
What review caught
Evidence the demo is not a happy-path toy: the guardian review of the verifier's federation
mode proved a real gap before landing it — the parameter-integrity check compared the
response-encryption key's kid label, which a hostile Trust Controller could keep while
substituting the key material or downgrading the advertised algorithms. The check was replaced
with a canonical whole-client_metadata comparison (keys sorted recursively, array order
preserved, so a TC that merely re-serialises is not read as tampering). The transcript's
thumbprint binding would still have failed the ceremony closed — but late and opaquely; the
review moved the refusal to where it names itself.
Spec ambiguities and gaps observed
Collected during implementation; see also idp-pf-vcs conformance/GAP-REGISTER.md G-32.
Recommendations
To be written last.