559 lines
20 KiB
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`. iOS→iOS uses the native
|
|
/// Path A envelope; iOS→Android 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
|
|
}
|
|
} |