cba84286c56f
naturalise foreign entry ids at the import seam instead of trusting them (v2.14.85)
- time
- 2026-09-01 03:59 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- define the entry-id namespace at the import seam
- parent
- 21276fade773
↓ peach-android-cba84286.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+21 −3app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+58 −0VERSION+1 −1
@@ -1 +1 @@1-2.14.84\ No newline at end of file1+2.14.85\ No newline at end of fileapp/src/main/java/com/peachpasswords/vault/VaultRepository.kt+21 −3
@@ -1612,12 +1612,30 @@16121612 16131613 // ---- Entry CRUD -------------------------------------------------------16141614 1615+ /**1616+ * A Peach entry id is a UUID. Import and restore carry ids from foreign1617+ * files (another Peach install, a vendor export, a hand-written backup) and1618+ * no reader bounds them, so an id that is not a well-formed Peach id is1619+ * replaced here rather than trusted.1620+ *1621+ * This is the naturalisation seam, and it is deliberately the only place it1622+ * happens. Minting costs at most one duplicate if the same vault is later1623+ * restored on a device that still holds the old id — visible and fixable.1624+ * The sync path must NOT do this: adopting a peer's id verbatim is the only1625+ * thing that keeps two devices agreeing on which entry they are discussing,1626+ * so reminting there would fork identity on every device, on every round.1627+ */1628+ private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")1629+1630+ private fun String?.normalizedEntryId(): String =1631+ if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()1632+16151633 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {16161634 val epoch = requireUnlocked()16171635 val stamp = VaultCrypto.monotonicEntryTimestamp()16181636 val now = System.currentTimeMillis()16191637 val entry = withDerivedLast4(draft.copy(1620- id = draft.id.ifBlank { UUID.randomUUID().toString() },1638+ id = draft.id.normalizedEntryId(),16211639 createdAt = if (draft.createdAt > 0) draft.createdAt else now,16221640 modifiedAt = stamp,16231641 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,@@ -1650,7 +1668,7 @@16501668 val stamp = VaultCrypto.monotonicEntryTimestamp()16511669 withDerivedLast4(16521670 draft.copy(1653- id = draft.id.ifBlank { UUID.randomUUID().toString() },1671+ id = draft.id.normalizedEntryId(),16541672 createdAt = if (draft.createdAt > 0) draft.createdAt else now,16551673 modifiedAt = stamp,16561674 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,@@ -1687,7 +1705,7 @@16871705 val stamp = VaultCrypto.monotonicEntryTimestamp()16881706 withDerivedLast4(16891707 draft.copy(1690- id = draft.id.ifBlank { UUID.randomUUID().toString() },1708+ id = draft.id.normalizedEntryId(),16911709 createdAt = if (draft.createdAt > 0) draft.createdAt else now,16921710 modifiedAt = stamp,16931711 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+58 −0
@@ -3,6 +3,7 @@33 import kotlinx.coroutines.runBlocking44 import org.junit.Assert.assertEquals55 import org.junit.Assert.assertFalse6+import org.junit.Assert.assertNotNull67 import org.junit.Assert.assertNull78 import org.junit.Assert.assertTrue89 import org.junit.Test@@ -269,6 +270,63 @@269270 assertEquals(2, repo.getEntries().size)270271 // The whole point: a sync apply must NOT advance the local version.271272 assertEquals(versionBefore, repo.getVaultVersion())273+ }274+275+ /**276+ * The entry-id namespace. A Peach id is a UUID, but a restored or imported277+ * file brings ids from whoever wrote it and no reader bounds them, so an id278+ * that is not a well-formed Peach id is replaced at the import seam rather279+ * than trusted. Minting costs at most one duplicate after a cross-device280+ * restore; trusting one used to be able to wedge sync on both platforms.281+ */282+ @Test fun import_keeps_a_well_formed_foreign_id() = runBlocking {283+ val repo = VaultRepository(FakeStorage())284+ repo.createVault("pw")285+ val fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40"286+ repo.addEntries(287+ listOf(288+ login("Bitwarden").copy(id = fromAnotherPeach),289+ login("OnePassword").copy(id = "sj4kd9f2q7m1x8zb3nv6ct0h"),290+ ),291+ )292+ assertEquals(293+ setOf(fromAnotherPeach, "sj4kd9f2q7m1x8zb3nv6ct0h"),294+ repo.getEntries().mapTo(mutableSetOf()) { it.id },295+ )296+ }297+298+ @Test fun import_mints_a_fresh_id_when_the_incoming_one_is_not_a_peach_id() = runBlocking {299+ val repo = VaultRepository(FakeStorage())300+ repo.createVault("pw")301+ repo.addEntries(302+ listOf(303+ login("Too long").copy(id = "x".repeat(200)),304+ login("Not an id").copy(id = "a b\"c\n{\"injected\":true}"),305+ ),306+ )307+ val minted = repo.getEntries().map { it.id }308+ assertEquals("both entries survive with fresh ids", 2, minted.toSet().size)309+ for (id in minted) {310+ assertEquals("a minted id is a UUID", 36, id.length)311+ assertNotNull(java.util.UUID.fromString(id))312+ }313+ }314+315+ /** The seam is the import path only — reminting here would fork identity. */316+ @Test fun sync_still_adopts_a_peer_id_verbatim() = runBlocking {317+ val repo = VaultRepository(FakeStorage())318+ repo.createVault("pw")319+ val peerId = "peer-" + "x".repeat(120)320+ repo.applyMergedRemoteEntries(321+ listOf(322+ VaultEntry(323+ id = peerId, title = "Remote", type = "login", password = "x",324+ createdAt = 1, modifiedAt = System.currentTimeMillis(),325+ ),326+ ),327+ emptyList(),328+ )329+ assertEquals(peerId, repo.getEntries().single().id)272330 }273331 274332 @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {