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:
parent
d5a17cc90d
commit
8967fd23cd
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue