DuressConfig.kt

4.6 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 = load()?.optString(FIELD_ACTION) in ACTION_WIRES6566    /** 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        prefs.edit().putString(KEY_DURESS_CONFIG, encode(action.wire)).commit()7273    /**74     * Disarm. Writes an explicit "off" record rather than removing the key: the75     * file's key set must not reveal whether duress is configured (PB-26-127).76     */77    fun clear(): Boolean =78        prefs.edit().putString(KEY_DURESS_CONFIG, encode(ACTION_OFF)).commit()7980    /**81     * Every state — off, decoy, destroy — is written as a NUL-padded record of82     * the same length. EncryptedSharedPreferences applies no padding to the83     * value, so an equal plaintext length is what keeps the three states84     * indistinguishable in the stored ciphertext.85     */86    private fun encode(action: String): String = encodeDuressPolicyRecord(action)8788    private fun load(): JSONObject? =89        prefs.getString(KEY_DURESS_CONFIG, null)90            ?.trimEnd(SLOT_RECORD_PAD)91            ?.let { runCatching { JSONObject(it) }.getOrNull() }9293    companion object {94        private const val PREFS_FILE = "peach_duress_prefs"95        private const val KEY_DURESS_CONFIG = "duress_config"96        private const val FIELD_ACTION = "action"97        internal const val RECORD_LENGTH = 6498        private const val ACTION_OFF = "off"99        private val ACTION_WIRES = setOf(DuressAction.DECOY.wire, DuressAction.DESTROY.wire)100    }101}102103/**104 * One fixed-length record for every duress state, including "off". Presence and105 * action must not be readable from the stored ciphertext, so the key set and the106 * plaintext length are identical whether duress is absent, set to decoy, or set107 * to destroy (PB-26-127).108 */109internal fun encodeDuressPolicyRecord(action: String): String {110    val json = JSONObject().apply {111        put("v", 2)112        put("action", action)113    }114    return VaultCodec.padSlotRecord(json.toString(), DuressConfigStore.RECORD_LENGTH)115}