183 lines
6.5 KiB
Swift
183 lines
6.5 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
// MARK: - Pairing Errors
|
|
|
|
public enum PairingError: Error, LocalizedError {
|
|
case notAuthenticated
|
|
case missingCoupleKey
|
|
case invalidInviteCode
|
|
case recoveryPhraseMismatch
|
|
case serverRejected(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .notAuthenticated:
|
|
return "You must be signed in to pair."
|
|
case .missingCoupleKey:
|
|
return "Couple encryption key is missing. Ask your partner to re-share the invite."
|
|
case .invalidInviteCode:
|
|
return "Invite code must be 6 characters."
|
|
case .recoveryPhraseMismatch:
|
|
return "The recovery phrase does not match this invite."
|
|
case .serverRejected(let message):
|
|
return message
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pairing State
|
|
|
|
public enum PairingState: Sendable {
|
|
case idle
|
|
case creatingInvite
|
|
case acceptingInvite
|
|
case paired(coupleId: String, partnerUserId: String)
|
|
case failed(error: PairingError)
|
|
}
|
|
|
|
// MARK: - Invite Result
|
|
|
|
public struct CreatedInvite: Sendable {
|
|
public let code: String
|
|
public let recoveryPhrase: String
|
|
public let expiresAt: Date?
|
|
|
|
public init(code: String, recoveryPhrase: String, expiresAt: Date? = nil) {
|
|
self.code = code
|
|
self.recoveryPhrase = recoveryPhrase
|
|
self.expiresAt = expiresAt
|
|
}
|
|
}
|
|
|
|
// MARK: - Pairing View Model
|
|
|
|
/// Orchestrates E2EE invite creation and acceptance.
|
|
///
|
|
/// Responsibilities:
|
|
/// - Generate a 6-character Crockford invite code.
|
|
/// - Generate a fresh couple key + recovery phrase for the inviter.
|
|
/// - Store the inviter's couple key locally before the server call (so the
|
|
/// inviter can answer daily questions immediately after sharing the code).
|
|
/// - Forward the strict-E2EE payload to `FirestoreInvitesProtocol`.
|
|
/// - On acceptance, decrypt the recovery phrase with the invite code, unwrap the
|
|
/// couple key, and persist it via `CoupleKeyStoreProtocol`.
|
|
///
|
|
/// The ViewModel never logs or surfaces the raw key material; the recovery phrase
|
|
/// is returned to the UI only once (for display/copy), then it is the user's responsibility.
|
|
@MainActor
|
|
public final class PairingViewModel: ObservableObject {
|
|
@Published public private(set) var state: PairingState = .idle
|
|
|
|
private let invites: FirestoreInvitesProtocol
|
|
private let keyStore: CoupleKeyStoreProtocol
|
|
private let codeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // Crockford, no I/O/0/1
|
|
|
|
public init(
|
|
invites: FirestoreInvitesProtocol = FirestoreService.shared,
|
|
keyStore: CoupleKeyStoreProtocol = CoupleKeyStore()
|
|
) {
|
|
self.invites = invites
|
|
self.keyStore = keyStore
|
|
}
|
|
|
|
/// Generates a new strict-E2EE invite.
|
|
/// - Returns: the public invite code and the recovery phrase to display to the user.
|
|
public func startCreateInvite(uid: String) async throws -> CreatedInvite {
|
|
state = .creatingInvite
|
|
defer {
|
|
if case .creatingInvite = state {
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
let code = generateSixCharCode()
|
|
let phrase = try RecoveryKeyManager.generatePhrase()
|
|
let key = try CoupleEncryptionManager.generateCoupleKey()
|
|
|
|
// Store the key locally keyed by the invite code. After the partner accepts,
|
|
// `completePairing(coupleId:)` migrates it to the real coupleId.
|
|
try keyStore.storeCoupleKey(key, for: code)
|
|
|
|
let payload = try await invites.createInvite(
|
|
uid: uid,
|
|
code: code,
|
|
recoveryPhrase: phrase
|
|
)
|
|
|
|
// If the server changed the code (not expected), re-key the stored key.
|
|
if payload.code != code {
|
|
try keyStore.deleteCoupleKey(for: code)
|
|
try keyStore.storeCoupleKey(key, for: payload.code)
|
|
}
|
|
|
|
return CreatedInvite(
|
|
code: payload.code,
|
|
recoveryPhrase: phrase,
|
|
expiresAt: nil
|
|
)
|
|
}
|
|
|
|
/// Accepts an invite using the invite code and recovery phrase supplied by the partner.
|
|
/// - Parameters:
|
|
/// - code: 6-character invite code.
|
|
/// - phrase: 10-word recovery phrase (normalized before use).
|
|
public func acceptInvite(code: String, phrase: String) async throws {
|
|
guard code.count == 6 else {
|
|
throw PairingError.invalidInviteCode
|
|
}
|
|
let normalizedPhrase = RecoveryKeyManager.normalize(phrase)
|
|
guard try RecoveryKeyManager.isWellFormed(normalizedPhrase) else {
|
|
throw PairingError.recoveryPhraseMismatch
|
|
}
|
|
|
|
state = .acceptingInvite
|
|
defer {
|
|
if case .acceptingInvite = state {
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
let result = try await invites.acceptInvite(
|
|
code: code,
|
|
inviterUserId: "", // The server returns the real inviterUserId; this placeholder is unused.
|
|
recoveryPhrase: normalizedPhrase
|
|
)
|
|
|
|
// The acceptor decrypts the recovery phrase using the invite code they typed.
|
|
// This validates that the server returned the E2EE blob for the same code.
|
|
let decryptedPhrase = try CoupleEncryptionManager.decryptRecoveryPhrase(
|
|
result.encryptedRecoveryPhrase,
|
|
with: code
|
|
)
|
|
guard RecoveryKeyManager.normalize(decryptedPhrase) == normalizedPhrase else {
|
|
throw PairingError.recoveryPhraseMismatch
|
|
}
|
|
|
|
let wrapped = WrappedCoupleKey(
|
|
ciphertext: Data(base64Encoded: result.wrappedCoupleKey) ?? Data(),
|
|
kdfSalt: Data(base64Encoded: result.kdfSalt) ?? Data(),
|
|
kdfParams: result.kdfParams
|
|
)
|
|
let key = try CoupleEncryptionManager.unwrap(wrapped, with: normalizedPhrase)
|
|
try keyStore.storeCoupleKey(key, for: result.coupleId)
|
|
|
|
state = .paired(coupleId: result.coupleId, partnerUserId: result.inviterUserId)
|
|
}
|
|
|
|
/// Migrates a locally-stored couple key from a temporary invite-code key to the
|
|
/// permanent coupleId once the server confirms the couple was created.
|
|
public func completePairing(inviteCode: String, coupleId: String) throws {
|
|
guard let key = try keyStore.loadCoupleKey(for: inviteCode) else {
|
|
throw PairingError.missingCoupleKey
|
|
}
|
|
try keyStore.storeCoupleKey(key, for: coupleId)
|
|
try keyStore.deleteCoupleKey(for: inviteCode)
|
|
}
|
|
|
|
private func generateSixCharCode() -> String {
|
|
var rng = SystemRandomNumberGenerator()
|
|
return String((0..<6).map { _ in codeChars.randomElement(using: &rng)! })
|
|
}
|
|
}
|