VaultStore.kt

9.3 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)9798/** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */99object VaultCodec {100    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }101102    // MN-1: pad every sealed plaintext to a size bucket before encryption so that103    // slot A (real) and slot B (duress decoy) — and the recovery escrow — quantise104    // to the same lengths and an at-rest observer cannot tell which slot is which105    // from its size. Padding is trailing NULs (never valid inside JSON); decode106    // strips them, so an unpadded legacy blob (no trailing NULs) still decodes.107    fun encode(payload: VaultPayload): ByteArray =108        VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))109    fun decode(bytes: ByteArray): VaultPayload =110        json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))111    /** Test seam: the entry codec is only reachable through a whole payload otherwise. */112    internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)113    internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)114115    fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)116    fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()117    fun encodeSlot(s: StoredVault): String = json.encodeToString(s)118    fun decodeSlot(s: String): StoredVault? = runCatching { json.decodeFromString<StoredVault>(s) }.getOrNull()119    fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)120    fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()121}122123/**124 * Persistence for the two vault slots, the monotonic version counter, and the125 * settings blob. Abstracted behind an interface so the repository can be126 * exercised against an in-memory fake in plain JUnit.127 */128interface VaultStorage {129    fun loadSlotA(): StoredVault?130    fun hasVault(): Boolean131    fun writeSlotA(slot: StoredVault): Boolean132    fun loadSlotB(): String? = null133    fun writeSlotB(blobBase64: String): Boolean134    fun loadDecoySlot(): StoredVault?135    fun writeDecoySlot(slot: StoredVault): Boolean136    fun hasDecoySlot(): Boolean137    fun clearDecoySlot()138    fun version(): Long139    fun stampLocalMutation(): Long140    fun loadSettings(): VaultSettings141    fun writeSettings(settings: VaultSettings): Boolean142    fun loadVaultHistory(): VaultHistory = VaultHistory()143    fun writeVaultHistory(history: VaultHistory): Boolean = true144    fun clearVaultHistory() {}145    fun clearVault()146}147148/**149 * SharedPreferences-backed [VaultStorage]. The slot blobs are already AES-GCM150 * sealed under the Argon2id key, so plaintext prefs are fine — only the salt is151 * in the clear, exactly as in storage.local on the extension. The Pro license152 * lives in a SEPARATE prefs file (LicenseRepository), so wiping this store can153 * never touch the license — destroyVault()'s core invariant.154 */155@Singleton156class VaultStore @Inject constructor(157    @ApplicationContext context: Context,158) : VaultStorage {159    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)160161    override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)162163    override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)164165    override fun writeSlotA(slot: StoredVault): Boolean =166        prefs.edit().putString(KEY_SLOT_A, VaultCodec.encodeSlot(slot)).commit()167168    override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)169170    override fun writeSlotB(blobBase64: String): Boolean =171        prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()172173    override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)174    override fun writeDecoySlot(slot: StoredVault): Boolean =175        prefs.edit().putString(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot)).commit()176    override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)177    override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }178179    override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L180181    override fun stampLocalMutation(): Long {182        val next = maxOf(System.currentTimeMillis(), version() + 1L)183        prefs.edit().putString(KEY_VERSION, next.toString()).commit()184        return next185    }186187    override fun loadSettings(): VaultSettings =188        prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()189190    override fun writeSettings(settings: VaultSettings): Boolean =191        prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()192193    override fun loadVaultHistory(): VaultHistory =194        prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()195196    override fun writeVaultHistory(history: VaultHistory): Boolean =197        prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()198199    override fun clearVaultHistory() {200        prefs.edit().remove(KEY_HISTORY).commit()201    }202203    override fun clearVault() {204        prefs.edit()205            .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)206            .commit()207    }208209    companion object {210        private const val PREFS = "peach_vault_store"211        private const val KEY_SLOT_A = "peach_vault_a"212        private const val KEY_SLOT_B = "peach_vault_b"213        private const val KEY_SLOT_DURESS = "peach_vault_duress"214        private const val KEY_VERSION = "peach_last_modified_at"215        private const val KEY_SETTINGS = "peach_settings"216        private const val KEY_HISTORY = "peach_vault_history"217    }218}