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.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton252627private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)313233private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 535363738394041private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44 data object Success : UnlockResult45 data object WrongPassword : UnlockResult46 data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50 data object Success : CreateVaultResult51 data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55 data class Success(val vaultId: String) : AdoptVaultResult56 data class Error(val reason: String) : AdoptVaultResult57}585960616263646566676869707172@Singleton73class VaultRepository @Inject constructor(74 private val store: VaultStorage,75) {76 private val mutex = Mutex()77 78 79 80 81 82 83 84 private val stateLock = ReentrantLock()85 private val rng = SecureRandom()8687 8889909192 private var sessionEpoch: Long = 0L9394 private fun nextSessionEpoch(epoch: Long): Long =95 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697 98 @VisibleForTesting99 internal var beforeDurableCommit: () -> Unit = {}100101 102 @VisibleForTesting103 internal var afterCodexRollback: () -> Unit = {}104105 106107108109110 private inline fun <T> mutateState(block: () -> T): T {111 stateLock.lock()112 return try { block() } finally { stateLock.unlock() }113 }114115 116 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117 stateLock.lock()118 return try {119 if (sessionEpoch != epoch) null else block()120 } finally {121 stateLock.unlock()122 }123 }124125 126 @VisibleForTesting127 internal var beforeSessionEpochCommit: () -> Unit = {}128129 130131132133134 @VisibleForTesting135 internal var beforeSessionStateMutation: () -> Unit = {}136137 138139140141142143 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144 beforeSessionEpochCommit()145 return withEpochCommit(epoch, block)146 }147148 private fun currentEpoch(): Long = mutateState { sessionEpoch }149150 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151 mutateState { if (sessionEpoch == epoch) block() else null }152153 154 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155 beforeSessionStateMutation()156 return mutateIfCurrentEpoch(epoch, block)157 }158159 private data class PersistSnapshot(160 val epoch: Long,161 val key: ByteArray,162 val payload: VaultPayload,163 val decoy: Boolean,164 )165166 167 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169 val liveKey = derivedKey ?: return@mutateState null170 PersistSnapshot(171 epoch = sessionEpoch,172 key = liveKey.copyOf(),173 payload = currentPayloadLocked(),174 decoy = decoyMode,175 )176 }177178 private var derivedKey: ByteArray? = null179 private var rootKey: ByteArray? = null180 private var entryList: MutableList<VaultEntry> = mutableListOf()181 private var duressConfigured: Boolean = false182 private var recoveryWordHash: String? = null183 private var recoveryWordsSet: Boolean = false184 185 private var sessionDataAvailable: Boolean = false186 187 private var decoyMode: Boolean = false188189 private val _isUnlocked = MutableStateFlow(false)190 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192193 194195196197198199 private val _uiGateLocked = MutableStateFlow(false)200 val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()201202 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())203 204 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()205206 private val _settings = MutableStateFlow(VaultSettings())207 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()208209 210 private fun clearSessionStateLocked() {211 derivedKey?.let(VaultCrypto::zero)212 rootKey?.let(VaultCrypto::zero)213 derivedKey = null214 rootKey = null215 entryList = mutableListOf()216 duressConfigured = false217 recoveryWordHash = null218 recoveryWordsSet = false219 sessionDataAvailable = false220 decoyMode = false221 _settings.value = VaultSettings()222 _entries.value = emptyList()223 _isUnlocked.value = false224 }225226 fun hasVault(): Boolean = store.hasVault()227228 229230 231232233234235236237 fun codexVaultBlobBase64(): String? {238 val slotA = store.loadSlotA() ?: return null239 240 241 val slotD = store.loadDecoySlot() ?: return null242 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""243 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))244 }245246 247248249250251 252 private fun JsonElement?.asSlotJson(): String? = when {253 this == null || this is JsonNull -> null254 this is JsonPrimitive && isString -> content255 else -> toString()256 }257258 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {259 val expectedEpoch = currentEpoch()260 var candidateKey: ByteArray? = null261 var plaintext: ByteArray? = null262 return try {263 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)264 265 266 267 268 269 270 271 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject272 ?: return UnlockResult.Error("invalid_codex_blob")273 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")274 275 276 277 278 279 val slotA = VaultCodec.decodeSlot(slotJson)280 ?: BrowserSlot.parse(slotJson)?.let {281 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword282 }283 ?: return UnlockResult.Error("invalid_codex_blob")284 285 286 287 288 289 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))290 plaintext = decryptSlotABlob(slotA, candidateKey)291 292 293 294 295 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {296 VaultCrypto.zero(candidateKey)297 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))298 plaintext = decryptSlotABlob(slotA, candidateKey)299 }300 if (plaintext == null) return UnlockResult.WrongPassword301 val payload = try {302 VaultCodec.decode(plaintext)303 } catch (_: Exception) {304 return UnlockResult.Error("invalid_codex_blob")305 }306307 val priorA = store.loadSlotA()308 val priorB = store.loadSlotB()309 val priorD = store.loadDecoySlot()310 311 312 313 314 315 val codexDuressJson = wrapper["b"].asSlotJson()316 val codexDuress: StoredVault? = codexDuressJson?.let {317 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->318 319 320 321 322 323 324 browserDuressPassthrough(b)325 }326 }327328 beforeDurableCommit()329 val committed = runCatching {330 withEpochCommit(expectedEpoch) {331 try {332 333 334 335 336 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")337 338 339 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")340 341 342 val duressOk = if (codexDuress != null) {343 store.writeDecoySlot(codexDuress)344 } else {345 writeFakeDuressSlot(slotA)346 }347 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")348 store.stampLocalMutation()349 true350 } catch (_: Exception) {351 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))352 353 354 355 afterCodexRollback()356 false357 }358 } ?: false359 }.getOrDefault(false)360 if (!committed) {361 return UnlockResult.Error("storage_write_failed")362 }363364 val ownedKey = candidateKey!!365 candidateKey = null 366 adoptSlotA(ownedKey, payload, expectedEpoch)367 } catch (e: Exception) {368 369 370 371 372 373 374 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))375 UnlockResult.Error(e.message ?: "restore_failed")376 } finally {377 plaintext?.let(VaultCrypto::zero)378 candidateKey?.let(VaultCrypto::zero)379 }380 }381382 383 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =384 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()385386 387388389390391392393394395 private fun rollbackRestoreLocked(396 priorA: StoredVault?,397 priorB: String?,398 priorD: StoredVault? = null,399 ): List<String> {400 val failures = mutableListOf<String>()401 if (priorA == null) {402 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }403 } else {404 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }405 .onFailure { failures += "writeSlotA: $it" }406 if (priorB != null) {407 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }408 .onFailure { failures += "writeSlotB: $it" }409 }410 411 412 413 if (priorD != null) {414 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }415 .onFailure { failures += "writeDecoySlot: $it" }416 } else {417 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }418 }419 }420 return failures421 }422423 private fun reportRollbackFailures(failures: List<String>) {424 425 426 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }427 }428429 430 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {431 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)432 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()433 val window = offset?.let {434 val from = (it - 40).coerceAtLeast(0)435 val to = (it + 40).coerceAtMost(wrapperJson.length)436 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")437 }438 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()439 val slot = wrapper?.get("a")440 buildString {441 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)442 append(" | blobBase64=").append(vaultBlobBase64.length)443 append(" wrapperChars=").append(wrapperJson.length)444 append(" wrapperParses=").append(wrapper != null)445 append(" keys=").append(wrapper?.keys)446 append(" slotAKind=").append(447 when {448 slot == null -> "absent"449 slot is JsonPrimitive && slot.isString -> "string"450 slot is JsonObject -> "object:" + slot.keys451 else -> slot::class.simpleName452 },453 )454 if (window != null) append(" masked@").append(offset).append("=").append(window)455 }456 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }457458 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {459 val expectedEpoch = currentEpoch()460 var key: ByteArray? = null461 return try {462 val salt = VaultCrypto.generateSalt()463 key = VaultCrypto.deriveKey(password, salt)464 if (mutateIfCurrentEpoch(expectedEpoch) {465 entryList = mutableListOf()466 duressConfigured = false467 recoveryWordHash = null468 recoveryWordsSet = false469 decoyMode = false470 sessionDataAvailable = false471 rootKey = generateRecoverableRootKey()472 } == null) return CreateVaultResult.Error("stale_create")473 474 475 476 477 val slotA = sealSlotA(key!!, salt)478 479 480 beforeDurableCommit()481 val created = withEpochCommit(expectedEpoch) {482 if (!store.writeSlotA(slotA)) return@withEpochCommit false483 try {484 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")485 486 487 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")488 store.stampLocalMutation()489 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")490 true491 } catch (_: Exception) {492 493 494 store.clearVault()495 false496 }497 } ?: false498 if (!created) {499 lock()500 return CreateVaultResult.Error("storage_write_failed")501 }502 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")503 if (mutateIfCurrentEpoch(expectedEpoch) {504 derivedKey = sessionKey505 key = null506 publish()507 _isUnlocked.value = true508 sessionDataAvailable = true509 sessionEpoch = nextSessionEpoch(sessionEpoch)510 } == null) return CreateVaultResult.Error("stale_create")511 CreateVaultResult.Success512 } catch (e: Exception) {513 lock()514 CreateVaultResult.Error(e.message ?: "create_failed")515 } finally {516 key?.let(VaultCrypto::zero)517 }518 }519520 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {521 val expectedEpoch = currentEpoch()522 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")523 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")524 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {525 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")526 }527 var adoptedRootKey: ByteArray? = null528 var nextDerivedKey: ByteArray? = null529 var sessionRootKey: ByteArray? = null530 return try {531 adoptedRootKey = hexToRootKey(rootKeyHex)532 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {533 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")534 }535 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)536 val salt = VaultCrypto.generateSalt()537 nextDerivedKey = VaultCrypto.deriveKey(password, salt)538 sessionRootKey = adoptedRootKey.copyOf()539 val payload = VaultPayload(540 entries = emptyList(),541 duressConfigured = false,542 rootKeyB64 = b64(sessionRootKey),543 recoveryWordHash = null,544 recoveryWordsSet = false,545 )546 val plaintext = VaultCodec.encode(payload)547 val blob = try {548 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))549 } finally {550 VaultCrypto.zero(plaintext)551 }552 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)553 554 555 beforeDurableCommit()556 val created = withEpochCommit(expectedEpoch) {557 if (!store.writeSlotA(slotA)) return@withEpochCommit false558 try {559 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")560 561 562 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")563 store.stampLocalMutation()564 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")565 true566 } catch (_: Exception) {567 store.clearVault()568 false569 }570 } ?: false571 if (!created) return AdoptVaultResult.Error("storage_write_failed")572573 val adopted = mutateIfCurrentEpoch(expectedEpoch) {574 val previousDerivedKey = derivedKey575 val previousRootKey = rootKey576 derivedKey = nextDerivedKey577 rootKey = sessionRootKey578 nextDerivedKey = null579 sessionRootKey = null580 entryList = mutableListOf()581 duressConfigured = false582 recoveryWordHash = null583 recoveryWordsSet = false584 decoyMode = false585 sessionDataAvailable = true586 publish()587 _isUnlocked.value = true588 sessionEpoch = nextSessionEpoch(sessionEpoch)589 previousDerivedKey to previousRootKey590 }591 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")592 val (previousDerivedKey, previousRootKey) = adopted593 previousDerivedKey?.let(VaultCrypto::zero)594 previousRootKey?.let(VaultCrypto::zero)595 596 597 598 runCatching {599 val words = rootKeyToWords(rootKey!!)600 val escrow = buildRecoveryEscrow(words)601 val rkEscrow = buildRootKeyEscrow(words)602 if (escrow != null || rkEscrow != null) {603 val prior = store.loadSlotA()604 if (prior != null) {605 store.writeSlotA(prior.copy(606 recoveryEscrow = escrow ?: prior.recoveryEscrow,607 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,608 ))609 }610 mutateState {611 recoveryWordHash = recoveryWordsHash(words)612 recoveryWordsSet = true613 }614 }615 }616 AdoptVaultResult.Success(vaultId)617 } catch (e: Exception) {618 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")619 } finally {620 adoptedRootKey?.let(VaultCrypto::zero)621 nextDerivedKey?.let(VaultCrypto::zero)622 sessionRootKey?.let(VaultCrypto::zero)623 }624 }625626 suspend fun unlock(password: String): UnlockResult = mutex.withLock {627 val result = unlockLocked(password, currentEpoch())628 if (result is UnlockResult.Success) ensureRecoveryEscrow()629 result630 }631632 633 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {634 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")635 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))636 val payload = try {637 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)638 try {639 VaultCodec.decode(plaintext)640 } finally {641 VaultCrypto.zero(plaintext)642 }643 } catch (_: Exception) {644 VaultCrypto.zero(key)645 646 647 648 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {649 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)650 if (legacy != null) return legacy651 }652 return UnlockResult.WrongPassword653 }654 return adoptSlotA(key, payload, expectedEpoch)655 }656657 658659660661662663664665 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {666 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))667 var newKey: ByteArray? = null668 return try {669 val payload = try {670 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)671 try {672 VaultCodec.decode(plaintext)673 } finally {674 VaultCrypto.zero(plaintext)675 }676 } catch (_: Exception) {677 return null678 }679 680 681 val newSalt = VaultCrypto.generateSalt()682 newKey = VaultCrypto.deriveKey(password, newSalt)683 val plaintext = VaultCodec.encode(payload)684 val blob = try {685 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))686 } finally {687 VaultCrypto.zero(plaintext)688 }689 val next = StoredVault(690 version = slotA.version,691 salt = b64(newSalt),692 blob = blob,693 recoveryEscrow = slotA.recoveryEscrow,694 rootKeyEscrow = slotA.rootKeyEscrow,695 )696 697 698 699 val priorB = store.loadSlotB()700 beforeDurableCommit()701 val healed = withEpochCommit(expectedEpoch) {702 if (!store.writeSlotA(next)) {703 false704 } else try {705 if (!writeMatchingNoiseSlotB(next)) {706 runCatching { store.writeSlotA(slotA) }707 if (priorB != null) runCatching { store.writeSlotB(priorB) }708 false709 } else if (!writeCompanionDuressSlot(next)) {710 runCatching { store.writeSlotA(slotA) }711 if (priorB != null) runCatching { store.writeSlotB(priorB) }712 false713 } else {714 store.stampLocalMutation()715 true716 }717 } catch (_: Exception) {718 runCatching { store.writeSlotA(slotA) }719 if (priorB != null) runCatching { store.writeSlotB(priorB) }720 false721 }722 } ?: false723 if (healed) {724 VaultCrypto.zero(rawKey!!)725 rawKey = null726 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 727 newKey = null728 result729 } else {730 VaultCrypto.zero(newKey!!)731 newKey = null732 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 733 rawKey = null734 result735 }736 } finally {737 738 739 rawKey?.let(VaultCrypto::zero)740 newKey?.let(VaultCrypto::zero)741 }742 }743744 745 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {746 return try {747 748 749 750 751 752 val adopted = mutateIfCurrentEpoch(expectedEpoch) {753 derivedKey = key754 decoyMode = false755 entryList = payload.entries.toMutableList()756 duressConfigured = payload.duressConfigured757 recoveryWordHash = payload.recoveryWordHash758 recoveryWordsSet = payload.recoveryWordsSet759 sessionDataAvailable = true760 _settings.value = store.loadSettings()761 ensureRootKey(payload)762 autoPurgeExpiredTrash()763 publish()764 _isUnlocked.value = true765 766 767 768 sessionEpoch = nextSessionEpoch(sessionEpoch)769 }770 if (adopted == null) {771 VaultCrypto.zero(key)772 UnlockResult.Error("stale_unlock")773 } else {774 UnlockResult.Success775 }776 } catch (e: Exception) {777 778 779 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }780 VaultCrypto.zero(key)781 UnlockResult.Error(e.message ?: "unlock_failed")782 }783 }784785 786787 788789790791792793 class SlotAUnlock internal constructor(794 key: ByteArray,795 internal val payload: VaultPayload,796 internal val epoch: Long,797 ) {798 private var ownedKey: ByteArray? = key799 private var consumed = false800801 802 internal fun takeKey(): ByteArray? = synchronized(this) {803 if (consumed) return@synchronized null804 consumed = true805 ownedKey.also { ownedKey = null }806 }807808 809 internal fun discard() = synchronized(this) {810 if (consumed) return@synchronized811 consumed = true812 ownedKey?.let(VaultCrypto::zero)813 ownedKey = null814 }815 }816817 818819820821822823 fun tryUnlockSlotA(password: String): SlotAUnlock? {824 val epoch = currentEpoch()825 val slotA = store.loadSlotA() ?: return null826 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))827 return try {828 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)829 val payload = try {830 VaultCodec.decode(plaintext)831 } finally {832 VaultCrypto.zero(plaintext)833 }834 SlotAUnlock(key, payload, epoch)835 } catch (_: Exception) {836 VaultCrypto.zero(key)837 null838 }839 }840841 842 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {843 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")844 adoptSlotA(key, unlock.payload, unlock.epoch)845 }846847 848 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()849850 851852 853854855856857 class DuressUnlock internal constructor(858 key: ByteArray,859 internal val payload: VaultPayload,860 internal val epoch: Long,861 ) {862 private var ownedKey: ByteArray? = key863 private var consumed = false864865 internal fun takeKey(): ByteArray? = synchronized(this) {866 if (consumed) return@synchronized null867 consumed = true868 ownedKey.also { ownedKey = null }869 }870871 internal fun discard() = synchronized(this) {872 if (consumed) return@synchronized873 consumed = true874 ownedKey?.let(VaultCrypto::zero)875 ownedKey = null876 }877 }878879 880881882883884885886 fun tryUnlockDuress(password: String): DuressUnlock? {887 val epoch = currentEpoch()888 val slot = store.loadDecoySlot() ?: return null889 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))890 return try {891 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)892 val payload = try {893 VaultCodec.decode(plaintext)894 } finally {895 VaultCrypto.zero(plaintext)896 }897 DuressUnlock(key, payload, epoch)898 } catch (_: Exception) {899 VaultCrypto.zero(key)900 null901 }902 }903904 905 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {906 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")907 if (currentEpoch() != unlock.epoch) {908 VaultCrypto.zero(key)909 return@withLock UnlockResult.Error("stale_unlock")910 }911 return try {912 val adopted = mutateIfCurrentEpoch(unlock.epoch) {913 derivedKey = key914 decoyMode = true915 entryList = unlock.payload.entries.toMutableList()916 duressConfigured = false917 recoveryWordHash = unlock.payload.recoveryWordHash918 recoveryWordsSet = unlock.payload.recoveryWordsSet919 sessionDataAvailable = true920 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)921 else VaultCrypto.generateRootKey()922 _settings.value = store.loadSettings()923 publish()924 _isUnlocked.value = true925 sessionEpoch = nextSessionEpoch(sessionEpoch)926 }927 if (adopted == null) {928 VaultCrypto.zero(key)929 UnlockResult.Error("stale_unlock")930 } else {931 UnlockResult.Success932 }933 } catch (e: Exception) {934 935 936 937 938 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }939 VaultCrypto.zero(key)940 UnlockResult.Error(e.message ?: "decoy_unlock_failed")941 }942 }943944 945 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()946947 948949950951952953 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {954 955 val expectedEpoch = mutateState {956 if (derivedKey != null && !decoyMode) sessionEpoch else null957 } ?: return false958 959 960 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }961 var key: ByteArray? = null962 var basePlaintext: ByteArray? = null963 return try {964 val salt = VaultCrypto.generateSalt()965 key = VaultCrypto.deriveKey(duressPassphrase, salt)966 967 val decoyRoot = VaultCrypto.generateRootKey()968 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }969 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)970 basePlaintext = VaultCodec.encode(decoyPayload)971 972 973 974 975 976 977 978 979 val slotA = store.loadSlotA()980 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0981 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!982 val blob = try {983 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))984 } finally {985 VaultCrypto.zero(plaintext)986 987 988 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)989 basePlaintext = null990 key?.let(VaultCrypto::zero)991 key = null992 }993 beforeDurableCommit()994 val ok = withEpochCommit(expectedEpoch) {995 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))996 } ?: false997 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {998 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }999 } else if (ok) {1000 return false1001 }1002 ok1003 } catch (_: Exception) {1004 false1005 } finally {1006 1007 1008 key?.let(VaultCrypto::zero)1009 basePlaintext?.let(VaultCrypto::zero)1010 }1011 }101210131014 101510161017101810191020102110221023 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1024 if (!duressConfigured) return null1025 val slot = store.loadDecoySlot() ?: return null1026 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1027 }10281029 1030103110321033103410351036 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1037 1038 1039 1040 1041 1042 var salt: ByteArray? = null1043 var blob: ByteArray? = null1044 var nonce: ByteArray? = null1045 var ctAndTag: ByteArray? = null1046 var ct: ByteArray? = null1047 var tag: ByteArray? = null1048 try {1049 salt = unb64(slot.salt)1050 blob = unb64(slot.blob)1051 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1052 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1053 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1054 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1055 val obj = JsonObject(mapOf(1056 "version" to JsonPrimitive(1),1057 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1058 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1059 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1060 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1061 ))1062 return obj.toString()1063 } finally {1064 salt?.let(VaultCrypto::zero)1065 blob?.let(VaultCrypto::zero)1066 nonce?.let(VaultCrypto::zero)1067 ctAndTag?.let(VaultCrypto::zero)1068 ct?.let(VaultCrypto::zero)1069 tag?.let(VaultCrypto::zero)1070 }1071 }10721073 1074107510761077107810791080108110821083 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1084 val b = BrowserSlot.parse(json) ?: return false1085 var stored: StoredVault? = null1086 var salt: ByteArray? = null1087 var nonce: ByteArray? = null1088 var ct: ByteArray? = null1089 var tag: ByteArray? = null1090 var blob: ByteArray? = null1091 try {1092 salt = unhex(b.salt)1093 nonce = unhex(b.nonce)1094 ct = unhex(b.ciphertext)1095 tag = unhex(b.tag)1096 blob = ByteArray(nonce.size + ct.size + tag.size).also {1097 System.arraycopy(nonce, 0, it, 0, nonce.size)1098 System.arraycopy(ct, 0, it, nonce.size, ct.size)1099 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1100 }1101 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1102 val expectedEpoch = sessionEpoch1103 beforeDurableCommit()1104 withEpochCommit(expectedEpoch) {1105 store.writeDecoySlot(stored)1106 } ?: false1107 } catch (_: Exception) {1108 false1109 } finally {1110 salt?.let(VaultCrypto::zero)1111 nonce?.let(VaultCrypto::zero)1112 ct?.let(VaultCrypto::zero)1113 tag?.let(VaultCrypto::zero)1114 blob?.let(VaultCrypto::zero)1115 }1116 }11171118 1119 suspend fun clearDuress(): Boolean = mutex.withLock {1120 1121 1122 1123 1124 val expectedEpoch = currentEpoch()1125 val replaced = withEpochCommit(expectedEpoch) {1126 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1127 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1128 } ?: false1129 if (!replaced) return@withLock false1130 1131 1132 if (!isSessionEpochCurrent(expectedEpoch)) return false1133 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1134 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1135 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1136 }1137 true1138 }11391140 1141 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1142 var current: Throwable? = error1143 while (current != null) {1144 if (current is javax.crypto.AEADBadTagException) return true1145 1146 1147 1148 1149 if (current is javax.crypto.BadPaddingException) return true1150 current = current.cause1151 }1152 return false1153 }11541155 1156 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1157 val expectedEpoch = currentEpoch()1158 val slotA = try {1159 store.loadSlotA()1160 } catch (_: Exception) {1161 return@withLock UnlockResult.Error("unlock_key_storage_failed")1162 } ?: return@withLock UnlockResult.Error("no_vault")1163 1164 1165 1166 1167 var stagedKey: ByteArray? = key.copyOf()1168 return try {1169 val encrypted = try {1170 unb64(slotA.blob)1171 } catch (_: Exception) {1172 return@withLock UnlockResult.Error("invalid_vault_blob")1173 }1174 1175 1176 1177 1178 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1179 return@withLock UnlockResult.Error("invalid_vault_blob")1180 }1181 val plaintext = try {1182 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1183 } catch (e: Exception) {1184 if (isGcmAuthenticationFailure(e)) {1185 return@withLock UnlockResult.WrongPassword1186 }1187 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1188 }1189 val payload = try {1190 VaultCodec.decode(plaintext)1191 } catch (_: Exception) {1192 return@withLock UnlockResult.Error("invalid_vault_payload")1193 } finally {1194 VaultCrypto.zero(plaintext)1195 }1196 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1197 val result = adoptSlotA(candidate, payload, expectedEpoch)1198 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1199 result1200 } finally {1201 stagedKey?.let(VaultCrypto::zero)1202 }1203 }12041205 fun lock() {1206 1207 1208 1209 1210 1211 1212 stateLock.lock()1213 try {1214 sessionEpoch = nextSessionEpoch(sessionEpoch)1215 clearSessionStateLocked()1216 _uiGateLocked.value = false1217 } finally {1218 stateLock.unlock()1219 }1220 }12211222 1223122412251226122712281229 fun lockUiGate() {1230 if (_isUnlocked.value) _uiGateLocked.value = true1231 }12321233 1234 fun clearUiGate() {1235 _uiGateLocked.value = false1236 }12371238 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12391240 1241 1242 @VisibleForTesting1243 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12441245 124612471248124912501251125212531254125512561257 fun expectTransientBackground() {1258 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1259 }12601261 12621263126412651266 fun consumeTransientBackgroundSuppression(): Boolean {1267 val until = suppressNextBackgroundLockUntil1268 val active = until != 0L && nowMs() < until1269 suppressNextBackgroundLockUntil = 0L1270 return active1271 }12721273 12741275 12761277127812791280 12811282128312841285128612871288128912901291 1292 1293 1294 1295 1296 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12971298 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1299 if (decoyMode || !_isUnlocked.value) null1300 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1301 }13021303 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1304 mutateState { sessionEpoch == epoch }13051306 internal fun unlockedSessionEpoch(): Long? = mutateState {1307 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1308 }13091310 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13111312 131313141315131613171318131913201321 fun sessionRootKey(): ByteArray? = mutateState {1322 if (!sessionDataAvailable) null else rootKey?.copyOf()1323 }13241325 13261327 fun syncVaultId(): String? = mutateState {1328 if (decoyMode || !_isUnlocked.value) null1329 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1330 }13311332 13331334133513361337 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1338 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1339 ?: return false1340 installRootKey(generateRecoverableRootKey(), expectedEpoch)1341 }13421343 13441345134613471348134913501351135213531354135513561357135813591360 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1361 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1362 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1363 }1364 val expectedEpoch = mutateState {1365 when {1366 derivedKey == null -> null1367 decoyMode -> Long.MIN_VALUE1368 else -> sessionEpoch1369 }1370 }1371 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1372 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1373 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13741375 val incoming = hexToRootKey(rootKeyHex)1376 if (incoming.size != VaultCrypto.KEY_SIZE) {1377 VaultCrypto.zero(incoming)1378 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1379 }1380 1381 1382 1383 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1384 if (!roundTrips) {1385 VaultCrypto.zero(incoming)1386 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1387 }13881389 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1390 return if (installRootKey(incoming, expectedEpoch)) {1391 AdoptVaultResult.Success(vaultId)1392 } else {1393 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1394 }1395 }13961397 13981399140014011402 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1403 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1404 val bytes = hexToRootKey(rootKeyHex)1405 return try {1406 val words = rootKeyToWords(bytes)1407 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1408 } catch (_: Exception) {1409 null1410 } finally {1411 VaultCrypto.zero(bytes)1412 }1413 }14141415 14161417141814191420 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1421 1422 1423 1424 1425 1426 1427 data class Snapshot(1428 val key: ByteArray,1429 val priorRootKey: ByteArray,1430 val priorRecoveryHash: String?,1431 val priorRecoverySet: Boolean,1432 )1433 val snap = mutateIfCurrentEpoch(expectedEpoch) {1434 val k = derivedKey ?: return@mutateIfCurrentEpoch null1435 if (decoyMode) return@mutateIfCurrentEpoch null1436 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1437 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1438 } ?: run {1439 VaultCrypto.zero(nextRootKey)1440 return false1441 }1442 val priorSlot = store.loadSlotA() ?: run {1443 VaultCrypto.zero(snap.key)1444 VaultCrypto.zero(snap.priorRootKey)1445 VaultCrypto.zero(nextRootKey)1446 return false1447 }1448 val nextWords = rootKeyToWords(nextRootKey)14491450 return try {1451 beforeDurableCommit()1452 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1453 if (mutateIfCurrentEpoch(expectedEpoch) {1454 rootKey = nextRootKey1455 recoveryWordHash = recoveryWordsHash(nextWords)1456 recoveryWordsSet = false1457 } == null) throw IllegalStateException("stale_root_rotation")14581459 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1460 ?: throw IllegalStateException("recovery_escrow_failed")1461 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1462 ?: throw IllegalStateException("root_escrow_failed")1463 val plaintext = VaultCodec.encode(currentPayload())1464 val blob = try {1465 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1466 } finally {1467 VaultCrypto.zero(plaintext)1468 }1469 val nextSlot = priorSlot.copy(1470 blob = blob,1471 recoveryEscrow = nextRecoveryEscrow,1472 rootKeyEscrow = nextRootKeyEscrow,1473 )1474 val committed = withEpochCommit(expectedEpoch) {1475 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1476 try {1477 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1478 1479 1480 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1481 store.stampLocalMutation()1482 } catch (_: Exception) {1483 store.writeSlotA(priorSlot)1484 return@withEpochCommit false1485 }1486 true1487 } ?: false1488 if (!committed) throw IllegalStateException("storage_write_failed")1489 mutateIfCurrentEpoch(expectedEpoch) { publish() }1490 true1491 } catch (_: Exception) {1492 val restored = mutateIfCurrentEpoch(expectedEpoch) {1493 rootKey?.let(VaultCrypto::zero)1494 rootKey = snap.priorRootKey.copyOf()1495 recoveryWordHash = snap.priorRecoveryHash1496 recoveryWordsSet = snap.priorRecoverySet1497 }1498 if (restored != null) {1499 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1500 }1501 false1502 } finally {1503 VaultCrypto.zero(snap.key)1504 VaultCrypto.zero(snap.priorRootKey)1505 1506 1507 1508 val stillOwnedBySession = mutateState {1509 sessionEpoch == expectedEpoch && rootKey === nextRootKey1510 }1511 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1512 }1513 }15141515 15161517151815191520 fun currentVaultKey(): ByteArray? = mutateState {1521 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1522 }15231524 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1525 val expectedEpoch = currentEpoch()1526 val slotA = store.loadSlotA() ?: return false1527 var oldKey: ByteArray? = null1528 var newKey: ByteArray? = null1529 var plaintext: ByteArray? = null1530 return try {1531 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1532 1533 1534 1535 1536 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1537 val payload = try {1538 VaultCodec.decode(decrypted)1539 } finally {1540 VaultCrypto.zero(decrypted)1541 }1542 val newSalt = VaultCrypto.generateSalt()1543 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1544 1545 plaintext = VaultCodec.encode(payload)1546 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1547 1548 1549 1550 1551 1552 1553 1554 val next = StoredVault(1555 version = slotA.version,1556 salt = b64(newSalt),1557 blob = blob,1558 recoveryEscrow = slotA.recoveryEscrow,1559 rootKeyEscrow = slotA.rootKeyEscrow,1560 )1561 1562 1563 1564 1565 val priorB = store.loadSlotB()1566 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1567 1568 1569 1570 if (mutateIfCurrentEpoch(expectedEpoch) {1571 oldKey?.let(VaultCrypto::zero); oldKey = null1572 derivedKey?.let(VaultCrypto::zero)1573 derivedKey = newKey; newKey = null1574 } == null) return false1575 true1576 } catch (_: Exception) {1577 false1578 } finally {1579 oldKey?.let(VaultCrypto::zero)1580 newKey?.let(VaultCrypto::zero)1581 plaintext?.let(VaultCrypto::zero)1582 }1583 }15841585 1586158715881589 suspend fun destroyVault() = mutex.withLock {1590 lock()1591 store.clearVault()1592 store.clearDecoySlot()1593 }15941595 15961597 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1598 val epoch = requireUnlocked()1599 val stamp = VaultCrypto.monotonicEntryTimestamp()1600 val now = System.currentTimeMillis()1601 val entry = withDerivedLast4(draft.copy(1602 id = draft.id.ifBlank { UUID.randomUUID().toString() },1603 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1604 modifiedAt = stamp,1605 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1606 ))1607 1608 1609 val rollback = mutateCurrentSession(epoch) {1610 val r = entryList.toMutableList()1611 entryList.add(entry)1612 r1613 } ?: throw IllegalStateException("vault_locked")1614 persistOrRollback(rollback, epoch)1615 entry1616 }16171618 161916201621162216231624162516261627 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1628 val epoch = requireUnlocked()1629 if (drafts.isEmpty()) return 01630 val now = System.currentTimeMillis()1631 val ready = drafts.map { draft ->1632 val stamp = VaultCrypto.monotonicEntryTimestamp()1633 withDerivedLast4(1634 draft.copy(1635 id = draft.id.ifBlank { UUID.randomUUID().toString() },1636 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1637 modifiedAt = stamp,1638 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1639 ),1640 )1641 }1642 1643 val rollback = mutateCurrentSession(epoch) {1644 val r = entryList.toMutableList()1645 entryList.addAll(ready)1646 r1647 } ?: throw IllegalStateException("vault_locked")1648 persistOrRollback(rollback, epoch)1649 return drafts.size1650 }16511652 1653165416551656165716581659166016611662166316641665 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1666 val epoch = requireUnlocked()1667 val now = System.currentTimeMillis()1668 val ready = newEntries.map { draft ->1669 val stamp = VaultCrypto.monotonicEntryTimestamp()1670 withDerivedLast4(1671 draft.copy(1672 id = draft.id.ifBlank { UUID.randomUUID().toString() },1673 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1674 modifiedAt = stamp,1675 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1676 ),1677 )1678 }1679 1680 1681 1682 1683 1684 1685 val incomingIds = ready.map { it.id }.toHashSet()1686 val rollback = mutateCurrentSession(epoch) {1687 val r = entryList.toMutableList()1688 val replaced = entryList.map { entry ->1689 if (entry.deletedAt != null) return@map entry1690 if (entry.id in incomingIds) return@map null1691 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1692 entry.copy(deletedAt = stamp, modifiedAt = stamp)1693 }.filterNotNull()1694 entryList = (replaced + ready).toMutableList()1695 r1696 } ?: throw IllegalStateException("vault_locked")1697 persistOrRollback(rollback, epoch)1698 return ready.size1699 }17001701 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1702 val epoch = requireUnlocked()1703 1704 1705 1706 1707 val prepared = mutateCurrentSession(epoch) {1708 val idx = entryList.indexOfFirst { it.id == patch.id }1709 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1710 val current = entryList[idx]1711 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1712 1713 val passwordChanged = patch.password != null && patch.password != current.password1714 1715 1716 1717 1718 1719 1720 val priorPassword = current.password1721 val shouldCapture = passwordChanged &&1722 !priorPassword.isNullOrEmpty() &&1723 current.passwordHistory?.firstOrNull()?.password != priorPassword1724 val nextHistory = if (shouldCapture) {1725 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1726 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1727 } else {1728 current.passwordHistory1729 }1730 val updated = withDerivedLast4(patch.copy(1731 createdAt = current.createdAt,1732 modifiedAt = stamp,1733 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1734 passwordHistory = nextHistory,1735 ))1736 val rollback = entryList.toMutableList()1737 entryList[idx] = updated1738 updated to rollback1739 } ?: throw IllegalStateException("vault_locked")1740 val (updated, rollback) = prepared1741 persistOrRollback(rollback, epoch)1742 updated1743 }17441745 1746 suspend fun deleteEntry(id: String) = mutex.withLock {1747 val epoch = requireUnlocked()1748 1749 1750 val rollback = mutateCurrentSession(epoch) {1751 val idx = entryList.indexOfFirst { it.id == id }1752 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1753 val current = entryList[idx]1754 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1755 val prior = entryList.toMutableList()1756 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1757 prior1758 } ?: throw IllegalStateException("vault_locked")1759 persistOrRollback(rollback, epoch)1760 }17611762 suspend fun restoreEntry(id: String) = mutex.withLock {1763 val epoch = requireUnlocked()1764 val rollback = mutateCurrentSession(epoch) {1765 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1766 if (idx == -1) throw IllegalStateException("deleted entry not found")1767 val current = entryList[idx]1768 val prior = entryList.toMutableList()1769 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1770 prior1771 } ?: throw IllegalStateException("vault_locked")1772 persistOrRollback(rollback, epoch)1773 }17741775 1776 suspend fun purgeEntry(id: String) = mutex.withLock {1777 val epoch = requireUnlocked()1778 val rollback = mutateCurrentSession(epoch) {1779 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1780 if (idx == -1) throw IllegalStateException("deleted entry not found")1781 val prior = entryList.toMutableList()1782 entryList.removeAt(idx)1783 prior1784 } ?: throw IllegalStateException("vault_locked")1785 persistOrRollback(rollback, epoch)1786 }17871788 1789 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1790 val epoch = requireUnlocked()1791 1792 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1793 val r = mutateCurrentSession(epoch) {1794 val rollback = entryList.toMutableList()1795 val before = entryList.size1796 entryList.removeAll { it.deletedAt != null }1797 R(rollback, before - entryList.size)1798 } ?: throw IllegalStateException("vault_locked")1799 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1800 r.removed1801 }18021803 1804 suspend fun countDuplicateEntries(): Int = mutex.withLock {1805 val epoch = requireUnlocked()1806 1807 1808 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1809 ?: throw IllegalStateException("vault_locked")1810 }18111812 18131814181518161817 suspend fun deduplicateEntries(): Int = mutex.withLock {1818 val epoch = requireUnlocked()1819 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1820 val ready = mutateCurrentSession(epoch) {1821 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1822 val active = entryList.filter { it.deletedAt == null }1823 val keepIds = mutableSetOf<String>()1824 active.groupBy { it.duplicateKey() }.values.forEach { group ->1825 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1826 }1827 val rollback = entryList.toMutableList()1828 var removed = 01829 entryList = entryList.map { entry ->1830 if (entry.deletedAt == null && entry.id !in keepIds) {1831 removed++1832 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1833 entry.copy(deletedAt = stamp, modifiedAt = stamp)1834 } else {1835 entry1836 }1837 }.toMutableList()1838 R(rollback, removed)1839 } ?: throw IllegalStateException("vault_locked")1840 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1841 ready.removed1842 }18431844 fun getEntries(): List<VaultEntry> = mutateState {1845 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1846 }1847 fun getEntry(id: String): VaultEntry? = mutateState {1848 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1849 }1850 fun getDeletedEntries(): List<VaultEntry> = mutateState {1851 if (!sessionDataAvailable) emptyList()1852 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1853 }18541855 1856 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1857 return mutateState {1858 if (!sessionDataAvailable) return@mutateState emptyList()1859 val active = entryList.filter { it.deletedAt == null }1860 if (tagFilter == null) return@mutateState active1861 val needle = tagFilter.trim().lowercase()1862 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1863 }1864 }18651866 1867 fun getDistinctTags(): List<String> = mutateState {1868 if (!sessionDataAvailable) return@mutateState emptyList()1869 entryList.asSequence()1870 .filter { it.deletedAt == null }1871 .flatMap { it.tags?.asSequence() ?: emptySequence() }1872 .map { it.trim().lowercase() }1873 .filter { it.isNotEmpty() }1874 .distinct()1875 .sorted()1876 .toList()1877 }18781879 188018811882188318841885 fun getDistinctFieldValues(field: String): List<String> {1886 val select: (VaultEntry) -> String? = when (field) {1887 "username" -> { e -> e.username }1888 "email" -> { e -> e.email }1889 else -> return emptyList()1890 }1891 return mutateState {1892 if (!sessionDataAvailable) return@mutateState emptyList()1893 entryList.asSequence()1894 .filter { it.deletedAt == null }1895 .mapNotNull { select(it)?.trim() }1896 .filter { it.isNotEmpty() }1897 .groupingBy { it.lowercase() }1898 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1899 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1900 }1901 .values1902 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1903 .map { it.first }1904 }1905 }19061907 fun getTombstones(): List<Tombstone> = mutateState {1908 if (!sessionDataAvailable) emptyList()1909 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1910 }1911 fun getVaultVersion(): Long = store.version()1912 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19131914 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1915 val expectedEpoch = currentEpoch()1916 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1917 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1918 1919 1920 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1921 val priorA = store.loadSlotA()1922 val priorB = store.loadSlotB()1923 val priorD = store.loadDecoySlot()1924 1925 1926 1927 1928 1929 val priorHistory = store.loadVaultHistory()1930 1931 1932 1933 1934 beforeDurableCommit()1935 if (!takeVaultSnapshot("restore", expectedEpoch)) {1936 return false1937 }1938 1939 1940 1941 val nextD: StoredVault = restoredD ?: run {1942 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161943 val fakeBlob = ByteArray(target).also(rng::nextBytes)1944 val fakeSalt = VaultCrypto.generateSalt()1945 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1946 }1947 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1948 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1949 return false1950 }1951 lock()1952 true1953 }19541955 19561957195819591960196119621963196419651966 private fun commitRestoreAtomic(1967 nextA: StoredVault,1968 nextB: String,1969 nextD: StoredVault,1970 priorA: StoredVault?,1971 priorB: String?,1972 priorD: StoredVault?,1973 expectedEpoch: Long,1974 ): Boolean {1975 return withEpochCommit(expectedEpoch) {1976 if (!store.writeSlotA(nextA)) return@withEpochCommit false1977 1978 1979 1980 try {1981 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1982 1983 1984 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1985 store.stampLocalMutation()1986 } catch (_: Exception) {1987 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1988 return@withEpochCommit false1989 }1990 true1991 } ?: false1992 }19931994 19951996 19971998199920002001 suspend fun applyMergedRemoteEntries(2002 remoteEntries: List<VaultEntry>,2003 remoteTombstones: List<Tombstone>,2004 expectedEpoch: Long? = null,2005 ): Int = mutex.withLock {2006 val epoch = expectedEpoch ?: requireUnlocked()2007 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02008 2009 2010 2011 2012 2013 val mergeNow = System.currentTimeMillis()2014 val clamped = remoteEntries.map { entry ->2015 entry.copy(2016 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2017 deletedAt = entry.deletedAt?.let {2018 SyncLimits.clampSyncTimestamp(it, mergeNow)2019 },2020 )2021 }2022 val tombMap = remoteTombstones.associate {2023 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2024 }2025 2026 2027 val applied = mutateCurrentSession(epoch) {2028 var n = 02029 val byId = entryList.associateBy { it.id }.toMutableMap()20302031 for (remote in clamped) {2032 val tomb = tombMap[remote.id]2033 if (tomb != null && tomb >= remote.modifiedAt) continue2034 val local = byId[remote.id]2035 if (local == null) {2036 entryList.add(remote); byId[remote.id] = remote; n++2037 } else if (remote.modifiedAt > local.modifiedAt) {2038 val i = entryList.indexOfFirst { it.id == local.id }2039 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2040 }2041 }20422043 for ((id, deletedAt) in tombMap) {2044 val local = byId[id] ?: continue2045 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2046 if (deletedAt < local.modifiedAt) continue2047 val i = entryList.indexOfFirst { it.id == id }2048 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2049 }2050 n2051 } ?: return@withLock 020522053 if (applied > 0) {2054 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2055 mutateIfCurrentEpoch(epoch) { publish() }2056 }2057 applied2058 }20592060 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2061 2062 2063 2064 val epoch = requireUnlocked()2065 val prior = mutateCurrentSession(epoch) {2066 val old = _settings.value2067 _settings.value = settings2068 old2069 } ?: throw IllegalStateException("vault_locked")20702071 val committed = try {2072 beforeDurableCommit()2073 2074 2075 2076 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2077 } catch (_: Exception) {2078 false2079 }2080 if (!committed) {2081 2082 2083 2084 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2085 throw IllegalStateException("settings_write_failed")2086 }2087 }20882089 20902091 2092 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20932094 20952096209720982099 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2100 val epoch = requireUnlocked()2101 2102 2103 2104 2105 2106 2107 val prior = mutateCurrentSession(epoch) {2108 val priorPair = recoveryWordHash to recoveryWordsSet2109 recoveryWordHash = recoveryWordsHash(words)2110 recoveryWordsSet = true2111 priorPair2112 } ?: return@withLock false2113 2114 2115 2116 2117 val persisted = runCatching {2118 persistSlotAWithEscrows(2119 buildRecoveryEscrow(words),2120 buildRootKeyEscrow(words),2121 expectedEpoch = epoch,2122 )2123 }.getOrDefault(false)2124 if (!persisted) {2125 mutateIfCurrentEpoch(epoch) {2126 recoveryWordHash = prior.first2127 recoveryWordsSet = prior.second2128 }2129 }2130 persisted2131 }21322133 2134 fun verifyRecoveryWords(words: List<String>): Boolean {2135 return mutateState {2136 val stored = recoveryWordHash ?: return@mutateState false2137 stored == recoveryWordsHash(words)2138 }2139 }21402141 21422143 2144 fun hasRecoveryEscrow(): Boolean =2145 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21462147 21482149215021512152 private fun ensureRecoveryEscrow() {2153 if (hasRecoveryEscrow()) return2154 val rk = mutateState { rootKey?.copyOf() } ?: return2155 runCatching {2156 val words = rootKeyToWords(rk)2157 val escrow = buildRecoveryEscrow(words)2158 val rkEscrow = buildRootKeyEscrow(words)2159 if (escrow != null || rkEscrow != null) {2160 val prior = store.loadSlotA() ?: return@runCatching2161 store.writeSlotA(prior.copy(2162 recoveryEscrow = escrow ?: prior.recoveryEscrow,2163 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2164 ))2165 mutateState {2166 recoveryWordHash = recoveryWordsHash(words)2167 recoveryWordsSet = true2168 }2169 }2170 }.also { VaultCrypto.zero(rk) }2171 }21722173 217421752176217721782179218021812182218321842185 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2186 val expectedEpoch = currentEpoch()2187 val slotA = store.loadSlotA() ?: return false2188 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21892190 2191 2192 val fullEscrow = slotA.recoveryEscrow2193 if (fullEscrow != null) {2194 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2195 if (material != null) {2196 var keyBytes: ByteArray? = null2197 var plaintext: ByteArray? = null2198 var candidateRoot: ByteArray? = null2199 var stateCommitStarted = false2200 try {2201 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2202 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2203 val payload = VaultCodec.decode(plaintext)2204 2205 2206 2207 2208 val candidateSettings = store.loadSettings()2209 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2210 val decodedRoot = unb64(payload.rootKeyB64)2211 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2212 2213 2214 2215 VaultCrypto.zero(decodedRoot)2216 throw IllegalArgumentException("invalid recovery root key")2217 }2218 decodedRoot2219 } else {2220 generateRecoverableRootKey()2221 }2222 stateCommitStarted = true2223 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2224 clearSessionStateLocked()2225 entryList = payload.entries.toMutableList()2226 duressConfigured = payload.duressConfigured2227 recoveryWordHash = payload.recoveryWordHash2228 recoveryWordsSet = payload.recoveryWordsSet2229 rootKey = candidateRoot2230 candidateRoot = null2231 _settings.value = candidateSettings2232 publish()2233 2234 2235 sessionDataAvailable = true2236 _isUnlocked.value = false2237 true2238 }2239 if (adopted == true) return@withLock true2240 } catch (_: Exception) {2241 2242 2243 2244 2245 if (stateCommitStarted) {2246 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2247 }2248 } finally {2249 plaintext?.let(VaultCrypto::zero)2250 VaultCrypto.zero(material)2251 keyBytes?.let(VaultCrypto::zero)2252 candidateRoot?.let(VaultCrypto::zero)2253 }2254 }2255 }22562257 2258 2259 2260 val rootEscrow = slotA.rootKeyEscrow2261 if (rootEscrow != null) {2262 var candidateRoot: ByteArray? = null2263 var stateCommitStarted = false2264 try {2265 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2266 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2267 val candidateSettings = store.loadSettings()2268 stateCommitStarted = true2269 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2270 clearSessionStateLocked()2271 entryList = mutableListOf()2272 duressConfigured = false2273 recoveryWordHash = recoveryWordsHash(words)2274 recoveryWordsSet = true2275 rootKey = candidateRoot2276 candidateRoot = null2277 _settings.value = candidateSettings2278 publish()2279 sessionDataAvailable = true2280 _isUnlocked.value = false2281 true2282 }2283 if (adopted == true) return@withLock true2284 } catch (_: Exception) {2285 if (stateCommitStarted) {2286 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2287 }2288 } finally {2289 candidateRoot?.let(VaultCrypto::zero)2290 }2291 }2292 return false2293 }22942295 229622972298229923002301230223032304 suspend fun resetPasswordWithRecoveryWords(2305 words: List<String>,2306 newPassword: String,2307 ): Boolean = mutex.withLock {2308 val expectedEpoch = currentEpoch()2309 2310 2311 2312 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2313 val priorA = store.loadSlotA() ?: return false2314 var newKey: ByteArray? = null2315 var plaintext: ByteArray? = null2316 return try {2317 val newSalt = VaultCrypto.generateSalt()2318 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2319 plaintext = VaultCodec.encode(currentPayload())2320 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2321 2322 2323 2324 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2325 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2326 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2327 val next = StoredVault(2328 version = priorA.version,2329 salt = b64(newSalt),2330 blob = blob,2331 recoveryEscrow = freshRecovery,2332 rootKeyEscrow = freshRootKey,2333 )2334 2335 2336 2337 val priorB = store.loadSlotB()2338 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2339 2340 2341 2342 if (mutateIfCurrentEpoch(expectedEpoch) {2343 derivedKey?.let(VaultCrypto::zero)2344 derivedKey = newKey; newKey = null2345 publish()2346 _isUnlocked.value = true2347 } == null) return false2348 true2349 } catch (_: Exception) {2350 false2351 } finally {2352 newKey?.let(VaultCrypto::zero)2353 plaintext?.let(VaultCrypto::zero)2354 }2355 }23562357 235823592360236123622363 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2364 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2365 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2366 var keyBytes: ByteArray? = null2367 var plaintext: ByteArray? = null2368 return try {2369 val salt = VaultCrypto.generateSalt()2370 val recoveryKey = deriveRecoveryKey(material, salt)2371 keyBytes = recoveryKey.encoded2372 plaintext = VaultCodec.encode(currentPayload())2373 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2374 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2375 } finally {2376 VaultCrypto.zero(material)2377 keyBytes?.let(VaultCrypto::zero)2378 plaintext?.let(VaultCrypto::zero)2379 }2380 }23812382 23832384238523862387238823892390 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2391 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2392 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2393 val rk = mutateState { rootKey?.copyOf() } ?: run {2394 VaultCrypto.zero(material)2395 return null2396 }2397 var keyBytes: ByteArray? = null2398 var plaintext: ByteArray? = null2399 return try {2400 val salt = VaultCrypto.generateSalt()2401 val recoveryKey = deriveRecoveryKey(material, salt)2402 keyBytes = recoveryKey.encoded2403 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2404 2405 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2406 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2407 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2408 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2409 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2410 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2411 } finally {2412 VaultCrypto.zero(rk)2413 VaultCrypto.zero(material)2414 keyBytes?.let(VaultCrypto::zero)2415 plaintext?.let(VaultCrypto::zero)2416 }2417 }24182419 2420 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2421 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2422 var keyBytes: ByteArray? = null2423 var pt: ByteArray? = null2424 return try {2425 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2426 keyBytes = recoveryKey.encoded2427 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2428 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2429 hexToRootKey(String(pt, Charsets.US_ASCII))2430 } catch (_: Exception) {2431 null2432 } finally {2433 2434 2435 2436 VaultCrypto.zero(material)2437 keyBytes?.let(VaultCrypto::zero)2438 pt?.let(VaultCrypto::zero)2439 }2440 }24412442 244324442445244624472448 private fun persistSlotAWithEscrows(2449 recoveryEscrow: RecoveryEscrow?,2450 rootKeyEscrow: RootKeyEscrow?,2451 expectedEpoch: Long? = null,2452 ): Boolean {2453 val snapshot = snapshotForPersist(expectedEpoch)2454 ?: throw IllegalStateException("vault_locked_or_stale")2455 if (snapshot.decoy) {2456 VaultCrypto.zero(snapshot.key)2457 return false2458 }2459 try {2460 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2461 val plaintext = VaultCodec.encode(snapshot.payload)2462 val blob = try {2463 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2464 } finally {2465 2466 2467 VaultCrypto.zero(plaintext)2468 }2469 val next = prior.copy(2470 blob = blob,2471 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2472 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2473 )2474 beforeDurableCommit()2475 return withEpochCommit(snapshot.epoch) {2476 if (!store.writeSlotA(next)) return@withEpochCommit false2477 try {2478 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2479 2480 2481 2482 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2483 } catch (e: Exception) {2484 store.writeSlotA(prior)2485 throw e2486 }2487 true2488 } ?: false2489 } finally {2490 VaultCrypto.zero(snapshot.key)2491 }2492 }24932494 24952496 private fun requireUnlocked(): Long {2497 2498 2499 2500 stateLock.lock()2501 try {2502 if (derivedKey == null) throw IllegalStateException("vault_locked")2503 return sessionEpoch2504 } finally {2505 stateLock.unlock()2506 }2507 }25082509 2510 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25112512 private fun countDuplicateEntriesLocked(): Int {2513 val seen = mutableMapOf<String, VaultEntry>()2514 var duplicates = 02515 for (entry in entryList.filter { it.deletedAt == null }) {2516 val key = entry.duplicateKey()2517 val existing = seen[key]2518 if (existing == null) {2519 seen[key] = entry2520 } else {2521 duplicates++2522 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2523 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2524 if (entryStamp > existingStamp) seen[key] = entry2525 }2526 }2527 return duplicates2528 }25292530 private fun VaultEntry.duplicateKey(): String {2531 if (type == "note") return "note|${title.lowercase()}"2532 val passkeyData = passkey2533 if (passkeyData != null) {2534 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2535 }2536 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2537 }25382539 254025412542254325442545 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2546 val epoch = expectedEpoch ?: currentEpoch()2547 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2548 val slotA = store.loadSlotA() ?: return false2549 val vaultA = VaultCodec.encodeSlot(slotA)2550 val vaultB = store.loadSlotB() ?: slotA.blob2551 2552 2553 2554 2555 2556 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2557 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162558 val fakeBlob = ByteArray(target).also(rng::nextBytes)2559 val fakeSalt = VaultCrypto.generateSalt()2560 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2561 }2562 val history = store.loadVaultHistory()2563 val latest = history.snapshots.firstOrNull()2564 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2565 val entryCount = mutateIfCurrentEpoch(epoch) {2566 if (derivedKey != null && !decoyMode) {2567 bucketEntryCount(entryList.count { it.deletedAt == null })2568 } else {2569 null2570 }2571 } ?: return false2572 return try {2573 val snapshot = VaultSnapshot(2574 id = UUID.randomUUID().toString(),2575 savedAt = System.currentTimeMillis(),2576 reason = reason,2577 vault_a = vaultA,2578 vault_b = vaultB,2579 vault_d = vaultD,2580 entryCount = entryCount,2581 )2582 2583 2584 2585 withEpochCommit(epoch) {2586 store.writeVaultHistory(2587 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2588 )2589 } ?: false2590 } catch (_: Exception) {2591 false2592 }2593 }25942595 private fun bucketEntryCount(n: Int): Int = when {2596 n <= 0 -> 02597 n <= 10 -> 102598 n <= 25 -> 252599 n <= 50 -> 502600 n <= 100 -> 1002601 n <= 250 -> 2502602 n <= 500 -> 5002603 else -> ((n + 499) / 500) * 5002604 }26052606 260726082609261026112612261326142615 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2616 if (!entry.isCard()) return entry2617 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2618 val derived = if (digits.length >= 4) digits.takeLast(4) else null2619 return entry.copy(last4 = derived ?: entry.last4)2620 }26212622 262326242625262626272628 private fun autoPurgeExpiredTrash() {2629 val days = _settings.value.trashRetentionDays ?: return2630 if (days <= 0) return2631 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2632 val before = entryList.size2633 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2634 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2635 }26362637 2638 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }26392640 private fun currentPayloadLocked(): VaultPayload =2641 VaultPayload(2642 entries = entryList.toList(),2643 duressConfigured = duressConfigured,2644 rootKeyB64 = rootKey?.let(::b64) ?: "",2645 recoveryWordHash = recoveryWordHash,2646 recoveryWordsSet = recoveryWordsSet,2647 )26482649 26502651265226532654 private fun ensureRootKey(payload: VaultPayload) {2655 if (payload.rootKeyB64.isNotBlank()) {2656 rootKey = unb64(payload.rootKeyB64)2657 return2658 }2659 rootKey = generateRecoverableRootKey()2660 runCatching { persistSlotA(bumpVersion = false) }2661 }26622663 private fun publish() {2664 _entries.value = entryList.filter { it.deletedAt == null }2665 }26662667 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2668 var plaintext = VaultCodec.encode(currentPayload())2669 return try {2670 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2671 StoredVault(version = 1, salt = b64(salt), blob = blob)2672 } finally {2673 2674 2675 2676 VaultCrypto.zero(plaintext)2677 }2678 }26792680 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2681 2682 2683 2684 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2685 val noise = ByteArray(approxBytes).also(rng::nextBytes)2686 return store.writeSlotB(b64(noise))2687 }26882689 269026912692 private fun matchingCiphertextLength(slotA: StoredVault): Int =2693 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26942695 26962697269826992700 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2701 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162702 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2703 val fakeSalt = VaultCrypto.generateSalt()2704 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2705 }27062707 270827092710271127122713 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2714 val isConfigured = mutateState { duressConfigured }2715 return if (isConfigured) true else writeFakeDuressSlot(slotA)2716 }27172718 2719272027212722272327242725 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2726 val salt = unhex(b.salt)2727 val nonce = unhex(b.nonce)2728 val ct = unhex(b.ciphertext)2729 val tag = unhex(b.tag)2730 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2731 System.arraycopy(nonce, 0, it, 0, nonce.size)2732 System.arraycopy(ct, 0, it, nonce.size, ct.size)2733 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2734 }2735 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2736 }27372738 2739 private fun persistSlotA(2740 bumpVersion: Boolean,2741 snapshotReason: String? = "local_save",2742 expectedEpoch: Long? = null,2743 ): Boolean {2744 val snapshot = snapshotForPersist(expectedEpoch)2745 ?: throw IllegalStateException("vault_locked_or_stale")2746 return try {2747 2748 if (snapshot.decoy) {2749 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2750 val plaintext = VaultCodec.encode(snapshot.payload)2751 val blob = try {2752 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2753 } finally {2754 2755 2756 VaultCrypto.zero(plaintext)2757 }2758 beforeDurableCommit()2759 return withEpochCommit(snapshot.epoch) {2760 store.writeDecoySlot(prior.copy(blob = blob))2761 } ?: false2762 }2763 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2764 2765 2766 2767 2768 val priorB = store.loadSlotB()2769 beforeDurableCommit()2770 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2771 val plaintext = VaultCodec.encode(snapshot.payload)2772 val blob = try {2773 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2774 } finally {2775 2776 2777 VaultCrypto.zero(plaintext)2778 }2779 val next = prior.copy(blob = blob)2780 2781 2782 2783 2784 withEpochCommit(snapshot.epoch) {2785 if (!store.writeSlotA(next)) return@withEpochCommit false2786 try {2787 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2788 2789 2790 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2791 if (bumpVersion) store.stampLocalMutation()2792 } catch (e: Exception) {2793 store.writeSlotA(prior)2794 if (priorB != null) runCatching { store.writeSlotB(priorB) }2795 throw e2796 }2797 true2798 } ?: false2799 } finally {2800 2801 2802 VaultCrypto.zero(snapshot.key)2803 }2804 }28052806 2807280828092810281128122813 private fun commitVaultRotation(2814 priorA: StoredVault,2815 priorB: String?,2816 nextA: StoredVault,2817 expectedEpoch: Long,2818 ): Boolean {2819 beforeDurableCommit()2820 return withEpochCommit(expectedEpoch) {2821 if (!store.writeSlotA(nextA)) return@withEpochCommit false2822 2823 2824 2825 try {2826 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2827 2828 2829 2830 2831 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2832 store.stampLocalMutation()2833 } catch (_: Exception) {2834 rollbackRotation(priorA, priorB)2835 return@withEpochCommit false2836 }2837 true2838 } ?: false2839 }28402841 28422843284428452846 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2847 val failures = mutableListOf<String>()2848 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2849 .onFailure { failures += "writeSlotA: $it" }2850 if (priorB != null) {2851 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2852 .onFailure { failures += "writeSlotB: $it" }2853 }2854 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2855 }28562857 2858 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2859 try {2860 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2861 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2862 throw IllegalStateException("storage_write_failed")2863 }2864 } catch (e: Exception) {2865 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2866 throw e2867 }2868 2869 2870 2871 mutateIfCurrentEpoch(expectedEpoch) { publish() }2872 }2873}