6453b0dda43f
feat: Add sync identity rotation (v2.3.24)
- time
- 2026-06-26 08:45 UTC · 4 months ago
- machine
- koh2
- parent
- 5c05adf0b225
↓ peach-android-6453b0dd.tar.gzatlas at this save
files changed5 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 −0app/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 {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 ──────────────────────────────── */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 nullapp/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.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)