d8244eb8a3b9
feat: import passkeys from CXF (FIDO Alliance / Google Password Manager) exports (v2.10.3)
- time
- 2026-08-02 16:54 UTC · 2 months ago
- machine
- koh2
- parent
- cf9af01df47c
↓ peach-android-d8244eb8.tar.gzatlas at this save
files changed4 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 −0app/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? {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…")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+}