b78005a3cb0d

feat: import passkeys from Bitwarden JSON exports (v2.10.1)

Browser parity gap I2/P2 (HIGH). Bitwarden JSON imports previously skipped fido2Credentials silently — users migrating from Bitwarden lost all passkeys. - CsvImport: parse fido2Credentials from Bitwarden login items, map ECDSA P-256 (alg -7) and Ed25519 (alg -8) to JWK, skip unsupported algorithms - ParsedEntry gains a passkeys list; ParsedPasskey carries credential data - ImportScreen confirmImport: create passkey VaultEntries alongside logins - New BitwardenPasskeyImportTest: 5 tests covering detection, ES256/Ed25519 parsing, unsupported algorithm skip, and no-passkey logins

time
2026-08-02 16:27 UTC · 2 months ago
machine
koh2
parent
7a7563cafa18
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+50 −0app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+18 −2app/src/test/java/com/peachpasswords/vault/BitwardenPasskeyImportTest.kt+154 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.01+2.10.1

view file

app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+50 −0
@@ -26,8 +26,22 @@2626     val url: String? = null,2727     val notes: String? = null,2828     val totp: String? = null,29+    val passkeys: List<ParsedPasskey> = emptyList(),2930 )3031 32+/** A passkey credential extracted from a third-party export. */33+data class ParsedPasskey(34+    val credentialId: String,35+    val rpId: String,36+    val rpName: String? = null,37+    val privateKeyJwk: String,38+    val algorithm: Int,39+    val userHandle: String? = null,40+    val userName: String? = null,41+    val userDisplayName: String? = null,42+    val signCount: Int = 0,43+)44+3145 /** Result of an import attempt: the detected provider label plus parsed rows. */3246 sealed interface ImportParseResult {3347     data class Success(val provider: String, val entries: List<ParsedEntry>) : ImportParseResult@@ -308,8 +322,44 @@308322                 url = extractFirstUri(loginBlock),309323                 notes = jsonString(obj, "notes"),310324                 totp = jsonString(loginBlock, "totp"),325+                passkeys = parseBitwardenFido2(loginBlock),311326             )312327         }328+    }329+330+    private fun parseBitwardenFido2(loginBlock: String): List<ParsedPasskey> {331+        val credsArray = extractArray(loginBlock, "fido2Credentials") ?: return emptyList()332+        val creds = splitTopLevelObjects(credsArray)333+        return creds.mapNotNull { cred ->334+            val credentialId = jsonString(cred, "credentialId") ?: return@mapNotNull null335+            val rpId = jsonString(cred, "rpId") ?: return@mapNotNull null336+            val keyValue = jsonString(cred, "keyValue") ?: return@mapNotNull null337+            val keyAlgorithm = jsonString(cred, "keyAlgorithm") ?: "ECDSA"338+            val keyCurve = jsonString(cred, "keyCurve") ?: "P-256"339+            val algorithm = when {340+                keyAlgorithm.equals("ECDSA", ignoreCase = true) && keyCurve == "P-256" -> -7341+                keyAlgorithm.equals("EdDSA", ignoreCase = true) || keyCurve == "Ed25519" -> -8342+                else -> return@mapNotNull null343+            }344+            val jwk = buildBitwardenJwk(keyValue, algorithm)345+            ParsedPasskey(346+                credentialId = credentialId,347+                rpId = rpId,348+                rpName = jsonString(cred, "rpName"),349+                privateKeyJwk = jwk,350+                algorithm = algorithm,351+                userHandle = jsonString(cred, "userHandle"),352+                userName = jsonString(cred, "userName"),353+                userDisplayName = jsonString(cred, "userDisplayName"),354+                signCount = jsonNumber(cred, "counter") ?: 0,355+            )356+        }357+    }358+359+    private fun buildBitwardenJwk(keyValue: String, algorithm: Int): String = when (algorithm) {360+        -7 -> "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + keyValue + "\"}"361+        -8 -> "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + keyValue + "\"}"362+        else -> "{}"313363     }314364 315365     private fun extractItemsArray(content: String): String? {

view file

app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+18 −2
@@ -319,12 +319,28 @@319319                     // explicit collision resolution (never overwrite a live entry).320320                     if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }321321                 } else {322-                    preview.entries.map { p ->323-                        VaultEntry(322+                    preview.entries.flatMap { p ->323+                        val login = VaultEntry(324324                             id = "", type = "login", title = p.title, username = p.username,325325                             password = p.password, url = p.url, notes = p.notes, totp = p.totp,326326                             createdAt = now, modifiedAt = now,327327                         )328+                        val passkeyEntries = p.passkeys.map { pk ->329+                            VaultEntry(330+                                id = "", type = "login", title = p.title,331+                                username = p.username, url = "https://${pk.rpId}",332+                                rpId = pk.rpId, rpName = pk.rpName,333+                                credentialId = pk.credentialId,334+                                privateKeyJwk = pk.privateKeyJwk,335+                                algorithm = pk.algorithm,336+                                userHandle = pk.userHandle,337+                                userName = pk.userName,338+                                userDisplayName = pk.userDisplayName,339+                                signCount = pk.signCount,340+                                createdAt = now, modifiedAt = now,341+                            )342+                        }343+                        listOf(login) + passkeyEntries328344                     }329345                 }330346                 _state.value = ImportUiState.Working("saving ${drafts.size} entries…")

view file

app/src/test/java/com/peachpasswords/vault/BitwardenPasskeyImportTest.kt+154 −0
@@ -0,0 +1,154 @@1+package com.peachpasswords.vault2+3+import com.peachpasswords.ui.vault.CsvImport4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class BitwardenPasskeyImportTest {9+10+    private val bitwardenWithPasskey = """11+    {12+      "encrypted": false,13+      "items": [14+        {15+          "id": "abc-123",16+          "type": 1,17+          "name": "example.com",18+          "login": {19+            "username": "[email protected]",20+            "password": "hunter2",21+            "totp": null,22+            "uris": [{"match": null, "uri": "https://example.com/login"}],23+            "fido2Credentials": [24+              {25+                "credentialId": "Y3JlZC1pZA",26+                "keyType": "public-key",27+                "keyAlgorithm": "ECDSA",28+                "keyCurve": "P-256",29+                "keyValue": "cHJpdmF0ZS1rZXktc2NhbGFy",30+                "rpId": "example.com",31+                "rpName": "Example Inc",32+                "userHandle": "dXNlci1oYW5kbGU",33+                "userName": "[email protected]",34+                "userDisplayName": "June",35+                "counter": 7,36+                "discoverable": true,37+                "creationDate": "2025-06-01T00:00:00Z"38+              }39+            ]40+          },41+          "notes": null42+        }43+      ]44+    }45+    """.trimIndent()46+47+    private val bitwardenNoPasskey = """48+    {49+      "encrypted": false,50+      "items": [51+        {52+          "id": "def-456",53+          "type": 1,54+          "name": "plain.com",55+          "login": {56+            "username": "user",57+            "password": "pass",58+            "uris": [{"match": null, "uri": "https://plain.com"}]59+          }60+        }61+      ]62+    }63+    """.trimIndent()64+65+    @Test fun detects_bitwarden_json() {66+        assertEquals(CsvImport.BITWARDEN_JSON, CsvImport.detectProvider(bitwardenWithPasskey))67+    }68+69+    @Test fun parses_login_with_passkey() {70+        val entries = CsvImport.parse(bitwardenWithPasskey, CsvImport.BITWARDEN_JSON)71+        assertEquals(1, entries.size)72+        val entry = entries[0]73+        assertEquals("example.com", entry.title)74+        assertEquals("[email protected]", entry.username)75+        assertEquals("hunter2", entry.password)76+        assertEquals(1, entry.passkeys.size)77+78+        val pk = entry.passkeys[0]79+        assertEquals("Y3JlZC1pZA", pk.credentialId)80+        assertEquals("example.com", pk.rpId)81+        assertEquals("Example Inc", pk.rpName)82+        assertEquals(-7, pk.algorithm)83+        assertEquals("dXNlci1oYW5kbGU", pk.userHandle)84+        assertEquals("[email protected]", pk.userName)85+        assertEquals("June", pk.userDisplayName)86+        assertEquals(7, pk.signCount)87+        assertTrue(pk.privateKeyJwk.contains("\"kty\":\"EC\""))88+        assertTrue(pk.privateKeyJwk.contains("\"d\":\"cHJpdmF0ZS1rZXktc2NhbGFy\""))89+    }90+91+    @Test fun login_without_passkey_has_empty_passkeys() {92+        val entries = CsvImport.parse(bitwardenNoPasskey, CsvImport.BITWARDEN_JSON)93+        assertEquals(1, entries.size)94+        assertTrue(entries[0].passkeys.isEmpty())95+    }96+97+    @Test fun ed25519_passkey_maps_to_algorithm_minus_8() {98+        val json = """99+        {100+          "items": [101+            {102+              "type": 1,103+              "name": "ed.example.com",104+              "login": {105+                "username": "u",106+                "fido2Credentials": [107+                  {108+                    "credentialId": "Y3JlZA",109+                    "keyAlgorithm": "EdDSA",110+                    "keyCurve": "Ed25519",111+                    "keyValue": "c2VlZA",112+                    "rpId": "ed.example.com",113+                    "counter": 0114+                  }115+                ]116+              }117+            }118+          ]119+        }120+        """.trimIndent()121+        val entries = CsvImport.parse(json, CsvImport.BITWARDEN_JSON)122+        val pk = entries[0].passkeys[0]123+        assertEquals(-8, pk.algorithm)124+        assertTrue(pk.privateKeyJwk.contains("\"kty\":\"OKP\""))125+    }126+127+    @Test fun unsupported_algorithm_is_skipped() {128+        val json = """129+        {130+          "items": [131+            {132+              "type": 1,133+              "name": "rsa.example.com",134+              "login": {135+                "username": "u",136+                "fido2Credentials": [137+                  {138+                    "credentialId": "Y3JlZA",139+                    "keyAlgorithm": "RSA",140+                    "keyCurve": "",141+                    "keyValue": "a2V5",142+                    "rpId": "rsa.example.com",143+                    "counter": 0144+                  }145+                ]146+              }147+            }148+          ]149+        }150+        """.trimIndent()151+        val entries = CsvImport.parse(json, CsvImport.BITWARDEN_JSON)152+        assertTrue(entries[0].passkeys.isEmpty())153+    }154+}

view file