aa4ab6270962
feat: passkey consolidation on unlock — merge orphan passkey-only entries (v2.10.2)
- time
- 2026-08-02 16:34 UTC · 2 months ago
- machine
- koh2
- parent
- b78005a3cb0d
↓ peach-android-aa4ab627.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+54 −0app/src/test/java/com/peachpasswords/vault/PasskeyConsolidationTest.kt+122 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+54 −0
@@ -760,6 +760,7 @@760760 _settings.value = store.loadSettings()761761 ensureRootKey(payload)762762 autoPurgeExpiredTrash()763+ consolidatePasskeyEntries()763764 publish()764765 _isUnlocked.value = true765766 // A successful adoption starts a new generation. A sibling@@ -2632,6 +2633,59 @@26322633 val before = entryList.size26332634 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }26342635 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2636+ }2637+2638+ /**2639+ * Browser parity (P1): merge orphaned passkey-only entries into matching2640+ * login entries that share the same registrable domain. A passkey-only2641+ * entry has passkey fields (rpId + credentialId) but no password. Runs on2642+ * unlock so consolidation is idempotent and cheap.2643+ */2644+ private fun consolidatePasskeyEntries() {2645+ val passkeyOnly = entryList.filter { entry ->2646+ entry.deletedAt == null &&2647+ entry.isLogin() &&2648+ entry.password.isNullOrBlank() &&2649+ !entry.rpId.isNullOrBlank() &&2650+ !entry.credentialId.isNullOrBlank()2651+ }2652+ if (passkeyOnly.isEmpty()) return2653+ var changed = false2654+ val now = System.currentTimeMillis()2655+ for (pkEntry in passkeyOnly) {2656+ val rpId = pkEntry.rpId ?: continue2657+ val target = entryList.firstOrNull { candidate ->2658+ candidate.deletedAt == null &&2659+ candidate.id != pkEntry.id &&2660+ candidate.isLogin() &&2661+ candidate.passkey == null &&2662+ candidate.credentialId.isNullOrBlank() &&2663+ !candidate.password.isNullOrBlank() &&2664+ listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2665+ .any { com.peachpasswords.shared.extractDomain(it) == rpId }2666+ } ?: continue2667+ val idx = entryList.indexOfFirst { it.id == target.id }2668+ if (idx < 0) continue2669+ entryList[idx] = target.copy(2670+ rpId = pkEntry.rpId,2671+ rpName = pkEntry.rpName ?: target.rpName,2672+ credentialId = pkEntry.credentialId,2673+ publicKeyCose = pkEntry.publicKeyCose,2674+ privateKeyJwk = pkEntry.privateKeyJwk,2675+ userHandle = pkEntry.userHandle,2676+ userName = pkEntry.userName,2677+ userDisplayName = pkEntry.userDisplayName,2678+ algorithm = pkEntry.algorithm,2679+ signCount = pkEntry.signCount,2680+ lastUsedAt = pkEntry.lastUsedAt,2681+ modifiedAt = now,2682+ )2683+ // Soft-delete the orphan so it lands in trash (recoverable).2684+ val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2685+ if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2686+ changed = true2687+ }2688+ if (changed) runCatching { persistSlotA(bumpVersion = false) }26352689 }26362690 26372691 /** The slot-A plaintext envelope for the current in-memory session. */app/src/test/java/com/peachpasswords/vault/PasskeyConsolidationTest.kt+122 −0
@@ -0,0 +1,122 @@1+package com.peachpasswords.vault2+3+import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertNotNull6+import org.junit.Assert.assertNull7+import org.junit.Test8+9+class PasskeyConsolidationTest {10+11+ private class Storage : VaultStorage {12+ var slotA: StoredVault? = null13+ var slotB: String? = null14+ private var ver: Long = 015+ private var settings = VaultSettings()16+ override fun loadSlotA() = slotA17+ override fun hasVault() = slotA != null18+ override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19+ override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }20+ var decoy: StoredVault? = null21+ override fun loadDecoySlot() = decoy22+ override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }23+ override fun hasDecoySlot() = decoy != null24+ override fun clearDecoySlot() { decoy = null }25+ override fun version() = ver26+ override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }27+ override fun loadSettings() = settings28+ override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }29+ override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }30+ }31+32+ private fun repo() = VaultRepository(Storage())33+34+ @Test fun passkey_only_entry_merges_into_matching_login_on_unlock() = runBlocking {35+ val vault = repo()36+ vault.createVault("test-password-123")37+38+ vault.addEntry(VaultEntry(39+ id = "", type = "login", title = "Example",40+ username = "june", password = "hunter2",41+ url = "https://example.com/login",42+ createdAt = 0, modifiedAt = 0,43+ ))44+ vault.addEntry(VaultEntry(45+ id = "", type = "login", title = "example.com passkey",46+ url = "https://example.com",47+ rpId = "example.com", credentialId = "Y3JlZA",48+ privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"dGVzdA"}""",49+ algorithm = -7, signCount = 5,50+ createdAt = 0, modifiedAt = 0,51+ ))52+53+ assertEquals(2, vault.getEntries().size)54+55+ vault.lock()56+ vault.unlock("test-password-123")57+58+ // getEntries() filters soft-deleted; the orphan is in trash59+ val active = vault.getEntries()60+ assertEquals("orphan merged and trashed — one active login", 1, active.size)61+62+ val merged = active[0]63+ assertEquals("Example", merged.title)64+ assertEquals("hunter2", merged.password)65+ assertEquals("example.com", merged.rpId)66+ assertEquals("Y3JlZA", merged.credentialId)67+ assertEquals(-7, merged.algorithm)68+ assertEquals(5, merged.signCount)69+ }70+71+ @Test fun no_consolidation_when_login_already_has_passkey() = runBlocking {72+ val vault = repo()73+ vault.createVault("test-password-123")74+75+ vault.addEntry(VaultEntry(76+ id = "", type = "login", title = "Example",77+ username = "june", password = "hunter2",78+ url = "https://example.com",79+ rpId = "example.com", credentialId = "ZXhpc3Rpbmc",80+ privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"b2xk"}""",81+ algorithm = -7,82+ createdAt = 0, modifiedAt = 0,83+ ))84+ vault.addEntry(VaultEntry(85+ id = "", type = "login", title = "orphan",86+ url = "https://example.com",87+ rpId = "example.com", credentialId = "b3JwaGFu",88+ privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"bmV3"}""",89+ algorithm = -7,90+ createdAt = 0, modifiedAt = 0,91+ ))92+93+ vault.lock()94+ vault.unlock("test-password-123")95+96+ assertEquals("both stay — target already has a passkey", 2, vault.getEntries().size)97+ }98+99+ @Test fun no_consolidation_for_different_domains() = runBlocking {100+ val vault = repo()101+ vault.createVault("test-password-123")102+103+ vault.addEntry(VaultEntry(104+ id = "", type = "login", title = "Other",105+ username = "june", password = "hunter2",106+ url = "https://other.com",107+ createdAt = 0, modifiedAt = 0,108+ ))109+ vault.addEntry(VaultEntry(110+ id = "", type = "login", title = "example passkey",111+ rpId = "example.com", credentialId = "Y3JlZA",112+ privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"dGVzdA"}""",113+ algorithm = -7,114+ createdAt = 0, modifiedAt = 0,115+ ))116+117+ vault.lock()118+ vault.unlock("test-password-123")119+120+ assertEquals("no domain match — both stay", 2, vault.getEntries().size)121+ }122+}