Closer/iphone/Closer/Pairing/PairingViewModel.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)! })
}
}