How do two parties agree on a key with CryptoKit, why must the shared secret go through HKDF, and what does HPKE add?

14.6.2 · L5 · Interview

Predict first

Do both sides derive the same key, and does HPKE enforce order?

import Foundation
import CryptoKit

let a = Curve25519.KeyAgreement.PrivateKey(), b = Curve25519.KeyAgreement.PrivateKey()
let sa = try a.sharedSecretFromKeyAgreement(with: b.publicKey)
let sb = try b.sharedSecretFromKeyAgreement(with: a.publicKey)
print("shared secrets equal:", sa == sb)
let salt = Data("salt".utf8)
let enc1 = sa.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("encrypt".utf8), outputByteCount: 32)
let enc2 = sb.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("encrypt".utf8), outputByteCount: 32)
let mac = sa.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data("mac".utf8), outputByteCount: 32)
print("both sides derive the same key:", enc1 == enc2, "| other label gives another key:", enc1 != mac)
let ikm = SymmetricKey(data: Data(repeating: 0x0b, count: 22))
let okm = HKDF<SHA256>.deriveKey(inputKeyMaterial: ikm, salt: Data((0...12).map { UInt8($0) }), info: Data((0xf0...0xf9).map { UInt8($0) }), outputByteCount: 42)
print("RFC 5869 vector 1:", okm.withUnsafeBytes { $0.map { String(format: "%02x", $0) }.joined() })
let suite = HPKE.Ciphersuite.Curve25519_SHA256_ChachaPoly
let recipient = Curve25519.KeyAgreement.PrivateKey()
var sender = try HPKE.Sender(recipientKey: recipient.publicKey, ciphersuite: suite, info: Data("ctx".utf8))
let c1 = try sender.seal(Data("hello".utf8), authenticating: Data("aad".utf8))
let c2 = try sender.seal(Data("hello".utf8), authenticating: Data("aad".utf8))
var r = try HPKE.Recipient(privateKey: recipient, ciphersuite: suite, info: Data("ctx".utf8), encapsulatedKey: sender.encapsulatedKey)
print("hpke:", String(decoding: try r.open(c1, authenticating: Data("aad".utf8)), as: UTF8.self), "| same plaintext twice differs:", c1 != c2)
var late = try HPKE.Recipient(privateKey: recipient, ciphersuite: suite, info: Data("ctx".utf8), encapsulatedKey: sender.encapsulatedKey)
do { _ = try late.open(c2, authenticating: Data("aad".utf8)) } catch { print("second message first:", error) }
  • shared secrets equal: true / both sides derive the same key: true | other label gives another key: true / RFC 5869 vector 1: 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865 / hpke: hello | same plaintext twice differs: true / second message first: authenticationFailure
  • shared secrets equal: false / second message first: opened
  • both sides derive the same key: false

Mental model. ECDH is two people mixing paint colours so both end with the same shade without ever showing it. HKDF then turns that shade into separately labelled keys, one per door.

Key agreement (ECDH) lets two parties who each hold a private key and know the other’s public key compute the same shared secret without sending it: a.sharedSecretFromKeyAgreement(with: b.publicKey) equals the result from the other side (checked, with Curve25519; P-256 works the same). That secret is raw key material, not a ready key: it is not uniformly random in the way a cipher key should be and it is the same for every use. Feed it through HKDF (hkdfDerivedSymmetricKey) with a salt and a sharedInfo label to get a proper key, and use a different label per purpose. Both sides get the same key for the same inputs, and a different label gives an unrelated key (checked), so one agreement can yield separate encryption and MAC keys. The HKDF implementation matches the published test vector in RFC 5869, a good sanity check that you are using the standard function. Remember agreement alone proves nothing about who you are talking to: without authenticating the public keys (a signature, a certificate, a pinned key) a man in the middle can agree a key with each side.

HPKE (RFC 9180, HPKE in CryptoKit from iOS 17 and macOS 14) packages the whole recipe: a sender encrypts to a recipient’s public key with an ephemeral key, producing an encapsulated key plus ciphertexts, and the recipient opens them with its private key. Messages are sealed in sequence with an internal counter, so sealing the same plaintext twice gives different ciphertexts, and opening them out of order fails with authenticationFailure (checked: the second message cannot be opened first). Use it for sending data to a server’s or a device’s public key (for example encrypting a payload to a key your backend published) instead of inventing a scheme; for a two-way chat with forward secrecy over time use a reviewed protocol. Not run: the Secure Enclave variants, which need a device.

Agreement is symmetric, labels separate keys, and HPKE messages carry sequence state.

Common traps

  • Using the raw shared secret as an AES key. Run it through HKDF with a label.
  • Agreeing a key with an unauthenticated public key. You may be talking to an attacker; bind the key to an identity.
  • Opening HPKE messages in any order. The sequence is part of the state; out-of-order or replayed messages fail.

They might follow up with

  • Why a salt and an info label? The salt strengthens extraction; the label gives domain separation between uses.
  • What is the encapsulated key? The sender’s ephemeral public value the recipient needs to rebuild the shared secret.

Related questions

  • 14.6.1 What does the associated data in AES-GCM bind, what exactly leaks when a nonce is reused, and which errors do…
  • 14.6.3 How do P-256 and Ed25519 signatures differ in CryptoKit: sizes, encodings, determinism, and interoperating…
  • 14.1.2 How do you use cryptography safely: key management, nonces, key derivation, password storage and comparison?
  • 12.9.4 How does HMAC request signing work, what must a canonical string include, and how do you verify a signature…

← What does the associated data in AES-GCM bind, what exactly leaks…   How do P-256 and Ed25519 signatures differ in CryptoKit: sizes… →