CxfImportTest.kt

5.6 KB · raw

1package com.peachpasswords.vault23import com.peachpasswords.ui.vault.CsvImport4import org.junit.Assert.assertEquals5import org.junit.Assert.assertNull6import org.junit.Assert.assertTrue7import org.junit.Test8import java.util.Base64910/**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 */15class CxfImportTest {1617    // ---- tiny DER builder so the vectors are self-assembling ---------------1819    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    }2425    private fun tlv(tag: Int, content: ByteArray): ByteArray =26        byteArrayOf(tag.toByte()) + len(content.size) + content2728    private fun b64url(bytes: ByteArray): String =29        Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)3031    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    }3637    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    }4950    private val seed = ByteArray(32) { it.toByte() }51    private val dScalar = ByteArray(32) { (0x40 + it).toByte() }52    private val pub = ByteArray(65) { 0x04 }5354    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()7980    @Test fun detects_cxf_json() {81        assertEquals(CsvImport.CXF_JSON, CsvImport.detectProvider(cxfJson(b64url(ed25519Pkcs8(seed)))))82    }8384    @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    }9091    @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)101102        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    }114115    @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    }123124    @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    }128129    @Test fun unreadable_key_is_skipped() {130        val entries = CsvImport.parse(cxfJson("!!!not-base64url!!!"), CsvImport.CXF_JSON)131        assertTrue(entries.isEmpty())132    }133134    @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}