1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertTrue6import org.junit.Test789private 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 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 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 82 assertTrue("noise slot matches slot A length", kotlin.math.abs(storage.slotBNoise!!.length - slotALen) <= 4)83 }8485 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 108109110111112113114 @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 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 140141142143 @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 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 168169170171172 @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}