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:
parent
b47be4e34c
commit
2c44cc6ff2
|
|
@ -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<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
|
||||
* ([CoupleKeyTransfer.wrapCoupleKey]). Null when this device doesn't hold the key. Never log it.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String?>(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
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue