CryptoKit: Encryption, Signing, and Post-Quantum Cryptography


You are shipping user data over the wire, storing tokens on disk, and verifying server payloads — yet the cryptographic primitives powering all of that are often buried behind third-party wrappers or, worse, raw CommonCrypto C calls. Apple’s CryptoKit framework gives you a Swift-native, misuse-resistant API for hashing, encryption, signing, and key agreement — and as of iOS 26, it adds post-quantum algorithms so your app is ready for the day quantum computers break classical key exchange.

This post covers CryptoKit’s full surface area: hashing, symmetric encryption, asymmetric keys, HMAC, Secure Enclave integration, and the new ML-KEM / ML-DSA post-quantum primitives. We will not cover TLS configuration or network-layer certificate pinning — those are covered in App Security Best Practices.

Contents

The Problem

Imagine your app manages a Pixar movie vault — users store unreleased plot synopses, character designs, and voice-over scripts on their devices. You need to hash passwords before sending them to your server, encrypt local drafts so a stolen device does not leak the next Toy Story screenplay, and verify that asset bundles downloaded from your CDN have not been tampered with.

Without CryptoKit, you might reach for CommonCrypto:

import CommonCrypto

func legacySHA256(data: Data) -> Data {
    var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
    data.withUnsafeBytes { buffer in
        _ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash)
    }
    return Data(hash)
}

This works, but it is C-bridged, requires manual buffer management, and gives you zero compile-time safety against mixing up digest lengths. CryptoKit replaces all of this with value types, generics, and protocols that make incorrect usage a compiler error rather than a runtime vulnerability.

Hashing with SHA-256 and SHA-512

Hashing is the most common cryptographic operation — you need it for integrity checks, fingerprinting, and password derivation. CryptoKit provides SHA256 and SHA512 as zero-configuration structs.

import CryptoKit
import Foundation

struct MovieVault {
    /// Computes a SHA-256 fingerprint for a movie script's content.
    static func fingerprint(for script: String) -> String {
        let data = Data(script.utf8)
        let digest = SHA256.hash(data: data)
        return digest.compactMap { String(format: "%02x", $0) }.joined()
    }

    /// Verifies that a downloaded asset matches the expected hash.
    static func verify(asset data: Data, expectedHash: String) -> Bool {
        let digest = SHA512.hash(data: data)
        let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
        return hex == expectedHash
    }
}

let hash = MovieVault.fingerprint(for: "Woody finds a new kid named Bonnie.")
// "a1f3c8..."  — deterministic 64-character hex string

Both SHA256.hash(data:) and SHA512.hash(data:) return a Digest value type that conforms to Sequence, Hashable, and ContiguousBytes. For incremental hashing of large files — say, a full Pixar render frame sequence — use the streaming API:

func hashLargeFile(at url: URL) throws -> SHA256Digest {
    var hasher = SHA256()
    let handle = try FileHandle(forReadingFrom: url)
    defer { handle.closeFile() }

    while true {
        let chunk = handle.readData(ofLength: 1024 * 1024) // 1 MB chunks
        if chunk.isEmpty { break }
        hasher.update(data: chunk)
    }
    return hasher.finalize()
}

The streaming approach keeps memory constant regardless of file size — critical when hashing multi-gigabyte asset bundles.

Symmetric Encryption with AES-GCM

When you need to encrypt data with a shared secret — local storage encryption, encrypting payloads between your app and your own server, or protecting cached voice-over recordings — AES-GCM is the standard choice. CryptoKit wraps it in AES.GCM, which handles nonce generation and authentication tagging for you.

struct ScriptEncryptor {
    private let key: SymmetricKey

    init() {
        // Generate a 256-bit key. Store this in the Keychain, not in code.
        self.key = SymmetricKey(size: .bits256)
    }

    /// Encrypts a movie script. Returns a combined sealed box (nonce + ciphertext + tag).
    func encrypt(script: String) throws -> Data {
        let plaintext = Data(script.utf8)
        let sealedBox = try AES.GCM.seal(plaintext, using: key)
        guard let combined = sealedBox.combined else {
            throw EncryptionError.sealingFailed
        }
        return combined
    }

    /// Decrypts a previously sealed script.
    func decrypt(data: Data) throws -> String {
        let sealedBox = try AES.GCM.SealedBox(combined: data)
        let decryptedData = try AES.GCM.open(sealedBox, using: key)
        guard let script = String(data: decryptedData, encoding: .utf8) else {
            throw EncryptionError.decodingFailed
        }
        return script
    }
}

enum EncryptionError: Error {
    case sealingFailed
    case decodingFailed
}

Warning: Never hardcode SymmetricKey values in your source code. Generate them at runtime and persist them in the Keychain. A key embedded in your binary can be extracted with strings or a disassembler in minutes.

The combined property of SealedBox concatenates the 12-byte nonce, ciphertext, and 16-byte authentication tag into a single Data blob — convenient for storage or transmission. AES-GCM is an authenticated encryption scheme, meaning open(_:using:) will throw CryptoKitError.authenticationFailure if any byte has been tampered with.

ChaChaPoly as an Alternative

CryptoKit also exposes ChaChaPoly, which uses ChaCha20-Poly1305. The API is identical to AES-GCM:

let sealedBox = try ChaChaPoly.seal(plaintext, using: key)
let decrypted = try ChaChaPoly.open(sealedBox, using: key)

ChaChaPoly performs better on devices without AES hardware acceleration (older Apple Watch models), but on modern Apple Silicon every device has AES-NI, so AES-GCM is the default choice for iOS.

HMAC: Message Authentication

When you download a Pixar asset bundle from your CDN, you need to verify the server actually produced it — not a man-in-the-middle. HMAC generates a keyed hash that proves both integrity and authenticity.

struct AssetVerifier {
    private let signingKey: SymmetricKey

    init(sharedSecret: Data) {
        self.signingKey = SymmetricKey(data: sharedSecret)
    }

    /// Creates an HMAC authentication code for an asset bundle.
    func authenticationCode(for bundle: Data) -> Data {
        let code = HMAC<SHA256>.authenticationCode(
            for: bundle, using: signingKey
        )
        return Data(code)
    }

    /// Validates that a bundle matches the provided HMAC.
    func validate(bundle: Data, against mac: Data) -> Bool {
        HMAC<SHA256>.isValidAuthenticationCode(
            mac, authenticating: bundle, using: signingKey
        )
    }
}

HMAC is generic over any HashFunction — swap in SHA512 or SHA384 by changing the type parameter. The isValidAuthenticationCode method uses constant-time comparison internally, preventing timing attacks.

Tip: Use HMAC when both parties share a secret key. If you need third-party verifiability (anyone can check the signature without the key), use asymmetric signing instead.

Asymmetric Keys: P-256 and Curve25519

Asymmetric cryptography is essential for key agreement (establishing a shared secret over an insecure channel) and digital signatures (proving a message came from a specific sender). CryptoKit supports both NIST P-256 and Curve25519 families.

Digital Signatures with P-256

Suppose your Pixar asset pipeline signs each render job manifest so the rendering farm can verify it came from a trusted coordinator:

struct RenderManifestSigner {
    private let privateKey: P256.Signing.PrivateKey

    init() {
        self.privateKey = P256.Signing.PrivateKey()
    }

    var publicKey: P256.Signing.PublicKey {
        privateKey.publicKey
    }

    func sign(manifest: Data) throws -> P256.Signing.ECDSASignature {
        try privateKey.signature(for: manifest)
    }

    static func verify(
        manifest: Data,
        signature: P256.Signing.ECDSASignature,
        publicKey: P256.Signing.PublicKey
    ) -> Bool {
        publicKey.isValidSignature(signature, for: manifest)
    }
}

Key Agreement with Curve25519

When two devices need to establish a shared secret — say, a director’s iPad sharing review notes with an animator’s Mac — use Curve25519.KeyAgreement:

struct SecureChannel {
    /// Derives a symmetric key from two Curve25519 key pairs using HKDF.
    static func deriveSharedKey(
        myPrivateKey: Curve25519.KeyAgreement.PrivateKey,
        theirPublicKey: Curve25519.KeyAgreement.PublicKey
    ) throws -> SymmetricKey {
        let sharedSecret = try myPrivateKey.sharedSecretFromKeyAgreement(
            with: theirPublicKey
        )
        // Derive a 256-bit key using HKDF with a protocol-specific salt
        return sharedSecret.hkdfDerivedSymmetricKey(
            using: SHA256.self,
            salt: Data("PixarSecureReview".utf8),
            sharedInfo: Data(),
            outputByteCount: 32
        )
    }
}

// Both sides generate key pairs, exchange public keys, derive the same shared key
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
let bobKey = Curve25519.KeyAgreement.PrivateKey()

let aliceShared = try SecureChannel.deriveSharedKey(
    myPrivateKey: aliceKey,
    theirPublicKey: bobKey.publicKey
)
let bobShared = try SecureChannel.deriveSharedKey(
    myPrivateKey: bobKey,
    theirPublicKey: aliceKey.publicKey
)
// aliceShared == bobShared — both sides derive the identical key

The HKDF step is critical — raw shared secrets should never be used directly as encryption keys. The salt parameter should be unique to your protocol to prevent cross-protocol attacks.

Apple Docs: Curve25519 — CryptoKit

Secure Enclave Keys

The Secure Enclave is a hardware coprocessor that stores private keys in a way that even a compromised kernel cannot extract them. CryptoKit provides SecureEnclave.P256 for signing and key agreement operations that never expose the raw key material.

struct VaultAuthenticator {
    private let enclaveKey: SecureEnclave.P256.Signing.PrivateKey

    init() throws {
        // Check hardware availability first
        guard SecureEnclave.isAvailable else {
            throw VaultError.secureEnclaveUnavailable
        }
        self.enclaveKey = try SecureEnclave.P256.Signing.PrivateKey()
    }

    var publicKey: P256.Signing.PublicKey {
        enclaveKey.publicKey
    }

    /// Signs a vault access request. The private key never leaves the Secure Enclave.
    func signAccessRequest(_ request: Data) throws -> P256.Signing.ECDSASignature {
        try enclaveKey.signature(for: request)
    }
}

enum VaultError: Error {
    case secureEnclaveUnavailable
}

Secure Enclave keys are bound to the device — they cannot be exported, backed up, or transferred. This makes them ideal for device attestation, biometric-gated signing, and any scenario where key theft must be impossible even if the file system is compromised.

Note: SecureEnclave.isAvailable returns false on the iOS Simulator. Always provide a software-key fallback for development and testing, but log a warning when the fallback is active in production builds.

Protecting Keys with Access Control

You can bind Secure Enclave keys to biometric authentication by passing a LAContext access control:

import LocalAuthentication

func createBiometricProtectedKey() throws -> SecureEnclave.P256.Signing.PrivateKey {
    let context = LAContext()
    context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds

    let accessControl = SecureEnclave.AccessControl(
        protection: .afterFirstUnlockThisDeviceOnly,
        flags: [.biometryCurrentSet]
    )

    return try SecureEnclave.P256.Signing.PrivateKey(
        accessControl: accessControl,
        authenticationContext: context
    )
}

With .biometryCurrentSet, the key is invalidated if the user adds or removes a fingerprint or Face ID appearance, preventing an attacker from enrolling their own biometric and then using the key.

Post-Quantum Cryptography: ML-KEM and ML-DSA

Classical asymmetric algorithms like P-256 and Curve25519 rely on the hardness of elliptic-curve discrete logarithm problems. A sufficiently powerful quantum computer could break these using Shor’s algorithm. iOS 26 introduces post-quantum algorithms based on NIST FIPS 203 and FIPS 204:

  • ML-KEM (Module-Lattice Key Encapsulation Mechanism) — quantum-resistant key agreement
  • ML-DSA (Module-Lattice Digital Signature Algorithm) — quantum-resistant signatures

Note: ML-KEM and ML-DSA require iOS 26 / macOS 26. These APIs are available starting with Xcode 26 beta 1.

ML-KEM: Quantum-Safe Key Exchange

ML-KEM replaces classical Diffie-Hellman key agreement. The encapsulation/decapsulation pattern differs from traditional key agreement:

@available(iOS 26, *)
struct QuantumSafeChannel {
    /// Server generates a key pair and publishes the encapsulation key.
    static func generateKeyPair() -> MLKEM1024.DecapsulationKey {
        let decapsulationKey = MLKEM1024.DecapsulationKey()
        return decapsulationKey
    }

    /// Client encapsulates: produces a shared secret and a ciphertext to send back.
    static func encapsulate(
        to encapsulationKey: MLKEM1024.EncapsulationKey
    ) -> MLKEM1024.EncapsulationResult {
        encapsulationKey.encapsulate()
    }

    /// Server decapsulates: recovers the same shared secret from the ciphertext.
    static func decapsulate(
        ciphertext: Data,
        using decapsulationKey: MLKEM1024.DecapsulationKey
    ) throws -> SymmetricKey {
        try decapsulationKey.decapsulate(ciphertext)
    }
}

ML-KEM-1024 provides NIST Security Level 5 (equivalent to AES-256). CryptoKit also offers MLKEM768 for Level 3 when smaller key sizes matter. The encapsulated ciphertext is roughly 1.5 KB — larger than a Curve25519 public key, but a negligible cost for the quantum resistance it provides.

ML-DSA: Quantum-Safe Signatures

ML-DSA replaces ECDSA for scenarios where signatures must remain secure for decades — think legal documents, long-lived certificates, or archival integrity proofs:

@available(iOS 26, *)
struct ArchivalSigner {
    private let signingKey: MLDSA65.SigningKey

    init() {
        self.signingKey = MLDSA65.SigningKey()
    }

    var verifyingKey: MLDSA65.VerifyingKey {
        signingKey.verifyingKey
    }

    func sign(document: Data) throws -> MLDSA65.Signature {
        try signingKey.signature(for: document)
    }

    static func verify(
        document: Data,
        signature: MLDSA65.Signature,
        verifyingKey: MLDSA65.VerifyingKey
    ) -> Bool {
        verifyingKey.isValidSignature(signature, for: document)
    }
}

ML-DSA-65 (FIPS 204 Security Level 3) produces signatures of roughly 3.3 KB — substantially larger than ECDSA’s ~72 bytes. For bandwidth-constrained scenarios, evaluate whether quantum resistance is worth the overhead today or whether a hybrid approach (classical + post-quantum) is more appropriate.

Hybrid Approach: Defense in Depth

Apple recommends a hybrid strategy during the transition period — use both classical and post-quantum algorithms, so you remain secure even if one is broken:

@available(iOS 26, *)
struct HybridKeyExchange {
    /// Performs both classical Curve25519 and quantum-safe ML-KEM key exchange,
    /// then combines both shared secrets via HKDF.
    static func deriveHybridKey(
        classicalPrivate: Curve25519.KeyAgreement.PrivateKey,
        classicalPublicTheirs: Curve25519.KeyAgreement.PublicKey,
        mlkemEncapsulationKey: MLKEM768.EncapsulationKey
    ) throws -> SymmetricKey {
        // Classical key agreement
        let classicalSecret = try classicalPrivate.sharedSecretFromKeyAgreement(
            with: classicalPublicTheirs
        )

        // Post-quantum encapsulation
        let result = mlkemEncapsulationKey.encapsulate()

        // Combine both secrets using HKDF
        var combinedInput = Data()
        classicalSecret.withUnsafeBytes { combinedInput.append(contentsOf: $0) }
        result.sharedSecret.withUnsafeBytes { combinedInput.append(contentsOf: $0) }

        return HKDF<SHA256>.deriveKey(
            inputKeyMaterial: SymmetricKey(data: combinedInput),
            salt: Data("PixarHybridKEX-v1".utf8),
            info: Data(),
            outputByteCount: 32
        )
    }
}

This way, even if ML-KEM is found to have a classical weakness, the Curve25519 component keeps you safe — and if a quantum computer breaks Curve25519, ML-KEM has you covered.

Performance Considerations

CryptoKit operations are hardware-accelerated on Apple Silicon. Here are the relative costs to keep in mind:

OperationRelative CostNotes
SHA-256 hash (1 KB)~1 usEffectively free for small payloads
AES-GCM encrypt (1 MB)~0.5 msHardware AES-NI on all modern devices
P-256 sign~1 msElliptic curve math is the bottleneck
P-256 verify~1 msSlightly faster than signing
Secure Enclave sign~30-50 msRound-trip to the coprocessor dominates
ML-KEM-768 encapsulate~0.3 msLattice operations are fast
ML-DSA-65 sign~2 msLarger key/signature sizes add overhead

The Secure Enclave round-trip is the most expensive operation by far. If you need to sign many items in rapid succession, batch the data and sign once, or sign a Merkle root rather than individual items.

For large-file encryption, streaming AES-GCM through 1 MB chunks keeps memory pressure low. CryptoKit does not provide a streaming encryption API directly, so you will need to manage chunking yourself with per-chunk nonces derived from a counter.

Tip: Profile with Instruments’ Crypto template (available in Xcode 26) to identify whether cryptographic operations are a real bottleneck before optimizing. In most apps, network latency dwarfs CryptoKit computation time by orders of magnitude.

When to Use (and When Not To)

ScenarioRecommendation
Hashing passwords before sending to serverUse SHA-256 for fingerprinting, but prefer server-side bcrypt/Argon2 for storage
Encrypting local data at restAES-GCM with a Keychain-stored SymmetricKey is the standard approach
Verifying downloaded assetsHMAC if you share a secret with your server; asymmetric signatures otherwise
Device attestation / biometric gatingSecure Enclave P-256 keys with LAContext access control
Long-lived signatures (10+ year validity)Consider ML-DSA now for forward security against quantum threats
TLS / HTTPS configurationDo not use CryptoKit — rely on URLSession and ATS
Password hashing on-deviceDo not use raw SHA-256. Use a KDF like HKDF or delegate to the server

Apple Docs: CryptoKit — Apple Developer Documentation

Summary

  • SHA-256 / SHA-512 provide fast, deterministic hashing for integrity checks and fingerprinting. Use the streaming API for large files.
  • AES-GCM is the go-to symmetric encryption scheme — it handles nonce generation and authentication tagging automatically. Store keys in the Keychain, never in code.
  • HMAC authenticates messages with a shared key using constant-time comparison. Use it for server-to-client payload verification.
  • P-256 and Curve25519 cover signing and key agreement. Combine key agreement with HKDF to derive encryption keys.
  • Secure Enclave keys never leave the hardware coprocessor, making them ideal for device-bound authentication and biometric-gated operations.
  • ML-KEM and ML-DSA (iOS 26) bring post-quantum cryptography to Apple platforms. Adopt a hybrid classical + post-quantum approach during the transition period.

CryptoKit handles the cryptographic primitives, but keys still need a secure home. Head over to Keychain Storage to learn how to persist symmetric keys and credentials properly, or check out Passkeys Authentication to see how Apple is replacing passwords entirely.