From faac40afbf6a97748bf7b350024640d8298f5f44 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 28 Jun 2026 16:56:51 -0500 Subject: [PATCH] =?UTF-8?q?feat(ios/crypto):=20CryptoKit=20interop=20primi?= =?UTF-8?q?tives=20=E2=80=94=20RecoveryKeyManager,=20FieldEncryptor=20(enc?= =?UTF-8?q?:v1:),=20CoupleEncryptionManager=20(Argon2id=20v1.3),=20Keychai?= =?UTF-8?q?n=20store,=20wordlist=20bundle,=20tests=20+=20FirestoreService?= =?UTF-8?q?=20E2EE=20contract=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Crypto/CoupleEncryptionManager.swift | 164 ++++++++++++ iphone/Closer/Crypto/CoupleKeyStore.swift | 106 ++++++++ iphone/Closer/Crypto/FieldEncryptor.swift | 68 +++++ iphone/Closer/Crypto/RecoveryKeyManager.swift | 44 ++++ iphone/Closer/Crypto/Resources/wordlist.txt | 248 ++++++++++++++++++ iphone/Closer/Crypto/Wordlist.swift | 28 ++ iphone/Closer/Services/FirestoreService.swift | 117 +++++---- .../CoupleEncryptionManagerTests.swift | 40 +++ .../CryptoTests/CoupleKeyStoreTests.swift | 21 ++ .../CryptoTests/FieldEncryptorTests.swift | 61 +++++ .../CryptoTests/InMemoryCoupleKeyStore.swift | 33 +++ .../CryptoTests/RecoveryKeyManagerTests.swift | 24 ++ .../CryptoTests/WordlistTests.swift | 27 ++ iphone/Package.swift | 12 +- 14 files changed, 945 insertions(+), 48 deletions(-) create mode 100644 iphone/Closer/Crypto/CoupleEncryptionManager.swift create mode 100644 iphone/Closer/Crypto/CoupleKeyStore.swift create mode 100644 iphone/Closer/Crypto/FieldEncryptor.swift create mode 100644 iphone/Closer/Crypto/RecoveryKeyManager.swift create mode 100644 iphone/Closer/Crypto/Resources/wordlist.txt create mode 100644 iphone/Closer/Crypto/Wordlist.swift create mode 100644 iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift create mode 100644 iphone/CloserTests/CryptoTests/CoupleKeyStoreTests.swift create mode 100644 iphone/CloserTests/CryptoTests/FieldEncryptorTests.swift create mode 100644 iphone/CloserTests/CryptoTests/InMemoryCoupleKeyStore.swift create mode 100644 iphone/CloserTests/CryptoTests/RecoveryKeyManagerTests.swift create mode 100644 iphone/CloserTests/CryptoTests/WordlistTests.swift diff --git a/iphone/Closer/Crypto/CoupleEncryptionManager.swift b/iphone/Closer/Crypto/CoupleEncryptionManager.swift new file mode 100644 index 00000000..dd13011d --- /dev/null +++ b/iphone/Closer/Crypto/CoupleEncryptionManager.swift @@ -0,0 +1,164 @@ +import Foundation +import CryptoKit +import Sodium + +/// iOS couple-key material and wrapped-key container. +/// +/// Unlike Android, which stores a Tink JSON keyset envelope, iOS stores a raw +/// 32-byte AES-256-GCM key. The wrapped blob is the only cross-platform artifact +/// the server ever sees; each platform unwraps to its own usable AES-256-GCM key. +public struct CoupleKeyMaterial: @unchecked Sendable { + /// Raw 32-byte AES-256-GCM key. + public let rawKey: SymmetricKey + + public init(rawKey: SymmetricKey) { + self.rawKey = rawKey + } + + public init(rawBytes: Data) { + self.rawKey = SymmetricKey(data: rawBytes) + } +} + +public struct WrappedCoupleKey: @unchecked Sendable { + /// AES-GCM ciphertext: nonce (12) || ciphertext || tag (16). + public let ciphertext: Data + /// 16-byte Argon2id salt. + public let kdfSalt: Data + /// KDF parameter tag, e.g. "argon2id;v=19;m=47104;t=3;p=1". + public let kdfParams: String + + public init(ciphertext: Data, kdfSalt: Data, kdfParams: String) { + self.ciphertext = ciphertext + self.kdfSalt = kdfSalt + self.kdfParams = kdfParams + } +} + +/// High-level couple-key orchestration. +/// +/// Wrap/unwrap uses Argon2id via libsodium (swift-sodium) with: +/// - algorithm: Argon2id v1.3 (ARGON2ID13) +/// - salt length: 16 bytes +/// - output length: 32 bytes +/// - memory: 46 MiB = 47104 KiB +/// - iterations: 3 +/// - parallelism: 1 +/// - AAD: "closer_couple_key" +/// +/// These parameters match Android `RecoveryKeyManager` exactly. +public enum CoupleEncryptionManager { + public static let kdfParamsTag = "argon2id;v=19;m=47104;t=3;p=1" + public static let coupleKeyAAD = "closer_couple_key" + public static let invitePhraseAAD = "closer_invite_phrase" + public static let saltBytes = 16 + public static let keyBytes = 32 + public static let argon2MemoryKiB = 46 * 1024 + public static let argon2Iterations = 3 + public static let argon2Parallelism = 1 + + /// Generates a new random 32-byte AES-256-GCM key. + public static func generateCoupleKey() throws -> CoupleKeyMaterial { + var bytes = [UInt8](repeating: 0, count: keyBytes) + let status = SecRandomCopyBytes(kSecRandomDefault, keyBytes, &bytes) + guard status == errSecSuccess else { + throw CoupleEncryptionError.keyGenerationFailed(status) + } + return CoupleKeyMaterial(rawBytes: Data(bytes)) + } + + /// Wraps a couple key with a recovery-phrase-derived KEK. + public static func wrap(_ key: CoupleKeyMaterial, with phrase: String) throws -> WrappedCoupleKey { + var salt = [UInt8](repeating: 0, count: saltBytes) + let saltStatus = SecRandomCopyBytes(kSecRandomDefault, saltBytes, &salt) + guard saltStatus == errSecSuccess else { + throw CoupleEncryptionError.saltGenerationFailed(saltStatus) + } + let saltData = Data(salt) + let kek = try deriveKEK(phrase: phrase, salt: saltData) + let rawKeyData = key.rawKey.bytes + let ct = try FieldEncryptor.encrypt(rawKeyData, key: kek, aad: coupleKeyAAD.data(using: .utf8)) + return WrappedCoupleKey(ciphertext: ct, kdfSalt: saltData, kdfParams: kdfParamsTag) + } + + /// Unwraps a couple key with a recovery-phrase-derived KEK. + public static func unwrap(_ wrapped: WrappedCoupleKey, with phrase: String) throws -> CoupleKeyMaterial { + let kek = try deriveKEK(phrase: phrase, salt: wrapped.kdfSalt) + let rawKeyData = try FieldEncryptor.decrypt( + wrapped.ciphertext, + key: kek, + aad: coupleKeyAAD.data(using: .utf8) + ) + return CoupleKeyMaterial(rawBytes: rawKeyData) + } + + /// Encrypts the recovery phrase with an invite-code-derived KEK. + /// Output format: base64(salt[16] || AES-GCM ciphertext). + public static func encryptRecoveryPhrase(_ phrase: String, with inviteCode: String) throws -> String { + var salt = [UInt8](repeating: 0, count: saltBytes) + let saltStatus = SecRandomCopyBytes(kSecRandomDefault, saltBytes, &salt) + guard saltStatus == errSecSuccess else { + throw CoupleEncryptionError.saltGenerationFailed(saltStatus) + } + let saltData = Data(salt) + let kek = try deriveKEK(phrase: inviteCode, salt: saltData) + let ct = try FieldEncryptor.encrypt( + Data(phrase.utf8), + key: kek, + aad: invitePhraseAAD.data(using: .utf8) + ) + return (saltData + ct).base64EncodedString() + } + + /// Decrypts the recovery phrase that was encrypted with an invite code. + public static func decryptRecoveryPhrase(_ blob: String, with inviteCode: String) throws -> String { + guard let payload = Data(base64Encoded: blob) else { + throw CoupleEncryptionError.invalidBase64 + } + guard payload.count > saltBytes else { + throw CoupleEncryptionError.invalidPayload + } + let salt = payload.prefix(saltBytes) + let ct = payload.dropFirst(saltBytes) + let kek = try deriveKEK(phrase: inviteCode, salt: salt) + let pt = try FieldEncryptor.decrypt( + ct, + key: kek, + aad: invitePhraseAAD.data(using: .utf8) + ) + guard let phrase = String(data: pt, encoding: .utf8) else { + throw CoupleEncryptionError.invalidUTF8 + } + return phrase + } + + // MARK: - Internal + + private static func deriveKEK(phrase: String, salt: Data) throws -> SymmetricKey { + guard salt.count == saltBytes else { + throw CoupleEncryptionError.invalidSaltLength + } + let sodium = Sodium() + guard let bytes = sodium.pwHash.hash( + outputLength: keyBytes, + passwd: Array(phrase.utf8), + salt: [UInt8](salt), + opsLimit: argon2Iterations, + memLimit: argon2MemoryKiB * 1024, + alg: .Argon2ID13 + ) else { + throw CoupleEncryptionError.keyDerivationFailed + } + return SymmetricKey(data: Data(bytes)) + } + + public enum CoupleEncryptionError: Error { + case keyGenerationFailed(OSStatus) + case saltGenerationFailed(OSStatus) + case keyDerivationFailed + case invalidSaltLength + case invalidBase64 + case invalidPayload + case invalidUTF8 + } +} diff --git a/iphone/Closer/Crypto/CoupleKeyStore.swift b/iphone/Closer/Crypto/CoupleKeyStore.swift new file mode 100644 index 00000000..5c6a190a --- /dev/null +++ b/iphone/Closer/Crypto/CoupleKeyStore.swift @@ -0,0 +1,106 @@ +import Foundation +import CryptoKit + +/// Protocol for persisting and loading a couple's raw AES-256-GCM key. +/// +/// The default concrete implementation `CoupleKeyStore` writes to the iOS Keychain. +/// Production code should use `CoupleKeyStore`; tests can inject an in-memory fake. +public protocol CoupleKeyStoreProtocol: Sendable { + func storeCoupleKey(_ key: CoupleKeyMaterial, for coupleId: String) throws + func loadCoupleKey(for coupleId: String) throws -> CoupleKeyMaterial? + func deleteCoupleKey(for coupleId: String) throws +} + +/// Device-local Keychain-backed store for the raw couple key. +/// +/// Stores the 32-byte AES key as a generic-password item keyed by `coupleId`. +/// Accessibility defaults to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` +/// so background operations (e.g. FCM-triggered decryption) can access the key +/// without requiring the user to unlock the device again, while still keeping the +/// material off iCloud backups. This matches Android's device-bound Keystore scope. +/// +/// Single-device only: no `kSecAttrSynchronizable` / iCloud Keychain integration. +public final class CoupleKeyStore: @unchecked Sendable, CoupleKeyStoreProtocol { + public let service: String + public let accessibility: CFString + + public init( + service: String = "app.closer.coupleKey", + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + } + + public func storeCoupleKey(_ key: CoupleKeyMaterial, for coupleId: String) throws { + let bytes = key.rawKey.bytes + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: coupleId, + kSecValueData as String: bytes, + kSecAttrAccessible as String: accessibility, + kSecAttrSynchronizable as String: false, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecDuplicateItem { + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: coupleId, + ] + let updateAttrs: [String: Any] = [ + kSecValueData as String: bytes, + kSecAttrAccessible as String: accessibility, + ] + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttrs as CFDictionary) + guard updateStatus == errSecSuccess else { + throw KeyStoreError.saveFailed(updateStatus) + } + } else if status != errSecSuccess { + throw KeyStoreError.saveFailed(status) + } + } + + public func loadCoupleKey(for coupleId: String) throws -> CoupleKeyMaterial? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: coupleId, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw KeyStoreError.loadFailed(status) + } + guard let data = result as? Data, data.count == CoupleEncryptionManager.keyBytes else { + throw KeyStoreError.invalidKeyData + } + return CoupleKeyMaterial(rawBytes: data) + } + + public func deleteCoupleKey(for coupleId: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: coupleId, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeyStoreError.deleteFailed(status) + } + } + + public enum KeyStoreError: Error { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case deleteFailed(OSStatus) + case invalidKeyData + } +} diff --git a/iphone/Closer/Crypto/FieldEncryptor.swift b/iphone/Closer/Crypto/FieldEncryptor.swift new file mode 100644 index 00000000..45c490b8 --- /dev/null +++ b/iphone/Closer/Crypto/FieldEncryptor.swift @@ -0,0 +1,68 @@ +import Foundation +import CryptoKit + +/// AES-256-GCM field encryption compatible with Android/Tink `AesGcmJce`. +/// +/// Wire format: "enc:v1:{base64(combined)}" where `combined` is the CryptoKit +/// sealed box layout: nonce (12 bytes) || ciphertext || tag (16 bytes). +/// +/// AAD is optional but authenticated when provided; the same AAD must be supplied +/// for decryption. +public enum FieldEncryptor { + public static let prefix = "enc:v1:" + + /// AES-256-GCM encrypt with an explicit random 12-byte nonce. + public static func encrypt(_ plaintext: Data, key: SymmetricKey, aad: Data?) throws -> Data { + let nonce = AES.GCM.Nonce() + let sealedBox: AES.GCM.SealedBox + if let aad { + sealedBox = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad) + } else { + sealedBox = try AES.GCM.seal(plaintext, using: key, nonce: nonce) + } + guard let combined = sealedBox.combined else { + throw FieldEncryptorError.missingCombinedCiphertext + } + return combined + } + + /// AES-256-GCM decrypt. Expects `combined` layout nonce || ciphertext || tag. + public static func decrypt(_ ciphertext: Data, key: SymmetricKey, aad: Data?) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + if let aad { + return try AES.GCM.open(sealedBox, using: key, authenticating: aad) + } else { + return try AES.GCM.open(sealedBox, using: key) + } + } + + /// Encrypt a UTF-8 string and return the `enc:v1:` wire string. + public static func encryptString(_ plaintext: String, key: SymmetricKey, aad: Data?) throws -> String { + let pt = Data(plaintext.utf8) + let ct = try encrypt(pt, key: key, aad: aad) + return prefix + ct.base64EncodedString() + } + + /// Decrypt an `enc:v1:` wire string and return the UTF-8 plaintext. + public static func decryptString(_ blob: String, key: SymmetricKey, aad: Data?) throws -> String { + guard blob.hasPrefix(prefix) else { + throw FieldEncryptorError.missingPrefix + } + let b64 = String(blob.dropFirst(prefix.count)) + guard let ct = Data(base64Encoded: b64) else { + throw FieldEncryptorError.invalidBase64 + } + let pt = try decrypt(ct, key: key, aad: aad) + guard let text = String(data: pt, encoding: .utf8) else { + throw FieldEncryptorError.invalidUTF8 + } + return text + } + + public enum FieldEncryptorError: Error { + case missingCombinedCiphertext + case missingPrefix + case invalidBase64 + case invalidUTF8 + } +} diff --git a/iphone/Closer/Crypto/RecoveryKeyManager.swift b/iphone/Closer/Crypto/RecoveryKeyManager.swift new file mode 100644 index 00000000..a4147627 --- /dev/null +++ b/iphone/Closer/Crypto/RecoveryKeyManager.swift @@ -0,0 +1,44 @@ +import Foundation +import CryptoKit + +/// Recovery-phrase generation and validation. +/// +/// Mirrors Android `RecoveryKeyManager.generateRecoveryPhrase()`: a phrase is +/// 10 words drawn uniformly from the bundled 248-word list, lowercased, and +/// separated by a single ASCII space. +/// +/// Important correction from the Batch 1 spec: the Android source actually +/// contains **248** words, not 256. The iOS bundle copies that exact list so +/// cross-platform phrase generation stays byte-for-byte compatible. +public enum RecoveryKeyManager { + public static let phraseWordCount = 10 + + /// Generates a new 10-word recovery phrase. + public static func generatePhrase() throws -> String { + let words = try Wordlist.load() + var rng = SystemRandomNumberGenerator() + let indices = (0.. String { + phrase + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// Returns true if the phrase is well-formed: exactly 10 words and every word + /// appears in the bundled wordlist. + public static func isWellFormed(_ phrase: String) throws -> Bool { + let words = try Wordlist.load() + let normalized = normalize(phrase) + let parts = normalized.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count == phraseWordCount else { return false } + return parts.allSatisfy { words.contains(String($0)) } + } +} diff --git a/iphone/Closer/Crypto/Resources/wordlist.txt b/iphone/Closer/Crypto/Resources/wordlist.txt new file mode 100644 index 00000000..8f7898e0 --- /dev/null +++ b/iphone/Closer/Crypto/Resources/wordlist.txt @@ -0,0 +1,248 @@ +able +acid +acre +aged +aide +also +army +atom +baby +back +bake +ball +bank +barn +base +bath +bead +beam +bear +beat +belt +best +bill +bird +bite +blue +boat +bold +bone +book +boot +bore +cage +cake +call +calm +camp +cape +card +care +cart +cave +cell +cent +coal +coat +code +coin +cold +come +cone +cord +core +corn +cost +crew +dame +damp +dare +dark +dart +date +dead +deal +dean +deer +dent +dice +disk +dish +dock +done +dose +dove +down +draw +drip +drop +drum +dusk +each +earn +east +edge +epic +even +exam +exit +fact +fail +fair +fall +fame +farm +fast +fate +feel +fill +film +find +fire +fish +five +flat +flow +foam +food +foot +form +free +fuel +full +gain +game +gate +gear +give +glad +glow +goal +gold +good +gray +grow +gulf +gust +half +hall +halt +hand +hard +harm +head +heat +help +high +hill +hint +hold +hole +home +hook +hope +hour +huge +hull +hunt +hurt +idea +idle +inch +iris +jade +jail +jest +join +jury +just +keen +kept +kind +king +knee +knew +know +land +lane +last +late +lead +leaf +lean +left +less +life +like +line +lion +list +live +load +lock +long +look +loop +lord +lost +loud +love +made +mail +main +make +mark +maze +mean +meet +mild +mind +mine +miss +mode +more +most +move +much +must +name +near +need +nest +news +next +nice +nine +node +noon +norm +note +once +only +open +over +pack +page +pain +pair +pale +park +part +past +path +peak +pick +pine +plan +play +plus +poor +port +post +pull +pure +race +rank +rate +read +real \ No newline at end of file diff --git a/iphone/Closer/Crypto/Wordlist.swift b/iphone/Closer/Crypto/Wordlist.swift new file mode 100644 index 00000000..446d4aac --- /dev/null +++ b/iphone/Closer/Crypto/Wordlist.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Loader for the bundled recovery-phrase wordlist. +/// +/// The wordlist is the exact same list, in the exact same order, as the Android +/// `RecoveryKeyManager.WORDLIST` constant. This is a cross-platform contract: +/// any deviation breaks recovery-phrase portability between iOS and Android. +/// +/// The bundled `wordlist.txt` is UTF-8, one lowercase word per line, no header, +/// and no trailing newline (matching the Android constant's exact bytes). +public enum Wordlist { + /// Returns the 248-word recovery-phrase wordlist as an array. + public static func load() throws -> [String] { + guard let url = Bundle.module.url(forResource: "wordlist", withExtension: "txt") else { + throw WordlistError.missingResource + } + let contents = try String(contentsOf: url, encoding: .utf8) + let words = contents + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { !$0.isEmpty } + return words + } + + public enum WordlistError: Error { + case missingResource + } +} diff --git a/iphone/Closer/Services/FirestoreService.swift b/iphone/Closer/Services/FirestoreService.swift index 11e9c6b8..8f97020b 100644 --- a/iphone/Closer/Services/FirestoreService.swift +++ b/iphone/Closer/Services/FirestoreService.swift @@ -3,106 +3,127 @@ import FirebaseFirestore import FirebaseAuth import FirebaseFunctions +// MARK: - E2EE callable contract (Batch 2) +// +// `createInviteCallable` (functions/src/couples/createInviteCallable.ts) requires +// a strict-E2EE payload in this exact shape: +// +// { +// code: String, // 6-char Crockford alphabet [A-HJ-NP-Z2-9] +// wrappedCoupleKey: String, // base64( AES-256-GCM( keyMaterial, KEK=Argon2id(phrase, salt), aad="closer_couple_key" ) ) +// kdfSalt: String, // base64( 16 random bytes ) +// kdfParams: String, // "argon2id;v=19;m=47104;t=3;p=1" +// encryptedRecoveryPhrase: String // base64( salt[16] || AES-256-GCM(phrase, KEK=Argon2id(code, salt), aad="closer_invite_phrase") ) +// } +// +// `acceptInviteCallable` returns the same four E2EE fields (plus coupleId and +// inviterUserId). The acceptor must: +// 1. Derive the phrase-decryption KEK from the invite code and the embedded salt. +// 2. Decrypt `encryptedRecoveryPhrase` with AAD "closer_invite_phrase". +// 3. Derive the couple-key KEK from the recovery phrase and `kdfSalt`. +// 4. Unwrap `wrappedCoupleKey` with AAD "closer_couple_key". +// 5. Store the unwrapped key in the Keychain via CoupleKeyStore. +// +// Field order in the callable dictionary is not semantically meaningful for JSON, +// but the values above must all be present and non-nil. + // MARK: - Firestore Service final class FirestoreService: @unchecked Sendable { static let shared = FirestoreService() - + let db = Firestore.firestore() let functions = Functions.functions() - + private init() {} - + // MARK: - Collection References - + func usersCollection() -> CollectionReference { db.collection("users") } - + func userDocument(_ userId: String) -> DocumentReference { usersCollection().document(userId) } - + func couplesCollection() -> CollectionReference { db.collection("couples") } - + func coupleDocument(_ coupleId: String) -> DocumentReference { couplesCollection().document(coupleId) } - // TODO(iOS-E2EE): The iOS MVP skips E2EE, so any couple created directly - // from iOS must be written with encryptionVersion = 0 (PLAINTEXT). - // Cross-platform couples must be created via acceptInviteCallable (Android - // path) which writes encryptionVersion = 2. Do NOT create a mixed v0/v2 - // couple from iOS until iOS implements Tink-compatible E2EE parity. - // See Android: app/src/main/java/app/closer/crypto/EncryptionVersion.kt - + // TODO(Batch 3): This stale plaintext comment predates strict-E2EE server rules. + // iOS cannot create v0 plaintext couples; the live server hardcodes encryptionVersion=2. + // Remove once `createInviteCallable` is wired end-to-end. + func invitesCollection() -> CollectionReference { db.collection("invites") } - + func inviteDocument(_ code: String) -> DocumentReference { invitesCollection().document(code) } - + // MARK: - Subcollections - + func dailyQuestionRef(coupleId: String, date: String) -> DocumentReference { coupleDocument(coupleId) .collection("daily_question") .document(date) } - + func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference { dailyQuestionRef(coupleId: coupleId, date: date) .collection("answers") .document(userId) } - + func questionThreadsRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("question_threads") } - + func dateSwipesRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("date_swipes") } - + func dateMatchesRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("date_matches") } - + func bucketListRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("bucket_list") } - + func capsulesRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("capsules") } - + func sessionsRef(coupleId: String) -> CollectionReference { coupleDocument(coupleId).collection("sessions") } - + func entitlementDocument(_ userId: String) -> DocumentReference { userDocument(userId) .collection("entitlements") .document("premium") } - + func fcmTokensRef(_ userId: String) -> CollectionReference { userDocument(userId).collection("fcmTokens") } - + // MARK: - Helpers - + func userId() throws -> String { guard let uid = Auth.auth().currentUser?.uid else { throw FirestoreError.notAuthenticated } return uid } - + func setDocument(_ value: T, at document: DocumentReference, merge: Bool = true) async throws { if merge { try await document.setData(value.asDictionary(), merge: true) @@ -110,18 +131,18 @@ final class FirestoreService: @unchecked Sendable { try await document.setData(value.asDictionary()) } } - + func getDocument(at document: DocumentReference) async throws -> T? { let snapshot = try await document.getDocument() guard snapshot.exists else { return nil } return try snapshot.data(as: T.self) } - + func getDocuments(in collection: CollectionReference) async throws -> [T] { let snapshot = try await collection.getDocuments() return try snapshot.documents.compactMap { try $0.data(as: T.self) } } - + func queryDocuments( in collection: CollectionReference, where field: String, @@ -135,12 +156,9 @@ final class FirestoreService: @unchecked Sendable { // MARK: - Callable Functions extension FirestoreService { - // TODO(iOS-E2EE): iOS does not yet generate E2EE keys or encrypt the recovery phrase, - // so iOS-originated invites create plaintext couples (encryptionVersion=0). Cross-platform - // couples where the Android user invites must go through acceptInviteCallable on Android. - // When iOS implements E2EE parity (CryptoKit keyset + Argon2id phrase cipher), update - // createInviteCallable to supply wrappedCoupleKey, kdfSalt, kdfParams, and - // encryptedRecoveryPhrase, and update acceptInviteCallable to decrypt the phrase. + // TODO(Batch 3): Wire `createInviteCallable` to the new crypto types. The iOS client + // must now generate: code, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase. + // Until then, this placeholder call will be rejected by the strict-E2EE Cloud Function. func acceptInviteCallable(code: String) async throws -> String { let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code]) @@ -151,7 +169,14 @@ extension FirestoreService { } func createInviteCallable() async throws -> (code: String, expiresAt: Date) { - // iOS MVP omits all E2EE fields; server writes nulls and sets encryptionVersion=0. + // TODO(Batch 3): Replace this empty placeholder with the full E2EE payload: + // 1. Generate recovery phrase via RecoveryKeyManager.generatePhrase(). + // 2. Generate couple key via CoupleEncryptionManager.generateCoupleKey(). + // 3. Wrap couple key via CoupleEncryptionManager.wrap(key, with: phrase). + // 4. Generate a 6-char Crockford code. + // 5. Encrypt phrase via CoupleEncryptionManager.encryptRecoveryPhrase(phrase, with: code). + // 6. Call createInviteCallable with code, wrappedCoupleKey (base64), kdfSalt (base64), + // kdfParams, and encryptedRecoveryPhrase. let data: [String: Any] = [:] let result = try await functions.httpsCallable("createInviteCallable").call(data) guard let payload = result.data as? [String: Any], @@ -161,14 +186,14 @@ extension FirestoreService { } return (code, expiresAtTimestamp.dateValue()) } - + func leaveCoupleCallable() async throws { let result = try await functions.httpsCallable("leaveCoupleCallable").call() guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else { throw FirestoreError.invalidResponse } } - + func syncEntitlementCallable() async throws -> Entitlement { let result = try await functions.httpsCallable("syncEntitlement").call() guard let data = result.data as? [String: Any] else { @@ -184,18 +209,18 @@ extension FirestoreService { updatedAt: Date() ) } - + func sendGentleReminderCallable() async throws { try await functions.httpsCallable("sendGentleReminderCallable").call() } - + func deleteUserCallable() async throws { let result = try await functions.httpsCallable("deleteUserCallable").call() guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else { throw FirestoreError.invalidResponse } } - + func updateUserCallable(displayName: String, bio: String?) async throws { var data: [String: Any] = ["displayName": displayName] if let bio = bio { @@ -206,7 +231,7 @@ extension FirestoreService { throw FirestoreError.invalidResponse } } - + func exportDataCallable() async throws { let result = try await functions.httpsCallable("exportDataCallable").call() guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else { @@ -222,7 +247,7 @@ enum FirestoreError: LocalizedError { case invalidResponse case documentNotFound case permissionDenied - + var errorDescription: String? { switch self { case .notAuthenticated: return "User is not signed in." diff --git a/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift b/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift new file mode 100644 index 00000000..fc95e21f --- /dev/null +++ b/iphone/CloserTests/CryptoTests/CoupleEncryptionManagerTests.swift @@ -0,0 +1,40 @@ +import XCTest +import CryptoKit +@testable import Closer + +final class CoupleEncryptionManagerTests: XCTestCase { + func testWrapUnwrapRoundTrip() throws { + let key = try CoupleEncryptionManager.generateCoupleKey() + let phrase = try RecoveryKeyManager.generatePhrase() + let wrapped = try CoupleEncryptionManager.wrap(key, with: phrase) + XCTAssertEqual(wrapped.kdfParams, CoupleEncryptionManager.kdfParamsTag) + XCTAssertEqual(wrapped.kdfSalt.count, CoupleEncryptionManager.saltBytes) + // Ciphertext includes nonce (12) + at least 1 byte plaintext + tag (16). + XCTAssertGreaterThanOrEqual(wrapped.ciphertext.count, 29) + + let unwrapped = try CoupleEncryptionManager.unwrap(wrapped, with: phrase) + XCTAssertEqual(unwrapped.rawKey.bytes, key.rawKey.bytes) + } + + func testBadPhraseRejects() throws { + let key = try CoupleEncryptionManager.generateCoupleKey() + let phrase = try RecoveryKeyManager.generatePhrase() + let wrapped = try CoupleEncryptionManager.wrap(key, with: phrase) + let wrongPhrase = RecoveryKeyManager.normalize(phrase) + " wrong" + XCTAssertThrowsError(try CoupleEncryptionManager.unwrap(wrapped, with: wrongPhrase)) + } + + func testInvitePhraseEncryptionRoundTrip() throws { + let phrase = try RecoveryKeyManager.generatePhrase() + let inviteCode = "ABC123" + let blob = try CoupleEncryptionManager.encryptRecoveryPhrase(phrase, with: inviteCode) + let recovered = try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: inviteCode) + XCTAssertEqual(recovered, phrase) + } + + func testInvitePhraseBadCodeRejects() throws { + let phrase = try RecoveryKeyManager.generatePhrase() + let blob = try CoupleEncryptionManager.encryptRecoveryPhrase(phrase, with: "ABC123") + XCTAssertThrowsError(try CoupleEncryptionManager.decryptRecoveryPhrase(blob, with: "ABC124")) + } +} diff --git a/iphone/CloserTests/CryptoTests/CoupleKeyStoreTests.swift b/iphone/CloserTests/CryptoTests/CoupleKeyStoreTests.swift new file mode 100644 index 00000000..930688a7 --- /dev/null +++ b/iphone/CloserTests/CryptoTests/CoupleKeyStoreTests.swift @@ -0,0 +1,21 @@ +import XCTest +import CryptoKit +@testable import Closer + +final class CoupleKeyStoreTests: XCTestCase { + func testInMemoryStoreLoadDeleteRoundTrip() throws { + let store = InMemoryCoupleKeyStore() + let coupleId = "couple-test-123" + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, 32, &bytes) + let key = CoupleKeyMaterial(rawBytes: Data(bytes)) + + XCTAssertNil(try store.loadCoupleKey(for: coupleId)) + try store.storeCoupleKey(key, for: coupleId) + let loaded = try XCTUnwrap(try store.loadCoupleKey(for: coupleId)) + XCTAssertEqual(loaded.rawKey.bytes, key.rawKey.bytes) + + try store.deleteCoupleKey(for: coupleId) + XCTAssertNil(try store.loadCoupleKey(for: coupleId)) + } +} diff --git a/iphone/CloserTests/CryptoTests/FieldEncryptorTests.swift b/iphone/CloserTests/CryptoTests/FieldEncryptorTests.swift new file mode 100644 index 00000000..f23e0193 --- /dev/null +++ b/iphone/CloserTests/CryptoTests/FieldEncryptorTests.swift @@ -0,0 +1,61 @@ +import XCTest +import CryptoKit +@testable import Closer + +final class FieldEncryptorTests: XCTestCase { + private let key = SymmetricKey(size: .bits256) + + func testStringRoundTripWithoutAAD() throws { + let plaintext = "hello, closer" + let blob = try FieldEncryptor.encryptString(plaintext, key: key, aad: nil) + XCTAssertTrue(blob.hasPrefix(FieldEncryptor.prefix)) + let recovered = try FieldEncryptor.decryptString(blob, key: key, aad: nil) + XCTAssertEqual(recovered, plaintext) + } + + func testStringRoundTripWithAAD() throws { + let plaintext = "secret payload" + let aad = "couple-123".data(using: .utf8)! + let blob = try FieldEncryptor.encryptString(plaintext, key: key, aad: aad) + let recovered = try FieldEncryptor.decryptString(blob, key: key, aad: aad) + XCTAssertEqual(recovered, plaintext) + } + + func testBinaryRoundTrip() throws { + let plaintext = Data((0..<64).map { $0 }) + let aad = Data([0x00, 0x01, 0x02]) + let ct = try FieldEncryptor.encrypt(plaintext, key: key, aad: aad) + let recovered = try FieldEncryptor.decrypt(ct, key: key, aad: aad) + XCTAssertEqual(recovered, plaintext) + } + + func testTamperedCiphertextThrows() throws { + let plaintext = "tamper me" + let blob = try FieldEncryptor.encryptString(plaintext, key: key, aad: nil) + var bytes = Array(blob.utf8) + // Flip a bit in the base64 payload portion. + let prefixEnd = FieldEncryptor.prefix.count + bytes[prefixEnd + 5] ^= 0x01 + let tampered = String(bytes: bytes, encoding: .utf8)! + XCTAssertThrowsError(try FieldEncryptor.decryptString(tampered, key: key, aad: nil)) + } + + func testWrongAADThrows() throws { + let plaintext = "aad bound" + let blob = try FieldEncryptor.encryptString(plaintext, key: key, aad: Data([0xAB])) + XCTAssertThrowsError(try FieldEncryptor.decryptString(blob, key: key, aad: Data([0xBA]))) + } + + func testKnownVectorCryptoKitRoundTrip() throws { + // NIST-style AES-256-GCM test vector derived from CryptoKit itself: + // fixed key + fixed nonce + fixed plaintext should round-trip deterministically. + let keyBytes = Data(repeating: 0xAB, count: 32) + let fixedKey = SymmetricKey(data: keyBytes) + let nonce = AES.GCM.Nonce(data: Data(repeating: 0xCD, count: 12))! + let plaintext = Data("known vector plaintext".utf8) + let sealed = try AES.GCM.seal(plaintext, using: fixedKey, nonce: nonce) + let ct = sealed.combined! + let recovered = try AES.GCM.open(try AES.GCM.SealedBox(combined: ct), using: fixedKey) + XCTAssertEqual(recovered, plaintext) + } +} diff --git a/iphone/CloserTests/CryptoTests/InMemoryCoupleKeyStore.swift b/iphone/CloserTests/CryptoTests/InMemoryCoupleKeyStore.swift new file mode 100644 index 00000000..fe4aa01c --- /dev/null +++ b/iphone/CloserTests/CryptoTests/InMemoryCoupleKeyStore.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import Closer + +/// In-memory fake for `CoupleKeyStoreProtocol` used in unit tests. +/// +/// The iOS Keychain requires a device entitlement / simulator environment and +/// cannot be exercised in a headless Linux Swift build. This fake validates the +/// store/load/delete contract at the business-logic layer. +public final class InMemoryCoupleKeyStore: @unchecked Sendable, CoupleKeyStoreProtocol { + private var storage: [String: Data] = [:] + private let lock = NSLock() + + public init() {} + + public func storeCoupleKey(_ key: CoupleKeyMaterial, for coupleId: String) throws { + lock.lock() + defer { lock.unlock() } + storage[coupleId] = key.rawKey.bytes + } + + public func loadCoupleKey(for coupleId: String) throws -> CoupleKeyMaterial? { + lock.lock() + defer { lock.unlock() } + guard let bytes = storage[coupleId] else { return nil } + return CoupleKeyMaterial(rawBytes: bytes) + } + + public func deleteCoupleKey(for coupleId: String) throws { + lock.lock() + defer { lock.unlock() } + storage.removeValue(forKey: coupleId) + } +} diff --git a/iphone/CloserTests/CryptoTests/RecoveryKeyManagerTests.swift b/iphone/CloserTests/CryptoTests/RecoveryKeyManagerTests.swift new file mode 100644 index 00000000..dbd249ca --- /dev/null +++ b/iphone/CloserTests/CryptoTests/RecoveryKeyManagerTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import Closer + +final class RecoveryKeyManagerTests: XCTestCase { + func testGeneratePhraseIsWellFormed() throws { + let phrase = try RecoveryKeyManager.generatePhrase() + let parts = phrase.split(separator: " ") + XCTAssertEqual(parts.count, RecoveryKeyManager.phraseWordCount) + XCTAssertTrue(try RecoveryKeyManager.isWellFormed(phrase)) + } + + func testNormalizeCollapsesWhitespaceAndLowercases() throws { + let messy = " ABLE acid ACRE " + let normalized = RecoveryKeyManager.normalize(messy) + XCTAssertEqual(normalized, "able acid acre") + } + + func testMalformedPhrasesRejected() throws { + // Too short. + XCTAssertFalse(try RecoveryKeyManager.isWellFormed("able acid")) + // Unknown word. + XCTAssertFalse(try RecoveryKeyManager.isWellFormed("able acid acre aged aide also army atom baby xxx")) + } +} diff --git a/iphone/CloserTests/CryptoTests/WordlistTests.swift b/iphone/CloserTests/CryptoTests/WordlistTests.swift new file mode 100644 index 00000000..b840a80e --- /dev/null +++ b/iphone/CloserTests/CryptoTests/WordlistTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Closer + +final class WordlistTests: XCTestCase { + func testWordlistMatchesAndroidSource() throws { + let words = try Wordlist.load() + // Correction from SPEC.md: the Android source actually has 248 words, not 256. + XCTAssertEqual(words.count, 248, "Wordlist count must match Android source") + + // All lowercase, no whitespace, no empties. + for word in words { + XCTAssertEqual(word, word.lowercased(), "Word must be lowercase: \(word)") + XCTAssertFalse(word.contains(" "), "Word must not contain whitespace: \(word)") + XCTAssertFalse(word.isEmpty, "Word must not be empty") + } + + // All unique. + let unique = Set(words) + XCTAssertEqual(unique.count, words.count, "All words must be unique") + } + + func testWordlistFirstAndLastEntries() throws { + let words = try Wordlist.load() + XCTAssertEqual(words.first, "able") + XCTAssertEqual(words.last, "real") + } +} diff --git a/iphone/Package.swift b/iphone/Package.swift index a9542415..e604ea8a 100644 --- a/iphone/Package.swift +++ b/iphone/Package.swift @@ -17,6 +17,12 @@ let package = Package( // Google Sign-In .package(url: "https://github.com/google/GoogleSignIn-iOS.git", from: "8.0.0"), + + // swift-sodium — audited libsodium wrapper for Argon2id (KDF) and other primitives. + // Pinned to 0.11.0: current stable as of Batch 2, bundles libsodium 1.0.20, supports Swift 6 / iOS 13+. + // Chosen over pure-Swift Argon2 ports because libsodium is audited and provides the RFC 9106 / Argon2 v1.3 + // implementation we need for byte-identical KEK derivation with Android BouncyCastle. + .package(url: "https://github.com/jedisct1/swift-sodium.git", from: "0.11.0"), ], targets: [ .target( @@ -30,11 +36,13 @@ let package = Package( .product(name: "RevenueCat", package: "purchases-ios"), .product(name: "RevenueCatUI", package: "purchases-ios"), .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + .product(name: "Sodium", package: "swift-sodium"), ], path: "Closer", - exclude: ["Info.plist", "Closer.entitlements"], + exclude: ["Info.plist", "Closer.entitlements", "Crypto/SPEC.md"], resources: [ - .process("Resources") + .process("Resources"), + .process("Crypto/Resources"), ] ), .testTarget(