package com.peachpasswords.vault import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import org.json.JSONObject import javax.inject.Inject import javax.inject.Singleton /** * The action Peach takes when the duress passphrase is entered at the lock screen. * * - [DECOY] open a separate empty/minimal "decoy" vault; the real vault stays * sealed and hidden. * - [DESTROY] irreversibly wipe the real vault, then drop to CreateVault. * * Persisted as the lowercase [wire] string so a future schema migration sees a * stable token, never an ordinal. */ enum class DuressAction(val wire: String) { DECOY("decoy"), DESTROY("destroy"); companion object { fun fromWire(s: String?): DuressAction = entries.firstOrNull { it.wire == s } ?: DECOY } } /** * Stores only the duress *policy* — whether duress is enabled and which * [DuressAction] to take — in a dedicated EncryptedSharedPreferences file. * * MF-6: the duress passphrase is NOT stored here (no hash, no salt). Instead it * keys a real second vault — slot B (the decoy slot in [VaultStorage]) — sealed * with Argon2id under the passphrase itself, exactly like the real slot A. The * lock screen tries to decrypt slot B with whatever password is typed; success * means the duress passphrase was entered. This matches the browser's * slot-B-under-duress-passphrase model and removes the old sentinel-keyed decoy * that was reachable without any secret. * * This store therefore holds no secret material — only the action token — but * remains in EncryptedSharedPreferences for parity with the license store and so * the action is not trivially readable. The slot-B ciphertext is the actual * gate; this is policy metadata read only after that gate opens. */ @Singleton class DuressConfigStore @Inject constructor( @ApplicationContext context: Context, ) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, PREFS_FILE, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) fun isConfigured(): Boolean = load()?.optString(FIELD_ACTION) in ACTION_WIRES /** The configured action, or [DuressAction.DECOY] when none is set. */ fun action(): DuressAction = load()?.let { DuressAction.fromWire(it.optString(FIELD_ACTION)) } ?: DuressAction.DECOY /** Record that duress is enabled with [action]. The passphrase lives in slot B, never here. */ fun enable(action: DuressAction): Boolean = prefs.edit().putString(KEY_DURESS_CONFIG, encode(action.wire)).commit() /** * Disarm. Writes an explicit "off" record rather than removing the key: the * file's key set must not reveal whether duress is configured (PB-26-127). */ fun clear(): Boolean = prefs.edit().putString(KEY_DURESS_CONFIG, encode(ACTION_OFF)).commit() /** * Every state — off, decoy, destroy — is written as a NUL-padded record of * the same length. EncryptedSharedPreferences applies no padding to the * value, so an equal plaintext length is what keeps the three states * indistinguishable in the stored ciphertext. */ private fun encode(action: String): String = encodeDuressPolicyRecord(action) private fun load(): JSONObject? = prefs.getString(KEY_DURESS_CONFIG, null) ?.trimEnd(SLOT_RECORD_PAD) ?.let { runCatching { JSONObject(it) }.getOrNull() } companion object { private const val PREFS_FILE = "peach_duress_prefs" private const val KEY_DURESS_CONFIG = "duress_config" private const val FIELD_ACTION = "action" internal const val RECORD_LENGTH = 64 private const val ACTION_OFF = "off" private val ACTION_WIRES = setOf(DuressAction.DECOY.wire, DuressAction.DESTROY.wire) } } /** * One fixed-length record for every duress state, including "off". Presence and * action must not be readable from the stored ciphertext, so the key set and the * plaintext length are identical whether duress is absent, set to decoy, or set * to destroy (PB-26-127). */ internal fun encodeDuressPolicyRecord(action: String): String { val json = JSONObject().apply { put("v", 2) put("action", action) } return VaultCodec.padSlotRecord(json.toString(), DuressConfigStore.RECORD_LENGTH) }