PeachScriptTest.kt

9.9 KB · raw

1package com.peachpasswords.vault23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNotNull5import org.junit.Assert.assertNull6import org.junit.Assert.assertThrows7import org.junit.Assert.assertTrue8import org.junit.Test9import java.util.Base641011class PeachScriptTest {1213    private fun login() = VaultEntry(14        id = "11111111-2222-3333-4444-555555555555", type = "login", title = "GitHub",15        url = "https://github.com", username = "june", email = "[email protected]",16        password = "hunter2-Σ-🔐", totp = "JBSWY3DPEHPK3PXP", tags = listOf("dev", "work"),17        notes = "primary account", additionalUrls = listOf("https://gist.github.com"),18        breach = 3, passwordChangedAt = 1_600_000_000_000,19        canary = Canary(webhookUrl = "https://canary.example.com/hook", label = "tripwire"),20        createdAt = 1_500_000_000_000, modifiedAt = 1_600_000_500_000,21    )2223    private fun note() = VaultEntry(24        id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", type = "note", title = "そふと メモ 🌸",25        notes = "日本語のノート\nsecond line", tags = listOf("personal"),26        createdAt = 1_500_000_000_000, modifiedAt = 1_500_000_000_000,27    )2829    private fun card() = VaultEntry(30        id = "99999999-8888-7777-6666-555544443333", type = "card", title = "Visa",31        cardholderName = "JUNE MOON", cardNumber = "4111111111111111", expMonth = "04",32        expYear = "2030", cvc = "123", cardNickname = "daily", cardNetwork = "visa",33        notes = "spending card", tags = listOf("finance"),34        createdAt = 1_500_000_000_000, modifiedAt = 1_500_000_000_000,35    )3637    // ── KAT: parse a REAL browser-produced PeachScript payload ──────────────38    // Envelope generated by the browser's own buildPayload (/tmp/gen_peach3.mjs39    // via vite SSR), proving Android decrypts + zstd-decompresses + unpacks the40    // exact bytes the extension writes. This is the cross-platform import proof.41    @Test fun parses_browser_produced_payload() {42        val envelope = Base64.getDecoder().decode(BROWSER_FIXTURE_B64)43        val entries = PeachPayloadCodec.parsePayload(envelope, "backup-pass-123")44        assertEquals(3, entries.size)4546        val l = entries[0]47        assertEquals("11111111-2222-3333-4444-555555555555", l.id)48        assertEquals("login", l.type)49        assertEquals("GitHub", l.title)50        assertEquals("https://github.com", l.url)51        assertEquals("june", l.username)52        assertEquals("[email protected]", l.email)53        assertEquals("hunter2-Σ-🔐", l.password)54        assertEquals("JBSWY3DPEHPK3PXP", l.totp)55        assertEquals(listOf("dev", "work"), l.tags)56        assertEquals("primary account", l.notes)57        assertEquals(listOf("https://gist.github.com"), l.additionalUrls)58        assertEquals(3, l.breach)59        assertEquals(1_600_000_000_000L, l.passwordChangedAt)60        assertEquals("https://canary.example.com/hook", l.canary?.webhookUrl)61        assertEquals("tripwire", l.canary?.label)62        assertEquals(1_500_000_000_000L, l.createdAt)63        assertEquals(1_600_000_500_000L, l.modifiedAt)6465        val n = entries[1]66        assertEquals("note", n.type)67        assertEquals("そふと メモ 🌸", n.title)68        assertEquals("日本語のノート\nsecond line", n.notes)69        assertEquals(listOf("personal"), n.tags)7071        val c = entries[2]72        assertEquals("card", c.type)73        assertEquals("JUNE MOON", c.cardholderName)74        assertEquals("4111111111111111", c.cardNumber)75        assertEquals("04", c.expMonth)76        assertEquals("2030", c.expYear)77        assertEquals("123", c.cvc)78        assertEquals("daily", c.cardNickname)79        assertEquals("visa", c.cardNetwork)80        assertEquals("spending card", c.notes)81        assertEquals(listOf("finance"), c.tags)82    }8384    // ── Android self round-trip: all fields, all entry types ────────────────85    @Test fun self_round_trips_all_fields() {86        val input = listOf(login(), note(), card())87        val out = PeachPayloadCodec.buildPayload(input, "pw-123")88        val back = PeachPayloadCodec.parsePayload(out, "pw-123")89        assertEquals(3, back.size)9091        val l = back[0]92        assertEquals(login().id, l.id)93        assertEquals("hunter2-Σ-🔐", l.password)94        assertEquals(listOf("dev", "work"), l.tags)95        assertEquals(3, l.breach)96        assertEquals(1_600_000_000_000L, l.passwordChangedAt)97        assertEquals("tripwire", l.canary?.label)98        assertEquals(listOf("https://gist.github.com"), l.additionalUrls)99        assertEquals(1_500_000_000_000L, l.createdAt)100        assertEquals(1_600_000_500_000L, l.modifiedAt)101102        val c = back[2]103        assertEquals("4111111111111111", c.cardNumber)104        assertEquals("visa", c.cardNetwork)105        assertEquals("123", c.cvc)106    }107108    // ── Cross-direction: Android export parsed by the browser's algorithm ───109    // (Verified manually via /tmp; here we at least prove a browser-shaped110    // envelope layout: salt(16)+nonce(12)+ct+tag, padded to a size bucket.)111    @Test fun export_envelope_has_browser_shape() {112        val out = PeachPayloadCodec.buildPayload(listOf(login()), "pw")113        // salt(16)+nonce(12)+tag(16) overhead; payload padded to >= 1024 bucket,114        // so the ciphertext alone is at least one bucket.115        assertTrue(out.size >= 16 + 12 + 1024 + 16)116    }117118    @Test fun empty_vault_round_trips() {119        val out = PeachPayloadCodec.buildPayload(emptyList(), "pw")120        assertEquals(0, PeachPayloadCodec.parsePayload(out, "pw").size)121    }122123    @Test fun unicode_fields_round_trip() {124        val out = PeachPayloadCodec.buildPayload(listOf(note()), "pw")125        val n = PeachPayloadCodec.parsePayload(out, "pw").single()126        assertEquals("そふと メモ 🌸", n.title)127        assertEquals("日本語のノート\nsecond line", n.notes)128    }129130    @Test fun wrong_password_is_invalid_passphrase() {131        val out = PeachPayloadCodec.buildPayload(listOf(login()), "right-pw")132        val ex = assertThrows(PeachPayloadCodec.PeachScriptException::class.java) {133            PeachPayloadCodec.parsePayload(out, "wrong-pw")134        }135        assertEquals("INVALID_PASSPHRASE", ex.message)136    }137138    @Test fun pua_codec_round_trips() {139        val bytes = ByteArray(256) { it.toByte() }140        val text = PeachScript.encode(bytes)141        assertTrue(PeachScript.isPeachScript(text))142        assertEquals(256, text.codePointCount(0, text.length))143        assertTrue(bytes.contentEquals(PeachScript.decode(text)))144        // Whitespace is stripped before decode.145        assertTrue(bytes.contentEquals(PeachScript.decode(PeachScript.normalise("  $text \n"))))146    }147148    @Test fun passkey_core_fields_round_trip() {149        val pk = VaultEntry(150            id = "12121212-3434-5656-7878-909090909090", type = "passkey", title = "Passkey",151            passkey = PasskeyData(152                credentialId = "cred-abc", publicKeyCose = "cose-xyz", rpId = "example.com",153                rpName = "Example", userHandle = "uh-1", userName = "june", userDisplayName = "June",154            ),155            createdAt = 1_500_000_000_000, modifiedAt = 1_500_000_000_000,156        )157        val out = PeachPayloadCodec.buildPayload(listOf(pk), "pw")158        val back = PeachPayloadCodec.parsePayload(out, "pw").single()159        assertEquals("passkey", back.type)160        assertNotNull(back.passkey)161        assertEquals("cred-abc", back.passkey?.credentialId)162        assertEquals("example.com", back.passkey?.rpId)163        assertEquals("june", back.passkey?.userName)164    }165166    @Test fun full_export_import_via_peachscript_text() {167        val text = com.peachpasswords.ui.vault.PeachScriptExport.buildEncrypted(listOf(login(), card()), "bp")168        assertTrue(com.peachpasswords.ui.vault.PeachScriptImport.looksLikePeachScript(text))169        val r = com.peachpasswords.ui.vault.PeachScriptImport.decryptAndParse(text, "bp")170        assertTrue(r is com.peachpasswords.ui.vault.PeachScriptImport.DecryptResult.Success)171        r as com.peachpasswords.ui.vault.PeachScriptImport.DecryptResult.Success172        assertEquals(2, r.entries.size)173        assertEquals("hunter2-Σ-🔐", r.entries[0].password)174        assertNull(175            (com.peachpasswords.ui.vault.PeachScriptImport.decryptAndParse(text, "nope")176                as? com.peachpasswords.ui.vault.PeachScriptImport.DecryptResult.Success)?.entries,177        )178    }179180    companion object {181        // Real browser PeachScript envelope (base64) — see /tmp/gen_peach3.mjs.182        private const val BROWSER_FIXTURE_B64 =183            "RTRXPzyl+oHgPhKUK3DCdab09Rb+zMF5Dfphcydo2qzHWqE2PfJHdLEalnhL7jSOxCiowSjIYmrbkCLP2qCLeSHwceeSD9RhBx04bHNoc5J/ml/sXrY/7BFIZizNk5dlDt921VSQNhaNEzYmP/fKkYWjFpz+OOhUCBTUgt+WtCwa5mf8Ctf6R1UPkh21Fu3pwwb/IQjhFvij+CYgS+1skXtogw0iYTrNbSLMIxMsIKvXytFGOp4IGWbbATO/7vFPXnwc2Zh9gOkbUbVM8D7dBUBV+hTodCNHO8PsvxzPAQL0io4/t/Iu60x91eiB8+TTJEwQy9d/aNGM0hgAZq5TzBj4IpYW2tGwfMqRwhuQz07e7SrcL3ggJVlrOf+rph0UnzgdRuCeMS67/83IhNg0UUCftSqdBdQFeqE3UNLavRohLO4AaqTLR1i4UfDApjL79MVtqjztfj8xTJr+zf1SH0BZ0/6PCyIXnm526lxk4WALgJiXu1FWoIXP3xGjs/ZAe1t7RONiVak51kTLs7djQMh6WLMGxW8KVaFAu1a+jG3Zus9Ynn9FBpowipW1E4rnUnytqUzh/udY0q4Y8nJvavyD3hO5xEbHkslfbPSYqxrrGaL3OFb9llKoLyZSiYGB5p+/QM7xn5DFZ3VOtx90xf9wVEe2Uhfhq0XfQUqT384kWp8Ujlyq4pFB6nIe3f3e8C2pE+fJMou0Q2RFvotx/F56T3Ul2d0fpoN7ucyqJC94Fo6B8Peh1u5oxu5njPZEjRSUl2eBt3K8Aval7d9yYbm7I1ecUSLNJ+nHphL/DMYKL2CYQd90pdyfOeDgRCt4WML8z6hjTtW6uPUKDkd5WVu0qp2ktuzawolWi7aah1BnTZMwH2/jm6bPlLNE5r/bFwr1MAgNscjpwwa0DXTfMovbgJHLxEp6/0vpxuYcCfoRoinpTErt7Eafz2rQqw7mEit3eoUZ1Nv+AdfvBC3PN0pB/L5gdzi/cCrEtVCX2A/1NkhV91EKnx0tCQnkjZyovd9ZhKb/QNgzA6v7lugua4e0foRJJBSqwcJJ4Sr7CUXsK5/yuwXsYcJ0I+yTwiHoe4WA5I/go61xvSPsDV/5aXyS0bJbAa5DusVd/SnUM1QFFwHjOfwGUlU/PupZSr2zvZdds+ZX8B8u16L55gZE8xOdOUq7tAd25Gd3v0r6sSwcYu1amjhbfQlgw2RBQBWHmMZ8Jn715A9RblOMW95EqVu8AhG1Jfl547WdahfgC93Aw3nlT39sUBpzqRwa54N6Q9RHc81uC1q+xtU7CiTIMWJm1qqnVjP89MWMnRpYjRXOYBOop73uHh4BjomUlEzXEXmtJiaMq1nsqW85k+FKnO0bF/rGVzmFvHWuBx4cWYic9e8tmUalxdJxDsqFr+dh3h3Fp/YD9oOIT2XD"184    }185}