← Back to portfolio

Attested Secure Keys

A Flutter plugin that mints non-exportable signing keys inside the phone’s secure hardware — Android StrongBox/TEE or the iOS Secure Enclave — and produces a manufacturer-signed proof that the key was really born there, which your server can verify. Like flutter_secure_storage, but for keys, not data.

Platforms
Android · iOS
Keys
EC P-256 · ES256
Hardware
StrongBox · Secure Enclave
Proof
Server-verifiable
Role
Solo — API design, Android (Kotlin) & iOS (Swift) native, Dart facade
Status
v0.1.0-dev.1 — device-verified on Android & iOS
License
Apache-2.0 · open source

The brief

EU Digital Identity (EUDI) wallets — and most serious fintech and identity apps — have a hard requirement: the private key that binds a credential to its holder must live inside the device’s secure hardware, must be non-exportable, and the backend must be able to prove the key was actually born in hardware before it trusts anything signed with it.

This library began as the key layer for an EUDI-wallet proof-of-concept, then generalised so any app needing hardware-backed keys can reuse it. Its protocol companion — issuance, holding and selective presentation of credentials — lives in SD-JWT VC + OpenID4VC.

The gap it fills

As of mid-2026 no package on pub.dev exposed key attestation. The obvious candidate, flutter_secure_storage, solves a different problem: it encrypts data. A private key parked there is still a software key — it round-trips through your app’s memory every time you use it, so it can be lifted from a compromised process, and there is no way to prove to a server where it came from.

The fix is to never let the key exist in app memory at all. The OS-certified secure element generates it, holds it, and uses it — and the device manufacturer signs a certificate vouching that it did. This plugin wraps both platforms’ native APIs behind one small, honest Dart surface.

How it talks to the secure hardware

It’s a federated Flutter plugin. Your app depends only on the Dart facade; calls travel through a typed Pigeon channel (no stringly-typed maps) into first-party native code, which speaks directly to the hardware-backed keystore. Crucially, the boundary is one-way: the private key is created below the line and never crosses back up.

Call flow from the Flutter app down to the secure hardwareDARTYour Flutter appAttestedSecureKeys · facadegenerateKey · sign · attest · capabilitiesPlatform interfacecontract + normalized modelPigeon typed channelDart ⇄ Kotlin ⇄ SwiftNATIVEAndroid · Kotlin pluginfirst-party Keystore APIsiOS · Swift pluginfirst-party CryptoKit / SecuritySECURE HARDWARE — the private key never crosses this lineHSMAndroid KeystoreStrongBox / TEEiOS Secure Enclavehardware key store
Federated plugin layout. Your app depends only on the Dart facade, which routes through a typed Pigeon channel into first-party native code. Key generation, signing and attestation all happen inside the device's secure hardware — only handles, signatures (raw R‖S) and the manufacturer's attestation travel back up. The private key is non-exportable and never crosses the platform channel.

Proving the key was born in hardware

This is the whole point of the library. A key’s hardware origin is vouched for by the device manufacturer — Google’s Hardware Attestation Root on Android, Apple’s App Attest Root on iOS — not by the app and not by the library. The plugin generates the key in certified hardware and surfaces the manufacturer’s signed proof; your backend makes the trust decision.

How a hardware key becomes server-trusted1 · Key born in secure hardwarenon-exportable · bound to the server nonce2 · Manufacturer signs the proofAndroid: Google Hardware Attestation Root (X.509 chain)iOS: Apple App Attest Root3 · Your backend verifies the attestation✓ chain terminates at the genuine manufacturer root✓ nonce matches the challenge (anti-replay)✓ security level is StrongBox, TEE or Secure Enclave✓ attested public key equals the JWK you were sent4 · Trust decisionhardware-born key — bind it to the accountTrust anchor = the device manufacturer (Google / Apple), not the app or the OS.
A key's hardware origin is vouched for by the device manufacturer, not by the library. The attestation — an Android Keystore X.509 chain or an Apple App Attest object — is verified on your backend against the genuine Google / Apple roots. The client-reported security level is only a hint; the trust verdict is always made server-side.

End to end

Put together, a single server nonce ties the whole flow together: it’s bound into the key at generation, echoed back in the attestation (so a replay can’t be reused), and the same key later signs proofs-of-possession the server validates against the public key it bound at enrolment.

End-to-end sequence: app, plugin/HSM and serverYour appPlugin + HSMsecure hardwareYour server① challenge (server nonce)② generateKey(alias, nonce)mints non-exportable key③ attest(nonce)manufacturer-signed proof④ public JWK + attestationverify vs root → bind keyLATER — proof of possession⑤ sign(payload) · biometricHSM signs → raw R‖S⑥ signed proof-of-possession (JWS)verify signature with bound key
The full lifecycle. A server nonce is bound into the key at generation and echoed in the attestation, so the backend can prove freshness and tie the proof to one request. Once the public key is bound to the account, the same hardware key signs proofs-of-possession the server verifies against it.

What it does

A facade modeled on the ergonomics of flutter_secure_storage, with eight methods:

capabilities()
Probe what the device can actually do — StrongBox / TEE / Secure Enclave, attestation and biometric support, best achievable security level.
generateKey()
Mint a non-exportable EC P-256 key in the strongest available hardware. Fails closed if the requested security floor can't be met.
sign()
ES256-sign inside the chip; returns the signature as raw 64-byte R‖S bytes plus a base64url .jose form (JOSE / COSE ready). Fires the biometric prompt for auth-gated keys.
attest()
Return the key's manufacturer-signed proof bound to a server nonce — an Android Keystore X.509 chain or an iOS App Attest object.
getKeyInfo · containsKey
Inspect live key metadata, or check whether an alias exists.
deleteKey · listAliases
Permanently delete a key, or enumerate the aliases this library manages.
final keys = AttestedSecureKeys();

// 1 · Mint a non-exportable P-256 key in the strongest hardware
//     available, binding the server's nonce into the attestation.
final key = await keys.generateKey(
  alias: 'wallet.holderKey',
  minSecurityLevel: KeySecurityLevel.trustedEnvironment,
  userAuth: const UserAuthPolicy.perUseBiometric(),
  attestationChallenge: nonceFromServer,
);

// 2 · Hand the public key + manufacturer-signed proof to your backend.
final attestation =
    await keys.attest(alias: key.alias, serverNonce: nonceFromServer);
await api.registerWalletKey(jwk: key.publicJwk, attestation: attestation);

// 3 · Later: sign a proof-of-possession. The biometric prompt fires
//     automatically for an auth-gated key.
final sig = await keys.sign(alias: key.alias, payload: utf8.encode(jwt));
final jws = '$jwt.${sig.jose}';
Android
Hardware
StrongBox (API 28+) → TEE
Attestation
Keystore X.509 chain (API 24+)
Signature
DER → raw R‖S (JDK BigInteger)
Min OS
API 24
iOS
Hardware
Secure Enclave (iOS 13+)
Attestation
App Attest (iOS 14+)
Signature
native raw R‖S (no DER)
Min OS
iOS 13 / 14

Design decisions & honest trade-offs

First-party crypto only

Keys, signatures and attestation come solely from the platform’s own security frameworks (Android Keystore, Apple CryptoKit / Security / DeviceCheck) plus official libraries (Pigeon). No hand-rolled crypto, no heavyweight third-party dependency — the smallest, all-official surface, because this is the most security-sensitive part of the stack.

EC P-256 / ES256 only

P-256 is the cross-platform floor (the iOS Secure Enclave is P-256-only) and the EUDI mdoc / SD-JWT VC baseline. No RSA, P-384 or EdDSA — one curve, both platforms, zero ambiguity.

Trust is server-side

The client-reported security level is a UX hint, never a trust decision. A key only counts once your backend verifies its attestation against the genuine Google / Apple roots — chain, security level, freshness, and the echoed nonce.

Fails closed, reports honestly

Every result states the assurance it actually achieved; the library never silently downgrades. If biometric gating is requested but the OS doesn’t enforce it, generation fails rather than handing back an ungated key.

The iOS asymmetry

iOS has no per-key X.509 attestation. App Attest attests the app instance, so the library binds the Secure Enclave key by hashing its JWK thumbprint + the server nonce into the App Attest challenge — one normalized model papering over a real platform difference.

Not a certified WSCD

It provides hardware-backed keys and the manufacturer’s attestation artifacts; it is not a certified eIDAS Wallet Secure Cryptographic Device and makes no Level-of-Assurance claim. Certification (CC / EUCC) is the integrator’s responsibility — stated plainly, on purpose.

Status

Published to pub.dev as 0.1.0-dev.1 while the API soaks toward a stable 0.1.0. Both platforms are device-verified — Android (StrongBox / TEE attestation) on real hardware via Firebase Test Lab, and iOS (Secure Enclave + App Attest) on a physical iPhone, with the exported Android attestation decoded end-to-end to a genuine Google root. It’s the key layer of a working EUDI wallet. Open source under Apache-2.0.

Stack

FlutterDartPigeonKotlinAndroid KeystoreStrongBox / TEEandroidx.biometricSwiftCryptoKitSecure EnclaveApp AttestEC P-256 · ES256JOSE / COSEX.509 attestation