# CryptoKit: Signatures, Key Agreement, HPKE and Post-Quantum

Topics: digital signatures (ECDSA, EdDSA), Diffie-Hellman over elliptic curves,
hybrid public-key encryption from iOS 17,
ML-KEM and ML-DSA with hybrid migration (iOS 26+), key serialization, and where the
Secure Enclave fits.

CryptoKit's types carry several rules for you: a signing key cannot perform key
agreement, a shared secret cannot be used as a key until it is run through a KDF,
and the only classical curve the Secure Enclave accepts is P-256.

Background: CryptoKit arrived at WWDC 2019 (session 709) as a Swift replacement for
the C-level `SecKey` API. It sits on corecrypto and zeroes private key memory on
deallocation. iOS 14 added PEM/DER import and export plus standalone HKDF. iOS 17
added HPKE (RFC 9180). iOS 26 (WWDC 2025 session 314) added formally verified
post-quantum algorithms and turned on post-quantum TLS by default.

## Contents

- [Picking a curve or algorithm](#picking-a-curve-or-algorithm)
- [Signing and key agreement are different types](#signing-and-key-agreement-are-different-types)
- [Key agreement always goes through a KDF](#key-agreement-always-goes-through-a-kdf)
- [HPKE (iOS 17+)](#hpke-ios-17)
- [Post-quantum cryptography (iOS 26+)](#post-quantum-cryptography-ios-26)
- [PEM and DER (iOS 14+)](#pem-and-der-ios-14)
- [The Secure Enclave, briefly](#the-secure-enclave-briefly)
- [RSA](#rsa)
- [Frequent mistakes](#frequent-mistakes)
- [Availability](#availability)
- [Performance and threads](#performance-and-threads)
- [Sources](#sources)
- [Summary](#summary)
- [Checklist](#checklist)

## Picking a curve or algorithm

Two errors show up often: suggesting Curve25519 when the key must live in the
Secure Enclave, and suggesting P-256 when the goal is simple constant-time software
keys.

- **P256 (secp256r1).** The only classical curve the Secure Enclave supports, so
  required for hardware keys with a biometric access control. NIST FIPS 186-5, the
  widest TLS, X.509 and server interop. Raw public key 64 bytes (uncompressed x||y),
  raw signature 64 bytes (r||s). PEM/DER from iOS 14.
- **Curve25519** (X25519 for agreement, Ed25519 for signing). The default for
  software-only keys: fixed, rigid parameters, constant time by construction, no
  point validation to forget, 32-byte public keys. Only `rawRepresentation` exists
  (no PEM, DER or x963). Not available in the Secure Enclave.
- **P384** and **P521**, roughly 192-bit and 256-bit strength (NIST levels 3 and 5).
  They mirror the P256 API. Pick them only because an external standard or auditor
  insists.
- **ML-KEM-768** (roughly AES-128 strength) and **ML-KEM-1024** (roughly AES-192).
  Lattice KEM from FIPS 203. Secure Enclave capable on iOS 26+.
- **ML-DSA-65** (roughly AES-128) and **ML-DSA-87** (roughly AES-192). Lattice
  signatures from FIPS 204. Secure Enclave capable on iOS 26+.
- **X-Wing** (`XWingMLKEM768X25519`). A hybrid of ML-KEM-768 and X25519; an attacker
  must break both. Apple's recommended path for migrating custom protocols, used
  through HPKE.

| Need | Choose |
| --- | --- |
| Key must never leave hardware | `SecureEnclave.P256.*` |
| Software signing or agreement | `Curve25519.*` |
| FIPS or enterprise interop | `P256` or `P384` |
| End-to-end encryption, iOS 17+ | HPKE `Curve25519_SHA256_ChachaPoly` |
| End-to-end encryption resistant to harvest-now-decrypt-later, iOS 26+ | HPKE `XWingMLKEM768X25519_SHA256_AES_GCM_256` |
| Highest classical level | `P521`, only if mandated |

| Algorithm | Public key | Secure Enclave |
| --- | --- | --- |
| P256 | 64 B | yes |
| P384 | 96 B | no |
| P521 | 132 B | no |
| Curve25519 | 32 B | no |
| ML-KEM-768 | 1,184 B | yes (iOS 26) |
| ML-KEM-1024 | 1,568 B | yes (iOS 26) |
| ML-DSA-65 | 1,952 B | yes (iOS 26) |
| ML-DSA-87 | 2,592 B | yes (iOS 26) |
| X-Wing | 1,216 B | yes (iOS 26) |

Speed is not a reason to prefer one of P256 and Curve25519 on Apple Silicon;
corecrypto's NISTZ256 code makes the difference negligible.

## Signing and key agreement are different types

Each curve has two separate families, `Signing` and `KeyAgreement`, and the compiler
will not let you mix them.

```swift
import CryptoKit
import Foundation

let releaseSigner = P256.Signing.PrivateKey()
let verifierKey: P256.Signing.PublicKey = releaseSigner.publicKey

let artifact = Data("build 5120 checksum".utf8)
let signature = try releaseSigner.signature(for: artifact)

let valid = verifierKey.isValidSignature(signature, for: artifact)
let asn1 = signature.derRepresentation
let compact = signature.rawRepresentation
let decoded = try P256.Signing.ECDSASignature(derRepresentation: asn1)
```

`signature(for:)` hashes `Data` with SHA-256 internally and returns a
`P256.Signing.ECDSASignature`. `derRepresentation` is the ASN.1 form most servers
expect; `rawRepresentation` is the 64-byte r||s form. If you already have a digest,
pass the `SHA256Digest` (any `Digest`) to `signature(for:)` instead.

Calling `sharedSecretFromKeyAgreement` on a `P256.Signing.PrivateKey`, or
`signature(for:)` on a `Curve25519.KeyAgreement.PrivateKey`, is a compile error.
Generate one key per purpose.

## Key agreement always goes through a KDF

An ECDH `SharedSecret` is not uniformly random and cannot be turned into a
`SymmetricKey` directly. Apple's documentation says it is not suitable as a key on
its own; the two sanctioned routes are `hkdfDerivedSymmetricKey` and
`x963DerivedSymmetricKey`.

```swift
let deviceKey = Curve25519.KeyAgreement.PrivateKey()
let serverKey = Curve25519.KeyAgreement.PrivateKey()

let secret = try deviceKey.sharedSecretFromKeyAgreement(with: serverKey.publicKey)
let channelKey = secret.hkdfDerivedSymmetricKey(
    using: SHA256.self,
    salt: Data("pairing-2026".utf8),
    sharedInfo: Data("telemetry-channel/v1/encrypt".utf8),
    outputByteCount: 32
)
let packet = try ChaChaPoly.seal(Data("temp=21.5".utf8), using: channelKey)
```

`sharedInfo` ties the key to your protocol and role. Derive separate keys with
different `sharedInfo` for encryption and for authentication.

```swift
// Broken: raw secret bytes used as a key
let bad = SymmetricKey(data: secret.withUnsafeBytes { Data($0) })
```

This skips extraction over non-uniform material (on P-256 roughly half of all 256-bit strings can
occur as an x-coordinate, so the bits are biased), and has no salt and no domain
separation.

## HPKE (iOS 17+)

Before iOS 17, encrypting to a public key meant building ECIES by hand: ECDH, HKDF,
AES-GCM, and shipping the ephemeral public key alongside. HPKE (RFC 9180) packages
that. Every mode the RFC defines is available (base, sender-authenticated, pre-shared key,
and the combination of the last two), and five suites come predefined:

| Suite name | KEM | KDF | AEAD | Since |
| --- | --- | --- | --- | --- |
| `.Curve25519_SHA256_ChachaPoly` | X25519 | SHA-256 HKDF | ChaCha20 with Poly1305 | 17 |
| `.P256_SHA256_AES_GCM_256` | NIST P-256 | SHA-256 HKDF | AES-GCM, 256-bit key | 17 |
| `.P384_SHA384_AES_GCM_256` | NIST P-384 | SHA-384 HKDF | AES-GCM, 256-bit key | 17 |
| `.P521_SHA512_AES_GCM_256` | NIST P-521 | SHA-512 HKDF | AES-GCM, 256-bit key | 17 |
| `.XWingMLKEM768X25519_SHA256_AES_GCM_256` | X-Wing hybrid | SHA-256 HKDF | AES-GCM, 256-bit key | 26 |

Other combinations are built with `HPKE.Ciphersuite(kem:kdf:aead:)`, for example a
P-384 KEM with the SHA-384 KDF and ChaChaPoly:
`HPKE.Ciphersuite(kem: .P384_HKDF_SHA384, kdf: .HKDF_SHA384, aead: .chaChaPoly)`.

```swift
@available(iOS 17.0, macOS 14.0, *)
func exchangeMedicalNote() throws -> Data {
    let suite = HPKE.Ciphersuite.Curve25519_SHA256_ChachaPoly
    let clinicKey = Curve25519.KeyAgreement.PrivateKey()
    let context = Data("clinic-inbox/v3".utf8)
    let header = Data("patient=5521".utf8)

    var outbound = try HPKE.Sender(
        recipientKey: clinicKey.publicKey, ciphersuite: suite, info: context
    )
    let ciphertext = try outbound.seal(Data("allergy: penicillin".utf8), authenticating: header)
    let encapsulated = outbound.encapsulatedKey

    var inbound = try HPKE.Recipient(
        privateKey: clinicKey, ciphersuite: suite, info: context,
        encapsulatedKey: encapsulated
    )
    return try inbound.open(ciphertext, authenticating: header)
}
```

Three things break HPKE code:

1. `encapsulatedKey` is not part of the ciphertext. You must send it with the
   message. Without it nothing can be decrypted, ever.
2. `Sender` and `Recipient` are structs with state (an internal nonce counter), and
   `seal` / `open` are `mutating`. Declaring them with `let` does not compile.
3. Messages must be opened in the order they were sealed; out-of-order opens fail
   because the counters disagree.

## Post-quantum cryptography (iOS 26+)

The threat is harvest now, decrypt later: traffic recorded today is decrypted once
a large quantum computer exists. From iOS 26, `URLSession` and Network.framework
offer the hybrid `X25519MLKEM768` group in the TLS ClientHello by default.

CryptoKit adds five types, formally verified against the FIPS specifications, all
usable in the Secure Enclave:

| Type | Public key | Output |
| --- | --- | --- |
| `MLKEM768` | 1,184 B | ciphertext 1,088 B |
| `MLKEM1024` | 1,568 B | |
| `XWingMLKEM768X25519` (draft-connolly-cfrg-xwing-kem) | 1,216 B | encapsulation 1,120 B |
| `MLDSA65` | 1,952 B | signature 3,309 B |
| `MLDSA87` | 2,592 B | signature 4,627 B |

What you pay is bytes on the wire, not CPU. Compare 3,309 bytes per ML-DSA-65
signature with 64 per Ed25519 signature, and 1,184 bytes of ML-KEM-768 public key
with 32 for X25519. Timing is in the same range as the classical algorithms.

Encapsulation works differently from Diffie-Hellman: the sending side has no key
pair at all. It runs `encapsulate()` against the receiver's public key, which yields
both the shared secret and a ciphertext that has to travel to the receiver.

```swift
@available(iOS 26, macOS 26, *)
func quantumSafeSession() throws -> SymmetricKey {
    let receiver = try MLKEM768.PrivateKey()

    let result = try receiver.publicKey.encapsulate()
    let sentOverWire = result.encapsulated          // 1,088 bytes
    // result.sharedSecret (32 bytes) stays with the sender

    let receiverSecret = try receiver.decapsulate(sentOverWire)
    return HKDF<SHA256>.deriveKey(
        inputKeyMaterial: receiverSecret, info: Data("kem-session".utf8),
        outputByteCount: 32
    )
}

@available(iOS 26, macOS 26, *)
func signFirmware(_ image: Data) throws -> Bool {
    let authority = try MLDSA65.PrivateKey()
    let sig = try authority.signature(for: image)    // 3,309 bytes
    return authority.publicKey.isValidSignature(sig, for: image)
}
```

Both sides end up with the same 32-byte `SymmetricKey`. As with ECDH, derive the
working key from it with HKDF and a protocol label rather than using it directly.

Hybrid key exchange through HPKE is the migration Apple recommends: the classical
code above changes only in suite and key type. The encapsulated key grows from
about 32 bytes to 1,120.

```swift
@available(iOS 26, macOS 26, *)
func hybridSeal(_ message: Data) throws -> (Data, Data) {
    let suite = HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256
    let mailboxKey = try XWingMLKEM768X25519.PrivateKey()
    var sender = try HPKE.Sender(
        recipientKey: mailboxKey.publicKey, ciphersuite: suite, info: Data("mailbox".utf8)
    )
    let sealed = try sender.seal(message)
    return (sender.encapsulatedKey, sealed)
}
```

Hybrid signatures follow the same idea: sign with both `MLDSA65` and `P256.Signing`,
ship both, and accept only if both verify.

```swift
@available(iOS 26, macOS 26, *)
struct DualSignature {
    let pq: Data
    let classical: Data

    static func make(for body: Data, pqKey: MLDSA65.PrivateKey,
                     ecKey: P256.Signing.PrivateKey) throws -> DualSignature {
        DualSignature(
            pq: try pqKey.signature(for: body),
            classical: try ecKey.signature(for: body).rawRepresentation
        )
    }

    func verify(_ body: Data, pqKey: MLDSA65.PublicKey,
                ecKey: P256.Signing.PublicKey) -> Bool {
        guard let ecSig = try? P256.Signing.ECDSASignature(rawRepresentation: classical) else {
            return false
        }
        return pqKey.isValidSignature(pq, for: body) && ecKey.isValidSignature(ecSig, for: body)
    }
}
```

## PEM and DER (iOS 14+)

PEM private keys are PKCS#8 (`BEGIN PRIVATE KEY`); PEM public keys are X.509
SubjectPublicKeyInfo (`BEGIN PUBLIC KEY`). Import also accepts SEC 1
(`BEGIN EC PRIVATE KEY`). These interoperate with OpenSSL, BoringSSL and typical
server TLS stacks.

```swift
let tokenSigner = P384.Signing.PrivateKey()
let privatePEM = tokenSigner.pemRepresentation
let publicPEM = tokenSigner.publicKey.pemRepresentation
let publicDER = tokenSigner.publicKey.derRepresentation

let restoredSigner = try P384.Signing.PrivateKey(pemRepresentation: privatePEM)
let restoredVerifier = try P384.Signing.PublicKey(derRepresentation: publicDER)
```

| Key | Public formats | Private formats |
| --- | --- | --- |
| P256 / P384 / P521 (iOS 14+) | SPKI DER and PEM, x963, raw | PKCS#8 DER and PEM, x963, raw |
| Curve25519 | raw 32 bytes only | raw 32 bytes only |
| Secure Enclave P256 | SPKI as usual | `dataRepresentation`: an encrypted blob bound to this device |
| ML-KEM / ML-DSA (iOS 26+) | raw | raw |

PEM and DER apply to the NIST curves only. Exchanging Curve25519 keys with another
system means agreeing on raw 32-byte encoding yourself.

### Storing keys in the Keychain

- NIST private keys go in as `kSecClassKey` by converting to `SecKey`.
- Curve25519 keys, post-quantum keys and Secure Enclave blobs go in as
  `kSecClassGenericPassword`, using `rawRepresentation` or `dataRepresentation`.
  Apple's sample code wraps this in a `GenericPasswordConvertible` protocol (see
  [credential-storage-patterns.md](credential-storage-patterns.md), which also shows
  the add-then-update-on-duplicate save).

Public keys of peers and recipients also need to be stored safely, because swapping
one lets an attacker impersonate the peer. Keep them in the Keychain, never in
UserDefaults, files or source:

- NIST public keys as `kSecClassKey` with `kSecAttrKeyClass: kSecAttrKeyClassPublic`.
- Curve25519 and post-quantum public keys as raw bytes in a generic password item.
- Accessibility `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
- A distinct tag or account name, for example a `peer-` prefix, so they never
  collide with your own keys.

## The Secure Enclave, briefly

Full coverage is in [secure-enclave.md](secure-enclave.md). A signing key there needs
`.privateKeyUsage` in its access control flags; without it key creation fails.
`SecureEnclave.isAvailable` is false in the Simulator, so guard it and test the
hardware path on a device.

```swift
enum HardwareKeyError: Error {
    case enclaveUnavailable
    case accessControlRejected
}

func makeHardwareSigner() throws -> (SecureEnclave.P256.Signing.PrivateKey, String) {
    guard SecureEnclave.isAvailable else { throw HardwareKeyError.enclaveUnavailable }

    guard let policy = SecAccessControlCreateWithFlags(
        nil,
        kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        [.privateKeyUsage, .biometryCurrentSet],
        nil
    ) else {
        throw HardwareKeyError.accessControlRejected
    }

    let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: policy)
    return (key, key.publicKey.pemRepresentation)
}
```

iOS 26 adds `SecureEnclave.MLKEM768`, `SecureEnclave.MLKEM1024`,
`SecureEnclave.MLDSA65` and `SecureEnclave.MLDSA87`.

A Secure Enclave key belongs to one physical device and one installation of its OS. The blob can
only be unwrapped by the enclave that created it, so an iCloud restore to a new
device loses the key. Plan rotation and recovery (re-enrol a new public key with
your server) from the start.

## RSA

CryptoKit does not implement RSA. RSA means the Security framework's `SecKey` C API
with no type safety and manual memory handling, for example
`SecKeyCreateRandomKey` with `kSecAttrKeyTypeRSA` and 2048 bits. Do not start new
designs there.

An RSA-2048 key is rated near 112 bits, and both its modulus and each signature take
256 bytes. P-256 gives
about 128 bits with a 32-byte private key and 64-byte signatures, 8x smaller.

RSA is still justified for interop with a legacy server, when a CA mandates RSA
X.509 certificates, or when a JWT profile is fixed to RS256.

## Frequent mistakes

| Mistake | Correction |
| --- | --- |
| Using a raw `SharedSecret` as a key | `hkdfDerivedSymmetricKey()` with salt and `sharedInfo` |
| Mixing `Signing` and `KeyAgreement` types | Compile error; generate a key of the right family |
| Not transmitting `encapsulatedKey` | The message can never be opened; send it |
| `let` for an HPKE `Sender` / `Recipient` | Compile error; use `var` |
| RSA in new code | P-256 or Curve25519 |
| Curve25519 for a Secure Enclave key | `SecureEnclave.P256` |
| Calling `.pemRepresentation` on a Curve25519 key | Not available; use `.rawRepresentation` |
| Opening HPKE messages out of order | Counter mismatch, open fails; keep order |

## Availability

| Release | Adds |
| --- | --- |
| iOS 13 | CryptoKit: P256, P384, P521, Curve25519, Secure Enclave P256 |
| iOS 14 | PEM/DER import and export (NIST curves), standalone HKDF |
| iOS 17 | HPKE in all four modes |
| iOS 26 | ML-KEM, ML-DSA, X-Wing, quantum-secure TLS |

```swift
func sealForRecipient(_ body: Data) throws {
    if #available(iOS 26, macOS 26, *) {
        // X-Wing HPKE path
    } else if #available(iOS 17, macOS 14, *) {
        // Classical HPKE path
    } else {
        // Hand-built ECIES: X25519 + HKDF + AES-GCM, ship the ephemeral public key
    }
}
```

## Performance and threads

Calls into CryptoKit only burn CPU and may run on any thread concurrently; the
library keeps no shared mutable state and takes no locks. Secure Enclave key creation and signing behind biometrics
can wait on the user, so never run them on `@MainActor`; use an actor or a detached
task. Size network payloads and stored records for post-quantum material, which is one
to two orders of magnitude larger than classical keys and signatures.

## Sources

WWDC 2019 session 709; WWDC 2020 "What's New in CryptoKit"; WWDC 2025 session 314;
Apple documentation for CryptoKit, `SharedSecret` and `HPKE`; "Storing CryptoKit Keys
in the Keychain"; "Protecting Keys with the Secure Enclave"; Apple's
"Quantum-Secure Cryptography in Apple Operating Systems".

See also [cryptokit-symmetric.md](cryptokit-symmetric.md) for the AEAD and KDF
details used above.

## Summary

Curve25519 for software keys, P256 for the Secure Enclave. HPKE instead of
hand-rolled ECIES. Every shared secret through HKDF with a protocol-specific
`sharedInfo`. Post-quantum migration is mostly swapping the HPKE suite to X-Wing and
changing the key type, so start listing your custom protocols now.

## Checklist

- [ ] Curve fits the requirement: P256 for the Secure Enclave or NIST interop,
      Curve25519 for software, P384/P521 only when mandated.
- [ ] Correct `Signing` versus `KeyAgreement` family.
- [ ] Every `SharedSecret` goes through
      `hkdfDerivedSymmetricKey(using:salt:sharedInfo:outputByteCount:)`.
- [ ] HPKE `encapsulatedKey` is transmitted.
- [ ] Both HPKE context structs are mutable variables.
- [ ] The receiver opens HPKE messages in the order the sender produced them.
- [ ] PEM or DER encoding appears only with P256, P384 and P521 keys.
- [ ] No RSA except legacy interop through `SecKey`.
- [ ] Post-quantum code behind `#available(iOS 26, *)`; HPKE behind iOS 17.
- [ ] Secure Enclave signing keys include `.privateKeyUsage`.
- [ ] Secure Enclave key lifecycle covers moving to a new device.
- [ ] Hybrid plan: X-Wing suite for encryption to a recipient, and every signature
      produced twice, once with ML-DSA and once with ECDSA.
- [ ] Peer public keys in the Keychain, AfterFirstUnlockThisDeviceOnly, distinct tags.
