DuressConfig.kt

3.4 KB · raw

1package com.peachpasswords.vault23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import dagger.hilt.android.qualifiers.ApplicationContext7import org.json.JSONObject8import javax.inject.Inject9import javax.inject.Singleton1011/**12 * The action Peach takes when the duress passphrase is entered at the lock screen.13 *14 *  - [DECOY]   open a separate empty/minimal "decoy" vault; the real vault stays15 *              sealed and hidden.16 *  - [DESTROY] irreversibly wipe the real vault, then drop to CreateVault.17 *18 * Persisted as the lowercase [wire] string so a future schema migration sees a19 * stable token, never an ordinal.20 */21enum class DuressAction(val wire: String) {22    DECOY("decoy"),23    DESTROY("destroy");2425    companion object {26        fun fromWire(s: String?): DuressAction =27            entries.firstOrNull { it.wire == s } ?: DECOY28    }29}3031/**32 * Stores only the duress *policy* — whether duress is enabled and which33 * [DuressAction] to take — in a dedicated EncryptedSharedPreferences file.34 *35 * MF-6: the duress passphrase is NOT stored here (no hash, no salt). Instead it36 * keys a real second vault — slot B (the decoy slot in [VaultStorage]) — sealed37 * with Argon2id under the passphrase itself, exactly like the real slot A. The38 * lock screen tries to decrypt slot B with whatever password is typed; success39 * means the duress passphrase was entered. This matches the browser's40 * slot-B-under-duress-passphrase model and removes the old sentinel-keyed decoy41 * that was reachable without any secret.42 *43 * This store therefore holds no secret material — only the action token — but44 * remains in EncryptedSharedPreferences for parity with the license store and so45 * the action is not trivially readable. The slot-B ciphertext is the actual46 * gate; this is policy metadata read only after that gate opens.47 */48@Singleton49class DuressConfigStore @Inject constructor(50    @ApplicationContext context: Context,51) {52    private val masterKey = MasterKey.Builder(context)53        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)54        .build()5556    private val prefs = EncryptedSharedPreferences.create(57        context,58        PREFS_FILE,59        masterKey,60        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,61        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,62    )6364    fun isConfigured(): Boolean = prefs.contains(KEY_DURESS_CONFIG)6566    /** The configured action, or [DuressAction.DECOY] when none is set. */67    fun action(): DuressAction = load()?.let { DuressAction.fromWire(it.optString(FIELD_ACTION)) } ?: DuressAction.DECOY6869    /** Record that duress is enabled with [action]. The passphrase lives in slot B, never here. */70    fun enable(action: DuressAction): Boolean {71        val json = JSONObject().apply {72            put(FIELD_VERSION, 2)73            put(FIELD_ACTION, action.wire)74        }75        return prefs.edit().putString(KEY_DURESS_CONFIG, json.toString()).commit()76    }7778    fun clear(): Boolean = prefs.edit().remove(KEY_DURESS_CONFIG).commit()7980    private fun load(): JSONObject? =81        prefs.getString(KEY_DURESS_CONFIG, null)?.let { runCatching { JSONObject(it) }.getOrNull() }8283    companion object {84        private const val PREFS_FILE = "peach_duress_prefs"85        private const val KEY_DURESS_CONFIG = "duress_config"86        private const val FIELD_VERSION = "v"87        private const val FIELD_ACTION = "action"88    }89}