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 consolidatePasskeyEntries()764 publish()765 _isUnlocked.value = true766 767 768 769 sessionEpoch = nextSessionEpoch(sessionEpoch)770 }771 if (adopted == null) {772 VaultCrypto.zero(key)773 UnlockResult.Error("stale_unlock")774 } else {775 UnlockResult.Success776 }777 } catch (e: Exception) {778 779 780 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }781 VaultCrypto.zero(key)782 UnlockResult.Error(e.message ?: "unlock_failed")783 }784 }785786 787788 789790791792793794 class SlotAUnlock internal constructor(795 key: ByteArray,796 internal val payload: VaultPayload,797 internal val epoch: Long,798 ) {799 private var ownedKey: ByteArray? = key800 private var consumed = false801802 803 internal fun takeKey(): ByteArray? = synchronized(this) {804 if (consumed) return@synchronized null805 consumed = true806 ownedKey.also { ownedKey = null }807 }808809 810 internal fun discard() = synchronized(this) {811 if (consumed) return@synchronized812 consumed = true813 ownedKey?.let(VaultCrypto::zero)814 ownedKey = null815 }816 }817818 819820821822823824 fun tryUnlockSlotA(password: String): SlotAUnlock? {825 val epoch = currentEpoch()826 val slotA = store.loadSlotA() ?: return null827 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))828 return try {829 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)830 val payload = try {831 VaultCodec.decode(plaintext)832 } finally {833 VaultCrypto.zero(plaintext)834 }835 SlotAUnlock(key, payload, epoch)836 } catch (_: Exception) {837 VaultCrypto.zero(key)838 null839 }840 }841842 843 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {844 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")845 adoptSlotA(key, unlock.payload, unlock.epoch)846 }847848 849 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()850851 852853 854855856857858 class DuressUnlock internal constructor(859 key: ByteArray,860 internal val payload: VaultPayload,861 internal val epoch: Long,862 ) {863 private var ownedKey: ByteArray? = key864 private var consumed = false865866 internal fun takeKey(): ByteArray? = synchronized(this) {867 if (consumed) return@synchronized null868 consumed = true869 ownedKey.also { ownedKey = null }870 }871872 internal fun discard() = synchronized(this) {873 if (consumed) return@synchronized874 consumed = true875 ownedKey?.let(VaultCrypto::zero)876 ownedKey = null877 }878 }879880 881882883884885886887 fun tryUnlockDuress(password: String): DuressUnlock? {888 val epoch = currentEpoch()889 val slot = store.loadDecoySlot() ?: return null890 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))891 return try {892 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)893 val payload = try {894 VaultCodec.decode(plaintext)895 } finally {896 VaultCrypto.zero(plaintext)897 }898 DuressUnlock(key, payload, epoch)899 } catch (_: Exception) {900 VaultCrypto.zero(key)901 null902 }903 }904905 906 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {907 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")908 if (currentEpoch() != unlock.epoch) {909 VaultCrypto.zero(key)910 return@withLock UnlockResult.Error("stale_unlock")911 }912 return try {913 val adopted = mutateIfCurrentEpoch(unlock.epoch) {914 derivedKey = key915 decoyMode = true916 entryList = unlock.payload.entries.toMutableList()917 duressConfigured = false918 recoveryWordHash = unlock.payload.recoveryWordHash919 recoveryWordsSet = unlock.payload.recoveryWordsSet920 sessionDataAvailable = true921 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)922 else VaultCrypto.generateRootKey()923 _settings.value = store.loadSettings()924 publish()925 _isUnlocked.value = true926 sessionEpoch = nextSessionEpoch(sessionEpoch)927 }928 if (adopted == null) {929 VaultCrypto.zero(key)930 UnlockResult.Error("stale_unlock")931 } else {932 UnlockResult.Success933 }934 } catch (e: Exception) {935 936 937 938 939 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }940 VaultCrypto.zero(key)941 UnlockResult.Error(e.message ?: "decoy_unlock_failed")942 }943 }944945 946 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()947948 949950951952953954 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {955 956 val expectedEpoch = mutateState {957 if (derivedKey != null && !decoyMode) sessionEpoch else null958 } ?: return false959 960 961 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }962 var key: ByteArray? = null963 var basePlaintext: ByteArray? = null964 return try {965 val salt = VaultCrypto.generateSalt()966 key = VaultCrypto.deriveKey(duressPassphrase, salt)967 968 val decoyRoot = VaultCrypto.generateRootKey()969 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }970 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)971 basePlaintext = VaultCodec.encode(decoyPayload)972 973 974 975 976 977 978 979 980 val slotA = store.loadSlotA()981 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0982 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!983 val blob = try {984 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))985 } finally {986 VaultCrypto.zero(plaintext)987 988 989 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)990 basePlaintext = null991 key?.let(VaultCrypto::zero)992 key = null993 }994 beforeDurableCommit()995 val ok = withEpochCommit(expectedEpoch) {996 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))997 } ?: false998 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {999 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1000 } else if (ok) {1001 return false1002 }1003 ok1004 } catch (_: Exception) {1005 false1006 } finally {1007 1008 1009 key?.let(VaultCrypto::zero)1010 basePlaintext?.let(VaultCrypto::zero)1011 }1012 }101310141015 101610171018101910201021102210231024 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1025 if (!duressConfigured) return null1026 val slot = store.loadDecoySlot() ?: return null1027 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1028 }10291030 1031103210331034103510361037 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1038 1039 1040 1041 1042 1043 var salt: ByteArray? = null1044 var blob: ByteArray? = null1045 var nonce: ByteArray? = null1046 var ctAndTag: ByteArray? = null1047 var ct: ByteArray? = null1048 var tag: ByteArray? = null1049 try {1050 salt = unb64(slot.salt)1051 blob = unb64(slot.blob)1052 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1053 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1054 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1055 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1056 val obj = JsonObject(mapOf(1057 "version" to JsonPrimitive(1),1058 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1059 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1060 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1061 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1062 ))1063 return obj.toString()1064 } finally {1065 salt?.let(VaultCrypto::zero)1066 blob?.let(VaultCrypto::zero)1067 nonce?.let(VaultCrypto::zero)1068 ctAndTag?.let(VaultCrypto::zero)1069 ct?.let(VaultCrypto::zero)1070 tag?.let(VaultCrypto::zero)1071 }1072 }10731074 1075107610771078107910801081108210831084 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1085 val b = BrowserSlot.parse(json) ?: return false1086 var stored: StoredVault? = null1087 var salt: ByteArray? = null1088 var nonce: ByteArray? = null1089 var ct: ByteArray? = null1090 var tag: ByteArray? = null1091 var blob: ByteArray? = null1092 try {1093 salt = unhex(b.salt)1094 nonce = unhex(b.nonce)1095 ct = unhex(b.ciphertext)1096 tag = unhex(b.tag)1097 blob = ByteArray(nonce.size + ct.size + tag.size).also {1098 System.arraycopy(nonce, 0, it, 0, nonce.size)1099 System.arraycopy(ct, 0, it, nonce.size, ct.size)1100 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1101 }1102 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1103 val expectedEpoch = sessionEpoch1104 beforeDurableCommit()1105 withEpochCommit(expectedEpoch) {1106 store.writeDecoySlot(stored)1107 } ?: false1108 } catch (_: Exception) {1109 false1110 } finally {1111 salt?.let(VaultCrypto::zero)1112 nonce?.let(VaultCrypto::zero)1113 ct?.let(VaultCrypto::zero)1114 tag?.let(VaultCrypto::zero)1115 blob?.let(VaultCrypto::zero)1116 }1117 }11181119 1120 suspend fun clearDuress(): Boolean = mutex.withLock {1121 1122 1123 1124 1125 val expectedEpoch = currentEpoch()1126 val replaced = withEpochCommit(expectedEpoch) {1127 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1128 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1129 } ?: false1130 if (!replaced) return@withLock false1131 1132 1133 if (!isSessionEpochCurrent(expectedEpoch)) return false1134 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1135 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1136 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1137 }1138 true1139 }11401141 1142 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1143 var current: Throwable? = error1144 while (current != null) {1145 if (current is javax.crypto.AEADBadTagException) return true1146 1147 1148 1149 1150 if (current is javax.crypto.BadPaddingException) return true1151 current = current.cause1152 }1153 return false1154 }11551156 1157 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1158 val expectedEpoch = currentEpoch()1159 val slotA = try {1160 store.loadSlotA()1161 } catch (_: Exception) {1162 return@withLock UnlockResult.Error("unlock_key_storage_failed")1163 } ?: return@withLock UnlockResult.Error("no_vault")1164 1165 1166 1167 1168 var stagedKey: ByteArray? = key.copyOf()1169 return try {1170 val encrypted = try {1171 unb64(slotA.blob)1172 } catch (_: Exception) {1173 return@withLock UnlockResult.Error("invalid_vault_blob")1174 }1175 1176 1177 1178 1179 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1180 return@withLock UnlockResult.Error("invalid_vault_blob")1181 }1182 val plaintext = try {1183 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1184 } catch (e: Exception) {1185 if (isGcmAuthenticationFailure(e)) {1186 return@withLock UnlockResult.WrongPassword1187 }1188 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1189 }1190 val payload = try {1191 VaultCodec.decode(plaintext)1192 } catch (_: Exception) {1193 return@withLock UnlockResult.Error("invalid_vault_payload")1194 } finally {1195 VaultCrypto.zero(plaintext)1196 }1197 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1198 val result = adoptSlotA(candidate, payload, expectedEpoch)1199 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1200 result1201 } finally {1202 stagedKey?.let(VaultCrypto::zero)1203 }1204 }12051206 fun lock() {1207 1208 1209 1210 1211 1212 1213 stateLock.lock()1214 try {1215 sessionEpoch = nextSessionEpoch(sessionEpoch)1216 clearSessionStateLocked()1217 _uiGateLocked.value = false1218 } finally {1219 stateLock.unlock()1220 }1221 }12221223 1224122512261227122812291230 fun lockUiGate() {1231 if (_isUnlocked.value) _uiGateLocked.value = true1232 }12331234 1235 fun clearUiGate() {1236 _uiGateLocked.value = false1237 }12381239 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12401241 1242 1243 @VisibleForTesting1244 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12451246 124712481249125012511252125312541255125612571258 fun expectTransientBackground() {1259 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1260 }12611262 12631264126512661267 fun consumeTransientBackgroundSuppression(): Boolean {1268 val until = suppressNextBackgroundLockUntil1269 val active = until != 0L && nowMs() < until1270 suppressNextBackgroundLockUntil = 0L1271 return active1272 }12731274 12751276 12771278127912801281 12821283128412851286128712881289129012911292 1293 1294 1295 1296 1297 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12981299 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1300 if (decoyMode || !_isUnlocked.value) null1301 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1302 }13031304 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1305 mutateState { sessionEpoch == epoch }13061307 internal fun unlockedSessionEpoch(): Long? = mutateState {1308 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1309 }13101311 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13121313 131413151316131713181319132013211322 fun sessionRootKey(): ByteArray? = mutateState {1323 if (!sessionDataAvailable) null else rootKey?.copyOf()1324 }13251326 13271328 fun syncVaultId(): String? = mutateState {1329 if (decoyMode || !_isUnlocked.value) null1330 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1331 }13321333 13341335133613371338 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1339 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1340 ?: return false1341 installRootKey(generateRecoverableRootKey(), expectedEpoch)1342 }13431344 13451346134713481349135013511352135313541355135613571358135913601361 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1362 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1363 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1364 }1365 val expectedEpoch = mutateState {1366 when {1367 derivedKey == null -> null1368 decoyMode -> Long.MIN_VALUE1369 else -> sessionEpoch1370 }1371 }1372 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1373 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1374 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13751376 val incoming = hexToRootKey(rootKeyHex)1377 if (incoming.size != VaultCrypto.KEY_SIZE) {1378 VaultCrypto.zero(incoming)1379 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1380 }1381 1382 1383 1384 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1385 if (!roundTrips) {1386 VaultCrypto.zero(incoming)1387 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1388 }13891390 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1391 return if (installRootKey(incoming, expectedEpoch)) {1392 AdoptVaultResult.Success(vaultId)1393 } else {1394 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1395 }1396 }13971398 13991400140114021403 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1404 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1405 val bytes = hexToRootKey(rootKeyHex)1406 return try {1407 val words = rootKeyToWords(bytes)1408 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1409 } catch (_: Exception) {1410 null1411 } finally {1412 VaultCrypto.zero(bytes)1413 }1414 }14151416 14171418141914201421 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1422 1423 1424 1425 1426 1427 1428 data class Snapshot(1429 val key: ByteArray,1430 val priorRootKey: ByteArray,1431 val priorRecoveryHash: String?,1432 val priorRecoverySet: Boolean,1433 )1434 val snap = mutateIfCurrentEpoch(expectedEpoch) {1435 val k = derivedKey ?: return@mutateIfCurrentEpoch null1436 if (decoyMode) return@mutateIfCurrentEpoch null1437 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1438 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1439 } ?: run {1440 VaultCrypto.zero(nextRootKey)1441 return false1442 }1443 val priorSlot = store.loadSlotA() ?: run {1444 VaultCrypto.zero(snap.key)1445 VaultCrypto.zero(snap.priorRootKey)1446 VaultCrypto.zero(nextRootKey)1447 return false1448 }1449 val nextWords = rootKeyToWords(nextRootKey)14501451 return try {1452 beforeDurableCommit()1453 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1454 if (mutateIfCurrentEpoch(expectedEpoch) {1455 rootKey = nextRootKey1456 recoveryWordHash = recoveryWordsHash(nextWords)1457 recoveryWordsSet = false1458 } == null) throw IllegalStateException("stale_root_rotation")14591460 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1461 ?: throw IllegalStateException("recovery_escrow_failed")1462 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1463 ?: throw IllegalStateException("root_escrow_failed")1464 val plaintext = VaultCodec.encode(currentPayload())1465 val blob = try {1466 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1467 } finally {1468 VaultCrypto.zero(plaintext)1469 }1470 val nextSlot = priorSlot.copy(1471 blob = blob,1472 recoveryEscrow = nextRecoveryEscrow,1473 rootKeyEscrow = nextRootKeyEscrow,1474 )1475 val committed = withEpochCommit(expectedEpoch) {1476 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1477 try {1478 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1479 1480 1481 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1482 store.stampLocalMutation()1483 } catch (_: Exception) {1484 store.writeSlotA(priorSlot)1485 return@withEpochCommit false1486 }1487 true1488 } ?: false1489 if (!committed) throw IllegalStateException("storage_write_failed")1490 mutateIfCurrentEpoch(expectedEpoch) { publish() }1491 true1492 } catch (_: Exception) {1493 val restored = mutateIfCurrentEpoch(expectedEpoch) {1494 rootKey?.let(VaultCrypto::zero)1495 rootKey = snap.priorRootKey.copyOf()1496 recoveryWordHash = snap.priorRecoveryHash1497 recoveryWordsSet = snap.priorRecoverySet1498 }1499 if (restored != null) {1500 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1501 }1502 false1503 } finally {1504 VaultCrypto.zero(snap.key)1505 VaultCrypto.zero(snap.priorRootKey)1506 1507 1508 1509 val stillOwnedBySession = mutateState {1510 sessionEpoch == expectedEpoch && rootKey === nextRootKey1511 }1512 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1513 }1514 }15151516 15171518151915201521 fun currentVaultKey(): ByteArray? = mutateState {1522 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1523 }15241525 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1526 val expectedEpoch = currentEpoch()1527 val slotA = store.loadSlotA() ?: return false1528 var oldKey: ByteArray? = null1529 var newKey: ByteArray? = null1530 var plaintext: ByteArray? = null1531 return try {1532 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1533 1534 1535 1536 1537 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1538 val payload = try {1539 VaultCodec.decode(decrypted)1540 } finally {1541 VaultCrypto.zero(decrypted)1542 }1543 val newSalt = VaultCrypto.generateSalt()1544 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1545 1546 plaintext = VaultCodec.encode(payload)1547 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1548 1549 1550 1551 1552 1553 1554 1555 val next = StoredVault(1556 version = slotA.version,1557 salt = b64(newSalt),1558 blob = blob,1559 recoveryEscrow = slotA.recoveryEscrow,1560 rootKeyEscrow = slotA.rootKeyEscrow,1561 )1562 1563 1564 1565 1566 val priorB = store.loadSlotB()1567 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1568 1569 1570 1571 if (mutateIfCurrentEpoch(expectedEpoch) {1572 oldKey?.let(VaultCrypto::zero); oldKey = null1573 derivedKey?.let(VaultCrypto::zero)1574 derivedKey = newKey; newKey = null1575 } == null) return false1576 true1577 } catch (_: Exception) {1578 false1579 } finally {1580 oldKey?.let(VaultCrypto::zero)1581 newKey?.let(VaultCrypto::zero)1582 plaintext?.let(VaultCrypto::zero)1583 }1584 }15851586 1587158815891590 suspend fun destroyVault() = mutex.withLock {1591 lock()1592 store.clearVault()1593 store.clearDecoySlot()1594 }15951596 15971598 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1599 val epoch = requireUnlocked()1600 val stamp = VaultCrypto.monotonicEntryTimestamp()1601 val now = System.currentTimeMillis()1602 val entry = withDerivedLast4(draft.copy(1603 id = draft.id.ifBlank { UUID.randomUUID().toString() },1604 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1605 modifiedAt = stamp,1606 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1607 ))1608 1609 1610 val rollback = mutateCurrentSession(epoch) {1611 val r = entryList.toMutableList()1612 entryList.add(entry)1613 r1614 } ?: throw IllegalStateException("vault_locked")1615 persistOrRollback(rollback, epoch)1616 entry1617 }16181619 162016211622162316241625162616271628 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1629 val epoch = requireUnlocked()1630 if (drafts.isEmpty()) return 01631 val now = System.currentTimeMillis()1632 val ready = drafts.map { draft ->1633 val stamp = VaultCrypto.monotonicEntryTimestamp()1634 withDerivedLast4(1635 draft.copy(1636 id = draft.id.ifBlank { UUID.randomUUID().toString() },1637 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1638 modifiedAt = stamp,1639 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1640 ),1641 )1642 }1643 1644 val rollback = mutateCurrentSession(epoch) {1645 val r = entryList.toMutableList()1646 entryList.addAll(ready)1647 r1648 } ?: throw IllegalStateException("vault_locked")1649 persistOrRollback(rollback, epoch)1650 return drafts.size1651 }16521653 1654165516561657165816591660166116621663166416651666 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1667 val epoch = requireUnlocked()1668 val now = System.currentTimeMillis()1669 val ready = newEntries.map { draft ->1670 val stamp = VaultCrypto.monotonicEntryTimestamp()1671 withDerivedLast4(1672 draft.copy(1673 id = draft.id.ifBlank { UUID.randomUUID().toString() },1674 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1675 modifiedAt = stamp,1676 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1677 ),1678 )1679 }1680 1681 1682 1683 1684 1685 1686 val incomingIds = ready.map { it.id }.toHashSet()1687 val rollback = mutateCurrentSession(epoch) {1688 val r = entryList.toMutableList()1689 val replaced = entryList.map { entry ->1690 if (entry.deletedAt != null) return@map entry1691 if (entry.id in incomingIds) return@map null1692 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1693 entry.copy(deletedAt = stamp, modifiedAt = stamp)1694 }.filterNotNull()1695 entryList = (replaced + ready).toMutableList()1696 r1697 } ?: throw IllegalStateException("vault_locked")1698 persistOrRollback(rollback, epoch)1699 return ready.size1700 }17011702 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1703 val epoch = requireUnlocked()1704 1705 1706 1707 1708 val prepared = mutateCurrentSession(epoch) {1709 val idx = entryList.indexOfFirst { it.id == patch.id }1710 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1711 val current = entryList[idx]1712 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1713 1714 val passwordChanged = patch.password != null && patch.password != current.password1715 1716 1717 1718 1719 1720 1721 val priorPassword = current.password1722 val shouldCapture = passwordChanged &&1723 !priorPassword.isNullOrEmpty() &&1724 current.passwordHistory?.firstOrNull()?.password != priorPassword1725 val nextHistory = if (shouldCapture) {1726 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1727 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1728 } else {1729 current.passwordHistory1730 }1731 val updated = withDerivedLast4(patch.copy(1732 createdAt = current.createdAt,1733 modifiedAt = stamp,1734 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1735 passwordHistory = nextHistory,1736 ))1737 val rollback = entryList.toMutableList()1738 entryList[idx] = updated1739 updated to rollback1740 } ?: throw IllegalStateException("vault_locked")1741 val (updated, rollback) = prepared1742 persistOrRollback(rollback, epoch)1743 updated1744 }17451746 1747 suspend fun deleteEntry(id: String) = mutex.withLock {1748 val epoch = requireUnlocked()1749 1750 1751 val rollback = mutateCurrentSession(epoch) {1752 val idx = entryList.indexOfFirst { it.id == id }1753 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1754 val current = entryList[idx]1755 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1756 val prior = entryList.toMutableList()1757 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1758 prior1759 } ?: throw IllegalStateException("vault_locked")1760 persistOrRollback(rollback, epoch)1761 }17621763 suspend fun restoreEntry(id: String) = mutex.withLock {1764 val epoch = requireUnlocked()1765 val rollback = mutateCurrentSession(epoch) {1766 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1767 if (idx == -1) throw IllegalStateException("deleted entry not found")1768 val current = entryList[idx]1769 val prior = entryList.toMutableList()1770 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1771 prior1772 } ?: throw IllegalStateException("vault_locked")1773 persistOrRollback(rollback, epoch)1774 }17751776 1777 suspend fun purgeEntry(id: String) = mutex.withLock {1778 val epoch = requireUnlocked()1779 val rollback = mutateCurrentSession(epoch) {1780 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1781 if (idx == -1) throw IllegalStateException("deleted entry not found")1782 val prior = entryList.toMutableList()1783 entryList.removeAt(idx)1784 prior1785 } ?: throw IllegalStateException("vault_locked")1786 persistOrRollback(rollback, epoch)1787 }17881789 1790 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1791 val epoch = requireUnlocked()1792 1793 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1794 val r = mutateCurrentSession(epoch) {1795 val rollback = entryList.toMutableList()1796 val before = entryList.size1797 entryList.removeAll { it.deletedAt != null }1798 R(rollback, before - entryList.size)1799 } ?: throw IllegalStateException("vault_locked")1800 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1801 r.removed1802 }18031804 1805 suspend fun countDuplicateEntries(): Int = mutex.withLock {1806 val epoch = requireUnlocked()1807 1808 1809 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1810 ?: throw IllegalStateException("vault_locked")1811 }18121813 18141815181618171818 suspend fun deduplicateEntries(): Int = mutex.withLock {1819 val epoch = requireUnlocked()1820 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1821 val ready = mutateCurrentSession(epoch) {1822 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1823 val active = entryList.filter { it.deletedAt == null }1824 val keepIds = mutableSetOf<String>()1825 active.groupBy { it.duplicateKey() }.values.forEach { group ->1826 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1827 }1828 val rollback = entryList.toMutableList()1829 var removed = 01830 entryList = entryList.map { entry ->1831 if (entry.deletedAt == null && entry.id !in keepIds) {1832 removed++1833 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1834 entry.copy(deletedAt = stamp, modifiedAt = stamp)1835 } else {1836 entry1837 }1838 }.toMutableList()1839 R(rollback, removed)1840 } ?: throw IllegalStateException("vault_locked")1841 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1842 ready.removed1843 }18441845 fun getEntries(): List<VaultEntry> = mutateState {1846 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1847 }1848 fun getEntry(id: String): VaultEntry? = mutateState {1849 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1850 }1851 fun getDeletedEntries(): List<VaultEntry> = mutateState {1852 if (!sessionDataAvailable) emptyList()1853 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1854 }18551856 1857 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1858 return mutateState {1859 if (!sessionDataAvailable) return@mutateState emptyList()1860 val active = entryList.filter { it.deletedAt == null }1861 if (tagFilter == null) return@mutateState active1862 val needle = tagFilter.trim().lowercase()1863 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1864 }1865 }18661867 1868 fun getDistinctTags(): List<String> = mutateState {1869 if (!sessionDataAvailable) return@mutateState emptyList()1870 entryList.asSequence()1871 .filter { it.deletedAt == null }1872 .flatMap { it.tags?.asSequence() ?: emptySequence() }1873 .map { it.trim().lowercase() }1874 .filter { it.isNotEmpty() }1875 .distinct()1876 .sorted()1877 .toList()1878 }18791880 188118821883188418851886 fun getDistinctFieldValues(field: String): List<String> {1887 val select: (VaultEntry) -> String? = when (field) {1888 "username" -> { e -> e.username }1889 "email" -> { e -> e.email }1890 else -> return emptyList()1891 }1892 return mutateState {1893 if (!sessionDataAvailable) return@mutateState emptyList()1894 entryList.asSequence()1895 .filter { it.deletedAt == null }1896 .mapNotNull { select(it)?.trim() }1897 .filter { it.isNotEmpty() }1898 .groupingBy { it.lowercase() }1899 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1900 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1901 }1902 .values1903 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1904 .map { it.first }1905 }1906 }19071908 fun getTombstones(): List<Tombstone> = mutateState {1909 if (!sessionDataAvailable) emptyList()1910 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1911 }1912 fun getVaultVersion(): Long = store.version()1913 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19141915 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1916 val expectedEpoch = currentEpoch()1917 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1918 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1919 1920 1921 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1922 val priorA = store.loadSlotA()1923 val priorB = store.loadSlotB()1924 val priorD = store.loadDecoySlot()1925 1926 1927 1928 1929 1930 val priorHistory = store.loadVaultHistory()1931 1932 1933 1934 1935 beforeDurableCommit()1936 if (!takeVaultSnapshot("restore", expectedEpoch)) {1937 return false1938 }1939 1940 1941 1942 val nextD: StoredVault = restoredD ?: run {1943 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161944 val fakeBlob = ByteArray(target).also(rng::nextBytes)1945 val fakeSalt = VaultCrypto.generateSalt()1946 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1947 }1948 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1949 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1950 return false1951 }1952 lock()1953 true1954 }19551956 19571958195919601961196219631964196519661967 private fun commitRestoreAtomic(1968 nextA: StoredVault,1969 nextB: String,1970 nextD: StoredVault,1971 priorA: StoredVault?,1972 priorB: String?,1973 priorD: StoredVault?,1974 expectedEpoch: Long,1975 ): Boolean {1976 return withEpochCommit(expectedEpoch) {1977 if (!store.writeSlotA(nextA)) return@withEpochCommit false1978 1979 1980 1981 try {1982 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1983 1984 1985 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1986 store.stampLocalMutation()1987 } catch (_: Exception) {1988 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1989 return@withEpochCommit false1990 }1991 true1992 } ?: false1993 }19941995 19961997 19981999200020012002 suspend fun applyMergedRemoteEntries(2003 remoteEntries: List<VaultEntry>,2004 remoteTombstones: List<Tombstone>,2005 expectedEpoch: Long? = null,2006 ): Int = mutex.withLock {2007 val epoch = expectedEpoch ?: requireUnlocked()2008 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02009 2010 2011 2012 2013 2014 val mergeNow = System.currentTimeMillis()2015 val clamped = remoteEntries.mapNotNull { entry ->2016 2017 2018 2019 SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2020 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2021 deletedAt = entry.deletedAt?.let {2022 SyncLimits.clampSyncTimestamp(it, mergeNow)2023 },2024 )2025 }2026 val tombMap = remoteTombstones.associate {2027 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2028 }2029 2030 2031 val applied = mutateCurrentSession(epoch) {2032 var n = 02033 val byId = entryList.associateBy { it.id }.toMutableMap()20342035 for (remote in clamped) {2036 val tomb = tombMap[remote.id]2037 if (tomb != null && tomb >= remote.modifiedAt) continue2038 val local = byId[remote.id]2039 if (local == null) {2040 entryList.add(remote); byId[remote.id] = remote; n++2041 } else if (remote.modifiedAt > local.modifiedAt) {2042 val i = entryList.indexOfFirst { it.id == local.id }2043 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2044 }2045 }20462047 for ((id, deletedAt) in tombMap) {2048 val local = byId[id] ?: continue2049 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2050 if (deletedAt < local.modifiedAt) continue2051 val i = entryList.indexOfFirst { it.id == id }2052 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2053 }2054 n2055 } ?: return@withLock 020562057 if (applied > 0) {2058 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2059 mutateIfCurrentEpoch(epoch) { publish() }2060 }2061 applied2062 }20632064 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2065 2066 2067 2068 val epoch = requireUnlocked()2069 val prior = mutateCurrentSession(epoch) {2070 val old = _settings.value2071 _settings.value = settings2072 old2073 } ?: throw IllegalStateException("vault_locked")20742075 val committed = try {2076 beforeDurableCommit()2077 2078 2079 2080 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2081 } catch (_: Exception) {2082 false2083 }2084 if (!committed) {2085 2086 2087 2088 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2089 throw IllegalStateException("settings_write_failed")2090 }2091 }20922093 20942095 2096 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20972098 20992100210121022103 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2104 val epoch = requireUnlocked()2105 2106 2107 2108 2109 2110 2111 val prior = mutateCurrentSession(epoch) {2112 val priorPair = recoveryWordHash to recoveryWordsSet2113 recoveryWordHash = recoveryWordsHash(words)2114 recoveryWordsSet = true2115 priorPair2116 } ?: return@withLock false2117 2118 2119 2120 2121 val persisted = runCatching {2122 persistSlotAWithEscrows(2123 buildRecoveryEscrow(words),2124 buildRootKeyEscrow(words),2125 expectedEpoch = epoch,2126 )2127 }.getOrDefault(false)2128 if (!persisted) {2129 mutateIfCurrentEpoch(epoch) {2130 recoveryWordHash = prior.first2131 recoveryWordsSet = prior.second2132 }2133 }2134 persisted2135 }21362137 2138 fun verifyRecoveryWords(words: List<String>): Boolean {2139 return mutateState {2140 val stored = recoveryWordHash ?: return@mutateState false2141 stored == recoveryWordsHash(words)2142 }2143 }21442145 21462147 2148 fun hasRecoveryEscrow(): Boolean =2149 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21502151 21522153215421552156 private fun ensureRecoveryEscrow() {2157 if (hasRecoveryEscrow()) return2158 val rk = mutateState { rootKey?.copyOf() } ?: return2159 runCatching {2160 val words = rootKeyToWords(rk)2161 val escrow = buildRecoveryEscrow(words)2162 val rkEscrow = buildRootKeyEscrow(words)2163 if (escrow != null || rkEscrow != null) {2164 val prior = store.loadSlotA() ?: return@runCatching2165 store.writeSlotA(prior.copy(2166 recoveryEscrow = escrow ?: prior.recoveryEscrow,2167 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2168 ))2169 mutateState {2170 recoveryWordHash = recoveryWordsHash(words)2171 recoveryWordsSet = true2172 }2173 }2174 }.also { VaultCrypto.zero(rk) }2175 }21762177 217821792180218121822183218421852186218721882189 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2190 val expectedEpoch = currentEpoch()2191 val slotA = store.loadSlotA() ?: return false2192 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21932194 2195 2196 val fullEscrow = slotA.recoveryEscrow2197 if (fullEscrow != null) {2198 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2199 if (material != null) {2200 var keyBytes: ByteArray? = null2201 var plaintext: ByteArray? = null2202 var candidateRoot: ByteArray? = null2203 var stateCommitStarted = false2204 try {2205 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2206 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2207 val payload = VaultCodec.decode(plaintext)2208 2209 2210 2211 2212 val candidateSettings = store.loadSettings()2213 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2214 val decodedRoot = unb64(payload.rootKeyB64)2215 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2216 2217 2218 2219 VaultCrypto.zero(decodedRoot)2220 throw IllegalArgumentException("invalid recovery root key")2221 }2222 decodedRoot2223 } else {2224 generateRecoverableRootKey()2225 }2226 stateCommitStarted = true2227 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2228 clearSessionStateLocked()2229 entryList = payload.entries.toMutableList()2230 duressConfigured = payload.duressConfigured2231 recoveryWordHash = payload.recoveryWordHash2232 recoveryWordsSet = payload.recoveryWordsSet2233 rootKey = candidateRoot2234 candidateRoot = null2235 _settings.value = candidateSettings2236 publish()2237 2238 2239 sessionDataAvailable = true2240 _isUnlocked.value = false2241 true2242 }2243 if (adopted == true) return@withLock true2244 } catch (_: Exception) {2245 2246 2247 2248 2249 if (stateCommitStarted) {2250 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2251 }2252 } finally {2253 plaintext?.let(VaultCrypto::zero)2254 VaultCrypto.zero(material)2255 keyBytes?.let(VaultCrypto::zero)2256 candidateRoot?.let(VaultCrypto::zero)2257 }2258 }2259 }22602261 2262 2263 2264 val rootEscrow = slotA.rootKeyEscrow2265 if (rootEscrow != null) {2266 var candidateRoot: ByteArray? = null2267 var stateCommitStarted = false2268 try {2269 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2270 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2271 val candidateSettings = store.loadSettings()2272 stateCommitStarted = true2273 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2274 clearSessionStateLocked()2275 entryList = mutableListOf()2276 duressConfigured = false2277 recoveryWordHash = recoveryWordsHash(words)2278 recoveryWordsSet = true2279 rootKey = candidateRoot2280 candidateRoot = null2281 _settings.value = candidateSettings2282 publish()2283 sessionDataAvailable = true2284 _isUnlocked.value = false2285 true2286 }2287 if (adopted == true) return@withLock true2288 } catch (_: Exception) {2289 if (stateCommitStarted) {2290 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2291 }2292 } finally {2293 candidateRoot?.let(VaultCrypto::zero)2294 }2295 }2296 return false2297 }22982299 230023012302230323042305230623072308 suspend fun resetPasswordWithRecoveryWords(2309 words: List<String>,2310 newPassword: String,2311 ): Boolean = mutex.withLock {2312 val expectedEpoch = currentEpoch()2313 2314 2315 2316 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2317 val priorA = store.loadSlotA() ?: return false2318 var newKey: ByteArray? = null2319 var plaintext: ByteArray? = null2320 return try {2321 val newSalt = VaultCrypto.generateSalt()2322 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2323 plaintext = VaultCodec.encode(currentPayload())2324 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2325 2326 2327 2328 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2329 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2330 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2331 val next = StoredVault(2332 version = priorA.version,2333 salt = b64(newSalt),2334 blob = blob,2335 recoveryEscrow = freshRecovery,2336 rootKeyEscrow = freshRootKey,2337 )2338 2339 2340 2341 val priorB = store.loadSlotB()2342 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2343 2344 2345 2346 if (mutateIfCurrentEpoch(expectedEpoch) {2347 derivedKey?.let(VaultCrypto::zero)2348 derivedKey = newKey; newKey = null2349 publish()2350 _isUnlocked.value = true2351 } == null) return false2352 true2353 } catch (_: Exception) {2354 false2355 } finally {2356 newKey?.let(VaultCrypto::zero)2357 plaintext?.let(VaultCrypto::zero)2358 }2359 }23602361 236223632364236523662367 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2368 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2369 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2370 var keyBytes: ByteArray? = null2371 var plaintext: ByteArray? = null2372 return try {2373 val salt = VaultCrypto.generateSalt()2374 val recoveryKey = deriveRecoveryKey(material, salt)2375 keyBytes = recoveryKey.encoded2376 plaintext = VaultCodec.encode(currentPayload())2377 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2378 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2379 } finally {2380 VaultCrypto.zero(material)2381 keyBytes?.let(VaultCrypto::zero)2382 plaintext?.let(VaultCrypto::zero)2383 }2384 }23852386 23872388238923902391239223932394 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2395 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2396 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2397 val rk = mutateState { rootKey?.copyOf() } ?: run {2398 VaultCrypto.zero(material)2399 return null2400 }2401 var keyBytes: ByteArray? = null2402 var plaintext: ByteArray? = null2403 return try {2404 val salt = VaultCrypto.generateSalt()2405 val recoveryKey = deriveRecoveryKey(material, salt)2406 keyBytes = recoveryKey.encoded2407 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2408 2409 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2410 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2411 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2412 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2413 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2414 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2415 } finally {2416 VaultCrypto.zero(rk)2417 VaultCrypto.zero(material)2418 keyBytes?.let(VaultCrypto::zero)2419 plaintext?.let(VaultCrypto::zero)2420 }2421 }24222423 2424 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2425 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2426 var keyBytes: ByteArray? = null2427 var pt: ByteArray? = null2428 return try {2429 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2430 keyBytes = recoveryKey.encoded2431 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2432 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2433 hexToRootKey(String(pt, Charsets.US_ASCII))2434 } catch (_: Exception) {2435 null2436 } finally {2437 2438 2439 2440 VaultCrypto.zero(material)2441 keyBytes?.let(VaultCrypto::zero)2442 pt?.let(VaultCrypto::zero)2443 }2444 }24452446 244724482449245024512452 private fun persistSlotAWithEscrows(2453 recoveryEscrow: RecoveryEscrow?,2454 rootKeyEscrow: RootKeyEscrow?,2455 expectedEpoch: Long? = null,2456 ): Boolean {2457 val snapshot = snapshotForPersist(expectedEpoch)2458 ?: throw IllegalStateException("vault_locked_or_stale")2459 if (snapshot.decoy) {2460 VaultCrypto.zero(snapshot.key)2461 return false2462 }2463 try {2464 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2465 val plaintext = VaultCodec.encode(snapshot.payload)2466 val blob = try {2467 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2468 } finally {2469 2470 2471 VaultCrypto.zero(plaintext)2472 }2473 val next = prior.copy(2474 blob = blob,2475 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2476 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2477 )2478 beforeDurableCommit()2479 return withEpochCommit(snapshot.epoch) {2480 if (!store.writeSlotA(next)) return@withEpochCommit false2481 try {2482 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2483 2484 2485 2486 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2487 } catch (e: Exception) {2488 store.writeSlotA(prior)2489 throw e2490 }2491 true2492 } ?: false2493 } finally {2494 VaultCrypto.zero(snapshot.key)2495 }2496 }24972498 24992500 private fun requireUnlocked(): Long {2501 2502 2503 2504 stateLock.lock()2505 try {2506 if (derivedKey == null) throw IllegalStateException("vault_locked")2507 return sessionEpoch2508 } finally {2509 stateLock.unlock()2510 }2511 }25122513 2514 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25152516 private fun countDuplicateEntriesLocked(): Int {2517 val seen = mutableMapOf<String, VaultEntry>()2518 var duplicates = 02519 for (entry in entryList.filter { it.deletedAt == null }) {2520 val key = entry.duplicateKey()2521 val existing = seen[key]2522 if (existing == null) {2523 seen[key] = entry2524 } else {2525 duplicates++2526 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2527 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2528 if (entryStamp > existingStamp) seen[key] = entry2529 }2530 }2531 return duplicates2532 }25332534 private fun VaultEntry.duplicateKey(): String {2535 if (type == "note") return "note|${title.lowercase()}"2536 val passkeyData = passkey2537 if (passkeyData != null) {2538 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2539 }2540 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2541 }25422543 254425452546254725482549 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2550 val epoch = expectedEpoch ?: currentEpoch()2551 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2552 val slotA = store.loadSlotA() ?: return false2553 val vaultA = VaultCodec.encodeSlot(slotA)2554 val vaultB = store.loadSlotB() ?: slotA.blob2555 2556 2557 2558 2559 2560 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2561 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162562 val fakeBlob = ByteArray(target).also(rng::nextBytes)2563 val fakeSalt = VaultCrypto.generateSalt()2564 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2565 }2566 val history = store.loadVaultHistory()2567 val latest = history.snapshots.firstOrNull()2568 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2569 val entryCount = mutateIfCurrentEpoch(epoch) {2570 if (derivedKey != null && !decoyMode) {2571 bucketEntryCount(entryList.count { it.deletedAt == null })2572 } else {2573 null2574 }2575 } ?: return false2576 return try {2577 val snapshot = VaultSnapshot(2578 id = UUID.randomUUID().toString(),2579 savedAt = System.currentTimeMillis(),2580 reason = reason,2581 vault_a = vaultA,2582 vault_b = vaultB,2583 vault_d = vaultD,2584 entryCount = entryCount,2585 )2586 2587 2588 2589 withEpochCommit(epoch) {2590 store.writeVaultHistory(2591 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2592 )2593 } ?: false2594 } catch (_: Exception) {2595 false2596 }2597 }25982599 private fun bucketEntryCount(n: Int): Int = when {2600 n <= 0 -> 02601 n <= 10 -> 102602 n <= 25 -> 252603 n <= 50 -> 502604 n <= 100 -> 1002605 n <= 250 -> 2502606 n <= 500 -> 5002607 else -> ((n + 499) / 500) * 5002608 }26092610 261126122613261426152616261726182619 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2620 if (!entry.isCard()) return entry2621 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2622 val derived = if (digits.length >= 4) digits.takeLast(4) else null2623 return entry.copy(last4 = derived ?: entry.last4)2624 }26252626 262726282629263026312632 private fun autoPurgeExpiredTrash() {2633 val days = _settings.value.trashRetentionDays ?: return2634 if (days <= 0) return2635 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2636 val before = entryList.size2637 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2638 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2639 }26402641 264226432644264526462647 private fun consolidatePasskeyEntries() {2648 val passkeyOnly = entryList.filter { entry ->2649 entry.deletedAt == null &&2650 entry.isLogin() &&2651 entry.password.isNullOrBlank() &&2652 !entry.rpId.isNullOrBlank() &&2653 !entry.credentialId.isNullOrBlank()2654 }2655 if (passkeyOnly.isEmpty()) return2656 var changed = false2657 val now = System.currentTimeMillis()2658 for (pkEntry in passkeyOnly) {2659 val rpId = pkEntry.rpId ?: continue2660 val target = entryList.firstOrNull { candidate ->2661 candidate.deletedAt == null &&2662 candidate.id != pkEntry.id &&2663 candidate.isLogin() &&2664 candidate.passkey == null &&2665 candidate.credentialId.isNullOrBlank() &&2666 !candidate.password.isNullOrBlank() &&2667 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2668 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2669 } ?: continue2670 val idx = entryList.indexOfFirst { it.id == target.id }2671 if (idx < 0) continue2672 entryList[idx] = target.copy(2673 rpId = pkEntry.rpId,2674 rpName = pkEntry.rpName ?: target.rpName,2675 credentialId = pkEntry.credentialId,2676 publicKeyCose = pkEntry.publicKeyCose,2677 privateKeyJwk = pkEntry.privateKeyJwk,2678 userHandle = pkEntry.userHandle,2679 userName = pkEntry.userName,2680 userDisplayName = pkEntry.userDisplayName,2681 algorithm = pkEntry.algorithm,2682 signCount = pkEntry.signCount,2683 lastUsedAt = pkEntry.lastUsedAt,2684 modifiedAt = now,2685 )2686 2687 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2688 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2689 changed = true2690 }2691 if (changed) runCatching { persistSlotA(bumpVersion = false) }2692 }26932694 2695 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }26962697 private fun currentPayloadLocked(): VaultPayload =2698 VaultPayload(2699 entries = entryList.toList(),2700 duressConfigured = duressConfigured,2701 rootKeyB64 = rootKey?.let(::b64) ?: "",2702 recoveryWordHash = recoveryWordHash,2703 recoveryWordsSet = recoveryWordsSet,2704 )27052706 27072708270927102711 private fun ensureRootKey(payload: VaultPayload) {2712 if (payload.rootKeyB64.isNotBlank()) {2713 rootKey = unb64(payload.rootKeyB64)2714 return2715 }2716 rootKey = generateRecoverableRootKey()2717 runCatching { persistSlotA(bumpVersion = false) }2718 }27192720 private fun publish() {2721 _entries.value = entryList.filter { it.deletedAt == null }2722 }27232724 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2725 var plaintext = VaultCodec.encode(currentPayload())2726 return try {2727 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2728 StoredVault(version = 1, salt = b64(salt), blob = blob)2729 } finally {2730 2731 2732 2733 VaultCrypto.zero(plaintext)2734 }2735 }27362737 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2738 2739 2740 2741 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2742 val noise = ByteArray(approxBytes).also(rng::nextBytes)2743 return store.writeSlotB(b64(noise))2744 }27452746 274727482749 private fun matchingCiphertextLength(slotA: StoredVault): Int =2750 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27512752 27532754275527562757 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2758 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162759 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2760 val fakeSalt = VaultCrypto.generateSalt()2761 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2762 }27632764 276527662767276827692770 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2771 val isConfigured = mutateState { duressConfigured }2772 return if (isConfigured) true else writeFakeDuressSlot(slotA)2773 }27742775 2776277727782779278027812782 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2783 val salt = unhex(b.salt)2784 val nonce = unhex(b.nonce)2785 val ct = unhex(b.ciphertext)2786 val tag = unhex(b.tag)2787 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2788 System.arraycopy(nonce, 0, it, 0, nonce.size)2789 System.arraycopy(ct, 0, it, nonce.size, ct.size)2790 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2791 }2792 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2793 }27942795 2796 private fun persistSlotA(2797 bumpVersion: Boolean,2798 snapshotReason: String? = "local_save",2799 expectedEpoch: Long? = null,2800 ): Boolean {2801 val snapshot = snapshotForPersist(expectedEpoch)2802 ?: throw IllegalStateException("vault_locked_or_stale")2803 return try {2804 2805 if (snapshot.decoy) {2806 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2807 val plaintext = VaultCodec.encode(snapshot.payload)2808 val blob = try {2809 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2810 } finally {2811 2812 2813 VaultCrypto.zero(plaintext)2814 }2815 beforeDurableCommit()2816 return withEpochCommit(snapshot.epoch) {2817 store.writeDecoySlot(prior.copy(blob = blob))2818 } ?: false2819 }2820 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2821 2822 2823 2824 2825 val priorB = store.loadSlotB()2826 beforeDurableCommit()2827 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2828 val plaintext = VaultCodec.encode(snapshot.payload)2829 val blob = try {2830 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2831 } finally {2832 2833 2834 VaultCrypto.zero(plaintext)2835 }2836 val next = prior.copy(blob = blob)2837 2838 2839 2840 2841 withEpochCommit(snapshot.epoch) {2842 if (!store.writeSlotA(next)) return@withEpochCommit false2843 try {2844 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2845 2846 2847 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2848 if (bumpVersion) store.stampLocalMutation()2849 } catch (e: Exception) {2850 store.writeSlotA(prior)2851 if (priorB != null) runCatching { store.writeSlotB(priorB) }2852 throw e2853 }2854 true2855 } ?: false2856 } finally {2857 2858 2859 VaultCrypto.zero(snapshot.key)2860 }2861 }28622863 2864286528662867286828692870 private fun commitVaultRotation(2871 priorA: StoredVault,2872 priorB: String?,2873 nextA: StoredVault,2874 expectedEpoch: Long,2875 ): Boolean {2876 beforeDurableCommit()2877 return withEpochCommit(expectedEpoch) {2878 if (!store.writeSlotA(nextA)) return@withEpochCommit false2879 2880 2881 2882 try {2883 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2884 2885 2886 2887 2888 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2889 store.stampLocalMutation()2890 } catch (_: Exception) {2891 rollbackRotation(priorA, priorB)2892 return@withEpochCommit false2893 }2894 true2895 } ?: false2896 }28972898 28992900290129022903 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2904 val failures = mutableListOf<String>()2905 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2906 .onFailure { failures += "writeSlotA: $it" }2907 if (priorB != null) {2908 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2909 .onFailure { failures += "writeSlotB: $it" }2910 }2911 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2912 }29132914 2915 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2916 try {2917 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2918 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2919 throw IllegalStateException("storage_write_failed")2920 }2921 } catch (e: Exception) {2922 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2923 throw e2924 }2925 2926 2927 2928 mutateIfCurrentEpoch(expectedEpoch) { publish() }2929 }2930}