578a68466d1c

translate browser-shaped slot A on Codex restore, wrap restore-option pills so labels survive narrow widths

time
2026-07-24 22:54 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
browser codex restore
parent
639970cb1815
4 files app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+19 −2app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+139 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+10 −1app/src/test/java/com/peachpasswords/vault/BrowserSlotTest.kt+115 −0
app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+19 −2
@@ -10,6 +10,8 @@1010 import androidx.compose.foundation.layout.Arrangement1111 import androidx.compose.foundation.layout.Box1212 import androidx.compose.foundation.layout.Column13+import androidx.compose.foundation.layout.ExperimentalLayoutApi14+import androidx.compose.foundation.layout.FlowRow1315 import androidx.compose.foundation.layout.Row1416 import androidx.compose.foundation.layout.Spacer1517 import androidx.compose.foundation.layout.fillMaxHeight@@ -364,6 +366,7 @@364366     }365367 }366368 369+@OptIn(ExperimentalLayoutApi::class)367370 @Composable368371 private fun RestoreTabs(onNavigate: (PeachNav) -> Unit) {369372     val tabs = listOf(@@ -375,7 +378,15 @@375378     Column(horizontalAlignment = Alignment.CenterHorizontally) {376379         Text("restore instead", color = peachColors.T1, style = PeachType.labelMono)377380         Spacer(Modifier.height(10.dp))378-        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {381+        // These four barely fit one line at 411dp. A plain Row has nowhere to382+        // put the overflow, so it hands the last pill near-zero width — the383+        // label stacks one letter per line and then disappears entirely,384+        // leaving a bordered box with no text. Wrap instead, and never let a385+        // label break mid-word.386+        FlowRow(387+            horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),388+            verticalArrangement = Arrangement.spacedBy(6.dp),389+        ) {379390             tabs.forEach { (label, dest) ->380391                 Box(381392                     modifier = Modifier@@ -383,7 +394,13 @@383394                         .clickable { onNavigate(dest) }384395                         .padding(horizontal = 10.dp, vertical = 8.dp),385396                 ) {386-                    Text(label, color = peachColors.T1, style = PeachType.sortPill)397+                    Text(398+                        label,399+                        color = peachColors.T1,400+                        style = PeachType.sortPill,401+                        maxLines = 1,402+                        softWrap = false,403+                    )387404                 }388405             }389406         }

view file

app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+139 −0
@@ -0,0 +1,139 @@1+package com.peachpasswords.vault2+3+import kotlinx.serialization.SerialName4+import kotlinx.serialization.Serializable5+import kotlinx.serialization.json.Json6+import java.util.Base647+8+/**9+ * Reads the browser extension's slot A so Android can restore a vault that was10+ * created there — a Codex printed from the extension, most importantly.11+ *12+ * The two platforms already agree on everything that matters cryptographically:13+ * Argon2id at m=65536/t=3/p=4 over the NFC-normalized password, AES-256-GCM14+ * with AAD `slot_a_v1`, and a slot-A plaintext that is plain JSON15+ * (`{entries, duressConfigured}`) which Android's [VaultCodec] parses as-is.16+ * What differs is only how the sealed bytes are written down:17+ *18+ *   browser   { salt, nonce, ciphertext, tag }   all lowercase hex, separate19+ *   Android   { salt, blob }                     base64, blob = nonce||ct||tag20+ *21+ * Android's [VaultStore.decodeSlot] required the Android shape, so a real22+ * browser Codex decoded all 1978 of its Aztec codes and then died at the last23+ * step with "invalid_codex_blob" — the vault was intact and readable the whole24+ * time, sitting behind a field-name mismatch.25+ *26+ * The root key needs moving too, and is the reason this is a translation rather27+ * than a rename: the browser keeps it wrapped in the slot (`pw_root_*`, AAD28+ * `pw_root_v1`, plaintext = lowercase hex of the 32 bytes) while Android keeps29+ * it inside the encrypted payload as `rootKeyB64`. Carrying it across is what30+ * makes the restored vault keep the same recovery words and the same sync31+ * identity it had on the browser; minting a fresh one would silently invalidate32+ * the words the user has written down.33+ */34+@Serializable35+data class BrowserSlotA(36+    val version: Int = 1,37+    val salt: String,38+    val nonce: String,39+    val ciphertext: String,40+    val tag: String,41+    @SerialName("recovery_salt") val recoverySalt: String? = null,42+    @SerialName("recovery_wrapped_root_key") val recoveryWrappedRootKey: String? = null,43+    @SerialName("recovery_nonce") val recoveryNonce: String? = null,44+    @SerialName("recovery_tag") val recoveryTag: String? = null,45+    @SerialName("pw_root_ciphertext") val pwRootCiphertext: String? = null,46+    @SerialName("pw_root_nonce") val pwRootNonce: String? = null,47+    @SerialName("pw_root_tag") val pwRootTag: String? = null,48+)49+50+object BrowserSlot {51+    private val json = Json { ignoreUnknownKeys = true; isLenient = true }52+53+    /** Null when [s] is not a browser slot — callers try the Android shape first. */54+    fun parse(s: String): BrowserSlotA? =55+        runCatching { json.decodeFromString<BrowserSlotA>(s) }.getOrNull()56+57+    /**58+     * Re-seal a browser slot as a native Android one under the same password.59+     *60+     * Returns null on a wrong password (the GCM tag fails), which the caller61+     * must surface as a wrong-password error rather than a corrupt-blob one —62+     * they are very different things to a user holding a paper Codex.63+     */64+    fun toAndroidSlot(browser: BrowserSlotA, password: String): StoredVault? {65+        val salt = unhex(browser.salt)66+        val key = VaultCrypto.deriveKey(password, salt)67+        try {68+            val sealed = unhex(browser.nonce) + unhex(browser.ciphertext) + unhex(browser.tag)69+            val payloadJson = try {70+                VaultCrypto.decrypt(sealed, key, VaultCrypto.AAD_SLOT_A)71+            } catch (_: Exception) {72+                return null73+            }74+            // The browser pads its plaintext to a size bucket with trailing75+            // SPACES; Android pads with NUL, so VaultCodec's own trim misses it.76+            val plain = payloadJson.toString(Charsets.UTF_8).trim()77+            val payload = VaultCodec.decode(plain.toByteArray(Charsets.UTF_8))78+79+            val rootKey = unwrapRootKey(browser, key)80+            val migrated = payload.copy(81+                rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,82+            )83+            rootKey?.let(VaultCrypto::zero)84+85+            return StoredVault(86+                version = 1,87+                salt = b64(salt),88+                blob = b64(VaultCrypto.encrypt(VaultCodec.encode(migrated), key, VaultCrypto.AAD_SLOT_A)),89+                rootKeyEscrow = browser.toRootKeyEscrow(),90+            )91+        } finally {92+            VaultCrypto.zero(key)93+        }94+    }95+96+    /** The browser wraps the root key's lowercase hex, not its raw bytes. */97+    private fun unwrapRootKey(browser: BrowserSlotA, derivedKey: ByteArray): ByteArray? {98+        val ct = browser.pwRootCiphertext ?: return null99+        val nonce = browser.pwRootNonce ?: return null100+        val tag = browser.pwRootTag ?: return null101+        return runCatching {102+            val hexBytes = VaultCrypto.decrypt(103+                unhex(nonce) + unhex(ct) + unhex(tag),104+                derivedKey,105+                VaultCrypto.AAD_ROOT_KEY,106+            )107+            unhex(hexBytes.toString(Charsets.UTF_8))108+        }.getOrNull()109+    }110+111+    private fun BrowserSlotA.toRootKeyEscrow(): RootKeyEscrow? {112+        val s = recoverySalt ?: return null113+        val w = recoveryWrappedRootKey ?: return null114+        val n = recoveryNonce ?: return null115+        val t = recoveryTag ?: return null116+        return RootKeyEscrow(117+            salt = b64(unhex(s)),118+            wrappedRootKey = b64(unhex(w)),119+            nonce = b64(unhex(n)),120+            tag = b64(unhex(t)),121+        )122+    }123+124+    private fun unhex(s: String): ByteArray {125+        require(s.length % 2 == 0) { "odd-length hex" }126+        return ByteArray(s.length / 2) { i ->127+            ((hexDigit(s[i * 2]) shl 4) or hexDigit(s[i * 2 + 1])).toByte()128+        }129+    }130+131+    private fun hexDigit(c: Char): Int = when (c) {132+        in '0'..'9' -> c - '0'133+        in 'a'..'f' -> c - 'a' + 10134+        in 'A'..'F' -> c - 'A' + 10135+        else -> throw IllegalArgumentException("bad hex digit")136+    }137+138+    private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)139+}

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+10 −1
@@ -122,7 +122,16 @@122122             val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject123123                 ?: return UnlockResult.Error("invalid_codex_blob")124124             val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")125-            val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")125+            // A Codex printed from the browser carries a browser-shaped slot,126+            // which is the common case — most Codexes are printed there. It is127+            // translated to Android's shape (see BrowserSlot) rather than128+            // rejected. A translation failure is a wrong password, not a bad129+            // blob: the codes decoded fine, the password just didn't open them.130+            val slotA = VaultCodec.decodeSlot(slotJson)131+                ?: BrowserSlot.parse(slotJson)?.let {132+                    BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword133+                }134+                ?: return UnlockResult.Error("invalid_codex_blob")126135             if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")127136             // Carry the real slot B across when the Codex has one — it holds a128137             // duress vault, which noise would silently destroy.

view file

app/src/test/java/com/peachpasswords/vault/BrowserSlotTest.kt+115 −0
@@ -0,0 +1,115 @@1+package com.peachpasswords.vault2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNotNull5+import org.junit.Assert.assertNull6+import org.junit.Test7+import java.util.Base648+9+/**10+ * Pins the browser→Android slot-A translation that Codex restore depends on.11+ *12+ * The fixture is built the way the browser writes a slot: hex-encoded13+ * salt/nonce/ciphertext/tag as separate fields, a space-padded JSON plaintext,14+ * and the root key wrapped separately under `pw_root_v1` as lowercase hex. If15+ * any of those encodings drift, a paper Codex printed on the browser stops16+ * restoring on the phone — which is exactly the bug this fixes.17+ */18+class BrowserSlotTest {19+20+    private val password = "correct horse battery staple"21+22+    private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }23+    private fun b64(b: ByteArray) = Base64.getEncoder().encodeToString(b)24+25+    /** Split Android's `nonce||ciphertext||tag` back into the browser's three fields. */26+    private fun splitSealed(sealed: ByteArray): Triple<String, String, String> {27+        val nonce = sealed.copyOfRange(0, VaultCrypto.NONCE_SIZE)28+        val body = sealed.copyOfRange(VaultCrypto.NONCE_SIZE, sealed.size - 16)29+        val tag = sealed.copyOfRange(sealed.size - 16, sealed.size)30+        return Triple(hex(nonce), hex(body), hex(tag))31+    }32+33+    private fun browserSlotJson(rootKey: ByteArray, entries: List<VaultEntry>): String {34+        val salt = VaultCrypto.generateSalt()35+        val key = VaultCrypto.deriveKey(password, salt)36+37+        // Browser payload: {entries, duressConfigured} with NO rootKeyB64, space-padded.38+        val payloadJson = """{"entries":${entriesJson(entries)},"duressConfigured":false}"""39+        val padded = payloadJson + " ".repeat(1024 - payloadJson.length)40+        val (nonce, ct, tag) = splitSealed(41+            VaultCrypto.encrypt(padded.toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_SLOT_A),42+        )43+44+        // Root key travels wrapped as its own lowercase hex, AAD pw_root_v1.45+        val (pn, pc, pt) = splitSealed(46+            VaultCrypto.encrypt(hex(rootKey).toByteArray(Charsets.UTF_8), key, VaultCrypto.AAD_ROOT_KEY),47+        )48+49+        return """{"version":1,"salt":"${hex(salt)}","nonce":"$nonce","ciphertext":"$ct","tag":"$tag",""" +50+            """"pw_root_ciphertext":"$pc","pw_root_nonce":"$pn","pw_root_tag":"$pt"}"""51+    }52+53+    private fun entriesJson(entries: List<VaultEntry>): String =54+        entries.joinToString(",", "[", "]") {55+            """{"id":"${it.id}","title":"${it.title}","username":"${it.username}",""" +56+                """"password":"${it.password}","createdAt":${it.createdAt},"modifiedAt":${it.modifiedAt}}"""57+        }58+59+    @Test60+    fun `translates a browser slot and preserves entries`() {61+        val rootKey = ByteArray(32) { it.toByte() }62+        val entries = listOf(63+            VaultEntry(64+                id = "e1",65+                title = "Example",66+                username = "june",67+                password = "hunter2",68+                createdAt = 1L,69+                modifiedAt = 2L,70+            ),71+        )72+73+        val slot = BrowserSlot.toAndroidSlot(BrowserSlot.parse(browserSlotJson(rootKey, entries))!!, password)74+        assertNotNull("browser slot should translate", slot)75+76+        val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot!!.salt))77+        val payload = VaultCodec.decode(78+            VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A),79+        )80+        assertEquals(1, payload.entries.size)81+        assertEquals("Example", payload.entries[0].title)82+        assertEquals("hunter2", payload.entries[0].password)83+    }84+85+    @Test86+    fun `carries the browser root key across so recovery words still work`() {87+        val rootKey = ByteArray(32) { (it * 7).toByte() }88+        val slot = BrowserSlot.toAndroidSlot(89+            BrowserSlot.parse(browserSlotJson(rootKey, emptyList()))!!,90+            password,91+        )!!92+93+        val key = VaultCrypto.deriveKey(password, Base64.getDecoder().decode(slot.salt))94+        val payload = VaultCodec.decode(95+            VaultCrypto.decrypt(Base64.getDecoder().decode(slot.blob), key, VaultCrypto.AAD_SLOT_A),96+        )97+        assertEquals(98+            "root key must survive the translation or the user's written-down words break",99+            b64(rootKey),100+            payload.rootKeyB64,101+        )102+    }103+104+    @Test105+    fun `wrong password yields null rather than a corrupt-blob error`() {106+        val json = browserSlotJson(ByteArray(32), emptyList())107+        assertNull(BrowserSlot.toAndroidSlot(BrowserSlot.parse(json)!!, "not the password"))108+    }109+110+    @Test111+    fun `an Android-shaped slot is not mistaken for a browser one`() {112+        val android = """{"version":1,"salt":"AAAA","blob":"BBBB"}"""113+        assertNull(BrowserSlot.parse(android))114+    }115+}

view file