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 javax.inject.Inject10import javax.inject.Singleton111213141516171819@Serializable20data class StoredVault(21 val version: Int = 1,22 val salt: String,23 val blob: String,24 val recoveryEscrow: RecoveryEscrow? = null,25 val rootKeyEscrow: RootKeyEscrow? = null,26)272829303132333435363738394041@Serializable42data class RootKeyEscrow(43 val salt: String,44 val wrappedRootKey: String,45 val nonce: String,46 val tag: String,47)4849505152535455565758@Serializable59data class RecoveryEscrow(60 val salt: String,61 val blob: String,62)6364@Serializable65data class VaultHistory(66 val snapshots: List<VaultSnapshot> = emptyList(),67)686970@Serializable71data class Tombstone(val id: String, val deletedAt: Long)72737475767778798081828384@Serializable85data class VaultPayload(86 val entries: List<VaultEntry> = emptyList(),87 val duressConfigured: Boolean = false,88 val rootKeyB64: String = "",89 90919293 val recoveryWordHash: String? = null,94 95 val recoveryWordsSet: Boolean = false,96)9798const val SLOT_RECORD_PAD: Char = '\u0000'99100101object VaultCodec {102 private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }103104 105 106 107 108 109 fun encode(payload: VaultPayload): ByteArray =110 VaultCrypto.padToBucket(json.encodeToString(payload).toByteArray(Charsets.UTF_8))111 fun decode(bytes: ByteArray): VaultPayload =112 json.decodeFromString(String(bytes, Charsets.UTF_8).trimEnd('\u0000'))113 114 internal fun decodeEntryForTest(entryJson: String): VaultEntry = json.decodeFromString(entryJson)115 internal fun encodeEntryForTest(entry: VaultEntry): String = json.encodeToString(entry)116117 fun encodeSettings(s: VaultSettings): String = json.encodeToString(s)118 fun decodeSettings(s: String): VaultSettings? = runCatching { json.decodeFromString<VaultSettings>(s) }.getOrNull()119 fun encodeSlot(s: StoredVault): String = json.encodeToString(s)120 fun decodeSlot(s: String): StoredVault? =121 runCatching { json.decodeFromString<StoredVault>(s.trimEnd(SLOT_RECORD_PAD)) }.getOrNull()122123 124125126127128 fun padSlotRecord(record: String, targetLength: Int): String =129 if (record.length >= targetLength) record else record.padEnd(targetLength, SLOT_RECORD_PAD)130131 fun slotRecordLength(record: String): Int = record.trimEnd(SLOT_RECORD_PAD).length132133 134135136137138139140141142143144145146147 fun alignSlotRecords(record: String, sibling: String?): Pair<String, String?> {148 if (sibling == null) return record to null149 val target = maxOf(record.length, slotRecordLength(sibling))150 return padSlotRecord(record, target) to padSlotRecord(sibling, target)151 }152 fun encodeHistory(h: VaultHistory): String = json.encodeToString(h)153 fun decodeHistory(s: String): VaultHistory? = runCatching { json.decodeFromString<VaultHistory>(s) }.getOrNull()154}155156157158159160161interface VaultStorage {162 fun loadSlotA(): StoredVault?163 fun hasVault(): Boolean164 fun writeSlotA(slot: StoredVault): Boolean165 fun loadSlotB(): String? = null166 fun writeSlotB(blobBase64: String): Boolean167 fun loadDecoySlot(): StoredVault?168 fun writeDecoySlot(slot: StoredVault): Boolean169 fun hasDecoySlot(): Boolean170 fun clearDecoySlot()171 fun version(): Long172 fun stampLocalMutation(): Long173 fun loadSettings(): VaultSettings174 fun writeSettings(settings: VaultSettings): Boolean175 fun loadVaultHistory(): VaultHistory = VaultHistory()176 fun writeVaultHistory(history: VaultHistory): Boolean = true177 fun clearVaultHistory() {}178 fun clearVault()179}180181182183184185186187188@Singleton189class VaultStore @Inject constructor(190 @ApplicationContext context: Context,191) : VaultStorage {192 private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)193194 override fun loadSlotA(): StoredVault? = prefs.getString(KEY_SLOT_A, null)?.let(VaultCodec::decodeSlot)195196 override fun hasVault(): Boolean = prefs.contains(KEY_SLOT_A)197198 override fun writeSlotA(slot: StoredVault): Boolean =199 writeAligned(KEY_SLOT_A, VaultCodec.encodeSlot(slot), KEY_SLOT_DURESS)200201 override fun loadSlotB(): String? = prefs.getString(KEY_SLOT_B, null)202203 override fun writeSlotB(blobBase64: String): Boolean =204 prefs.edit().putString(KEY_SLOT_B, blobBase64).commit()205206 override fun loadDecoySlot(): StoredVault? = prefs.getString(KEY_SLOT_DURESS, null)?.let(VaultCodec::decodeSlot)207 override fun writeDecoySlot(slot: StoredVault): Boolean =208 writeAligned(KEY_SLOT_DURESS, VaultCodec.encodeSlot(slot), KEY_SLOT_A)209210 211 private fun writeAligned(key: String, record: String, siblingKey: String): Boolean {212 val (aligned, alignedSibling) = VaultCodec.alignSlotRecords(213 record,214 prefs.getString(siblingKey, null),215 )216 val edits = prefs.edit().putString(key, aligned)217 if (alignedSibling != null) edits.putString(siblingKey, alignedSibling)218 return edits.commit()219 }220 override fun hasDecoySlot(): Boolean = prefs.contains(KEY_SLOT_DURESS)221 override fun clearDecoySlot() { prefs.edit().remove(KEY_SLOT_DURESS).commit() }222223 override fun version(): Long = prefs.getString(KEY_VERSION, null)?.toLongOrNull() ?: 0L224225 override fun stampLocalMutation(): Long {226 val next = maxOf(System.currentTimeMillis(), version() + 1L)227 228 229 230 231 if (!prefs.edit().putString(KEY_VERSION, next.toString()).commit()) {232 throw java.io.IOException("version_stamp_commit_failed")233 }234 return next235 }236237 override fun loadSettings(): VaultSettings =238 prefs.getString(KEY_SETTINGS, null)?.let(VaultCodec::decodeSettings) ?: VaultSettings()239240 override fun writeSettings(settings: VaultSettings): Boolean =241 prefs.edit().putString(KEY_SETTINGS, VaultCodec.encodeSettings(settings)).commit()242243 override fun loadVaultHistory(): VaultHistory =244 prefs.getString(KEY_HISTORY, null)?.let(VaultCodec::decodeHistory) ?: VaultHistory()245246 override fun writeVaultHistory(history: VaultHistory): Boolean =247 prefs.edit().putString(KEY_HISTORY, VaultCodec.encodeHistory(history)).commit()248249 override fun clearVaultHistory() {250 prefs.edit().remove(KEY_HISTORY).commit()251 }252253 override fun clearVault() {254 255 256 257 prefs.edit()258 .remove(KEY_SLOT_A).remove(KEY_SLOT_B).remove(KEY_SLOT_DURESS)259 .remove(KEY_VERSION).remove(KEY_SETTINGS).remove(KEY_HISTORY)260 .remove("peach_pin_salt").remove("peach_pin_verifier").remove("peach_pin_wrapped_key")261 .remove("peach_pin_preferred")262 .remove("peach_pin_format").remove("peach_pin_salt_v2")263 .remove("peach_pin_verifier_v2").remove("peach_pin_wrapped_key_v2")264 .remove("peach_pin_wrapped_iv_v2").remove("peach_pin_attempt_aliases_v2")265 .remove("peach_pin_preferred_v2")266 .commit()267 }268269 companion object {270 private const val PREFS = "peach_vault_store"271 private const val KEY_SLOT_A = "peach_vault_a"272 private const val KEY_SLOT_B = "peach_vault_b"273 private const val KEY_SLOT_DURESS = "peach_vault_duress"274 private const val KEY_VERSION = "peach_last_modified_at"275 private const val KEY_SETTINGS = "peach_settings"276 private const val KEY_HISTORY = "peach_vault_history"277 }278}