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() { 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 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 60 assertTrue("noise slot matches slot A length", kotlin.math.abs(storage.slotBNoise!!.length - slotALen) <= 4)61 }6263 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 86878889909192 @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 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 118119120121 @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 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}