Closer/iphone/Closer/Crypto/CoupleEncryptionManager.swift

171 lines
6.5 KiB
Swift

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
/// Exposed for deterministic known-vector tests. Derives the KEK from a
/// password + salt using the same Argon2id parameters as Android.
public static func unwrapKEK(phrase: String, salt: Data) throws -> SymmetricKey {
return try deriveKEK(phrase: phrase, salt: salt)
}
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
}
}