VaultCryptoTest.kt

10.0 KB · raw

1package com.peachpasswords.vault23import org.junit.Assert.assertArrayEquals4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotEquals7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * VaultCrypto suite (brief Suite 2): Argon2id key derivation, AES-256-GCM12 * round-trips, AAD enforcement, and KDF parameters. The cross-platform KAT for13 * the master key lives in [CrossPlatformKATTest]; this file pins local properties.14 */15class VaultCryptoTest {1617    private val salt = ByteArray(VaultCrypto.SALT_SIZE) { it.toByte() }18    private val salt2 = ByteArray(VaultCrypto.SALT_SIZE) { (it + 1).toByte() }1920    // ── Argon2id parameters (spec / browser parity) ───────────────────────────2122    @Test fun argon2_memory_is_64_mib() = assertEquals(65536, VaultCrypto.ARGON2_MEMORY_KB)23    @Test fun argon2_iterations_is_3() = assertEquals(3, VaultCrypto.ARGON2_ITERATIONS)24    @Test fun argon2_parallelism_is_4() = assertEquals(4, VaultCrypto.ARGON2_PARALLELISM)25    @Test fun argon2_output_is_32_bytes() {26        assertEquals(32, VaultCrypto.ARGON2_HASH_LENGTH)27        assertEquals(32, VaultCrypto.deriveKey("pw", salt).size)28    }2930    // ── Key derivation ────────────────────────────────────────────────────────3132    @Test fun same_password_and_salt_is_deterministic() {33        assertArrayEquals(VaultCrypto.deriveKey("hunter2", salt), VaultCrypto.deriveKey("hunter2", salt))34    }3536    @Test fun different_passwords_derive_different_keys() {37        assertFalse(VaultCrypto.deriveKey("a-password", salt).contentEquals(VaultCrypto.deriveKey("b-password", salt)))38    }3940    @Test fun different_salts_derive_different_keys() {41        assertFalse(VaultCrypto.deriveKey("same", salt).contentEquals(VaultCrypto.deriveKey("same", salt2)))42    }4344    @Test fun empty_password_is_handled() {45        val k = VaultCrypto.deriveKey("", salt)46        assertEquals(32, k.size)47    }4849    @Test fun very_long_password_is_handled() {50        val k = VaultCrypto.deriveKey("x".repeat(2000), salt)51        assertEquals(32, k.size)52    }5354    @Test fun unicode_password_is_handled() {55        assertEquals(32, VaultCrypto.deriveKey("日本語🔑Ωpäss", salt).size)56    }5758    @Test fun nfc_and_nfd_forms_derive_the_same_key() {59        // "é": NFC = U+00E9, NFD = "e" + U+0301. deriveKey normalizes to NFC.60        val nfc = "café"61        val nfd = "café"62        assertNotEquals("the two forms are distinct strings", nfc, nfd)63        assertArrayEquals(VaultCrypto.deriveKey(nfc, salt), VaultCrypto.deriveKey(nfd, salt))64    }6566    @Test fun raw_derive_differs_from_nfc_for_non_nfc_password() {67        val nfd = "café"68        // The legacy raw path hashes un-normalized bytes, so it differs from the NFC path.69        assertFalse(VaultCrypto.deriveKeyRaw(nfd, salt).contentEquals(VaultCrypto.deriveKey(nfd, salt)))70    }7172    @Test fun raw_and_nfc_match_for_already_nfc_password() {73        // A pure-ASCII password is already NFC, so both paths agree.74        assertArrayEquals(VaultCrypto.deriveKeyRaw("plain-ascii", salt), VaultCrypto.deriveKey("plain-ascii", salt))75    }7677    // ── Encryption / decryption ───────────────────────────────────────────────7879    private val key = VaultCrypto.deriveKey("enc-key", salt)8081    @Test fun encrypt_then_decrypt_round_trips() {82        val pt = "secret message".toByteArray()83        val ct = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A)84        assertArrayEquals(pt, VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A))85    }8687    @Test fun ciphertext_layout_is_nonce_plus_ct_plus_tag() {88        val pt = "abc".toByteArray()89        val ct = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A)90        // nonce(12) + ciphertext(=pt) + tag(16)91        assertEquals(VaultCrypto.NONCE_SIZE + pt.size + 16, ct.size)92    }9394    @Test fun fresh_nonce_per_call_so_ciphertexts_differ() {95        val pt = "same plaintext".toByteArray()96        val a = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A)97        val b = VaultCrypto.encrypt(pt, key, VaultCrypto.AAD_SLOT_A)98        assertFalse("nonces must differ", a.contentEquals(b))99        // ...but both decrypt to the same plaintext.100        assertArrayEquals(pt, VaultCrypto.decrypt(a, key, VaultCrypto.AAD_SLOT_A))101        assertArrayEquals(pt, VaultCrypto.decrypt(b, key, VaultCrypto.AAD_SLOT_A))102    }103104    @Test fun modifying_one_byte_fails_authentication() {105        val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A).copyOf()106        ct[ct.size - 1] = (ct[ct.size - 1].toInt() xor 0xFF).toByte()107        assertThrows { VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A) }108    }109110    @Test fun wrong_key_fails_authentication() {111        val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A)112        val wrong = VaultCrypto.deriveKey("other", salt)113        assertThrows { VaultCrypto.decrypt(ct, wrong, VaultCrypto.AAD_SLOT_A) }114    }115116    @Test fun aad_is_enforced_mismatch_fails() {117        val ct = VaultCrypto.encrypt("payload".toByteArray(), key, VaultCrypto.AAD_SLOT_A)118        assertThrows { VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_B) }119    }120121    @Test fun slot_aad_constants_are_distinct() {122        val all = listOf(VaultCrypto.AAD_SLOT_A, VaultCrypto.AAD_SLOT_B, VaultCrypto.AAD_RECOVERY, VaultCrypto.AAD_ROOT_KEY)123        assertEquals("all slot AADs are unique", all.size, all.toSet().size)124    }125126    @Test fun recovery_rootkey_aad_is_the_single_byte_0x01() {127        // Cross-platform wire constant — the browser wraps the root key under AAD = 0x01.128        assertArrayEquals(byteArrayOf(1), VaultCrypto.AAD_RECOVERY_ROOTKEY)129    }130131    @Test fun byte_aad_round_trips_and_is_enforced() {132        val ct = VaultCrypto.encrypt("rk".toByteArray(), key, VaultCrypto.AAD_RECOVERY_ROOTKEY)133        assertArrayEquals("rk".toByteArray(), VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_RECOVERY_ROOTKEY))134        assertThrows { VaultCrypto.decrypt(ct, key, byteArrayOf(2)) }135    }136137    @Test fun decrypt_rejects_too_short_data() {138        assertThrows { VaultCrypto.decrypt(ByteArray(4), key, VaultCrypto.AAD_SLOT_A) }139    }140141    @Test fun empty_plaintext_round_trips() {142        val ct = VaultCrypto.encrypt(ByteArray(0), key, VaultCrypto.AAD_SLOT_A)143        assertEquals(0, VaultCrypto.decrypt(ct, key, VaultCrypto.AAD_SLOT_A).size)144    }145146    // ── Padding ───────────────────────────────────────────────────────────────147148    @Test fun pad_to_bucket_uses_smallest_fitting_bucket() {149        assertEquals(1024, VaultCrypto.padToBucket(ByteArray(500)).size)150        assertEquals(4096, VaultCrypto.padToBucket(ByteArray(1025)).size)151        assertEquals(262144, VaultCrypto.padToBucket(ByteArray(70000)).size)152    }153154    @Test fun pad_to_bucket_keeps_the_top_bucket_boundary() {155        assertEquals(262144, VaultCrypto.padToBucket(ByteArray(262144)).size)156    }157158    @Test fun pad_to_bucket_rounds_above_top_to_the_next_power_of_two() {159        // PB-26-128: shared with the browser-matched encoder, which doubles from160        // the top bucket rather than stepping 64 KiB.161        assertEquals(524288, VaultCrypto.padToBucket(ByteArray(262145)).size)162        assertEquals(524288, VaultCrypto.padToBucket(ByteArray(300000)).size)163        assertEquals(262144, VaultCrypto.padToBucket(ByteArray(262144)).size)164    }165166    @Test fun both_padding_encoders_agree_above_the_top_bucket() {167        for (size in listOf(262_143, 262_144, 262_145, 300_000, 1_000_000)) {168            assertEquals(169                "padding size for $size bytes must not depend on the encoder",170                PeachPayloadCodec.paddedBucketSize(size),171                VaultCrypto.padToBucket(ByteArray(size)).size,172            )173        }174    }175176    @Test fun pad_to_block_rounds_up_to_1024() {177        assertEquals(1024, VaultCrypto.padToBlock(ByteArray(1)).size)178        assertEquals(1024, VaultCrypto.padToBlock(ByteArray(1024)).size)179        assertEquals(2048, VaultCrypto.padToBlock(ByteArray(1025)).size)180    }181182    // ── Misc ──────────────────────────────────────────────────────────────────183184    @Test fun monotonic_timestamp_strictly_increases() {185        var prev = VaultCrypto.monotonicEntryTimestamp()186        repeat(100) {187            val next = VaultCrypto.monotonicEntryTimestamp()188            assertTrue(next > prev)189            prev = next190        }191    }192193    @Test fun monotonic_timestamp_beats_supplied_baseline() {194        val future = System.currentTimeMillis() + 1_000_000195        try {196            assertTrue(VaultCrypto.monotonicEntryTimestamp(future) > future)197        } finally {198            // The counter is process-global and never moves backwards, so199            // leaving it ~17 minutes ahead makes every later test in this JVM200            // stamp entries beyond the sync clock-skew window.201            VaultCrypto.resetMonotonicClockForTest()202        }203    }204205    @Test fun generate_salt_and_keys_have_expected_sizes() {206        assertEquals(VaultCrypto.SALT_SIZE, VaultCrypto.generateSalt().size)207        assertEquals(VaultCrypto.KEY_SIZE, VaultCrypto.generateRootKey().size)208    }209210    @Test fun generated_salts_are_not_all_equal() {211        assertFalse(VaultCrypto.generateSalt().contentEquals(VaultCrypto.generateSalt()))212    }213214    @Test fun zero_clears_the_buffer() {215        val b = byteArrayOf(1, 2, 3, 4)216        VaultCrypto.zero(b)217        assertArrayEquals(ByteArray(4), b)218    }219220    private fun assertThrows(block: () -> Unit) {221        var threw = false222        try { block() } catch (_: Exception) { threw = true }223        assertTrue("expected an exception", threw)224    }225}