feat(crypto): couple-key rotation, phase 1 — rotate forward (Future.md security #3)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-15 02:49:50 -05:00
parent b47be4e34c
commit 2c44cc6ff2
20 changed files with 695 additions and 13 deletions

View File

@ -118,6 +118,106 @@ class CoupleEncryptionManager @Inject constructor(
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) 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<PreparedRotation> =
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 * 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. * ([CoupleKeyTransfer.wrapCoupleKey]). Null when this device doesn't hold the key. Never log it.

View File

@ -56,6 +56,17 @@ class CoupleKeyStore @Inject constructor(
fun loadRecoveryPhrase(coupleId: String): String? = fun loadRecoveryPhrase(coupleId: String): String? =
prefs.getString(recoveryPhraseKey(coupleId), null) 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? = fun loadKeyset(coupleId: String): KeysetHandle? =
load(prefKey(coupleId)) load(prefKey(coupleId))
@ -80,6 +91,7 @@ class CoupleKeyStore @Inject constructor(
prefs.edit() prefs.edit()
.remove(prefKey(coupleId)) .remove(prefKey(coupleId))
.remove(recoveryPhraseKey(coupleId)) .remove(recoveryPhraseKey(coupleId))
.remove(keyGenerationKey(coupleId))
.apply() .apply()
aeadCache.remove(coupleId) aeadCache.remove(coupleId)
} }
@ -96,6 +108,7 @@ class CoupleKeyStore @Inject constructor(
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode" private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode" private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId" private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun keyGenerationKey(coupleId: String) = "key_generation_$coupleId"
private fun serialize(handle: KeysetHandle): String { private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()

View File

@ -34,7 +34,10 @@ class RecoveryKeyManager @Inject constructor() {
} }
fun newCoupleKeyset(): KeysetHandle = 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. * Wraps [keyset] with Argon2id(passphrase, salt) using AES-256-GCM.

View File

@ -68,6 +68,27 @@ class FirestoreCoupleDataSource @Inject constructor(
).await() ).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) { private suspend fun updateUserCoupleId(uid: String, coupleId: String) {
userRef(uid).set(mapOf("coupleId" to coupleId), SetOptions.merge()).await() userRef(uid).set(mapOf("coupleId" to coupleId), SetOptions.merge()).await()
} }
@ -116,7 +137,8 @@ class FirestoreCoupleDataSource @Inject constructor(
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(), encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
wrappedCoupleKey = getString("wrappedCoupleKey"), wrappedCoupleKey = getString("wrappedCoupleKey"),
kdfSalt = getString("kdfSalt"), 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)) { private fun DocumentSnapshot.millisOrNull(field: String): Long? = when (val raw = get(field)) {

View File

@ -62,4 +62,16 @@ class CoupleRepositoryImpl @Inject constructor(
val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it } val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it }
coupleDataSource.updateWrappedKey(coupleId, newWrapped) coupleDataSource.updateWrappedKey(coupleId, newWrapped)
} }
override suspend fun rotateCoupleKey(coupleId: String): Result<Unit> = 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)
}
} }

View File

@ -15,5 +15,7 @@ data class Couple(
val encryptionVersion: Int = EncryptionVersion.STRICT, val encryptionVersion: Int = EncryptionVersion.STRICT,
val wrappedCoupleKey: String? = null, val wrappedCoupleKey: String? = null,
val kdfSalt: 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
) )

View File

@ -21,4 +21,12 @@ interface CoupleRepository {
* device). See SECURITY.md (recovery) and the Engineering Manual landmine "recovery-phrase change desync". * device). See SECURITY.md (recovery) and the Engineering Manual landmine "recovery-phrase change desync".
*/ */
suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit> suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit>
/**
* 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<Unit>
} }

View File

@ -158,7 +158,28 @@ class HomeViewModel @Inject constructor(
val partnerName = partnerUser?.displayName val partnerName = partnerUser?.displayName
val partnerPhotoUrl = partnerUser?.photoUrl val partnerPhotoUrl = partnerUser?.photoUrl
val encryptionStatus = couple?.let(encryptionManager::checkStatus) 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; // 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. // covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet.

View File

@ -35,6 +35,10 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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 // 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 // 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. // 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 @HiltViewModel
@ -81,7 +89,8 @@ class SecurityViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager private val encryptionManager: CoupleEncryptionManager,
private val crashReporter: app.closer.core.crash.CrashReporter
) : ViewModel() { ) : ViewModel() {
// The revealed phrase (null until biometric reveal). // The revealed phrase (null until biometric reveal).
@ -93,6 +102,10 @@ class SecurityViewModel @Inject constructor(
private val _storedPhrase = MutableStateFlow<String?>(null) private val _storedPhrase = MutableStateFlow<String?>(null)
// Whether the couple key itself is present on this device (set up), independent of the phrase. // Whether the couple key itself is present on this device (set up), independent of the phrase.
private val _coupleKeyPresent = MutableStateFlow(false) 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 // 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. // Google, not anonymous) has a password to change.
@ -103,6 +116,7 @@ class SecurityViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val uid = authRepository.currentUserId ?: return@launch val uid = authRepository.currentUserId ?: return@launch
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
this@SecurityViewModel.coupleId = coupleId
_storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) } _storedPhrase.value = coupleId?.let { encryptionManager.recoveryPhrase(it) }
_coupleKeyPresent.value = coupleId?.let { encryptionManager.aeadFor(it) != null } ?: false _coupleKeyPresent.value = coupleId?.let { encryptionManager.aeadFor(it) != null } ?: false
} }
@ -112,17 +126,45 @@ class SecurityViewModel @Inject constructor(
settingsRepository.settings, settingsRepository.settings,
_recoveryPhrase, _recoveryPhrase,
_storedPhrase, _storedPhrase,
_coupleKeyPresent _coupleKeyPresent,
) { settings, phrase, stored, keyPresent -> _rotation
) { settings, phrase, stored, keyPresent, rotation ->
SecurityUiState( SecurityUiState(
biometricLoginEnabled = settings.biometricLoginEnabled, biometricLoginEnabled = settings.biometricLoginEnabled,
canChangePassword = canChangePassword, canChangePassword = canChangePassword,
hasRecoveryPhrase = stored != null, hasRecoveryPhrase = stored != null,
recoveryPhrase = phrase, 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()) }.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) { fun setBiometricLogin(enabled: Boolean) {
viewModelScope.launch { settingsRepository.setBiometricLogin(enabled) } viewModelScope.launch { settingsRepository.setBiometricLogin(enabled) }
} }
@ -188,6 +230,39 @@ fun SecurityScreen(
Toast.makeText(context, "Recovery phrase copied", Toast.LENGTH_SHORT).show() 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 -> state.recoveryPhrase?.let { phrase ->
AlertDialog( AlertDialog(
onDismissRequest = viewModel::hideRecoveryPhrase, 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-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 // "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 // couple key (you can't hand over a key you don't have). The screen shows "no request

View File

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

View File

@ -180,8 +180,16 @@ service cloud.firestore {
&& request.resource.data.kdfSalt is string && request.resource.data.kdfSalt is string
&& request.resource.data.kdfParams is string && request.resource.data.kdfParams is string
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([ && 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() { function isUpdatingCoupleRhythm() {

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); 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 // Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined. // setGlobalOptions runs before the functions below are defined.
require("./options"); require("./options");
@ -94,6 +94,8 @@ var onMessageWritten_1 = require("./questions/onMessageWritten");
Object.defineProperty(exports, "onMessageWritten", { enumerable: true, get: function () { return onMessageWritten_1.onMessageWritten; } }); Object.defineProperty(exports, "onMessageWritten", { enumerable: true, get: function () { return onMessageWritten_1.onMessageWritten; } });
var onCoupleLeave_1 = require("./couples/onCoupleLeave"); var onCoupleLeave_1 = require("./couples/onCoupleLeave");
Object.defineProperty(exports, "onCoupleLeave", { enumerable: true, get: function () { return onCoupleLeave_1.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"); var leaveCoupleCallable_1 = require("./couples/leaveCoupleCallable");
Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } }); Object.defineProperty(exports, "leaveCoupleCallable", { enumerable: true, get: function () { return leaveCoupleCallable_1.leaveCoupleCallable; } });
var acceptInviteCallable_1 = require("./couples/acceptInviteCallable"); var acceptInviteCallable_1 = require("./couples/acceptInviteCallable");

View File

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

View File

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

View File

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

View File

@ -42,6 +42,7 @@ export { onAnswerWritten } from './questions/onAnswerWritten'
export { onAnswerRevealed } from './questions/onAnswerRevealed' export { onAnswerRevealed } from './questions/onAnswerRevealed'
export { onMessageWritten } from './questions/onMessageWritten' export { onMessageWritten } from './questions/onMessageWritten'
export { onCoupleLeave } from './couples/onCoupleLeave' export { onCoupleLeave } from './couples/onCoupleLeave'
export { onCoupleKeyRotated } from './couples/onCoupleKeyRotated'
export { leaveCoupleCallable } from './couples/leaveCoupleCallable' export { leaveCoupleCallable } from './couples/leaveCoupleCallable'
export { acceptInviteCallable } from './couples/acceptInviteCallable' export { acceptInviteCallable } from './couples/acceptInviteCallable'
export { createInviteCallable } from './couples/createInviteCallable' export { createInviteCallable } from './couples/createInviteCallable'