From 2c44cc6ff27f9f26d0b9cab5954908bd83496dd2 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 15 Jul 2026 02:49:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20couple-key=20rotation,=20phase?= =?UTF-8?q?=201=20=E2=80=94=20rotate=20forward=20(Future.md=20security=20#?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A couple-key compromise currently exposes everything, forever, because the key never changes. This adds the rotation ceremony: a fresh AES-256-GCM key becomes the keyset's primary while the old keys stay for reads. The keyset is a Tink keyring and every enc:v1: blob carries its key-id internally, so all history keeps decrypting with zero wire-format changes — none of the 25 isCiphertext rule sites move. Phase 1 protects FUTURE content only (a stolen keyset still contains the old key); forward secrecy for history is phase 2, which builds on the keyGeneration plumbing laid here. The Security-screen copy says so plainly. The ceremony (CoupleRepositoryImpl.rotateCoupleKey): read the couple fresh so concurrent rotations collide at the rules instead of overwriting each other → prepareRotation builds the rotated keyset and re-wraps it under the SAME phrase (fail-closed with a typed error when this device lacks keyset or phrase; nothing persisted anywhere) → ONE merge write lands the new wrap + a strictly-increasing keyGeneration atomically, so the partner can never observe a bumped generation pointing at the old wrap → only then commitRotation stores locally. A failed server write leaves the device coherent on the old key; a crash after it self-heals through the same adoption path as the partner. Adoption (CoupleEncryptionManager.adoptRotationIfNeeded, hooked into Home's healing block, synchronously before the screen settles — until the rotated keyset is stored, new content renders locked): couple.keyGeneration ahead of the local generation → unwrap the published wrap with the locally-stored phrase → replace the keyset. Replaced only on success, never deleted on failure, so old content survives anything. No phrase on this device → needsRecovery, and both recovery flows already deliver the rotated keyset for free (phrase entry unwraps the current wrap; partner-assist exports the current keyset). Same phrase both sides is the entire distribution trick — no new ceremony, no partner action. Server: onCoupleKeyRotated (couples/{id} update, pure isKeyGenerationIncrease edge guard so streak/rhythm/re-wrap updates never fire it, and a rules-forbidden downgrade or redelivered stale event never alerts) sends both members the 🔑 security alert through the house pipeline, bypassing quiet hours like the restore self-alerts. The push is also functional: the partner's closed app can't read new-key content until it next loads Home — the tap takes them there. Rules: isUpdatingRecoveryWrap admits keyGeneration, strictly increasing (monotonic like encryptionVersion), untouched for plain phrase re-wraps. Tests (real Tink, mocks stop at storage): history readable after rotation + NEW writes unreadable by the old keyset — mutation-checked by dropping setPrimary, which kills exactly that test (a rotation that forgets setPrimary passes everything else while protecting nothing) — same-phrase unwrap reads both eras (the partner's whole adoption, proven), prepare persists nothing until commit, fail-closed without phrase/keyset, adoption state machine incl. corrupt-wrap. Android suite green, assembleDebug clean, functions 105/105, tsc clean. Deploy (scoped): firebase deploy --only firestore:rules and --only functions:onCoupleKeyRotated. Live verify follows deploys. Co-Authored-By: Claude Opus 4.8 --- .../closer/crypto/CoupleEncryptionManager.kt | 100 +++++++++ .../java/app/closer/crypto/CoupleKeyStore.kt | 13 ++ .../app/closer/crypto/RecoveryKeyManager.kt | 5 +- .../data/remote/FirestoreCoupleDataSource.kt | 24 ++- .../data/repository/CoupleRepositoryImpl.kt | 12 ++ .../java/app/closer/domain/model/Couple.kt | 4 +- .../domain/repository/CoupleRepository.kt | 8 + .../java/app/closer/ui/home/HomeViewModel.kt | 23 +- .../app/closer/ui/settings/SecurityScreen.kt | 109 +++++++++- .../closer/crypto/CoupleKeyRotationTest.kt | 203 ++++++++++++++++++ firestore.rules | 12 +- functions/dist/couples/onCoupleKeyRotated.js | 84 ++++++++ .../dist/couples/onCoupleKeyRotated.js.map | 1 + .../dist/couples/onCoupleKeyRotated.test.js | 24 +++ .../couples/onCoupleKeyRotated.test.js.map | 1 + functions/dist/index.js | 4 +- functions/dist/index.js.map | 2 +- .../src/couples/onCoupleKeyRotated.test.ts | 25 +++ functions/src/couples/onCoupleKeyRotated.ts | 53 +++++ functions/src/index.ts | 1 + 20 files changed, 695 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/app/closer/crypto/CoupleKeyRotationTest.kt create mode 100644 functions/dist/couples/onCoupleKeyRotated.js create mode 100644 functions/dist/couples/onCoupleKeyRotated.js.map create mode 100644 functions/dist/couples/onCoupleKeyRotated.test.js create mode 100644 functions/dist/couples/onCoupleKeyRotated.test.js.map create mode 100644 functions/src/couples/onCoupleKeyRotated.test.ts create mode 100644 functions/src/couples/onCoupleKeyRotated.ts diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 5de6832a..88dc94e9 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -118,6 +118,106 @@ class CoupleEncryptionManager @Inject constructor( fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) + // ── Couple-key rotation (phase 1: rotate-forward) ──────────────────────────── + // + // The keyset is a Tink keyring, and every ciphertext carries its key-id internally (TINK output + // prefix), so a keyset with the NEW key primary and the old key retained keeps all `enc:v1:` + // history readable while new writes go out under the new key — no wire-format change anywhere. + // Phase 1 therefore protects FUTURE content only: a stolen keyset still contains the old key. + // Forward secrecy for history is phase 2 (re-encrypt sweeps + old-key destruction). + + /** Everything a rotation produces, before anything is persisted anywhere. */ + data class PreparedRotation( + val rotatedHandle: KeysetHandle, + val wrapped: RecoveryKeyManager.WrappedKey, + val newGeneration: Long + ) + + class RotationRequiresPhraseException(coupleId: String) : + IllegalStateException("Rotation needs the recovery phrase on this device for $coupleId") + + /** + * Builds the rotated keyset (new AES-256-GCM key primary, old keys retained for reads) and + * re-wraps it under the phrase already stored on THIS device — same phrase, so the partner's + * copy of it stays valid, which is exactly what lets them adopt the rotation without any new + * ceremony. Fails closed with [RotationRequiresPhraseException] when the phrase isn't here. + * + * Persists NOTHING: the caller must write the wrap + generation to the server first and only + * then [commitRotation] — a failed server write must leave this device coherent on the old key. + */ + suspend fun prepareRotation(coupleId: String, currentGeneration: Long): Result = + withContext(Dispatchers.Default) { + runCatching { + val handle = keyStore.loadKeyset(coupleId) + ?: throw MissingCoupleKeyException(coupleId) + val phrase = keyStore.loadRecoveryPhrase(coupleId) + ?: throw RotationRequiresPhraseException(coupleId) + val beforeIds = handle.keysetInfo.keyInfoList.map { it.keyId }.toSet() + val manager = com.google.crypto.tink.KeysetManager.withKeysetHandle(handle) + .add(keyManager.newCoupleKeyTemplate()) + val newKeyId = manager.keysetHandle.keysetInfo.keyInfoList + .map { it.keyId } + .first { it !in beforeIds } + val rotated = manager.setPrimary(newKeyId).keysetHandle + PreparedRotation( + rotatedHandle = rotated, + wrapped = keyManager.wrap(rotated, phrase), + newGeneration = currentGeneration + 1 + ) + } + } + + /** Store the rotated keyset locally — only after the server accepted the new wrap + generation. */ + fun commitRotation(coupleId: String, prepared: PreparedRotation) { + keyStore.storeKeyset(coupleId, prepared.rotatedHandle) + keyStore.storeKeyGeneration(coupleId, prepared.newGeneration) + } + + sealed interface AdoptionResult { + /** Local keyset already matches the couple doc's generation. */ + data object UpToDate : AdoptionResult + /** Rotated keyset unwrapped with the local phrase and stored. */ + data object Adopted : AdoptionResult + /** A rotation happened elsewhere but this device has no phrase — route through Recovery. */ + data object PhraseMissing : AdoptionResult + /** Unwrap failed (corrupt wrap / stale phrase). Old keyset left untouched. */ + data class Failed(val cause: Throwable) : AdoptionResult + } + + /** + * Adopt a rotation performed on the other device: when the couple doc's `keyGeneration` is ahead + * of this device's, unwrap the (rotated) `wrappedCoupleKey` with the locally-stored phrase and + * replace the local keyset. The local keyset is only ever REPLACED on success — never deleted on + * failure — so old content keeps decrypting no matter what happens here. + * + * Devices without a local keyset at all don't come here; the normal NEEDS_RECOVERY flows already + * hand them the current (rotated) keyset for free. + */ + suspend fun adoptRotationIfNeeded(couple: Couple): AdoptionResult = withContext(Dispatchers.Default) { + val coupleId = couple.id + if (!keyStore.hasKeyset(coupleId)) return@withContext AdoptionResult.UpToDate + if (couple.keyGeneration <= keyStore.loadKeyGeneration(coupleId)) return@withContext AdoptionResult.UpToDate + val wrappedKey = couple.wrappedCoupleKey + val salt = couple.kdfSalt + val params = couple.kdfParams + if (wrappedKey.isNullOrBlank() || salt.isNullOrBlank() || params.isNullOrBlank()) { + return@withContext AdoptionResult.Failed(IllegalStateException("couple doc has no recovery wrap")) + } + val phrase = keyStore.loadRecoveryPhrase(coupleId) + ?: return@withContext AdoptionResult.PhraseMissing + runCatching { + val handle = keyManager.unwrap( + RecoveryKeyManager.WrappedKey(cipherB64 = wrappedKey, saltB64 = salt, params = params), + phrase + ) + keyStore.storeKeyset(coupleId, handle) + keyStore.storeKeyGeneration(coupleId, couple.keyGeneration) + }.fold( + onSuccess = { AdoptionResult.Adopted }, + onFailure = { AdoptionResult.Failed(it) } + ) + } + /** * The locally-held couple keyset, for wrapping to a partner during partner-assisted restore * ([CoupleKeyTransfer.wrapCoupleKey]). Null when this device doesn't hold the key. Never log it. diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt index 27581075..a9185461 100644 --- a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt +++ b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt @@ -56,6 +56,17 @@ class CoupleKeyStore @Inject constructor( fun loadRecoveryPhrase(coupleId: String): String? = prefs.getString(recoveryPhraseKey(coupleId), null) + /** + * The couple-key generation this device's keyset corresponds to. Compared against the couple + * doc's `keyGeneration` to detect a rotation performed on the other device; 0 = pre-rotation. + */ + fun loadKeyGeneration(coupleId: String): Long = + prefs.getLong(keyGenerationKey(coupleId), 0L) + + fun storeKeyGeneration(coupleId: String, generation: Long) { + prefs.edit().putLong(keyGenerationKey(coupleId), generation).apply() + } + fun loadKeyset(coupleId: String): KeysetHandle? = load(prefKey(coupleId)) @@ -80,6 +91,7 @@ class CoupleKeyStore @Inject constructor( prefs.edit() .remove(prefKey(coupleId)) .remove(recoveryPhraseKey(coupleId)) + .remove(keyGenerationKey(coupleId)) .apply() aeadCache.remove(coupleId) } @@ -96,6 +108,7 @@ class CoupleKeyStore @Inject constructor( private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode" private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode" private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId" + private fun keyGenerationKey(coupleId: String) = "key_generation_$coupleId" private fun serialize(handle: KeysetHandle): String { val baos = ByteArrayOutputStream() diff --git a/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt index 2dfc0738..41bef2df 100644 --- a/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt +++ b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt @@ -34,7 +34,10 @@ class RecoveryKeyManager @Inject constructor() { } fun newCoupleKeyset(): KeysetHandle = - KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()) + KeysetHandle.generateNew(newCoupleKeyTemplate()) + + /** The one true couple-key type; rotation adds a fresh key of exactly this template. */ + fun newCoupleKeyTemplate(): com.google.crypto.tink.KeyTemplate = AesGcmKeyManager.aes256GcmTemplate() /** * Wraps [keyset] with Argon2id(passphrase, salt) using AES-256-GCM. diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index b1f2283e..dda0e764 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -68,6 +68,27 @@ class FirestoreCoupleDataSource @Inject constructor( ).await() } + /** + * Rotation write: the new wrap AND the generation bump land atomically, so the partner can never + * observe a bumped generation pointing at the old wrap (or vice versa). Rules enforce the + * generation is strictly increasing. + */ + suspend fun rotateWrappedKey( + coupleId: String, + wrappedKey: RecoveryKeyManager.WrappedKey, + newGeneration: Long + ) { + coupleRef(coupleId).set( + mapOf( + "wrappedCoupleKey" to wrappedKey.cipherB64, + "kdfSalt" to wrappedKey.saltB64, + "kdfParams" to wrappedKey.params, + "keyGeneration" to newGeneration + ), + SetOptions.merge() + ).await() + } + private suspend fun updateUserCoupleId(uid: String, coupleId: String) { userRef(uid).set(mapOf("coupleId" to coupleId), SetOptions.merge()).await() } @@ -116,7 +137,8 @@ class FirestoreCoupleDataSource @Inject constructor( encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(), wrappedCoupleKey = getString("wrappedCoupleKey"), kdfSalt = getString("kdfSalt"), - kdfParams = getString("kdfParams") + kdfParams = getString("kdfParams"), + keyGeneration = getLong("keyGeneration") ?: 0L ) private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) { diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index a8b9d668..df006666 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -62,4 +62,16 @@ class CoupleRepositoryImpl @Inject constructor( val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it } coupleDataSource.updateWrappedKey(coupleId, newWrapped) } + + override suspend fun rotateCoupleKey(coupleId: String): Result = runCatching { + // Generation truth lives on the couple doc — read it fresh so concurrent rotations collide + // at the rules' strictly-increasing check instead of silently overwriting each other. + val couple = coupleDataSource.getCoupleById(coupleId) ?: error("couple not found") + val prepared = encryptionManager.prepareRotation(coupleId, couple.keyGeneration).getOrElse { throw it } + // Server first: if this write fails, this device stays coherent on the old key and nothing + // anywhere has changed. If the process dies right after it, this device self-heals through + // the same adoption path as the partner (generation ahead → unwrap with own stored phrase). + coupleDataSource.rotateWrappedKey(coupleId, prepared.wrapped, prepared.newGeneration) + encryptionManager.commitRotation(coupleId, prepared) + } } diff --git a/app/src/main/java/app/closer/domain/model/Couple.kt b/app/src/main/java/app/closer/domain/model/Couple.kt index 0440e6e9..dcd5d482 100644 --- a/app/src/main/java/app/closer/domain/model/Couple.kt +++ b/app/src/main/java/app/closer/domain/model/Couple.kt @@ -15,5 +15,7 @@ data class Couple( val encryptionVersion: Int = EncryptionVersion.STRICT, val wrappedCoupleKey: String? = null, val kdfSalt: String? = null, - val kdfParams: String? = null + val kdfParams: String? = null, + // Bumped by couple-key rotation; devices compare it against their local generation to adopt. 0 = never rotated. + val keyGeneration: Long = 0L ) diff --git a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt index 3b0bdaa3..cd59a0cf 100644 --- a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt @@ -21,4 +21,12 @@ interface CoupleRepository { * device). See SECURITY.md (recovery) and the Engineering Manual landmine "recovery-phrase change desync". */ suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result + + /** + * Rotate the couple key forward (phase 1): new key becomes primary, old keys stay readable, the + * rotated keyset is re-wrapped under the SAME phrase and published with a bumped `keyGeneration` + * so the partner adopts it automatically. Server write first; local state only changes on success. + * Fails when this device lacks the keyset or the phrase (fail closed, nothing written anywhere). + */ + suspend fun rotateCoupleKey(coupleId: String): Result } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index f85834a7..2424facc 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -158,7 +158,28 @@ class HomeViewModel @Inject constructor( val partnerName = partnerUser?.displayName val partnerPhotoUrl = partnerUser?.photoUrl val encryptionStatus = couple?.let(encryptionManager::checkStatus) - val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY + var needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY + + // Adopt a couple-key rotation performed on the partner's device (their 🔑 push lands + // people here). Synchronous with this load on purpose: until the rotated keyset is + // stored, new K2 content renders locked, so adopting before the screen settles is the + // difference between "it just works" and a screen full of 🔒. Old content is safe + // either way — the local keyset is only ever replaced on success. + if (couple != null && encryptionStatus == EncryptionStatus.UNLOCKED) { + when (val adoption = encryptionManager.adoptRotationIfNeeded(couple)) { + is CoupleEncryptionManager.AdoptionResult.Adopted -> + Log.i(TAG, "Adopted rotated couple key (generation ${couple.keyGeneration})") + is CoupleEncryptionManager.AdoptionResult.PhraseMissing -> { + // A rotation happened but this device can't unwrap it (no local phrase). + // Recovery fixes this — phrase entry or partner-assist both deliver the + // rotated keyset. Old content keeps decrypting meanwhile. + needsRecovery = true + } + is CoupleEncryptionManager.AdoptionResult.Failed -> + Log.w(TAG, "Couldn't adopt rotated couple key; keeping current keyset", adoption.cause) + CoupleEncryptionManager.AdoptionResult.UpToDate -> Unit + } + } // Heal profile-metadata encryption once the couple key is available (idempotent + targeted; // covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet. diff --git a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt index adef62eb..0de5559d 100644 --- a/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SecurityScreen.kt @@ -35,6 +35,10 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -73,7 +77,11 @@ data class SecurityUiState( // The couple key IS on this device (encrypt/decrypt works) even when no phrase is stored — this is the // partner-restored case (storeTransferredKeyset transfers the key, not the phrase). Lets the empty state // tell those users to ask their partner for the phrase instead of implying setup is incomplete. - val coupleKeyPresent: Boolean = false + val coupleKeyPresent: Boolean = false, + val canRotateKey: Boolean = false, + val isRotatingKey: Boolean = false, + // One-shot outcome message for the rotation action; consumed by the UI after showing. + val rotateKeyMessage: String? = null ) @HiltViewModel @@ -81,7 +89,8 @@ class SecurityViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, - private val encryptionManager: CoupleEncryptionManager + private val encryptionManager: CoupleEncryptionManager, + private val crashReporter: app.closer.core.crash.CrashReporter ) : ViewModel() { // The revealed phrase (null until biometric reveal). @@ -93,6 +102,10 @@ class SecurityViewModel @Inject constructor( private val _storedPhrase = MutableStateFlow(null) // Whether the couple key itself is present on this device (set up), independent of the phrase. private val _coupleKeyPresent = MutableStateFlow(false) + private val _rotation = MutableStateFlow(RotationState()) + private var coupleId: String? = null + + data class RotationState(val isRotating: Boolean = false, val message: String? = null) // Account type is fixed for the life of this screen: only an email/password account (signed in, not // Google, not anonymous) has a password to change. @@ -103,6 +116,7 @@ class SecurityViewModel @Inject constructor( viewModelScope.launch { val uid = authRepository.currentUserId ?: return@launch val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id + this@SecurityViewModel.coupleId = coupleId _storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) } _coupleKeyPresent.value = coupleId?.let { encryptionManager.aeadFor(it) != null } ?: false } @@ -112,17 +126,45 @@ class SecurityViewModel @Inject constructor( settingsRepository.settings, _recoveryPhrase, _storedPhrase, - _coupleKeyPresent - ) { settings, phrase, stored, keyPresent -> + _coupleKeyPresent, + _rotation + ) { settings, phrase, stored, keyPresent, rotation -> SecurityUiState( biometricLoginEnabled = settings.biometricLoginEnabled, canChangePassword = canChangePassword, hasRecoveryPhrase = stored != null, recoveryPhrase = phrase, - coupleKeyPresent = keyPresent + coupleKeyPresent = keyPresent, + // Rotation needs the keyset AND the phrase on this device — the re-wrap goes under the + // SAME phrase, which is exactly what lets the partner adopt it with no new ceremony. + canRotateKey = keyPresent && stored != null, + isRotatingKey = rotation.isRotating, + rotateKeyMessage = rotation.message ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SecurityUiState()) + fun rotateCoupleKey() { + val id = coupleId ?: return + if (_rotation.value.isRotating) return + _rotation.value = RotationState(isRotating = true) + viewModelScope.launch { + coupleRepository.rotateCoupleKey(id) + .onSuccess { + _rotation.value = RotationState(message = "Done — you're on a fresh key. Your partner's phone picks it up automatically.") + } + .onFailure { e -> + crashReporter.recordException(e) + // Server write failed or a precondition wasn't met: nothing changed on either + // device, so trying again is always safe. + _rotation.value = RotationState(message = "Couldn't rotate the key — nothing changed. Please try again.") + } + } + } + + fun consumeRotateMessage() { + _rotation.update { it.copy(message = null) } + } + fun setBiometricLogin(enabled: Boolean) { viewModelScope.launch { settingsRepository.setBiometricLogin(enabled) } } @@ -188,6 +230,39 @@ fun SecurityScreen( Toast.makeText(context, "Recovery phrase copied", Toast.LENGTH_SHORT).show() } + var showRotateConfirm by remember { mutableStateOf(false) } + if (showRotateConfirm) { + AlertDialog( + onDismissRequest = { showRotateConfirm = false }, + title = { Text("Rotate your couple key?", color = SettingsInk) }, + text = { + Text( + "This gives the two of you a fresh encryption key. Everything you already have stays readable, " + + "and your partner's phone picks the new key up on its own — neither of you has to do anything else. " + + "Do this if you think someone may have gotten hold of your key.\n\n" + + "Note: new moments are protected right away; fully re-securing your past history comes in a later step.", + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted + ) + }, + confirmButton = { + TextButton(onClick = { showRotateConfirm = false; viewModel.rotateCoupleKey() }) { Text("Rotate") } + }, + dismissButton = { + TextButton(onClick = { showRotateConfirm = false }) { Text("Cancel") } + }, + containerColor = SettingsSoft + ) + } + + // One-shot rotation outcome, surfaced in this screen's own idiom (same as the Copy toast). + state.rotateKeyMessage?.let { message -> + LaunchedEffect(message) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + viewModel.consumeRotateMessage() + } + } + state.recoveryPhrase?.let { phrase -> AlertDialog( onDismissRequest = viewModel::hideRecoveryPhrase, @@ -322,6 +397,30 @@ fun SecurityScreen( ) } + // Rotate couple key — phase 1 (rotate-forward): fresh key for everything new, + // history stays readable, partner adopts automatically via the keyGeneration bump. + // Needs key + phrase on THIS device (the re-wrap goes under the same phrase). + if (state.canRotateKey) { + Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !state.isRotatingKey) { showRotateConfirm = true } + .padding(horizontal = 16.dp, vertical = 14.dp) + .alpha(if (state.isRotatingKey) 0.5f else 1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(CloserGlyphs.Key, contentDescription = null, tint = SettingsMuted) + Text( + text = if (state.isRotatingKey) "Rotating key…" else "Rotate couple key", + style = MaterialTheme.typography.bodyLarge, + color = SettingsInk, + modifier = Modifier.weight(1f) + ) + } + } + // Help-my-partner-restore entry — a manual way to reach the consent screen if the // "Help your partner restore" notification never arrived. Only offered when we hold the // couple key (you can't hand over a key you don't have). The screen shows "no request diff --git a/app/src/test/java/app/closer/crypto/CoupleKeyRotationTest.kt b/app/src/test/java/app/closer/crypto/CoupleKeyRotationTest.kt new file mode 100644 index 00000000..8d4c8f51 --- /dev/null +++ b/app/src/test/java/app/closer/crypto/CoupleKeyRotationTest.kt @@ -0,0 +1,203 @@ +package app.closer.crypto + +import app.closer.domain.model.Couple +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.aead.AeadConfig +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import java.security.GeneralSecurityException + +/** + * The rotation ceremony's cryptographic contract, with real Tink (the mocks stop at storage): + * + * - History survives: every `enc:v1:` blob carries its Tink key-id, so the rotated keyset (old key + * retained) must keep decrypting pre-rotation ciphertext. + * - The rotation is real: new writes must come out under the NEW key — a keyset that only knows the + * old key must fail to read them. Without this assertion a "rotation" that forgot setPrimary would + * pass every other test while protecting nothing. + * - The partner can follow: the published wrap must unwrap with the SAME phrase into a keyset that + * reads both old and new ciphertext — that unwrap IS the partner's entire adoption ceremony. + * - Fail closed: no phrase or no keyset on this device → typed refusal, nothing stored anywhere. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CoupleKeyRotationTest { + + companion object { + @BeforeClass @JvmStatic fun setup() { + AeadConfig.register() + } + } + + private val keyManager = RecoveryKeyManager() + private val keyStore: CoupleKeyStore = mockk(relaxUnitFun = true) + private val manager = CoupleEncryptionManager(keyStore, keyManager) + + private val phrase = "hunt down gear east lead over drop live more baby" + private val coupleId = "c1" + private val aad = coupleId.toByteArray() + + private fun k1(): KeysetHandle = keyManager.newCoupleKeyset() + private fun aead(handle: KeysetHandle): Aead = handle.getPrimitive(Aead::class.java) + + @Test + fun `rotation keeps history readable and puts new writes under the new key`() = runTest { + val original = k1() + val oldCiphertext = aead(original).encrypt("written before rotation".toByteArray(), aad) + every { keyStore.loadKeyset(coupleId) } returns original + every { keyStore.loadRecoveryPhrase(coupleId) } returns phrase + + val prepared = manager.prepareRotation(coupleId, currentGeneration = 0L).getOrThrow() + + // History: the rotated keyset still reads what the old key wrote. + assertEquals( + "written before rotation", + String(aead(prepared.rotatedHandle).decrypt(oldCiphertext, aad)) + ) + // Rotation is real: the old keyset cannot read what the rotated keyset writes now. + val newCiphertext = aead(prepared.rotatedHandle).encrypt("written after rotation".toByteArray(), aad) + assertThrows(GeneralSecurityException::class.java) { + aead(original).decrypt(newCiphertext, aad) + } + assertEquals(1L, prepared.newGeneration) + } + + @Test + fun `the published wrap unwraps with the SAME phrase into a keyset that reads everything`() = runTest { + // This unwrap is the partner's whole adoption ceremony — same phrase, no new ritual. + val original = k1() + val oldCiphertext = aead(original).encrypt("their history".toByteArray(), aad) + every { keyStore.loadKeyset(coupleId) } returns original + every { keyStore.loadRecoveryPhrase(coupleId) } returns phrase + + val prepared = manager.prepareRotation(coupleId, currentGeneration = 3L).getOrThrow() + val adopted = keyManager.unwrap(prepared.wrapped, phrase) + + assertEquals("their history", String(aead(adopted).decrypt(oldCiphertext, aad))) + val newCiphertext = aead(prepared.rotatedHandle).encrypt("fresh".toByteArray(), aad) + assertEquals("fresh", String(aead(adopted).decrypt(newCiphertext, aad))) + assertEquals(4L, prepared.newGeneration) + } + + @Test + fun `no phrase on this device - typed refusal, nothing stored`() = runTest { + every { keyStore.loadKeyset(coupleId) } returns k1() + every { keyStore.loadRecoveryPhrase(coupleId) } returns null + + val result = manager.prepareRotation(coupleId, 0L) + + assertTrue(result.exceptionOrNull() is CoupleEncryptionManager.RotationRequiresPhraseException) + verify(exactly = 0) { keyStore.storeKeyset(any(), any()) } + verify(exactly = 0) { keyStore.storeKeyGeneration(any(), any()) } + } + + @Test + fun `no keyset on this device - typed refusal`() = runTest { + every { keyStore.loadKeyset(coupleId) } returns null + + val result = manager.prepareRotation(coupleId, 0L) + + assertTrue(result.exceptionOrNull() is MissingCoupleKeyException) + } + + @Test + fun `commitRotation stores keyset and generation - and only commit does`() = runTest { + every { keyStore.loadKeyset(coupleId) } returns k1() + every { keyStore.loadRecoveryPhrase(coupleId) } returns phrase + every { keyStore.storeKeyset(any(), any()) } just Runs + every { keyStore.storeKeyGeneration(any(), any()) } just Runs + + val prepared = manager.prepareRotation(coupleId, 0L).getOrThrow() + // prepare persisted nothing — the server write happens between these two calls. + verify(exactly = 0) { keyStore.storeKeyset(any(), any()) } + + manager.commitRotation(coupleId, prepared) + + verify { keyStore.storeKeyset(coupleId, prepared.rotatedHandle) } + verify { keyStore.storeKeyGeneration(coupleId, 1L) } + } + + @Test + fun `adoption - partner unwraps the rotated keyset and records the generation`() = runTest { + val original = k1() + every { keyStore.loadKeyset(coupleId) } returns original + every { keyStore.loadRecoveryPhrase(coupleId) } returns phrase + val prepared = manager.prepareRotation(coupleId, 0L).getOrThrow() + + // The partner device: has the OLD keyset, sees generation 1 on the couple doc. + every { keyStore.hasKeyset(coupleId) } returns true + every { keyStore.loadKeyGeneration(coupleId) } returns 0L + every { keyStore.storeKeyset(any(), any()) } just Runs + every { keyStore.storeKeyGeneration(any(), any()) } just Runs + val couple = Couple( + id = coupleId, + keyGeneration = 1L, + wrappedCoupleKey = prepared.wrapped.cipherB64, + kdfSalt = prepared.wrapped.saltB64, + kdfParams = prepared.wrapped.params + ) + + val outcome = manager.adoptRotationIfNeeded(couple) + + assertTrue(outcome is CoupleEncryptionManager.AdoptionResult.Adopted) + verify { keyStore.storeKeyset(coupleId, any()) } + verify { keyStore.storeKeyGeneration(coupleId, 1L) } + } + + @Test + fun `adoption - no local phrase routes to recovery, old keyset untouched`() = runTest { + every { keyStore.hasKeyset(coupleId) } returns true + every { keyStore.loadKeyGeneration(coupleId) } returns 0L + every { keyStore.loadRecoveryPhrase(coupleId) } returns null + val couple = Couple(id = coupleId, keyGeneration = 2L, wrappedCoupleKey = "x", kdfSalt = "y", kdfParams = "z") + + val outcome = manager.adoptRotationIfNeeded(couple) + + assertTrue(outcome is CoupleEncryptionManager.AdoptionResult.PhraseMissing) + verify(exactly = 0) { keyStore.storeKeyset(any(), any()) } + } + + @Test + fun `adoption - up to date and never-rotated couples are a no-op`() = runTest { + every { keyStore.hasKeyset(coupleId) } returns true + every { keyStore.loadKeyGeneration(coupleId) } returns 1L + + assertTrue( + manager.adoptRotationIfNeeded(Couple(id = coupleId, keyGeneration = 1L)) + is CoupleEncryptionManager.AdoptionResult.UpToDate + ) + assertTrue( + manager.adoptRotationIfNeeded(Couple(id = coupleId, keyGeneration = 0L)) + is CoupleEncryptionManager.AdoptionResult.UpToDate + ) + verify(exactly = 0) { keyStore.storeKeyset(any(), any()) } + } + + @Test + fun `adoption - a corrupt wrap fails without touching the working keyset`() = runTest { + every { keyStore.hasKeyset(coupleId) } returns true + every { keyStore.loadKeyGeneration(coupleId) } returns 0L + every { keyStore.loadRecoveryPhrase(coupleId) } returns phrase + val couple = Couple( + id = coupleId, keyGeneration = 1L, + wrappedCoupleKey = "bm90IGEgcmVhbCB3cmFw", kdfSalt = "AAAAAAAAAAAAAAAAAAAAAA==", + kdfParams = "argon2id;v=19;m=47104;t=3;p=1" + ) + + val outcome = manager.adoptRotationIfNeeded(couple) + + assertTrue(outcome is CoupleEncryptionManager.AdoptionResult.Failed) + verify(exactly = 0) { keyStore.storeKeyset(any(), any()) } + verify(exactly = 0) { keyStore.storeKeyGeneration(any(), any()) } + } +} diff --git a/firestore.rules b/firestore.rules index cb5dcc72..aab66599 100644 --- a/firestore.rules +++ b/firestore.rules @@ -180,8 +180,16 @@ service cloud.firestore { && request.resource.data.kdfSalt is string && request.resource.data.kdfParams is string && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ - 'wrappedCoupleKey', 'kdfSalt', 'kdfParams' - ]); + 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'keyGeneration' + ]) + // Key rotation bumps keyGeneration strictly upward alongside the new wrap; a plain + // phrase re-wrap doesn't touch it. Monotonic like encryptionVersion: never downgradeable, + // so a replayed/stale rotation write can't roll the couple back to an older wrap. + && ( + !request.resource.data.diff(resource.data).affectedKeys().hasAny(['keyGeneration']) + || (request.resource.data.keyGeneration is int + && request.resource.data.keyGeneration > resource.data.get('keyGeneration', 0)) + ); } function isUpdatingCoupleRhythm() { diff --git a/functions/dist/couples/onCoupleKeyRotated.js b/functions/dist/couples/onCoupleKeyRotated.js new file mode 100644 index 00000000..1f15f2cc --- /dev/null +++ b/functions/dist/couples/onCoupleKeyRotated.js @@ -0,0 +1,84 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.onCoupleKeyRotated = void 0; +exports.isKeyGenerationIncrease = isKeyGenerationIncrease; +const admin = __importStar(require("firebase-admin")); +const firestore_1 = require("firebase-functions/v2/firestore"); +const queueAndPush_1 = require("../notifications/queueAndPush"); +const log_1 = require("../log"); +/** + * Fires when a couple's key generation increases — one member rotated the couple key. + * + * Two jobs in one push, sent to BOTH members (every device): + * 1. Security signal ("was this you?" philosophy, same class as the restore self-alerts — bypasses + * quiet hours, no preference toggle): a key rotation is worth knowing about even when benign. + * 2. Functional pickup nudge: the partner's closed app still holds only the old key and cannot + * decrypt anything written under the new one until it next loads Home — the tap gets them there, + * where the existing adoption path unwraps the rotated keyset. No key material is read or logged. + */ +/** Pure edge guard: fire only when keyGeneration strictly increases (ignores every other update). */ +function isKeyGenerationIncrease(before, after) { + const b = typeof before === 'number' ? before : 0; + const a = typeof after === 'number' ? after : 0; + return a > b; +} +exports.onCoupleKeyRotated = (0, firestore_1.onDocumentUpdated)('couples/{coupleId}', async (event) => { + var _a, _b, _c; + const before = (_a = event.data) === null || _a === void 0 ? void 0 : _a.before.data(); + const after = (_b = event.data) === null || _b === void 0 ? void 0 : _b.after.data(); + if (!isKeyGenerationIncrease(before === null || before === void 0 ? void 0 : before.keyGeneration, after === null || after === void 0 ? void 0 : after.keyGeneration)) + return; + const { coupleId } = event.params; + const db = admin.firestore(); + const userIds = ((_c = after === null || after === void 0 ? void 0 : after.userIds) !== null && _c !== void 0 ? _c : []); + if (userIds.length === 0) + return; + log_1.logger.log(`[onCoupleKeyRotated] couple=${coupleId} generation=${after === null || after === void 0 ? void 0 : after.keyGeneration}`); + // Per-member isolation: one failed send must not cost the other member their alert. + const results = await Promise.allSettled(userIds.map((uid) => (0, queueAndPush_1.queueAndPush)(db, uid, { + type: 'couple_key_rotated', + title: '🔑 Security update', + body: 'Your shared key was rotated. Open the app and everything continues as normal.', + coupleId, + bypassQuietHours: true, + }))); + results.forEach((r, i) => { + if (r.status === 'rejected') { + log_1.logger.warn('[onCoupleKeyRotated] alert failed', { uid: userIds[i], error: String(r.reason) }); + } + }); +}); +//# sourceMappingURL=onCoupleKeyRotated.js.map \ No newline at end of file diff --git a/functions/dist/couples/onCoupleKeyRotated.js.map b/functions/dist/couples/onCoupleKeyRotated.js.map new file mode 100644 index 00000000..fe4400db --- /dev/null +++ b/functions/dist/couples/onCoupleKeyRotated.js.map @@ -0,0 +1 @@ +{"version":3,"file":"onCoupleKeyRotated.js","sourceRoot":"","sources":["../../src/couples/onCoupleKeyRotated.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiBA,0DAIC;AArBD,sDAAuC;AACvC,+DAAmE;AACnE,gEAA4D;AAC5D,gCAA+B;AAE/B;;;;;;;;;GASG;AAEH,qGAAqG;AACrG,SAAgB,uBAAuB,CAAC,MAAe,EAAE,KAAc;IACrE,MAAM,CAAC,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;IACjD,MAAM,CAAC,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/C,OAAO,CAAC,GAAG,CAAC,CAAA;AACd,CAAC;AAEY,QAAA,kBAAkB,GAAG,IAAA,6BAAiB,EAAC,oBAAoB,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;;IACxF,MAAM,MAAM,GAAG,MAAA,KAAK,CAAC,IAAI,0CAAE,MAAM,CAAC,IAAI,EAAE,CAAA;IACxC,MAAM,KAAK,GAAG,MAAA,KAAK,CAAC,IAAI,0CAAE,KAAK,CAAC,IAAI,EAAE,CAAA;IACtC,IAAI,CAAC,uBAAuB,CAAC,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,aAAa,EAAE,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,aAAa,CAAC;QAAE,OAAM;IAEjF,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC,MAAM,CAAA;IACjC,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,OAAO,GAAG,CAAC,MAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAClD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAM;IAEhC,YAAM,CAAC,GAAG,CAAC,+BAA+B,QAAQ,eAAe,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,aAAa,EAAE,CAAC,CAAA;IAExF,oFAAoF;IACpF,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAClB,IAAA,2BAAY,EAAC,EAAE,EAAE,GAAG,EAAE;QACpB,IAAI,EAAE,oBAAoB;QAC1B,KAAK,EAAE,oBAAoB;QAC3B,IAAI,EAAE,+EAA+E;QACrF,QAAQ;QACR,gBAAgB,EAAE,IAAI;KACvB,CAAC,CACH,CACF,CAAA;IACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;YAC5B,YAAM,CAAC,IAAI,CAAC,mCAAmC,EAAE,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;QAChG,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/dist/couples/onCoupleKeyRotated.test.js b/functions/dist/couples/onCoupleKeyRotated.test.js new file mode 100644 index 00000000..07696446 --- /dev/null +++ b/functions/dist/couples/onCoupleKeyRotated.test.js @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const onCoupleKeyRotated_1 = require("./onCoupleKeyRotated"); +describe('isKeyGenerationIncrease', () => { + it('fires on a genuine rotation (0 → 1, and every later bump)', () => { + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, 1)).toBe(true); + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, 1)).toBe(true); + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(3, 4)).toBe(true); + }); + it('ignores every non-rotation update to the couple doc', () => { + // The trigger watches the whole couple doc — streaks, rhythm, recovery re-wraps all pass through here. + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, undefined)).toBe(false); + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(2, 2)).toBe(false); + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, undefined)).toBe(false); + }); + it('never fires on a downgrade — rules forbid it, but a redelivered stale event must not alert either', () => { + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(5, 4)).toBe(false); + }); + it('treats junk as zero rather than alerting on garbage', () => { + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)('x', 'y')).toBe(false); + expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(null, 'high')).toBe(false); + }); +}); +//# sourceMappingURL=onCoupleKeyRotated.test.js.map \ No newline at end of file diff --git a/functions/dist/couples/onCoupleKeyRotated.test.js.map b/functions/dist/couples/onCoupleKeyRotated.test.js.map new file mode 100644 index 00000000..05a91588 --- /dev/null +++ b/functions/dist/couples/onCoupleKeyRotated.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"onCoupleKeyRotated.test.js","sourceRoot":"","sources":["../../src/couples/onCoupleKeyRotated.test.ts"],"names":[],"mappings":";;AAAA,6DAA8D;AAE9D,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,IAAA,4CAAuB,EAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACxD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAChD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,uGAAuG;QACvG,MAAM,CAAC,IAAA,4CAAuB,EAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjE,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACjD,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC3D,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,mGAAmG,EAAE,GAAG,EAAE;QAC3G,MAAM,CAAC,IAAA,4CAAuB,EAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACnD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,CAAC,IAAA,4CAAuB,EAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACrD,MAAM,CAAC,IAAA,4CAAuB,EAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC3D,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index 3b06c8eb..7446c5ee 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.cleanupExpiredRestoreRequests = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleKeyRotated = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.cleanupExpiredRestoreRequests = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; // Global v2 options (region pin, instance cap) — MUST be imported before any function module so // setGlobalOptions runs before the functions below are defined. require("./options"); @@ -94,6 +94,8 @@ var onMessageWritten_1 = require("./questions/onMessageWritten"); Object.defineProperty(exports, "onMessageWritten", { enumerable: true, get: function () { return onMessageWritten_1.onMessageWritten; } }); var onCoupleLeave_1 = require("./couples/onCoupleLeave"); Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.onCoupleLeave; } }); +var onCoupleKeyRotated_1 = require("./couples/onCoupleKeyRotated"); +Object.defineProperty(exports, "onCoupleKeyRotated", { enumerable: true, get: function () { return onCoupleKeyRotated_1.onCoupleKeyRotated; } }); var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable"); Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } }); var acceptInviteCallable_1 = require("./couples/acceptInviteCallable"); diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index b4c3de6a..2a5a2bd5 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,iGAAiG;AACjG,8CAA8C;AAC9C,6DAA6D;AAC7D,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,0EAA+E;AAAtE,uIAAA,6BAA6B,OAAA;AACtC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,mGAAmG;AACnG,8FAA8F;AAC9F,iGAAiG;AACjG,8CAA8C;AAC9C,6DAA6D;AAC7D,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,0EAA+E;AAAtE,uIAAA,6BAA6B,OAAA;AACtC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,mEAAiE;AAAxD,wHAAA,kBAAkB,OAAA;AAC3B,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} \ No newline at end of file diff --git a/functions/src/couples/onCoupleKeyRotated.test.ts b/functions/src/couples/onCoupleKeyRotated.test.ts new file mode 100644 index 00000000..d41f2fed --- /dev/null +++ b/functions/src/couples/onCoupleKeyRotated.test.ts @@ -0,0 +1,25 @@ +import { isKeyGenerationIncrease } from './onCoupleKeyRotated' + +describe('isKeyGenerationIncrease', () => { + it('fires on a genuine rotation (0 → 1, and every later bump)', () => { + expect(isKeyGenerationIncrease(undefined, 1)).toBe(true) + expect(isKeyGenerationIncrease(0, 1)).toBe(true) + expect(isKeyGenerationIncrease(3, 4)).toBe(true) + }) + + it('ignores every non-rotation update to the couple doc', () => { + // The trigger watches the whole couple doc — streaks, rhythm, recovery re-wraps all pass through here. + expect(isKeyGenerationIncrease(undefined, undefined)).toBe(false) + expect(isKeyGenerationIncrease(2, 2)).toBe(false) + expect(isKeyGenerationIncrease(0, undefined)).toBe(false) + }) + + it('never fires on a downgrade — rules forbid it, but a redelivered stale event must not alert either', () => { + expect(isKeyGenerationIncrease(5, 4)).toBe(false) + }) + + it('treats junk as zero rather than alerting on garbage', () => { + expect(isKeyGenerationIncrease('x', 'y')).toBe(false) + expect(isKeyGenerationIncrease(null, 'high')).toBe(false) + }) +}) diff --git a/functions/src/couples/onCoupleKeyRotated.ts b/functions/src/couples/onCoupleKeyRotated.ts new file mode 100644 index 00000000..e68c06d0 --- /dev/null +++ b/functions/src/couples/onCoupleKeyRotated.ts @@ -0,0 +1,53 @@ +import * as admin from 'firebase-admin' +import { onDocumentUpdated } from 'firebase-functions/v2/firestore' +import { queueAndPush } from '../notifications/queueAndPush' +import { logger } from '../log' + +/** + * Fires when a couple's key generation increases — one member rotated the couple key. + * + * Two jobs in one push, sent to BOTH members (every device): + * 1. Security signal ("was this you?" philosophy, same class as the restore self-alerts — bypasses + * quiet hours, no preference toggle): a key rotation is worth knowing about even when benign. + * 2. Functional pickup nudge: the partner's closed app still holds only the old key and cannot + * decrypt anything written under the new one until it next loads Home — the tap gets them there, + * where the existing adoption path unwraps the rotated keyset. No key material is read or logged. + */ + +/** Pure edge guard: fire only when keyGeneration strictly increases (ignores every other update). */ +export function isKeyGenerationIncrease(before: unknown, after: unknown): boolean { + const b = typeof before === 'number' ? before : 0 + const a = typeof after === 'number' ? after : 0 + return a > b +} + +export const onCoupleKeyRotated = onDocumentUpdated('couples/{coupleId}', async (event) => { + const before = event.data?.before.data() + const after = event.data?.after.data() + if (!isKeyGenerationIncrease(before?.keyGeneration, after?.keyGeneration)) return + + const { coupleId } = event.params + const db = admin.firestore() + const userIds = (after?.userIds ?? []) as string[] + if (userIds.length === 0) return + + logger.log(`[onCoupleKeyRotated] couple=${coupleId} generation=${after?.keyGeneration}`) + + // Per-member isolation: one failed send must not cost the other member their alert. + const results = await Promise.allSettled( + userIds.map((uid) => + queueAndPush(db, uid, { + type: 'couple_key_rotated', + title: '🔑 Security update', + body: 'Your shared key was rotated. Open the app and everything continues as normal.', + coupleId, + bypassQuietHours: true, + }) + ) + ) + results.forEach((r, i) => { + if (r.status === 'rejected') { + logger.warn('[onCoupleKeyRotated] alert failed', { uid: userIds[i], error: String(r.reason) }) + } + }) +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index 70066816..4abdcbbe 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -42,6 +42,7 @@ export { onAnswerWritten } from './questions/onAnswerWritten' export { onAnswerRevealed } from './questions/onAnswerRevealed' export { onMessageWritten } from './questions/onMessageWritten' export { onCoupleLeave } from './couples/onCoupleLeave' +export { onCoupleKeyRotated } from './couples/onCoupleKeyRotated' export { leaveCoupleCallable } from './couples/leaveCoupleCallable' export { acceptInviteCallable } from './couples/acceptInviteCallable' export { createInviteCallable } from './couples/createInviteCallable'