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 com.peachpasswords.sync.LockedSyncSpoolHooks17import kotlinx.serialization.json.JsonElement18import kotlinx.serialization.json.JsonNull19import kotlinx.serialization.json.JsonObject20import kotlinx.serialization.json.JsonPrimitive21import java.util.Base6422import java.util.UUID23import java.util.concurrent.locks.ReentrantLock24import javax.inject.Inject25import javax.inject.Singleton262728private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2930private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)31private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)323334private const val PASSWORD_HISTORY_MAX = 5035private const val VAULT_HISTORY_MAX = 536373839404142private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4344sealed interface UnlockResult {45 data object Success : UnlockResult46 data object WrongPassword : UnlockResult47 data class Error(val reason: String) : UnlockResult48}4950sealed interface CreateVaultResult {51 data object Success : CreateVaultResult52 data class Error(val reason: String) : CreateVaultResult53}5455sealed interface AdoptVaultResult {56 data class Success(val vaultId: String) : AdoptVaultResult57 data class Error(val reason: String) : AdoptVaultResult58}596061626364656667686970717273@Singleton74class VaultRepository @Inject constructor(75 private val store: VaultStorage,76) {77 private val mutex = Mutex()78 79 80 81 82 83 84 85 private val stateLock = ReentrantLock()86 private val rng = SecureRandom()8788 8990919293 private var sessionEpoch: Long = 0L9495 private fun nextSessionEpoch(epoch: Long): Long =96 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9798 99 @VisibleForTesting100 internal var beforeDurableCommit: () -> Unit = {}101102 103 @VisibleForTesting104 internal var afterCodexRollback: () -> Unit = {}105106 107108109110111 private inline fun <T> mutateState(block: () -> T): T {112 stateLock.lock()113 return try { block() } finally { stateLock.unlock() }114 }115116 117 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {118 stateLock.lock()119 return try {120 if (sessionEpoch != epoch) null else block()121 } finally {122 stateLock.unlock()123 }124 }125126 127 @VisibleForTesting128 internal var beforeSessionEpochCommit: () -> Unit = {}129130 131132133134135 @VisibleForTesting136 internal var beforeSessionStateMutation: () -> Unit = {}137138 139140141142143144 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {145 beforeSessionEpochCommit()146 return withEpochCommit(epoch, block)147 }148149 private fun currentEpoch(): Long = mutateState { sessionEpoch }150151 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =152 mutateState { if (sessionEpoch == epoch) block() else null }153154 155 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {156 beforeSessionStateMutation()157 return mutateIfCurrentEpoch(epoch, block)158 }159160 private data class PersistSnapshot(161 val epoch: Long,162 val key: ByteArray,163 val payload: VaultPayload,164 val decoy: Boolean,165 )166167 168 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {169 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null170 val liveKey = derivedKey ?: return@mutateState null171 PersistSnapshot(172 epoch = sessionEpoch,173 key = liveKey.copyOf(),174 payload = currentPayloadLocked(),175 decoy = decoyMode,176 )177 }178179 private var derivedKey: ByteArray? = null180 private var rootKey: ByteArray? = null181 private var entryList: MutableList<VaultEntry> = mutableListOf()182 private var duressConfigured: Boolean = false183 private var recoveryWordHash: String? = null184 private var recoveryWordsSet: Boolean = false185 186 private var sessionDataAvailable: Boolean = false187 188 private var decoyMode: Boolean = false189190 private val _isUnlocked = MutableStateFlow(false)191 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()192193194 195196197198199200 private val _uiGateLocked = MutableStateFlow(false)201 val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()202203 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())204 205 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()206207 208 209 210 211 private val _settings = MutableStateFlow(store.loadSettings())212 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()213214 215 private fun clearSessionStateLocked() {216 derivedKey?.let(VaultCrypto::zero)217 rootKey?.let(VaultCrypto::zero)218 derivedKey = null219 rootKey = null220 entryList = mutableListOf()221 duressConfigured = false222 recoveryWordHash = null223 recoveryWordsSet = false224 sessionDataAvailable = false225 decoyMode = false226 _settings.value = store.loadSettings()227 _entries.value = emptyList()228 _isUnlocked.value = false229 }230231 fun hasVault(): Boolean = store.hasVault()232233 234235 236237238239240241242 fun codexVaultBlobBase64(): String? {243 val slotA = store.loadSlotA() ?: return null244 245 246 val slotD = store.loadDecoySlot() ?: return null247 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""248 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))249 }250251 252253254255256 257 private fun JsonElement?.asSlotJson(): String? = when {258 this == null || this is JsonNull -> null259 this is JsonPrimitive && isString -> content260 else -> toString()261 }262263 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {264 val expectedEpoch = currentEpoch()265 var candidateKey: ByteArray? = null266 var plaintext: ByteArray? = null267 return try {268 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)269 270 271 272 273 274 275 276 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject277 ?: return UnlockResult.Error("invalid_codex_blob")278 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")279 280 281 282 283 284 val slotA = VaultCodec.decodeSlot(slotJson)285 ?: BrowserSlot.parse(slotJson)?.let {286 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword287 }288 ?: return UnlockResult.Error("invalid_codex_blob")289 290 291 292 293 294 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))295 plaintext = decryptSlotABlob(slotA, candidateKey)296 297 298 299 300 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {301 VaultCrypto.zero(candidateKey)302 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))303 plaintext = decryptSlotABlob(slotA, candidateKey)304 }305 if (plaintext == null) return UnlockResult.WrongPassword306 val payload = try {307 VaultCodec.decode(plaintext)308 } catch (_: Exception) {309 return UnlockResult.Error("invalid_codex_blob")310 }311312 val priorA = store.loadSlotA()313 val priorB = store.loadSlotB()314 val priorD = store.loadDecoySlot()315 316 317 318 319 320 val codexDuressJson = wrapper["b"].asSlotJson()321 val codexDuress: StoredVault? = codexDuressJson?.let {322 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->323 324 325 326 327 328 329 browserDuressPassthrough(b)330 }331 }332333 beforeDurableCommit()334 val committed = runCatching {335 withEpochCommit(expectedEpoch) {336 try {337 338 339 340 341 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")342 343 344 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")345 346 347 val duressOk = if (codexDuress != null) {348 store.writeDecoySlot(codexDuress)349 } else {350 writeFakeDuressSlot(slotA)351 }352 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")353 store.stampLocalMutation()354 true355 } catch (_: Exception) {356 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))357 358 359 360 afterCodexRollback()361 false362 }363 } ?: false364 }.getOrDefault(false)365 if (!committed) {366 return UnlockResult.Error("storage_write_failed")367 }368369 val ownedKey = candidateKey!!370 candidateKey = null 371 adoptSlotA(ownedKey, payload, expectedEpoch)372 } catch (e: Exception) {373 374 375 376 377 378 379 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))380 UnlockResult.Error(e.message ?: "restore_failed")381 } finally {382 plaintext?.let(VaultCrypto::zero)383 candidateKey?.let(VaultCrypto::zero)384 }385 }386387 388 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =389 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()390391 392393394395396397398399400 private fun rollbackRestoreLocked(401 priorA: StoredVault?,402 priorB: String?,403 priorD: StoredVault? = null,404 ): List<String> {405 val failures = mutableListOf<String>()406 if (priorA == null) {407 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }408 } else {409 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }410 .onFailure { failures += "writeSlotA: $it" }411 if (priorB != null) {412 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }413 .onFailure { failures += "writeSlotB: $it" }414 }415 416 417 418 if (priorD != null) {419 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }420 .onFailure { failures += "writeDecoySlot: $it" }421 } else {422 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }423 }424 }425 return failures426 }427428 private fun reportRollbackFailures(failures: List<String>) {429 430 431 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }432 }433434 435 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {436 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)437 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()438 val window = offset?.let {439 val from = (it - 40).coerceAtLeast(0)440 val to = (it + 40).coerceAtMost(wrapperJson.length)441 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")442 }443 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()444 val slot = wrapper?.get("a")445 buildString {446 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)447 append(" | blobBase64=").append(vaultBlobBase64.length)448 append(" wrapperChars=").append(wrapperJson.length)449 append(" wrapperParses=").append(wrapper != null)450 append(" keys=").append(wrapper?.keys)451 append(" slotAKind=").append(452 when {453 slot == null -> "absent"454 slot is JsonPrimitive && slot.isString -> "string"455 slot is JsonObject -> "object:" + slot.keys456 else -> slot::class.simpleName457 },458 )459 if (window != null) append(" masked@").append(offset).append("=").append(window)460 }461 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }462463 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {464 val expectedEpoch = currentEpoch()465 var key: ByteArray? = null466 return try {467 val salt = VaultCrypto.generateSalt()468 key = VaultCrypto.deriveKey(password, salt)469 if (mutateIfCurrentEpoch(expectedEpoch) {470 entryList = mutableListOf()471 duressConfigured = false472 recoveryWordHash = null473 recoveryWordsSet = false474 decoyMode = false475 sessionDataAvailable = false476 rootKey = generateRecoverableRootKey()477 } == null) return CreateVaultResult.Error("stale_create")478 479 480 481 482 val slotA = sealSlotA(key!!, salt)483 484 485 beforeDurableCommit()486 val created = withEpochCommit(expectedEpoch) {487 if (!store.writeSlotA(slotA)) return@withEpochCommit false488 try {489 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")490 491 492 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")493 store.stampLocalMutation()494 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")495 true496 } catch (_: Exception) {497 498 499 store.clearVault()500 false501 }502 } ?: false503 if (!created) {504 lock()505 return CreateVaultResult.Error("storage_write_failed")506 }507 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")508 if (mutateIfCurrentEpoch(expectedEpoch) {509 derivedKey = sessionKey510 key = null511 publish()512 _isUnlocked.value = true513 sessionDataAvailable = true514 sessionEpoch = nextSessionEpoch(sessionEpoch)515 } == null) return CreateVaultResult.Error("stale_create")516 CreateVaultResult.Success517 } catch (e: Exception) {518 lock()519 CreateVaultResult.Error(e.message ?: "create_failed")520 } finally {521 key?.let(VaultCrypto::zero)522 }523 }524525 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {526 val expectedEpoch = currentEpoch()527 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")528 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")529 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {530 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")531 }532 var adoptedRootKey: ByteArray? = null533 var nextDerivedKey: ByteArray? = null534 var sessionRootKey: ByteArray? = null535 return try {536 adoptedRootKey = hexToRootKey(rootKeyHex)537 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {538 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")539 }540 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)541 val salt = VaultCrypto.generateSalt()542 nextDerivedKey = VaultCrypto.deriveKey(password, salt)543 sessionRootKey = adoptedRootKey.copyOf()544 val payload = VaultPayload(545 entries = emptyList(),546 duressConfigured = false,547 rootKeyB64 = b64(sessionRootKey),548 recoveryWordHash = null,549 recoveryWordsSet = false,550 )551 val plaintext = VaultCodec.encode(payload)552 val blob = try {553 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))554 } finally {555 VaultCrypto.zero(plaintext)556 }557 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)558 559 560 beforeDurableCommit()561 val created = withEpochCommit(expectedEpoch) {562 if (!store.writeSlotA(slotA)) return@withEpochCommit false563 try {564 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")565 566 567 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")568 store.stampLocalMutation()569 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")570 true571 } catch (_: Exception) {572 store.clearVault()573 false574 }575 } ?: false576 if (!created) return AdoptVaultResult.Error("storage_write_failed")577578 val adopted = mutateIfCurrentEpoch(expectedEpoch) {579 val previousDerivedKey = derivedKey580 val previousRootKey = rootKey581 derivedKey = nextDerivedKey582 rootKey = sessionRootKey583 nextDerivedKey = null584 sessionRootKey = null585 entryList = mutableListOf()586 duressConfigured = false587 recoveryWordHash = null588 recoveryWordsSet = false589 decoyMode = false590 sessionDataAvailable = true591 publish()592 _isUnlocked.value = true593 sessionEpoch = nextSessionEpoch(sessionEpoch)594 previousDerivedKey to previousRootKey595 }596 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")597 val (previousDerivedKey, previousRootKey) = adopted598 previousDerivedKey?.let(VaultCrypto::zero)599 previousRootKey?.let(VaultCrypto::zero)600 601 602 603 runCatching {604 val words = rootKeyToWords(rootKey!!)605 val escrow = buildRecoveryEscrow(words)606 val rkEscrow = buildRootKeyEscrow(words)607 if (escrow != null || rkEscrow != null) {608 val prior = store.loadSlotA()609 if (prior != null) {610 store.writeSlotA(prior.copy(611 recoveryEscrow = escrow ?: prior.recoveryEscrow,612 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,613 ))614 }615 mutateState {616 recoveryWordHash = recoveryWordsHash(words)617 recoveryWordsSet = true618 }619 }620 }621 AdoptVaultResult.Success(vaultId)622 } catch (e: Exception) {623 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")624 } finally {625 adoptedRootKey?.let(VaultCrypto::zero)626 nextDerivedKey?.let(VaultCrypto::zero)627 sessionRootKey?.let(VaultCrypto::zero)628 }629 }630631 suspend fun unlock(password: String): UnlockResult = mutex.withLock {632 val result = unlockLocked(password, currentEpoch())633 if (result is UnlockResult.Success) ensureRecoveryEscrow()634 result635 }636637 638 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {639 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")640 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))641 val payload = try {642 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)643 try {644 VaultCodec.decode(plaintext)645 } finally {646 VaultCrypto.zero(plaintext)647 }648 } catch (_: Exception) {649 VaultCrypto.zero(key)650 651 652 653 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {654 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)655 if (legacy != null) return legacy656 }657 return UnlockResult.WrongPassword658 }659 return adoptSlotA(key, payload, expectedEpoch)660 }661662 663664665666667668669670 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {671 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))672 var newKey: ByteArray? = null673 return try {674 val payload = try {675 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)676 try {677 VaultCodec.decode(plaintext)678 } finally {679 VaultCrypto.zero(plaintext)680 }681 } catch (_: Exception) {682 return null683 }684 685 686 val newSalt = VaultCrypto.generateSalt()687 newKey = VaultCrypto.deriveKey(password, newSalt)688 val plaintext = VaultCodec.encode(payload)689 val blob = try {690 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))691 } finally {692 VaultCrypto.zero(plaintext)693 }694 val next = StoredVault(695 version = slotA.version,696 salt = b64(newSalt),697 blob = blob,698 recoveryEscrow = slotA.recoveryEscrow,699 rootKeyEscrow = slotA.rootKeyEscrow,700 )701 702 703 704 val priorB = store.loadSlotB()705 beforeDurableCommit()706 val healed = withEpochCommit(expectedEpoch) {707 if (!store.writeSlotA(next)) {708 false709 } else try {710 if (!writeMatchingNoiseSlotB(next)) {711 runCatching { store.writeSlotA(slotA) }712 if (priorB != null) runCatching { store.writeSlotB(priorB) }713 false714 } else if (!writeCompanionDuressSlot(next)) {715 runCatching { store.writeSlotA(slotA) }716 if (priorB != null) runCatching { store.writeSlotB(priorB) }717 false718 } else {719 store.stampLocalMutation()720 true721 }722 } catch (_: Exception) {723 runCatching { store.writeSlotA(slotA) }724 if (priorB != null) runCatching { store.writeSlotB(priorB) }725 false726 }727 } ?: false728 if (healed) {729 VaultCrypto.zero(rawKey!!)730 rawKey = null731 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 732 newKey = null733 result734 } else {735 VaultCrypto.zero(newKey!!)736 newKey = null737 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 738 rawKey = null739 result740 }741 } finally {742 743 744 rawKey?.let(VaultCrypto::zero)745 newKey?.let(VaultCrypto::zero)746 }747 }748749 750 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {751 return try {752 753 754 755 756 757 val adopted = mutateIfCurrentEpoch(expectedEpoch) {758 derivedKey = key759 decoyMode = false760 entryList = payload.entries.toMutableList()761 duressConfigured = payload.duressConfigured762 recoveryWordHash = payload.recoveryWordHash763 recoveryWordsSet = payload.recoveryWordsSet764 sessionDataAvailable = true765 _settings.value = store.loadSettings()766 ensureRootKey(payload)767 autoPurgeExpiredTrash()768 consolidatePasskeyEntries()769 publish()770 _isUnlocked.value = true771 772 773 774 sessionEpoch = nextSessionEpoch(sessionEpoch)775 }776 if (adopted == null) {777 VaultCrypto.zero(key)778 UnlockResult.Error("stale_unlock")779 } else {780 UnlockResult.Success781 }782 } catch (e: Exception) {783 784 785 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }786 VaultCrypto.zero(key)787 UnlockResult.Error(e.message ?: "unlock_failed")788 }789 }790791 792793 794795796797798799 class SlotAUnlock internal constructor(800 key: ByteArray,801 internal val payload: VaultPayload,802 internal val epoch: Long,803 ) {804 private var ownedKey: ByteArray? = key805 private var consumed = false806807 808 internal fun takeKey(): ByteArray? = synchronized(this) {809 if (consumed) return@synchronized null810 consumed = true811 ownedKey.also { ownedKey = null }812 }813814 815 internal fun discard() = synchronized(this) {816 if (consumed) return@synchronized817 consumed = true818 ownedKey?.let(VaultCrypto::zero)819 ownedKey = null820 }821 }822823 824825826827828829 fun tryUnlockSlotA(password: String): SlotAUnlock? {830 val epoch = currentEpoch()831 val slotA = store.loadSlotA() ?: return null832 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))833 return try {834 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)835 val payload = try {836 VaultCodec.decode(plaintext)837 } finally {838 VaultCrypto.zero(plaintext)839 }840 SlotAUnlock(key, payload, epoch)841 } catch (_: Exception) {842 VaultCrypto.zero(key)843 null844 }845 }846847 848 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {849 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")850 adoptSlotA(key, unlock.payload, unlock.epoch)851 }852853 854 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()855856 857858 859860861862863 class DuressUnlock internal constructor(864 key: ByteArray,865 internal val payload: VaultPayload,866 internal val epoch: Long,867 ) {868 private var ownedKey: ByteArray? = key869 private var consumed = false870871 internal fun takeKey(): ByteArray? = synchronized(this) {872 if (consumed) return@synchronized null873 consumed = true874 ownedKey.also { ownedKey = null }875 }876877 internal fun discard() = synchronized(this) {878 if (consumed) return@synchronized879 consumed = true880 ownedKey?.let(VaultCrypto::zero)881 ownedKey = null882 }883 }884885 886887888889890891892 fun tryUnlockDuress(password: String): DuressUnlock? {893 val epoch = currentEpoch()894 val slot = store.loadDecoySlot() ?: return null895 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))896 return try {897 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)898 val payload = try {899 VaultCodec.decode(plaintext)900 } finally {901 VaultCrypto.zero(plaintext)902 }903 DuressUnlock(key, payload, epoch)904 } catch (_: Exception) {905 VaultCrypto.zero(key)906 null907 }908 }909910 911 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {912 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")913 if (currentEpoch() != unlock.epoch) {914 VaultCrypto.zero(key)915 return@withLock UnlockResult.Error("stale_unlock")916 }917 return try {918 val adopted = mutateIfCurrentEpoch(unlock.epoch) {919 derivedKey = key920 decoyMode = true921 entryList = unlock.payload.entries.toMutableList()922 duressConfigured = false923 recoveryWordHash = unlock.payload.recoveryWordHash924 recoveryWordsSet = unlock.payload.recoveryWordsSet925 sessionDataAvailable = true926 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)927 else VaultCrypto.generateRootKey()928 _settings.value = store.loadSettings()929 publish()930 _isUnlocked.value = true931 sessionEpoch = nextSessionEpoch(sessionEpoch)932 }933 if (adopted == null) {934 VaultCrypto.zero(key)935 UnlockResult.Error("stale_unlock")936 } else {937 UnlockResult.Success938 }939 } catch (e: Exception) {940 941 942 943 944 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }945 VaultCrypto.zero(key)946 UnlockResult.Error(e.message ?: "decoy_unlock_failed")947 }948 }949950 951 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()952953 954955956957958959 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {960 961 val expectedEpoch = mutateState {962 if (derivedKey != null && !decoyMode) sessionEpoch else null963 } ?: return false964 965 966 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }967 var key: ByteArray? = null968 var basePlaintext: ByteArray? = null969 return try {970 val salt = VaultCrypto.generateSalt()971 key = VaultCrypto.deriveKey(duressPassphrase, salt)972 973 val decoyRoot = VaultCrypto.generateRootKey()974 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }975 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)976 basePlaintext = VaultCodec.encode(decoyPayload)977 978 979 980 981 982 983 984 985 val slotA = store.loadSlotA()986 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0987 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!988 val blob = try {989 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))990 } finally {991 VaultCrypto.zero(plaintext)992 993 994 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)995 basePlaintext = null996 key?.let(VaultCrypto::zero)997 key = null998 }999 beforeDurableCommit()1000 val ok = withEpochCommit(expectedEpoch) {1001 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))1002 } ?: false1003 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1004 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1005 } else if (ok) {1006 return false1007 }1008 ok1009 } catch (_: Exception) {1010 false1011 } finally {1012 1013 1014 key?.let(VaultCrypto::zero)1015 basePlaintext?.let(VaultCrypto::zero)1016 }1017 }101810191020 102110221023102410251026102710281029 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1030 if (!duressConfigured) return null1031 val slot = store.loadDecoySlot() ?: return null1032 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1033 }10341035 1036103710381039104010411042 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1043 1044 1045 1046 1047 1048 var salt: ByteArray? = null1049 var blob: ByteArray? = null1050 var nonce: ByteArray? = null1051 var ctAndTag: ByteArray? = null1052 var ct: ByteArray? = null1053 var tag: ByteArray? = null1054 try {1055 salt = unb64(slot.salt)1056 blob = unb64(slot.blob)1057 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1058 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1059 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1060 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1061 val obj = JsonObject(mapOf(1062 "version" to JsonPrimitive(1),1063 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1064 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1065 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1066 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1067 ))1068 return obj.toString()1069 } finally {1070 salt?.let(VaultCrypto::zero)1071 blob?.let(VaultCrypto::zero)1072 nonce?.let(VaultCrypto::zero)1073 ctAndTag?.let(VaultCrypto::zero)1074 ct?.let(VaultCrypto::zero)1075 tag?.let(VaultCrypto::zero)1076 }1077 }10781079 1080108110821083108410851086108710881089 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1090 val b = BrowserSlot.parse(json) ?: return false1091 var stored: StoredVault? = null1092 var salt: ByteArray? = null1093 var nonce: ByteArray? = null1094 var ct: ByteArray? = null1095 var tag: ByteArray? = null1096 var blob: ByteArray? = null1097 try {1098 salt = unhex(b.salt)1099 nonce = unhex(b.nonce)1100 ct = unhex(b.ciphertext)1101 tag = unhex(b.tag)1102 blob = ByteArray(nonce.size + ct.size + tag.size).also {1103 System.arraycopy(nonce, 0, it, 0, nonce.size)1104 System.arraycopy(ct, 0, it, nonce.size, ct.size)1105 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1106 }1107 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1108 val expectedEpoch = sessionEpoch1109 beforeDurableCommit()1110 withEpochCommit(expectedEpoch) {1111 store.writeDecoySlot(stored)1112 } ?: false1113 } catch (_: Exception) {1114 false1115 } finally {1116 salt?.let(VaultCrypto::zero)1117 nonce?.let(VaultCrypto::zero)1118 ct?.let(VaultCrypto::zero)1119 tag?.let(VaultCrypto::zero)1120 blob?.let(VaultCrypto::zero)1121 }1122 }11231124 1125 suspend fun clearDuress(): Boolean = mutex.withLock {1126 1127 1128 1129 1130 val expectedEpoch = currentEpoch()1131 val replaced = withEpochCommit(expectedEpoch) {1132 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1133 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1134 } ?: false1135 if (!replaced) return@withLock false1136 1137 1138 if (!isSessionEpochCurrent(expectedEpoch)) return false1139 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1140 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1141 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1142 }1143 true1144 }11451146 1147 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1148 var current: Throwable? = error1149 while (current != null) {1150 if (current is javax.crypto.AEADBadTagException) return true1151 1152 1153 1154 1155 if (current is javax.crypto.BadPaddingException) return true1156 current = current.cause1157 }1158 return false1159 }11601161 1162 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1163 val expectedEpoch = currentEpoch()1164 val slotA = try {1165 store.loadSlotA()1166 } catch (_: Exception) {1167 return@withLock UnlockResult.Error("unlock_key_storage_failed")1168 } ?: return@withLock UnlockResult.Error("no_vault")1169 1170 1171 1172 1173 var stagedKey: ByteArray? = key.copyOf()1174 return try {1175 val encrypted = try {1176 unb64(slotA.blob)1177 } catch (_: Exception) {1178 return@withLock UnlockResult.Error("invalid_vault_blob")1179 }1180 1181 1182 1183 1184 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1185 return@withLock UnlockResult.Error("invalid_vault_blob")1186 }1187 val plaintext = try {1188 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1189 } catch (e: Exception) {1190 if (isGcmAuthenticationFailure(e)) {1191 return@withLock UnlockResult.WrongPassword1192 }1193 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1194 }1195 val payload = try {1196 VaultCodec.decode(plaintext)1197 } catch (_: Exception) {1198 return@withLock UnlockResult.Error("invalid_vault_payload")1199 } finally {1200 VaultCrypto.zero(plaintext)1201 }1202 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1203 val result = adoptSlotA(candidate, payload, expectedEpoch)1204 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1205 result1206 } finally {1207 stagedKey?.let(VaultCrypto::zero)1208 }1209 }12101211 fun lock() {1212 1213 1214 1215 1216 if (mutex.tryLock()) {1217 try {1218 runCatching { LockedSyncSpoolHooks.refresh(this) }1219 } finally {1220 mutex.unlock()1221 }1222 }1223 1224 1225 1226 1227 1228 1229 stateLock.lock()1230 try {1231 sessionEpoch = nextSessionEpoch(sessionEpoch)1232 clearSessionStateLocked()1233 _uiGateLocked.value = false1234 } finally {1235 stateLock.unlock()1236 }1237 }12381239 1240124112421243124412451246 fun lockUiGate() {1247 if (_isUnlocked.value) _uiGateLocked.value = true1248 }12491250 1251 fun clearUiGate() {1252 _uiGateLocked.value = false1253 }12541255 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12561257 1258 1259 @VisibleForTesting1260 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12611262 126312641265126612671268126912701271127212731274 fun expectTransientBackground() {1275 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1276 }12771278 12791280128112821283 fun consumeTransientBackgroundSuppression(): Boolean {1284 val until = suppressNextBackgroundLockUntil1285 val active = until != 0L && nowMs() < until1286 suppressNextBackgroundLockUntil = 0L1287 return active1288 }12891290 12911292 12931294129512961297 12981299130013011302130313041305130613071308 1309 1310 1311 1312 1313 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13141315 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1316 if (decoyMode || !_isUnlocked.value) null1317 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1318 }13191320 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1321 mutateState { sessionEpoch == epoch }13221323 internal fun unlockedSessionEpoch(): Long? = mutateState {1324 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1325 }13261327 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13281329 133013311332133313341335133613371338 fun sessionRootKey(): ByteArray? = mutateState {1339 if (!sessionDataAvailable) null else rootKey?.copyOf()1340 }13411342 13431344 fun syncVaultId(): String? = mutateState {1345 if (decoyMode || !_isUnlocked.value) null1346 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1347 }13481349 13501351135213531354 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1355 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1356 ?: return false1357 installRootKey(generateRecoverableRootKey(), expectedEpoch)1358 }13591360 13611362136313641365136613671368136913701371137213731374137513761377 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1378 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1379 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1380 }1381 val expectedEpoch = mutateState {1382 when {1383 derivedKey == null -> null1384 decoyMode -> Long.MIN_VALUE1385 else -> sessionEpoch1386 }1387 }1388 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1389 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1390 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13911392 val incoming = hexToRootKey(rootKeyHex)1393 if (incoming.size != VaultCrypto.KEY_SIZE) {1394 VaultCrypto.zero(incoming)1395 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1396 }1397 1398 1399 1400 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1401 if (!roundTrips) {1402 VaultCrypto.zero(incoming)1403 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1404 }14051406 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1407 return if (installRootKey(incoming, expectedEpoch)) {1408 AdoptVaultResult.Success(vaultId)1409 } else {1410 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1411 }1412 }14131414 14151416141714181419 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1420 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1421 val bytes = hexToRootKey(rootKeyHex)1422 return try {1423 val words = rootKeyToWords(bytes)1424 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1425 } catch (_: Exception) {1426 null1427 } finally {1428 VaultCrypto.zero(bytes)1429 }1430 }14311432 14331434143514361437 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1438 1439 1440 1441 1442 1443 1444 data class Snapshot(1445 val key: ByteArray,1446 val priorRootKey: ByteArray,1447 val priorRecoveryHash: String?,1448 val priorRecoverySet: Boolean,1449 )1450 val snap = mutateIfCurrentEpoch(expectedEpoch) {1451 val k = derivedKey ?: return@mutateIfCurrentEpoch null1452 if (decoyMode) return@mutateIfCurrentEpoch null1453 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1454 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1455 } ?: run {1456 VaultCrypto.zero(nextRootKey)1457 return false1458 }1459 val priorSlot = store.loadSlotA() ?: run {1460 VaultCrypto.zero(snap.key)1461 VaultCrypto.zero(snap.priorRootKey)1462 VaultCrypto.zero(nextRootKey)1463 return false1464 }1465 val nextWords = rootKeyToWords(nextRootKey)14661467 return try {1468 beforeDurableCommit()1469 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1470 if (mutateIfCurrentEpoch(expectedEpoch) {1471 rootKey = nextRootKey1472 recoveryWordHash = recoveryWordsHash(nextWords)1473 recoveryWordsSet = false1474 } == null) throw IllegalStateException("stale_root_rotation")14751476 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1477 ?: throw IllegalStateException("recovery_escrow_failed")1478 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1479 ?: throw IllegalStateException("root_escrow_failed")1480 val plaintext = VaultCodec.encode(currentPayload())1481 val blob = try {1482 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1483 } finally {1484 VaultCrypto.zero(plaintext)1485 }1486 val nextSlot = priorSlot.copy(1487 blob = blob,1488 recoveryEscrow = nextRecoveryEscrow,1489 rootKeyEscrow = nextRootKeyEscrow,1490 )1491 val committed = withEpochCommit(expectedEpoch) {1492 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1493 try {1494 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1495 1496 1497 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1498 store.stampLocalMutation()1499 } catch (_: Exception) {1500 store.writeSlotA(priorSlot)1501 return@withEpochCommit false1502 }1503 true1504 } ?: false1505 if (!committed) throw IllegalStateException("storage_write_failed")1506 mutateIfCurrentEpoch(expectedEpoch) { publish() }1507 true1508 } catch (_: Exception) {1509 val restored = mutateIfCurrentEpoch(expectedEpoch) {1510 rootKey?.let(VaultCrypto::zero)1511 rootKey = snap.priorRootKey.copyOf()1512 recoveryWordHash = snap.priorRecoveryHash1513 recoveryWordsSet = snap.priorRecoverySet1514 }1515 if (restored != null) {1516 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1517 }1518 false1519 } finally {1520 VaultCrypto.zero(snap.key)1521 VaultCrypto.zero(snap.priorRootKey)1522 1523 1524 1525 val stillOwnedBySession = mutateState {1526 sessionEpoch == expectedEpoch && rootKey === nextRootKey1527 }1528 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1529 }1530 }15311532 15331534153515361537 fun currentVaultKey(): ByteArray? = mutateState {1538 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1539 }15401541 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1542 val expectedEpoch = currentEpoch()1543 val slotA = store.loadSlotA() ?: return false1544 var oldKey: ByteArray? = null1545 var newKey: ByteArray? = null1546 var plaintext: ByteArray? = null1547 return try {1548 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1549 1550 1551 1552 1553 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1554 val payload = try {1555 VaultCodec.decode(decrypted)1556 } finally {1557 VaultCrypto.zero(decrypted)1558 }1559 val newSalt = VaultCrypto.generateSalt()1560 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1561 1562 plaintext = VaultCodec.encode(payload)1563 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1564 1565 1566 1567 1568 1569 1570 1571 val next = StoredVault(1572 version = slotA.version,1573 salt = b64(newSalt),1574 blob = blob,1575 recoveryEscrow = slotA.recoveryEscrow,1576 rootKeyEscrow = slotA.rootKeyEscrow,1577 )1578 1579 1580 1581 1582 val priorB = store.loadSlotB()1583 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1584 1585 1586 1587 if (mutateIfCurrentEpoch(expectedEpoch) {1588 oldKey?.let(VaultCrypto::zero); oldKey = null1589 derivedKey?.let(VaultCrypto::zero)1590 derivedKey = newKey; newKey = null1591 } == null) return false1592 true1593 } catch (_: Exception) {1594 false1595 } finally {1596 oldKey?.let(VaultCrypto::zero)1597 newKey?.let(VaultCrypto::zero)1598 plaintext?.let(VaultCrypto::zero)1599 }1600 }16011602 1603160416051606 suspend fun destroyVault() = mutex.withLock {1607 lock()1608 store.clearVault()1609 store.clearDecoySlot()1610 }16111612 16131614 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1615 val epoch = requireUnlocked()1616 val stamp = VaultCrypto.monotonicEntryTimestamp()1617 val now = System.currentTimeMillis()1618 val entry = withDerivedLast4(draft.copy(1619 id = draft.id.ifBlank { UUID.randomUUID().toString() },1620 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1621 modifiedAt = stamp,1622 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1623 ))1624 1625 1626 val rollback = mutateCurrentSession(epoch) {1627 val r = entryList.toMutableList()1628 entryList.add(entry)1629 r1630 } ?: throw IllegalStateException("vault_locked")1631 persistOrRollback(rollback, epoch)1632 entry1633 }16341635 163616371638163916401641164216431644 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1645 val epoch = requireUnlocked()1646 if (drafts.isEmpty()) return 01647 val now = System.currentTimeMillis()1648 val ready = drafts.map { draft ->1649 val stamp = VaultCrypto.monotonicEntryTimestamp()1650 withDerivedLast4(1651 draft.copy(1652 id = draft.id.ifBlank { UUID.randomUUID().toString() },1653 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1654 modifiedAt = stamp,1655 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1656 ),1657 )1658 }1659 1660 val rollback = mutateCurrentSession(epoch) {1661 val r = entryList.toMutableList()1662 entryList.addAll(ready)1663 r1664 } ?: throw IllegalStateException("vault_locked")1665 persistOrRollback(rollback, epoch)1666 return drafts.size1667 }16681669 1670167116721673167416751676167716781679168016811682 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1683 val epoch = requireUnlocked()1684 val now = System.currentTimeMillis()1685 val ready = newEntries.map { draft ->1686 val stamp = VaultCrypto.monotonicEntryTimestamp()1687 withDerivedLast4(1688 draft.copy(1689 id = draft.id.ifBlank { UUID.randomUUID().toString() },1690 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1691 modifiedAt = stamp,1692 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1693 ),1694 )1695 }1696 1697 1698 1699 1700 1701 1702 val incomingIds = ready.map { it.id }.toHashSet()1703 val rollback = mutateCurrentSession(epoch) {1704 val r = entryList.toMutableList()1705 val replaced = entryList.map { entry ->1706 if (entry.deletedAt != null) return@map entry1707 if (entry.id in incomingIds) return@map null1708 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1709 entry.copy(deletedAt = stamp, modifiedAt = stamp)1710 }.filterNotNull()1711 entryList = (replaced + ready).toMutableList()1712 r1713 } ?: throw IllegalStateException("vault_locked")1714 persistOrRollback(rollback, epoch)1715 return ready.size1716 }17171718 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1719 val epoch = requireUnlocked()1720 1721 1722 1723 1724 val prepared = mutateCurrentSession(epoch) {1725 val idx = entryList.indexOfFirst { it.id == patch.id }1726 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1727 val current = entryList[idx]1728 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1729 1730 val passwordChanged = patch.password != null && patch.password != current.password1731 1732 1733 1734 1735 1736 1737 val priorPassword = current.password1738 val shouldCapture = passwordChanged &&1739 !priorPassword.isNullOrEmpty() &&1740 current.passwordHistory?.firstOrNull()?.password != priorPassword1741 val nextHistory = if (shouldCapture) {1742 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1743 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1744 } else {1745 current.passwordHistory1746 }1747 val updated = withDerivedLast4(patch.copy(1748 createdAt = current.createdAt,1749 modifiedAt = stamp,1750 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1751 passwordHistory = nextHistory,1752 ))1753 val rollback = entryList.toMutableList()1754 entryList[idx] = updated1755 updated to rollback1756 } ?: throw IllegalStateException("vault_locked")1757 val (updated, rollback) = prepared1758 persistOrRollback(rollback, epoch)1759 updated1760 }17611762 1763 suspend fun deleteEntry(id: String) = mutex.withLock {1764 val epoch = requireUnlocked()1765 1766 1767 val rollback = mutateCurrentSession(epoch) {1768 val idx = entryList.indexOfFirst { it.id == id }1769 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1770 val current = entryList[idx]1771 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1772 val prior = entryList.toMutableList()1773 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1774 prior1775 } ?: throw IllegalStateException("vault_locked")1776 persistOrRollback(rollback, epoch)1777 }17781779 suspend fun restoreEntry(id: String) = mutex.withLock {1780 val epoch = requireUnlocked()1781 val rollback = mutateCurrentSession(epoch) {1782 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1783 if (idx == -1) throw IllegalStateException("deleted entry not found")1784 val current = entryList[idx]1785 val prior = entryList.toMutableList()1786 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1787 prior1788 } ?: throw IllegalStateException("vault_locked")1789 persistOrRollback(rollback, epoch)1790 }17911792 1793 suspend fun purgeEntry(id: String) = mutex.withLock {1794 val epoch = requireUnlocked()1795 val rollback = mutateCurrentSession(epoch) {1796 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1797 if (idx == -1) throw IllegalStateException("deleted entry not found")1798 val prior = entryList.toMutableList()1799 entryList.removeAt(idx)1800 prior1801 } ?: throw IllegalStateException("vault_locked")1802 persistOrRollback(rollback, epoch)1803 }18041805 1806 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1807 val epoch = requireUnlocked()1808 1809 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1810 val r = mutateCurrentSession(epoch) {1811 val rollback = entryList.toMutableList()1812 val before = entryList.size1813 entryList.removeAll { it.deletedAt != null }1814 R(rollback, before - entryList.size)1815 } ?: throw IllegalStateException("vault_locked")1816 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1817 r.removed1818 }18191820 1821 suspend fun countDuplicateEntries(): Int = mutex.withLock {1822 val epoch = requireUnlocked()1823 1824 1825 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1826 ?: throw IllegalStateException("vault_locked")1827 }18281829 18301831183218331834 suspend fun deduplicateEntries(): Int = mutex.withLock {1835 val epoch = requireUnlocked()1836 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1837 val ready = mutateCurrentSession(epoch) {1838 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1839 val active = entryList.filter { it.deletedAt == null }1840 val keepIds = mutableSetOf<String>()1841 active.groupBy { it.duplicateKey() }.values.forEach { group ->1842 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1843 }1844 val rollback = entryList.toMutableList()1845 var removed = 01846 entryList = entryList.map { entry ->1847 if (entry.deletedAt == null && entry.id !in keepIds) {1848 removed++1849 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1850 entry.copy(deletedAt = stamp, modifiedAt = stamp)1851 } else {1852 entry1853 }1854 }.toMutableList()1855 R(rollback, removed)1856 } ?: throw IllegalStateException("vault_locked")1857 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1858 ready.removed1859 }18601861 fun getEntries(): List<VaultEntry> = mutateState {1862 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1863 }1864 fun getEntry(id: String): VaultEntry? = mutateState {1865 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1866 }1867 fun getDeletedEntries(): List<VaultEntry> = mutateState {1868 if (!sessionDataAvailable) emptyList()1869 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1870 }18711872 1873 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1874 return mutateState {1875 if (!sessionDataAvailable) return@mutateState emptyList()1876 val active = entryList.filter { it.deletedAt == null }1877 if (tagFilter == null) return@mutateState active1878 val needle = tagFilter.trim().lowercase()1879 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1880 }1881 }18821883 1884 fun getDistinctTags(): List<String> = mutateState {1885 if (!sessionDataAvailable) return@mutateState emptyList()1886 entryList.asSequence()1887 .filter { it.deletedAt == null }1888 .flatMap { it.tags?.asSequence() ?: emptySequence() }1889 .map { it.trim().lowercase() }1890 .filter { it.isNotEmpty() }1891 .distinct()1892 .sorted()1893 .toList()1894 }18951896 189718981899190019011902 fun getDistinctFieldValues(field: String): List<String> {1903 val select: (VaultEntry) -> String? = when (field) {1904 "username" -> { e -> e.username }1905 "email" -> { e -> e.email }1906 else -> return emptyList()1907 }1908 return mutateState {1909 if (!sessionDataAvailable) return@mutateState emptyList()1910 entryList.asSequence()1911 .filter { it.deletedAt == null }1912 .mapNotNull { select(it)?.trim() }1913 .filter { it.isNotEmpty() }1914 .groupingBy { it.lowercase() }1915 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1916 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1917 }1918 .values1919 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1920 .map { it.first }1921 }1922 }19231924 fun getTombstones(): List<Tombstone> = mutateState {1925 if (!sessionDataAvailable) emptyList()1926 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1927 }1928 fun getVaultVersion(): Long = store.version()1929 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19301931 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1932 val expectedEpoch = currentEpoch()1933 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1934 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1935 1936 1937 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1938 val priorA = store.loadSlotA()1939 val priorB = store.loadSlotB()1940 val priorD = store.loadDecoySlot()1941 1942 1943 1944 1945 1946 val priorHistory = store.loadVaultHistory()1947 1948 1949 1950 1951 beforeDurableCommit()1952 if (!takeVaultSnapshot("restore", expectedEpoch)) {1953 return false1954 }1955 1956 1957 1958 val nextD: StoredVault = restoredD ?: run {1959 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161960 val fakeBlob = ByteArray(target).also(rng::nextBytes)1961 val fakeSalt = VaultCrypto.generateSalt()1962 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1963 }1964 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1965 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1966 return false1967 }1968 lock()1969 true1970 }19711972 19731974197519761977197819791980198119821983 private fun commitRestoreAtomic(1984 nextA: StoredVault,1985 nextB: String,1986 nextD: StoredVault,1987 priorA: StoredVault?,1988 priorB: String?,1989 priorD: StoredVault?,1990 expectedEpoch: Long,1991 ): Boolean {1992 return withEpochCommit(expectedEpoch) {1993 if (!store.writeSlotA(nextA)) return@withEpochCommit false1994 1995 1996 1997 try {1998 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1999 2000 2001 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2002 store.stampLocalMutation()2003 } catch (_: Exception) {2004 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2005 return@withEpochCommit false2006 }2007 true2008 } ?: false2009 }20102011 20122013 20142015201620172018 suspend fun applyMergedRemoteEntries(2019 remoteEntries: List<VaultEntry>,2020 remoteTombstones: List<Tombstone>,2021 expectedEpoch: Long? = null,2022 ): Int = mutex.withLock {2023 val epoch = expectedEpoch ?: requireUnlocked()2024 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02025 2026 2027 2028 2029 2030 val mergeNow = System.currentTimeMillis()2031 val clamped = remoteEntries.mapNotNull { entry ->2032 2033 2034 2035 SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2036 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2037 deletedAt = entry.deletedAt?.let {2038 SyncLimits.clampSyncTimestamp(it, mergeNow)2039 },2040 )2041 }2042 val tombMap = remoteTombstones.associate {2043 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2044 }2045 2046 2047 val applied = mutateCurrentSession(epoch) {2048 var n = 02049 val byId = entryList.associateBy { it.id }.toMutableMap()20502051 for (remote in clamped) {2052 val tomb = tombMap[remote.id]2053 if (tomb != null && tomb >= remote.modifiedAt) continue2054 val local = byId[remote.id]2055 if (local == null) {2056 entryList.add(remote); byId[remote.id] = remote; n++2057 } else if (remote.modifiedAt > local.modifiedAt) {2058 val i = entryList.indexOfFirst { it.id == local.id }2059 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2060 }2061 }20622063 for ((id, deletedAt) in tombMap) {2064 val local = byId[id] ?: continue2065 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2066 if (deletedAt < local.modifiedAt) continue2067 val i = entryList.indexOfFirst { it.id == id }2068 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2069 }2070 n2071 } ?: return@withLock 020722073 if (applied > 0) {2074 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2075 mutateIfCurrentEpoch(epoch) { publish() }2076 runCatching { LockedSyncSpoolHooks.refresh(this) }2077 }2078 applied2079 }20802081 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2082 2083 2084 2085 val epoch = requireUnlocked()2086 val prior = mutateCurrentSession(epoch) {2087 val old = _settings.value2088 _settings.value = settings2089 old2090 } ?: throw IllegalStateException("vault_locked")20912092 val committed = try {2093 beforeDurableCommit()2094 2095 2096 2097 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2098 } catch (_: Exception) {2099 false2100 }2101 if (!committed) {2102 2103 2104 2105 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2106 throw IllegalStateException("settings_write_failed")2107 }2108 }21092110 21112112 2113 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21142115 21162117211821192120 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2121 val epoch = requireUnlocked()2122 2123 2124 2125 2126 2127 2128 val prior = mutateCurrentSession(epoch) {2129 val priorPair = recoveryWordHash to recoveryWordsSet2130 recoveryWordHash = recoveryWordsHash(words)2131 recoveryWordsSet = true2132 priorPair2133 } ?: return@withLock false2134 2135 2136 2137 2138 val persisted = runCatching {2139 persistSlotAWithEscrows(2140 buildRecoveryEscrow(words),2141 buildRootKeyEscrow(words),2142 expectedEpoch = epoch,2143 )2144 }.getOrDefault(false)2145 if (!persisted) {2146 mutateIfCurrentEpoch(epoch) {2147 recoveryWordHash = prior.first2148 recoveryWordsSet = prior.second2149 }2150 }2151 persisted2152 }21532154 2155 fun verifyRecoveryWords(words: List<String>): Boolean {2156 return mutateState {2157 val stored = recoveryWordHash ?: return@mutateState false2158 stored == recoveryWordsHash(words)2159 }2160 }21612162 21632164 2165 fun hasRecoveryEscrow(): Boolean =2166 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21672168 21692170217121722173 private fun ensureRecoveryEscrow() {2174 if (hasRecoveryEscrow()) return2175 val rk = mutateState { rootKey?.copyOf() } ?: return2176 runCatching {2177 val words = rootKeyToWords(rk)2178 val escrow = buildRecoveryEscrow(words)2179 val rkEscrow = buildRootKeyEscrow(words)2180 if (escrow != null || rkEscrow != null) {2181 val prior = store.loadSlotA() ?: return@runCatching2182 store.writeSlotA(prior.copy(2183 recoveryEscrow = escrow ?: prior.recoveryEscrow,2184 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2185 ))2186 mutateState {2187 recoveryWordHash = recoveryWordsHash(words)2188 recoveryWordsSet = true2189 }2190 }2191 }.also { VaultCrypto.zero(rk) }2192 }21932194 219521962197219821992200220122022203220422052206 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2207 val expectedEpoch = currentEpoch()2208 val slotA = store.loadSlotA() ?: return false2209 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22102211 2212 2213 val fullEscrow = slotA.recoveryEscrow2214 if (fullEscrow != null) {2215 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2216 if (material != null) {2217 var keyBytes: ByteArray? = null2218 var plaintext: ByteArray? = null2219 var candidateRoot: ByteArray? = null2220 var stateCommitStarted = false2221 try {2222 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2223 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2224 val payload = VaultCodec.decode(plaintext)2225 2226 2227 2228 2229 val candidateSettings = store.loadSettings()2230 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2231 val decodedRoot = unb64(payload.rootKeyB64)2232 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2233 2234 2235 2236 VaultCrypto.zero(decodedRoot)2237 throw IllegalArgumentException("invalid recovery root key")2238 }2239 decodedRoot2240 } else {2241 generateRecoverableRootKey()2242 }2243 stateCommitStarted = true2244 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2245 clearSessionStateLocked()2246 entryList = payload.entries.toMutableList()2247 duressConfigured = payload.duressConfigured2248 recoveryWordHash = payload.recoveryWordHash2249 recoveryWordsSet = payload.recoveryWordsSet2250 rootKey = candidateRoot2251 candidateRoot = null2252 _settings.value = candidateSettings2253 publish()2254 2255 2256 sessionDataAvailable = true2257 _isUnlocked.value = false2258 true2259 }2260 if (adopted == true) return@withLock true2261 } catch (_: Exception) {2262 2263 2264 2265 2266 if (stateCommitStarted) {2267 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2268 }2269 } finally {2270 plaintext?.let(VaultCrypto::zero)2271 VaultCrypto.zero(material)2272 keyBytes?.let(VaultCrypto::zero)2273 candidateRoot?.let(VaultCrypto::zero)2274 }2275 }2276 }22772278 2279 2280 2281 val rootEscrow = slotA.rootKeyEscrow2282 if (rootEscrow != null) {2283 var candidateRoot: ByteArray? = null2284 var stateCommitStarted = false2285 try {2286 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2287 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2288 val candidateSettings = store.loadSettings()2289 stateCommitStarted = true2290 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2291 clearSessionStateLocked()2292 entryList = mutableListOf()2293 duressConfigured = false2294 recoveryWordHash = recoveryWordsHash(words)2295 recoveryWordsSet = true2296 rootKey = candidateRoot2297 candidateRoot = null2298 _settings.value = candidateSettings2299 publish()2300 sessionDataAvailable = true2301 _isUnlocked.value = false2302 true2303 }2304 if (adopted == true) return@withLock true2305 } catch (_: Exception) {2306 if (stateCommitStarted) {2307 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2308 }2309 } finally {2310 candidateRoot?.let(VaultCrypto::zero)2311 }2312 }2313 return false2314 }23152316 231723182319232023212322232323242325 suspend fun resetPasswordWithRecoveryWords(2326 words: List<String>,2327 newPassword: String,2328 ): Boolean = mutex.withLock {2329 val expectedEpoch = currentEpoch()2330 2331 2332 2333 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2334 val priorA = store.loadSlotA() ?: return false2335 var newKey: ByteArray? = null2336 var plaintext: ByteArray? = null2337 return try {2338 val newSalt = VaultCrypto.generateSalt()2339 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2340 plaintext = VaultCodec.encode(currentPayload())2341 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2342 2343 2344 2345 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2346 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2347 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2348 val next = StoredVault(2349 version = priorA.version,2350 salt = b64(newSalt),2351 blob = blob,2352 recoveryEscrow = freshRecovery,2353 rootKeyEscrow = freshRootKey,2354 )2355 2356 2357 2358 val priorB = store.loadSlotB()2359 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2360 2361 2362 2363 if (mutateIfCurrentEpoch(expectedEpoch) {2364 derivedKey?.let(VaultCrypto::zero)2365 derivedKey = newKey; newKey = null2366 publish()2367 _isUnlocked.value = true2368 } == null) return false2369 true2370 } catch (_: Exception) {2371 false2372 } finally {2373 newKey?.let(VaultCrypto::zero)2374 plaintext?.let(VaultCrypto::zero)2375 }2376 }23772378 237923802381238223832384 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2385 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2386 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2387 var keyBytes: ByteArray? = null2388 var plaintext: ByteArray? = null2389 return try {2390 val salt = VaultCrypto.generateSalt()2391 val recoveryKey = deriveRecoveryKey(material, salt)2392 keyBytes = recoveryKey.encoded2393 plaintext = VaultCodec.encode(currentPayload())2394 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2395 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2396 } finally {2397 VaultCrypto.zero(material)2398 keyBytes?.let(VaultCrypto::zero)2399 plaintext?.let(VaultCrypto::zero)2400 }2401 }24022403 24042405240624072408240924102411 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2412 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2413 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2414 val rk = mutateState { rootKey?.copyOf() } ?: run {2415 VaultCrypto.zero(material)2416 return null2417 }2418 var keyBytes: ByteArray? = null2419 var plaintext: ByteArray? = null2420 return try {2421 val salt = VaultCrypto.generateSalt()2422 val recoveryKey = deriveRecoveryKey(material, salt)2423 keyBytes = recoveryKey.encoded2424 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2425 2426 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2427 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2428 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2429 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2430 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2431 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2432 } finally {2433 VaultCrypto.zero(rk)2434 VaultCrypto.zero(material)2435 keyBytes?.let(VaultCrypto::zero)2436 plaintext?.let(VaultCrypto::zero)2437 }2438 }24392440 2441 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2442 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2443 var keyBytes: ByteArray? = null2444 var pt: ByteArray? = null2445 return try {2446 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2447 keyBytes = recoveryKey.encoded2448 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2449 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2450 hexToRootKey(String(pt, Charsets.US_ASCII))2451 } catch (_: Exception) {2452 null2453 } finally {2454 2455 2456 2457 VaultCrypto.zero(material)2458 keyBytes?.let(VaultCrypto::zero)2459 pt?.let(VaultCrypto::zero)2460 }2461 }24622463 246424652466246724682469 private fun persistSlotAWithEscrows(2470 recoveryEscrow: RecoveryEscrow?,2471 rootKeyEscrow: RootKeyEscrow?,2472 expectedEpoch: Long? = null,2473 ): Boolean {2474 val snapshot = snapshotForPersist(expectedEpoch)2475 ?: throw IllegalStateException("vault_locked_or_stale")2476 if (snapshot.decoy) {2477 VaultCrypto.zero(snapshot.key)2478 return false2479 }2480 try {2481 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2482 val plaintext = VaultCodec.encode(snapshot.payload)2483 val blob = try {2484 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2485 } finally {2486 2487 2488 VaultCrypto.zero(plaintext)2489 }2490 val next = prior.copy(2491 blob = blob,2492 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2493 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2494 )2495 beforeDurableCommit()2496 return withEpochCommit(snapshot.epoch) {2497 if (!store.writeSlotA(next)) return@withEpochCommit false2498 try {2499 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2500 2501 2502 2503 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2504 } catch (e: Exception) {2505 store.writeSlotA(prior)2506 throw e2507 }2508 true2509 } ?: false2510 } finally {2511 VaultCrypto.zero(snapshot.key)2512 }2513 }25142515 25162517 private fun requireUnlocked(): Long {2518 2519 2520 2521 stateLock.lock()2522 try {2523 if (derivedKey == null) throw IllegalStateException("vault_locked")2524 return sessionEpoch2525 } finally {2526 stateLock.unlock()2527 }2528 }25292530 2531 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25322533 private fun countDuplicateEntriesLocked(): Int {2534 val seen = mutableMapOf<String, VaultEntry>()2535 var duplicates = 02536 for (entry in entryList.filter { it.deletedAt == null }) {2537 val key = entry.duplicateKey()2538 val existing = seen[key]2539 if (existing == null) {2540 seen[key] = entry2541 } else {2542 duplicates++2543 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2544 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2545 if (entryStamp > existingStamp) seen[key] = entry2546 }2547 }2548 return duplicates2549 }25502551 private fun VaultEntry.duplicateKey(): String {2552 if (type == "note") return "note|${title.lowercase()}"2553 val passkeyData = passkey2554 if (passkeyData != null) {2555 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2556 }2557 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2558 }25592560 256125622563256425652566 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2567 val epoch = expectedEpoch ?: currentEpoch()2568 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2569 val slotA = store.loadSlotA() ?: return false2570 val vaultA = VaultCodec.encodeSlot(slotA)2571 val vaultB = store.loadSlotB() ?: slotA.blob2572 2573 2574 2575 2576 2577 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2578 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162579 val fakeBlob = ByteArray(target).also(rng::nextBytes)2580 val fakeSalt = VaultCrypto.generateSalt()2581 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2582 }2583 val history = store.loadVaultHistory()2584 val latest = history.snapshots.firstOrNull()2585 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2586 val entryCount = mutateIfCurrentEpoch(epoch) {2587 if (derivedKey != null && !decoyMode) {2588 bucketEntryCount(entryList.count { it.deletedAt == null })2589 } else {2590 null2591 }2592 } ?: return false2593 return try {2594 val snapshot = VaultSnapshot(2595 id = UUID.randomUUID().toString(),2596 savedAt = System.currentTimeMillis(),2597 reason = reason,2598 vault_a = vaultA,2599 vault_b = vaultB,2600 vault_d = vaultD,2601 entryCount = entryCount,2602 )2603 2604 2605 2606 withEpochCommit(epoch) {2607 store.writeVaultHistory(2608 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2609 )2610 } ?: false2611 } catch (_: Exception) {2612 false2613 }2614 }26152616 private fun bucketEntryCount(n: Int): Int = when {2617 n <= 0 -> 02618 n <= 10 -> 102619 n <= 25 -> 252620 n <= 50 -> 502621 n <= 100 -> 1002622 n <= 250 -> 2502623 n <= 500 -> 5002624 else -> ((n + 499) / 500) * 5002625 }26262627 262826292630263126322633263426352636 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2637 if (!entry.isCard()) return entry2638 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2639 val derived = if (digits.length >= 4) digits.takeLast(4) else null2640 return entry.copy(last4 = derived ?: entry.last4)2641 }26422643 264426452646264726482649 private fun autoPurgeExpiredTrash() {2650 val days = _settings.value.trashRetentionDays ?: return2651 if (days <= 0) return2652 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2653 val before = entryList.size2654 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2655 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2656 }26572658 265926602661266226632664 private fun consolidatePasskeyEntries() {2665 val passkeyOnly = entryList.filter { entry ->2666 entry.deletedAt == null &&2667 entry.isLogin() &&2668 entry.password.isNullOrBlank() &&2669 !entry.rpId.isNullOrBlank() &&2670 !entry.credentialId.isNullOrBlank()2671 }2672 if (passkeyOnly.isEmpty()) return2673 var changed = false2674 val now = System.currentTimeMillis()2675 for (pkEntry in passkeyOnly) {2676 val rpId = pkEntry.rpId ?: continue2677 val target = entryList.firstOrNull { candidate ->2678 candidate.deletedAt == null &&2679 candidate.id != pkEntry.id &&2680 candidate.isLogin() &&2681 candidate.passkey == null &&2682 candidate.credentialId.isNullOrBlank() &&2683 !candidate.password.isNullOrBlank() &&2684 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2685 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2686 } ?: continue2687 val idx = entryList.indexOfFirst { it.id == target.id }2688 if (idx < 0) continue2689 entryList[idx] = target.copy(2690 rpId = pkEntry.rpId,2691 rpName = pkEntry.rpName ?: target.rpName,2692 credentialId = pkEntry.credentialId,2693 publicKeyCose = pkEntry.publicKeyCose,2694 privateKeyJwk = pkEntry.privateKeyJwk,2695 userHandle = pkEntry.userHandle,2696 userName = pkEntry.userName,2697 userDisplayName = pkEntry.userDisplayName,2698 algorithm = pkEntry.algorithm,2699 signCount = pkEntry.signCount,2700 lastUsedAt = pkEntry.lastUsedAt,2701 modifiedAt = now,2702 )2703 2704 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2705 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2706 changed = true2707 }2708 if (changed) runCatching { persistSlotA(bumpVersion = false) }2709 }27102711 2712 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27132714 private fun currentPayloadLocked(): VaultPayload =2715 VaultPayload(2716 entries = entryList.toList(),2717 duressConfigured = duressConfigured,2718 rootKeyB64 = rootKey?.let(::b64) ?: "",2719 recoveryWordHash = recoveryWordHash,2720 recoveryWordsSet = recoveryWordsSet,2721 )27222723 27242725272627272728 private fun ensureRootKey(payload: VaultPayload) {2729 if (payload.rootKeyB64.isNotBlank()) {2730 rootKey = unb64(payload.rootKeyB64)2731 return2732 }2733 rootKey = generateRecoverableRootKey()2734 runCatching { persistSlotA(bumpVersion = false) }2735 }27362737 private fun publish() {2738 _entries.value = entryList.filter { it.deletedAt == null }2739 }27402741 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2742 var plaintext = VaultCodec.encode(currentPayload())2743 return try {2744 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2745 StoredVault(version = 1, salt = b64(salt), blob = blob)2746 } finally {2747 2748 2749 2750 VaultCrypto.zero(plaintext)2751 }2752 }27532754 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2755 2756 2757 2758 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2759 val noise = ByteArray(approxBytes).also(rng::nextBytes)2760 return store.writeSlotB(b64(noise))2761 }27622763 276427652766 private fun matchingCiphertextLength(slotA: StoredVault): Int =2767 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27682769 27702771277227732774 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2775 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162776 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2777 val fakeSalt = VaultCrypto.generateSalt()2778 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2779 }27802781 278227832784278527862787 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2788 val isConfigured = mutateState { duressConfigured }2789 return if (isConfigured) true else writeFakeDuressSlot(slotA)2790 }27912792 2793279427952796279727982799 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2800 val salt = unhex(b.salt)2801 val nonce = unhex(b.nonce)2802 val ct = unhex(b.ciphertext)2803 val tag = unhex(b.tag)2804 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2805 System.arraycopy(nonce, 0, it, 0, nonce.size)2806 System.arraycopy(ct, 0, it, nonce.size, ct.size)2807 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2808 }2809 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2810 }28112812 2813 private fun persistSlotA(2814 bumpVersion: Boolean,2815 snapshotReason: String? = "local_save",2816 expectedEpoch: Long? = null,2817 ): Boolean {2818 val snapshot = snapshotForPersist(expectedEpoch)2819 ?: throw IllegalStateException("vault_locked_or_stale")2820 return try {2821 2822 if (snapshot.decoy) {2823 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2824 val plaintext = VaultCodec.encode(snapshot.payload)2825 val blob = try {2826 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2827 } finally {2828 2829 2830 VaultCrypto.zero(plaintext)2831 }2832 beforeDurableCommit()2833 return withEpochCommit(snapshot.epoch) {2834 store.writeDecoySlot(prior.copy(blob = blob))2835 } ?: false2836 }2837 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2838 2839 2840 2841 2842 val priorB = store.loadSlotB()2843 beforeDurableCommit()2844 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2845 val plaintext = VaultCodec.encode(snapshot.payload)2846 val blob = try {2847 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2848 } finally {2849 2850 2851 VaultCrypto.zero(plaintext)2852 }2853 val next = prior.copy(blob = blob)2854 2855 2856 2857 2858 withEpochCommit(snapshot.epoch) {2859 if (!store.writeSlotA(next)) return@withEpochCommit false2860 try {2861 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2862 2863 2864 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2865 if (bumpVersion) store.stampLocalMutation()2866 } catch (e: Exception) {2867 store.writeSlotA(prior)2868 if (priorB != null) runCatching { store.writeSlotB(priorB) }2869 throw e2870 }2871 true2872 } ?: false2873 } finally {2874 2875 2876 VaultCrypto.zero(snapshot.key)2877 }2878 }28792880 2881288228832884288528862887 private fun commitVaultRotation(2888 priorA: StoredVault,2889 priorB: String?,2890 nextA: StoredVault,2891 expectedEpoch: Long,2892 ): Boolean {2893 beforeDurableCommit()2894 return withEpochCommit(expectedEpoch) {2895 if (!store.writeSlotA(nextA)) return@withEpochCommit false2896 2897 2898 2899 try {2900 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2901 2902 2903 2904 2905 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2906 store.stampLocalMutation()2907 } catch (_: Exception) {2908 rollbackRotation(priorA, priorB)2909 return@withEpochCommit false2910 }2911 true2912 } ?: false2913 }29142915 29162917291829192920 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2921 val failures = mutableListOf<String>()2922 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2923 .onFailure { failures += "writeSlotA: $it" }2924 if (priorB != null) {2925 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2926 .onFailure { failures += "writeSlotB: $it" }2927 }2928 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2929 }29302931 2932 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2933 try {2934 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2935 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2936 throw IllegalStateException("storage_write_failed")2937 }2938 } catch (e: Exception) {2939 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2940 throw e2941 }2942 2943 2944 2945 mutateIfCurrentEpoch(expectedEpoch) { publish() }2946 runCatching { LockedSyncSpoolHooks.refresh(this) }2947 }2948}