b78005a3cb0d
feat: import passkeys from Bitwarden JSON exports (v2.10.1)
- time
- 2026-08-02 16:27 UTC · 2 months ago
- machine
- koh2
- parent
- 7a7563cafa18
↓ peach-android-b78005a3.tar.gzatlas at this save
files changed4 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 −0app/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? {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…")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+}