Docs
Compliance

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) where challenge = nonceBytes || SHA-256(canonicalJSON(attestedKeys)). KeyStore copies the bytes into the KeyDescription extension on the leaf cert (OID 1.3.6.1.4.1.11129.2.1.17).
  • iOS App Attest: the wallet calls attestKey(keyId, clientDataHash: SHA256(nonceBytes || SHA256(canonicalJSON(attestedKeys)))). Apple embeds SHA256(authData || clientDataHash) in the leaf cert's 1.2.840.113635.100.8.2 extension.

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:

PlatformSignalLevel
AndroidStrongBox or TrustedEnvironment (TEE)iso_18045_moderate
AndroidSoftware-onlyrejected
iOSSecure Enclave + App Attestiso_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 keyAttestation signs 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.attestationChallenge equals nonceBytes || SHA-256(canonicalJSON(attestedKeys)) (nonce bytes are the base64url-decoded server nonce).
  • softwareEnforced.attestationApplicationId lists the configured package name and at least one allowed signing-cert digest (WP_ANDROID_PACKAGE, WP_ANDROID_SIGNATURE_DIGESTS).
  • attestationSecurityLevel is StrongBox or TrustedEnvironment.

iOS App Attest

  • Attestation object's fmt is apple-appattest.
  • attStmt.x5c validates against the Apple App Attestation Root CA.
  • SHA-256(authData || clientDataHash) equals the leaf cert's 1.2.840.113635.100.8.2 extension value, where clientDataHash = SHA-256(nonceBytes || SHA-256(canonicalJSON(attestedKeys))).
  • SHA-256(leaf.publicKey raw uncompressed EC point) equals the wallet-supplied keyId.
  • authData.rpIdHash equals SHA-256(teamId.bundleId) (WP_IOS_TEAM_ID, WP_IOS_BUNDLE_ID).
  • authData.counter == 0 (initial attestation).
  • authData.aaguid is appattest (production); when allowDevelopmentAaguid is on, the development (appattestdevelop) and sandbox AAGUIDs are also accepted.
  • authData.credentialId equals 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.online is calendar-only.

See the operations runbook (apps/api/docs/WALLET_PROVIDER_RUNBOOK.md) for the CA ceremony, env wiring, deployment, monitoring and rotation procedures.