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.map { entry ->2016 entry.copy(2017 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2018 deletedAt = entry.deletedAt?.let {2019 SyncLimits.clampSyncTimestamp(it, mergeNow)2020 },2021 )2022 }2023 val tombMap = remoteTombstones.associate {2024 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2025 }2026 2027 2028 val applied = mutateCurrentSession(epoch) {2029 var n = 02030 val byId = entryList.associateBy { it.id }.toMutableMap()20312032 for (remote in clamped) {2033 val tomb = tombMap[remote.id]2034 if (tomb != null && tomb >= remote.modifiedAt) continue2035 val local = byId[remote.id]2036 if (local == null) {2037 entryList.add(remote); byId[remote.id] = remote; n++2038 } else if (remote.modifiedAt > local.modifiedAt) {2039 val i = entryList.indexOfFirst { it.id == local.id }2040 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2041 }2042 }20432044 for ((id, deletedAt) in tombMap) {2045 val local = byId[id] ?: continue2046 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2047 if (deletedAt < local.modifiedAt) continue2048 val i = entryList.indexOfFirst { it.id == id }2049 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2050 }2051 n2052 } ?: return@withLock 020532054 if (applied > 0) {2055 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2056 mutateIfCurrentEpoch(epoch) { publish() }2057 }2058 applied2059 }20602061 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2062 2063 2064 2065 val epoch = requireUnlocked()2066 val prior = mutateCurrentSession(epoch) {2067 val old = _settings.value2068 _settings.value = settings2069 old2070 } ?: throw IllegalStateException("vault_locked")20712072 val committed = try {2073 beforeDurableCommit()2074 2075 2076 2077 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2078 } catch (_: Exception) {2079 false2080 }2081 if (!committed) {2082 2083 2084 2085 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2086 throw IllegalStateException("settings_write_failed")2087 }2088 }20892090 20912092 2093 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20942095 20962097209820992100 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2101 val epoch = requireUnlocked()2102 2103 2104 2105 2106 2107 2108 val prior = mutateCurrentSession(epoch) {2109 val priorPair = recoveryWordHash to recoveryWordsSet2110 recoveryWordHash = recoveryWordsHash(words)2111 recoveryWordsSet = true2112 priorPair2113 } ?: return@withLock false2114 2115 2116 2117 2118 val persisted = runCatching {2119 persistSlotAWithEscrows(2120 buildRecoveryEscrow(words),2121 buildRootKeyEscrow(words),2122 expectedEpoch = epoch,2123 )2124 }.getOrDefault(false)2125 if (!persisted) {2126 mutateIfCurrentEpoch(epoch) {2127 recoveryWordHash = prior.first2128 recoveryWordsSet = prior.second2129 }2130 }2131 persisted2132 }21332134 2135 fun verifyRecoveryWords(words: List<String>): Boolean {2136 return mutateState {2137 val stored = recoveryWordHash ?: return@mutateState false2138 stored == recoveryWordsHash(words)2139 }2140 }21412142 21432144 2145 fun hasRecoveryEscrow(): Boolean =2146 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21472148 21492150215121522153 private fun ensureRecoveryEscrow() {2154 if (hasRecoveryEscrow()) return2155 val rk = mutateState { rootKey?.copyOf() } ?: return2156 runCatching {2157 val words = rootKeyToWords(rk)2158 val escrow = buildRecoveryEscrow(words)2159 val rkEscrow = buildRootKeyEscrow(words)2160 if (escrow != null || rkEscrow != null) {2161 val prior = store.loadSlotA() ?: return@runCatching2162 store.writeSlotA(prior.copy(2163 recoveryEscrow = escrow ?: prior.recoveryEscrow,2164 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2165 ))2166 mutateState {2167 recoveryWordHash = recoveryWordsHash(words)2168 recoveryWordsSet = true2169 }2170 }2171 }.also { VaultCrypto.zero(rk) }2172 }21732174 217521762177217821792180218121822183218421852186 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2187 val expectedEpoch = currentEpoch()2188 val slotA = store.loadSlotA() ?: return false2189 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21902191 2192 2193 val fullEscrow = slotA.recoveryEscrow2194 if (fullEscrow != null) {2195 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2196 if (material != null) {2197 var keyBytes: ByteArray? = null2198 var plaintext: ByteArray? = null2199 var candidateRoot: ByteArray? = null2200 var stateCommitStarted = false2201 try {2202 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2203 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2204 val payload = VaultCodec.decode(plaintext)2205 2206 2207 2208 2209 val candidateSettings = store.loadSettings()2210 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2211 val decodedRoot = unb64(payload.rootKeyB64)2212 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2213 2214 2215 2216 VaultCrypto.zero(decodedRoot)2217 throw IllegalArgumentException("invalid recovery root key")2218 }2219 decodedRoot2220 } else {2221 generateRecoverableRootKey()2222 }2223 stateCommitStarted = true2224 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2225 clearSessionStateLocked()2226 entryList = payload.entries.toMutableList()2227 duressConfigured = payload.duressConfigured2228 recoveryWordHash = payload.recoveryWordHash2229 recoveryWordsSet = payload.recoveryWordsSet2230 rootKey = candidateRoot2231 candidateRoot = null2232 _settings.value = candidateSettings2233 publish()2234 2235 2236 sessionDataAvailable = true2237 _isUnlocked.value = false2238 true2239 }2240 if (adopted == true) return@withLock true2241 } catch (_: Exception) {2242 2243 2244 2245 2246 if (stateCommitStarted) {2247 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2248 }2249 } finally {2250 plaintext?.let(VaultCrypto::zero)2251 VaultCrypto.zero(material)2252 keyBytes?.let(VaultCrypto::zero)2253 candidateRoot?.let(VaultCrypto::zero)2254 }2255 }2256 }22572258 2259 2260 2261 val rootEscrow = slotA.rootKeyEscrow2262 if (rootEscrow != null) {2263 var candidateRoot: ByteArray? = null2264 var stateCommitStarted = false2265 try {2266 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2267 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2268 val candidateSettings = store.loadSettings()2269 stateCommitStarted = true2270 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2271 clearSessionStateLocked()2272 entryList = mutableListOf()2273 duressConfigured = false2274 recoveryWordHash = recoveryWordsHash(words)2275 recoveryWordsSet = true2276 rootKey = candidateRoot2277 candidateRoot = null2278 _settings.value = candidateSettings2279 publish()2280 sessionDataAvailable = true2281 _isUnlocked.value = false2282 true2283 }2284 if (adopted == true) return@withLock true2285 } catch (_: Exception) {2286 if (stateCommitStarted) {2287 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2288 }2289 } finally {2290 candidateRoot?.let(VaultCrypto::zero)2291 }2292 }2293 return false2294 }22952296 229722982299230023012302230323042305 suspend fun resetPasswordWithRecoveryWords(2306 words: List<String>,2307 newPassword: String,2308 ): Boolean = mutex.withLock {2309 val expectedEpoch = currentEpoch()2310 2311 2312 2313 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2314 val priorA = store.loadSlotA() ?: return false2315 var newKey: ByteArray? = null2316 var plaintext: ByteArray? = null2317 return try {2318 val newSalt = VaultCrypto.generateSalt()2319 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2320 plaintext = VaultCodec.encode(currentPayload())2321 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2322 2323 2324 2325 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2326 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2327 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2328 val next = StoredVault(2329 version = priorA.version,2330 salt = b64(newSalt),2331 blob = blob,2332 recoveryEscrow = freshRecovery,2333 rootKeyEscrow = freshRootKey,2334 )2335 2336 2337 2338 val priorB = store.loadSlotB()2339 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2340 2341 2342 2343 if (mutateIfCurrentEpoch(expectedEpoch) {2344 derivedKey?.let(VaultCrypto::zero)2345 derivedKey = newKey; newKey = null2346 publish()2347 _isUnlocked.value = true2348 } == null) return false2349 true2350 } catch (_: Exception) {2351 false2352 } finally {2353 newKey?.let(VaultCrypto::zero)2354 plaintext?.let(VaultCrypto::zero)2355 }2356 }23572358 235923602361236223632364 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2365 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2366 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2367 var keyBytes: ByteArray? = null2368 var plaintext: ByteArray? = null2369 return try {2370 val salt = VaultCrypto.generateSalt()2371 val recoveryKey = deriveRecoveryKey(material, salt)2372 keyBytes = recoveryKey.encoded2373 plaintext = VaultCodec.encode(currentPayload())2374 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2375 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2376 } finally {2377 VaultCrypto.zero(material)2378 keyBytes?.let(VaultCrypto::zero)2379 plaintext?.let(VaultCrypto::zero)2380 }2381 }23822383 23842385238623872388238923902391 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2392 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2393 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2394 val rk = mutateState { rootKey?.copyOf() } ?: run {2395 VaultCrypto.zero(material)2396 return null2397 }2398 var keyBytes: ByteArray? = null2399 var plaintext: ByteArray? = null2400 return try {2401 val salt = VaultCrypto.generateSalt()2402 val recoveryKey = deriveRecoveryKey(material, salt)2403 keyBytes = recoveryKey.encoded2404 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2405 2406 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2407 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2408 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2409 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2410 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2411 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2412 } finally {2413 VaultCrypto.zero(rk)2414 VaultCrypto.zero(material)2415 keyBytes?.let(VaultCrypto::zero)2416 plaintext?.let(VaultCrypto::zero)2417 }2418 }24192420 2421 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2422 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2423 var keyBytes: ByteArray? = null2424 var pt: ByteArray? = null2425 return try {2426 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2427 keyBytes = recoveryKey.encoded2428 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2429 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2430 hexToRootKey(String(pt, Charsets.US_ASCII))2431 } catch (_: Exception) {2432 null2433 } finally {2434 2435 2436 2437 VaultCrypto.zero(material)2438 keyBytes?.let(VaultCrypto::zero)2439 pt?.let(VaultCrypto::zero)2440 }2441 }24422443 244424452446244724482449 private fun persistSlotAWithEscrows(2450 recoveryEscrow: RecoveryEscrow?,2451 rootKeyEscrow: RootKeyEscrow?,2452 expectedEpoch: Long? = null,2453 ): Boolean {2454 val snapshot = snapshotForPersist(expectedEpoch)2455 ?: throw IllegalStateException("vault_locked_or_stale")2456 if (snapshot.decoy) {2457 VaultCrypto.zero(snapshot.key)2458 return false2459 }2460 try {2461 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2462 val plaintext = VaultCodec.encode(snapshot.payload)2463 val blob = try {2464 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2465 } finally {2466 2467 2468 VaultCrypto.zero(plaintext)2469 }2470 val next = prior.copy(2471 blob = blob,2472 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2473 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2474 )2475 beforeDurableCommit()2476 return withEpochCommit(snapshot.epoch) {2477 if (!store.writeSlotA(next)) return@withEpochCommit false2478 try {2479 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2480 2481 2482 2483 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2484 } catch (e: Exception) {2485 store.writeSlotA(prior)2486 throw e2487 }2488 true2489 } ?: false2490 } finally {2491 VaultCrypto.zero(snapshot.key)2492 }2493 }24942495 24962497 private fun requireUnlocked(): Long {2498 2499 2500 2501 stateLock.lock()2502 try {2503 if (derivedKey == null) throw IllegalStateException("vault_locked")2504 return sessionEpoch2505 } finally {2506 stateLock.unlock()2507 }2508 }25092510 2511 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25122513 private fun countDuplicateEntriesLocked(): Int {2514 val seen = mutableMapOf<String, VaultEntry>()2515 var duplicates = 02516 for (entry in entryList.filter { it.deletedAt == null }) {2517 val key = entry.duplicateKey()2518 val existing = seen[key]2519 if (existing == null) {2520 seen[key] = entry2521 } else {2522 duplicates++2523 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2524 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2525 if (entryStamp > existingStamp) seen[key] = entry2526 }2527 }2528 return duplicates2529 }25302531 private fun VaultEntry.duplicateKey(): String {2532 if (type == "note") return "note|${title.lowercase()}"2533 val passkeyData = passkey2534 if (passkeyData != null) {2535 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2536 }2537 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2538 }25392540 254125422543254425452546 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2547 val epoch = expectedEpoch ?: currentEpoch()2548 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2549 val slotA = store.loadSlotA() ?: return false2550 val vaultA = VaultCodec.encodeSlot(slotA)2551 val vaultB = store.loadSlotB() ?: slotA.blob2552 2553 2554 2555 2556 2557 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2558 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162559 val fakeBlob = ByteArray(target).also(rng::nextBytes)2560 val fakeSalt = VaultCrypto.generateSalt()2561 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2562 }2563 val history = store.loadVaultHistory()2564 val latest = history.snapshots.firstOrNull()2565 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2566 val entryCount = mutateIfCurrentEpoch(epoch) {2567 if (derivedKey != null && !decoyMode) {2568 bucketEntryCount(entryList.count { it.deletedAt == null })2569 } else {2570 null2571 }2572 } ?: return false2573 return try {2574 val snapshot = VaultSnapshot(2575 id = UUID.randomUUID().toString(),2576 savedAt = System.currentTimeMillis(),2577 reason = reason,2578 vault_a = vaultA,2579 vault_b = vaultB,2580 vault_d = vaultD,2581 entryCount = entryCount,2582 )2583 2584 2585 2586 withEpochCommit(epoch) {2587 store.writeVaultHistory(2588 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2589 )2590 } ?: false2591 } catch (_: Exception) {2592 false2593 }2594 }25952596 private fun bucketEntryCount(n: Int): Int = when {2597 n <= 0 -> 02598 n <= 10 -> 102599 n <= 25 -> 252600 n <= 50 -> 502601 n <= 100 -> 1002602 n <= 250 -> 2502603 n <= 500 -> 5002604 else -> ((n + 499) / 500) * 5002605 }26062607 260826092610261126122613261426152616 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2617 if (!entry.isCard()) return entry2618 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2619 val derived = if (digits.length >= 4) digits.takeLast(4) else null2620 return entry.copy(last4 = derived ?: entry.last4)2621 }26222623 262426252626262726282629 private fun autoPurgeExpiredTrash() {2630 val days = _settings.value.trashRetentionDays ?: return2631 if (days <= 0) return2632 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2633 val before = entryList.size2634 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2635 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2636 }26372638 263926402641264226432644 private fun consolidatePasskeyEntries() {2645 val passkeyOnly = entryList.filter { entry ->2646 entry.deletedAt == null &&2647 entry.isLogin() &&2648 entry.password.isNullOrBlank() &&2649 !entry.rpId.isNullOrBlank() &&2650 !entry.credentialId.isNullOrBlank()2651 }2652 if (passkeyOnly.isEmpty()) return2653 var changed = false2654 val now = System.currentTimeMillis()2655 for (pkEntry in passkeyOnly) {2656 val rpId = pkEntry.rpId ?: continue2657 val target = entryList.firstOrNull { candidate ->2658 candidate.deletedAt == null &&2659 candidate.id != pkEntry.id &&2660 candidate.isLogin() &&2661 candidate.passkey == null &&2662 candidate.credentialId.isNullOrBlank() &&2663 !candidate.password.isNullOrBlank() &&2664 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2665 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2666 } ?: continue2667 val idx = entryList.indexOfFirst { it.id == target.id }2668 if (idx < 0) continue2669 entryList[idx] = target.copy(2670 rpId = pkEntry.rpId,2671 rpName = pkEntry.rpName ?: target.rpName,2672 credentialId = pkEntry.credentialId,2673 publicKeyCose = pkEntry.publicKeyCose,2674 privateKeyJwk = pkEntry.privateKeyJwk,2675 userHandle = pkEntry.userHandle,2676 userName = pkEntry.userName,2677 userDisplayName = pkEntry.userDisplayName,2678 algorithm = pkEntry.algorithm,2679 signCount = pkEntry.signCount,2680 lastUsedAt = pkEntry.lastUsedAt,2681 modifiedAt = now,2682 )2683 2684 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2685 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2686 changed = true2687 }2688 if (changed) runCatching { persistSlotA(bumpVersion = false) }2689 }26902691 2692 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }26932694 private fun currentPayloadLocked(): VaultPayload =2695 VaultPayload(2696 entries = entryList.toList(),2697 duressConfigured = duressConfigured,2698 rootKeyB64 = rootKey?.let(::b64) ?: "",2699 recoveryWordHash = recoveryWordHash,2700 recoveryWordsSet = recoveryWordsSet,2701 )27022703 27042705270627072708 private fun ensureRootKey(payload: VaultPayload) {2709 if (payload.rootKeyB64.isNotBlank()) {2710 rootKey = unb64(payload.rootKeyB64)2711 return2712 }2713 rootKey = generateRecoverableRootKey()2714 runCatching { persistSlotA(bumpVersion = false) }2715 }27162717 private fun publish() {2718 _entries.value = entryList.filter { it.deletedAt == null }2719 }27202721 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2722 var plaintext = VaultCodec.encode(currentPayload())2723 return try {2724 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2725 StoredVault(version = 1, salt = b64(salt), blob = blob)2726 } finally {2727 2728 2729 2730 VaultCrypto.zero(plaintext)2731 }2732 }27332734 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2735 2736 2737 2738 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2739 val noise = ByteArray(approxBytes).also(rng::nextBytes)2740 return store.writeSlotB(b64(noise))2741 }27422743 274427452746 private fun matchingCiphertextLength(slotA: StoredVault): Int =2747 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27482749 27502751275227532754 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2755 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162756 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2757 val fakeSalt = VaultCrypto.generateSalt()2758 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2759 }27602761 276227632764276527662767 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2768 val isConfigured = mutateState { duressConfigured }2769 return if (isConfigured) true else writeFakeDuressSlot(slotA)2770 }27712772 2773277427752776277727782779 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2780 val salt = unhex(b.salt)2781 val nonce = unhex(b.nonce)2782 val ct = unhex(b.ciphertext)2783 val tag = unhex(b.tag)2784 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2785 System.arraycopy(nonce, 0, it, 0, nonce.size)2786 System.arraycopy(ct, 0, it, nonce.size, ct.size)2787 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2788 }2789 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2790 }27912792 2793 private fun persistSlotA(2794 bumpVersion: Boolean,2795 snapshotReason: String? = "local_save",2796 expectedEpoch: Long? = null,2797 ): Boolean {2798 val snapshot = snapshotForPersist(expectedEpoch)2799 ?: throw IllegalStateException("vault_locked_or_stale")2800 return try {2801 2802 if (snapshot.decoy) {2803 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2804 val plaintext = VaultCodec.encode(snapshot.payload)2805 val blob = try {2806 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2807 } finally {2808 2809 2810 VaultCrypto.zero(plaintext)2811 }2812 beforeDurableCommit()2813 return withEpochCommit(snapshot.epoch) {2814 store.writeDecoySlot(prior.copy(blob = blob))2815 } ?: false2816 }2817 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2818 2819 2820 2821 2822 val priorB = store.loadSlotB()2823 beforeDurableCommit()2824 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2825 val plaintext = VaultCodec.encode(snapshot.payload)2826 val blob = try {2827 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2828 } finally {2829 2830 2831 VaultCrypto.zero(plaintext)2832 }2833 val next = prior.copy(blob = blob)2834 2835 2836 2837 2838 withEpochCommit(snapshot.epoch) {2839 if (!store.writeSlotA(next)) return@withEpochCommit false2840 try {2841 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2842 2843 2844 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2845 if (bumpVersion) store.stampLocalMutation()2846 } catch (e: Exception) {2847 store.writeSlotA(prior)2848 if (priorB != null) runCatching { store.writeSlotB(priorB) }2849 throw e2850 }2851 true2852 } ?: false2853 } finally {2854 2855 2856 VaultCrypto.zero(snapshot.key)2857 }2858 }28592860 2861286228632864286528662867 private fun commitVaultRotation(2868 priorA: StoredVault,2869 priorB: String?,2870 nextA: StoredVault,2871 expectedEpoch: Long,2872 ): Boolean {2873 beforeDurableCommit()2874 return withEpochCommit(expectedEpoch) {2875 if (!store.writeSlotA(nextA)) return@withEpochCommit false2876 2877 2878 2879 try {2880 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2881 2882 2883 2884 2885 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2886 store.stampLocalMutation()2887 } catch (_: Exception) {2888 rollbackRotation(priorA, priorB)2889 return@withEpochCommit false2890 }2891 true2892 } ?: false2893 }28942895 28962897289828992900 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2901 val failures = mutableListOf<String>()2902 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2903 .onFailure { failures += "writeSlotA: $it" }2904 if (priorB != null) {2905 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2906 .onFailure { failures += "writeSlotB: $it" }2907 }2908 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2909 }29102911 2912 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2913 try {2914 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2915 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2916 throw IllegalStateException("storage_write_failed")2917 }2918 } catch (e: Exception) {2919 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2920 throw e2921 }2922 2923 2924 2925 mutateIfCurrentEpoch(expectedEpoch) { publish() }2926 }2927}