Post-quantum signing keys that never leave the Secure Enclave
DEV Community

Post-quantum signing keys that never leave the Secure Enclave

What iOS 26 gives you

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.

What Android gives you

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: Android does not give you ML-KEM

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.

Doing it in software without giving up

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 trap: a biometric prompt that proves nothing

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.

What I have not proven

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.

The plugin

MIT, iOS and Android, with a software fallback on the web for development:

npm i capacitor-pq-secure-storage
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.

Comments

No comments yet. Start the discussion.