SlotPaddingTest.kt

8.7 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(): Boolean { slotA = null; slotBNoise = null; ver = 0; settings = VaultSettings(); return true }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 above_top_bucket_vault_codec_round_trip_preserves_payload() {50        val notes = "n".repeat(300_000)51        val payload = VaultPayload(52            entries = listOf(53                VaultEntry(54                    id = "large",55                    title = "Large note",56                    type = "note",57                    notes = notes,58                    createdAt = 0,59                    modifiedAt = 0,60                ),61            ),62            rootKeyB64 = "root",63        )6465        val encoded = VaultCodec.encode(payload)66        // Above the top bucket the shared rule doubles from 262144 (PB-26-128).67        assertEquals(524288, encoded.size)68        assertEquals(notes, VaultCodec.decode(encoded).entries.single().notes)69    }7071    @Test fun real_and_duress_slots_share_a_bucket_for_small_vaults() = runBlocking {72        val storage = PaddingFakeStorage()73        val repo = VaultRepository(storage)74        repo.createVault("the-real-password-123")75        repo.addEntry(VaultEntry(id = "", title = "Bank", type = "login", password = "pw", createdAt = 0, modifiedAt = 0))76        assertTrue(repo.setDuressPassphrase("open-the-decoy-456"))7778        val slotALen = storage.slotA!!.blob.length79        val decoyLen = storage.decoy!!.blob.length80        assertEquals("a small real vault and its decoy quantise to the same slot length", slotALen, decoyLen)81        // And the always-present noise slot matches slot A's length too (±base64 rounding).82        assertTrue("noise slot matches slot A length", kotlin.math.abs(storage.slotBNoise!!.length - slotALen) <= 4)83    }8485    /** Mirrors VaultStore.writeAligned: write one record, re-align both. */86    private class AlignedSlotRecords {87        var a: String? = null88        var d: String? = null89        fun writeSlotA(slot: StoredVault) {90            val (aligned, sibling) = VaultCodec.alignSlotRecords(VaultCodec.encodeSlot(slot), d)91            a = aligned92            if (sibling != null) d = sibling93        }94        fun writeDecoy(slot: StoredVault) {95            val (aligned, sibling) = VaultCodec.alignSlotRecords(VaultCodec.encodeSlot(slot), a)96            d = aligned97            if (sibling != null) a = sibling98        }99    }100101    private fun slotWithBlob(blobLength: Int) = StoredVault(102        version = 1,103        salt = "c2FsdHNhbHRzYWx0c2FsdA==",104        blob = "A".repeat(blobLength),105    )106107    /**108     * PAI-2026-004: a real decoy's blob is frozen at the size it had when109     * duress was configured, because re-sealing it would need the duress110     * passphrase. Walking slot A up through every padding bucket used to leave111     * the decoy record visibly shorter, which told an at-rest observer both112     * that duress was configured and how far the real vault had grown.113     */114    @Test fun slot_records_stay_aligned_as_the_vault_crosses_every_bucket() {115        val slots = AlignedSlotRecords()116        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))117        // Duress is configured once, at the smallest bucket, and frozen there.118        val frozenDecoy = slotWithBlob(VaultCrypto.PADDING_BUCKETS.first())119        slots.writeDecoy(frozenDecoy)120        val configuredAt = slots.d!!.length121122        for (bucket in VaultCrypto.PADDING_BUCKETS) {123            slots.writeSlotA(slotWithBlob(bucket))124            assertEquals("bucket $bucket: the two records are the same length", slots.a!!.length, slots.d!!.length)125            assertEquals("bucket $bucket: padding is transparent to decode", bucket, VaultCodec.decodeSlot(slots.a!!)!!.blob.length)126            assertEquals("bucket $bucket: the decoy still decodes", frozenDecoy, VaultCodec.decodeSlot(slots.d!!))127        }128        assertTrue("alignment actually did work at the top bucket", slots.a!!.length > configuredAt)129    }130131    @Test fun slot_records_stay_aligned_when_the_vault_shrinks_below_the_decoy() {132        val slots = AlignedSlotRecords()133        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))134        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.last()))135        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))136        assertEquals(slots.a!!.length, slots.d!!.length)137    }138139    /**140     * The target is the longer TRUE record, not the longer stored one, so a141     * vault that grows and then shrinks is not pinned at a high-water mark.142     */143    @Test fun a_fake_decoy_lets_the_pair_follow_the_vault_back_down() {144        val slots = AlignedSlotRecords()145        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))146        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))147        val small = slots.a!!.length148149        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.last()))150        assertTrue("grows with the vault", slots.a!!.length > small)151        assertEquals(slots.a!!.length, slots.d!!.length)152153        // Unconfigured: a fresh fake is generated at the new, smaller size.154        slots.writeSlotA(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))155        slots.writeDecoy(slotWithBlob(VaultCrypto.PADDING_BUCKETS.first()))156        assertEquals("back to the small record length", small, slots.a!!.length)157        assertEquals(slots.a!!.length, slots.d!!.length)158    }159160    @Test fun a_slot_record_with_no_sibling_is_never_padded() {161        val record = VaultCodec.encodeSlot(slotWithBlob(64))162        val (aligned, sibling) = VaultCodec.alignSlotRecords(record, null)163        assertEquals(record, aligned)164        assertEquals(null, sibling)165    }166167    /**168     * PB-26-002: restoreVaultTriad must align slot A and the decoy to equal169     * lengths, the same way writeAligned does, so a post-restore at-rest170     * observer cannot tell which slot is the real vault by length.171     */172    @Test fun restored_triad_aligns_slot_records_to_equal_lengths() {173        val slotA = slotWithBlob(500)174        val decoy = slotWithBlob(50)175        val encodedA = VaultCodec.encodeSlot(slotA)176        val encodedDecoy = VaultCodec.encodeSlot(decoy)177        assertTrue("test setup: slot A is longer than decoy", encodedA.length > encodedDecoy.length)178179        val (alignedA, alignedDecoy) = VaultCodec.alignSlotRecords(encodedA, encodedDecoy)180        assertEquals("restored records must be equal length", alignedA.length, alignedDecoy!!.length)181        assertEquals("slot A content is preserved through padding", slotA, VaultCodec.decodeSlot(alignedA))182        assertEquals("decoy content is preserved through padding", decoy, VaultCodec.decodeSlot(alignedDecoy))183    }184}