aa4ab6270962

feat: passkey consolidation on unlock — merge orphan passkey-only entries (v2.10.2)

Browser parity gap P1 (MEDIUM). Passkey-only entries (rpId + credentialId but no password) accumulated as standalone entries forever. Now on unlock, consolidatePasskeyEntries() merges them into matching same-domain logins that have a password but no passkey, then soft-deletes the orphan to trash. - VaultRepository.consolidatePasskeyEntries(): runs in adoptSlotA after autoPurgeExpiredTrash, before publish — idempotent, cheap - PasskeyConsolidationTest: 3 tests covering merge, skip-when-target-has-passkey, and skip-on-domain-mismatch

time
2026-08-02 16:34 UTC · 2 months ago
machine
koh2
parent
b78005a3cb0d
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+54 −0app/src/test/java/com/peachpasswords/vault/PasskeyConsolidationTest.kt+122 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.11+2.10.2

view file

app/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. */

view file

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+}

view file