iOS 26 quietly added something I had been waiting for: the Secure Enclave can now hold ML-DSA keys.
Not "the app does lattice math and stores a blob in the Keychain". The private key is generated inside the SEP, never leaves it, and every signature is gated by Face ID at the hardware level.
I built a Capacitor plugin on top of it, and the interesting part was not iOS. It was discovering that Android gives you exactly half of what you need, and working out what to do about the other half.
CryptoKit now ships SecureEnclave.MLDSA65
and SecureEnclave.MLDSA87
, with the same API shape as the long-standing SecureEnclave.P256.Signing.PrivateKey
:
let key = try SecureEnclave.MLDSA65.PrivateKey(accessControl: accessControl)
let publicKey = key.publicKey.rawRepresentation // 1952 raw bytes, FIPS 204
let blob = key.dataRepresentation // encrypted SEP blob, NOT the key
Two things matter here.
dataRepresentation
is not the private key. It is an opaque blob that only this device's SEP can reload. You persist it in the Keychain, and on the next launch you hand it back to the enclave. The
key material never exists in your process, so a memory dump gets you nothing.
The biometric gate lives in the key, not in the Keychain item. You pass a SecAccessControl
at creation time, and from then on the SEP itself refuses to sign without a fresh biometric. It is not your code checking a flag and deciding to proceed. There is no flag to check.
That is the whole iOS story. It works, and it is boring, which is the highest compliment you can pay a crypto API.
Android's Keystore exposes ML-DSA to apps:
val kpg = KeyPairGenerator.getInstance("ML-DSA-65", "AndroidKeyStore")
with per-operation biometric auth:
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
That 0
is the timeout in seconds. Zero means every single operation needs its own fresh authentication: no 15-second grace window, no reusing an earlier unlock. AUTH_BIOMETRIC_STRONG
means a class-3 biometric only, with no fallback to the device PIN.
So far, symmetric with iOS. Now the part nobody mentions.
ML-KEM (FIPS 203, the key encapsulation half of the NIST post-quantum suite) is in Android. KeyMint uses it. Attestation uses it. TLS uses it. It is simply not exposed to app code through the Keystore API. You cannot ask AndroidKeyStore
for an ML-KEM keypair. The algorithm is there, in the same secure hardware, and there is no door into it.
iOS decapsulates ML-KEM inside the Secure Enclave. Android will not. That asymmetry is not a bug in your code and there is no flag that fixes it.
Which leaves you with an unpleasant choice. Either you drop ML-KEM on Android, or you do it in software and accept that the private key exists, at some point, as bytes in your process.
I picked software, with BouncyCastle:
val kpg = KeyPairGenerator.getInstance("ML-KEM", bouncyCastle)
kpg.initialize(MLKEMParameterSpec.ml_kem_1024)
and then spent the real effort on making the private key useless to an attacker who is not standing in front of the phone with the right face.
The private key is never stored in the clear. It is encrypted (AES-256-GCM) with a key that lives in the Keystore, in the TEE, and that key is itself auth-required. So the stored ML-KEM private is inert: to decrypt it you need the Keystore to hand you a working Cipher
, and the Keystore will not do that without a biometric.
That is the easy half. The hard half is how you ask for the biometric.
The obvious way to gate an operation on Android is:
BiometricPrompt(activity, executor, object : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
doTheSensitiveThing() // <-- wrong
}
}).authenticate(promptInfo)
This is a callback. It is a function pointer in your process. On a rooted device, or under an instrumentation framework like Frida, an attacker hooks onAuthenticationSucceeded
and calls it. No face, no finger, no prompt. Your sensitive thing happens anyway. This is GHSA-vx5f-vmr6-32wf, and a surprising amount of shipping code has this shape.
The fix is to make the authentication produce something you cannot get any other way, rather than merely announce that it happened:
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey, GCMParameterSpec(128, iv))
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
BiometricPrompt(activity, executor, object : AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
// use the cipher FROM the result, not the one from the enclosing scope
val unwrapped = result.cryptoObject!!.cipher!!.doFinal(encryptedPrivate)
// ... decapsulate, then zeroize
}
}).authenticate(promptInfo, cryptoObject)
The difference looks cosmetic and is not. wrapKey
is a Keystore key with setUserAuthenticationRequired(true)
. The Keystore will not authorize that Cipher
until the biometric hardware tells it, out of band, that a real match happened. If you hook the callback and call it yourself, result.cryptoObject.cipher
is a cipher the Keystore never authorized, and doFinal
throws.
The biometric is no longer a boolean you can flip. It is the thing that unlocks the key, and the TEE is the one checking, not you.
The same pattern gates ML-DSA signing, via CryptoObject(signature)
and result.cryptoObject.signature
. Same idea: take the object the authentication handed you, never the one you were already holding.
The honest part, and the reason I am writing this instead of a launch announcement.
iOS is verified on hardware: an iPhone 15 Pro on iOS 26 generates the ML-DSA key in the enclave with no "algorithm not supported", prompts Face ID on every single signature, and the signatures verify off-device against the raw public key.
Android is not. Everything above compiles and runs, but only on an emulator, where KeyMint is a software implementation. The attestation chain that would prove the key really sits in StrongBox or the TEE has not been checked on a physical device, because Android 17 hardware is not in my hands yet. So the code is right and the guarantee is unproven, and those are different things.
The plugin reports this at runtime rather than hiding it. getHardwareCapabilities()
probes the real security level of the key rather than gating on an API level, and returns hardwareBacked: false
if KeyMint quietly fell back to software. If you build on this, gate your trust on that flag, not on the marketing.
I would rather say this now than have someone find it later.
MIT, iOS and Android, with a software fallback on the web for development:
npm i capacitor-pq-secure-storage
js
import { PqSecureStorage, SignatureType } from 'capacitor-pq-secure-storage';
await PqSecureStorage.generateKeyPair({ keyAlias: 'signing', type: SignatureType.MLDSA_65 });
const { signature } = await PqSecureStorage.sign({
keyAlias: 'signing',
type: SignatureType.MLDSA_65,
data: payload,
description: 'Approve transfer of 10 tokens',
}); // prompts Face ID / fingerprint, every time
It also does ML-KEM, AES-256-GCM at rest, ECDSA P-256, Ed25519, and a biometric-gated key-value store, but those are the boring parts.
https://github.com/jimcase/capacitor-pq-secure-storage
If you have an Android 17 device with StrongBox and ten minutes, DEVICE-VERIFICATION.md
has the attestation checklist, and I would love the issue.