93837a48a6ee

keep the real and duress slot records the same length on every write (v2.14.83)

PAI-2026-004: a real decoy cannot be re-sealed without the duress passphrase — which is never retained — so its blob stayed at the length it had when duress was configured while slot A kept growing through the padding buckets. An at-rest observer comparing the two stored record lengths could read out both that duress was configured and how far the real vault had grown. VaultStore now aligns the pair on every write: writing either slot record re-pads both with trailing NULs to the longer of the two, in a single commit so the pair can never be caught misaligned. NUL cannot occur inside the JSON, for the same reason the plaintext padding uses it, so decodeSlot only has to trim it. The target is the longer TRUE record rather than the longer stored one, so the pair tracks the current vault instead of ratcheting up to a high-water mark whenever the vault temporarily grows. Duress configuration itself is untouched: a fake decoy is still regenerated at slot A's size when duress is not configured, which keeps the blobs equal too, not just the records. Verification: four new cases covering growth through every padding bucket with a frozen decoy, shrink below the decoy's frozen size, a fake decoy following the vault back down with no ratchet, and an unsibled record staying unpadded; full JVM suite 1038 tests / 0 failures / 0 errors / 2 skipped, up from 1034; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug and debug androidTest sources compile.

time
2026-09-01 00:29 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
resolve independent audit findings
parent
0dd507815d8b
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+11 −10app/src/main/java/com/peachpasswords/vault/VaultStore.kt+47 −3app/src/test/java/com/peachpasswords/vault/SlotPaddingTest.kt+82 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.82\ No newline at end of file1+2.14.83\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+11 −10
@@ -978,10 +978,11 @@978978             // duress blob matches slot A's blob on disk. VaultCodec.decode strips979979             // trailing NULs, so the extra padding survives encrypt but never980980             // reaches the JSON parser.981-            // ponytail: only re-pads at configuration time — if slot A grows past982-            // its initial bucket (entries cross 4096/16384/etc.), the real decoy983-            // stays at the smaller bucket. Re-padding on every persist requires984-            // keeping the duress passphrase in memory, which we don't.981+            // This decoy plaintext length is frozen from here on: re-sealing it982+            // on later growth would need the duress passphrase, which is never983+            // retained. VaultStore compensates by re-padding the two slot984+            // RECORDS to the longer of the two on every write, so the frozen985+            // blob no longer shows up as a length difference at rest.985986             val slotA = store.loadSlotA()986987             val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0987988             val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!@@ -2778,12 +2779,12 @@27782779         return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))27792780     }27802781 2781-    /** H-04: keep the duress record presence- and size-indistinguishable from2782-     *  slot A on every persist. A real decoy is left alone (it was padded at2783-     *  setDuressPassphrase time, and we don't have the duress key here to2784-     *  re-pad on entry growth — that's an accepted residual tell, see the2785-     *  ponytail note on setDuressPassphrase). No real decoy → a fake of slot2786-     *  A's size. */2782+    /** H-04: keep the duress record present on every persist. A real decoy is2783+     *  left alone (it was padded at setDuressPassphrase time, and we don't have2784+     *  the duress key here to re-seal it) — VaultStore re-pads both slot2785+     *  records to the longer of the two on every write, which is what keeps the2786+     *  pair the same length after slot A crosses a padding bucket2787+     *  (PAI-2026-004). No real decoy → a fresh fake of slot A's size. */27872788     private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {27882789         val isConfigured = mutateState { duressConfigured }27892790         return if (isConfigured) true else writeFakeDuressSlot(slotA)

view file

app/src/main/java/com/peachpasswords/vault/VaultStore.kt+47 −3
@@ -95,6 +95,8 @@9595     val recoveryWordsSet: Boolean = false,9696 )9797 98+const val SLOT_RECORD_PAD: Char = '\u0000'99+98100 /** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */99101 object VaultCodec {100102     private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }@@ -115,7 +117,38 @@115117     fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)116118     fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()117119     fun encodeSlot(s: StoredVault): String = json.encodeToString(s)118-    fun decodeSlot(s: String): StoredVault? = runCatching { json.decodeFromString<StoredVault>(s) }.getOrNull()120+    fun decodeSlot(s: String): StoredVault? =121+        runCatching { json.decodeFromString<StoredVault>(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull()122+123+    /**124+     * Trailing NULs pad a slot record to its sibling's length. NUL cannot occur125+     * inside the JSON — the same reason [encode] can pad plaintext with it — so126+     * [decodeSlot] only has to trim it.127+     */128+    fun padSlotRecord(record: String, targetLength: Int): String =129+        if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD)130+131+    fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length132+133+    /**134+     * The (written, sibling) record pair after alignment, so both slots are the135+     * same length at rest (PAI-2026-004).136+     *137+     * A real decoy cannot be re-sealed without the duress passphrase, so its138+     * blob keeps the length it had when duress was configured while slot A139+     * keeps growing through the padding buckets. Padding the shorter record to140+     * the longer one is what keeps an at-rest observer from reading duress141+     * configuration, or how far the vault has grown, out of the pair.142+     *143+     * The target is the longer TRUE record, not the longer stored one, so the144+     * pair tracks the current vault instead of ratcheting up to a high-water145+     * mark every time the vault temporarily grows.146+     */147+    fun alignSlotRecords(record: String, sibling: String?): Pair<String, String?> {148+        if (sibling == null) return record to null149+        val target = maxOf(record.length, slotRecordLength(sibling))150+        return padSlotRecord(record, target) to padSlotRecord(sibling, target)151+    }119152     fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)120153     fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()121154 }@@ -163,7 +196,7 @@163196     override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)164197 165198     override fun writeSlotA(slot: StoredVault): Boolean =166-        prefs.edit().putString(KEY_SLOT_A, VaultCodec.encodeSlot(slot)).commit()199+        writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS)167200 168201     override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)169202 @@ -172,7 +205,18 @@172205 173206     override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)174207     override fun writeDecoySlot(slot: StoredVault): Boolean =175-        prefs.edit().putString(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot)).commit()208+        writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A)209+210+    /** Both slot records are written in one commit so the pair can never be caught misaligned. */211+    private fun writeAligned(key: String, record: String, siblingKey: String): Boolean {212+        val (aligned, alignedSibling) = VaultCodec.alignSlotRecords(213+            record,214+            prefs.getString(siblingKey, null),215+        )216+        val edits = prefs.edit().putString(key, aligned)217+        if (alignedSibling != null) edits.putString(siblingKey, alignedSibling)218+        return edits.commit()219+    }176220     override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)177221     override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }178222 

view file

app/src/test/java/com/peachpasswords/vault/SlotPaddingTest.kt+82 −0
@@ -59,4 +59,86 @@5959         // And the always-present noise slot matches slot A's length too (±base64 rounding).6060         assertTrue("noise slot matches slot A length", kotlin.math.abs(storage.slotBNoise!!.length - slotALen) <= 4)6161     }62+63+    /** 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+    }78+79+    private fun slotWithBlob(blobLength: Int) = StoredVault(80+        version = 1,81+        salt = "c2FsdHNhbHRzYWx0c2FsdA==",82+        blob = "A".repeat(blobLength),83+    )84+85+    /**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!!.length99+100+        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+    }108+109+    @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+    }116+117+    /**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!!.length126+127+        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)130+131+        // 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+    }137+138+    @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+    }62144 }

view file