1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.util.Log12import com.peachpasswords.BuildConfig13import com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import java.util.concurrent.locks.ReentrantLock21import javax.inject.Inject22import javax.inject.Singleton232425private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2627private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)28private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)293031private const val PASSWORD_HISTORY_MAX = 5032private const val VAULT_HISTORY_MAX = 53334sealed interface UnlockResult {35 data object Success : UnlockResult36 data object WrongPassword : UnlockResult37 data class Error(val reason: String) : UnlockResult38}3940sealed interface CreateVaultResult {41 data object Success : CreateVaultResult42 data class Error(val reason: String) : CreateVaultResult43}4445sealed interface AdoptVaultResult {46 data class Success(val vaultId: String) : AdoptVaultResult47 data class Error(val reason: String) : AdoptVaultResult48}495051525354555657585960616263@Singleton64class VaultRepository @Inject constructor(65 private val store: VaultStorage,66) {67 private val mutex = Mutex()68 69 70 71 72 73 74 75 private val stateLock = ReentrantLock()76 private val rng = SecureRandom()7778 7980818283 private inline fun <T> mutateState(block: () -> T): T {84 stateLock.lock()85 return try { block() } finally { stateLock.unlock() }86 }8788 private var derivedKey: ByteArray? = null89 private var rootKey: ByteArray? = null90 private var entryList: MutableList<VaultEntry> = mutableListOf()91 private var duressConfigured: Boolean = false92 private var recoveryWordHash: String? = null93 private var recoveryWordsSet: Boolean = false94 95 private var decoyMode: Boolean = false9697 private val _isUnlocked = MutableStateFlow(false)98 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()99100 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())101 102 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()103104 private val _settings = MutableStateFlow(VaultSettings())105 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()106107 fun hasVault(): Boolean = store.hasVault()108109 110111 112113114115116 fun codexVaultBlobBase64(): String? {117 val slotA = store.loadSlotA() ?: return null118 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""119 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))120 }121122 123124125126127 128 private fun JsonElement?.asSlotJson(): String? = when {129 this == null || this is JsonNull -> null130 this is JsonPrimitive && isString -> content131 else -> toString()132 }133134 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {135 var candidateKey: ByteArray? = null136 var plaintext: ByteArray? = null137 return try {138 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)139 140 141 142 143 144 145 146 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject147 ?: return UnlockResult.Error("invalid_codex_blob")148 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")149 150 151 152 153 154 val slotA = VaultCodec.decodeSlot(slotJson)155 ?: BrowserSlot.parse(slotJson)?.let {156 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword157 }158 ?: return UnlockResult.Error("invalid_codex_blob")159 160 161 162 163 164 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))165 plaintext = decryptSlotABlob(slotA, candidateKey)166 167 168 169 170 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {171 VaultCrypto.zero(candidateKey)172 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))173 plaintext = decryptSlotABlob(slotA, candidateKey)174 }175 if (plaintext == null) return UnlockResult.WrongPassword176 val payload = try {177 VaultCodec.decode(plaintext)178 } catch (_: Exception) {179 return UnlockResult.Error("invalid_codex_blob")180 }181182 val priorA = store.loadSlotA()183 val priorB = store.loadSlotB()184 185 186 val codexSlotB = wrapper["b"].asSlotJson()187188 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")189 val committed = runCatching {190 val wroteB = if (codexSlotB != null) {191 store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))192 } else {193 writeMatchingNoiseSlotB(slotA)194 }195 if (!wroteB) throw IllegalStateException("storage_write_failed")196 store.stampLocalMutation()197 }.isSuccess198 if (!committed) {199 rollbackRestore(priorA, priorB)200 return UnlockResult.Error("storage_write_failed")201 }202203 val ownedKey = candidateKey!!204 candidateKey = null 205 adoptSlotA(ownedKey, payload)206 } catch (e: Exception) {207 208 209 210 211 212 213 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))214 UnlockResult.Error(e.message ?: "restore_failed")215 } finally {216 plaintext?.let(VaultCrypto::zero)217 candidateKey?.let(VaultCrypto::zero)218 }219 }220221 222 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =223 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()224225 226227228229230231 private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {232 val failures = mutableListOf<String>()233 if (priorA == null) {234 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }235 } else {236 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }237 .onFailure { failures += "writeSlotA: $it" }238 if (priorB != null) {239 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }240 .onFailure { failures += "writeSlotB: $it" }241 }242 }243 244 245 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }246 }247248 249 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {250 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)251 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()252 val window = offset?.let {253 val from = (it - 40).coerceAtLeast(0)254 val to = (it + 40).coerceAtMost(wrapperJson.length)255 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")256 }257 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()258 val slot = wrapper?.get("a")259 buildString {260 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)261 append(" | blobBase64=").append(vaultBlobBase64.length)262 append(" wrapperChars=").append(wrapperJson.length)263 append(" wrapperParses=").append(wrapper != null)264 append(" keys=").append(wrapper?.keys)265 append(" slotAKind=").append(266 when {267 slot == null -> "absent"268 slot is JsonPrimitive && slot.isString -> "string"269 slot is JsonObject -> "object:" + slot.keys270 else -> slot::class.simpleName271 },272 )273 if (window != null) append(" masked@").append(offset).append("=").append(window)274 }275 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }276277 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {278 return try {279 val salt = VaultCrypto.generateSalt()280 val key = VaultCrypto.deriveKey(password, salt)281 mutateState {282 entryList = mutableListOf()283 duressConfigured = false284 recoveryWordHash = null285 recoveryWordsSet = false286 decoyMode = false287 rootKey = generateRecoverableRootKey()288 }289 290 291 292 293 val slotA = sealSlotA(key, salt)294 if (!store.writeSlotA(slotA)) {295 lock()296 return CreateVaultResult.Error("storage_write_failed")297 }298 299 300 val created = try {301 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")302 store.stampLocalMutation()303 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")304 true305 } catch (_: Exception) {306 307 308 store.clearVault()309 false310 }311 if (!created) {312 lock()313 return CreateVaultResult.Error("storage_write_failed")314 }315 mutateState {316 derivedKey = key317 publish()318 _isUnlocked.value = true319 }320 CreateVaultResult.Success321 } catch (e: Exception) {322 lock()323 CreateVaultResult.Error(e.message ?: "create_failed")324 }325 }326327 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {328 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")329 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")330 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {331 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")332 }333 var adoptedRootKey: ByteArray? = null334 var nextDerivedKey: ByteArray? = null335 var sessionRootKey: ByteArray? = null336 return try {337 adoptedRootKey = hexToRootKey(rootKeyHex)338 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {339 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")340 }341 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)342 val salt = VaultCrypto.generateSalt()343 nextDerivedKey = VaultCrypto.deriveKey(password, salt)344 sessionRootKey = adoptedRootKey.copyOf()345 val payload = VaultPayload(346 entries = emptyList(),347 duressConfigured = false,348 rootKeyB64 = b64(sessionRootKey),349 recoveryWordHash = null,350 recoveryWordsSet = false,351 )352 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))353 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)354 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")355 356 357 val created = try {358 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")359 store.stampLocalMutation()360 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")361 true362 } catch (_: Exception) {363 store.clearVault()364 false365 }366 if (!created) return AdoptVaultResult.Error("storage_write_failed")367368 val previousDerivedKey = derivedKey369 val previousRootKey = rootKey370 mutateState {371 derivedKey = nextDerivedKey372 rootKey = sessionRootKey373 nextDerivedKey = null374 sessionRootKey = null375 entryList = mutableListOf()376 duressConfigured = false377 recoveryWordHash = null378 recoveryWordsSet = false379 decoyMode = false380 publish()381 _isUnlocked.value = true382 }383 previousDerivedKey?.let(VaultCrypto::zero)384 previousRootKey?.let(VaultCrypto::zero)385 AdoptVaultResult.Success(vaultId)386 } catch (e: Exception) {387 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")388 } finally {389 adoptedRootKey?.let(VaultCrypto::zero)390 nextDerivedKey?.let(VaultCrypto::zero)391 sessionRootKey?.let(VaultCrypto::zero)392 }393 }394395 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }396397 398 private fun unlockLocked(password: String): UnlockResult {399 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")400 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))401 val payload = try {402 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))403 } catch (_: Exception) {404 VaultCrypto.zero(key)405 406 407 408 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {409 val legacy = tryLegacyRawUnlock(password, slotA)410 if (legacy != null) return legacy411 }412 return UnlockResult.WrongPassword413 }414 return adoptSlotA(key, payload)415 }416417 418419420421422423424425 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {426 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))427 val payload = try {428 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))429 } catch (_: Exception) {430 VaultCrypto.zero(rawKey)431 return null432 }433 434 435 val newSalt = VaultCrypto.generateSalt()436 val newKey = VaultCrypto.deriveKey(password, newSalt)437 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))438 val next = StoredVault(439 version = slotA.version,440 salt = b64(newSalt),441 blob = blob,442 recoveryEscrow = slotA.recoveryEscrow,443 rootKeyEscrow = slotA.rootKeyEscrow,444 )445 446 447 448 val priorB = store.loadSlotB()449 val healed = if (!store.writeSlotA(next)) {450 false451 } else try {452 if (!writeMatchingNoiseSlotB(next)) {453 runCatching { store.writeSlotA(slotA) }454 if (priorB != null) runCatching { store.writeSlotB(priorB) }455 false456 } else {457 store.stampLocalMutation()458 true459 }460 } catch (_: Exception) {461 runCatching { store.writeSlotA(slotA) }462 if (priorB != null) runCatching { store.writeSlotB(priorB) }463 false464 }465 return if (healed) {466 VaultCrypto.zero(rawKey)467 adoptSlotA(newKey, payload) 468 } else {469 VaultCrypto.zero(newKey)470 adoptSlotA(rawKey, payload) 471 }472 }473474 475 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {476 return try {477 478 479 480 481 482 mutateState {483 derivedKey = key484 decoyMode = false485 entryList = payload.entries.toMutableList()486 duressConfigured = payload.duressConfigured487 recoveryWordHash = payload.recoveryWordHash488 recoveryWordsSet = payload.recoveryWordsSet489 _settings.value = store.loadSettings()490 ensureRootKey(payload)491 autoPurgeExpiredTrash()492 publish()493 _isUnlocked.value = true494 }495 UnlockResult.Success496 } catch (e: Exception) {497 lock()498 UnlockResult.Error(e.message ?: "unlock_failed")499 }500 }501502 503504 505506507508509510 class SlotAUnlock internal constructor(511 internal val key: ByteArray,512 internal val payload: VaultPayload,513 )514515 516517518519520521 fun tryUnlockSlotA(password: String): SlotAUnlock? {522 val slotA = store.loadSlotA() ?: return null523 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))524 return try {525 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))526 SlotAUnlock(key, payload)527 } catch (_: Exception) {528 VaultCrypto.zero(key)529 null530 }531 }532533 534 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {535 adoptSlotA(unlock.key, unlock.payload)536 }537538 539 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)540541 542543 544545546547548 class DuressUnlock internal constructor(549 internal val key: ByteArray,550 internal val payload: VaultPayload,551 )552553 554555556557558559560 fun tryUnlockDuress(password: String): DuressUnlock? {561 val slot = store.loadDecoySlot() ?: return null562 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))563 return try {564 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))565 DuressUnlock(key, payload)566 } catch (_: Exception) {567 VaultCrypto.zero(key)568 null569 }570 }571572 573 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {574 return try {575 mutateState {576 derivedKey = unlock.key577 decoyMode = true578 entryList = unlock.payload.entries.toMutableList()579 duressConfigured = false580 recoveryWordHash = unlock.payload.recoveryWordHash581 recoveryWordsSet = unlock.payload.recoveryWordsSet582 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)583 else VaultCrypto.generateRootKey()584 _settings.value = store.loadSettings()585 publish()586 _isUnlocked.value = true587 }588 UnlockResult.Success589 } catch (e: Exception) {590 UnlockResult.Error(e.message ?: "decoy_unlock_failed")591 }592 }593594 595 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)596597 598599600601602603 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {604 605 val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }606 if (!unlockedAndReal) return false607 608 609 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }610 return try {611 val salt = VaultCrypto.generateSalt()612 val key = VaultCrypto.deriveKey(duressPassphrase, salt)613 614 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))615 val plaintext = VaultCodec.encode(decoyPayload)616 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))617 VaultCrypto.zero(key)618 VaultCrypto.zero(plaintext)619 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))620 if (ok) {621 mutateState { duressConfigured = true }622 runCatching { persistSlotA(bumpVersion = false) }623 }624 ok625 } catch (_: Exception) {626 false627 }628 }629630 631 suspend fun clearDuress(): Boolean = mutex.withLock {632 store.clearDecoySlot()633 634 635 val shouldClear = mutateState { derivedKey != null && !decoyMode }636 if (shouldClear) {637 mutateState { duressConfigured = false }638 runCatching { persistSlotA(bumpVersion = false) }639 }640 true641 }642643 644 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {645 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")646 return try {647 val payload = VaultCodec.decode(648 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),649 )650 mutateState {651 derivedKey = key.copyOf()652 decoyMode = false653 entryList = payload.entries.toMutableList()654 duressConfigured = payload.duressConfigured655 recoveryWordHash = payload.recoveryWordHash656 recoveryWordsSet = payload.recoveryWordsSet657 _settings.value = store.loadSettings()658 ensureRootKey(payload)659 autoPurgeExpiredTrash()660 publish()661 _isUnlocked.value = true662 }663 UnlockResult.Success664 } catch (_: Exception) {665 UnlockResult.WrongPassword666 }667 }668669 fun lock() {670 671 672 673 674 stateLock.lock()675 try {676 derivedKey?.let(VaultCrypto::zero)677 rootKey?.let(VaultCrypto::zero)678 derivedKey = null679 rootKey = null680 entryList = mutableListOf()681 duressConfigured = false682 recoveryWordHash = null683 recoveryWordsSet = false684 decoyMode = false685 _entries.value = emptyList()686 _isUnlocked.value = false687 } finally {688 stateLock.unlock()689 }690 }691692 @Volatile private var suppressNextBackgroundLock = false693694 695696697698699700701702703 fun expectTransientBackground() {704 suppressNextBackgroundLock = true705 }706707 708 fun consumeTransientBackgroundSuppression(): Boolean {709 val suppressed = suppressNextBackgroundLock710 suppressNextBackgroundLock = false711 return suppressed712 }713714 715716 717718719720721 722723724725726727728729730731732 733 734 735 736 737 fun syncMasterKey(): ByteArray? =738 if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()739740 741742743744745746747748749 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()750751 752753 fun syncVaultId(): String? =754 if (decoyMode || !_isUnlocked.value) null755 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }756757 758759760761762 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {763 installRootKey(generateRecoverableRootKey())764 }765766 767768769770771772773774775776777778779780781782783 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {784 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {785 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")786 }787 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")788 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")789 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")790791 val incoming = hexToRootKey(rootKeyHex)792 if (incoming.size != VaultCrypto.KEY_SIZE) {793 VaultCrypto.zero(incoming)794 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")795 }796 797 798 799 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess800 if (!roundTrips) {801 VaultCrypto.zero(incoming)802 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")803 }804805 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)806 return if (installRootKey(incoming)) {807 AdoptVaultResult.Success(vaultId)808 } else {809 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")810 }811 }812813 814815816817818 fun previewRecoveryWords(rootKeyHex: String): List<String>? {819 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null820 val bytes = hexToRootKey(rootKeyHex)821 return try {822 val words = rootKeyToWords(bytes)823 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null824 } catch (_: Exception) {825 null826 } finally {827 VaultCrypto.zero(bytes)828 }829 }830831 832833834835836 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {837 838 839 840 841 842 843 data class Snapshot(844 val key: ByteArray,845 val priorRootKey: ByteArray,846 val priorRecoveryHash: String?,847 val priorRecoverySet: Boolean,848 )849 val snap = mutateState {850 val k = derivedKey ?: return false851 if (decoyMode) return false852 val priorRoot = rootKey ?: return false853 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)854 } as Snapshot? ?: return false855 val priorSlot = store.loadSlotA() ?: run {856 VaultCrypto.zero(snap.priorRootKey)857 return false858 }859 val nextWords = rootKeyToWords(nextRootKey)860861 return try {862 takeVaultSnapshot("local_save")863 mutateState {864 rootKey = nextRootKey865 recoveryWordHash = recoveryWordsHash(nextWords)866 recoveryWordsSet = false867 }868869 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)870 ?: throw IllegalStateException("recovery_escrow_failed")871 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)872 ?: throw IllegalStateException("root_escrow_failed")873 val plaintext = VaultCodec.encode(currentPayload())874 val blob = try {875 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))876 } finally {877 VaultCrypto.zero(plaintext)878 }879 val nextSlot = priorSlot.copy(880 blob = blob,881 recoveryEscrow = nextRecoveryEscrow,882 rootKeyEscrow = nextRootKeyEscrow,883 )884 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")885 try {886 writeMatchingNoiseSlotB(nextSlot)887 store.stampLocalMutation()888 } catch (e: Exception) {889 store.writeSlotA(priorSlot)890 throw e891 }892 VaultCrypto.zero(snap.priorRootKey)893 mutateState { publish() }894 true895 } catch (_: Exception) {896 mutateState {897 rootKey = snap.priorRootKey898 recoveryWordHash = snap.priorRecoveryHash899 recoveryWordsSet = snap.priorRecoverySet900 }901 VaultCrypto.zero(nextRootKey)902 store.writeSlotA(priorSlot)903 false904 }905 }906907 908909910911912 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()913914 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {915 val slotA = store.loadSlotA() ?: return false916 var oldKey: ByteArray? = null917 var newKey: ByteArray? = null918 var plaintext: ByteArray? = null919 return try {920 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))921 val payload = VaultCodec.decode(922 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),923 )924 val newSalt = VaultCrypto.generateSalt()925 newKey = VaultCrypto.deriveKey(newPassword, newSalt)926 927 plaintext = VaultCodec.encode(payload)928 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))929 930 931 932 933 934 935 936 val next = StoredVault(937 version = slotA.version,938 salt = b64(newSalt),939 blob = blob,940 recoveryEscrow = slotA.recoveryEscrow,941 rootKeyEscrow = slotA.rootKeyEscrow,942 )943 944 945 946 947 val priorB = store.loadSlotB()948 if (!commitVaultRotation(slotA, priorB, next)) return false949 950 951 952 mutateState {953 oldKey?.let(VaultCrypto::zero); oldKey = null954 derivedKey?.let(VaultCrypto::zero)955 derivedKey = newKey; newKey = null956 }957 true958 } catch (_: Exception) {959 false960 } finally {961 oldKey?.let(VaultCrypto::zero)962 newKey?.let(VaultCrypto::zero)963 plaintext?.let(VaultCrypto::zero)964 }965 }966967 968969970971 suspend fun destroyVault() = mutex.withLock {972 lock()973 store.clearVault()974 store.clearDecoySlot()975 }976977 978979 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {980 requireUnlocked()981 val stamp = VaultCrypto.monotonicEntryTimestamp()982 val now = System.currentTimeMillis()983 val entry = withDerivedLast4(draft.copy(984 id = draft.id.ifBlank { UUID.randomUUID().toString() },985 createdAt = if (draft.createdAt > 0) draft.createdAt else now,986 modifiedAt = stamp,987 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,988 ))989 990 991 val rollback = mutateState {992 val r = entryList.toMutableList()993 entryList.add(entry)994 r995 }996 persistOrRollback(rollback)997 entry998 }9991000 100110021003100410051006100710081009 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1010 requireUnlocked()1011 if (drafts.isEmpty()) return 01012 val now = System.currentTimeMillis()1013 val ready = drafts.map { draft ->1014 val stamp = VaultCrypto.monotonicEntryTimestamp()1015 withDerivedLast4(1016 draft.copy(1017 id = draft.id.ifBlank { UUID.randomUUID().toString() },1018 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1019 modifiedAt = stamp,1020 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1021 ),1022 )1023 }1024 1025 val rollback = mutateState {1026 val r = entryList.toMutableList()1027 entryList.addAll(ready)1028 r1029 }1030 persistOrRollback(rollback)1031 return drafts.size1032 }10331034 1035103610371038103910401041104210431044104510461047 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1048 requireUnlocked()1049 val now = System.currentTimeMillis()1050 val ready = newEntries.map { draft ->1051 val stamp = VaultCrypto.monotonicEntryTimestamp()1052 withDerivedLast4(1053 draft.copy(1054 id = draft.id.ifBlank { UUID.randomUUID().toString() },1055 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1056 modifiedAt = stamp,1057 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1058 ),1059 )1060 }1061 1062 val rollback = mutateState {1063 val r = entryList.toMutableList()1064 val replaced = entryList.map { entry ->1065 if (entry.deletedAt != null) return@map entry1066 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1067 entry.copy(deletedAt = stamp, modifiedAt = stamp)1068 }1069 entryList = (replaced + ready).toMutableList()1070 r1071 }1072 persistOrRollback(rollback)1073 return ready.size1074 }10751076 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1077 requireUnlocked()1078 1079 1080 1081 1082 var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1083 mutateState {1084 val idx = entryList.indexOfFirst { it.id == patch.id }1085 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1086 val current = entryList[idx]1087 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1088 1089 val passwordChanged = patch.password != null && patch.password != current.password1090 1091 1092 1093 1094 1095 1096 val priorPassword = current.password1097 val shouldCapture = passwordChanged &&1098 !priorPassword.isNullOrEmpty() &&1099 current.passwordHistory?.firstOrNull()?.password != priorPassword1100 val nextHistory = if (shouldCapture) {1101 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1102 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1103 } else {1104 current.passwordHistory1105 }1106 val updated = withDerivedLast4(patch.copy(1107 createdAt = current.createdAt,1108 modifiedAt = stamp,1109 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1110 passwordHistory = nextHistory,1111 ))1112 val rollback = entryList.toMutableList()1113 entryList[idx] = updated1114 prepared = updated to rollback1115 }1116 val (updated, rollback) = prepared!!1117 persistOrRollback(rollback)1118 updated1119 }11201121 1122 suspend fun deleteEntry(id: String) = mutex.withLock {1123 requireUnlocked()1124 1125 1126 var rollback: MutableList<VaultEntry>? = null1127 mutateState {1128 val idx = entryList.indexOfFirst { it.id == id }1129 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1130 val current = entryList[idx]1131 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1132 rollback = entryList.toMutableList()1133 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1134 }1135 persistOrRollback(rollback!!)1136 }11371138 suspend fun restoreEntry(id: String) = mutex.withLock {1139 requireUnlocked()1140 var rollback: MutableList<VaultEntry>? = null1141 mutateState {1142 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1143 if (idx == -1) throw IllegalStateException("deleted entry not found")1144 val current = entryList[idx]1145 rollback = entryList.toMutableList()1146 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1147 }1148 persistOrRollback(rollback!!)1149 }11501151 1152 suspend fun purgeEntry(id: String) = mutex.withLock {1153 requireUnlocked()1154 var rollback: MutableList<VaultEntry>? = null1155 mutateState {1156 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1157 if (idx == -1) throw IllegalStateException("deleted entry not found")1158 rollback = entryList.toMutableList()1159 entryList.removeAt(idx)1160 }1161 persistOrRollback(rollback!!)1162 }11631164 1165 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1166 requireUnlocked()1167 1168 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1169 val r = mutateState {1170 val rollback = entryList.toMutableList()1171 val before = entryList.size1172 entryList.removeAll { it.deletedAt != null }1173 R(rollback, before - entryList.size)1174 }1175 if (r.removed > 0) persistOrRollback(r.rollback)1176 r.removed1177 }11781179 1180 suspend fun countDuplicateEntries(): Int = mutex.withLock {1181 requireUnlocked()1182 1183 1184 mutateState { countDuplicateEntriesLocked() }1185 }11861187 11881189119011911192 suspend fun deduplicateEntries(): Int = mutex.withLock {1193 requireUnlocked()1194 1195 1196 val decoy = mutateState { decoyMode }1197 if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1198 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1199 val ready = mutateState {1200 val active = entryList.filter { it.deletedAt == null }1201 val keepIds = mutableSetOf<String>()1202 active.groupBy { it.duplicateKey() }.values.forEach { group ->1203 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1204 }1205 val rollback = entryList.toMutableList()1206 var removed = 01207 entryList = entryList.map { entry ->1208 if (entry.deletedAt == null && entry.id !in keepIds) {1209 removed++1210 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1211 entry.copy(deletedAt = stamp, modifiedAt = stamp)1212 } else {1213 entry1214 }1215 }.toMutableList()1216 R(rollback, removed)1217 }1218 if (ready.removed > 0) persistOrRollback(ready.rollback)1219 ready.removed1220 }12211222 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1223 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1224 fun getDeletedEntries(): List<VaultEntry> =1225 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }12261227 1228 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1229 val active = entryList.filter { it.deletedAt == null }1230 if (tagFilter == null) return active1231 val needle = tagFilter.trim().lowercase()1232 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1233 }12341235 1236 fun getDistinctTags(): List<String> =1237 entryList.asSequence()1238 .filter { it.deletedAt == null }1239 .flatMap { it.tags?.asSequence() ?: emptySequence() }1240 .map { it.trim().lowercase() }1241 .filter { it.isNotEmpty() }1242 .distinct()1243 .sorted()1244 .toList()12451246 124712481249125012511252 fun getDistinctFieldValues(field: String): List<String> {1253 val select: (VaultEntry) -> String? = when (field) {1254 "username" -> { e -> e.username }1255 "email" -> { e -> e.email }1256 else -> return emptyList()1257 }1258 return entryList.asSequence()1259 .filter { it.deletedAt == null }1260 .mapNotNull { select(it)?.trim() }1261 .filter { it.isNotEmpty() }1262 .groupingBy { it.lowercase() }1263 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1264 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1265 }1266 .values1267 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1268 .map { it.first }1269 }12701271 fun getTombstones(): List<Tombstone> =1272 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1273 fun getVaultVersion(): Long = store.version()1274 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots12751276 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1277 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1278 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1279 val priorA = store.loadSlotA()1280 val priorB = store.loadSlotB()1281 1282 1283 1284 1285 1286 val priorHistory = store.loadVaultHistory()1287 1288 1289 1290 1291 if (!takeVaultSnapshot("restore")) {1292 return false1293 }1294 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1295 store.writeVaultHistory(priorHistory)1296 return false1297 }1298 lock()1299 true1300 }13011302 13031304130513061307130813091310 private fun commitRestoreAtomic(1311 nextA: StoredVault,1312 nextB: String,1313 priorA: StoredVault?,1314 priorB: String?,1315 ): Boolean {1316 if (!store.writeSlotA(nextA)) return false1317 1318 1319 1320 try {1321 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1322 store.stampLocalMutation()1323 } catch (_: Exception) {1324 rollbackRestore(priorA, priorB)1325 return false1326 }1327 return true1328 }13291330 13311332 13331334133513361337 suspend fun applyMergedRemoteEntries(1338 remoteEntries: List<VaultEntry>,1339 remoteTombstones: List<Tombstone>,1340 ): Int = mutex.withLock {1341 requireUnlocked()1342 1343 1344 1345 1346 1347 val mergeNow = System.currentTimeMillis()1348 val clamped = remoteEntries.map { entry ->1349 entry.copy(1350 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1351 deletedAt = entry.deletedAt?.let {1352 SyncLimits.clampSyncTimestamp(it, mergeNow)1353 },1354 )1355 }1356 val tombMap = remoteTombstones.associate {1357 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1358 }1359 1360 1361 val applied = mutateState {1362 var n = 01363 val byId = entryList.associateBy { it.id }.toMutableMap()13641365 for (remote in clamped) {1366 val tomb = tombMap[remote.id]1367 if (tomb != null && tomb >= remote.modifiedAt) continue1368 val local = byId[remote.id]1369 if (local == null) {1370 entryList.add(remote); byId[remote.id] = remote; n++1371 } else if (remote.modifiedAt > local.modifiedAt) {1372 val i = entryList.indexOfFirst { it.id == local.id }1373 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1374 }1375 }13761377 for ((id, deletedAt) in tombMap) {1378 val local = byId[id] ?: continue1379 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1380 if (deletedAt < local.modifiedAt) continue1381 val i = entryList.indexOfFirst { it.id == id }1382 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1383 }1384 n1385 }13861387 if (applied > 0) {1388 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1389 mutateState { publish() }1390 }1391 applied1392 }13931394 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1395 1396 1397 1398 mutateState { _settings.value = settings }1399 store.writeSettings(settings)1400 }14011402 14031404 1405 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet14061407 14081409141014111412 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1413 requireUnlocked()1414 1415 1416 1417 1418 1419 1420 val prior = mutateState {1421 val priorPair = recoveryWordHash to recoveryWordsSet1422 recoveryWordHash = recoveryWordsHash(words)1423 recoveryWordsSet = true1424 priorPair1425 }1426 1427 1428 1429 1430 val persisted = runCatching {1431 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1432 }.getOrDefault(false)1433 if (!persisted) {1434 mutateState {1435 recoveryWordHash = prior.first1436 recoveryWordsSet = prior.second1437 }1438 }1439 persisted1440 }14411442 1443 fun verifyRecoveryWords(words: List<String>): Boolean {1444 val stored = recoveryWordHash ?: return false1445 return stored == recoveryWordsHash(words)1446 }14471448 14491450 1451 fun hasRecoveryEscrow(): Boolean =1452 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false14531454 145514561457145814591460146114621463146414651466 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1467 val slotA = store.loadSlotA() ?: return false1468 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false14691470 1471 1472 slotA.recoveryEscrow?.let { escrow ->1473 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1474 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1475 val keyBytes = recoveryKey.encoded1476 try {1477 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1478 val payload = VaultCodec.decode(plaintext)1479 1480 mutateState {1481 decoyMode = false1482 entryList = payload.entries.toMutableList()1483 duressConfigured = payload.duressConfigured1484 recoveryWordHash = payload.recoveryWordHash1485 recoveryWordsSet = payload.recoveryWordsSet1486 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1487 else generateRecoverableRootKey()1488 _settings.value = store.loadSettings()1489 }1490 VaultCrypto.zero(plaintext)1491 return true1492 } catch (_: Exception) {1493 1494 } finally {1495 VaultCrypto.zero(material)1496 VaultCrypto.zero(keyBytes)1497 }1498 }14991500 1501 1502 1503 slotA.rootKeyEscrow?.let { escrow ->1504 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1505 mutateState {1506 decoyMode = false1507 entryList = mutableListOf()1508 duressConfigured = false1509 recoveryWordHash = recoveryWordsHash(words)1510 recoveryWordsSet = true1511 rootKey = rk1512 _settings.value = store.loadSettings()1513 }1514 return true1515 }1516 return false1517 }15181519 152015211522152315241525152615271528 suspend fun resetPasswordWithRecoveryWords(1529 words: List<String>,1530 newPassword: String,1531 ): Boolean = mutex.withLock {1532 1533 1534 1535 if (!mutateState { rootKey != null }) return false1536 val priorA = store.loadSlotA() ?: return false1537 var newKey: ByteArray? = null1538 var plaintext: ByteArray? = null1539 return try {1540 val newSalt = VaultCrypto.generateSalt()1541 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1542 plaintext = VaultCodec.encode(currentPayload())1543 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1544 1545 1546 1547 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1548 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1549 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1550 val next = StoredVault(1551 version = priorA.version,1552 salt = b64(newSalt),1553 blob = blob,1554 recoveryEscrow = freshRecovery,1555 rootKeyEscrow = freshRootKey,1556 )1557 1558 1559 1560 val priorB = store.loadSlotB()1561 if (!commitVaultRotation(priorA, priorB, next)) return false1562 1563 1564 1565 mutateState {1566 derivedKey?.let(VaultCrypto::zero)1567 derivedKey = newKey; newKey = null1568 publish()1569 _isUnlocked.value = true1570 }1571 true1572 } catch (_: Exception) {1573 false1574 } finally {1575 newKey?.let(VaultCrypto::zero)1576 plaintext?.let(VaultCrypto::zero)1577 }1578 }15791580 158115821583158415851586 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1587 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1588 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1589 val salt = VaultCrypto.generateSalt()1590 val recoveryKey = deriveRecoveryKey(material, salt)1591 val keyBytes = recoveryKey.encoded1592 val plaintext = VaultCodec.encode(currentPayload())1593 return try {1594 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1595 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1596 } finally {1597 VaultCrypto.zero(material)1598 VaultCrypto.zero(keyBytes)1599 VaultCrypto.zero(plaintext)1600 }1601 }16021603 16041605160616071608160916101611 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1612 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1613 val rk = rootKey ?: return null1614 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1615 val salt = VaultCrypto.generateSalt()1616 val recoveryKey = deriveRecoveryKey(material, salt)1617 val keyBytes = recoveryKey.encoded1618 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1619 return try {1620 1621 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1622 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1623 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1624 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1625 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1626 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1627 } finally {1628 VaultCrypto.zero(material)1629 VaultCrypto.zero(keyBytes)1630 VaultCrypto.zero(plaintext)1631 }1632 }16331634 1635 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1636 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1637 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1638 val keyBytes = recoveryKey.encoded1639 var pt: ByteArray? = null1640 return try {1641 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1642 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1643 hexToRootKey(String(pt, Charsets.US_ASCII))1644 } catch (_: Exception) {1645 null1646 } finally {1647 1648 1649 1650 VaultCrypto.zero(material)1651 VaultCrypto.zero(keyBytes)1652 pt?.let(VaultCrypto::zero)1653 }1654 }16551656 165716581659166016611662 private fun persistSlotAWithEscrows(1663 recoveryEscrow: RecoveryEscrow?,1664 rootKeyEscrow: RootKeyEscrow?,1665 ): Boolean {1666 val key = derivedKey ?: throw IllegalStateException("vault_locked")1667 if (decoyMode) return false1668 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1669 val plaintext = VaultCodec.encode(currentPayload())1670 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1671 val next = prior.copy(1672 blob = blob,1673 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1674 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1675 )1676 if (!store.writeSlotA(next)) return false1677 try {1678 writeMatchingNoiseSlotB(next)1679 } catch (e: Exception) {1680 store.writeSlotA(prior)1681 throw e1682 }1683 return true1684 }16851686 16871688 private fun requireUnlocked() {1689 1690 1691 1692 stateLock.lock()1693 try {1694 if (derivedKey == null) throw IllegalStateException("vault_locked")1695 } finally {1696 stateLock.unlock()1697 }1698 }16991700 private fun countDuplicateEntriesLocked(): Int {1701 val seen = mutableMapOf<String, VaultEntry>()1702 var duplicates = 01703 for (entry in entryList.filter { it.deletedAt == null }) {1704 val key = entry.duplicateKey()1705 val existing = seen[key]1706 if (existing == null) {1707 seen[key] = entry1708 } else {1709 duplicates++1710 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1711 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1712 if (entryStamp > existingStamp) seen[key] = entry1713 }1714 }1715 return duplicates1716 }17171718 private fun VaultEntry.duplicateKey(): String {1719 if (type == "note") return "note|${title.lowercase()}"1720 val passkeyData = passkey1721 if (passkeyData != null) {1722 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1723 }1724 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1725 }17261727 172817291730173117321733 private fun takeVaultSnapshot(reason: String): Boolean {1734 val slotA = store.loadSlotA() ?: return false1735 val vaultA = VaultCodec.encodeSlot(slotA)1736 val vaultB = store.loadSlotB() ?: slotA.blob1737 val history = store.loadVaultHistory()1738 val latest = history.snapshots.firstOrNull()1739 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1740 val entryCount = if (derivedKey != null && !decoyMode) {1741 bucketEntryCount(entryList.count { it.deletedAt == null })1742 } else {1743 null1744 }1745 return try {1746 val snapshot = VaultSnapshot(1747 id = UUID.randomUUID().toString(),1748 savedAt = System.currentTimeMillis(),1749 reason = reason,1750 vault_a = vaultA,1751 vault_b = vaultB,1752 entryCount = entryCount,1753 )1754 1755 1756 1757 store.writeVaultHistory(1758 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1759 )1760 } catch (_: Exception) {1761 false1762 }1763 }17641765 private fun bucketEntryCount(n: Int): Int = when {1766 n <= 0 -> 01767 n <= 10 -> 101768 n <= 25 -> 251769 n <= 50 -> 501770 n <= 100 -> 1001771 n <= 250 -> 2501772 n <= 500 -> 5001773 else -> ((n + 499) / 500) * 5001774 }17751776 17771778177917801781 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1782 if (!entry.isCard()) return entry1783 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1784 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1785 }17861787 178817891790179117921793 private fun autoPurgeExpiredTrash() {1794 val days = _settings.value.trashRetentionDays ?: return1795 if (days <= 0) return1796 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1797 val before = entryList.size1798 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1799 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1800 }18011802 1803 private fun currentPayload(): VaultPayload =1804 VaultPayload(1805 entries = entryList.toList(),1806 duressConfigured = duressConfigured,1807 rootKeyB64 = rootKey?.let(::b64) ?: "",1808 recoveryWordHash = recoveryWordHash,1809 recoveryWordsSet = recoveryWordsSet,1810 )18111812 18131814181518161817 private fun ensureRootKey(payload: VaultPayload) {1818 if (payload.rootKeyB64.isNotBlank()) {1819 rootKey = unb64(payload.rootKeyB64)1820 return1821 }1822 rootKey = generateRecoverableRootKey()1823 runCatching { persistSlotA(bumpVersion = false) }1824 }18251826 private fun publish() {1827 _entries.value = entryList.filter { it.deletedAt == null }1828 }18291830 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1831 var plaintext = VaultCodec.encode(currentPayload())1832 return try {1833 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1834 StoredVault(version = 1, salt = b64(salt), blob = blob)1835 } finally {1836 1837 1838 1839 VaultCrypto.zero(plaintext)1840 }1841 }18421843 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1844 1845 1846 1847 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1848 val noise = ByteArray(approxBytes).also(rng::nextBytes)1849 return store.writeSlotB(b64(noise))1850 }18511852 1853 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1854 val key = derivedKey ?: throw IllegalStateException("vault_locked")1855 var plaintext: ByteArray? = null1856 return try {1857 1858 if (decoyMode) {1859 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1860 plaintext = VaultCodec.encode(currentPayload())1861 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1862 plaintext = null1863 return store.writeDecoySlot(prior.copy(blob = blob))1864 }1865 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1866 1867 1868 1869 1870 val priorB = store.loadSlotB()1871 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1872 plaintext = VaultCodec.encode(currentPayload())1873 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1874 plaintext = null1875 val next = prior.copy(blob = blob)1876 if (!store.writeSlotA(next)) return false1877 1878 1879 1880 1881 try {1882 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1883 if (bumpVersion) store.stampLocalMutation()1884 } catch (e: Exception) {1885 store.writeSlotA(prior)1886 if (priorB != null) runCatching { store.writeSlotB(priorB) }1887 throw e1888 }1889 true1890 } finally {1891 1892 1893 1894 plaintext?.let(VaultCrypto::zero)1895 }1896 }18971898 1899190019011902190319041905 private fun commitVaultRotation(1906 priorA: StoredVault,1907 priorB: String?,1908 nextA: StoredVault,1909 ): Boolean {1910 if (!store.writeSlotA(nextA)) return false1911 1912 1913 1914 try {1915 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1916 store.stampLocalMutation()1917 } catch (_: Exception) {1918 rollbackRotation(priorA, priorB)1919 return false1920 }1921 return true1922 }19231924 19251926192719281929 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1930 val failures = mutableListOf<String>()1931 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1932 .onFailure { failures += "writeSlotA: $it" }1933 if (priorB != null) {1934 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1935 .onFailure { failures += "writeSlotB: $it" }1936 }1937 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1938 }19391940 1941 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1942 try {1943 if (!persistSlotA(bumpVersion = true)) {1944 mutateState { entryList = rollback }1945 throw IllegalStateException("storage_write_failed")1946 }1947 } catch (e: Exception) {1948 mutateState { entryList = rollback }1949 throw e1950 }1951 mutateState { publish() }1952 }1953}