Closer/iphone/Closer/Services/FirestoreService.swift

559 lines
20 KiB
Swift

import Foundation
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: - FirestoreInvitesProtocol
/// Protocol covering the invite create/accept callables. Allows the production
/// `FirestoreService` and a deterministic test fake to share the same contract.
public protocol FirestoreInvitesProtocol: Sendable {
func createInvite(uid: String, code: String, recoveryPhrase: String) async throws -> InvitePayload
func acceptInvite(code: String, inviterUserId: String, recoveryPhrase: String) async throws -> AcceptResult
}
// MARK: - Invite Payload Models
public struct InvitePayload: Sendable {
public let code: String
public let wrappedCoupleKey: String
public let kdfSalt: String
public let kdfParams: String
public let encryptedRecoveryPhrase: String
public init(
code: String,
wrappedCoupleKey: String,
kdfSalt: String,
kdfParams: String,
encryptedRecoveryPhrase: String
) {
self.code = code
self.wrappedCoupleKey = wrappedCoupleKey
self.kdfSalt = kdfSalt
self.kdfParams = kdfParams
self.encryptedRecoveryPhrase = encryptedRecoveryPhrase
}
var callableDictionary: [String: Any] {
[
"code": code,
"wrappedCoupleKey": wrappedCoupleKey,
"kdfSalt": kdfSalt,
"kdfParams": kdfParams,
"encryptedRecoveryPhrase": encryptedRecoveryPhrase
]
}
}
public struct AcceptResult: Sendable {
public let coupleId: String
public let inviterUserId: String
public let wrappedCoupleKey: String
public let kdfSalt: String
public let kdfParams: String
public let encryptedRecoveryPhrase: String
public init(
coupleId: String,
inviterUserId: String,
wrappedCoupleKey: String,
kdfSalt: String,
kdfParams: String,
encryptedRecoveryPhrase: String
) {
self.coupleId = coupleId
self.inviterUserId = inviterUserId
self.wrappedCoupleKey = wrappedCoupleKey
self.kdfSalt = kdfSalt
self.kdfParams = kdfParams
self.encryptedRecoveryPhrase = encryptedRecoveryPhrase
}
}
// 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)
}
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")
}
func userDeviceDocument(_ userId: String) -> DocumentReference {
userDocument(userId)
.collection("devices")
.document("primary")
}
// MARK: - Sealed-answer subcollection helpers
/// Daily-question answer doc for [userId].
func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference {
return coupleDocument(coupleId)
.collection("daily_question")
.document(date)
.collection("answers")
.document(userId)
}
/// SchemaVersion 3 release-key subdoc: written by sender for recipient.
func releaseKeyRef(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String
) -> DocumentReference {
return dailyAnswerRef(coupleId: coupleId, date: date, userId: senderUserId)
.collection("releaseKeys")
.document(recipientUserId)
}
/// Thread release-key subdoc.
func threadReleaseKeyRef(
coupleId: String,
threadId: String,
senderUserId: String,
recipientUserId: String
) -> DocumentReference {
return coupleDocument(coupleId)
.collection("question_threads")
.document(threadId)
.collection("answers")
.document(senderUserId)
.collection("releaseKeys")
.document(recipientUserId)
}
// 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)
} else {
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,
isEqualTo value: Any
) async throws -> [T] {
let snapshot = try await collection.whereField(field, isEqualTo: value).getDocuments()
return try snapshot.documents.compactMap { try $0.data(as: T.self) }
}
}
// MARK: - Callable Functions
// MARK: - Sealed answers
extension FirestoreService {
/// Submits a schemaVersion 3 sealed answer to Firestore.
///
/// Path: `couples/{coupleId}/daily_question/{date}/answers/{userId}`.
/// The caller retains the one-time key locally and must later release it
/// to the partner via `writeReleaseKey(...)` once both partners have answered.
public func submitSealedAnswer(
coupleId: String,
date: String,
payload: SealedAnswerPayload,
answerType: String,
isRevealed: Bool = false
) async throws {
guard let userId = Auth.auth().currentUser?.uid else {
throw FirestoreError.notAuthenticated
}
let answerRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: userId)
let data: [String: Any] = [
"userId": userId,
"questionId": payload.questionId,
"answerType": answerType,
"encryptedPayload": try SealedAnswerCrypto.encode(payload),
"commitmentHash": payload.commitment,
"schemaVersion": SealedAnswerCrypto.schemaVersion,
"answerKeyReleased": false,
"answerDate": date,
"createdAt": FieldValue.serverTimestamp(),
"updatedAt": FieldValue.serverTimestamp(),
"isRevealed": isRevealed
]
try await answerRef.setData(data)
}
/// Observes the partner's sealed answer metadata for a daily question.
///
/// Emits nil if the doc does not exist or is deleted. The caller can use the
/// `answerKeyReleased` field to decide when to attempt a reveal.
public func observePartnerSealedAnswer(
coupleId: String,
date: String,
partnerUserId: String,
onUpdate: @escaping @Sendable (SealedAnswerPayload?, Bool answerKeyReleased) -> Void
) -> ListenerRegistration {
let docRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: partnerUserId)
return docRef.addSnapshotListener { snapshot, _ in
guard let snapshot = snapshot, snapshot.exists,
let data = snapshot.data(),
let sealedBlob = data["encryptedPayload"] as? String,
let released = data["answerKeyReleased"] as? Bool else {
onUpdate(nil, false)
return
}
do {
let payload = try SealedAnswerCrypto.decode(sealedBlob)
onUpdate(payload, released)
} catch {
onUpdate(nil, released)
}
}
}
/// Writes the release key (keybox) for the partner after both answers exist.
public func writeReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String,
keybox: String
) async throws {
let ref = releaseKeyRef(
coupleId: coupleId,
date: date,
senderUserId: senderUserId,
recipientUserId: recipientUserId
)
let data: [String: Any] = [
"recipientUserId": recipientUserId,
"encryptedAnswerKey": keybox,
"releasedAt": FieldValue.serverTimestamp()
]
try await ref.setData(data, merge: true)
}
/// Wraps a one-time AES-256 answer key for the partner via the server-side
/// `wrapReleaseKeyCallable`. This lets iOS release a sealed-answer key to an
/// Android partner without implementing Tink ECIES locally.
///
/// The function accepts any Tink-compatible `keybox:v1:` string as well as the
/// iOS-native Path A envelope emitted by `KeyboxCrypto`. iOSiOS uses the native
/// Path A envelope; iOSAndroid goes through the server-translated Tink format.
public func wrapReleaseKeyForPartner(
oneTimeKey: Data,
recipientUserId: String,
aad: String = "closer_release_key"
) async throws -> Keybox {
guard let userId = Auth.auth().currentUser?.uid else {
throw FirestoreError.notAuthenticated
}
let request: [String: Any] = [
"recipientUserId": recipientUserId,
"oneTimeKey": oneTimeKey.base64EncodedString(),
"aad": aad
]
let result = try await functions.httpsCallable("wrapReleaseKeyCallable").call(request)
guard let data = result.data as? [String: Any],
let keyboxString = data["keybox"] as? String,
keyboxString.hasPrefix(KeyboxCrypto.keyboxPrefix) else {
throw FirestoreError.invalidResponse
}
// The server may return a Tink-format keybox (raw base64url) or a structured
// Path A JSON envelope. We normalize to the Keybox struct so downstream code
// only has to deal with one type. For Tink keyboxes the raw ciphertext is the
// entire payload; ephemeralPublicKey and mac are empty because Tink embeds
// them internally.
let ciphertextB64 = data["ciphertext"] as? String ?? String(keyboxString.dropFirst(KeyboxCrypto.keyboxPrefix.count))
let ephemeralPublicKeyB64 = data["ephemeralPublicKey"] as? String ?? ""
let macB64 = data["mac"] as? String ?? ""
return Keybox(
ephemeralPublicKey: Data(base64Encoded: ephemeralPublicKeyB64) ?? Data(),
ciphertext: Data(base64Encoded: ciphertextB64) ?? Data(),
mac: Data(base64Encoded: macB64) ?? Data()
)
}
/// Observes the release-key subdoc written by the partner for us.
public func observeOwnReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String,
onUpdate: @escaping @Sendable (String?) -> Void
) -> ListenerRegistration {
let ref = releaseKeyRef(
coupleId: coupleId,
date: date,
senderUserId: senderUserId,
recipientUserId: recipientUserId
)
return ref.addSnapshotListener { snapshot, _ in
let keybox = snapshot?.data()?["encryptedAnswerKey"] as? String
onUpdate(keybox)
}
}
}
// MARK: - Invite callables
extension FirestoreService: FirestoreInvitesProtocol {
/// Creates a strict-E2EE invite. The caller (PairingViewModel) supplies the
/// 6-character Crockford code and recovery phrase; this method generates the
/// couple key, wraps it with the phrase, encrypts the phrase with the code,
/// and forwards the full payload to `createInviteCallable`.
public func createInvite(uid: String, code: String, recoveryPhrase: String) async throws -> InvitePayload {
let key = try CoupleEncryptionManager.generateCoupleKey()
let wrapped = try CoupleEncryptionManager.wrap(key, with: recoveryPhrase)
let encryptedPhrase = try CoupleEncryptionManager.encryptRecoveryPhrase(recoveryPhrase, with: code)
let payload = InvitePayload(
code: code,
wrappedCoupleKey: wrapped.ciphertext.base64EncodedString(),
kdfSalt: wrapped.kdfSalt.base64EncodedString(),
kdfParams: wrapped.kdfParams,
encryptedRecoveryPhrase: encryptedPhrase
)
let result = try await functions.httpsCallable("createInviteCallable").call(payload.callableDictionary)
guard let data = result.data as? [String: Any],
let returnedCode = data["code"] as? String else {
throw FirestoreError.invalidResponse
}
// The server may return a different code in theory; the contract says it returns
// the same code we sent. Include it in the returned payload for the UI.
return InvitePayload(
code: returnedCode,
wrappedCoupleKey: payload.wrappedCoupleKey,
kdfSalt: payload.kdfSalt,
kdfParams: payload.kdfParams,
encryptedRecoveryPhrase: payload.encryptedRecoveryPhrase
)
}
/// Accepts an invite and recovers the couple key. The acceptor already knows
/// the invite code (they typed it) but must receive the E2EE fields from the
/// server to decrypt the recovery phrase and unwrap the couple key.
public func acceptInvite(code: String, inviterUserId: String, recoveryPhrase: String) async throws -> AcceptResult {
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
guard let data = result.data as? [String: Any],
let coupleId = data["coupleId"] as? String,
let inviter = data["inviterUserId"] as? String,
let wrappedKey = data["wrappedCoupleKey"] as? String,
let salt = data["kdfSalt"] as? String,
let params = data["kdfParams"] as? String,
let encryptedPhrase = data["encryptedRecoveryPhrase"] as? String else {
throw FirestoreError.invalidResponse
}
return AcceptResult(
coupleId: coupleId,
inviterUserId: inviter,
wrappedCoupleKey: wrappedKey,
kdfSalt: salt,
kdfParams: params,
encryptedRecoveryPhrase: encryptedPhrase
)
}
}
// MARK: - Legacy callable wrappers (kept for existing call sites)
extension FirestoreService {
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 {
throw FirestoreError.invalidResponse
}
return Entitlement(
id: UUID().uuidString,
userId: try userId(),
source: "sync",
productId: "closer_premium",
isActive: data["premium"] as? Bool ?? false,
expiresAt: (data["expiresAt"] as? Timestamp)?.dateValue(),
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 {
data["bio"] = bio
}
let result = try await functions.httpsCallable("updateUserCallable").call(data)
guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else {
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 {
throw FirestoreError.invalidResponse
}
}
}
// MARK: - Errors
enum FirestoreError: LocalizedError {
case notAuthenticated
case invalidResponse
case documentNotFound
case permissionDenied
var errorDescription: String? {
switch self {
case .notAuthenticated: return "User is not signed in."
case .invalidResponse: return "Invalid server response."
case .documentNotFound: return "Document not found."
case .permissionDenied: return "Permission denied."
}
}
}
// MARK: - Encodable Helpers
extension Encodable {
func asDictionary() throws -> [String: Any] {
let data = try JSONEncoder().encode(self)
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Not a dictionary"))
}
return dict
}
}