Wallet Attestation (OID4VCI §A.7)
How the Verifiables wallet proves to issuers that its keys are hardware-backed
What it does
The wallet attestation flow lets the Verifiables wallet present a short-lived JWT to credential issuers that vouches for two facts about each wallet-bound key:
- The key lives in a hardware-backed secure environment (Apple Secure Enclave on iOS, Android StrongBox or TEE on Android).
- The wallet provider (Verifiables) has verified the device attestation chain that establishes it.
This is the mechanism defined in OID4VCI 1.0 Appendix
A.7.
Strict issuers (the EUDIW Large-Scale Pilots, france2026.mdoc.online,
and similar) accept proofs.attestation: [<jwt>] and reject the
self-signed proofs.jwt path. Wallet attestation is what unlocks those
issuers.
Trust model
┌──────────────────────────────┐
│ Verifiables Wallet Provider │
│ ┌──────────────────────────┐ │
│ │ Offline root CA (10y) │ │ ← Trust anchor distributed to issuers.
│ └─────────────┬────────────┘ │ Private key never online.
│ │ signs │
│ ┌─────────────▼────────────┐ │
│ │ Online intermediate CA │ │ ← Scaleway KMS. Signs every
│ │ (3y, KMS-backed) │ │ attestation JWT.
│ └─────────────┬────────────┘ │
└───────────────┼──────────────┘
│ signs
▼
┌────────────────────┐
│ §A.7 JWT │ ← Wallet attaches this to OID4VCI
│ attested_keys:[…] │ credential requests.
│ key_storage:[…] │
│ user_auth:[…] │
│ x5c: [int, root] │
└────────────────────┘Issuers register the root certificate (published at
https://api.verifiables.com/.well-known/wallet-provider/root.cer) as
a trust anchor in their wallet provider trust list. The JWT carries
x5c: [intermediate, root] in its protected header, so verification is
self-contained.
Protocol
Three round-trips per fresh key:
┌─────────┐ ┌──────────────────┐ ┌────────┐
│ Wallet │ │ Wallet Provider │ │ Issuer │
└────┬────┘ └────────┬─────────┘ └────┬───┘
│ │ │
│ ── POST /nonce ───────────────▶│ │
│ │ │
│ ◀──── { nonce, expires_in } ───│ │
│ │ │
│ ── platform attestation │ │
│ binds nonce internally │ │
│ │ │
│ ── POST /attestation { … } ───▶│ │
│ │ Verifies chain, nonce, │
│ │ app id, revocation list. │
│ │ Signs §A.7 JWT. │
│ ◀──── { attestation_jwt } ─────│ │
│ │ │
│ ── OID4VCI credential request: │
│ proofs.attestation: [<jwt>] ▶│
│ │Nonce binding
The wallet provider's nonce is the anchor against replays. The wallet provider issues a base64url-encoded 32-byte nonce, stores it in Redis with a 5-minute TTL, and consumes it (single-use) on the attestation request. The platform attestation must embed both the nonce bytes and a hash of the attested keys, so the wallet commits to the exact JWK list at key-generation time and cannot swap it later:
- Android KeyStore: the wallet generates the key with
KeyGenParameterSpec.Builder.setAttestationChallenge(challenge)wherechallenge = nonceBytes || SHA-256(canonicalJSON(attestedKeys)). KeyStore copies the bytes into theKeyDescriptionextension on the leaf cert (OID1.3.6.1.4.1.11129.2.1.17). - iOS App Attest: the wallet calls
attestKey(keyId, clientDataHash: SHA256(nonceBytes || SHA256(canonicalJSON(attestedKeys)))). Apple embedsSHA256(authData || clientDataHash)in the leaf cert's1.2.840.113635.100.8.2extension.
The server re-derives the expected value from the nonce and the
supplied attestedKeys and compares.
iso_18045 levels
The signed JWT carries key_storage and user_authentication arrays
of ISO 18045 strings. The wallet provider derives them from the
platform attestation:
| Platform | Signal | Level |
|---|---|---|
| Android | StrongBox or TrustedEnvironment (TEE) | iso_18045_moderate |
| Android | Software-only | rejected |
| iOS | Secure Enclave + App Attest | iso_18045_moderate |
On Android the level is derived from attestationSecurityLevel alone:
StrongBox and TEE both map to iso_18045_moderate unconditionally, and
software-only attestations are rejected. Differentiating by user-auth
gating (for example downgrading a TEE key with no biometric/PIN
requirement to iso_18045_enhanced-basic via userAuthType) is not
implemented yet; it is captured as a TODO in the verifier.
CC EAL-evaluated iso_18045_high requires a formal six-month
evaluation; not currently in scope.
Verification checks
The POST /wallet-provider/attestation handler runs the following
checks before signing:
Android KeyStore
- Each cert in
keyAttestationsigns the next. - Top of chain anchors to a known Google attestation root
(
WP_GOOGLE_ATTESTATION_ROOTS). - No cert in the chain is on Google's revocation list (cached 1 h fresh / 24 h stale, fails closed past staleness).
- Leaf cert's
KeyDescription.attestationChallengeequalsnonceBytes || SHA-256(canonicalJSON(attestedKeys))(nonce bytes are the base64url-decoded server nonce). softwareEnforced.attestationApplicationIdlists the configured package name and at least one allowed signing-cert digest (WP_ANDROID_PACKAGE,WP_ANDROID_SIGNATURE_DIGESTS).attestationSecurityLevelis StrongBox or TrustedEnvironment.
iOS App Attest
- Attestation object's
fmtisapple-appattest. attStmt.x5cvalidates against the Apple App Attestation Root CA.SHA-256(authData || clientDataHash)equals the leaf cert's1.2.840.113635.100.8.2extension value, whereclientDataHash = SHA-256(nonceBytes || SHA-256(canonicalJSON(attestedKeys))).SHA-256(leaf.publicKey raw uncompressed EC point)equals the wallet-supplied keyId.authData.rpIdHashequalsSHA-256(teamId.bundleId)(WP_IOS_TEAM_ID,WP_IOS_BUNDLE_ID).authData.counter == 0(initial attestation).authData.aaguidisappattest(production); whenallowDevelopmentAaguidis on, the development (appattestdevelop) and sandbox AAGUIDs are also accepted.authData.credentialIdequals the wallet-supplied keyId.
Failures return 400 {"error":"attestation_verification_failed", …}.
Bad / replayed nonces return 400 {"error":"invalid_nonce", …} so the
wallet can distinguish "retry me" from "this device is wrong".
JWT shape
{
"alg": "ES256",
"typ": "key-attestation+jwt",
"x5c": ["<base64 DER intermediate>", "<base64 DER root>"]
}
.
{
"iss": "https://api.verifiables.com/wallet-provider",
"iat": 1735689600,
"exp": 1735776000,
"attested_keys": [
{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }
],
"key_storage": ["iso_18045_moderate"],
"user_authentication": ["iso_18045_moderate"],
"nonce": "<issuer c_nonce, or the wallet-provider nonce>"
}The header typ is key-attestation+jwt, as required for the OID4VCI
§A.7 key attestation proof type; issuers reject the OAuth client
attestation type wallet-attestation+jwt on this path.
The nonce claim carries the OID4VCI issuer's c_nonce when the
wallet supplies issuer_nonce in the attestation request (so the
issuer's nonce store recognises it), and falls back to the
wallet-provider nonce for non-OID4VCI callers that omit it.
24-hour exp by default. The mobile wallet caches the JWT per signing
key in SQLCipher and refreshes ~1 min before expiry.
Attestations the issuer receives
Everything above describes the attestations Verifiables signs as a
wallet provider. The issuer also receives them, from any wallet: the
§A.7 key attestation in proofs.attestation, and the OAuth client
attestation at the token endpoint.
The signature on those is checked against the key the JWT itself
carries. Whether that signer is a wallet provider the deployment trusts
is a separate question, answered under a setting with three positions:
off, log (evaluate and record, the default) and enforce (refuse a
signer that cannot be anchored). The default is log because few
wallets carry an attestation at all today.
The position is per issuing organization, resolved from the credential being requested, and falls back to the platform default for an organization that has not set one. Anchors are platform-wide.
Anchoring walks the x5c chain in the attestation header: every link's
signature and validity window must hold, and the chain must reach a
configured anchor. Anchors are the deployment's own wallet provider CA
plus any third-party certificates the operator registers. A provider
that publishes no chain can be matched on its iss claim through an
allowlist instead, which proves less and is recorded as such.
What's not covered
The first-pass implementation deliberately leaves a few items for later:
- Per-org attestation levels (which iso_18045 levels are acceptable per caller). The trust mode is per organization, but the level itself is still derived globally from the attestation.
- Dashboard view of issued attestations. Today, monitoring is via
application logs (
[wallet-provider]prefix). - Trust-list registration with issuers beyond
france2026.mdoc.onlineis calendar-only.
See the operations runbook
(apps/api/docs/WALLET_PROVIDER_RUNBOOK.md) for the CA ceremony, env
wiring, deployment, monitoring and rotation procedures.