04 · The lock
Sources/SophaxChatCore/Crypto/DoubleRatchet.swift
479 lines · Swift
1// DoubleRatchet.swift
2// SophaxChatCore
3//
4// Double Ratchet with Header Encryption (HE).
5//
6// Provides:
7// • Forward secrecy: compromise of current key doesn't expose past messages
8// • Break-in recovery: after compromise, future messages become secure again
9// • Header privacy: ratchet public key, message number, and chain length are
10// encrypted — relay nodes see only opaque ciphertext
11//
12// The Double Ratchet combines:
13// 1. DH Ratchet — generates new DH secrets on each exchange (break-in recovery)
14// 2. Symmetric Ratchet — HMAC chain advancing with each message (forward secrecy)
15// 3. Header Encryption — header encrypted with a rotating header key (privacy)
16//
17// Encryption: ChaCha20-Poly1305 (AEAD), both for header and body.
18// The encrypted header is used as AAD for the body AEAD, binding them together.
19//
20// Reference: https://signal.org/docs/specifications/doubleratchet/ §4.3
21
22import Foundation
23import CryptoKit
24
25// MARK: - Message structures
26
27/// Header included with every Double Ratchet message.
28/// Serialised to JSON, then encrypted with the sending header key before transmission.
29public struct RatchetHeader: Codable, Equatable, Sendable {
30 /// Sender's current DH ratchet public key.
31 public let senderRatchetKey: Data
32 /// Number of messages sent in the PREVIOUS sending chain (PN).
33 public let previousChainLength: UInt32
34 /// Message number in the CURRENT sending chain (N).
35 public let messageNumber: UInt32
36}
37
38/// A complete encrypted Double Ratchet message.
39public struct RatchetMessage: Codable, Sendable {
40 /// Encrypted header: ChaCha20-Poly1305(nonce‖ciphertext‖tag).
41 /// Also used as AAD for the body AEAD, preventing header/body swapping.
42 public let encryptedHeader: Data
43 /// ChaCha20-Poly1305 sealed box: nonce(12B) + ciphertext + tag(16B).
44 public let ciphertext: Data
45}
46
47// MARK: - Session State (Codable for Keychain persistence)
48
49struct RatchetSessionState: Codable {
50 // DH ratchet sending key pair
51 var sendingRatchetPrivateKey: Data
52 var sendingRatchetPublicKey: Data
53
54 // DH ratchet receiving public key (nil until first message received)
55 var receivingRatchetPublicKey: Data?
56
57 // Root key
58 var rootKey: SerializableSymmetricKey
59
60 // Chain keys (nil until the corresponding ratchet step happens)
61 var sendingChainKey: SerializableSymmetricKey?
62 var receivingChainKey: SerializableSymmetricKey?
63
64 // Message counters
65 var sendMessageCount: UInt32 = 0
66 var receiveMessageCount: UInt32 = 0
67 var previousSendingChainLength: UInt32 = 0
68
69 // Header encryption keys
70 var sendingHeaderKey: SerializableSymmetricKey? // HKs
71 var receivingHeaderKey: SerializableSymmetricKey? // HKr
72 var nextSendingHeaderKey: SerializableSymmetricKey? // NHKs
73 var nextReceivingHeaderKey: SerializableSymmetricKey? // NHKr
74
75 // Skipped message keys indexed by base64(headerKey) → msgNumStr → messageKey.
76 // Bounded by CryptoConstants.maxSkippedMessages across all bundles.
77 var skippedKeyBundles: [String: [String: SerializableSymmetricKey]] = [:]
78}
79
80// MARK: - Double Ratchet
81
82// Threading contract: a DoubleRatchet session is NOT thread-safe. Each session
83// instance must be accessed from a single thread or under external mutual exclusion.
84// ChatManager serializes all encrypt/decrypt calls through its internal dispatch queue
85// (via `withSession`). Never share a session instance across concurrent callers.
86public final class DoubleRatchet: @unchecked Sendable {
87
88 private var state: RatchetSessionState
89
90 // MARK: - Initialization
91
92 /// Initialize as the **initiating party (Alice)**.
93 /// Called after X3DH with the shared secret and Bob's initial ratchet public key (SPK_B).
94 ///
95 /// Header key setup (derived from SK before the DH ratchet step):
96 /// HKs = HKDF(SK, hkAliceInfo) — Alice's first sending header key
97 /// NHKr = HKDF(SK, nhkBobInfo) — Alice's initial NHKr (= Bob's NHKs)
98 /// NHKs comes from the initial kdfRK output (DH ratchet step Alice performs immediately)
99 public static func initAsInitiator(
100 sharedSecret: SymmetricKey,
101 remoteRatchetPublicKey: Data // Bob's SPK public key
102 ) throws -> DoubleRatchet {
103 let sendingPair = DHKeyPair()
104 let remoteKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: remoteRatchetPublicKey)
105
106 // Derive initial header keys from X3DH shared secret
107 let hks = deriveHeaderKey(from: sharedSecret, info: CryptoConstants.hkAliceInfo)
108 let nhkr = deriveHeaderKey(from: sharedSecret, info: CryptoConstants.nhkBobInfo)
109
110 // Perform initial DH ratchet step — produces sending chain key and NHKs
111 let dhOut = try sendingPair.privateKey.sharedSecretFromKeyAgreement(with: remoteKey)
112 let (newRootKey, sendingChainKey, nhks) = kdfRK(rootKey: sharedSecret, dhOutput: dhOut)
113
114 let state = RatchetSessionState(
115 sendingRatchetPrivateKey: sendingPair.privateKey.rawRepresentation,
116 sendingRatchetPublicKey: sendingPair.publicKeyData,
117 receivingRatchetPublicKey: remoteRatchetPublicKey,
118 rootKey: SerializableSymmetricKey(newRootKey),
119 sendingChainKey: SerializableSymmetricKey(sendingChainKey),
120 receivingChainKey: nil,
121 sendingHeaderKey: SerializableSymmetricKey(hks),
122 receivingHeaderKey: nil,
123 nextSendingHeaderKey: SerializableSymmetricKey(nhks),
124 nextReceivingHeaderKey: SerializableSymmetricKey(nhkr)
125 )
126 return DoubleRatchet(state: state)
127 }
128
129 /// Initialize as the **receiving party (Bob)**.
130 /// Called after X3DH with the shared secret.
131 ///
132 /// Header key setup:
133 /// NHKr = HKDF(SK, hkAliceInfo) — Alice's HKs triggers Bob's first DHRatchet
134 /// NHKs = HKDF(SK, nhkBobInfo) — Bob's first sending header key (after DHRatchet)
135 /// HKs and HKr start nil; Alice's first message arrives via NHKr path → DHRatchet
136 public static func initAsResponder(
137 sharedSecret: SymmetricKey,
138 ownRatchetKeyPair: DHKeyPair // Bob's SPK, which becomes his initial ratchet key
139 ) throws -> DoubleRatchet {
140 let nhkr = deriveHeaderKey(from: sharedSecret, info: CryptoConstants.hkAliceInfo)
141 let nhks = deriveHeaderKey(from: sharedSecret, info: CryptoConstants.nhkBobInfo)
142
143 let state = RatchetSessionState(
144 sendingRatchetPrivateKey: ownRatchetKeyPair.privateKey.rawRepresentation,
145 sendingRatchetPublicKey: ownRatchetKeyPair.publicKeyData,
146 receivingRatchetPublicKey: nil,
147 rootKey: SerializableSymmetricKey(sharedSecret),
148 sendingChainKey: nil,
149 receivingChainKey: nil,
150 sendingHeaderKey: nil,
151 receivingHeaderKey: nil,
152 nextSendingHeaderKey: SerializableSymmetricKey(nhks),
153 nextReceivingHeaderKey: SerializableSymmetricKey(nhkr)
154 )
155 return DoubleRatchet(state: state)
156 }
157
158 private init(state: RatchetSessionState) {
159 self.state = state
160 }
161
162 // MARK: - Encrypt
163
164 /// Encrypt a plaintext message.
165 /// - Parameters:
166 /// - plaintext: Raw message bytes
167 /// - associatedData: Additional data to authenticate (e.g. sorted peer IDs)
168 /// - Returns: Encrypted `RatchetMessage` ready to send
169 public func encrypt(plaintext: Data, associatedData: Data = Data()) throws -> RatchetMessage {
170 guard let ck = state.sendingChainKey?.key else {
171 throw SophaxError.missingChainKey
172 }
173 guard let hks = state.sendingHeaderKey?.key else {
174 throw SophaxError.missingChainKey
175 }
176
177 // Advance the sending chain
178 let (newCK, mk) = Self.kdfCK(ck)
179 state.sendingChainKey = SerializableSymmetricKey(newCK)
180
181 guard state.sendMessageCount < CryptoConstants.maxChainMessages else { throw SophaxError.counterOverflow }
182 let header = RatchetHeader(
183 senderRatchetKey: state.sendingRatchetPublicKey,
184 previousChainLength: state.previousSendingChainLength,
185 messageNumber: state.sendMessageCount
186 )
187 state.sendMessageCount += 1
188
189 let encryptedHeader = try encryptHeader(header, using: hks)
190 let ciphertext = try encryptBody(mk, plaintext: plaintext,
191 encryptedHeader: encryptedHeader,
192 associatedData: associatedData)
193 return RatchetMessage(encryptedHeader: encryptedHeader, ciphertext: ciphertext)
194 }
195
196 // MARK: - Decrypt
197
198 /// Decrypt an incoming `RatchetMessage`.
199 /// - Parameters:
200 /// - message: The received encrypted message
201 /// - associatedData: Must match what the sender used in `encrypt`
202 /// - Returns: Decrypted plaintext
203 public func decrypt(message: RatchetMessage, associatedData: Data = Data()) throws -> Data {
204
205 // 1. Check skipped message keys first (handles out-of-order delivery)
206 if let plaintext = try decryptWithSkippedKey(message: message, associatedData: associatedData) {
207 return plaintext
208 }
209
210 // 2. Try decrypting the header with the current receiving header key (same DH epoch)
211 if let hkr = state.receivingHeaderKey?.key,
212 let header = try? decryptHeaderBytes(message.encryptedHeader, using: hkr) {
213 try skipMessageKeys(until: header.messageNumber)
214 return try decryptBody(advanceReceivingChain(), message: message, associatedData: associatedData)
215 }
216
217 // 3. Try the next receiving header key (new DH epoch — performs DHRatchet first)
218 guard let nhkr = state.nextReceivingHeaderKey?.key,
219 let header = try? decryptHeaderBytes(message.encryptedHeader, using: nhkr) else {
220 throw SophaxError.decryptionFailed
221 }
222 try skipMessageKeys(until: header.previousChainLength)
223 try dhRatchetStep(with: header.senderRatchetKey)
224 try skipMessageKeys(until: header.messageNumber)
225 return try decryptBody(advanceReceivingChain(), message: message, associatedData: associatedData)
226 }
227
228 // MARK: - State Persistence
229
230 public func exportState() throws -> Data {
231 try JSONEncoder().encode(state)
232 }
233
234 public static func importState(_ data: Data) throws -> DoubleRatchet {
235 let state = try JSONDecoder().decode(RatchetSessionState.self, from: data)
236 // Bound the number of distinct header-key buckets in addition to the total
237 // key count. Without this, a crafted state blob could allocate O(n) dictionary
238 // entries that each contain only a few keys and still pass the total-count check.
239 guard state.skippedKeyBundles.count <= 50 else {
240 throw SophaxError.invalidState
241 }
242 let totalSkipped = state.skippedKeyBundles.values.reduce(0) { $0 + $1.count }
243 guard totalSkipped <= CryptoConstants.maxSkippedMessages else {
244 throw SophaxError.invalidState
245 }
246 guard state.sendMessageCount < CryptoConstants.maxChainMessages,
247 state.receiveMessageCount < CryptoConstants.maxChainMessages else {
248 throw SophaxError.invalidState
249 }
250 // Verify that the stored sending key pair is internally consistent.
251 // A crafted or corrupted blob could carry mismatched private/public bytes,
252 // silently producing wrong DH outputs on the next ratchet step.
253 guard state.sendingRatchetPrivateKey.count == 32,
254 state.sendingRatchetPublicKey.count == 32 else {
255 throw SophaxError.invalidState
256 }
257 let derivedSendingPriv = try Curve25519.KeyAgreement.PrivateKey(
258 rawRepresentation: state.sendingRatchetPrivateKey)
259 guard derivedSendingPriv.publicKey.rawRepresentation == state.sendingRatchetPublicKey else {
260 throw SophaxError.invalidState
261 }
262 // Validate chain key widths.
263 if let ck = state.sendingChainKey {
264 guard ck.key.withUnsafeBytes({ $0.count }) == 32 else { throw SophaxError.invalidState }
265 }
266 if let ck = state.receivingChainKey {
267 guard ck.key.withUnsafeBytes({ $0.count }) == 32 else { throw SophaxError.invalidState }
268 }
269 return DoubleRatchet(state: state)
270 }
271
272 // MARK: - Private: DH Ratchet Step
273
274 /// Advance the DH ratchet: rotate header keys, derive new receiving chain,
275 /// generate new sending key pair, derive new sending chain.
276 private func dhRatchetStep(with remoteRatchetPublicKeyData: Data) throws {
277 // Validate the remote key BEFORE mutating any state. A malicious sender could
278 // send an invalid key that causes this to throw — if state were already mutated
279 // (counters zeroed, header keys rotated) the session would be permanently broken.
280 let remoteKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: remoteRatchetPublicKeyData)
281
282 state.previousSendingChainLength = state.sendMessageCount
283 state.sendMessageCount = 0
284 state.receiveMessageCount = 0
285
286 // Rotate header keys (NHK → HK)
287 state.sendingHeaderKey = state.nextSendingHeaderKey
288 state.receivingHeaderKey = state.nextReceivingHeaderKey
289
290 state.receivingRatchetPublicKey = remoteRatchetPublicKeyData
291 let currentSendingKey = try Curve25519.KeyAgreement.PrivateKey(
292 rawRepresentation: state.sendingRatchetPrivateKey
293 )
294
295 // Step 1: DH(current_sending, new_remote) → receiving chain key + NHKr
296 let dhOut1 = try currentSendingKey.sharedSecretFromKeyAgreement(with: remoteKey)
297 let (rk1, receivingCK, newNHKr) = Self.kdfRK(rootKey: state.rootKey.key, dhOutput: dhOut1)
298 state.rootKey = SerializableSymmetricKey(rk1)
299 state.receivingChainKey = SerializableSymmetricKey(receivingCK)
300 state.nextReceivingHeaderKey = SerializableSymmetricKey(newNHKr)
301
302 // Step 2: Generate new sending ratchet key pair
303 let newSendingPair = DHKeyPair()
304 state.sendingRatchetPrivateKey = newSendingPair.privateKey.rawRepresentation
305 state.sendingRatchetPublicKey = newSendingPair.publicKeyData
306
307 // Step 3: DH(new_sending, new_remote) → sending chain key + NHKs
308 let dhOut2 = try newSendingPair.privateKey.sharedSecretFromKeyAgreement(with: remoteKey)
309 let (rk2, sendingCK, newNHKs) = Self.kdfRK(rootKey: rk1, dhOutput: dhOut2)
310 state.rootKey = SerializableSymmetricKey(rk2)
311 state.sendingChainKey = SerializableSymmetricKey(sendingCK)
312 state.nextSendingHeaderKey = SerializableSymmetricKey(newNHKs)
313 }
314
315 // MARK: - Private: Chain advance
316
317 /// Advance the receiving chain by one step. Returns the message key for this position.
318 private func advanceReceivingChain() throws -> MessageKey {
319 guard let ck = state.receivingChainKey?.key else { throw SophaxError.missingChainKey }
320 guard state.receiveMessageCount < CryptoConstants.maxChainMessages else { throw SophaxError.counterOverflow }
321 let (newCK, mk) = Self.kdfCK(ck)
322 state.receivingChainKey = SerializableSymmetricKey(newCK)
323 state.receiveMessageCount += 1
324 return mk
325 }
326
327 // MARK: - Private: Skip message keys
328
329 /// Compute and store message keys for messages Nr through `target - 1`.
330 /// Keyed by base64(HKr) so out-of-order messages can still be decrypted
331 /// even after the header key rotates.
332 private func skipMessageKeys(until target: UInt32) throws {
333 guard let ck = state.receivingChainKey?.key else { return }
334
335 // Use UInt32 subtraction to avoid Int underflow on crafted messageNumber values.
336 guard target > state.receiveMessageCount else { return }
337 let totalSkipped = Int(target - state.receiveMessageCount)
338
339 let totalStored = state.skippedKeyBundles.values.reduce(0) { $0 + $1.count }
340 guard totalStored + totalSkipped <= CryptoConstants.maxSkippedMessages else {
341 throw SophaxError.tooManySkippedMessages
342 }
343
344 // Index skipped keys by the current receiving header key
345 let hkrB64 = state.receivingHeaderKey.map {
346 $0.key.withUnsafeBytes { Data($0) }.base64EncodedString()
347 } ?? ""
348
349 var chainKey = ck
350 while state.receiveMessageCount < target {
351 let (newCK, mk) = Self.kdfCK(chainKey)
352 let msgNumStr = "\(state.receiveMessageCount)"
353 state.skippedKeyBundles[hkrB64, default: [:]][msgNumStr] = SerializableSymmetricKey(mk)
354 chainKey = newCK
355 state.receiveMessageCount += 1
356 }
357 state.receivingChainKey = SerializableSymmetricKey(chainKey)
358 }
359
360 // MARK: - Private: Try skipped key decryption
361
362 private func decryptWithSkippedKey(message: RatchetMessage, associatedData: Data) throws -> Data? {
363 for (headerKeyB64, bundle) in state.skippedKeyBundles {
364 guard let headerKeyData = Data(base64Encoded: headerKeyB64) else { continue }
365 let headerKey = SymmetricKey(data: headerKeyData)
366 guard let header = try? decryptHeaderBytes(message.encryptedHeader, using: headerKey) else {
367 continue
368 }
369 let msgNumStr = "\(header.messageNumber)"
370 guard let skippedMK = bundle[msgNumStr]?.key else { continue }
371
372 var updatedBundle = bundle
373 updatedBundle.removeValue(forKey: msgNumStr)
374 if updatedBundle.isEmpty {
375 state.skippedKeyBundles.removeValue(forKey: headerKeyB64)
376 } else {
377 state.skippedKeyBundles[headerKeyB64] = updatedBundle
378 }
379 return try decryptBody(skippedMK, message: message, associatedData: associatedData)
380 }
381 return nil
382 }
383
384 // MARK: - Private: Header encryption/decryption
385
386 /// Static domain-separator bound to every header AEAD tag.
387 /// Prevents cross-protocol confusion and makes the empty-AAD choice explicit.
388 private static let headerAAD = Data("SophaxChat_DR_Header_v2".utf8)
389
390 private func encryptHeader(_ header: RatchetHeader, using key: HeaderKey) throws -> Data {
391 let headerData = try JSONEncoder().encode(header)
392 let nonce = ChaChaPoly.Nonce()
393 let sealed = try ChaChaPoly.seal(headerData, using: key, nonce: nonce,
394 authenticating: DoubleRatchet.headerAAD)
395 return sealed.combined
396 }
397
398 private func decryptHeaderBytes(_ encryptedHeader: Data, using key: HeaderKey) throws -> RatchetHeader {
399 do {
400 let sealedBox = try ChaChaPoly.SealedBox(combined: encryptedHeader)
401 let headerData = try ChaChaPoly.open(sealedBox, using: key,
402 authenticating: DoubleRatchet.headerAAD)
403 return try JSONDecoder().decode(RatchetHeader.self, from: headerData)
404 } catch {
405 throw SophaxError.decryptionFailed
406 }
407 }
408
409 // MARK: - Private: Body encryption/decryption
410
411 private func encryptBody(
412 _ mk: MessageKey,
413 plaintext: Data,
414 encryptedHeader: Data,
415 associatedData: Data
416 ) throws -> Data {
417 let aad = associatedData + encryptedHeader // binds header to body
418 let nonce = ChaChaPoly.Nonce()
419 let sealed = try ChaChaPoly.seal(plaintext, using: mk, nonce: nonce, authenticating: aad)
420 return sealed.combined
421 }
422
423 private func decryptBody(
424 _ mk: MessageKey,
425 message: RatchetMessage,
426 associatedData: Data
427 ) throws -> Data {
428 let aad = associatedData + message.encryptedHeader
429 do {
430 let sealedBox = try ChaChaPoly.SealedBox(combined: message.ciphertext)
431 return try ChaChaPoly.open(sealedBox, using: mk, authenticating: aad)
432 } catch {
433 throw SophaxError.decryptionFailed
434 }
435 }
436
437 // MARK: - KDF Functions
438
439 /// KDF_RK(rk, dh_out) → (new_root_key, chain_key, next_header_key)
440 /// HKDF-SHA256: salt=rk, IKM=dh_out, info=rkInfo, output=96 bytes (32+32+32)
441 static func kdfRK(rootKey: SymmetricKey, dhOutput: SharedSecret) -> (RootKey, ChainKey, HeaderKey) {
442 let saltData = rootKey.withUnsafeBytes { Data($0) }
443
444 var ikmData = Data()
445 dhOutput.withUnsafeBytes { ikmData.append(contentsOf: $0) }
446
447 let derived = HKDF<SHA256>.deriveKey(
448 inputKeyMaterial: SymmetricKey(data: ikmData),
449 salt: saltData,
450 info: CryptoConstants.rkInfo,
451 outputByteCount: 96
452 )
453
454 let derivedBytes = derived.withUnsafeBytes { Data($0) }
455 return (
456 SymmetricKey(data: derivedBytes[0..<32]), // new root key
457 SymmetricKey(data: derivedBytes[32..<64]), // new chain key
458 SymmetricKey(data: derivedBytes[64..<96]) // next header key
459 )
460 }
461
462 /// KDF_CK(ck) → (new_chain_key, message_key)
463 /// HMAC-SHA256(ck, 0x02) = next chain key
464 /// HMAC-SHA256(ck, 0x01) = message key
465 static func kdfCK(_ chainKey: ChainKey) -> (ChainKey, MessageKey) {
466 let newCKBytes = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: chainKey))
467 let mkBytes = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: chainKey))
468 return (SymmetricKey(data: newCKBytes), SymmetricKey(data: mkBytes))
469 }
470
471 /// Derive a header key from the X3DH shared secret using a distinct info string.
472 private static func deriveHeaderKey(from sharedSecret: SymmetricKey, info: Data) -> HeaderKey {
473 HKDF<SHA256>.deriveKey(
474 inputKeyMaterial: sharedSecret,
475 info: info,
476 outputByteCount: 32
477 )
478 }
479}