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 AdoptVaultResult.Success(vaultId)596 } catch (e: Exception) {597 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")598 } finally {599 adoptedRootKey?.let(VaultCrypto::zero)600 nextDerivedKey?.let(VaultCrypto::zero)601 sessionRootKey?.let(VaultCrypto::zero)602 }603 }604605 suspend fun unlock(password: String): UnlockResult = mutex.withLock {606 unlockLocked(password, currentEpoch())607 }608609 610 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {611 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")612 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))613 val payload = try {614 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)615 try {616 VaultCodec.decode(plaintext)617 } finally {618 VaultCrypto.zero(plaintext)619 }620 } catch (_: Exception) {621 VaultCrypto.zero(key)622 623 624 625 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {626 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)627 if (legacy != null) return legacy628 }629 return UnlockResult.WrongPassword630 }631 return adoptSlotA(key, payload, expectedEpoch)632 }633634 635636637638639640641642 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {643 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))644 var newKey: ByteArray? = null645 return try {646 val payload = try {647 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)648 try {649 VaultCodec.decode(plaintext)650 } finally {651 VaultCrypto.zero(plaintext)652 }653 } catch (_: Exception) {654 return null655 }656 657 658 val newSalt = VaultCrypto.generateSalt()659 newKey = VaultCrypto.deriveKey(password, newSalt)660 val plaintext = VaultCodec.encode(payload)661 val blob = try {662 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))663 } finally {664 VaultCrypto.zero(plaintext)665 }666 val next = StoredVault(667 version = slotA.version,668 salt = b64(newSalt),669 blob = blob,670 recoveryEscrow = slotA.recoveryEscrow,671 rootKeyEscrow = slotA.rootKeyEscrow,672 )673 674 675 676 val priorB = store.loadSlotB()677 beforeDurableCommit()678 val healed = withEpochCommit(expectedEpoch) {679 if (!store.writeSlotA(next)) {680 false681 } else try {682 if (!writeMatchingNoiseSlotB(next)) {683 runCatching { store.writeSlotA(slotA) }684 if (priorB != null) runCatching { store.writeSlotB(priorB) }685 false686 } else if (!writeCompanionDuressSlot(next)) {687 runCatching { store.writeSlotA(slotA) }688 if (priorB != null) runCatching { store.writeSlotB(priorB) }689 false690 } else {691 store.stampLocalMutation()692 true693 }694 } catch (_: Exception) {695 runCatching { store.writeSlotA(slotA) }696 if (priorB != null) runCatching { store.writeSlotB(priorB) }697 false698 }699 } ?: false700 if (healed) {701 VaultCrypto.zero(rawKey!!)702 rawKey = null703 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 704 newKey = null705 result706 } else {707 VaultCrypto.zero(newKey!!)708 newKey = null709 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 710 rawKey = null711 result712 }713 } finally {714 715 716 rawKey?.let(VaultCrypto::zero)717 newKey?.let(VaultCrypto::zero)718 }719 }720721 722 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {723 return try {724 725 726 727 728 729 val adopted = mutateIfCurrentEpoch(expectedEpoch) {730 derivedKey = key731 decoyMode = false732 entryList = payload.entries.toMutableList()733 duressConfigured = payload.duressConfigured734 recoveryWordHash = payload.recoveryWordHash735 recoveryWordsSet = payload.recoveryWordsSet736 sessionDataAvailable = true737 _settings.value = store.loadSettings()738 ensureRootKey(payload)739 autoPurgeExpiredTrash()740 publish()741 _isUnlocked.value = true742 743 744 745 sessionEpoch = nextSessionEpoch(sessionEpoch)746 }747 if (adopted == null) {748 VaultCrypto.zero(key)749 UnlockResult.Error("stale_unlock")750 } else {751 UnlockResult.Success752 }753 } catch (e: Exception) {754 755 756 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }757 VaultCrypto.zero(key)758 UnlockResult.Error(e.message ?: "unlock_failed")759 }760 }761762 763764 765766767768769770 class SlotAUnlock internal constructor(771 key: ByteArray,772 internal val payload: VaultPayload,773 internal val epoch: Long,774 ) {775 private var ownedKey: ByteArray? = key776 private var consumed = false777778 779 internal fun takeKey(): ByteArray? = synchronized(this) {780 if (consumed) return@synchronized null781 consumed = true782 ownedKey.also { ownedKey = null }783 }784785 786 internal fun discard() = synchronized(this) {787 if (consumed) return@synchronized788 consumed = true789 ownedKey?.let(VaultCrypto::zero)790 ownedKey = null791 }792 }793794 795796797798799800 fun tryUnlockSlotA(password: String): SlotAUnlock? {801 val epoch = currentEpoch()802 val slotA = store.loadSlotA() ?: return null803 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))804 return try {805 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)806 val payload = try {807 VaultCodec.decode(plaintext)808 } finally {809 VaultCrypto.zero(plaintext)810 }811 SlotAUnlock(key, payload, epoch)812 } catch (_: Exception) {813 VaultCrypto.zero(key)814 null815 }816 }817818 819 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {820 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")821 adoptSlotA(key, unlock.payload, unlock.epoch)822 }823824 825 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()826827 828829 830831832833834 class DuressUnlock internal constructor(835 key: ByteArray,836 internal val payload: VaultPayload,837 internal val epoch: Long,838 ) {839 private var ownedKey: ByteArray? = key840 private var consumed = false841842 internal fun takeKey(): ByteArray? = synchronized(this) {843 if (consumed) return@synchronized null844 consumed = true845 ownedKey.also { ownedKey = null }846 }847848 internal fun discard() = synchronized(this) {849 if (consumed) return@synchronized850 consumed = true851 ownedKey?.let(VaultCrypto::zero)852 ownedKey = null853 }854 }855856 857858859860861862863 fun tryUnlockDuress(password: String): DuressUnlock? {864 val epoch = currentEpoch()865 val slot = store.loadDecoySlot() ?: return null866 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))867 return try {868 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)869 val payload = try {870 VaultCodec.decode(plaintext)871 } finally {872 VaultCrypto.zero(plaintext)873 }874 DuressUnlock(key, payload, epoch)875 } catch (_: Exception) {876 VaultCrypto.zero(key)877 null878 }879 }880881 882 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {883 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")884 if (currentEpoch() != unlock.epoch) {885 VaultCrypto.zero(key)886 return@withLock UnlockResult.Error("stale_unlock")887 }888 return try {889 val adopted = mutateIfCurrentEpoch(unlock.epoch) {890 derivedKey = key891 decoyMode = true892 entryList = unlock.payload.entries.toMutableList()893 duressConfigured = false894 recoveryWordHash = unlock.payload.recoveryWordHash895 recoveryWordsSet = unlock.payload.recoveryWordsSet896 sessionDataAvailable = true897 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)898 else VaultCrypto.generateRootKey()899 _settings.value = store.loadSettings()900 publish()901 _isUnlocked.value = true902 sessionEpoch = nextSessionEpoch(sessionEpoch)903 }904 if (adopted == null) {905 VaultCrypto.zero(key)906 UnlockResult.Error("stale_unlock")907 } else {908 UnlockResult.Success909 }910 } catch (e: Exception) {911 912 913 914 915 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }916 VaultCrypto.zero(key)917 UnlockResult.Error(e.message ?: "decoy_unlock_failed")918 }919 }920921 922 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()923924 925926927928929930 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {931 932 val expectedEpoch = mutateState {933 if (derivedKey != null && !decoyMode) sessionEpoch else null934 } ?: return false935 936 937 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }938 var key: ByteArray? = null939 var basePlaintext: ByteArray? = null940 return try {941 val salt = VaultCrypto.generateSalt()942 key = VaultCrypto.deriveKey(duressPassphrase, salt)943 944 val decoyRoot = VaultCrypto.generateRootKey()945 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }946 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)947 basePlaintext = VaultCodec.encode(decoyPayload)948 949 950 951 952 953 954 955 956 val slotA = store.loadSlotA()957 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0958 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!959 val blob = try {960 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))961 } finally {962 VaultCrypto.zero(plaintext)963 964 965 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)966 basePlaintext = null967 key?.let(VaultCrypto::zero)968 key = null969 }970 beforeDurableCommit()971 val ok = withEpochCommit(expectedEpoch) {972 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))973 } ?: false974 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {975 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }976 } else if (ok) {977 return false978 }979 ok980 } catch (_: Exception) {981 false982 } finally {983 984 985 key?.let(VaultCrypto::zero)986 basePlaintext?.let(VaultCrypto::zero)987 }988 }989990991 9929939949959969979989991000 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1001 if (!duressConfigured) return null1002 val slot = store.loadDecoySlot() ?: return null1003 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1004 }10051006 1007100810091010101110121013 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1014 1015 1016 1017 1018 1019 var salt: ByteArray? = null1020 var blob: ByteArray? = null1021 var nonce: ByteArray? = null1022 var ctAndTag: ByteArray? = null1023 var ct: ByteArray? = null1024 var tag: ByteArray? = null1025 try {1026 salt = unb64(slot.salt)1027 blob = unb64(slot.blob)1028 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1029 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1030 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1031 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1032 val obj = JsonObject(mapOf(1033 "version" to JsonPrimitive(1),1034 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1035 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1036 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1037 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1038 ))1039 return obj.toString()1040 } finally {1041 salt?.let(VaultCrypto::zero)1042 blob?.let(VaultCrypto::zero)1043 nonce?.let(VaultCrypto::zero)1044 ctAndTag?.let(VaultCrypto::zero)1045 ct?.let(VaultCrypto::zero)1046 tag?.let(VaultCrypto::zero)1047 }1048 }10491050 1051105210531054105510561057105810591060 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1061 val b = BrowserSlot.parse(json) ?: return false1062 var stored: StoredVault? = null1063 var salt: ByteArray? = null1064 var nonce: ByteArray? = null1065 var ct: ByteArray? = null1066 var tag: ByteArray? = null1067 var blob: ByteArray? = null1068 try {1069 salt = unhex(b.salt)1070 nonce = unhex(b.nonce)1071 ct = unhex(b.ciphertext)1072 tag = unhex(b.tag)1073 blob = ByteArray(nonce.size + ct.size + tag.size).also {1074 System.arraycopy(nonce, 0, it, 0, nonce.size)1075 System.arraycopy(ct, 0, it, nonce.size, ct.size)1076 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1077 }1078 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1079 val expectedEpoch = sessionEpoch1080 beforeDurableCommit()1081 withEpochCommit(expectedEpoch) {1082 store.writeDecoySlot(stored)1083 } ?: false1084 } catch (_: Exception) {1085 false1086 } finally {1087 salt?.let(VaultCrypto::zero)1088 nonce?.let(VaultCrypto::zero)1089 ct?.let(VaultCrypto::zero)1090 tag?.let(VaultCrypto::zero)1091 blob?.let(VaultCrypto::zero)1092 }1093 }10941095 1096 suspend fun clearDuress(): Boolean = mutex.withLock {1097 1098 1099 1100 1101 val expectedEpoch = currentEpoch()1102 val replaced = withEpochCommit(expectedEpoch) {1103 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1104 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1105 } ?: false1106 if (!replaced) return@withLock false1107 1108 1109 if (!isSessionEpochCurrent(expectedEpoch)) return false1110 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1111 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1112 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1113 }1114 true1115 }11161117 1118 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1119 var current: Throwable? = error1120 while (current != null) {1121 if (current is javax.crypto.AEADBadTagException) return true1122 1123 1124 1125 1126 if (current is javax.crypto.BadPaddingException) return true1127 current = current.cause1128 }1129 return false1130 }11311132 1133 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1134 val expectedEpoch = currentEpoch()1135 val slotA = try {1136 store.loadSlotA()1137 } catch (_: Exception) {1138 return@withLock UnlockResult.Error("unlock_key_storage_failed")1139 } ?: return@withLock UnlockResult.Error("no_vault")1140 1141 1142 1143 1144 var stagedKey: ByteArray? = key.copyOf()1145 return try {1146 val encrypted = try {1147 unb64(slotA.blob)1148 } catch (_: Exception) {1149 return@withLock UnlockResult.Error("invalid_vault_blob")1150 }1151 1152 1153 1154 1155 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1156 return@withLock UnlockResult.Error("invalid_vault_blob")1157 }1158 val plaintext = try {1159 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1160 } catch (e: Exception) {1161 if (isGcmAuthenticationFailure(e)) {1162 return@withLock UnlockResult.WrongPassword1163 }1164 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1165 }1166 val payload = try {1167 VaultCodec.decode(plaintext)1168 } catch (_: Exception) {1169 return@withLock UnlockResult.Error("invalid_vault_payload")1170 } finally {1171 VaultCrypto.zero(plaintext)1172 }1173 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1174 val result = adoptSlotA(candidate, payload, expectedEpoch)1175 if (result is UnlockResult.Success) stagedKey = null1176 result1177 } finally {1178 stagedKey?.let(VaultCrypto::zero)1179 }1180 }11811182 fun lock() {1183 1184 1185 1186 1187 1188 1189 stateLock.lock()1190 try {1191 sessionEpoch = nextSessionEpoch(sessionEpoch)1192 clearSessionStateLocked()1193 _uiGateLocked.value = false1194 } finally {1195 stateLock.unlock()1196 }1197 }11981199 1200120112021203120412051206 fun lockUiGate() {1207 if (_isUnlocked.value) _uiGateLocked.value = true1208 }12091210 1211 fun clearUiGate() {1212 _uiGateLocked.value = false1213 }12141215 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12161217 1218 1219 @VisibleForTesting1220 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12211222 122312241225122612271228122912301231123212331234 fun expectTransientBackground() {1235 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1236 }12371238 12391240124112421243 fun consumeTransientBackgroundSuppression(): Boolean {1244 val until = suppressNextBackgroundLockUntil1245 val active = until != 0L && nowMs() < until1246 suppressNextBackgroundLockUntil = 0L1247 return active1248 }12491250 12511252 12531254125512561257 12581259126012611262126312641265126612671268 1269 1270 1271 1272 1273 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12741275 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1276 if (decoyMode || !_isUnlocked.value) null1277 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1278 }12791280 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1281 mutateState { sessionEpoch == epoch }12821283 internal fun unlockedSessionEpoch(): Long? = mutateState {1284 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1285 }12861287 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12881289 129012911292129312941295129612971298 fun sessionRootKey(): ByteArray? = mutateState {1299 if (!sessionDataAvailable) null else rootKey?.copyOf()1300 }13011302 13031304 fun syncVaultId(): String? = mutateState {1305 if (decoyMode || !_isUnlocked.value) null1306 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1307 }13081309 13101311131213131314 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1315 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1316 ?: return false1317 installRootKey(generateRecoverableRootKey(), expectedEpoch)1318 }13191320 13211322132313241325132613271328132913301331133213331334133513361337 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1338 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1339 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1340 }1341 val expectedEpoch = mutateState {1342 when {1343 derivedKey == null -> null1344 decoyMode -> Long.MIN_VALUE1345 else -> sessionEpoch1346 }1347 }1348 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1349 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1350 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13511352 val incoming = hexToRootKey(rootKeyHex)1353 if (incoming.size != VaultCrypto.KEY_SIZE) {1354 VaultCrypto.zero(incoming)1355 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1356 }1357 1358 1359 1360 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1361 if (!roundTrips) {1362 VaultCrypto.zero(incoming)1363 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1364 }13651366 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1367 return if (installRootKey(incoming, expectedEpoch)) {1368 AdoptVaultResult.Success(vaultId)1369 } else {1370 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1371 }1372 }13731374 13751376137713781379 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1380 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1381 val bytes = hexToRootKey(rootKeyHex)1382 return try {1383 val words = rootKeyToWords(bytes)1384 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1385 } catch (_: Exception) {1386 null1387 } finally {1388 VaultCrypto.zero(bytes)1389 }1390 }13911392 13931394139513961397 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1398 1399 1400 1401 1402 1403 1404 data class Snapshot(1405 val key: ByteArray,1406 val priorRootKey: ByteArray,1407 val priorRecoveryHash: String?,1408 val priorRecoverySet: Boolean,1409 )1410 val snap = mutateIfCurrentEpoch(expectedEpoch) {1411 val k = derivedKey ?: return@mutateIfCurrentEpoch null1412 if (decoyMode) return@mutateIfCurrentEpoch null1413 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1414 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1415 } ?: run {1416 VaultCrypto.zero(nextRootKey)1417 return false1418 }1419 val priorSlot = store.loadSlotA() ?: run {1420 VaultCrypto.zero(snap.key)1421 VaultCrypto.zero(snap.priorRootKey)1422 VaultCrypto.zero(nextRootKey)1423 return false1424 }1425 val nextWords = rootKeyToWords(nextRootKey)14261427 return try {1428 beforeDurableCommit()1429 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1430 if (mutateIfCurrentEpoch(expectedEpoch) {1431 rootKey = nextRootKey1432 recoveryWordHash = recoveryWordsHash(nextWords)1433 recoveryWordsSet = false1434 } == null) throw IllegalStateException("stale_root_rotation")14351436 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1437 ?: throw IllegalStateException("recovery_escrow_failed")1438 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1439 ?: throw IllegalStateException("root_escrow_failed")1440 val plaintext = VaultCodec.encode(currentPayload())1441 val blob = try {1442 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1443 } finally {1444 VaultCrypto.zero(plaintext)1445 }1446 val nextSlot = priorSlot.copy(1447 blob = blob,1448 recoveryEscrow = nextRecoveryEscrow,1449 rootKeyEscrow = nextRootKeyEscrow,1450 )1451 val committed = withEpochCommit(expectedEpoch) {1452 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1453 try {1454 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1455 1456 1457 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1458 store.stampLocalMutation()1459 } catch (_: Exception) {1460 store.writeSlotA(priorSlot)1461 return@withEpochCommit false1462 }1463 true1464 } ?: false1465 if (!committed) throw IllegalStateException("storage_write_failed")1466 mutateIfCurrentEpoch(expectedEpoch) { publish() }1467 true1468 } catch (_: Exception) {1469 val restored = mutateIfCurrentEpoch(expectedEpoch) {1470 rootKey?.let(VaultCrypto::zero)1471 rootKey = snap.priorRootKey.copyOf()1472 recoveryWordHash = snap.priorRecoveryHash1473 recoveryWordsSet = snap.priorRecoverySet1474 }1475 if (restored != null) {1476 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1477 }1478 false1479 } finally {1480 VaultCrypto.zero(snap.key)1481 VaultCrypto.zero(snap.priorRootKey)1482 1483 1484 1485 val stillOwnedBySession = mutateState {1486 sessionEpoch == expectedEpoch && rootKey === nextRootKey1487 }1488 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1489 }1490 }14911492 14931494149514961497 fun currentVaultKey(): ByteArray? = mutateState {1498 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1499 }15001501 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1502 val expectedEpoch = currentEpoch()1503 val slotA = store.loadSlotA() ?: return false1504 var oldKey: ByteArray? = null1505 var newKey: ByteArray? = null1506 var plaintext: ByteArray? = null1507 return try {1508 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1509 1510 1511 1512 1513 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1514 val payload = try {1515 VaultCodec.decode(decrypted)1516 } finally {1517 VaultCrypto.zero(decrypted)1518 }1519 val newSalt = VaultCrypto.generateSalt()1520 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1521 1522 plaintext = VaultCodec.encode(payload)1523 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1524 1525 1526 1527 1528 1529 1530 1531 val next = StoredVault(1532 version = slotA.version,1533 salt = b64(newSalt),1534 blob = blob,1535 recoveryEscrow = slotA.recoveryEscrow,1536 rootKeyEscrow = slotA.rootKeyEscrow,1537 )1538 1539 1540 1541 1542 val priorB = store.loadSlotB()1543 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1544 1545 1546 1547 if (mutateIfCurrentEpoch(expectedEpoch) {1548 oldKey?.let(VaultCrypto::zero); oldKey = null1549 derivedKey?.let(VaultCrypto::zero)1550 derivedKey = newKey; newKey = null1551 } == null) return false1552 true1553 } catch (_: Exception) {1554 false1555 } finally {1556 oldKey?.let(VaultCrypto::zero)1557 newKey?.let(VaultCrypto::zero)1558 plaintext?.let(VaultCrypto::zero)1559 }1560 }15611562 1563156415651566 suspend fun destroyVault() = mutex.withLock {1567 lock()1568 store.clearVault()1569 store.clearDecoySlot()1570 }15711572 15731574 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1575 val epoch = requireUnlocked()1576 val stamp = VaultCrypto.monotonicEntryTimestamp()1577 val now = System.currentTimeMillis()1578 val entry = withDerivedLast4(draft.copy(1579 id = draft.id.ifBlank { UUID.randomUUID().toString() },1580 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1581 modifiedAt = stamp,1582 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1583 ))1584 1585 1586 val rollback = mutateCurrentSession(epoch) {1587 val r = entryList.toMutableList()1588 entryList.add(entry)1589 r1590 } ?: throw IllegalStateException("vault_locked")1591 persistOrRollback(rollback, epoch)1592 entry1593 }15941595 159615971598159916001601160216031604 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1605 val epoch = requireUnlocked()1606 if (drafts.isEmpty()) return 01607 val now = System.currentTimeMillis()1608 val ready = drafts.map { draft ->1609 val stamp = VaultCrypto.monotonicEntryTimestamp()1610 withDerivedLast4(1611 draft.copy(1612 id = draft.id.ifBlank { UUID.randomUUID().toString() },1613 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1614 modifiedAt = stamp,1615 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1616 ),1617 )1618 }1619 1620 val rollback = mutateCurrentSession(epoch) {1621 val r = entryList.toMutableList()1622 entryList.addAll(ready)1623 r1624 } ?: throw IllegalStateException("vault_locked")1625 persistOrRollback(rollback, epoch)1626 return drafts.size1627 }16281629 1630163116321633163416351636163716381639164016411642 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1643 val epoch = requireUnlocked()1644 val now = System.currentTimeMillis()1645 val ready = newEntries.map { draft ->1646 val stamp = VaultCrypto.monotonicEntryTimestamp()1647 withDerivedLast4(1648 draft.copy(1649 id = draft.id.ifBlank { UUID.randomUUID().toString() },1650 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1651 modifiedAt = stamp,1652 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1653 ),1654 )1655 }1656 1657 1658 1659 1660 1661 1662 val incomingIds = ready.map { it.id }.toHashSet()1663 val rollback = mutateCurrentSession(epoch) {1664 val r = entryList.toMutableList()1665 val replaced = entryList.map { entry ->1666 if (entry.deletedAt != null) return@map entry1667 if (entry.id in incomingIds) return@map null1668 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1669 entry.copy(deletedAt = stamp, modifiedAt = stamp)1670 }.filterNotNull()1671 entryList = (replaced + ready).toMutableList()1672 r1673 } ?: throw IllegalStateException("vault_locked")1674 persistOrRollback(rollback, epoch)1675 return ready.size1676 }16771678 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1679 val epoch = requireUnlocked()1680 1681 1682 1683 1684 val prepared = mutateCurrentSession(epoch) {1685 val idx = entryList.indexOfFirst { it.id == patch.id }1686 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1687 val current = entryList[idx]1688 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1689 1690 val passwordChanged = patch.password != null && patch.password != current.password1691 1692 1693 1694 1695 1696 1697 val priorPassword = current.password1698 val shouldCapture = passwordChanged &&1699 !priorPassword.isNullOrEmpty() &&1700 current.passwordHistory?.firstOrNull()?.password != priorPassword1701 val nextHistory = if (shouldCapture) {1702 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1703 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1704 } else {1705 current.passwordHistory1706 }1707 val updated = withDerivedLast4(patch.copy(1708 createdAt = current.createdAt,1709 modifiedAt = stamp,1710 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1711 passwordHistory = nextHistory,1712 ))1713 val rollback = entryList.toMutableList()1714 entryList[idx] = updated1715 updated to rollback1716 } ?: throw IllegalStateException("vault_locked")1717 val (updated, rollback) = prepared1718 persistOrRollback(rollback, epoch)1719 updated1720 }17211722 1723 suspend fun deleteEntry(id: String) = mutex.withLock {1724 val epoch = requireUnlocked()1725 1726 1727 val rollback = mutateCurrentSession(epoch) {1728 val idx = entryList.indexOfFirst { it.id == id }1729 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1730 val current = entryList[idx]1731 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1732 val prior = entryList.toMutableList()1733 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1734 prior1735 } ?: throw IllegalStateException("vault_locked")1736 persistOrRollback(rollback, epoch)1737 }17381739 suspend fun restoreEntry(id: String) = mutex.withLock {1740 val epoch = requireUnlocked()1741 val rollback = mutateCurrentSession(epoch) {1742 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1743 if (idx == -1) throw IllegalStateException("deleted entry not found")1744 val current = entryList[idx]1745 val prior = entryList.toMutableList()1746 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1747 prior1748 } ?: throw IllegalStateException("vault_locked")1749 persistOrRollback(rollback, epoch)1750 }17511752 1753 suspend fun purgeEntry(id: String) = mutex.withLock {1754 val epoch = requireUnlocked()1755 val rollback = mutateCurrentSession(epoch) {1756 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1757 if (idx == -1) throw IllegalStateException("deleted entry not found")1758 val prior = entryList.toMutableList()1759 entryList.removeAt(idx)1760 prior1761 } ?: throw IllegalStateException("vault_locked")1762 persistOrRollback(rollback, epoch)1763 }17641765 1766 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1767 val epoch = requireUnlocked()1768 1769 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1770 val r = mutateCurrentSession(epoch) {1771 val rollback = entryList.toMutableList()1772 val before = entryList.size1773 entryList.removeAll { it.deletedAt != null }1774 R(rollback, before - entryList.size)1775 } ?: throw IllegalStateException("vault_locked")1776 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1777 r.removed1778 }17791780 1781 suspend fun countDuplicateEntries(): Int = mutex.withLock {1782 val epoch = requireUnlocked()1783 1784 1785 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1786 ?: throw IllegalStateException("vault_locked")1787 }17881789 17901791179217931794 suspend fun deduplicateEntries(): Int = mutex.withLock {1795 val epoch = requireUnlocked()1796 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1797 val ready = mutateCurrentSession(epoch) {1798 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1799 val active = entryList.filter { it.deletedAt == null }1800 val keepIds = mutableSetOf<String>()1801 active.groupBy { it.duplicateKey() }.values.forEach { group ->1802 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1803 }1804 val rollback = entryList.toMutableList()1805 var removed = 01806 entryList = entryList.map { entry ->1807 if (entry.deletedAt == null && entry.id !in keepIds) {1808 removed++1809 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1810 entry.copy(deletedAt = stamp, modifiedAt = stamp)1811 } else {1812 entry1813 }1814 }.toMutableList()1815 R(rollback, removed)1816 } ?: throw IllegalStateException("vault_locked")1817 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1818 ready.removed1819 }18201821 fun getEntries(): List<VaultEntry> = mutateState {1822 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1823 }1824 fun getEntry(id: String): VaultEntry? = mutateState {1825 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1826 }1827 fun getDeletedEntries(): List<VaultEntry> = mutateState {1828 if (!sessionDataAvailable) emptyList()1829 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1830 }18311832 1833 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1834 return mutateState {1835 if (!sessionDataAvailable) return@mutateState emptyList()1836 val active = entryList.filter { it.deletedAt == null }1837 if (tagFilter == null) return@mutateState active1838 val needle = tagFilter.trim().lowercase()1839 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1840 }1841 }18421843 1844 fun getDistinctTags(): List<String> = mutateState {1845 if (!sessionDataAvailable) return@mutateState emptyList()1846 entryList.asSequence()1847 .filter { it.deletedAt == null }1848 .flatMap { it.tags?.asSequence() ?: emptySequence() }1849 .map { it.trim().lowercase() }1850 .filter { it.isNotEmpty() }1851 .distinct()1852 .sorted()1853 .toList()1854 }18551856 185718581859186018611862 fun getDistinctFieldValues(field: String): List<String> {1863 val select: (VaultEntry) -> String? = when (field) {1864 "username" -> { e -> e.username }1865 "email" -> { e -> e.email }1866 else -> return emptyList()1867 }1868 return mutateState {1869 if (!sessionDataAvailable) return@mutateState emptyList()1870 entryList.asSequence()1871 .filter { it.deletedAt == null }1872 .mapNotNull { select(it)?.trim() }1873 .filter { it.isNotEmpty() }1874 .groupingBy { it.lowercase() }1875 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1876 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1877 }1878 .values1879 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1880 .map { it.first }1881 }1882 }18831884 fun getTombstones(): List<Tombstone> = mutateState {1885 if (!sessionDataAvailable) emptyList()1886 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1887 }1888 fun getVaultVersion(): Long = store.version()1889 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18901891 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1892 val expectedEpoch = currentEpoch()1893 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1894 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1895 1896 1897 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1898 val priorA = store.loadSlotA()1899 val priorB = store.loadSlotB()1900 val priorD = store.loadDecoySlot()1901 1902 1903 1904 1905 1906 val priorHistory = store.loadVaultHistory()1907 1908 1909 1910 1911 beforeDurableCommit()1912 if (!takeVaultSnapshot("restore", expectedEpoch)) {1913 return false1914 }1915 1916 1917 1918 val nextD: StoredVault = restoredD ?: run {1919 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161920 val fakeBlob = ByteArray(target).also(rng::nextBytes)1921 val fakeSalt = VaultCrypto.generateSalt()1922 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1923 }1924 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1925 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1926 return false1927 }1928 lock()1929 true1930 }19311932 19331934193519361937193819391940194119421943 private fun commitRestoreAtomic(1944 nextA: StoredVault,1945 nextB: String,1946 nextD: StoredVault,1947 priorA: StoredVault?,1948 priorB: String?,1949 priorD: StoredVault?,1950 expectedEpoch: Long,1951 ): Boolean {1952 return withEpochCommit(expectedEpoch) {1953 if (!store.writeSlotA(nextA)) return@withEpochCommit false1954 1955 1956 1957 try {1958 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1959 1960 1961 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1962 store.stampLocalMutation()1963 } catch (_: Exception) {1964 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1965 return@withEpochCommit false1966 }1967 true1968 } ?: false1969 }19701971 19721973 19741975197619771978 suspend fun applyMergedRemoteEntries(1979 remoteEntries: List<VaultEntry>,1980 remoteTombstones: List<Tombstone>,1981 expectedEpoch: Long? = null,1982 ): Int = mutex.withLock {1983 val epoch = expectedEpoch ?: requireUnlocked()1984 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01985 1986 1987 1988 1989 1990 val mergeNow = System.currentTimeMillis()1991 val clamped = remoteEntries.map { entry ->1992 entry.copy(1993 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1994 deletedAt = entry.deletedAt?.let {1995 SyncLimits.clampSyncTimestamp(it, mergeNow)1996 },1997 )1998 }1999 val tombMap = remoteTombstones.associate {2000 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2001 }2002 2003 2004 val applied = mutateCurrentSession(epoch) {2005 var n = 02006 val byId = entryList.associateBy { it.id }.toMutableMap()20072008 for (remote in clamped) {2009 val tomb = tombMap[remote.id]2010 if (tomb != null && tomb >= remote.modifiedAt) continue2011 val local = byId[remote.id]2012 if (local == null) {2013 entryList.add(remote); byId[remote.id] = remote; n++2014 } else if (remote.modifiedAt > local.modifiedAt) {2015 val i = entryList.indexOfFirst { it.id == local.id }2016 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2017 }2018 }20192020 for ((id, deletedAt) in tombMap) {2021 val local = byId[id] ?: continue2022 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2023 if (deletedAt < local.modifiedAt) continue2024 val i = entryList.indexOfFirst { it.id == id }2025 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2026 }2027 n2028 } ?: return@withLock 020292030 if (applied > 0) {2031 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2032 mutateIfCurrentEpoch(epoch) { publish() }2033 }2034 applied2035 }20362037 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2038 2039 2040 2041 val epoch = requireUnlocked()2042 val prior = mutateCurrentSession(epoch) {2043 val old = _settings.value2044 _settings.value = settings2045 old2046 } ?: throw IllegalStateException("vault_locked")20472048 val committed = try {2049 beforeDurableCommit()2050 2051 2052 2053 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2054 } catch (_: Exception) {2055 false2056 }2057 if (!committed) {2058 2059 2060 2061 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2062 throw IllegalStateException("settings_write_failed")2063 }2064 }20652066 20672068 2069 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20702071 20722073207420752076 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2077 val epoch = requireUnlocked()2078 2079 2080 2081 2082 2083 2084 val prior = mutateCurrentSession(epoch) {2085 val priorPair = recoveryWordHash to recoveryWordsSet2086 recoveryWordHash = recoveryWordsHash(words)2087 recoveryWordsSet = true2088 priorPair2089 } ?: return@withLock false2090 2091 2092 2093 2094 val persisted = runCatching {2095 persistSlotAWithEscrows(2096 buildRecoveryEscrow(words),2097 buildRootKeyEscrow(words),2098 expectedEpoch = epoch,2099 )2100 }.getOrDefault(false)2101 if (!persisted) {2102 mutateIfCurrentEpoch(epoch) {2103 recoveryWordHash = prior.first2104 recoveryWordsSet = prior.second2105 }2106 }2107 persisted2108 }21092110 2111 fun verifyRecoveryWords(words: List<String>): Boolean {2112 return mutateState {2113 val stored = recoveryWordHash ?: return@mutateState false2114 stored == recoveryWordsHash(words)2115 }2116 }21172118 21192120 2121 fun hasRecoveryEscrow(): Boolean =2122 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21232124 212521262127212821292130213121322133213421352136 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2137 val expectedEpoch = currentEpoch()2138 val slotA = store.loadSlotA() ?: return false2139 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21402141 2142 2143 val fullEscrow = slotA.recoveryEscrow2144 if (fullEscrow != null) {2145 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2146 if (material != null) {2147 var keyBytes: ByteArray? = null2148 var plaintext: ByteArray? = null2149 var candidateRoot: ByteArray? = null2150 var stateCommitStarted = false2151 try {2152 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2153 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2154 val payload = VaultCodec.decode(plaintext)2155 2156 2157 2158 2159 val candidateSettings = store.loadSettings()2160 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2161 val decodedRoot = unb64(payload.rootKeyB64)2162 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2163 2164 2165 2166 VaultCrypto.zero(decodedRoot)2167 throw IllegalArgumentException("invalid recovery root key")2168 }2169 decodedRoot2170 } else {2171 generateRecoverableRootKey()2172 }2173 stateCommitStarted = true2174 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2175 clearSessionStateLocked()2176 entryList = payload.entries.toMutableList()2177 duressConfigured = payload.duressConfigured2178 recoveryWordHash = payload.recoveryWordHash2179 recoveryWordsSet = payload.recoveryWordsSet2180 rootKey = candidateRoot2181 candidateRoot = null2182 _settings.value = candidateSettings2183 publish()2184 2185 2186 sessionDataAvailable = true2187 _isUnlocked.value = false2188 true2189 }2190 if (adopted == true) return@withLock true2191 } catch (_: Exception) {2192 2193 2194 2195 2196 if (stateCommitStarted) {2197 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2198 }2199 } finally {2200 plaintext?.let(VaultCrypto::zero)2201 VaultCrypto.zero(material)2202 keyBytes?.let(VaultCrypto::zero)2203 candidateRoot?.let(VaultCrypto::zero)2204 }2205 }2206 }22072208 2209 2210 2211 val rootEscrow = slotA.rootKeyEscrow2212 if (rootEscrow != null) {2213 var candidateRoot: ByteArray? = null2214 var stateCommitStarted = false2215 try {2216 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2217 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2218 val candidateSettings = store.loadSettings()2219 stateCommitStarted = true2220 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2221 clearSessionStateLocked()2222 entryList = mutableListOf()2223 duressConfigured = false2224 recoveryWordHash = recoveryWordsHash(words)2225 recoveryWordsSet = true2226 rootKey = candidateRoot2227 candidateRoot = null2228 _settings.value = candidateSettings2229 publish()2230 sessionDataAvailable = true2231 _isUnlocked.value = false2232 true2233 }2234 if (adopted == true) return@withLock true2235 } catch (_: Exception) {2236 if (stateCommitStarted) {2237 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2238 }2239 } finally {2240 candidateRoot?.let(VaultCrypto::zero)2241 }2242 }2243 return false2244 }22452246 224722482249225022512252225322542255 suspend fun resetPasswordWithRecoveryWords(2256 words: List<String>,2257 newPassword: String,2258 ): Boolean = mutex.withLock {2259 val expectedEpoch = currentEpoch()2260 2261 2262 2263 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2264 val priorA = store.loadSlotA() ?: return false2265 var newKey: ByteArray? = null2266 var plaintext: ByteArray? = null2267 return try {2268 val newSalt = VaultCrypto.generateSalt()2269 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2270 plaintext = VaultCodec.encode(currentPayload())2271 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2272 2273 2274 2275 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2276 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2277 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2278 val next = StoredVault(2279 version = priorA.version,2280 salt = b64(newSalt),2281 blob = blob,2282 recoveryEscrow = freshRecovery,2283 rootKeyEscrow = freshRootKey,2284 )2285 2286 2287 2288 val priorB = store.loadSlotB()2289 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2290 2291 2292 2293 if (mutateIfCurrentEpoch(expectedEpoch) {2294 derivedKey?.let(VaultCrypto::zero)2295 derivedKey = newKey; newKey = null2296 publish()2297 _isUnlocked.value = true2298 } == null) return false2299 true2300 } catch (_: Exception) {2301 false2302 } finally {2303 newKey?.let(VaultCrypto::zero)2304 plaintext?.let(VaultCrypto::zero)2305 }2306 }23072308 230923102311231223132314 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2315 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2316 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2317 var keyBytes: ByteArray? = null2318 var plaintext: ByteArray? = null2319 return try {2320 val salt = VaultCrypto.generateSalt()2321 val recoveryKey = deriveRecoveryKey(material, salt)2322 keyBytes = recoveryKey.encoded2323 plaintext = VaultCodec.encode(currentPayload())2324 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2325 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2326 } finally {2327 VaultCrypto.zero(material)2328 keyBytes?.let(VaultCrypto::zero)2329 plaintext?.let(VaultCrypto::zero)2330 }2331 }23322333 23342335233623372338233923402341 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2342 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2343 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2344 val rk = mutateState { rootKey?.copyOf() } ?: run {2345 VaultCrypto.zero(material)2346 return null2347 }2348 var keyBytes: ByteArray? = null2349 var plaintext: ByteArray? = null2350 return try {2351 val salt = VaultCrypto.generateSalt()2352 val recoveryKey = deriveRecoveryKey(material, salt)2353 keyBytes = recoveryKey.encoded2354 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2355 2356 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2357 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2358 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2359 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2360 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2361 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2362 } finally {2363 VaultCrypto.zero(rk)2364 VaultCrypto.zero(material)2365 keyBytes?.let(VaultCrypto::zero)2366 plaintext?.let(VaultCrypto::zero)2367 }2368 }23692370 2371 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2372 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2373 var keyBytes: ByteArray? = null2374 var pt: ByteArray? = null2375 return try {2376 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2377 keyBytes = recoveryKey.encoded2378 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2379 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2380 hexToRootKey(String(pt, Charsets.US_ASCII))2381 } catch (_: Exception) {2382 null2383 } finally {2384 2385 2386 2387 VaultCrypto.zero(material)2388 keyBytes?.let(VaultCrypto::zero)2389 pt?.let(VaultCrypto::zero)2390 }2391 }23922393 239423952396239723982399 private fun persistSlotAWithEscrows(2400 recoveryEscrow: RecoveryEscrow?,2401 rootKeyEscrow: RootKeyEscrow?,2402 expectedEpoch: Long? = null,2403 ): Boolean {2404 val snapshot = snapshotForPersist(expectedEpoch)2405 ?: throw IllegalStateException("vault_locked_or_stale")2406 if (snapshot.decoy) {2407 VaultCrypto.zero(snapshot.key)2408 return false2409 }2410 try {2411 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2412 val plaintext = VaultCodec.encode(snapshot.payload)2413 val blob = try {2414 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2415 } finally {2416 2417 2418 VaultCrypto.zero(plaintext)2419 }2420 val next = prior.copy(2421 blob = blob,2422 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2423 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2424 )2425 beforeDurableCommit()2426 return withEpochCommit(snapshot.epoch) {2427 if (!store.writeSlotA(next)) return@withEpochCommit false2428 try {2429 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2430 2431 2432 2433 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2434 } catch (e: Exception) {2435 store.writeSlotA(prior)2436 throw e2437 }2438 true2439 } ?: false2440 } finally {2441 VaultCrypto.zero(snapshot.key)2442 }2443 }24442445 24462447 private fun requireUnlocked(): Long {2448 2449 2450 2451 stateLock.lock()2452 try {2453 if (derivedKey == null) throw IllegalStateException("vault_locked")2454 return sessionEpoch2455 } finally {2456 stateLock.unlock()2457 }2458 }24592460 2461 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24622463 private fun countDuplicateEntriesLocked(): Int {2464 val seen = mutableMapOf<String, VaultEntry>()2465 var duplicates = 02466 for (entry in entryList.filter { it.deletedAt == null }) {2467 val key = entry.duplicateKey()2468 val existing = seen[key]2469 if (existing == null) {2470 seen[key] = entry2471 } else {2472 duplicates++2473 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2474 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2475 if (entryStamp > existingStamp) seen[key] = entry2476 }2477 }2478 return duplicates2479 }24802481 private fun VaultEntry.duplicateKey(): String {2482 if (type == "note") return "note|${title.lowercase()}"2483 val passkeyData = passkey2484 if (passkeyData != null) {2485 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2486 }2487 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2488 }24892490 249124922493249424952496 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2497 val epoch = expectedEpoch ?: currentEpoch()2498 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2499 val slotA = store.loadSlotA() ?: return false2500 val vaultA = VaultCodec.encodeSlot(slotA)2501 val vaultB = store.loadSlotB() ?: slotA.blob2502 2503 2504 2505 2506 2507 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2508 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162509 val fakeBlob = ByteArray(target).also(rng::nextBytes)2510 val fakeSalt = VaultCrypto.generateSalt()2511 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2512 }2513 val history = store.loadVaultHistory()2514 val latest = history.snapshots.firstOrNull()2515 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2516 val entryCount = mutateIfCurrentEpoch(epoch) {2517 if (derivedKey != null && !decoyMode) {2518 bucketEntryCount(entryList.count { it.deletedAt == null })2519 } else {2520 null2521 }2522 } ?: return false2523 return try {2524 val snapshot = VaultSnapshot(2525 id = UUID.randomUUID().toString(),2526 savedAt = System.currentTimeMillis(),2527 reason = reason,2528 vault_a = vaultA,2529 vault_b = vaultB,2530 vault_d = vaultD,2531 entryCount = entryCount,2532 )2533 2534 2535 2536 withEpochCommit(epoch) {2537 store.writeVaultHistory(2538 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2539 )2540 } ?: false2541 } catch (_: Exception) {2542 false2543 }2544 }25452546 private fun bucketEntryCount(n: Int): Int = when {2547 n <= 0 -> 02548 n <= 10 -> 102549 n <= 25 -> 252550 n <= 50 -> 502551 n <= 100 -> 1002552 n <= 250 -> 2502553 n <= 500 -> 5002554 else -> ((n + 499) / 500) * 5002555 }25562557 255825592560256125622563256425652566 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2567 if (!entry.isCard()) return entry2568 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2569 val derived = if (digits.length >= 4) digits.takeLast(4) else null2570 return entry.copy(last4 = derived ?: entry.last4)2571 }25722573 257425752576257725782579 private fun autoPurgeExpiredTrash() {2580 val days = _settings.value.trashRetentionDays ?: return2581 if (days <= 0) return2582 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2583 val before = entryList.size2584 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2585 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2586 }25872588 2589 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25902591 private fun currentPayloadLocked(): VaultPayload =2592 VaultPayload(2593 entries = entryList.toList(),2594 duressConfigured = duressConfigured,2595 rootKeyB64 = rootKey?.let(::b64) ?: "",2596 recoveryWordHash = recoveryWordHash,2597 recoveryWordsSet = recoveryWordsSet,2598 )25992600 26012602260326042605 private fun ensureRootKey(payload: VaultPayload) {2606 if (payload.rootKeyB64.isNotBlank()) {2607 rootKey = unb64(payload.rootKeyB64)2608 return2609 }2610 rootKey = generateRecoverableRootKey()2611 runCatching { persistSlotA(bumpVersion = false) }2612 }26132614 private fun publish() {2615 _entries.value = entryList.filter { it.deletedAt == null }2616 }26172618 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2619 var plaintext = VaultCodec.encode(currentPayload())2620 return try {2621 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2622 StoredVault(version = 1, salt = b64(salt), blob = blob)2623 } finally {2624 2625 2626 2627 VaultCrypto.zero(plaintext)2628 }2629 }26302631 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2632 2633 2634 2635 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2636 val noise = ByteArray(approxBytes).also(rng::nextBytes)2637 return store.writeSlotB(b64(noise))2638 }26392640 264126422643 private fun matchingCiphertextLength(slotA: StoredVault): Int =2644 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26452646 26472648264926502651 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2652 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162653 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2654 val fakeSalt = VaultCrypto.generateSalt()2655 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2656 }26572658 265926602661266226632664 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2665 val isConfigured = mutateState { duressConfigured }2666 return if (isConfigured) true else writeFakeDuressSlot(slotA)2667 }26682669 2670267126722673267426752676 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2677 val salt = unhex(b.salt)2678 val nonce = unhex(b.nonce)2679 val ct = unhex(b.ciphertext)2680 val tag = unhex(b.tag)2681 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2682 System.arraycopy(nonce, 0, it, 0, nonce.size)2683 System.arraycopy(ct, 0, it, nonce.size, ct.size)2684 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2685 }2686 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2687 }26882689 2690 private fun persistSlotA(2691 bumpVersion: Boolean,2692 snapshotReason: String? = "local_save",2693 expectedEpoch: Long? = null,2694 ): Boolean {2695 val snapshot = snapshotForPersist(expectedEpoch)2696 ?: throw IllegalStateException("vault_locked_or_stale")2697 return try {2698 2699 if (snapshot.decoy) {2700 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2701 val plaintext = VaultCodec.encode(snapshot.payload)2702 val blob = try {2703 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2704 } finally {2705 2706 2707 VaultCrypto.zero(plaintext)2708 }2709 beforeDurableCommit()2710 return withEpochCommit(snapshot.epoch) {2711 store.writeDecoySlot(prior.copy(blob = blob))2712 } ?: false2713 }2714 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2715 2716 2717 2718 2719 val priorB = store.loadSlotB()2720 beforeDurableCommit()2721 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2722 val plaintext = VaultCodec.encode(snapshot.payload)2723 val blob = try {2724 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2725 } finally {2726 2727 2728 VaultCrypto.zero(plaintext)2729 }2730 val next = prior.copy(blob = blob)2731 2732 2733 2734 2735 withEpochCommit(snapshot.epoch) {2736 if (!store.writeSlotA(next)) return@withEpochCommit false2737 try {2738 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2739 2740 2741 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2742 if (bumpVersion) store.stampLocalMutation()2743 } catch (e: Exception) {2744 store.writeSlotA(prior)2745 if (priorB != null) runCatching { store.writeSlotB(priorB) }2746 throw e2747 }2748 true2749 } ?: false2750 } finally {2751 2752 2753 VaultCrypto.zero(snapshot.key)2754 }2755 }27562757 2758275927602761276227632764 private fun commitVaultRotation(2765 priorA: StoredVault,2766 priorB: String?,2767 nextA: StoredVault,2768 expectedEpoch: Long,2769 ): Boolean {2770 beforeDurableCommit()2771 return withEpochCommit(expectedEpoch) {2772 if (!store.writeSlotA(nextA)) return@withEpochCommit false2773 2774 2775 2776 try {2777 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2778 2779 2780 2781 2782 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2783 store.stampLocalMutation()2784 } catch (_: Exception) {2785 rollbackRotation(priorA, priorB)2786 return@withEpochCommit false2787 }2788 true2789 } ?: false2790 }27912792 27932794279527962797 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2798 val failures = mutableListOf<String>()2799 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2800 .onFailure { failures += "writeSlotA: $it" }2801 if (priorB != null) {2802 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2803 .onFailure { failures += "writeSlotB: $it" }2804 }2805 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2806 }28072808 2809 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2810 try {2811 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2812 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2813 throw IllegalStateException("storage_write_failed")2814 }2815 } catch (e: Exception) {2816 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2817 throw e2818 }2819 2820 2821 2822 mutateIfCurrentEpoch(expectedEpoch) { publish() }2823 }2824}