package com.peachpasswords.vault import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test /** * VaultCrypto suite (brief Suite 2): Argon2id key derivation, AES-256-GCM * round-trips, AAD enforcement, and KDF parameters. The cross-platform KAT for * the master key lives in [CrossPlatformKATTest]; this file pins local properties. */ class VaultCryptoTest { private val salt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() } private val salt2 = ByteArray(VaultCrypto.SALT_SIZE) { (it + 1).toByte() } // ── Argon2id parameters (spec / browser parity) ─────────────────────────── @Test fun argon2_memory_is_64_mib() = assertEquals(65536, VaultCrypto.ARGON2_MEMORY_KB) @Test fun argon2_iterations_is_3() = assertEquals(3, VaultCrypto.ARGON2_ITERATIONS) @Test fun argon2_parallelism_is_4() = assertEquals(4, VaultCrypto.ARGON2_PARALLELISM) @Test fun argon2_output_is_32_bytes() { assertEquals(32, VaultCrypto.ARGON2_HASH_LENGTH) assertEquals(32, VaultCrypto.deriveKey("pw", salt).size) } // ── Key derivation ──────────────────────────────────────────────────────── @Test fun same_password_and_salt_is_deterministic() { assertArrayEquals(VaultCrypto.deriveKey("hunter2", salt), VaultCrypto.deriveKey("hunter2", salt)) } @Test fun different_passwords_derive_different_keys() { assertFalse(VaultCrypto.deriveKey("a-password", salt).contentEquals(VaultCrypto.deriveKey("b-password", salt))) } @Test fun different_salts_derive_different_keys() { assertFalse(VaultCrypto.deriveKey("same", salt).contentEquals(VaultCrypto.deriveKey("same", salt2))) } @Test fun empty_password_is_handled() { val k = VaultCrypto.deriveKey("", salt) assertEquals(32, k.size) } @Test fun very_long_password_is_handled() { val k = VaultCrypto.deriveKey("x".repeat(2000), salt) assertEquals(32, k.size) } @Test fun unicode_password_is_handled() { assertEquals(32, VaultCrypto.deriveKey("日本語🔑Ωpäss", salt).size) } @Test fun nfc_and_nfd_forms_derive_the_same_key() { // "é": NFC = U+00E9, NFD = "e" + U+0301. deriveKey normalizes to NFC. val nfc = "café" val nfd = "café" assertNotEquals("the two forms are distinct strings", nfc, nfd) assertArrayEquals(VaultCrypto.deriveKey(nfc, salt), VaultCrypto.deriveKey(nfd, salt)) } @Test fun raw_derive_differs_from_nfc_for_non_nfc_password() { val nfd = "café" // The legacy raw path hashes un-normalized bytes, so it differs from the NFC path. assertFalse(VaultCrypto.deriveKeyRaw(nfd, salt).contentEquals(VaultCrypto.deriveKey(nfd, salt))) } @Test fun raw_and_nfc_match_for_already_nfc_password() { // A pure-ASCII password is already NFC, so both paths agree. assertArrayEquals(VaultCrypto.deriveKeyRaw("plain-ascii", salt), VaultCrypto.deriveKey("plain-ascii", salt)) } // ── Encryption / decryption ─────────────────────────────────────────────── private val key = VaultCrypto.deriveKey("enc-key", salt) @Test fun encrypt_then_decrypt_round_trips() { val pt = "secret message".toByteArray() val ct = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A) assertArrayEquals(pt, VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A)) } @Test fun ciphertext_layout_is_nonce_plus_ct_plus_tag() { val pt = "abc".toByteArray() val ct = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A) // nonce(12) + ciphertext(=pt) + tag(16) assertEquals(VaultCrypto.NONCE_SIZE + pt.size + 16, ct.size) } @Test fun fresh_nonce_per_call_so_ciphertexts_differ() { val pt = "same plaintext".toByteArray() val a = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A) val b = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A) assertFalse("nonces must differ", a.contentEquals(b)) // ...but both decrypt to the same plaintext. assertArrayEquals(pt, VaultCrypto.decrypt(a, key, VaultCrypto.AAD_SLOT_A)) assertArrayEquals(pt, VaultCrypto.decrypt(b, key, VaultCrypto.AAD_SLOT_A)) } @Test fun modifying_one_byte_fails_authentication() { val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A).copyOf() ct[ct.size - 1] = (ct[ct.size - 1].toInt() xor 0xFF).toByte() assertThrows { VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A) } } @Test fun wrong_key_fails_authentication() { val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A) val wrong = VaultCrypto.deriveKey("other", salt) assertThrows { VaultCrypto.decrypt(ct, wrong, VaultCrypto.AAD_SLOT_A) } } @Test fun aad_is_enforced_mismatch_fails() { val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A) assertThrows { VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_B) } } @Test fun slot_aad_constants_are_distinct() { val all = listOf(VaultCrypto.AAD_SLOT_A, VaultCrypto.AAD_SLOT_B, VaultCrypto.AAD_RECOVERY, VaultCrypto.AAD_ROOT_KEY) assertEquals("all slot AADs are unique", all.size, all.toSet().size) } @Test fun recovery_rootkey_aad_is_the_single_byte_0x01() { // Cross-platform wire constant — the browser wraps the root key under AAD = 0x01. assertArrayEquals(byteArrayOf(1), VaultCrypto.AAD_RECOVERY_ROOTKEY) } @Test fun byte_aad_round_trips_and_is_enforced() { val ct = VaultCrypto.encrypt("rk".toByteArray(), key, VaultCrypto.AAD_RECOVERY_ROOTKEY) assertArrayEquals("rk".toByteArray(), VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_RECOVERY_ROOTKEY)) assertThrows { VaultCrypto.decrypt(ct, key, byteArrayOf(2)) } } @Test fun decrypt_rejects_too_short_data() { assertThrows { VaultCrypto.decrypt(ByteArray(4), key, VaultCrypto.AAD_SLOT_A) } } @Test fun empty_plaintext_round_trips() { val ct = VaultCrypto.encrypt(ByteArray(0), key, VaultCrypto.AAD_SLOT_A) assertEquals(0, VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A).size) } // ── Padding ─────────────────────────────────────────────────────────────── @Test fun pad_to_bucket_uses_smallest_fitting_bucket() { assertEquals(1024, VaultCrypto.padToBucket(ByteArray(500)).size) assertEquals(4096, VaultCrypto.padToBucket(ByteArray(1025)).size) assertEquals(262144, VaultCrypto.padToBucket(ByteArray(70000)).size) } @Test fun pad_to_bucket_beyond_largest_keeps_size() { assertEquals(300000, VaultCrypto.padToBucket(ByteArray(300000)).size) } @Test fun pad_to_block_rounds_up_to_1024() { assertEquals(1024, VaultCrypto.padToBlock(ByteArray(1)).size) assertEquals(1024, VaultCrypto.padToBlock(ByteArray(1024)).size) assertEquals(2048, VaultCrypto.padToBlock(ByteArray(1025)).size) } // ── Misc ────────────────────────────────────────────────────────────────── @Test fun monotonic_timestamp_strictly_increases() { var prev = VaultCrypto.monotonicEntryTimestamp() repeat(100) { val next = VaultCrypto.monotonicEntryTimestamp() assertTrue(next > prev) prev = next } } @Test fun monotonic_timestamp_beats_supplied_baseline() { val future = System.currentTimeMillis() + 1_000_000 try { assertTrue(VaultCrypto.monotonicEntryTimestamp(future) > future) } finally { // The counter is process-global and never moves backwards, so // leaving it ~17 minutes ahead makes every later test in this JVM // stamp entries beyond the sync clock-skew window. VaultCrypto.resetMonotonicClockForTest() } } @Test fun generate_salt_and_keys_have_expected_sizes() { assertEquals(VaultCrypto.SALT_SIZE, VaultCrypto.generateSalt().size) assertEquals(VaultCrypto.KEY_SIZE, VaultCrypto.generateRootKey().size) } @Test fun generated_salts_are_not_all_equal() { assertFalse(VaultCrypto.generateSalt().contentEquals(VaultCrypto.generateSalt())) } @Test fun zero_clears_the_buffer() { val b = byteArrayOf(1, 2, 3, 4) VaultCrypto.zero(b) assertArrayEquals(ByteArray(4), b) } private fun assertThrows(block: () -> Unit) { var threw = false try { block() } catch (_: Exception) { threw = true } assertTrue("expected an exception", threw) } }