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
↓ peach-android-578a6846.tar.gzatlas at this save
files changed4 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 −0app/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 }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+}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.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+}