0a5587e2c57d
log a masked structural diagnostic when a Codex restore throws, and strip bucket padding in the browser-slot and root-key paths
- time
- 2026-07-25 03:54 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- codex restore diagnosis
- parent
- b61ca4e4a735
↓ peach-android-0a5587e2.tar.gzatlas at this save
files changed3 files
app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+19 −6app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+19 −4app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+38 −0app/src/main/java/com/peachpasswords/ui/codex/CodexViewModel.kt+19 −6
@@ -3,6 +3,8 @@33 import android.content.Context44 import android.net.Uri55 import androidx.core.content.FileProvider6+import android.util.Log7+import com.peachpasswords.BuildConfig68 import androidx.lifecycle.ViewModel79 import androidx.lifecycle.viewModelScope810 import androidx.camera.core.ImageProxy@@ -182,12 +184,23 @@182184 viewModelScope.launch {183185 when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {184186 is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }185- is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error(186- codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),187- )188- is UnlockResult.Error -> _restore.value = RestoreState.Error(189- codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),190- )187+ is UnlockResult.WrongPassword -> {188+ // The three outcomes below look nearly identical on screen but189+ // mean completely different things (bad password vs. a slot190+ // shape neither reader understands). Codex screens are191+ // FLAG_SECURE, so a screenshot can't be used to tell them192+ // apart — this line is the only way to see which one happened.193+ if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore rejected: WRONG_PASSPHRASE")194+ _restore.value = RestoreState.Error(195+ codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),196+ )197+ }198+ is UnlockResult.Error -> {199+ if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore failed: ${r.reason}")200+ _restore.value = RestoreState.Error(201+ codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),202+ )203+ }191204 }192205 }193206 }app/src/main/java/com/peachpasswords/vault/BrowserSlot.kt+19 −4
@@ -71,9 +71,17 @@7171 } catch (_: Exception) {7272 return null7373 }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()74+ // The browser pads its plaintext up to a size bucket with ZERO75+ // BYTES (`padToNearestBucket` in peachPayload.ts — "zero-filled in76+ // the padding region"), to stop file size leaking vault size.77+ //78+ // This said "trailing SPACES" and used a plain trim(), which only79+ // strips whitespace — and NUL is not whitespace to Kotlin. So the80+ // padding survived and JSON parsing died the moment the real81+ // payload ended, reporting an offset equal to the payload's own82+ // length. It looked like a corrupt Codex; the vault was fine and83+ // the password was right.84+ val plain = payloadJson.toString(Charsets.UTF_8).trimBucketPadding()7785 val payload = VaultCodec.decode(plain.toByteArray(Charsets.UTF_8))7886 7987 val rootKey = unwrapRootKey(browser, key)@@ -104,7 +112,7 @@104112 derivedKey,105113 VaultCrypto.AAD_ROOT_KEY,106114 )107- unhex(hexBytes.toString(Charsets.UTF_8))115+ unhex(hexBytes.toString(Charsets.UTF_8).trimBucketPadding())108116 }.getOrNull()109117 }110118 @@ -137,3 +145,10 @@137145 138146 private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)139147 }148+149+/**150+ * Strip the browser's size-bucket padding: zero bytes, plus any surrounding151+ * whitespace. `String.trim()` alone is not enough — `Char.isWhitespace()` is152+ * false for NUL, so the padding sails straight into the JSON parser.153+ */154+private fun String.trimBucketPadding(): String = trim { it == '\u0000' || it.isWhitespace() }app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+38 −0
@@ -8,6 +8,8 @@88 import java.security.SecureRandom99 import java.text.Normalizer1010 import kotlinx.serialization.json.Json11+import android.util.Log12+import com.peachpasswords.BuildConfig1113 import kotlinx.serialization.json.JsonElement1214 import kotlinx.serialization.json.JsonNull1315 import kotlinx.serialization.json.JsonObject@@ -144,9 +146,45 @@144146 store.stampLocalMutation()145147 unlockLocked(password)146148 } catch (e: Exception) {149+ // A raw kotlinx parse error ("Unexpected JSON token at offset N")150+ // reaching the user says nothing about WHICH json failed — the151+ // wrapper, or the payload decrypted out of it. Codex screens are152+ // FLAG_SECURE so this can't be screenshotted either. Debug builds153+ // log the structure around the failure; alphanumerics are masked so154+ // no ciphertext or vault content lands in a log.155+ if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))147156 UnlockResult.Error(e.message ?: "restore_failed")148157 }149158 }159+160+ /** Debug-only: structure of a failed Codex restore, with content masked. */161+ private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {162+ val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)163+ val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()164+ val window = offset?.let {165+ val from = (it - 40).coerceAtLeast(0)166+ val to = (it + 40).coerceAtMost(wrapperJson.length)167+ wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")168+ }169+ val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()170+ val slot = wrapper?.get("a")171+ buildString {172+ append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)173+ append(" | blobBase64=").append(vaultBlobBase64.length)174+ append(" wrapperChars=").append(wrapperJson.length)175+ append(" wrapperParses=").append(wrapper != null)176+ append(" keys=").append(wrapper?.keys)177+ append(" slotAKind=").append(178+ when {179+ slot == null -> "absent"180+ slot is JsonPrimitive && slot.isString -> "string"181+ slot is JsonObject -> "object:" + slot.keys182+ else -> slot::class.simpleName183+ },184+ )185+ if (window != null) append(" masked@").append(offset).append("=").append(window)186+ }187+ }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }150188 151189 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {152190 return try {