d8244eb8a3b9

feat: import passkeys from CXF (FIDO Alliance / Google Password Manager) exports (v2.10.3)

Browser parity gap I1/P3 (MEDIUM). CXF is the FIDO Alliance Credential Exchange Format; Google Password Manager exports use it. Android had zero CXF awareness, so passkeys exported from those managers were lost on import. - CsvImport: detect CXF via an accounts sniff placed BEFORE the Bitwarden items sniff (CXF nests items under each account and would otherwise misclassify); parseCxfJson walks accounts->items->credentials and imports type==passkey credentials, requiring the same six fields the browser does - cxfKeyToJwk: decode the base64url PKCS#8 DER key with a minimal DerReader, detect Ed25519 (OID 1.3.101.112 -> alg -8) vs EC P-256 (1.2.840.10045.2.1 -> alg -7), and extract d into the same signing JWK the assertion engine already consumes; unreadable keys skip just that credential - ImportScreen.confirmImport: skip spawning an empty login for passkey-only entries (the browser creates a standalone passkey, not a login+passkey pair) - CxfImportTest: 7 tests with self-assembling PKCS#8 DER vectors

time
2026-08-02 16:54 UTC · 2 months ago
machine
koh2
parent
cf9af01df47c
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+130 −0app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+7 −2app/src/test/java/com/peachpasswords/vault/CxfImportTest.kt+149 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.21+2.10.3

view file

app/src/main/java/com/peachpasswords/ui/vault/CsvImport.kt+130 −0
@@ -10,6 +10,7 @@1010  * Supported providers (detected from header / content shape):1111  *   - "Bitwarden CSV"  (folder,favorite,type,name,login_uri,login_username,login_password,login_totp,notes)1212  *   - "Bitwarden JSON" ({"items":[...]})13+ *   - "Credential Exchange JSON" (CXF / FIDO Alliance: {"accounts":[{"items":[{"credentials":[...]}]}]} — passkey import)1314  *   - "Proton Pass JSON" ({"vaults":{...}})1415  *   - "1Password CSV"  (Title,Url,Username,Password,... — 1Password v8 export)1516  *   - "Chrome CSV"     (name,url,username,password)@@ -82,6 +83,7 @@8283     // Display labels used both for detection return values and "Detected: X export".8384     const val BITWARDEN_CSV = "Bitwarden CSV"8485     const val BITWARDEN_JSON = "Bitwarden JSON"86+    const val CXF_JSON = "Credential Exchange JSON"8587     const val PROTONPASS_JSON = "Proton Pass JSON"8688     const val ONEPASSWORD_CSV = "1Password CSV"8789     const val CHROME_CSV = "Chrome CSV"@@ -97,6 +99,13 @@9799         val trimmed = content.trim()98100         if (trimmed.isEmpty()) return null99101 102+        // CXF (FIDO Alliance Credential Exchange Format): an object carrying an103+        // "accounts" array. Must be checked before the Bitwarden "items" sniff —104+        // a CXF file nests "items" under each account and would otherwise105+        // misclassify as Bitwarden JSON.106+        if (trimmed.startsWith("{") && Regex("\"accounts\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {107+            return CXF_JSON108+        }100109         // Proton Pass JSON: an object carrying a "vaults" map.101110         if (trimmed.startsWith("{") && Regex("\"vaults\"\\s*:\\s*\\{").containsMatchIn(trimmed)) {102111             return PROTONPASS_JSON@@ -138,6 +147,7 @@138147     fun parse(content: String, provider: String): List<ParsedEntry> = when (provider) {139148         PROTONPASS_JSON -> parseProtonPassJson(content)140149         BITWARDEN_JSON -> parseBitwardenJson(content)150+        CXF_JSON -> parseCxfJson(content)141151         BITWARDEN_CSV -> parseCsvByHeader(content, BitwardenCsvMap)142152         CHROME_CSV, EDGE_CSV -> parseCsvByHeader(content, ChromeCsvMap)143153         SAFARI_CSV -> parseCsvByHeader(content, SafariCsvMap)@@ -360,6 +370,126 @@360370         -7 -> "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + keyValue + "\"}"361371         -8 -> "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + keyValue + "\"}"362372         else -> "{}"373+    }374+375+    // ---- Credential Exchange Format (CXF / FIDO Alliance) -----------------376+377+    /**378+     * CXF — the JSON shape Google Password Manager (and other managers) export379+     * passkeys in: an `accounts` array, each with `items`, each with380+     * `credentials`. Only `type == "passkey"` credentials are imported. Mirrors381+     * the browser extension's `parseCredentialExchangeJSON`: the same six fields382+     * are required, and an unreadable private key skips just that credential383+     * rather than failing the whole import.384+     */385+    private fun parseCxfJson(content: String): List<ParsedEntry> {386+        val accounts = extractArray(content, "accounts") ?: return emptyList()387+        val entries = mutableListOf<ParsedEntry>()388+        for (account in splitTopLevelObjects(accounts)) {389+            val items = extractArray(account, "items") ?: continue390+            for (item in splitTopLevelObjects(items)) {391+                val creds = extractArray(item, "credentials") ?: continue392+                val itemTitle = jsonString(item, "title")393+                val passkeys = mutableListOf<ParsedPasskey>()394+                var firstRpId: String? = null395+                for (cred in splitTopLevelObjects(creds)) {396+                    if (!jsonString(cred, "type").equals("passkey", ignoreCase = true)) continue397+                    val credentialId = jsonString(cred, "credentialId") ?: continue398+                    val rpId = jsonString(cred, "rpId") ?: continue399+                    val userName = jsonString(cred, "username") ?: continue400+                    val userDisplayName = jsonString(cred, "userDisplayName") ?: continue401+                    val userHandle = jsonString(cred, "userHandle") ?: continue402+                    val key = jsonString(cred, "key") ?: continue403+                    val (jwk, algorithm) = cxfKeyToJwk(key) ?: continue404+                    if (firstRpId == null) firstRpId = rpId405+                    passkeys += ParsedPasskey(406+                        credentialId = credentialId,407+                        rpId = rpId,408+                        rpName = itemTitle ?: rpId,409+                        privateKeyJwk = jwk,410+                        algorithm = algorithm,411+                        userHandle = userHandle,412+                        userName = userName,413+                        userDisplayName = userDisplayName,414+                        signCount = 0,415+                    )416+                }417+                if (passkeys.isEmpty()) continue418+                // Passkey-only: leave login fields null so the UI does not spawn419+                // an empty login entry (the browser creates a standalone passkey).420+                entries += ParsedEntry(421+                    title = itemTitle ?: firstRpId ?: "Untitled",422+                    url = firstRpId?.let { "https://$it" },423+                    passkeys = passkeys,424+                )425+            }426+        }427+        return entries428+    }429+430+    /**431+     * Decode a CXF `key` (base64url PKCS#8 DER) into the minimal signing JWK the432+     * assertion engine consumes (`kty`/`crv`/`d`). Ed25519 vs EC P-256 is read433+     * from the PKCS#8 AlgorithmIdentifier OID. Returns null for any key we cannot434+     * parse — the caller skips that credential.435+     */436+    private fun cxfKeyToJwk(keyB64url: String): Pair<String, Int>? = runCatching {437+        val der = java.util.Base64.getUrlDecoder().decode(keyB64url)438+        val r = DerReader(der)439+        r.expect(0x30); r.readLength()                       // PrivateKeyInfo SEQUENCE440+        r.expect(0x02); r.skip(r.readLength())               // version INTEGER441+        r.expect(0x30)                                       // AlgorithmIdentifier442+        val algLen = r.readLength()443+        val algEnd = r.pos + algLen                          // pos is past the length byte here444+        r.expect(0x06); val oid = r.readValue(r.readLength())445+        r.pos = algEnd446+        r.expect(0x04); val privKey = r.readValue(r.readLength())  // privateKey OCTET STRING447+        when {448+            oid.contentEquals(ED25519_OID) -> {449+                // RFC 8410: privateKey wraps the 32-byte seed as an inner OCTET STRING.450+                val inner = DerReader(privKey)451+                inner.expect(0x04)452+                val d = inner.readValue(inner.readLength())453+                "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + b64Url(d) + "\"}" to -8454+            }455+            oid.contentEquals(EC_PUBLIC_KEY_OID) -> {456+                // RFC 5915 ECPrivateKey: SEQUENCE { version, privateKey OCTET STRING, ... }.457+                val inner = DerReader(privKey)458+                inner.expect(0x30); inner.readLength()459+                inner.expect(0x02); inner.skip(inner.readLength())  // version == 1460+                inner.expect(0x04)461+                val d = inner.readValue(inner.readLength())462+                "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + b64Url(d) + "\"}" to -7463+            }464+            else -> null465+        }466+    }.getOrNull()467+468+    private fun b64Url(bytes: ByteArray): String =469+        java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)470+471+    // PKCS#8 AlgorithmIdentifier OIDs (DER content, without the 06 <len> prefix).472+    private val ED25519_OID = byteArrayOf(0x2B, 0x65, 0x70)                                          // 1.3.101.112473+    private val EC_PUBLIC_KEY_OID = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) // 1.2.840.10045.2.1474+475+    /** Minimal DER tag-length-value walker — just enough for PKCS#8 private keys. */476+    private class DerReader(private val bytes: ByteArray) {477+        var pos = 0478+        fun expect(tag: Int) {479+            if (pos >= bytes.size || (bytes[pos].toInt() and 0xFF) != tag) {480+                throw IllegalArgumentException("unexpected DER tag at $pos")481+            }482+            pos++483+        }484+        fun readLength(): Int {485+            val first = bytes[pos++].toInt() and 0xFF486+            if (first < 0x80) return first487+            var len = 0488+            repeat(first and 0x7F) { len = (len shl 8) or (bytes[pos++].toInt() and 0xFF) }489+            return len490+        }491+        fun readValue(len: Int): ByteArray = bytes.copyOfRange(pos, pos + len).also { pos += len }492+        fun skip(len: Int) { pos += len }363493     }364494 365495     private fun extractItemsArray(content: String): String? {

view file

app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+7 −2
@@ -320,7 +320,12 @@320320                     if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }321321                 } else {322322                     preview.entries.flatMap { p ->323-                        val login = VaultEntry(323+                        // A passkey-only entry (CXF) carries no login fields; spawning324+                        // an empty login for it would leave junk the browser never creates.325+                        val passkeyOnly = p.passkeys.isNotEmpty() &&326+                            p.password == null && p.username == null &&327+                            p.totp == null && p.notes == null328+                        val login = if (passkeyOnly) null else VaultEntry(324329                             id = "", type = "login", title = p.title, username = p.username,325330                             password = p.password, url = p.url, notes = p.notes, totp = p.totp,326331                             createdAt = now, modifiedAt = now,@@ -340,7 +345,7 @@340345                                 createdAt = now, modifiedAt = now,341346                             )342347                         }343-                        listOf(login) + passkeyEntries348+                        listOfNotNull(login) + passkeyEntries344349                     }345350                 }346351                 _state.value = ImportUiState.Working("saving ${drafts.size} entries…")

view file

app/src/test/java/com/peachpasswords/vault/CxfImportTest.kt+149 −0
@@ -0,0 +1,149 @@1+package com.peachpasswords.vault2+3+import com.peachpasswords.ui.vault.CsvImport4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertNull6+import org.junit.Assert.assertTrue7+import org.junit.Test8+import java.util.Base649+10+/**11+ * CXF (FIDO Alliance Credential Exchange Format) passkey import. The private12+ * keys are real PKCS#8 DER, assembled here so the parser's OID detection and13+ * `d` extraction are genuinely exercised rather than stubbed.14+ */15+class CxfImportTest {16+17+    // ---- tiny DER builder so the vectors are self-assembling ---------------18+19+    private fun len(len: Int): ByteArray = when {20+        len < 0x80 -> byteArrayOf(len.toByte())21+        len < 0x100 -> byteArrayOf(0x81.toByte(), len.toByte())22+        else -> byteArrayOf(0x82.toByte(), (len shr 8).toByte(), (len and 0xFF).toByte())23+    }24+25+    private fun tlv(tag: Int, content: ByteArray): ByteArray =26+        byteArrayOf(tag.toByte()) + len(content.size) + content27+28+    private fun b64url(bytes: ByteArray): String =29+        Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)30+31+    private fun ed25519Pkcs8(seed: ByteArray): ByteArray {32+        val algId = tlv(0x30, tlv(0x06, byteArrayOf(0x2B, 0x65, 0x70)))33+        val privKey = tlv(0x04, tlv(0x04, seed))34+        return tlv(0x30, tlv(0x02, byteArrayOf(0x00)) + algId + privKey)35+    }36+37+    private fun ecP256Pkcs8(d: ByteArray, pub: ByteArray): ByteArray {38+        val ecPubOid = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)39+        val p256Oid = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)40+        val algId = tlv(0x30, tlv(0x06, ecPubOid) + tlv(0x06, p256Oid))41+        val ecPriv = tlv(42+            0x30,43+            tlv(0x02, byteArrayOf(0x01)) +44+                tlv(0x04, d) +45+                tlv(0xA1, tlv(0x03, byteArrayOf(0x00) + pub)),46+        )47+        return tlv(0x30, tlv(0x02, byteArrayOf(0x00)) + algId + tlv(0x04, ecPriv))48+    }49+50+    private val seed = ByteArray(32) { it.toByte() }51+    private val dScalar = ByteArray(32) { (0x40 + it).toByte() }52+    private val pub = ByteArray(65) { 0x04 }53+54+    private fun cxfJson(keyB64url: String, type: String = "passkey") = """55+    {56+      "accounts": [57+        {58+          "userId": "acct-1",59+          "items": [60+            {61+              "title": "example.com",62+              "credentials": [63+                {64+                  "type": "$type",65+                  "credentialId": "Y3JlZC1pZA",66+                  "rpId": "example.com",67+                  "username": "[email protected]",68+                  "userDisplayName": "June",69+                  "userHandle": "dXNlci1oYW5kbGU",70+                  "key": "$keyB64url"71+                }72+              ]73+            }74+          ]75+        }76+      ]77+    }78+    """.trimIndent()79+80+    @Test fun detects_cxf_json() {81+        assertEquals(CsvImport.CXF_JSON, CsvImport.detectProvider(cxfJson(b64url(ed25519Pkcs8(seed)))))82+    }83+84+    @Test fun cxf_wins_over_bitwarden_items_sniff() {85+        // CXF nests "items" under each account; the accounts sniff must win.86+        val json = cxfJson(b64url(ed25519Pkcs8(seed)))87+        assertTrue(json.contains("\"items\""))88+        assertEquals(CsvImport.CXF_JSON, CsvImport.detectProvider(json))89+    }90+91+    @Test fun parses_ed25519_passkey() {92+        val entries = CsvImport.parse(cxfJson(b64url(ed25519Pkcs8(seed))), CsvImport.CXF_JSON)93+        assertEquals(1, entries.size)94+        val entry = entries[0]95+        assertEquals("example.com", entry.title)96+        assertEquals("https://example.com", entry.url)97+        // Passkey-only: no login fields populated, so no empty login is spawned.98+        assertNull(entry.username)99+        assertNull(entry.password)100+        assertEquals(1, entry.passkeys.size)101+102+        val pk = entry.passkeys[0]103+        assertEquals("Y3JlZC1pZA", pk.credentialId)104+        assertEquals("example.com", pk.rpId)105+        assertEquals("example.com", pk.rpName)106+        assertEquals(-8, pk.algorithm)107+        assertEquals("dXNlci1oYW5kbGU", pk.userHandle)108+        assertEquals("[email protected]", pk.userName)109+        assertEquals("June", pk.userDisplayName)110+        assertTrue(pk.privateKeyJwk.contains("\"kty\":\"OKP\""))111+        assertTrue(pk.privateKeyJwk.contains("\"crv\":\"Ed25519\""))112+        assertTrue(pk.privateKeyJwk.contains("\"d\":\"" + b64url(seed) + "\""))113+    }114+115+    @Test fun parses_ec_p256_passkey() {116+        val entries = CsvImport.parse(cxfJson(b64url(ecP256Pkcs8(dScalar, pub))), CsvImport.CXF_JSON)117+        val pk = entries[0].passkeys[0]118+        assertEquals(-7, pk.algorithm)119+        assertTrue(pk.privateKeyJwk.contains("\"kty\":\"EC\""))120+        assertTrue(pk.privateKeyJwk.contains("\"crv\":\"P-256\""))121+        assertTrue(pk.privateKeyJwk.contains("\"d\":\"" + b64url(dScalar) + "\""))122+    }123+124+    @Test fun non_passkey_credential_is_skipped() {125+        val entries = CsvImport.parse(cxfJson(b64url(ed25519Pkcs8(seed)), type = "password"), CsvImport.CXF_JSON)126+        assertTrue(entries.isEmpty())127+    }128+129+    @Test fun unreadable_key_is_skipped() {130+        val entries = CsvImport.parse(cxfJson("!!!not-base64url!!!"), CsvImport.CXF_JSON)131+        assertTrue(entries.isEmpty())132+    }133+134+    @Test fun missing_required_field_is_skipped() {135+        // Drop userHandle — the browser requires all six fields.136+        val json = """137+        {138+          "accounts": [139+            { "items": [ { "title": "x.com", "credentials": [140+              { "type": "passkey", "credentialId": "Y3JlZA", "rpId": "x.com",141+                "username": "u", "userDisplayName": "U",142+                "key": "${b64url(ed25519Pkcs8(seed))}" }143+            ] } ] }144+          ]145+        }146+        """.trimIndent()147+        assertTrue(CsvImport.parse(json, CsvImport.CXF_JSON).isEmpty())148+    }149+}

view file