VaultStore.kt

14.4 KB · raw

1package com.peachpasswords.vault23import android.content.Context4import dagger.hilt.android.qualifiers.ApplicationContext5import kotlinx.serialization.Serializable6import kotlinx.serialization.decodeFromString7import kotlinx.serialization.encodeToString8import kotlinx.serialization.json.Json9import java.io.File10import javax.inject.Inject11import javax.inject.Singleton1213/**14 * One encrypted slot at rest. [blob] is base64 of VaultCrypto's15 * `nonce || ciphertext || tag` output; [salt] (base64) drives Argon2id.16 * Mirrors the browser's StoredVault. [recoveryEscrow] is null until the user17 * sets recovery words; it is a password-independent second copy of the slot-A18 * plaintext that powers the forgot-password flow.19 */20@Serializable21data class StoredVault(22    /** Version 1 is the legacy Slot-A-AAD record; current decoys use version 2. */23    val version: Int = LEGACY_ANDROID_SLOT_VERSION,24    val salt: String,25    val blob: String,26    val recoveryEscrow: RecoveryEscrow? = null,27    val rootKeyEscrow: RootKeyEscrow? = null,28)2930const val LEGACY_ANDROID_SLOT_VERSION = 131const val CURRENT_ANDROID_SLOT_VERSION = 23233/**34 * Browser-byte-compatible recovery escrow that wraps ONLY the 32-byte root key,35 * so the same 12 recovery words unwrap the same root key on either platform36 * (MF-1). The wrapped plaintext is the lowercase ASCII hex of the root key; the37 * key is `HKDF-SHA256(wordsToRootKeyMaterial(words), salt, "peach-recovery-v1")`38 * and the AEAD AAD is the single byte 0x01 ([VaultCrypto.AAD_RECOVERY_ROOTKEY]) —39 * all matching the browser (`vault.ts:282-316`).40 *41 * Fields mirror the browser's slot-A `recovery_*` fields (base64 here vs hex on42 * the browser) so a future browser-slot importer can map them directly:43 * [salt] = `recovery_salt`, [wrappedRootKey] = `recovery_wrapped_root_key`44 * (ciphertext only), [nonce] = `recovery_nonce`, [tag] = `recovery_tag`.45 */46@Serializable47data class RootKeyEscrow(48    val salt: String,49    val wrappedRootKey: String,50    val nonce: String,51    val tag: String,52)5354/**55 * A second, password-independent copy of the slot-A plaintext envelope, sealed56 * under a key the 12 BIP39 recovery words derive (HKDF). Lets the vault be57 * recovered and re-wrapped under a new password without the old password.58 *59 * [salt] (base64, 16B) is the HKDF salt for `deriveRecoveryKey`.60 * [blob] (base64) is `VaultCrypto.encrypt(slotAPlaintext, recoveryKey, AAD_RECOVERY)`61 *   i.e. `nonce(12) || AES-256-GCM ciphertext||tag`.62 */63@Serializable64data class RecoveryEscrow(65    val salt: String,66    val blob: String,67)6869@Serializable70data class VaultHistory(71    val snapshots: List<VaultSnapshot> = emptyList(),72)7374/** A soft-deleted entry, exposed to the sync layer for last-write-wins merges. */75@Serializable76data class Tombstone(val id: String, val deletedAt: Long)7778/**79 * The decrypted slot-A envelope: the entry list, the duress flag, and the80 * stable sync root key.81 *82 * [rootKeyB64] is the vault's stable 32-byte sync master key (base64, no83 * padding). Unlike the Argon2id password key it never rotates on a password84 * change, so paired devices keep deriving the same vault_id, Noise static key,85 * and per-entry envelope keys across independent password rotations (browser86 * SEV-2-B). Empty on legacy payloads written before sync landed — the87 * repository lazily mints and persists one on first unlock.88 */89@Serializable90data class VaultPayload(91    val entries: List<VaultEntry> = emptyList(),92    val duressConfigured: Boolean = false,93    val rootKeyB64: String = "",94    /**95     * BLAKE3 hash of the space-joined 12 BIP39 recovery words. Lets the app verify96     * recovery-word entry without storing the words themselves. Null until set.97     */98    val recoveryWordHash: String? = null,99    /** True once the user has confirmed (written down) their recovery words. */100    val recoveryWordsSet: Boolean = false,101)102103const val SLOT_RECORD_PAD: Char = '\u0000'104105/** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */106object VaultCodec {107    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }108109    // MN-1: pad every sealed plaintext to a size bucket before encryption so that110    // slot A (real) and slot B (duress decoy) — and the recovery escrow — quantise111    // to the same lengths and an at-rest observer cannot tell which slot is which112    // from its size. Padding is trailing NULs (never valid inside JSON); decode113    // strips them, so an unpadded legacy blob (no trailing NULs) still decodes.114    fun encode(payload: VaultPayload): ByteArray =115        VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))116    fun decode(bytes: ByteArray): VaultPayload =117        json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))118    /** Test seam: the entry codec is only reachable through a whole payload otherwise. */119    internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)120    internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)121122    fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)123    fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()124    fun encodeSlot(s: StoredVault): String = json.encodeToString(s)125    fun decodeSlot(s: String): StoredVault? =126        runCatching { json.decodeFromString<StoredVault>(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull()127128    /**129     * Trailing NULs pad a slot record to its sibling's length. NUL cannot occur130     * inside the JSON — the same reason [encode] can pad plaintext with it — so131     * [decodeSlot] only has to trim it.132     */133    fun padSlotRecord(record: String, targetLength: Int): String =134        if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD)135136    fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length137138    /**139     * The (written, sibling) record pair after alignment, so both slots are the140     * same length at rest (PAI-2026-004).141     *142     * A real decoy cannot be re-sealed without the duress passphrase, so its143     * blob keeps the length it had when duress was configured while slot A144     * keeps growing through the padding buckets. Padding the shorter record to145     * the longer one is what keeps an at-rest observer from reading duress146     * configuration, or how far the vault has grown, out of the pair.147     *148     * The target is the longer TRUE record, not the longer stored one, so the149     * pair tracks the current vault instead of ratcheting up to a high-water150     * mark every time the vault temporarily grows.151     */152    fun alignSlotRecords(record: String, sibling: String?): Pair<String, String?> {153        if (sibling == null) return record to null154        val target = maxOf(record.length, slotRecordLength(sibling))155        return padSlotRecord(record, target) to padSlotRecord(sibling, target)156    }157    fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)158    fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()159}160161interface VaultStorage {162    fun loadDecoySlot(): StoredVault?163    fun loadSlotA(): StoredVault?164    fun hasVault(): Boolean165    fun writeSlotA(slot: StoredVault): Boolean166    fun loadSlotB(): String? = null167    fun writeSlotB(blobBase64: String): Boolean168    fun writeDecoySlot(slot: StoredVault): Boolean169    /** Restore the exact triad, rolling back each write if a later write fails. */170    fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean {171        val priorA = loadSlotA()172        val priorB = loadSlotB()173        val priorD = loadDecoySlot()174        return try {175            if (!writeSlotA(slotA)) return false176            if (slotB == null) clearSlotB() else if (!writeSlotB(slotB)) throw IllegalStateException("slot_b_restore_failed")177            if (!writeDecoySlot(decoy)) throw IllegalStateException("decoy_restore_failed")178            true179        } catch (_: Exception) {180            runCatching { if (priorA == null) clearVault() else writeSlotA(priorA) }181            runCatching { if (priorB == null) clearSlotB() else writeSlotB(priorB) }182            runCatching { if (priorD == null) clearDecoySlot() else writeDecoySlot(priorD) }183            false184        }185    }186    fun clearSlotB() {}187    fun hasDecoySlot(): Boolean = loadDecoySlot() != null188    fun clearDecoySlot()189    fun version(): Long190    fun stampLocalMutation(): Long191    fun loadSettings(): VaultSettings192    fun writeSettings(settings: VaultSettings): Boolean193    fun loadVaultHistory(): VaultHistory = VaultHistory()194    fun writeVaultHistory(history: VaultHistory): Boolean = true195    fun clearVaultHistory() {}196    fun clearVault(): Boolean197}198199/**200 * SharedPreferences-backed [VaultStorage]. The slot blobs are already AES-GCM201 * sealed under the Argon2id key, so plaintext prefs are fine — only the salt is202 * in the clear, exactly as in storage.local on the extension. The Pro license203 * lives in a SEPARATE prefs file (LicenseRepository), so wiping this store can204 * never touch the license — destroyVault()'s core invariant.205 */206@Singleton207class VaultStore @Inject constructor(208    @ApplicationContext context: Context,209) : VaultStorage {210    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)211212    /** Sharing a Codex leaves its ciphertext here; destroy must sweep it too (PB-26-137). */213    private val codexCacheDir = File(context.cacheDir, CODEX_CACHE_DIR)214215    override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)216217    override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)218219    override fun writeSlotA(slot: StoredVault): Boolean =220        writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS)221    override fun clearSlotB() { prefs.edit().remove(KEY_SLOT_B).commit() }222    override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)223224    override fun writeSlotB(blobBase64: String): Boolean =225        prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()226227    override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)228    override fun writeDecoySlot(slot: StoredVault): Boolean =229        writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A)230231    /** Both slot records are written in one commit so the pair can never be caught misaligned. */232    private fun writeAligned(key: String, record: String, siblingKey: String): Boolean {233        val (aligned, alignedSibling) = VaultCodec.alignSlotRecords(234            record,235            prefs.getString(siblingKey, null),236        )237        val edits = prefs.edit().putString(key, aligned)238        if (alignedSibling != null) edits.putString(siblingKey, alignedSibling)239        return edits.commit()240    }241242    override fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean {243        val (alignedA, alignedDecoy) = VaultCodec.alignSlotRecords(244            VaultCodec.encodeSlot(slotA),245            VaultCodec.encodeSlot(decoy),246        )247        val edits = prefs.edit()248            .putString(KEY_SLOT_A, alignedA)249            .putString(KEY_SLOT_DURESS, alignedDecoy)250        if (slotB != null) edits.putString(KEY_SLOT_B, slotB) else edits.remove(KEY_SLOT_B)251        return edits.commit()252    }253    override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)254    override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }255256    override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L257258    override fun stampLocalMutation(): Long {259        val next = maxOf(System.currentTimeMillis(), version() + 1L)260        // commit() returning false must surface as a failure — every caller261        // either treats an exception as a rollback trigger (H-01/H-02 helpers,262        // persistSlotA) or advances a version it has not actually persisted.263        // Throwing keeps the boolean-visible failure mode aligned with codex P1-2.264        if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) {265            throw java.io.IOException("version_stamp_commit_failed")266        }267        return next268    }269270    override fun loadSettings(): VaultSettings =271        prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()272273    override fun writeSettings(settings: VaultSettings): Boolean =274        prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()275276    override fun loadVaultHistory(): VaultHistory =277        prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()278279    override fun writeVaultHistory(history: VaultHistory): Boolean =280        prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()281282    override fun clearVaultHistory() {283        prefs.edit().remove(KEY_HISTORY).commit()284    }285286    override fun clearVault(): Boolean {287        // H-04: the duress record is part of the vault's at-rest surface and must288        // go with slot A — leaving it behind on a half-built vault is the289        // forensic tell the audit flagged.290        // PB-26-003: surface the commit and cache-deletion results instead of291        // discarding them — callers must not treat a failed wipe as completed.292        val prefsCleared = prefs.edit()293            .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)294            .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)295            .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key")296            .remove("peach_pin_preferred")297            .remove("peach_pin_format").remove("peach_pin_salt_v2")298            .remove("peach_pin_verifier_v2").remove("peach_pin_wrapped_key_v2")299            .remove("peach_pin_wrapped_iv_v2").remove("peach_pin_attempt_aliases_v2")300            .remove("peach_pin_preferred_v2")301            .commit()302        val cacheCleared = codexCacheDir.deleteRecursively()303        return prefsCleared && cacheCleared304    }305306    companion object {307        private const val PREFS = "peach_vault_store"308        private const val KEY_SLOT_A = "peach_vault_a"309        private const val KEY_SLOT_B = "peach_vault_b"310        private const val KEY_SLOT_DURESS = "peach_vault_duress"311        private const val KEY_VERSION = "peach_last_modified_at"312        private const val KEY_SETTINGS = "peach_settings"313        private const val KEY_HISTORY = "peach_vault_history"314        private const val CODEX_CACHE_DIR = "codex"315    }316}