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.
This commit is contained in:
null 2026-06-20 22:29:43 -05:00
parent d83e557b8d
commit 73910bd459
4 changed files with 50 additions and 4 deletions

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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,

View File

@ -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")