package com.peachpasswords.vault import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.File import javax.inject.Inject import javax.inject.Singleton /** * One encrypted slot at rest. [blob] is base64 of VaultCrypto's * `nonce || ciphertext || tag` output; [salt] (base64) drives Argon2id. * Mirrors the browser's StoredVault. [recoveryEscrow] is null until the user * sets recovery words; it is a password-independent second copy of the slot-A * plaintext that powers the forgot-password flow. */ @Serializable data class StoredVault( /** Version 1 is the legacy Slot-A-AAD record; current decoys use version 2. */ val version: Int = LEGACY_ANDROID_SLOT_VERSION, val salt: String, val blob: String, val recoveryEscrow: RecoveryEscrow? = null, val rootKeyEscrow: RootKeyEscrow? = null, ) const val LEGACY_ANDROID_SLOT_VERSION = 1 const val CURRENT_ANDROID_SLOT_VERSION = 2 /** * Browser-byte-compatible recovery escrow that wraps ONLY the 32-byte root key, * so the same 12 recovery words unwrap the same root key on either platform * (MF-1). The wrapped plaintext is the lowercase ASCII hex of the root key; the * key is `HKDF-SHA256(wordsToRootKeyMaterial(words), salt, "peach-recovery-v1")` * and the AEAD AAD is the single byte 0x01 ([VaultCrypto.AAD_RECOVERY_ROOTKEY]) — * all matching the browser (`vault.ts:282-316`). * * Fields mirror the browser's slot-A `recovery_*` fields (base64 here vs hex on * the browser) so a future browser-slot importer can map them directly: * [salt] = `recovery_salt`, [wrappedRootKey] = `recovery_wrapped_root_key` * (ciphertext only), [nonce] = `recovery_nonce`, [tag] = `recovery_tag`. */ @Serializable data class RootKeyEscrow( val salt: String, val wrappedRootKey: String, val nonce: String, val tag: String, ) /** * A second, password-independent copy of the slot-A plaintext envelope, sealed * under a key the 12 BIP39 recovery words derive (HKDF). Lets the vault be * recovered and re-wrapped under a new password without the old password. * * [salt] (base64, 16B) is the HKDF salt for `deriveRecoveryKey`. * [blob] (base64) is `VaultCrypto.encrypt(slotAPlaintext, recoveryKey, AAD_RECOVERY)` * i.e. `nonce(12) || AES-256-GCM ciphertext||tag`. */ @Serializable data class RecoveryEscrow( val salt: String, val blob: String, ) @Serializable data class VaultHistory( val snapshots: List = emptyList(), ) /** A soft-deleted entry, exposed to the sync layer for last-write-wins merges. */ @Serializable data class Tombstone(val id: String, val deletedAt: Long) /** * The decrypted slot-A envelope: the entry list, the duress flag, and the * stable sync root key. * * [rootKeyB64] is the vault's stable 32-byte sync master key (base64, no * padding). Unlike the Argon2id password key it never rotates on a password * change, so paired devices keep deriving the same vault_id, Noise static key, * and per-entry envelope keys across independent password rotations (browser * SEV-2-B). Empty on legacy payloads written before sync landed — the * repository lazily mints and persists one on first unlock. */ @Serializable data class VaultPayload( val entries: List = emptyList(), val duressConfigured: Boolean = false, val rootKeyB64: String = "", /** * BLAKE3 hash of the space-joined 12 BIP39 recovery words. Lets the app verify * recovery-word entry without storing the words themselves. Null until set. */ val recoveryWordHash: String? = null, /** True once the user has confirmed (written down) their recovery words. */ val recoveryWordsSet: Boolean = false, ) const val SLOT_RECORD_PAD: Char = '\u0000' /** Pure JSON codec for the slot-A plaintext envelope — usable without Android. */ object VaultCodec { private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } // MN-1: pad every sealed plaintext to a size bucket before encryption so that // slot A (real) and slot B (duress decoy) — and the recovery escrow — quantise // to the same lengths and an at-rest observer cannot tell which slot is which // from its size. Padding is trailing NULs (never valid inside JSON); decode // strips them, so an unpadded legacy blob (no trailing NULs) still decodes. fun encode(payload: VaultPayload): ByteArray = VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8)) fun decode(bytes: ByteArray): VaultPayload = json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000')) /** Test seam: the entry codec is only reachable through a whole payload otherwise. */ internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson) internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry) fun encodeSettings(s: VaultSettings): String = json.encodeToString(s) fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString(s) }.getOrNull() fun encodeSlot(s: StoredVault): String = json.encodeToString(s) fun decodeSlot(s: String): StoredVault? = runCatching { json.decodeFromString(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull() /** * Trailing NULs pad a slot record to its sibling's length. NUL cannot occur * inside the JSON — the same reason [encode] can pad plaintext with it — so * [decodeSlot] only has to trim it. */ fun padSlotRecord(record: String, targetLength: Int): String = if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD) fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length /** * The (written, sibling) record pair after alignment, so both slots are the * same length at rest (PAI-2026-004). * * A real decoy cannot be re-sealed without the duress passphrase, so its * blob keeps the length it had when duress was configured while slot A * keeps growing through the padding buckets. Padding the shorter record to * the longer one is what keeps an at-rest observer from reading duress * configuration, or how far the vault has grown, out of the pair. * * The target is the longer TRUE record, not the longer stored one, so the * pair tracks the current vault instead of ratcheting up to a high-water * mark every time the vault temporarily grows. */ fun alignSlotRecords(record: String, sibling: String?): Pair { if (sibling == null) return record to null val target = maxOf(record.length, slotRecordLength(sibling)) return padSlotRecord(record, target) to padSlotRecord(sibling, target) } fun encodeHistory(h: VaultHistory): String = json.encodeToString(h) fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString(s) }.getOrNull() } interface VaultStorage { fun loadDecoySlot(): StoredVault? fun loadSlotA(): StoredVault? fun hasVault(): Boolean fun writeSlotA(slot: StoredVault): Boolean fun loadSlotB(): String? = null fun writeSlotB(blobBase64: String): Boolean fun writeDecoySlot(slot: StoredVault): Boolean /** Restore the exact triad, rolling back each write if a later write fails. */ fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean { val priorA = loadSlotA() val priorB = loadSlotB() val priorD = loadDecoySlot() return try { if (!writeSlotA(slotA)) return false if (slotB == null) clearSlotB() else if (!writeSlotB(slotB)) throw IllegalStateException("slot_b_restore_failed") if (!writeDecoySlot(decoy)) throw IllegalStateException("decoy_restore_failed") true } catch (_: Exception) { runCatching { if (priorA == null) clearVault() else writeSlotA(priorA) } runCatching { if (priorB == null) clearSlotB() else writeSlotB(priorB) } runCatching { if (priorD == null) clearDecoySlot() else writeDecoySlot(priorD) } false } } fun clearSlotB() {} fun hasDecoySlot(): Boolean = loadDecoySlot() != null fun clearDecoySlot() fun version(): Long fun stampLocalMutation(): Long fun loadSettings(): VaultSettings fun writeSettings(settings: VaultSettings): Boolean fun loadVaultHistory(): VaultHistory = VaultHistory() fun writeVaultHistory(history: VaultHistory): Boolean = true fun clearVaultHistory() {} fun clearVault(): Boolean } /** * SharedPreferences-backed [VaultStorage]. The slot blobs are already AES-GCM * sealed under the Argon2id key, so plaintext prefs are fine — only the salt is * in the clear, exactly as in storage.local on the extension. The Pro license * lives in a SEPARATE prefs file (LicenseRepository), so wiping this store can * never touch the license — destroyVault()'s core invariant. */ @Singleton class VaultStore @Inject constructor( @ApplicationContext context: Context, ) : VaultStorage { private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) /** Sharing a Codex leaves its ciphertext here; destroy must sweep it too (PB-26-137). */ private val codexCacheDir = File(context.cacheDir, CODEX_CACHE_DIR) override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot) override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A) override fun writeSlotA(slot: StoredVault): Boolean = writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS) override fun clearSlotB() { prefs.edit().remove(KEY_SLOT_B).commit() } override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null) override fun writeSlotB(blobBase64: String): Boolean = prefs.edit().putString(KEY_SLOT_B, blobBase64).commit() override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot) override fun writeDecoySlot(slot: StoredVault): Boolean = writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A) /** Both slot records are written in one commit so the pair can never be caught misaligned. */ private fun writeAligned(key: String, record: String, siblingKey: String): Boolean { val (aligned, alignedSibling) = VaultCodec.alignSlotRecords( record, prefs.getString(siblingKey, null), ) val edits = prefs.edit().putString(key, aligned) if (alignedSibling != null) edits.putString(siblingKey, alignedSibling) return edits.commit() } override fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean { val (alignedA, alignedDecoy) = VaultCodec.alignSlotRecords( VaultCodec.encodeSlot(slotA), VaultCodec.encodeSlot(decoy), ) val edits = prefs.edit() .putString(KEY_SLOT_A, alignedA) .putString(KEY_SLOT_DURESS, alignedDecoy) if (slotB != null) edits.putString(KEY_SLOT_B, slotB) else edits.remove(KEY_SLOT_B) return edits.commit() } override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS) override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() } override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L override fun stampLocalMutation(): Long { val next = maxOf(System.currentTimeMillis(), version() + 1L) // commit() returning false must surface as a failure — every caller // either treats an exception as a rollback trigger (H-01/H-02 helpers, // persistSlotA) or advances a version it has not actually persisted. // Throwing keeps the boolean-visible failure mode aligned with codex P1-2. if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) { throw java.io.IOException("version_stamp_commit_failed") } return next } override fun loadSettings(): VaultSettings = prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings() override fun writeSettings(settings: VaultSettings): Boolean = prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit() override fun loadVaultHistory(): VaultHistory = prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory() override fun writeVaultHistory(history: VaultHistory): Boolean = prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit() override fun clearVaultHistory() { prefs.edit().remove(KEY_HISTORY).commit() } override fun clearVault(): Boolean { // H-04: the duress record is part of the vault's at-rest surface and must // go with slot A — leaving it behind on a half-built vault is the // forensic tell the audit flagged. // PB-26-003: surface the commit and cache-deletion results instead of // discarding them — callers must not treat a failed wipe as completed. val prefsCleared = prefs.edit() .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS) .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY) .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key") .remove("peach_pin_preferred") .remove("peach_pin_format").remove("peach_pin_salt_v2") .remove("peach_pin_verifier_v2").remove("peach_pin_wrapped_key_v2") .remove("peach_pin_wrapped_iv_v2").remove("peach_pin_attempt_aliases_v2") .remove("peach_pin_preferred_v2") .commit() val cacheCleared = codexCacheDir.deleteRecursively() return prefsCleared && cacheCleared } companion object { private const val PREFS = "peach_vault_store" private const val KEY_SLOT_A = "peach_vault_a" private const val KEY_SLOT_B = "peach_vault_b" private const val KEY_SLOT_DURESS = "peach_vault_duress" private const val KEY_VERSION = "peach_last_modified_at" private const val KEY_SETTINGS = "peach_settings" private const val KEY_HISTORY = "peach_vault_history" private const val CODEX_CACHE_DIR = "codex" } }