feat(ios/crypto): CryptoKit interop primitives — RecoveryKeyManager, FieldEncryptor (enc:v1:), CoupleEncryptionManager (Argon2id v1.3), Keychain store, wordlist bundle, tests + FirestoreService E2EE contract annotation

This commit is contained in:
null 2026-06-28 16:56:51 -05:00
parent 9cec1e7e09
commit faac40afbf
14 changed files with 945 additions and 48 deletions

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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..<phraseWordCount).map { _ in Int.random(in: 0..<words.count, using: &rng) }
return indices.map { words[$0] }.joined(separator: " ")
}
/// Normalizes a recovery phrase: trim, lowercase, collapse internal whitespace,
/// join with a single space. Matches Android normalization intent.
public static func normalize(_ phrase: String) -> 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)) }
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -3,6 +3,30 @@ 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 {
@ -31,12 +55,9 @@ final class FirestoreService: @unchecked Sendable {
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")
@ -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],

View File

@ -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"))
}
}

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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"))
}
}

View File

@ -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")
}
}

View File

@ -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(