{"slug": "post-quantum-signing-keys-that-never-leave-the-secure-enclave", "title": "Post-quantum signing keys that never leave the Secure Enclave", "summary": "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.", "body_md": "iOS 26 quietly added something I had been waiting for: the Secure Enclave can now hold ML-DSA keys.\n\nNot \"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.\n\nI 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.\n\nCryptoKit now ships `SecureEnclave.MLDSA65`\n\nand `SecureEnclave.MLDSA87`\n\n, with the same API shape as the long-standing `SecureEnclave.P256.Signing.PrivateKey`\n\n:\n\n``` js\nlet key = try SecureEnclave.MLDSA65.PrivateKey(accessControl: accessControl)\nlet publicKey = key.publicKey.rawRepresentation   // 1952 raw bytes, FIPS 204\nlet blob = key.dataRepresentation                  // encrypted SEP blob, NOT the key\n```\n\nTwo things matter here.\n\n`dataRepresentation`\n\nis 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\n\nkey material never exists in your process, so a memory dump gets you nothing.\n\nThe biometric gate lives in the key, not in the Keychain item. You pass a `SecAccessControl`\n\nat 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.\n\nThat is the whole iOS story. It works, and it is boring, which is the highest compliment you can pay a crypto API.\n\nAndroid's Keystore exposes ML-DSA to apps:\n\n```\nval kpg = KeyPairGenerator.getInstance(\"ML-DSA-65\", \"AndroidKeyStore\")\n```\n\nwith per-operation biometric auth:\n\n```\n.setUserAuthenticationRequired(true)\n.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)\n```\n\nThat `0`\n\nis 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`\n\nmeans a class-3 biometric only, with no fallback to the device PIN.\n\nSo far, symmetric with iOS. Now the part nobody mentions.\n\nML-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`\n\nfor an ML-KEM keypair. The algorithm is there, in the same secure hardware, and there is no door into it.\n\niOS 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.\n\nWhich 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.\n\nI picked software, with BouncyCastle:\n\n```\nval kpg = KeyPairGenerator.getInstance(\"ML-KEM\", bouncyCastle)\nkpg.initialize(MLKEMParameterSpec.ml_kem_1024)\n```\n\nand 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.\n\nThe 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`\n\n, and the Keystore will not do that without a biometric.\n\nThat is the easy half. The hard half is *how* you ask for the biometric.\n\nThe obvious way to gate an operation on Android is:\n\n```\nBiometricPrompt(activity, executor, object : AuthenticationCallback() {\n    override fun onAuthenticationSucceeded(result: AuthenticationResult) {\n        doTheSensitiveThing()     // <-- wrong\n    }\n}).authenticate(promptInfo)\n```\n\nThis 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`\n\nand 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.\n\nThe fix is to make the authentication *produce* something you cannot get any other way, rather than merely announce that it happened:\n\n```\nval cipher = Cipher.getInstance(\"AES/GCM/NoPadding\")\ncipher.init(Cipher.DECRYPT_MODE, wrapKey, GCMParameterSpec(128, iv))\n\nval cryptoObject = BiometricPrompt.CryptoObject(cipher)\n\nBiometricPrompt(activity, executor, object : AuthenticationCallback() {\n    override fun onAuthenticationSucceeded(result: AuthenticationResult) {\n        // use the cipher FROM the result, not the one from the enclosing scope\n        val unwrapped = result.cryptoObject!!.cipher!!.doFinal(encryptedPrivate)\n        // ... decapsulate, then zeroize\n    }\n}).authenticate(promptInfo, cryptoObject)\n```\n\nThe difference looks cosmetic and is not. `wrapKey`\n\nis a Keystore key with `setUserAuthenticationRequired(true)`\n\n. The Keystore will not authorize that `Cipher`\n\nuntil 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`\n\nis a cipher the Keystore never authorized, and `doFinal`\n\nthrows.\n\nThe 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.\n\nThe same pattern gates ML-DSA signing, via `CryptoObject(signature)`\n\nand `result.cryptoObject.signature`\n\n. Same idea: take the object the authentication handed you, never the one you were already holding.\n\nThe honest part, and the reason I am writing this instead of a launch announcement.\n\niOS 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.\n\nAndroid 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.\n\nThe plugin reports this at runtime rather than hiding it. `getHardwareCapabilities()`\n\nprobes the real security level of the key rather than gating on an API level, and returns `hardwareBacked: false`\n\nif KeyMint quietly fell back to software. If you build on this, gate your trust on that flag, not on the marketing.\n\nI would rather say this now than have someone find it later.\n\nMIT, iOS and Android, with a software fallback on the web for development:\n\n```\nnpm i capacitor-pq-secure-storage\njs\nimport { PqSecureStorage, SignatureType } from 'capacitor-pq-secure-storage';\n\nawait PqSecureStorage.generateKeyPair({ keyAlias: 'signing', type: SignatureType.MLDSA_65 });\n\nconst { signature } = await PqSecureStorage.sign({\n  keyAlias: 'signing',\n  type: SignatureType.MLDSA_65,\n  data: payload,\n  description: 'Approve transfer of 10 tokens',\n});  // prompts Face ID / fingerprint, every time\n```\n\nIt 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.\n\n[https://github.com/jimcase/capacitor-pq-secure-storage](https://github.com/jimcase/capacitor-pq-secure-storage)\n\nIf you have an Android 17 device with StrongBox and ten minutes, `DEVICE-VERIFICATION.md`\n\nhas the attestation checklist, and I would love the issue.", "url": "https://wpnews.pro/news/post-quantum-signing-keys-that-never-leave-the-secure-enclave", "canonical_source": "https://dev.to/jcaso/post-quantum-signing-keys-that-never-leave-the-secure-enclave-1g88", "published_at": "2026-07-14 14:38:13+00:00", "updated_at": "2026-07-14 14:59:40.170264+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Apple", "iOS 26", "Secure Enclave", "Android", "Android Keystore", "BouncyCastle", "Capacitor", "CryptoKit"], "alternates": {"html": "https://wpnews.pro/news/post-quantum-signing-keys-that-never-leave-the-secure-enclave", "markdown": "https://wpnews.pro/news/post-quantum-signing-keys-that-never-leave-the-secure-enclave.md", "text": "https://wpnews.pro/news/post-quantum-signing-keys-that-never-leave-the-secure-enclave.txt", "jsonld": "https://wpnews.pro/news/post-quantum-signing-keys-that-never-leave-the-secure-enclave.jsonld"}}