6453b0dda43f

feat: Add sync identity rotation (v2.3.24)

- Add repository-level sync root rotation with fresh recovery escrows - Clear paired devices after successful rotation and route to new recovery words - Bring Sync card paired-device/remove/status copy closer to browser parity - Mark T008 complete and record T009 progress

time
2026-06-26 08:45 UTC · 4 months ago
machine
koh2
parent
5c05adf0b225
5 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+96 −3app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+10 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+57 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+34 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23230020-        versionName "2.3.23"19+        versionCode 23240020+        versionName "2.3.24"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+96 −3
@@ -29,6 +29,7 @@2929 import androidx.compose.runtime.collectAsState3030 import androidx.compose.runtime.getValue3131 import com.peachpasswords.feedback.FeedbackState32+import com.peachpasswords.sync.SyncStatus3233 import com.peachpasswords.ui.theme.LocalPeachAccent3334 import com.peachpasswords.vault.DuressAction3435 import com.peachpasswords.ui.theme.peachColors@@ -572,6 +573,12 @@572573 private fun SyncBody(fg: Color, onNavigate: (PeachNav) -> Unit) {573574     val syncVm: SyncViewModel = hiltViewModel()574575     val pairedDevices by syncVm.pairedDevices.collectAsState()576+    val syncStatus by syncVm.status.collectAsState()577+    val scope = rememberCoroutineScope()578+    var confirmUnpair by remember { mutableStateOf<String?>(null) }579+    var rotateConfirmOpen by remember { mutableStateOf(false) }580+    var rotating by remember { mutableStateOf(false) }581+    var rotateError by remember { mutableStateOf<String?>(null) }575582 576583     SubLabel("PAIRING", fg, top = 0.dp)577584     RowLine(fg) {@@ -614,14 +621,100 @@614621     } else {615622         pairedDevices.forEach { d ->616623             RowLine(fg) {617-                Name(d.deviceName ?: d.deviceId, fg)618-                ValueText(if (d.lastSyncedAt != null) "synced" else "never", fg)624+                Column(Modifier.weight(1f)) {625+                    Text(d.deviceName ?: d.deviceId, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)626+                    Text(627+                        "${deviceTypeLabel(d.deviceType)} · ${syncTimeLabel(d.lastSyncedAt)}${if (isSyncStale(d.lastSyncedAt)) " · not seen recently" else ""}",628+                        color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,629+                        modifier = Modifier.padding(top = 2.dp),630+                    )631+                }632+                if (confirmUnpair == d.deviceId) {633+                    Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {634+                        SubtleButton("cancel", fg) { confirmUnpair = null }635+                        SubtleButton("remove", peachColors.Breach) { syncVm.unpairDevice(d.deviceId); confirmUnpair = null }636+                    }637+                } else {638+                    SubtleButton("remove", fg) { confirmUnpair = d.deviceId }639+                }619640             }620641         }621-        RowLine(fg) { Name("Sync", fg); SubtleButton("sync now", fg) { syncVm.syncNow() } }642+        RowLine(fg) {643+            ValueText(syncFlashLabel(syncStatus), fg)644+            SubtleButton(if (syncStatus == SyncStatus.Syncing) "syncing..." else "sync now", fg) {645+                if (syncStatus != SyncStatus.Syncing) syncVm.syncNow()646+            }647+        }622648     }623649 650+    SubLabel("RECOVERY", fg)651+    if (rotateConfirmOpen) {652+        Hint("This will disconnect all paired devices and your 12 recovery words will change. You'll be shown the new words next.", peachColors.Breach)653+        rotateError?.let {654+            Text(it, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 11.sp, modifier = Modifier.padding(top = 6.dp))655+        }656+        Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {657+            SubtleButton("cancel", fg) { if (!rotating) rotateConfirmOpen = false }658+            SubtleButton(if (rotating) "rotating..." else "Rotate sync identity", peachColors.Breach) {659+                if (rotating) return@SubtleButton660+                rotating = true661+                rotateError = null662+                scope.launch {663+                    val ok = syncVm.rotateSyncIdentity()664+                    rotating = false665+                    if (ok) {666+                        rotateConfirmOpen = false667+                        onNavigate(PeachNav.RecoverySetup)668+                    } else {669+                        rotateError = "Rotation failed. Unlock your real vault and try again."670+                    }671+                }672+            }673+        }674+    } else {675+        RowLine(fg) {676+            Column(Modifier.weight(1f)) {677+                Text("Rotate sync identity", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)678+                Text(679+                    "Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.",680+                    color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,681+                    modifier = Modifier.padding(top = 2.dp),682+                )683+            }684+            SubtleButton("rotate", peachColors.Breach) { rotateError = null; rotateConfirmOpen = true }685+        }686+    }687+624688     Hint("Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.", fg)689+}690+691+private fun deviceTypeLabel(type: String?): String = when (type) {692+    "android" -> "Android"693+    "phone" -> "Phone"694+    "tablet" -> "Tablet"695+    "desktop" -> "Desktop"696+    "browser" -> "Browser"697+    else -> "Device"698+}699+700+private fun syncTimeLabel(lastSyncedAt: Long?): String {701+    if (lastSyncedAt == null) return "never synced"702+    val diff = System.currentTimeMillis() - lastSyncedAt703+    if (diff < 60_000) return "just now"704+    if (diff < 3_600_000) return "${diff / 60_000}m ago"705+    if (diff < 86_400_000) return "${diff / 3_600_000}h ago"706+    return "${diff / 86_400_000}d ago"707+}708+709+private fun isSyncStale(lastSyncedAt: Long?): Boolean =710+    lastSyncedAt != null && System.currentTimeMillis() - lastSyncedAt > 7L * 24L * 60L * 60L * 1000L711+712+private fun syncFlashLabel(status: SyncStatus): String = when (status) {713+    SyncStatus.Idle -> ""714+    SyncStatus.Syncing -> "syncing..."715+    SyncStatus.Offline -> "offline"716+    is SyncStatus.Error -> status.message717+    is SyncStatus.LastSynced -> "synced ${syncTimeLabel(status.timestamp)}"625718 }626719 627720 /* ──────────────────────────────  Shortcuts  ──────────────────────────────── */

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+10 −0
@@ -60,6 +60,16 @@6060     /** Forget a paired device — it can no longer be dialled for sync. */6161     fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }6262 63+    /** Browser-style nuclear recovery path: re-key sync and forget every peer. */64+    suspend fun rotateSyncIdentity(): Boolean {65+        val ok = vault.rotateSyncIdentity()66+        if (ok) {67+            deviceStore.clear()68+            _status.value = SyncStatus.Idle69+        }70+        return ok71+    }72+6373     /** Adapter over the live vault; null while the vault is locked. */6474     fun buildAdapter(): ChirpSyncVaultAdapter? {6575         if (!vault.isUnlocked.value) return null

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+57 −0
@@ -401,6 +401,63 @@401401     fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }402402 403403     /**404+     * Browser parity (MV-8): rotate the sync identity by replacing the stable405+     * root key. Entries remain under the master-password key, while sync406+     * credentials, vault_id, and recovery words all change.407+     */408+    suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {409+        val key = derivedKey ?: return false410+        if (decoyMode) return false411+        val priorSlot = store.loadSlotA() ?: return false412+        val priorRootKey = rootKey ?: return false413+        val priorRecoveryHash = recoveryWordHash414+        val priorRecoverySet = recoveryWordsSet415+        val nextRootKey = generateRecoverableRootKey()416+        val nextWords = rootKeyToWords(nextRootKey)417+418+        try {419+            takeVaultSnapshot("local_save")420+            rootKey = nextRootKey421+            recoveryWordHash = recoveryWordsHash(nextWords)422+            recoveryWordsSet = false423+424+            val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)425+                ?: throw IllegalStateException("recovery_escrow_failed")426+            val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)427+                ?: throw IllegalStateException("root_escrow_failed")428+            val plaintext = VaultCodec.encode(currentPayload())429+            val blob = try {430+                b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))431+            } finally {432+                VaultCrypto.zero(plaintext)433+            }434+            val nextSlot = priorSlot.copy(435+                blob = blob,436+                recoveryEscrow = nextRecoveryEscrow,437+                rootKeyEscrow = nextRootKeyEscrow,438+            )439+            if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")440+            try {441+                writeMatchingNoiseSlotB(nextSlot)442+                store.stampLocalMutation()443+            } catch (e: Exception) {444+                store.writeSlotA(priorSlot)445+                throw e446+            }447+            VaultCrypto.zero(priorRootKey)448+            publish()449+            true450+        } catch (_: Exception) {451+            rootKey = priorRootKey452+            recoveryWordHash = priorRecoveryHash453+            recoveryWordsSet = priorRecoverySet454+            VaultCrypto.zero(nextRootKey)455+            store.writeSlotA(priorSlot)456+            false457+        }458+    }459+460+    /**404461      * A copy of the live Argon2id-derived vault key, for biometric escrow only405462      * (wrapped under a hardware KeyStore key on biometric enable). Null when406463      * locked. Caller must [VaultCrypto.zero] it after use.

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+34 −0
@@ -362,6 +362,40 @@362362         assertTrue(repo.verifyRecoveryWords(words))363363     }364364 365+    @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {366+        val storage = FakeStorage()367+        val repo = VaultRepository(storage)368+        repo.createVault("pw")369+        val added = repo.addEntry(login("Bank"))370+        val wordsBefore = repo.syncMasterKey()!!.let { key ->371+            rootKeyToWords(key).also { VaultCrypto.zero(key) }372+        }373+        assertTrue(repo.setRecoveryWords(wordsBefore))374+        val vaultIdBefore = repo.syncVaultId()375+        val versionBefore = repo.getVaultVersion()376+377+        assertTrue(repo.rotateSyncIdentity())378+379+        val wordsAfter = repo.syncMasterKey()!!.let { key ->380+            rootKeyToWords(key).also { VaultCrypto.zero(key) }381+        }382+        assertTrue(wordsAfter != wordsBefore)383+        assertTrue(repo.syncVaultId() != vaultIdBefore)384+        assertFalse(repo.isRecoveryWordsSet())385+        assertFalse(repo.verifyRecoveryWords(wordsBefore))386+        assertTrue(repo.verifyRecoveryWords(wordsAfter))387+        assertTrue(storage.slotA?.recoveryEscrow != null)388+        assertTrue(storage.slotA?.rootKeyEscrow != null)389+        assertTrue(repo.getVaultVersion() > versionBefore)390+        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)391+392+        repo.lock()393+        assertEquals(UnlockResult.Success, repo.unlock("pw"))394+        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)395+        assertFalse(repo.isRecoveryWordsSet())396+        assertTrue(repo.verifyRecoveryWords(wordsAfter))397+    }398+365399     @Test fun destroy_clears_everything() = runBlocking {366400         val storage = FakeStorage()367401         val repo = VaultRepository(storage)

view file