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 = prefs.contains(KEY_DURESS_CONFIG) /** 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 { val json = JSONObject().apply { put(FIELD_VERSION, 2) put(FIELD_ACTION, action.wire) } return prefs.edit().putString(KEY_DURESS_CONFIG, json.toString()).commit() } fun clear(): Boolean = prefs.edit().remove(KEY_DURESS_CONFIG).commit() private fun load(): JSONObject? = prefs.getString(KEY_DURESS_CONFIG, null)?.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_VERSION = "v" private const val FIELD_ACTION = "action" } }