Post-quantum signing keys that never leave the Secure Enclave Apple's iOS 26 now supports ML-DSA keys in the Secure Enclave, with private keys generated and stored exclusively in hardware, never exposed to the application process. A developer built a Capacitor plugin for this feature but found that Android's Keystore, while supporting ML-DSA with biometric authentication, does not expose ML-KEM to app code, forcing a software implementation with BouncyCastle and encrypted key storage. 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 : js 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 https://github.com/advisories/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 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.