VaultStore.kt

7.5 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/** A soft-deleted entry, exposed to the sync layer for last-write-wins merges. */65@Serializable66data class Tombstone(val id: String, val deletedAt: Long)6768/**69 * The decrypted slot-A envelope: the entry list, the duress flag, and the70 * stable sync root key.71 *72 * [rootKeyB64] is the vault's stable 32-byte sync master key (base64, no73 * padding). Unlike the Argon2id password key it never rotates on a password74 * change, so paired devices keep deriving the same vault_id, Noise static key,75 * and per-entry envelope keys across independent password rotations (browser76 * SEV-2-B). Empty on legacy payloads written before sync landed — the77 * repository lazily mints and persists one on first unlock.78 */79@Serializable80data class VaultPayload(81    val entries: List<VaultEntry> = emptyList(),82    val duressConfigured: Boolean = false,83    val rootKeyB64: String = "",84    /**85     * BLAKE3 hash of the space-joined 12 BIP39 recovery words. Lets the app verify86     * recovery-word entry without storing the words themselves. Null until set.87     */88    val recoveryWordHash: String? = null,89    /** True once the user has confirmed (written down) their recovery words. */90    val recoveryWordsSet: Boolean = false,91)9293/** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */94object VaultCodec {95    private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }96    fun encode(payload: VaultPayload): ByteArray = json.encodeToString(payload).toByteArray(Charsets.UTF_8)97    fun decode(bytes: ByteArray): VaultPayload = json.decodeFromString(String(bytes, Charsets.UTF_8))98    fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)99    fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()100    fun encodeSlot(s: StoredVault): String = json.encodeToString(s)101    fun decodeSlot(s: String): StoredVault? = runCatching { json.decodeFromString<StoredVault>(s) }.getOrNull()102}103104/**105 * Persistence for the two vault slots, the monotonic version counter, and the106 * settings blob. Abstracted behind an interface so the repository can be107 * exercised against an in-memory fake in plain JUnit.108 */109interface VaultStorage {110    fun loadSlotA(): StoredVault?111    fun hasVault(): Boolean112    fun writeSlotA(slot: StoredVault): Boolean113    fun writeSlotB(blobBase64: String): Boolean114    fun loadDecoySlot(): StoredVault?115    fun writeDecoySlot(slot: StoredVault): Boolean116    fun hasDecoySlot(): Boolean117    fun clearDecoySlot()118    fun version(): Long119    fun stampLocalMutation(): Long120    fun loadSettings(): VaultSettings121    fun writeSettings(settings: VaultSettings): Boolean122    fun clearVault()123}124125/**126 * SharedPreferences-backed [VaultStorage]. The slot blobs are already AES-GCM127 * sealed under the Argon2id key, so plaintext prefs are fine — only the salt is128 * in the clear, exactly as in storage.local on the extension. The Pro license129 * lives in a SEPARATE prefs file (LicenseRepository), so wiping this store can130 * never touch the license — destroyVault()'s core invariant.131 */132@Singleton133class VaultStore @Inject constructor(134    @ApplicationContext context: Context,135) : VaultStorage {136    private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)137138    override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)139140    override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)141142    override fun writeSlotA(slot: StoredVault): Boolean =143        prefs.edit().putString(KEY_SLOT_A, VaultCodec.encodeSlot(slot)).commit()144145    override fun writeSlotB(blobBase64: String): Boolean =146        prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()147148    override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)149    override fun writeDecoySlot(slot: StoredVault): Boolean =150        prefs.edit().putString(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot)).commit()151    override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)152    override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }153154    override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L155156    override fun stampLocalMutation(): Long {157        val next = maxOf(System.currentTimeMillis(), version() + 1L)158        prefs.edit().putString(KEY_VERSION, next.toString()).commit()159        return next160    }161162    override fun loadSettings(): VaultSettings =163        prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()164165    override fun writeSettings(settings: VaultSettings): Boolean =166        prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()167168    override fun clearVault() {169        prefs.edit()170            .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_VERSION).remove(KEY_SETTINGS)171            .commit()172    }173174    companion object {175        private const val PREFS = "peach_vault_store"176        private const val KEY_SLOT_A = "peach_vault_a"177        private const val KEY_SLOT_B = "peach_vault_b"178        private const val KEY_SLOT_DURESS = "peach_vault_duress"179        private const val KEY_VERSION = "peach_last_modified_at"180        private const val KEY_SETTINGS = "peach_settings"181    }182}