package com.peachpasswords.vault import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Test /** * MF-4: the Argon2id KDF must hash the password's explicit UTF-8 bytes (matching the * browser's `TextEncoder().encode`), not its UTF-16 code units. These pin determinism * and the UTF-8 contract for a non-BMP password. */ class VaultCryptoKdfTest { // U+1F511 (๐Ÿ”‘) is non-BMP โ€” a UTF-16 surrogate pair โ€” exactly the case where a // char[]/UTF-16 path could diverge from the browser's UTF-8 encoding. private val nonBmpPassword = "test๐Ÿ”‘" // "test๐Ÿ”‘" private val fixedSalt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() } @Test fun derive_is_deterministic_for_non_bmp_password() { val a = VaultCrypto.deriveKey(nonBmpPassword, fixedSalt) val b = VaultCrypto.deriveKey(nonBmpPassword, fixedSalt) assertArrayEquals("same password + salt yields the same key", a, b) assertEquals(VaultCrypto.ARGON2_HASH_LENGTH, a.size) } @Test fun derive_keys_off_utf8_bytes_of_the_password() { // The 6-codepoint string "test๐Ÿ”‘" is 8 UTF-8 bytes (4 ASCII + 4 for the emoji). assertEquals(8, nonBmpPassword.toByteArray(Charsets.UTF_8).size) // TODO("Verify against browser KAT"): MN-4 pins deriveKey(nonBmpPassword, fixedSalt) // to the exact 32 bytes the browser's argon2id produces for the same input. Until // that vector is wired here, assert the UTF-8 contract: changing only the encoding // assumption (UTF-16 vs UTF-8) must change the key, so a regression to char[] is // observable via the cross-platform vector in MN-4. val key = VaultCrypto.deriveKey(nonBmpPassword, fixedSalt) val asciiKey = VaultCrypto.deriveKey("test", fixedSalt) assertFalse("the emoji must contribute to the derived key", key.contentEquals(asciiKey)) } }