CrossPlatformKATTest.kt

4.9 KB · raw

1package com.peachpasswords23import com.peachpasswords.sync.SASDerivation4import com.peachpasswords.sync.computeVaultId5import com.peachpasswords.sync.noiseXXDeterministicHandshakeHashForTest6import com.peachpasswords.sync.noiseNKHkdf2ForTest7import com.peachpasswords.sync.noiseNKInitHashForTest8import com.peachpasswords.sync.noiseXXHkdf2ForTest9import com.peachpasswords.sync.noiseXXInitHashForTest10import com.peachpasswords.vault.VaultCrypto11import org.junit.Assert.assertArrayEquals12import org.junit.Assert.assertEquals13import org.junit.Assert.assertFalse14import org.junit.Assert.assertNotEquals15import org.junit.Test16import java.text.Normalizer1718/**19 * MN-4: cross-platform known-answer tests. These pin the byte-level output of the20 * primitives Android shares with the browser, so a regression on either side21 * (encoding, params, algorithm) is caught immediately. The Android implementations22 * are exact ports of the browser source, so these golden vectors hold on both.23 *24 * Inputs are fixed and printable; expected outputs are frozen below.25 */26class CrossPlatformKATTest {2728    private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }2930    // Argon2id over the non-BMP password "test🔑" (U+1F511, UTF-8) and a fixed salt.31    private val argonSalt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }3233    @Test fun argon2id_non_bmp_password_vector() {34        // password "test🔑" (UTF-8) + salt bytes 0x00..0x0f, Argon2id m=64MiB t=3 p=4.35        // Generated from peach-browser argon2.ts deriveKey after the LV-1 NFC fix —36        // the emoji is NFC-stable, so this vector holds across the normalization fix.37        val key = VaultCrypto.deriveKey("test🔑", argonSalt)38        assertEquals("a519dd4e695d25c6cd69a2f89d51ddcabd952c979f5714ab29fb77f6d1ad3728", hex(key))39    }4041    @Test fun nfc_normalization_makes_composed_and_decomposed_match() {42        // MF-4 / LV-1: "café" decomposed (e + U+0301) and composed (U+00E9) must43        // derive the same key, matching the browser's password.normalize("NFC").44        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)45        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)46        assertNotEquals(nfd, nfc)47        assertArrayEquals(VaultCrypto.deriveKey(nfd, argonSalt), VaultCrypto.deriveKey(nfc, argonSalt))48    }4950    @Test fun deriveKeyRaw_does_not_normalize() {51        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)52        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)53        assertFalse(54            VaultCrypto.deriveKeyRaw(nfd, argonSalt).contentEquals(VaultCrypto.deriveKeyRaw(nfc, argonSalt)),55        )56    }5758    @Test fun compute_vault_id_vector() {59        // Blake3-256( masterKey(0x00..0x1f) || "peach-vault-id" ), hex.60        val masterKey = ByteArray(32) { it.toByte() }61        assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))62    }6364    @Test fun sas_derivation_vector() {65        // First 4 bytes 0x12345678 = 305419896; 305419896 mod 1_000_000 = 419896.66        val handshakeHash = byteArrayOf(0x12, 0x34, 0x56, 0x78) + ByteArray(28)67        assertEquals("419896", SASDerivation.derive(handshakeHash))68    }6970    // PCH-01-002: the Noise protocol-name init hash and the MixKey/Split HKDF now71    // derive over BLAKE2s to match the declared pattern name. These vectors are72    // pinned IDENTICALLY in peach-browser's noise-blake2s-kat.test.ts — a mismatch73    // here means the two repos' Noise wire derivations have diverged (which would74    // break cross-device pairing/sync). A SHA-256 revert on either side also fails.75    @Test fun noise_xx_init_hash_is_blake2s_vector() {76        assertEquals(77            "1ceedd81c5f458b225923dc2507787bf156f9251fc17c45af63263a929fc1ed2",78            hex(noiseXXInitHashForTest()),79        )80    }8182    @Test fun noise_nk_init_hash_is_blake2s_vector() {83        assertEquals(84            "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",85            hex(noiseNKInitHashForTest()),86        )87    }8889    @Test fun noise_hkdf2_blake2s_vector() {90        // ck = 0x00..0x1f, ikm = 0x20..0x3f.91        val ck = ByteArray(32) { it.toByte() }92        val ikm = ByteArray(32) { (it + 32).toByte() }93        val (xk1, xk2) = noiseXXHkdf2ForTest(ck, ikm)94        assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(xk1))95        assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(xk2))96        // hkdf2 is duplicated in NoiseXX.kt and NoiseNK.kt — they must agree.97        val (nk1, nk2) = noiseNKHkdf2ForTest(ck, ikm)98        assertArrayEquals(xk1, nk1)99        assertArrayEquals(xk2, nk2)100    }101102    @Test fun noise_xx_handshake_hash_includes_canonical_msg1_empty_payload_hash() {103        assertEquals(104            "505b6dcb59838e5d163f4fd4897cfff87c20ecca23aa993b416206aba25ee71c",105            hex(noiseXXDeterministicHandshakeHashForTest()),106        )107    }108}