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:
parent
9cec1e7e09
commit
faac40afbf
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,30 @@ import FirebaseFirestore
|
||||||
import FirebaseAuth
|
import FirebaseAuth
|
||||||
import FirebaseFunctions
|
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
|
// MARK: - Firestore Service
|
||||||
|
|
||||||
final class FirestoreService: @unchecked Sendable {
|
final class FirestoreService: @unchecked Sendable {
|
||||||
|
|
@ -31,12 +55,9 @@ final class FirestoreService: @unchecked Sendable {
|
||||||
couplesCollection().document(coupleId)
|
couplesCollection().document(coupleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(iOS-E2EE): The iOS MVP skips E2EE, so any couple created directly
|
// TODO(Batch 3): This stale plaintext comment predates strict-E2EE server rules.
|
||||||
// from iOS must be written with encryptionVersion = 0 (PLAINTEXT).
|
// iOS cannot create v0 plaintext couples; the live server hardcodes encryptionVersion=2.
|
||||||
// Cross-platform couples must be created via acceptInviteCallable (Android
|
// Remove once `createInviteCallable` is wired end-to-end.
|
||||||
// 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
|
|
||||||
|
|
||||||
func invitesCollection() -> CollectionReference {
|
func invitesCollection() -> CollectionReference {
|
||||||
db.collection("invites")
|
db.collection("invites")
|
||||||
|
|
@ -135,12 +156,9 @@ final class FirestoreService: @unchecked Sendable {
|
||||||
// MARK: - Callable Functions
|
// MARK: - Callable Functions
|
||||||
|
|
||||||
extension FirestoreService {
|
extension FirestoreService {
|
||||||
// TODO(iOS-E2EE): iOS does not yet generate E2EE keys or encrypt the recovery phrase,
|
// TODO(Batch 3): Wire `createInviteCallable` to the new crypto types. The iOS client
|
||||||
// so iOS-originated invites create plaintext couples (encryptionVersion=0). Cross-platform
|
// must now generate: code, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase.
|
||||||
// couples where the Android user invites must go through acceptInviteCallable on Android.
|
// Until then, this placeholder call will be rejected by the strict-E2EE Cloud Function.
|
||||||
// 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.
|
|
||||||
|
|
||||||
func acceptInviteCallable(code: String) async throws -> String {
|
func acceptInviteCallable(code: String) async throws -> String {
|
||||||
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
|
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
|
||||||
|
|
@ -151,7 +169,14 @@ extension FirestoreService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func createInviteCallable() async throws -> (code: String, expiresAt: Date) {
|
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 data: [String: Any] = [:]
|
||||||
let result = try await functions.httpsCallable("createInviteCallable").call(data)
|
let result = try await functions.httpsCallable("createInviteCallable").call(data)
|
||||||
guard let payload = result.data as? [String: Any],
|
guard let payload = result.data as? [String: Any],
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,12 @@ let package = Package(
|
||||||
|
|
||||||
// Google Sign-In
|
// Google Sign-In
|
||||||
.package(url: "https://github.com/google/GoogleSignIn-iOS.git", from: "8.0.0"),
|
.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: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|
@ -30,11 +36,13 @@ let package = Package(
|
||||||
.product(name: "RevenueCat", package: "purchases-ios"),
|
.product(name: "RevenueCat", package: "purchases-ios"),
|
||||||
.product(name: "RevenueCatUI", package: "purchases-ios"),
|
.product(name: "RevenueCatUI", package: "purchases-ios"),
|
||||||
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
|
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
|
||||||
|
.product(name: "Sodium", package: "swift-sodium"),
|
||||||
],
|
],
|
||||||
path: "Closer",
|
path: "Closer",
|
||||||
exclude: ["Info.plist", "Closer.entitlements"],
|
exclude: ["Info.plist", "Closer.entitlements", "Crypto/SPEC.md"],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Resources")
|
.process("Resources"),
|
||||||
|
.process("Crypto/Resources"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue