DuressConfig.kt
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}