BrowserSlot.kt

6.7 KB · raw

1package com.peachpasswords.vault23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json6import java.util.Base6478/**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@Serializable35data 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)4950object BrowserSlot {51    private val json = Json { ignoreUnknownKeys = true; isLenient = true }5253    /** 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()5657    /**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 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()85            val payload = VaultCodec.decode(plain.toByteArray(Charsets.UTF_8))8687            val rootKey = unwrapRootKey(browser, key)88            val migrated = payload.copy(89                rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,90            )91            rootKey?.let(VaultCrypto::zero)9293            return StoredVault(94                version = 1,95                salt = b64(salt),96                blob = b64(VaultCrypto.encrypt(VaultCodec.encode(migrated), key, VaultCrypto.AAD_SLOT_A)),97                rootKeyEscrow = browser.toRootKeyEscrow(),98            )99        } finally {100            VaultCrypto.zero(key)101        }102    }103104    /** The browser wraps the root key's lowercase hex, not its raw bytes. */105    private fun unwrapRootKey(browser: BrowserSlotA, derivedKey: ByteArray): ByteArray? {106        val ct = browser.pwRootCiphertext ?: return null107        val nonce = browser.pwRootNonce ?: return null108        val tag = browser.pwRootTag ?: return null109        return runCatching {110            val hexBytes = VaultCrypto.decrypt(111                unhex(nonce) + unhex(ct) + unhex(tag),112                derivedKey,113                VaultCrypto.AAD_ROOT_KEY,114            )115            unhex(hexBytes.toString(Charsets.UTF_8).trimBucketPadding())116        }.getOrNull()117    }118119    private fun BrowserSlotA.toRootKeyEscrow(): RootKeyEscrow? {120        val s = recoverySalt ?: return null121        val w = recoveryWrappedRootKey ?: return null122        val n = recoveryNonce ?: return null123        val t = recoveryTag ?: return null124        return RootKeyEscrow(125            salt = b64(unhex(s)),126            wrappedRootKey = b64(unhex(w)),127            nonce = b64(unhex(n)),128            tag = b64(unhex(t)),129        )130    }131132    private fun unhex(s: String): ByteArray {133        require(s.length % 2 == 0) { "odd-length hex" }134        return ByteArray(s.length / 2) { i ->135            ((hexDigit(s[i * 2]) shl 4) or hexDigit(s[i * 2 + 1])).toByte()136        }137    }138139    private fun hexDigit(c: Char): Int = when (c) {140        in '0'..'9' -> c - '0'141        in 'a'..'f' -> c - 'a' + 10142        in 'A'..'F' -> c - 'A' + 10143        else -> throw IllegalArgumentException("bad hex digit")144    }145146    private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)147}148149/**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 */154private fun String.trimBucketPadding(): String = trim { it == '\u0000' || it.isWhitespace() }