93837a48a6ee
keep the real and duress slot records the same length on every write (v2.14.83)
- time
- 2026-09-01 00:29 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- resolve independent audit findings
- parent
- 0dd507815d8b
↓ peach-android-93837a48.tar.gzatlas at this save
files changed4 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 −0VERSION+1 −1
@@ -1 +1 @@1-2.14.82\ No newline at end of file1+2.14.83\ No newline at end of fileapp/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)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 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 }