c7836f5947ee

make Codex and PeachScript import actually work on a real browser-generated vault — three separate bugs, all invisible to the small Android-generated fixtures that previously passed.

(1) Codex page geometry. The importer blind-cropped Android's own private grid (36pt margin, 54pt header, computed square cell) while the canonical Codex page format is the browser's pdf-layout.ts (28pt margin, 44+16pt header, fixed 80pt codes, 112pt row step). A browser Codex therefore decoded 437 of 1709 attempted cells with 0 of 9 stripes ready — looking like a flaky scan rather than a format mismatch. Ported the shared geometry to CodexPageLayout and pointed BOTH the importer and the generator at it, so an Android Codex and a browser Codex are now the same artifact. Real PDF now reads 1978 of 1978 codes and reconstructs: verify code PX-2YVP-KVL6, 806 entries. (2) Codex vault blob shape. restoreFromCodex pulled slot A out of the wrapper with a regex assuming Android's shape — an inline object under 'a' with nothing after it. The browser writes each slot as a JSON *string* and includes 'b', so the regex matched neither and every browser Codex failed with invalid_codex_blob after decoding perfectly. Parses the wrapper as JSON now (no regex — this is the second bug that regex has caused), accepts a slot as either string or object, and carries a real slot B across instead of overwriting it with noise, which would have destroyed a duress vault. Covered by a new test pinning both wrapper shapes. (3) PeachScript file import. A real export is 262k PUA characters and the file path fed all of it through a Compose BasicTextField, which bogs down badly enough to look broken. File content now bypasses the field entirely and goes straight to the decoder; the field is only for hand-pasted text. Verified the codec itself was never at fault via a host-JVM test against the real 262k backup: decodes to 262188 bytes and reports WrongPassword (not corrupted) for a bad password. Also: the paste field rendered PeachScript as Chinese glyphs — PeachScript lives in the PUA block and without its font Android fell back to Noto CJK, which also maps that block. Field now uses PeachType.Script. Final decrypt still unverified here: it needs June's master password, so she completes that step on her device.

time
2026-07-24 21:03 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
codex + peach import of a real browser vault
parent
8984cc3fac9d
7 files app/src/main/java/com/peachpasswords/codex/CodexPageLayout.kt+55 −0app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+14 −18app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+14 −20app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+32 −12app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −8app/src/test/java/com/peachpasswords/ui/vault/RealBackupDecodeTest.kt+48 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+27 −0
app/src/main/java/com/peachpasswords/codex/CodexPageLayout.kt+55 −0
@@ -0,0 +1,55 @@1+package com.peachpasswords.codex2+3+/**4+ * A4 Codex page geometry — the exact port of the browser's5+ * `popup/codex/pdf-layout.ts`, which is the canonical Codex page format.6+ *7+ * A Codex is a *cross-platform paper backup*: a PDF printed from the browser8+ * extension has to restore on Android and vice versa. That only holds if both9+ * sides agree on where every Aztec sits on the page, to the point.10+ *11+ * They didn't. Android's generator used its own geometry (36pt margin, 54pt12+ * header, a computed square cell) and its importer blind-cropped that same13+ * private grid, so it could only ever read PDFs it had produced itself. Handed14+ * a real browser-generated Codex it cropped the wrong regions and decoded15+ * roughly a quarter of the codes — enough to look like a flaky scan rather16+ * than a format mismatch.17+ *18+ * Units are PDF points (1pt = 1/72 inch). Keep these values identical to19+ * pdf-layout.ts; drift here silently breaks paper restore across platforms.20+ */21+object CodexPageLayout {22+    const val PAGE_WIDTH = 595.28f23+    const val PAGE_HEIGHT = 841.89f24+25+    const val MARGIN = 28f26+27+    const val HEADER_HEIGHT = 44f28+29+    /** Vertical gap between the header hairline and the top of row 0. */30+    const val HEADER_GAP = 16f31+32+    const val CODE_SIZE = 80f33+34+    /** Horizontal gap between adjacent codes in a row. */35+    const val COL_GAP = (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1)36+37+    /** Space reserved for the per-code numeric label drawn under each Aztec. */38+    const val LABEL_HEIGHT = 14f39+40+    /** Printed white space between a label and the next row's code. */41+    const val ROW_GAP = 18f42+43+    /** Top-of-code-N to top-of-code-N+1 distance — what the importer steps by. */44+    const val ROW_STEP = CODE_SIZE + ROW_GAP + LABEL_HEIGHT45+46+    /** Top of row 0, measured from the top of the page. */47+    const val GRID_TOP = MARGIN + HEADER_HEIGHT + HEADER_GAP48+49+    /** Quiet zone the importer includes around each code, as a fraction of it. */50+    const val QUIET_FRACTION = 0.12f51+52+    fun codeLeft(col: Int): Float = MARGIN + col * (CODE_SIZE + COL_GAP)53+54+    fun codeTop(row: Int): Float = GRID_TOP + row * ROW_STEP55+}

view file

app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+14 −18
@@ -24,23 +24,15 @@2424  * lossless path instead.2525  */2626 object CodexPdf {27-    private const val PAGE_W = 59528-    private const val PAGE_H = 84229-    private const val MARGIN = 36f30-    private const val HEADER_H = 54f31-    private const val FOOTER_H = 28f27+    // Canonical shared page format — see CodexPageLayout. These used to be this28+    // file's own private geometry, which made an Android Codex readable only by29+    // Android.30+    private val PAGE_W = CodexPageLayout.PAGE_WIDTH.toInt()31+    private val PAGE_H = CodexPageLayout.PAGE_HEIGHT.toInt()32+    private const val MARGIN = CodexPageLayout.MARGIN3233 3334     fun render(aztecs: List<BitMatrix>, manifest: CodexManifest): ByteArray {3435         val doc = PdfDocument()35-        val gridTop = MARGIN + HEADER_H36-        val gridW = PAGE_W - 2 * MARGIN37-        val gridH = PAGE_H - gridTop - MARGIN - FOOTER_H38-        val cellW = gridW / CODES_PER_ROW39-        val cellH = gridH / CODES_PER_COL40-        val cell = minOf(cellW, cellH)41-        // Tighter padding → larger codes (~78pt, close to the browser's 80pt),42-        // which keeps modules above the camera/scan decode threshold.43-        val pad = cell * 0.05f4436 4537         val title = Paint().apply { color = Color.BLACK; textSize = 16f; isAntiAlias = true; isFakeBoldText = true }4638         val mono = Paint().apply { color = Color.rgb(0x55, 0x55, 0x55); textSize = 9f; isAntiAlias = true }@@ -68,10 +60,14 @@6860                 val slot = idx - first6961                 val row = slot / CODES_PER_ROW7062                 val col = slot % CODES_PER_ROW71-                val left = MARGIN + col * cell + pad72-                val top = gridTop + row * cell + pad73-                val sizePt = cell - 2 * pad74-                drawAztecVector(canvas, aztecs[idx], left, top, sizePt, modulePaint)63+                drawAztecVector(64+                    canvas,65+                    aztecs[idx],66+                    CodexPageLayout.codeLeft(col),67+                    CodexPageLayout.codeTop(row),68+                    CodexPageLayout.CODE_SIZE,69+                    modulePaint,70+                )7571             }7672 7773             canvas.drawText(

view file

app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+14 −20
@@ -42,34 +42,28 @@4242     }4343 4444     /**45-     * Render each grid cell directly into a high-res bitmap via a per-cell46-     * transform (CodexPdf geometry), then decode. Per-cell rendering keeps each47-     * Aztec at high fidelity without allocating a giant full-page bitmap.45+     * Render each code's region into a high-res bitmap via a per-cell46+     * transform, then decode. Per-cell rendering keeps each Aztec at high47+     * fidelity without allocating a giant full-page bitmap.4848      *49-     * [target] was 640 and consistently under-decoded any Codex past a small50-     * handful of codes — a live-generated real vault's PDF decoded roughly51-     * 5 of 76 codes at that resolution. Bumped to 1600 (confirmed via an52-     * instrumented round-trip test: 75/75 codes decoded cleanly at a53-     * realistic 76-code scale, vs. failing at 640 once codes needed more than54-     * a handful of Aztec layers to fit their data). See [CodexPdf] for the55-     * other half of this fix — the codes themselves used to be corrupted by56-     * raster-image PDF embedding before this crop resolution even mattered.49+     * Positions come from [CodexPageLayout] — the shared browser/Android page50+     * format — not from whatever grid this app's own generator happens to use.5751      */5852     private fun decodeGrid(page: PdfRenderer.Page): List<ChunkEnvelope> {59-        val margin = 36f60-        val gridTop = margin + 54f61-        val cell = minOf((595f - 2 * margin) / CODES_PER_ROW, (842f - gridTop - margin - 28f) / CODES_PER_COL)62-        val pad = cell * 0.05f // must match CodexPdf63-        val sizePt = cell - 2 * pad64-        val bleed = sizePt * 0.10f65-        val region = sizePt + 2 * bleed53+        val L = CodexPageLayout54+        // Crop each code plus its quiet zone, at the canonical positions —55+        // mirrors the browser importer's cellRectFor. Aztec cannot tolerate a56+        // clipped finder pattern, so the region must match the generator's57+        // geometry exactly rather than being an evenly-sliced grid.58+        val quiet = L.CODE_SIZE * L.QUIET_FRACTION59+        val region = L.CODE_SIZE + 2 * quiet6660         val target = 16006761         val scale = target / region6862 6963         val out = ArrayList<ChunkEnvelope>()7064         for (row in 0 until CODES_PER_COL) for (col in 0 until CODES_PER_ROW) {71-            val leftPt = margin + col * cell + pad - bleed72-            val topPt = gridTop + row * cell + pad - bleed65+            val leftPt = L.codeLeft(col) - quiet66+            val topPt = L.codeTop(row) - quiet7367             val cellBmp = Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888)7468             cellBmp.eraseColor(Color.WHITE)7569             val m = android.graphics.Matrix().apply {

view file

app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+32 −12
@@ -525,18 +525,35 @@525525 526526 @Composable527527 private fun PeachScriptView(vm: ImportViewModel, initialPaste: String = "") {528-    var paste by remember(initialPaste) { mutableStateOf(initialPaste) }528+    // A backup loaded from a file never goes through the text field. A real529+    // vault's export is hundreds of thousands of PUA characters (June's is530+    // 262k) and pushing that into a BasicTextField bogs the UI down badly531+    // enough that the import looks broken. The field is only for hand-pasted532+    // text; file content is held aside and handed straight to the decoder.533+    val fromFile = initialPaste.isNotEmpty()534+    var paste by remember(initialPaste) { mutableStateOf(if (fromFile) "" else initialPaste) }529535     var backupPassword by remember { mutableStateOf("") }530-    val needsPassword = remember(paste) { vm.peachScriptNeedsPassword(paste) }536+    val source = if (fromFile) initialPaste else paste537+    val needsPassword = remember(source) { vm.peachScriptNeedsPassword(source) }531538 532539     GlassCard {533-        FieldLabel("Paste your PeachScript export")534-        Spacer(Modifier.size(PeachSpacing.sm))535-        PeachMultilineField(536-            value = paste,537-            onValueChange = { paste = it },538-            minLines = 6,539-        )540+        if (fromFile) {541+            FieldLabel("Backup loaded from file")542+            Spacer(Modifier.size(PeachSpacing.sm))543+            Text(544+                "%,d characters of PeachScript ready to import.".format(initialPaste.length),545+                color = peachColors.T1,546+                style = PeachType.body,547+            )548+        } else {549+            FieldLabel("Paste your PeachScript export")550+            Spacer(Modifier.size(PeachSpacing.sm))551+            PeachMultilineField(552+                value = paste,553+                onValueChange = { paste = it },554+                minLines = 6,555+            )556+        }540557         if (needsPassword) {541558             Spacer(Modifier.size(PeachSpacing.lg))542559             PeachUnderlineField(@@ -550,8 +567,8 @@550567     Spacer(Modifier.size(PeachSpacing.lg))551568     AccentButton(552569         label = "Import",553-        enabled = paste.isNotBlank(),554-        onClick = { vm.importPeachScript(paste, backupPassword) },570+        enabled = source.isNotBlank(),571+        onClick = { vm.importPeachScript(source, backupPassword) },555572     )556573 }557574 @@ -787,7 +804,10 @@787804         BasicTextField(788805             value = value,789806             onValueChange = onValueChange,790-            textStyle = PeachType.bodyMono.copy(color = peachColors.T0),807+            // PeachScript lives in the PUA block (U+E000..U+E0FF). Without the808+            // PeachScript font Android falls back to a font that also maps that809+            // block — Noto CJK — so a pasted backup rendered as Chinese glyphs.810+            textStyle = PeachType.bodyMono.copy(color = peachColors.T0, fontFamily = PeachType.Script),791811             cursorBrush = SolidColor(accent),792812             minLines = minLines,793813             modifier = Modifier.fillMaxWidth(),

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −8
@@ -7,6 +7,11 @@77 import kotlinx.coroutines.sync.withLock88 import java.security.SecureRandom99 import java.text.Normalizer10+import kotlinx.serialization.json.Json11+import kotlinx.serialization.json.JsonElement12+import kotlinx.serialization.json.JsonNull13+import kotlinx.serialization.json.JsonObject14+import kotlinx.serialization.json.JsonPrimitive1015 import java.util.Base641116 import java.util.UUID1217 import javax.inject.Inject@@ -49,6 +54,8 @@4954  *  - destroyVault never touches the Pro license (separate store).5055  */5156 @Singleton57+private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }58+5259 class VaultRepository @Inject constructor(5360     private val store: VaultStorage,5461 ) {@@ -94,20 +101,36 @@94101      * Writes slot A (and a size-matched noise slot B) before unlocking so the95102      * restored vault is at rest exactly as a freshly-created one would be.96103      */104+    /** A slot is stored either as a JSON string (browser) or inline object (Android). */105+    private fun JsonElement?.asSlotJson(): String? = when {106+        this == null || this is JsonNull -> null107+        this is JsonPrimitive && isString -> content108+        else -> toString()109+    }110+97111     suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {98112         return try {99113             val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)100-            // The trailing `}` (the wrapper object's own close-brace, after the101-            // captured inner slot object) must be escaped — Android's regex102-            // compiler rejects a bare, unescaped `}` outside a quantifier103-            // context as a syntax error, so this never matched anything and104-            // Codex restore always failed with a generic "invalid_codex_blob"105-            // regardless of whether the codes themselves decoded correctly.106-            val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapperJson)?.groupValues?.get(1)114+            // Parsed as JSON, not matched with a regex. The wrapper is115+            // `{ "a": <slot>, "b": <slot|null> }` and the browser — which116+            // generates most real Codexes — writes each slot as a JSON117+            // *string*, with "b" present. A regex looking for an object118+            // literal under "a" at the very end of the wrapper matches119+            // neither of those, so every browser-generated Codex failed here120+            // with "invalid_codex_blob" after decoding perfectly.121+            val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject107122                 ?: return UnlockResult.Error("invalid_codex_blob")123+            val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")108124             val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")109125             if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")110-            writeMatchingNoiseSlotB(slotA)126+            // Carry the real slot B across when the Codex has one — it holds a127+            // duress vault, which noise would silently destroy.128+            val slotB = wrapper["b"].asSlotJson()129+            if (slotB != null) {130+                store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))131+            } else {132+                writeMatchingNoiseSlotB(slotA)133+            }111134             store.stampLocalMutation()112135             unlockLocked(password)113136         } catch (e: Exception) {

view file

app/src/test/java/com/peachpasswords/ui/vault/RealBackupDecodeTest.kt+48 −0
@@ -0,0 +1,48 @@1+package com.peachpasswords.ui.vault2+3+import org.junit.Assume.assumeTrue4+import com.peachpasswords.vault.PeachScript5+import org.junit.Test6+import java.io.File7+import org.junit.Assert.assertTrue8+9+/**10+ * Decode-path check against a real, full-size PeachScript backup rather than a11+ * toy fixture. The small round-trip fixtures all passed while a real export12+ * (262k PUA characters) failed in the app, so size itself is the variable13+ * worth covering.14+ *15+ * Skips when the file isn't present — it's a local artifact, never committed16+ * (it's an encrypted vault backup).17+ */18+class RealBackupDecodeTest {19+20+    private val backup = File(System.getProperty("user.home"), "Downloads/peach-backup-2026-07-24.peach")21+22+    @Test fun real_backup_is_recognised_and_decodes_to_bytes() {23+        assumeTrue("no local backup artifact", backup.exists())24+        val text = backup.readText()25+        println("chars=${text.length}")26+27+        assertTrue("looksLikePeachScript rejected a real export", PeachScriptImport.looksLikePeachScript(text))28+29+        val normalised = PeachScript.normalise(text)30+        assertTrue("normalise broke the sniff", PeachScript.isPeachScript(normalised))31+32+        // Decrypting needs the backup password, which we don't have — but33+        // decode() is the whole pre-crypto path and is where size would bite.34+        val bytes = PeachScript.decode(normalised)35+        println("decoded bytes=${bytes.size}")36+        assertTrue("decoded to nothing", bytes.isNotEmpty())37+    }38+39+    @Test fun wrong_password_is_reported_as_wrong_not_corrupted() {40+        assumeTrue("no local backup artifact", backup.exists())41+        val result = PeachScriptImport.decryptAndParse(backup.readText(), "definitely-not-the-password")42+        println("result=$result")43+        assertTrue(44+            "a real export with a bad password should report WrongPassword, got $result",45+            result is PeachScriptImport.DecryptResult.WrongPassword,46+        )47+    }48+}

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+27 −0
@@ -77,6 +77,33 @@7777         assertTrue(dst.getEntries().any { it.title == "Portable" })7878     }7979 80+    /**81+     * The browser writes each slot as a JSON *string* and includes "b"; Android82+     * wrote slot "a" as an inline object with nothing after it. Restore used to83+     * pull "a" out with a regex that assumed Android's shape, so every real84+     * browser-generated Codex died with "invalid_codex_blob" after decoding85+     * perfectly. Both shapes must restore.86+     */87+    @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking {88+        val src = repo()89+        src.addEntry(login("Portable"))90+        val androidBlob = src.codexVaultBlobBase64()!!91+        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]93+94+        // Slot as a JSON string, plus a "b" key after it — the browser's shape.95+        val browserWrapper = kotlinx.serialization.json.buildJsonObject {96+            put("a", kotlinx.serialization.json.JsonPrimitive(slotA))97+            put("b", kotlinx.serialization.json.JsonNull)98+        }.toString()99+        val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()100+            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))101+102+        val dst = VaultRepository(Storage())103+        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))104+        assertTrue(dst.getEntries().any { it.title == "Portable" })105+    }106+80107     @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {81108         val rootKeyHex = "66".repeat(32)82109         val rootKey = hexToRootKey(rootKeyHex)

view file