BrowserSlot.kt

8.4 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        var salt: ByteArray? = null66        var key: ByteArray? = null67        var sealed: ByteArray? = null68        var payloadJson: ByteArray? = null69        var plainBytes: ByteArray? = null70        var encoded: ByteArray? = null71        var encrypted: ByteArray? = null72        var rootKey: ByteArray? = null73        try {74            salt = unhex(browser.salt)75            key = VaultCrypto.deriveKey(password, salt!!)76            sealed = sealedHex(browser.nonce, browser.ciphertext, browser.tag)77            payloadJson = try {78                VaultCrypto.decrypt(sealed!!, key!!, VaultCrypto.AAD_SLOT_A)79            } catch (_: Exception) {80                return null81            }82            // The browser pads its plaintext up to a size bucket with ZERO83            // BYTES (`padToNearestBucket` in peachPayload.ts — "zero-filled in84            // the padding region"), to stop file size leaking vault size.85            //86            // This said "trailing SPACES" and used a plain trim(), which only87            // strips whitespace — and NUL is not whitespace to Kotlin. So the88            // padding survived and JSON parsing died the moment the real89            // payload ended, reporting an offset equal to the payload's own90            // length. It looked like a corrupt Codex; the vault was fine and91            // the password was right.92            val plain = payloadJson!!.toString(Charsets.UTF_8).trimBucketPadding()93            plainBytes = plain.toByteArray(Charsets.UTF_8)94            val payload = VaultCodec.decode(plainBytes!!)9596            rootKey = unwrapRootKey(browser, key!!)97            val migrated = payload.copy(98                rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,99            )100101            encoded = VaultCodec.encode(migrated)102            encrypted = VaultCrypto.encrypt(encoded!!, key!!, VaultCrypto.AAD_SLOT_A)103104            return StoredVault(105                version = 1,106                salt = b64(salt!!),107                blob = b64(encrypted!!),108                rootKeyEscrow = browser.toRootKeyEscrow(),109            )110        } finally {111            encrypted?.let(VaultCrypto::zero)112            encoded?.let(VaultCrypto::zero)113            plainBytes?.let(VaultCrypto::zero)114            payloadJson?.let(VaultCrypto::zero)115            sealed?.let(VaultCrypto::zero)116            rootKey?.let(VaultCrypto::zero)117            salt?.let(VaultCrypto::zero)118            key?.let(VaultCrypto::zero)119        }120    }121122    /** The browser wraps the root key's lowercase hex, not its raw bytes. */123    private fun unwrapRootKey(browser: BrowserSlotA, derivedKey: ByteArray): ByteArray? {124        val ct = browser.pwRootCiphertext ?: return null125        val nonce = browser.pwRootNonce ?: return null126        val tag = browser.pwRootTag ?: return null127        var sealed: ByteArray? = null128        var hexBytes: ByteArray? = null129        return try {130            sealed = sealedHex(nonce, ct, tag)131            hexBytes = VaultCrypto.decrypt(132                sealed!!,133                derivedKey,134                VaultCrypto.AAD_ROOT_KEY,135            )136            unhex(hexBytes!!.toString(Charsets.UTF_8).trimBucketPadding())137        } catch (_: Exception) {138            null139        } finally {140            hexBytes?.let(VaultCrypto::zero)141            sealed?.let(VaultCrypto::zero)142        }143    }144145    private fun BrowserSlotA.toRootKeyEscrow(): RootKeyEscrow? {146        val s = recoverySalt ?: return null147        val w = recoveryWrappedRootKey ?: return null148        val n = recoveryNonce ?: return null149        val t = recoveryTag ?: return null150        return RootKeyEscrow(151            salt = b64Hex(s),152            wrappedRootKey = b64Hex(w),153            nonce = b64Hex(n),154            tag = b64Hex(t),155        )156    }157158    /** Decode concatenated browser hex fields while scrubbing each component. */159    private fun sealedHex(vararg parts: String): ByteArray {160        val decoded = ArrayList<ByteArray>(parts.size)161        return try {162            parts.forEach { decoded += unhex(it) }163            val out = ByteArray(decoded.sumOf { it.size })164            var offset = 0165            decoded.forEach {166                System.arraycopy(it, 0, out, offset, it.size)167                offset += it.size168            }169            out170        } finally {171            decoded.forEach(VaultCrypto::zero)172        }173    }174175    private fun b64Hex(hex: String): String {176        val bytes = unhex(hex)177        return try {178            b64(bytes)179        } finally {180            VaultCrypto.zero(bytes)181        }182    }183184    private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)185}186187/** Hex -> bytes. Lowercase or uppercase digits; throws on odd length or bad char. */188internal fun unhex(s: String): ByteArray {189    require(s.length % 2 == 0) { "odd-length hex" }190    return ByteArray(s.length / 2) { i ->191        ((hexDigit(s[i * 2]) shl 4) or hexDigit(s[i * 2 + 1])).toByte()192    }193}194195private fun hexDigit(c: Char): Int = when (c) {196    in '0'..'9' -> c - '0'197    in 'a'..'f' -> c - 'a' + 10198    in 'A'..'F' -> c - 'A' + 10199    else -> throw IllegalArgumentException("bad hex digit")200}201202/**203 * Strip the browser's size-bucket padding: zero bytes, plus any surrounding204 * whitespace. `String.trim()` alone is not enough — `Char.isWhitespace()` is205 * false for NUL, so the padding sails straight into the JSON parser.206 */207private fun String.trimBucketPadding(): String = trim { it == '\u0000' || it.isWhitespace() }