CrossPlatformKATTest.kt

9.0 KB · raw

1package com.peachpasswords23import com.peachpasswords.sync.DEVICE_MANIFEST_VERSION4import com.peachpasswords.sync.DeviceManifestBody5import com.peachpasswords.sync.DeviceRevocation6import com.peachpasswords.sync.ManifestDeviceEntry7import com.peachpasswords.sync.SASDerivation8import com.peachpasswords.sync.canonicalDeviceManifestBody9import com.peachpasswords.sync.computeVaultId10import com.peachpasswords.sync.noiseNKHkdf2ForTest11import com.peachpasswords.sync.noiseNKInitHashForTest12import com.peachpasswords.vault.VaultCrypto13import org.junit.Assert.assertArrayEquals14import org.junit.Assert.assertEquals15import org.junit.Assert.assertFalse16import org.junit.Assert.assertNotEquals17import org.junit.Test18import java.text.Normalizer1920/**21 * MN-4: cross-platform known-answer tests. These pin the byte-level output of the22 * primitives Android shares with the browser, so a regression on either side23 * (encoding, params, algorithm) is caught immediately. The Android implementations24 * are exact ports of the browser source, so these golden vectors hold on both.25 *26 * Inputs are fixed and printable; expected outputs are frozen below.27 */28class CrossPlatformKATTest {2930    private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }3132    // Argon2id over the non-BMP password "test🔑" (U+1F511, UTF-8) and a fixed salt.33    private val argonSalt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }3435    @Test fun argon2id_non_bmp_password_vector() {36        // password "test🔑" (UTF-8) + salt bytes 0x00..0x0f, Argon2id m=64MiB t=3 p=4.37        // Generated from peach-browser argon2.ts deriveKey after the LV-1 NFC fix —38        // the emoji is NFC-stable, so this vector holds across the normalization fix.39        val key = VaultCrypto.deriveKey("test🔑", argonSalt)40        assertEquals("a519dd4e695d25c6cd69a2f89d51ddcabd952c979f5714ab29fb77f6d1ad3728", hex(key))41    }4243    @Test fun nfc_normalization_makes_composed_and_decomposed_match() {44        // MF-4 / LV-1: "café" decomposed (e + U+0301) and composed (U+00E9) must45        // derive the same key, matching the browser's password.normalize("NFC").46        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)47        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)48        assertNotEquals(nfd, nfc)49        assertArrayEquals(VaultCrypto.deriveKey(nfd, argonSalt), VaultCrypto.deriveKey(nfc, argonSalt))50    }5152    @Test fun deriveKeyRaw_does_not_normalize() {53        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)54        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)55        assertFalse(56            VaultCrypto.deriveKeyRaw(nfd, argonSalt).contentEquals(VaultCrypto.deriveKeyRaw(nfc, argonSalt)),57        )58    }5960    @Test fun compute_vault_id_vector() {61        // Blake3-256( masterKey(0x00..0x1f) || "peach-vault-id" ), hex.62        val masterKey = ByteArray(32) { it.toByte() }63        assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))64    }6566    @Test fun device_manifest_v5_canonical_form_vector() {67        // Pinned byte-for-byte against peach-browser's canonicalise() in68        // device-manifest.test.ts. If either side reorders a key, renames a69        // field, or changes number formatting, this string moves and the two70        // stop verifying each other's signatures.71        val body = DeviceManifestBody(72            manifestVersion = DEVICE_MANIFEST_VERSION,73            issuerDeviceId = "issuer-1",74            issuedAt = 1_700_000_000_000L,75            devices = listOf(76                ManifestDeviceEntry(77                    deviceId = "peer-1",78                    deviceName = "Peer One",79                    deviceType = "android",80                    longTermPublicKeyHex = "aa".repeat(32),81                    signingPublicKeyHex = "bb".repeat(32),82                    wsPort = 49142,83                    relayUrl = "wss://relay.peachpasswords.com",84                    pairedAt = 1_700_000_000_001L,85                ),86            ),87            revoked = listOf(88                DeviceRevocation(89                    deviceId = "gone-1",90                    longTermPublicKeyHex = "cc".repeat(32),91                    revokedAt = 1_700_000_000_002L,92                    revokedByDeviceId = "issuer-1",93                ),94            ),95        )96        val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +97            "\"deviceType\":\"android\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +98            "\"pairedAt\":1700000000001,\"relayUrl\":\"wss://relay.peachpasswords.com\"," +99            "\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +100            "\"vouchDepth\":0,\"wsPort\":49142}],\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +101            "\"manifestVersion\":5,\"revoked\":[{\"deviceId\":\"gone-1\"," +102            "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"recordVersion\":2," +103            "\"revokedAt\":1700000000002,\"revokedByDeviceId\":\"issuer-1\",\"signatureHex\":\"\"}]}"104        assertEquals(expected, canonicalDeviceManifestBody(body))105    }106107    @Test fun sas_derivation_vector() {108        // First 8 bytes are unsigned big-endian, modulo 100_000_000.109        val handshakeHash = byteArrayOf(0x12, 0x34, 0x56, 0x78) + ByteArray(28)110        assertEquals("67721216", SASDerivation.derive(handshakeHash))111    }112113    // PCH-01-002: the Noise protocol-name init hash and the MixKey/Split HKDF now114    // derive over BLAKE2s to match the declared pattern name. These vectors are115    // pinned IDENTICALLY in peach-browser's noise-blake2s-kat.test.ts — a mismatch116    // here means the two repos' Noise wire derivations have diverged (which would117    // break cross-device pairing/sync). A SHA-256 revert on either side also fails.118    @Test fun noise_nk_init_hash_is_blake2s_vector() {119        assertEquals(120            "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",121            hex(noiseNKInitHashForTest()),122        )123    }124125    @Test fun noise_hkdf2_blake2s_vector() {126        // ck = 0x00..0x1f, ikm = 0x20..0x3f.127        val ck = ByteArray(32) { it.toByte() }128        val ikm = ByteArray(32) { (it + 32).toByte() }129        val (nk1, nk2) = noiseNKHkdf2ForTest(ck, ikm)130        assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(nk1))131        assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(nk2))132    }133134    @Test fun device_manifest_v5_canonical_form_omits_null_ws_port_and_relay_url() {135        // Pinned against peach-browser canonicalise() in device-manifest.test.ts.136        // Android's manifestEntryMap (DeviceManifestSupport.kt:194) omits wsPort137        // and relayUrl when null; the browser's canonicalise skips null values138        // for the same reason (v4.7.4). Both sides must reach the same canonical139        // bytes — a future regression that re-introduces the null key on either140        // side will fail this KAT and the browser's mirror test.141        val body = DeviceManifestBody(142            manifestVersion = DEVICE_MANIFEST_VERSION,143            issuerDeviceId = "issuer-1",144            issuedAt = 1_700_000_000_000L,145            devices = listOf(146                ManifestDeviceEntry(147                    deviceId = "peer-1",148                    deviceName = "Peer One",149                    deviceType = "extension",150                    longTermPublicKeyHex = "aa".repeat(32),151                    signingPublicKeyHex = "bb".repeat(32),152                    wsPort = null,153                    relayUrl = null,154                    pairedAt = 1_700_000_000_001L,155                ),156            ),157            revoked = listOf(158                DeviceRevocation(159                    deviceId = "gone-1",160                    longTermPublicKeyHex = "cc".repeat(32),161                    revokedAt = 1_700_000_000_002L,162                    revokedByDeviceId = "issuer-1",163                ),164            ),165        )166        val canonical = canonicalDeviceManifestBody(body)167        // Canonical does NOT mention wsPort or relayUrl at all.168        assertFalse("wsPort must be omitted when null but got: $canonical", canonical.contains("wsPort"))169        assertFalse("relayUrl must be omitted when null but got: $canonical", canonical.contains("relayUrl"))170        // Pinned byte-for-byte — moving a key or changing any escaping fails this.171        val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +172            "\"deviceType\":\"extension\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +173            "\"pairedAt\":1700000000001," +174            "\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\",\"vouchDepth\":0}]," +175            "\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +176            "\"manifestVersion\":5," +177            "\"revoked\":[{\"deviceId\":\"gone-1\"," +178            "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"recordVersion\":2," +179            "\"revokedAt\":1700000000002,\"revokedByDeviceId\":\"issuer-1\",\"signatureHex\":\"\"}]}"180        assertEquals(expected, canonical)181    }182}