SlotPaddingTest.kt

6.9 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertTrue6import org.junit.Test78/** Exposes the raw stored slots so the padding lengths (MN-1) can be inspected. */9private class PaddingFakeStorage : VaultStorage {10    var slotA: StoredVault? = null11    var slotBNoise: String? = null12    var decoy: StoredVault? = null13    private var ver: Long = 014    private var settings = VaultSettings()15    override fun loadSlotA() = slotA16    override fun hasVault() = slotA != null17    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }18    override fun writeSlotB(blobBase64: String): Boolean { slotBNoise = blobBase64; return true }19    override fun loadDecoySlot() = decoy20    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }21    override fun hasDecoySlot() = decoy != null22    override fun clearDecoySlot() { decoy = null }23    override fun version() = ver24    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }25    override fun loadSettings() = settings26    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }27    override fun clearVault() { slotA = null; slotBNoise = null; ver = 0; settings = VaultSettings() }28}2930class SlotPaddingTest {31    @Test fun payload_round_trips_through_bucket_padding() {32        val payload = VaultPayload(33            entries = listOf(VaultEntry(id = "1", title = "GitHub", type = "login", password = "pw", createdAt = 0, modifiedAt = 0)),34            rootKeyB64 = "AAAA",35        )36        val padded = VaultCodec.encode(payload)37        assertEquals("padded to the smallest bucket", VaultCrypto.PADDING_BUCKETS.first(), padded.size)38        val decoded = VaultCodec.decode(padded)39        assertEquals("padding is transparent to decode", "GitHub", decoded.entries.single().title)40        assertEquals("AAAA", decoded.rootKeyB64)41    }4243    @Test fun legacy_unpadded_blob_still_decodes() {44        // A blob written before MN-1 has no trailing NUL padding — must still decode.45        val raw = "{\"entries\":[],\"rootKeyB64\":\"X\"}".toByteArray(Charsets.UTF_8)46        assertEquals("X", VaultCodec.decode(raw).rootKeyB64)47    }4849    @Test fun real_and_duress_slots_share_a_bucket_for_small_vaults() = runBlocking {50        val storage = PaddingFakeStorage()51        val repo = VaultRepository(storage)52        repo.createVault("the-real-password-123")53        repo.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))54        assertTrue(repo.setDuressPassphrase("open-the-decoy-456"))5556        val slotALen = storage.slotA!!.blob.length57        val decoyLen = storage.decoy!!.blob.length58        assertEquals("a small real vault and its decoy quantise to the same slot length", slotALen, decoyLen)59        // And the always-present noise slot matches slot A's length too (±base64 rounding).60        assertTrue("noise slot matches slot A length", kotlin.math.abs(storage.slotBNoise!!.length - slotALen) <= 4)61    }6263    /** Mirrors VaultStore.writeAligned: write one record, re-align both. */64    private class AlignedSlotRecords {65        var a: String? = null66        var d: String? = null67        fun writeSlotA(slot: StoredVault) {68            val (aligned, sibling) = VaultCodec.alignSlotRecords(VaultCodec.encodeSlot(slot), d)69            a = aligned70            if (sibling != null) d = sibling71        }72        fun writeDecoy(slot: StoredVault) {73            val (aligned, sibling) = VaultCodec.alignSlotRecords(VaultCodec.encodeSlot(slot), a)74            d = aligned75            if (sibling != null) a = sibling76        }77    }7879    private fun slotWithBlob(blobLength: Int) = StoredVault(80        version = 1,81        salt = "c2FsdHNhbHRzYWx0c2FsdA==",82        blob = "A".repeat(blobLength),83    )8485    /**86     * PAI-2026-004: a real decoy's blob is frozen at the size it had when87     * duress was configured, because re-sealing it would need the duress88     * passphrase. Walking slot A up through every padding bucket used to leave89     * the decoy record visibly shorter, which told an at-rest observer both90     * that duress was configured and how far the real vault had grown.91     */92    @Test fun slot_records_stay_aligned_as_the_vault_crosses_every_bucket() {93        val slots = AlignedSlotRecords()94        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))95        // Duress is configured once, at the smallest bucket, and frozen there.96        val frozenDecoy = slotWithBlob(VaultCrypto.PADDING_BUCKETS.first())97        slots.writeDecoy(frozenDecoy)98        val configuredAt = slots.d!!.length99100        for (bucket in VaultCrypto.PADDING_BUCKETS) {101            slots.writeSlotA(slotWithBlob(bucket))102            assertEquals("bucket $bucket: the two records are the same length", slots.a!!.length, slots.d!!.length)103            assertEquals("bucket $bucket: padding is transparent to decode", bucket, VaultCodec.decodeSlot(slots.a!!)!!.blob.length)104            assertEquals("bucket $bucket: the decoy still decodes", frozenDecoy, VaultCodec.decodeSlot(slots.d!!))105        }106        assertTrue("alignment actually did work at the top bucket", slots.a!!.length > configuredAt)107    }108109    @Test fun slot_records_stay_aligned_when_the_vault_shrinks_below_the_decoy() {110        val slots = AlignedSlotRecords()111        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))112        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.last()))113        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))114        assertEquals(slots.a!!.length, slots.d!!.length)115    }116117    /**118     * The target is the longer TRUE record, not the longer stored one, so a119     * vault that grows and then shrinks is not pinned at a high-water mark.120     */121    @Test fun a_fake_decoy_lets_the_pair_follow_the_vault_back_down() {122        val slots = AlignedSlotRecords()123        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))124        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))125        val small = slots.a!!.length126127        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.last()))128        assertTrue("grows with the vault", slots.a!!.length > small)129        assertEquals(slots.a!!.length, slots.d!!.length)130131        // Unconfigured: a fresh fake is generated at the new, smaller size.132        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))133        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))134        assertEquals("back to the small record length", small, slots.a!!.length)135        assertEquals(slots.a!!.length, slots.d!!.length)136    }137138    @Test fun a_slot_record_with_no_sibling_is_never_padded() {139        val record = VaultCodec.encodeSlot(slotWithBlob(64))140        val (aligned, sibling) = VaultCodec.alignSlotRecords(record, null)141        assertEquals(record, aligned)142        assertEquals(null, sibling)143    }144}