1package com.peachpasswords.vault23import android.content.Context4import dagger.hilt.android.qualifiers.ApplicationContext5import kotlinx.serialization.Serializable6import kotlinx.serialization.decodeFromString7import kotlinx.serialization.encodeToString8import kotlinx.serialization.json.Json9import java.io.File10import javax.inject.Inject11import javax.inject.Singleton121314151617181920@Serializable21data class StoredVault(22 23 val version: Int = LEGACY_ANDROID_SLOT_VERSION,24 val salt: String,25 val blob: String,26 val recoveryEscrow: RecoveryEscrow? = null,27 val rootKeyEscrow: RootKeyEscrow? = null,28)2930const val LEGACY_ANDROID_SLOT_VERSION = 131const val CURRENT_ANDROID_SLOT_VERSION = 2323334353637383940414243444546@Serializable47data class RootKeyEscrow(48 val salt: String,49 val wrappedRootKey: String,50 val nonce: String,51 val tag: String,52)5354555657585960616263@Serializable64data class RecoveryEscrow(65 val salt: String,66 val blob: String,67)6869@Serializable70data class VaultHistory(71 val snapshots: List<VaultSnapshot> = emptyList(),72)737475@Serializable76data class Tombstone(val id: String, val deletedAt: Long)77787980818283848586878889@Serializable90data class VaultPayload(91 val entries: List<VaultEntry> = emptyList(),92 val duressConfigured: Boolean = false,93 val rootKeyB64: String = "",94 95969798 val recoveryWordHash: String? = null,99 100 val recoveryWordsSet: Boolean = false,101)102103const val SLOT_RECORD_PAD: Char = '\u0000'104105106object VaultCodec {107 private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }108109 110 111 112 113 114 fun encode(payload: VaultPayload): ByteArray =115 VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))116 fun decode(bytes: ByteArray): VaultPayload =117 json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))118 119 internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)120 internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)121122 fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)123 fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()124 fun encodeSlot(s: StoredVault): String = json.encodeToString(s)125 fun decodeSlot(s: String): StoredVault? =126 runCatching { json.decodeFromString<StoredVault>(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull()127128 129130131132133 fun padSlotRecord(record: String, targetLength: Int): String =134 if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD)135136 fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length137138 139140141142143144145146147148149150151152 fun alignSlotRecords(record: String, sibling: String?): Pair<String, String?> {153 if (sibling == null) return record to null154 val target = maxOf(record.length, slotRecordLength(sibling))155 return padSlotRecord(record, target) to padSlotRecord(sibling, target)156 }157 fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)158 fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()159}160161interface VaultStorage {162 fun loadDecoySlot(): StoredVault?163 fun loadSlotA(): StoredVault?164 fun hasVault(): Boolean165 fun writeSlotA(slot: StoredVault): Boolean166 fun loadSlotB(): String? = null167 fun writeSlotB(blobBase64: String): Boolean168 fun writeDecoySlot(slot: StoredVault): Boolean169 170 fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean {171 val priorA = loadSlotA()172 val priorB = loadSlotB()173 val priorD = loadDecoySlot()174 return try {175 if (!writeSlotA(slotA)) return false176 if (slotB == null) clearSlotB() else if (!writeSlotB(slotB)) throw IllegalStateException("slot_b_restore_failed")177 if (!writeDecoySlot(decoy)) throw IllegalStateException("decoy_restore_failed")178 true179 } catch (_: Exception) {180 runCatching { if (priorA == null) clearVault() else writeSlotA(priorA) }181 runCatching { if (priorB == null) clearSlotB() else writeSlotB(priorB) }182 runCatching { if (priorD == null) clearDecoySlot() else writeDecoySlot(priorD) }183 false184 }185 }186 fun clearSlotB() {}187 fun hasDecoySlot(): Boolean = loadDecoySlot() != null188 fun clearDecoySlot()189 fun version(): Long190 fun stampLocalMutation(): Long191 fun loadSettings(): VaultSettings192 fun writeSettings(settings: VaultSettings): Boolean193 fun loadVaultHistory(): VaultHistory = VaultHistory()194 fun writeVaultHistory(history: VaultHistory): Boolean = true195 fun clearVaultHistory() {}196 fun clearVault(): Boolean197}198199200201202203204205206@Singleton207class VaultStore @Inject constructor(208 @ApplicationContext context: Context,209) : VaultStorage {210 private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)211212 213 private val codexCacheDir = File(context.cacheDir, CODEX_CACHE_DIR)214215 override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)216217 override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)218219 override fun writeSlotA(slot: StoredVault): Boolean =220 writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS)221 override fun clearSlotB() { prefs.edit().remove(KEY_SLOT_B).commit() }222 override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)223224 override fun writeSlotB(blobBase64: String): Boolean =225 prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()226227 override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)228 override fun writeDecoySlot(slot: StoredVault): Boolean =229 writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A)230231 232 private fun writeAligned(key: String, record: String, siblingKey: String): Boolean {233 val (aligned, alignedSibling) = VaultCodec.alignSlotRecords(234 record,235 prefs.getString(siblingKey, null),236 )237 val edits = prefs.edit().putString(key, aligned)238 if (alignedSibling != null) edits.putString(siblingKey, alignedSibling)239 return edits.commit()240 }241242 override fun restoreVaultTriad(slotA: StoredVault, slotB: String?, decoy: StoredVault): Boolean {243 val (alignedA, alignedDecoy) = VaultCodec.alignSlotRecords(244 VaultCodec.encodeSlot(slotA),245 VaultCodec.encodeSlot(decoy),246 )247 val edits = prefs.edit()248 .putString(KEY_SLOT_A, alignedA)249 .putString(KEY_SLOT_DURESS, alignedDecoy)250 if (slotB != null) edits.putString(KEY_SLOT_B, slotB) else edits.remove(KEY_SLOT_B)251 return edits.commit()252 }253 override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)254 override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }255256 override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L257258 override fun stampLocalMutation(): Long {259 val next = maxOf(System.currentTimeMillis(), version() + 1L)260 261 262 263 264 if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) {265 throw java.io.IOException("version_stamp_commit_failed")266 }267 return next268 }269270 override fun loadSettings(): VaultSettings =271 prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()272273 override fun writeSettings(settings: VaultSettings): Boolean =274 prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()275276 override fun loadVaultHistory(): VaultHistory =277 prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()278279 override fun writeVaultHistory(history: VaultHistory): Boolean =280 prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()281282 override fun clearVaultHistory() {283 prefs.edit().remove(KEY_HISTORY).commit()284 }285286 override fun clearVault(): Boolean {287 288 289 290 291 292 val prefsCleared = prefs.edit()293 .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)294 .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)295 .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key")296 .remove("peach_pin_preferred")297 .remove("peach_pin_format").remove("peach_pin_salt_v2")298 .remove("peach_pin_verifier_v2").remove("peach_pin_wrapped_key_v2")299 .remove("peach_pin_wrapped_iv_v2").remove("peach_pin_attempt_aliases_v2")300 .remove("peach_pin_preferred_v2")301 .commit()302 val cacheCleared = codexCacheDir.deleteRecursively()303 return prefsCleared && cacheCleared304 }305306 companion object {307 private const val PREFS = "peach_vault_store"308 private const val KEY_SLOT_A = "peach_vault_a"309 private const val KEY_SLOT_B = "peach_vault_b"310 private const val KEY_SLOT_DURESS = "peach_vault_duress"311 private const val KEY_VERSION = "peach_last_modified_at"312 private const val KEY_SETTINGS = "peach_settings"313 private const val KEY_HISTORY = "peach_vault_history"314 private const val CODEX_CACHE_DIR = "codex"315 }316}