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,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<T: Encodable>(_ 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<T: Decodable>(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<T: Decodable>(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<T: Decodable>(
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue