Skip to content
ecdsa.com
← All articles

Hardware-backed keys: Secure Enclave, StrongBox, TPM, PIV

We created keys in this Mac's Secure Enclave, including post-quantum ones, and in a software TPM with attestation, and read the Android and Yubico attestation rules. The common promise is narrow and valuable: the key cannot be copied.

Short answer. A hardware-backed key is generated inside a separate chip or isolated processor and never leaves it in plaintext; your code holds a handle and asks the hardware to sign. That guarantees one thing well: the private key cannot be copied off the device, so a stolen disk image, backup or memory dump is useless. It does not stop malware on the same device from asking the hardware to sign, unless you require the user's presence or biometrics per use. Supported algorithms are few (P-256 everywhere, RSA on some, post-quantum ML-DSA and ML-KEM in Apple's Secure Enclave since the 26 releases). Attestation lets a server verify that a key really lives in such hardware, via a certificate chain to the manufacturer: Google's roots for Android, Yubico's for YubiKeys, the TPM vendor's for TPMs. Losing the device means losing the key, so design for enrollment of several keys and rotation from the start.

The platforms

Where the key livesTypical algorithmsAttestationUser checks
Apple Secure EnclaveA dedicated coprocessor in Apple siliconP-256; ML-KEM-768/1024 and ML-DSA-65/87 (CryptoKit, OS 26+)App Attest on iOS; none for arbitrary keysTouch ID, Face ID, passcode via access control
Android Keystore, TEEThe trusted execution environment of the main SoCRSA, EC, AES, HMACKey attestation to Google rootsLock screen credential or biometric per use or per time
Android StrongBoxAn embedded secure element or integrated secure enclave (Android 9+)RSA 2048, P-256, AES, HMAC-SHA256, 3DESSame, with security level StrongBoxSame
TPM 2.0A discrete chip, firmware TPM or virtual TPMRSA 2048+, P-256, P-384 (vendor-dependent)AK certified by the EK, EK certificate from the vendorAuthorization values and policies
YubiKey PIVA smart card applet on the tokenRSA 1024/2048, P-256, P-384; RSA 3072/4096, Ed25519 and X25519 from firmware 5.7Attestation certificate from slot f9, chained to YubicoPIN policy and touch policy per key
Cloud HSMProvider-operated HSMsSee signing with cloud KMSGoogle Cloud HSM attestation statementsIAM, not people

Passkeys are the most common use of all of this: a platform authenticator is one of the first four rows with a WebAuthn interface (passkeys for developers).

Apple Secure Enclave, tested on this Mac

The Secure Enclave is Apple's isolated key processor. Through CryptoKit a key is one line. We ran this on an Apple M4 Max with macOS 27.0.1 and Swift 6.4, as a plain command-line tool without special entitlements:

main.swift: a Secure Enclave P-256 key next to a software key
// Secure Enclave from a command-line tool: create a P-256 signing key, sign, export the public key,
// and look at what the "private key" object actually contains.
import CryptoKit
import Foundation

print("SecureEnclave.isAvailable: \(SecureEnclave.isAvailable)")

let key = try SecureEnclave.P256.Signing.PrivateKey()
let message = Data("release 1.4.2".utf8)
let signature = try key.signature(for: message)

print("public key (x9.63): \(key.publicKey.x963Representation.count) bytes")
print("signature (DER): \(signature.derRepresentation.count) bytes, raw r||s: \(signature.rawRepresentation.count) bytes")
print("verifies with the public key: \(key.publicKey.isValidSignature(signature, for: message))")

// dataRepresentation is an opaque blob: the key encrypted by the Secure Enclave for this device.
let blob = key.dataRepresentation
print("dataRepresentation: \(blob.count) bytes (opaque, usable only by this device's Secure Enclave)")
let restored = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob)
print("restored from the blob signs too: \(restored.publicKey.isValidSignature(try restored.signature(for: message), for: message))")

// A software key from the same API family, for comparison: its raw bytes are the private scalar.
let software = P256.Signing.PrivateKey()
print("software key rawRepresentation: \(software.rawRepresentation.count) bytes (the private scalar itself)")
Output (macOS 27.0.1, Apple M4 Max; the PEM public key is omitted)
SecureEnclave.isAvailable: true
public key (x9.63): 65 bytes
signature (DER): 71 bytes, raw r||s: 64 bytes
verifies with the public key: true
dataRepresentation: 284 bytes (opaque, usable only by this device's Secure Enclave)
restored from the blob signs too: true
software key rawRepresentation: 32 bytes (the private scalar itself)

The 284-byte dataRepresentation is what an app stores (in the keychain or a file) to use the key again later; it is wrapped by the Secure Enclave and only that device's enclave can use it. There is no way to ask for the private scalar at all: code that tries does not compile.

swiftc on a file that reads key.rawRepresentation from a Secure Enclave key
export.swift:5:11: error: value of type 'SecureEnclave.P256.Signing.PrivateKey' has no member 'rawRepresentation'

Apple's guide Protecting keys with the Secure Enclave, written for the Security framework's SecKey API, still says it "Works only with NIST P-256 elliptic curve keys". CryptoKit went further: SecureEnclave.MLDSA65, MLDSA87, MLKEM768 and MLKEM1024 are available from iOS and macOS 26. On this Mac:

pq.swift: post-quantum keys in the Secure Enclave
let mldsa = try SecureEnclave.MLDSA65.PrivateKey()
let signature = try mldsa.signature(for: message)
print("ML-DSA-65 in the Secure Enclave: public key \(mldsa.publicKey.rawRepresentation.count) bytes, signature \(signature.count) bytes")
print("verifies: \(mldsa.publicKey.isValidSignature(signature, for: message))")

let mlkem = try SecureEnclave.MLKEM768.PrivateKey()
let encapsulated = try mlkem.publicKey.encapsulate()
let shared = try mlkem.decapsulate(encapsulated.encapsulated)
print("ML-KEM-768 in the Secure Enclave: ciphertext \(encapsulated.encapsulated.count) bytes, shared secrets match: \(shared == encapsulated.sharedSecret)")
Output
ML-DSA-65 in the Secure Enclave: public key 1952 bytes, signature 3309 bytes
verifies: true
ML-KEM-768 in the Secure Enclave: ciphertext 1088 bytes, shared secrets match: true

The sizes are the FIPS 204 and FIPS 203 values. For arbitrary keys Apple offers no attestation that a server could check; on iOS, App Attest (DeviceCheck) attests the app and a Secure Enclave key it creates for that purpose.

Android Keystore: TEE and StrongBox

On Android, keys generated in the Android Keystore stay "non-exportable": "Key material never enters the application process", and when the device's secure hardware supports the key's algorithm and parameters, "its key material is never exposed outside of secure hardware." Two levels of hardware exist:

  • TEE (KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT): an isolated environment on the main processor.
  • StrongBox (Android 9 and later, setIsStrongBoxBacked(true)): "embedded SEs or integrated Secure Enclaves (iSE), providing stronger isolation and tamper resistance compared to the TEE", with its own CPU, storage, random number generator and secure timer. It supports only "RSA 2048, AES 128 and 256, ECDSA, ECDH P-256, HMAC-SHA256" and Triple DES, and Google warns that it "is slower, more resource-constrained" and "For most apps, StrongBox is not necessary." An unsupported request throws StrongBoxUnavailableException.

Check where a key ended up with KeyInfo.getSecurityLevel() (API 31+; isInsideSecureHardware() on older versions), and bind use to the user with setUserAuthenticationParameters(), either per operation or for a time window after unlock. One caveat from the same page: hardware "doesn't usually enforce temporal validity interval authorizations, because it normally doesn't have an independent, secure real-time clock."

Key attestation proves it to your server. The device returns a certificate chain for the key; the first certificate carrying the attestation extension (OID 1.3.6.1.4.1.11129.2.1.17) describes the key, its properties and its security level. The key attestation rules worth copying into your verifier:

  • Verify on a server, never on the device itself.
  • The root must be one of Google's attestation roots, published at android.googleapis.com/attestation/root. An ECDSA P-384 root, "Key Attestation CA1", "will begin signing attestation certificate chains on February 1, 2026", next to the long-standing RSA root; accept both.
  • Check every certificate against Google's revocation list at android.googleapis.com/attestation/status.
  • "Only the first occurrence of the extension in the chain can be trusted"; later ones may come from an attacker extending the chain.
  • Require attestationSecurityLevel of TrustedEnvironment or StrongBox; a chain to any other root, or a software attestation, says nothing about hardware.

Google recommends its Kotlin verification library over custom verifiers. Pixel's C2PA camera signing is an example built on this: claim signing keys are "generated and stored using Android StrongBox in the Titan M2 security chip", and certificates are issued only after key attestation (C2PA Content Credentials).

TPM 2.0: keys, policies and attestation, in a software TPM

A TPM is the PC and server equivalent. To show its mechanics without hardware, the lab ran swtpm 0.10.2 (a software TPM, which by construction protects nothing) with tpm2-tools 5.8 in debian:forky-20261005-slim. A signing key is created under a primary key with attributes that bind it to this TPM:

lab.sh (excerpt): a non-exportable P-256 signing key, a signature, OpenSSL verification
tpm2_createprimary -Q -C o -g sha256 -G ecc256 -c primary.ctx
tpm2_create -Q -C primary.ctx -G ecc256:ecdsa-sha256 -a "fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign" -u sig.pub -r sig.priv
tpm2_load -Q -C primary.ctx -u sig.pub -r sig.priv -c sig.ctx
tpm2_readpublic -c sig.ctx -f pem -o sig.pem
tpm2_sign -Q -c sig.ctx -g sha256 -f plain -o msg.sig msg.txt
openssl dgst -sha256 -verify sig.pem -signature msg.sig msg.txt
Output (filtered)
attributes:
  value: fixedtpm|fixedparent|sensitivedataorigin|userwithauth|sign
sig.priv: 128 bytes (encrypted by the TPM, usable only under this parent)
Verified OK

fixedtpm and fixedparent mean the key can never be duplicated to another TPM or parent; sensitivedataorigin that the TPM generated it. sig.priv is the key encrypted under the parent, safe to store on disk. The signature is ordinary ECDSA in DER, verified by OpenSSL with the exported public key.

Attestation uses a separate attestation key (AK) with the restricted attribute. tpm2_certify makes the AK sign a statement about another key, including the verifier's nonce:

lab.sh (excerpt): certify the signing key with a restricted attestation key
tpm2_create -Q -C primary.ctx -G ecc256:ecdsa-sha256:null -a "fixedtpm|fixedparent|sensitivedataorigin|userwithauth|restricted|sign" -u ak.pub -r ak.priv
tpm2_load -Q -C primary.ctx -u ak.pub -r ak.priv -c ak.ctx
tpm2_readpublic -Q -c ak.ctx -f pem -o ak.pem
printf 'server-nonce-7f3a' > nonce.bin
tpm2_certify -Q -c sig.ctx -C ak.ctx -g sha256 -q nonce.bin -o attest.bin -s attest.sig -f plain
openssl dgst -sha256 -verify ak.pem -signature attest.sig attest.bin
tpm2_print -t TPMS_ATTEST attest.bin
Output (filtered)
Verified OK
magic: ff544347
type: 8017
extraData: 7365727665722d6e6f6e63652d37663361
    name: 000b2d7a257558828e1caf174b67daabff261f8c02077768266be3cdc58015de4bee
    qualifiedName: 000b6d68f45fa311b20dc86b37e1659c1f06055e5641aa6a03e28cbb2caad6646fcd
name of the signing key: 000b2d7a257558828e1caf174b67daabff261f8c02077768266be3cdc58015de4bee

The structure starts with the magic 0xff544347 ("TPM_GENERATED"), type 0x8017 is a certify statement, extraData is our nonce in hex, and name is the hash of the certified key's public area and attributes: it matches the signing key. Why trust it? Because a restricted key refuses to sign data that starts with the TPM_GENERATED magic unless the TPM produced that data itself:

The same attestation key: ordinary data, then a forged attestation (one byte changed)
ordinary data (msg.txt): signed by the attestation key
ERROR: Esys_Sign(0x3E0) - tpm:parameter(3):invalid ticket

In a real deployment the chain continues: the AK is tied to the TPM's endorsement key, whose certificate the TPM vendor issued, and the verifier checks that chain; WebAuthn's tpm attestation statement format works this way. A software TPM has no such certificate, which is exactly why its attestation must not be trusted.

YubiKey PIV and attestation

A YubiKey's PIV applet stores keys in slots (9a authentication, 9c signature, 9d key management, 9e card authentication) with a PIN policy and a touch policy fixed at generation time. PIV attestation shows "that a certain asymmetric key has been generated on device and not imported": the YubiKey signs a certificate for the key with its attestation key in slot f9, which comes with a certificate from a Yubico PIV CA. The commands, from yubico-piv-tool 2.7.3 (we have no token in the lab, so only its --help was checked):

yubico-piv-tool (not run against a token)
yubico-piv-tool -a generate -s 9c -A ECCP256 --pin-policy=always --touch-policy=always -o 9c.pub
yubico-piv-tool -a attest -s 9c > 9c-attest.pem
yubico-piv-tool -a read-certificate -s f9 > f9-intermediate.pem

The attestation certificate carries Yubico extensions: firmware version (1.3.6.1.4.1.41482.3.3), serial number (…3.7), PIN and touch policy (…3.8), form factor (…3.9) and FIPS or CSPN status (…3.10, …3.11). A verifier checks the chain from the slot certificate through the f9 certificate to the Yubico root, then reads the policies: "touch always" means a person was physically present for every signature. Yubico is rotating its root: "YubiKeys with firmware version 5.7.4 and later will be signed by our new root CA", so verifiers need both roots. The CA/Browser Forum accepts exactly this kind of proof for code signing keys (Authenticode), and OpenSSH uses the same tokens through FIDO2 instead (ssh-keygen).

What hardware keys do not give you

  • Protection from code running as the user. Malware on the device can ask the hardware to sign; it just cannot take the key with it. Require user presence (touch), user verification (PIN, biometric) or a confirmation for keys that matter.
  • Availability. A lost, wiped or broken device takes the key with it, and clearing a TPM (which some firmware updates and reinstalls do) invalidates the keys under it. Register at least two authenticators, keep a recovery path, and plan rotation.
  • Algorithm agility. You get what the chip implements. Many platforms still offer only P-256 and RSA; if a protocol needs Ed25519 or secp256k1, check before you design around the hardware.
  • Trust in what the hardware signs. A secure key signs whatever the operating system feeds it; the screen showing "approve payment" belongs to the same possibly compromised OS. Hardware wallets with their own screen exist for this reason.
  • Proof without attestation. If your server does not verify an attestation chain, "the key is in hardware" is just what the client says.

Checklist for using hardware keys

  1. Generate keys on the device; never import a key you want to call hardware-backed.
  2. Choose the level you need: Secure Enclave or TEE for most apps, StrongBox or a secure element for high-value keys, an HSM for server keys.
  3. Bind use to the user (biometric or PIN per use) where a signature authorizes something.
  4. Verify attestation on the server: roots (current and rotated), revocation, nonce, security level, key properties.
  5. Store only public keys and opaque handles; expect keys to disappear and support re-enrollment.

Sources