BrowserSlot.kt

5.8 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 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))7879            val rootKey = unwrapRootKey(browser, key)80            val migrated = payload.copy(81                rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,82            )83            rootKey?.let(VaultCrypto::zero)8485            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    }9596    /** 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    }110111    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    }123124    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    }130131    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    }137138    private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)139}