VaultStore.kt

12.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 javax.inject.Inject10import javax.inject.Singleton1112/**13 * One encrypted slot at rest. [blob] is base64 of VaultCrypto's14 * `nonce || ciphertext || tag` output; [salt] (base64) drives Argon2id.15 * Mirrors the browser's StoredVault. [recoveryEscrow] is null until the user16 * sets recovery words; it is a password-independent second copy of the slot-A17 * plaintext that powers the forgot-password flow.18 */19@Serializable20data class StoredVault(21    val version: Int = 1,22    val salt: String,23    val blob: String,24    val recoveryEscrow: RecoveryEscrow? = null,25    val rootKeyEscrow: RootKeyEscrow? = null,26)2728/**29 * Browser-byte-compatible recovery escrow that wraps ONLY the 32-byte root key,30 * so the same 12 recovery words unwrap the same root key on either platform31 * (MF-1). The wrapped plaintext is the lowercase ASCII hex of the root key; the32 * key is `HKDF-SHA256(wordsToRootKeyMaterial(words), salt, "peach-recovery-v1")`33 * and the AEAD AAD is the single byte 0x01 ([VaultCrypto.AAD_RECOVERY_ROOTKEY]) —34 * all matching the browser (`vault.ts:282-316`).35 *36 * Fields mirror the browser's slot-A `recovery_*` fields (base64 here vs hex on37 * the browser) so a future browser-slot importer can map them directly:38 * [salt] = `recovery_salt`, [wrappedRootKey] = `recovery_wrapped_root_key`39 * (ciphertext only), [nonce] = `recovery_nonce`, [tag] = `recovery_tag`.40 */41@Serializable42data class RootKeyEscrow(43    val salt: String,44    val wrappedRootKey: String,45    val nonce: String,46    val tag: String,47)4849/**50 * A second, password-independent copy of the slot-A plaintext envelope, sealed51 * under a key the 12 BIP39 recovery words derive (HKDF). Lets the vault be52 * recovered and re-wrapped under a new password without the old password.53 *54 * [salt] (base64, 16B) is the HKDF salt for `deriveRecoveryKey`.55 * [blob] (base64) is `VaultCrypto.encrypt(slotAPlaintext, recoveryKey, AAD_RECOVERY)`56 *   i.e. `nonce(12) || AES-256-GCM ciphertext||tag`.57 */58@Serializable59data class RecoveryEscrow(60    val salt: String,61    val blob: String,62)6364@Serializable65data class VaultHistory(66    val snapshots: List<VaultSnapshot> = emptyList(),67)6869/** A soft-deleted entry, exposed to the sync layer for last-write-wins merges. */70@Serializable71data class Tombstone(val id: String, val deletedAt: Long)7273/**74 * The decrypted slot-A envelope: the entry list, the duress flag, and the75 * stable sync root key.76 *77 * [rootKeyB64] is the vault's stable 32-byte sync master key (base64, no78 * padding). Unlike the Argon2id password key it never rotates on a password79 * change, so paired devices keep deriving the same vault_id, Noise static key,80 * and per-entry envelope keys across independent password rotations (browser81 * SEV-2-B). Empty on legacy payloads written before sync landed — the82 * repository lazily mints and persists one on first unlock.83 */84@Serializable85data class VaultPayload(86    val entries: List<VaultEntry> = emptyList(),87    val duressConfigured: Boolean = false,88    val rootKeyB64: String = "",89    /**90     * BLAKE3 hash of the space-joined 12 BIP39 recovery words. Lets the app verify91     * recovery-word entry without storing the words themselves. Null until set.92     */93    val recoveryWordHash: String? = null,94    /** True once the user has confirmed (written down) their recovery words. */95    val recoveryWordsSet: Boolean = false,96)9798const val SLOT_RECORD_PAD: Char = '\u0000'99100/** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */101object VaultCodec {102    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }103104    // MN-1: pad every sealed plaintext to a size bucket before encryption so that105    // slot A (real) and slot B (duress decoy) — and the recovery escrow — quantise106    // to the same lengths and an at-rest observer cannot tell which slot is which107    // from its size. Padding is trailing NULs (never valid inside JSON); decode108    // strips them, so an unpadded legacy blob (no trailing NULs) still decodes.109    fun encode(payload: VaultPayload): ByteArray =110        VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))111    fun decode(bytes: ByteArray): VaultPayload =112        json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))113    /** Test seam: the entry codec is only reachable through a whole payload otherwise. */114    internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)115    internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)116117    fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)118    fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()119    fun encodeSlot(s: StoredVault): String = json.encodeToString(s)120    fun decodeSlot(s: String): StoredVault? =121        runCatching { json.decodeFromString<StoredVault>(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull()122123    /**124     * Trailing NULs pad a slot record to its sibling's length. NUL cannot occur125     * inside the JSON — the same reason [encode] can pad plaintext with it — so126     * [decodeSlot] only has to trim it.127     */128    fun padSlotRecord(record: String, targetLength: Int): String =129        if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD)130131    fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length132133    /**134     * The (written, sibling) record pair after alignment, so both slots are the135     * same length at rest (PAI-2026-004).136     *137     * A real decoy cannot be re-sealed without the duress passphrase, so its138     * blob keeps the length it had when duress was configured while slot A139     * keeps growing through the padding buckets. Padding the shorter record to140     * the longer one is what keeps an at-rest observer from reading duress141     * configuration, or how far the vault has grown, out of the pair.142     *143     * The target is the longer TRUE record, not the longer stored one, so the144     * pair tracks the current vault instead of ratcheting up to a high-water145     * mark every time the vault temporarily grows.146     */147    fun alignSlotRecords(record: String, sibling: String?): Pair<String, String?> {148        if (sibling == null) return record to null149        val target = maxOf(record.length, slotRecordLength(sibling))150        return padSlotRecord(record, target) to padSlotRecord(sibling, target)151    }152    fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)153    fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()154}155156/**157 * Persistence for the two vault slots, the monotonic version counter, and the158 * settings blob. Abstracted behind an interface so the repository can be159 * exercised against an in-memory fake in plain JUnit.160 */161interface VaultStorage {162    fun loadSlotA(): StoredVault?163    fun hasVault(): Boolean164    fun writeSlotA(slot: StoredVault): Boolean165    fun loadSlotB(): String? = null166    fun writeSlotB(blobBase64: String): Boolean167    fun loadDecoySlot(): StoredVault?168    fun writeDecoySlot(slot: StoredVault): Boolean169    fun hasDecoySlot(): Boolean170    fun clearDecoySlot()171    fun version(): Long172    fun stampLocalMutation(): Long173    fun loadSettings(): VaultSettings174    fun writeSettings(settings: VaultSettings): Boolean175    fun loadVaultHistory(): VaultHistory = VaultHistory()176    fun writeVaultHistory(history: VaultHistory): Boolean = true177    fun clearVaultHistory() {}178    fun clearVault()179}180181/**182 * SharedPreferences-backed [VaultStorage]. The slot blobs are already AES-GCM183 * sealed under the Argon2id key, so plaintext prefs are fine — only the salt is184 * in the clear, exactly as in storage.local on the extension. The Pro license185 * lives in a SEPARATE prefs file (LicenseRepository), so wiping this store can186 * never touch the license — destroyVault()'s core invariant.187 */188@Singleton189class VaultStore @Inject constructor(190    @ApplicationContext context: Context,191) : VaultStorage {192    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)193194    override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)195196    override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)197198    override fun writeSlotA(slot: StoredVault): Boolean =199        writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS)200201    override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)202203    override fun writeSlotB(blobBase64: String): Boolean =204        prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()205206    override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)207    override fun writeDecoySlot(slot: StoredVault): Boolean =208        writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A)209210    /** Both slot records are written in one commit so the pair can never be caught misaligned. */211    private fun writeAligned(key: String, record: String, siblingKey: String): Boolean {212        val (aligned, alignedSibling) = VaultCodec.alignSlotRecords(213            record,214            prefs.getString(siblingKey, null),215        )216        val edits = prefs.edit().putString(key, aligned)217        if (alignedSibling != null) edits.putString(siblingKey, alignedSibling)218        return edits.commit()219    }220    override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)221    override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }222223    override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L224225    override fun stampLocalMutation(): Long {226        val next = maxOf(System.currentTimeMillis(), version() + 1L)227        // commit() returning false must surface as a failure — every caller228        // either treats an exception as a rollback trigger (H-01/H-02 helpers,229        // persistSlotA) or advances a version it has not actually persisted.230        // Throwing keeps the boolean-visible failure mode aligned with codex P1-2.231        if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) {232            throw java.io.IOException("version_stamp_commit_failed")233        }234        return next235    }236237    override fun loadSettings(): VaultSettings =238        prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()239240    override fun writeSettings(settings: VaultSettings): Boolean =241        prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()242243    override fun loadVaultHistory(): VaultHistory =244        prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()245246    override fun writeVaultHistory(history: VaultHistory): Boolean =247        prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()248249    override fun clearVaultHistory() {250        prefs.edit().remove(KEY_HISTORY).commit()251    }252253    override fun clearVault() {254        // H-04: the duress record is part of the vault's at-rest surface and must255        // go with slot A — leaving it behind on a half-built vault is the256        // forensic tell the audit flagged.257        prefs.edit()258            .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)259            .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)260            .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key")261            .remove("peach_pin_preferred")262            .remove("peach_pin_format").remove("peach_pin_salt_v2")263            .remove("peach_pin_verifier_v2").remove("peach_pin_wrapped_key_v2")264            .remove("peach_pin_wrapped_iv_v2").remove("peach_pin_attempt_aliases_v2")265            .remove("peach_pin_preferred_v2")266            .commit()267    }268269    companion object {270        private const val PREFS = "peach_vault_store"271        private const val KEY_SLOT_A = "peach_vault_a"272        private const val KEY_SLOT_B = "peach_vault_b"273        private const val KEY_SLOT_DURESS = "peach_vault_duress"274        private const val KEY_VERSION = "peach_last_modified_at"275        private const val KEY_SETTINGS = "peach_settings"276        private const val KEY_HISTORY = "peach_vault_history"277    }278}