cd /news/developer-tools/post-quantum-signing-keys-that-never… · home topics developer-tools article
[ARTICLE · art-59048] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read6 min views1 publishedJul 14, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @apple 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/post-quantum-signing…] indexed:0 read:6min 2026-07-14 ·