CrossPlatformKATTest.kt

6.1 KB · raw

1package com.peachpasswords23import com.peachpasswords.sync.DeviceManifestBody4import com.peachpasswords.sync.DeviceRevocation5import com.peachpasswords.sync.ManifestDeviceEntry6import com.peachpasswords.sync.SASDerivation7import com.peachpasswords.sync.canonicalDeviceManifestBody8import com.peachpasswords.sync.computeVaultId9import com.peachpasswords.sync.noiseNKHkdf2ForTest10import com.peachpasswords.sync.noiseNKInitHashForTest11import com.peachpasswords.vault.VaultCrypto12import org.junit.Assert.assertArrayEquals13import org.junit.Assert.assertEquals14import org.junit.Assert.assertFalse15import org.junit.Assert.assertNotEquals16import org.junit.Test17import java.text.Normalizer1819/**20 * MN-4: cross-platform known-answer tests. These pin the byte-level output of the21 * primitives Android shares with the browser, so a regression on either side22 * (encoding, params, algorithm) is caught immediately. The Android implementations23 * are exact ports of the browser source, so these golden vectors hold on both.24 *25 * Inputs are fixed and printable; expected outputs are frozen below.26 */27class CrossPlatformKATTest {2829    private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }3031    // Argon2id over the non-BMP password "test🔑" (U+1F511, UTF-8) and a fixed salt.32    private val argonSalt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }3334    @Test fun argon2id_non_bmp_password_vector() {35        // password "test🔑" (UTF-8) + salt bytes 0x00..0x0f, Argon2id m=64MiB t=3 p=4.36        // Generated from peach-browser argon2.ts deriveKey after the LV-1 NFC fix —37        // the emoji is NFC-stable, so this vector holds across the normalization fix.38        val key = VaultCrypto.deriveKey("test🔑", argonSalt)39        assertEquals("a519dd4e695d25c6cd69a2f89d51ddcabd952c979f5714ab29fb77f6d1ad3728", hex(key))40    }4142    @Test fun nfc_normalization_makes_composed_and_decomposed_match() {43        // MF-4 / LV-1: "café" decomposed (e + U+0301) and composed (U+00E9) must44        // derive the same key, matching the browser's password.normalize("NFC").45        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)46        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)47        assertNotEquals(nfd, nfc)48        assertArrayEquals(VaultCrypto.deriveKey(nfd, argonSalt), VaultCrypto.deriveKey(nfc, argonSalt))49    }5051    @Test fun deriveKeyRaw_does_not_normalize() {52        val nfd = Normalizer.normalize("café", Normalizer.Form.NFD)53        val nfc = Normalizer.normalize("café", Normalizer.Form.NFC)54        assertFalse(55            VaultCrypto.deriveKeyRaw(nfd, argonSalt).contentEquals(VaultCrypto.deriveKeyRaw(nfc, argonSalt)),56        )57    }5859    @Test fun compute_vault_id_vector() {60        // Blake3-256( masterKey(0x00..0x1f) || "peach-vault-id" ), hex.61        val masterKey = ByteArray(32) { it.toByte() }62        assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))63    }6465    @Test fun device_manifest_v2_canonical_form_vector() {66        // Pinned byte-for-byte against peach-browser's canonicalise() in67        // device-manifest.test.ts. If either side reorders a key, renames a68        // field, or changes number formatting, this string moves and the two69        // stop verifying each other's signatures.70        val body = DeviceManifestBody(71            manifestVersion = 2,72            issuerDeviceId = "issuer-1",73            issuedAt = 1_700_000_000_000L,74            devices = listOf(75                ManifestDeviceEntry(76                    deviceId = "peer-1",77                    deviceName = "Peer One",78                    deviceType = "android",79                    longTermPublicKeyHex = "aa".repeat(32),80                    signingPublicKeyHex = "bb".repeat(32),81                    wsPort = 49142,82                    pairedAt = 1_700_000_000_001L,83                ),84            ),85            revoked = listOf(86                DeviceRevocation(87                    deviceId = "gone-1",88                    longTermPublicKeyHex = "cc".repeat(32),89                    revokedAt = 1_700_000_000_002L,90                    revokedByDeviceId = "issuer-1",91                ),92            ),93        )94        val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +95            "\"deviceType\":\"android\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +96            "\"pairedAt\":1700000000001,\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +97            "\"wsPort\":49142}],\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +98            "\"manifestVersion\":2,\"revoked\":[{\"deviceId\":\"gone-1\"," +99            "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"revokedAt\":1700000000002," +100            "\"revokedByDeviceId\":\"issuer-1\"}]}"101        assertEquals(expected, canonicalDeviceManifestBody(body))102    }103104    @Test fun sas_derivation_vector() {105        // First 8 bytes are unsigned big-endian, modulo 100_000_000.106        val handshakeHash = byteArrayOf(0x12, 0x34, 0x56, 0x78) + ByteArray(28)107        assertEquals("67721216", SASDerivation.derive(handshakeHash))108    }109110    // PCH-01-002: the Noise protocol-name init hash and the MixKey/Split HKDF now111    // derive over BLAKE2s to match the declared pattern name. These vectors are112    // pinned IDENTICALLY in peach-browser's noise-blake2s-kat.test.ts — a mismatch113    // here means the two repos' Noise wire derivations have diverged (which would114    // break cross-device pairing/sync). A SHA-256 revert on either side also fails.115    @Test fun noise_nk_init_hash_is_blake2s_vector() {116        assertEquals(117            "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",118            hex(noiseNKInitHashForTest()),119        )120    }121122    @Test fun noise_hkdf2_blake2s_vector() {123        // ck = 0x00..0x1f, ikm = 0x20..0x3f.124        val ck = ByteArray(32) { it.toByte() }125        val ikm = ByteArray(32) { (it + 32).toByte() }126        val (nk1, nk2) = noiseNKHkdf2ForTest(ck, ikm)127        assertEquals("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0", hex(nk1))128        assertEquals("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5", hex(nk2))129    }130}