From 8967fd23cdbafbf1d8b09e640e8908893e6137a6 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 22:29:43 -0500 Subject: [PATCH] fix(crypto): define single source of truth for encryptionVersion and document v0/v2 drift risk - Add EncryptionVersion.kt with constants PLAINTEXT(0), MIGRATING(1), STRICT(2) - Route CoupleEncryptionManager through the new constants and add explicit v2 branch - Comment acceptInviteCallable.ts:91 explaining the version and sync contract - Add TODO in iOS FirestoreService.swift warning that iOS MVP creates v0 couples Fixes Risk #2 from review.md. --- .../closer/crypto/CoupleEncryptionManager.kt | 16 ++++++++--- .../app/closer/crypto/EncryptionVersion.kt | 27 +++++++++++++++++++ functions/src/couples/acceptInviteCallable.ts | 4 +++ iphone/Closer/Services/FirestoreService.swift | 7 +++++ 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/app/closer/crypto/EncryptionVersion.kt diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 6c5bbd64..d7cb898f 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -1,5 +1,6 @@ package app.closer.crypto +import app.closer.crypto.EncryptionVersion.STRICT import app.closer.domain.model.Couple import com.google.crypto.tink.Aead import com.google.crypto.tink.KeysetHandle @@ -78,16 +79,22 @@ class CoupleEncryptionManager @Inject constructor( * Handles inviter reconciliation (flow B') transparently. */ fun checkStatus(couple: Couple): EncryptionStatus { - if (couple.encryptionVersion == 0) return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE + // v2 couples were created by Android with a strict couple key. + // v1 couples are mid-migration; v0 couples are plaintext (iOS MVP). + when (couple.encryptionVersion) { + EncryptionVersion.PLAINTEXT -> return EncryptionStatus.NEEDS_ENCRYPTION_UPGRADE + EncryptionVersion.MIGRATING -> { /* fall through to keyset checks below */ } + EncryptionVersion.STRICT -> { /* fall through to keyset checks below */ } + } if (keyStore.hasKeyset(couple.id)) { - return if (couple.encryptionVersion >= STRICT_ENCRYPTION_VERSION) { + return if (couple.encryptionVersion >= STRICT) { EncryptionStatus.UNLOCKED } else { EncryptionStatus.NEEDS_CONTENT_MIGRATION } } if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) { - return if (couple.encryptionVersion >= STRICT_ENCRYPTION_VERSION) { + return if (couple.encryptionVersion >= STRICT) { EncryptionStatus.RECONCILED_FROM_INVITE } else { EncryptionStatus.NEEDS_CONTENT_MIGRATION @@ -138,6 +145,7 @@ class CoupleEncryptionManager @Inject constructor( keyStore.clearPendingRecoveryPhrase(coupleId) companion object { - const val STRICT_ENCRYPTION_VERSION = 2 + /** Kept for backwards compatibility; prefer [EncryptionVersion.STRICT]. */ + const val STRICT_ENCRYPTION_VERSION = EncryptionVersion.STRICT } } diff --git a/app/src/main/java/app/closer/crypto/EncryptionVersion.kt b/app/src/main/java/app/closer/crypto/EncryptionVersion.kt new file mode 100644 index 00000000..5cb449ac --- /dev/null +++ b/app/src/main/java/app/closer/crypto/EncryptionVersion.kt @@ -0,0 +1,27 @@ +package app.closer.crypto + +/** + * Single source of truth for couple encryption versions shared by Android, + * iOS, and Cloud Functions. + * + * v0 = legacy plaintext (no couple key, all answer paths write plaintext). + * Used by the iOS MVP because E2EE is skipped for the initial port. + * v1 = legacy Tink key migration-in-progress (mixed plaintext + encrypted). + * Kept for backwards compatibility with older couples; no new couples + * should be created at v1. + * v2 = strict E2EE (all answer-bearing paths require a couple key and + * ciphertext). This is the default for all new Android couples. + * + * IMPORTANT: keep this mapping in sync with: + * - functions/src/couples/acceptInviteCallable.ts + * - iphone/ARCHITECTURE_AUDIT.md (E2EE section) + * - iphone/Closer/Services/FirestoreService.swift (couple creation TODOs) + */ +object EncryptionVersion { + const val PLAINTEXT = 0 + const val MIGRATING = 1 + const val STRICT = 2 + + /** Version used when creating a new couple from the Android client. */ + const val NEW_COUPLE_DEFAULT = STRICT +} diff --git a/functions/src/couples/acceptInviteCallable.ts b/functions/src/couples/acceptInviteCallable.ts index bb4e8e58..804142d7 100644 --- a/functions/src/couples/acceptInviteCallable.ts +++ b/functions/src/couples/acceptInviteCallable.ts @@ -88,6 +88,10 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con inviteCode: code, createdAt: admin.firestore.FieldValue.serverTimestamp(), streakCount: 0, + // encryptionVersion must stay in sync with Android's + // app/src/main/java/app/closer/crypto/EncryptionVersion.kt. + // v0 = plaintext (iOS MVP, no E2EE); v1 = legacy migration; + // v2 = strict E2EE — the default for all new Android couples. encryptionVersion: 2, wrappedCoupleKey: wrappedCoupleKey ?? null, kdfSalt: kdfSalt ?? null, diff --git a/iphone/Closer/Services/FirestoreService.swift b/iphone/Closer/Services/FirestoreService.swift index 6278a5ce..f9f248ed 100644 --- a/iphone/Closer/Services/FirestoreService.swift +++ b/iphone/Closer/Services/FirestoreService.swift @@ -30,6 +30,13 @@ final class FirestoreService: @unchecked Sendable { func coupleDocument(_ coupleId: String) -> DocumentReference { couplesCollection().document(coupleId) } + + // TODO(iOS-E2EE): The iOS MVP skips E2EE, so any couple created directly + // from iOS must be written with encryptionVersion = 0 (PLAINTEXT). + // Cross-platform couples must be created via acceptInviteCallable (Android + // path) which writes encryptionVersion = 2. Do NOT create a mixed v0/v2 + // couple from iOS until iOS implements Tink-compatible E2EE parity. + // See Android: app/src/main/java/app/closer/crypto/EncryptionVersion.kt func invitesCollection() -> CollectionReference { db.collection("invites")