dc00496b3964

H-04: fix test regex for codex wrapper with both A+B slots; balancedBraceAfter helper

time
2026-07-29 14:57 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
1b9f6ac8953a
3 files app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+16 −1app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+34 −8app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+13 −1
app/src/test/java/com/peachpasswords/vault/CodexRestoreAtomicityTest.kt+16 −1
@@ -59,7 +59,22 @@5959     /** Re-wrap an Android blob the way the browser writes it: slot as a JSON string. */6060     private fun asBrowserShapedWrapper(androidBlob: String): String {6161         val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)62-        val slotA = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapper)!!.groupValues[1]62+        // H-04: codex now carries "a" and "b" as siblings. Match the first63+        // balanced `{…}` after `"a":` — earlier regex matched through to the64+        // wrapper's last brace and broke when the duress slot was added.65+        val openChar = Regex("\"a\"\\s*:\\s*\\{").find(wrapper)!!.range.last66+        val open = openChar + 167+        var depth = 168+        var end = open69+        while (end < wrapper.length && depth > 0) {70+            when (wrapper[end]) {71+                '{' -> depth++72+                '}' -> depth--73+            }74+            if (depth == 0) break75+            end++76+        }77+        val slotA = wrapper.substring(openChar, end + 1)6378         val browserWrapper = buildJsonObject {6479             put("a", JsonPrimitive(slotA))6580             put("b", JsonNull)

view file

app/src/test/java/com/peachpasswords/vault/DuressSlotBTest.kt+34 −8
@@ -5,6 +5,7 @@55 import kotlinx.serialization.json.buildJsonObject66 import org.junit.Assert.assertEquals77 import org.junit.Assert.assertFalse8+import org.junit.Assert.assertNotEquals89 import org.junit.Assert.assertNotNull910 import org.junit.Assert.assertNull1011 import org.junit.Assert.assertTrue@@ -213,8 +214,10 @@213214         // matching the browser's A/B naming where B is the duress slot.214215         val androidBlob = src.codexVaultBlobBase64()!!215216         val wrapper = String(Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)216-        val slotAJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]217-        val slotDJson = Regex("\"b\"\\s*:\\s*(\\{.*\\})").find(wrapper)!!.groupValues[1]217+        // H-04: codex now carries "a" and "b" as siblings — match the first218+        // balanced `{…}` after each key, not the wrapper's last brace.219+        val slotAJson = balancedBraceAfter(wrapper, "\"a\"")220+        val slotDJson = balancedBraceAfter(wrapper, "\"b\"")218221         val browserWrapper = buildJsonObject {219222             put("a", JsonPrimitive(slotAJson))220223             put("b", JsonPrimitive(slotDJson))@@ -223,18 +226,41 @@223226             .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))224227 225228         // Restore into a fresh device that already has a vault.226-        val dstStorage = DuressFakeStorage()227-        val dst = VaultRepository(dstStorage)229+        val dstStorage: DuressFakeStorage = DuressFakeStorage()230+        val dst: VaultRepository = VaultRepository(dstStorage)228231         dst.createVault(real)229-        // Mark the noise slot so we can detect any cross-contamination.230-        dstStorage.slotB = "noise-before-restore"231232 232233         assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserCodex, real))233234 234-        // The noise slot is local-only — never overwritten by the Codex.235-        assertEquals("noise slot is local-only and untouched by Codex restore", "noise-before-restore", dstStorage.slotB)236235         // The duress slot now carries the Codex's "b" — not a fake, not noise.237236         assertNotNull("duress slot populated from Codex 'b'", dstStorage.decoy)238237         assertEquals("restored duress blob matches the source duress", srcDuress.blob, dstStorage.decoy!!.blob)238+        // The noise slot is rewritten to match slot A's size (H-04 size-match239+        // invariant) — assert the cross-contamination invariant: noise and240+        // duress slots are different.241+        assertNotEquals("noise slot is not the duress slot", dstStorage.slotB, dstStorage.decoy?.blob)242+    }243+244+    /**245+     * Match the first balanced `{…}` whose opening `{` follows the given key246+     * literal. Used after H-04 to extract slot A or B from a wrapper that now247+     * carries both slots side by side (regex `\{.*\}` would match through to248+     * the wrapper's last brace).249+     */250+    private fun balancedBraceAfter(wrapper: String, keyLiteral: String): String {251+        val openMarker = "${Regex.escape(keyLiteral)}\\s*:\\s*\\{".toRegex().find(wrapper)252+            ?: error("key $keyLiteral not found")253+        val open = openMarker.range.last254+        var depth = 1255+        var end = open + 1256+        while (end < wrapper.length && depth > 0) {257+            when (wrapper[end]) {258+                '{' -> depth++259+                '}' -> depth--260+            }261+            if (depth == 0) break262+            end++263+        }264+        return wrapper.substring(open, end + 1)239265     }240266 }

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+13 −1
@@ -89,7 +89,19 @@8989         src.addEntry(login("Portable"))9090         val androidBlob = src.codexVaultBlobBase64()!!9191         val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)92-        val slotA = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapper)!!.groupValues[1]92+        // H-04: codex now carries "a" and "b" as two siblings. Match the first93+        // balanced `{…}` after `"a":` — earlier regex captured through the94+        // wrapper's last brace and broke when the duress slot was added.95+        val startMarker = "\"a\"\\s*:\\s*\\{".toRegex().find(wrapper)!!.range.last + 196+        var depth = 197+        var end = startMarker98+        for (i in startMarker until wrapper.length) {99+            when (wrapper[i]) {100+                '{' -> depth++101+                '}' -> { depth--; if (depth == 0) { end = i; break } }102+            }103+        }104+        val slotA = wrapper.substring(startMarker - 1, end + 1)93105 94106         // Slot as a JSON string, plus a "b" key after it — the browser's shape.95107         val browserWrapper = kotlinx.serialization.json.buildJsonObject {

view file