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 986 val slotA = store.loadSlotA()987 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0988 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!989 val blob = try {990 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))991 } finally {992 VaultCrypto.zero(plaintext)993 994 995 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)996 basePlaintext = null997 key?.let(VaultCrypto::zero)998 key = null999 }1000 beforeDurableCommit()1001 val ok = withEpochCommit(expectedEpoch) {1002 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))1003 } ?: false1004 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1005 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1006 } else if (ok) {1007 return false1008 }1009 ok1010 } catch (_: Exception) {1011 false1012 } finally {1013 1014 1015 key?.let(VaultCrypto::zero)1016 basePlaintext?.let(VaultCrypto::zero)1017 }1018 }101910201021 102210231024102510261027102810291030 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1031 if (!duressConfigured) return null1032 val slot = store.loadDecoySlot() ?: return null1033 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1034 }10351036 1037103810391040104110421043 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1044 1045 1046 1047 1048 1049 var salt: ByteArray? = null1050 var blob: ByteArray? = null1051 var nonce: ByteArray? = null1052 var ctAndTag: ByteArray? = null1053 var ct: ByteArray? = null1054 var tag: ByteArray? = null1055 try {1056 salt = unb64(slot.salt)1057 blob = unb64(slot.blob)1058 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1059 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1060 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1061 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1062 val obj = JsonObject(mapOf(1063 "version" to JsonPrimitive(1),1064 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1065 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1066 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1067 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1068 ))1069 return obj.toString()1070 } finally {1071 salt?.let(VaultCrypto::zero)1072 blob?.let(VaultCrypto::zero)1073 nonce?.let(VaultCrypto::zero)1074 ctAndTag?.let(VaultCrypto::zero)1075 ct?.let(VaultCrypto::zero)1076 tag?.let(VaultCrypto::zero)1077 }1078 }10791080 1081108210831084108510861087108810891090 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1091 val b = BrowserSlot.parse(json) ?: return false1092 var stored: StoredVault? = null1093 var salt: ByteArray? = null1094 var nonce: ByteArray? = null1095 var ct: ByteArray? = null1096 var tag: ByteArray? = null1097 var blob: ByteArray? = null1098 try {1099 salt = unhex(b.salt)1100 nonce = unhex(b.nonce)1101 ct = unhex(b.ciphertext)1102 tag = unhex(b.tag)1103 blob = ByteArray(nonce.size + ct.size + tag.size).also {1104 System.arraycopy(nonce, 0, it, 0, nonce.size)1105 System.arraycopy(ct, 0, it, nonce.size, ct.size)1106 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1107 }1108 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1109 val expectedEpoch = sessionEpoch1110 beforeDurableCommit()1111 withEpochCommit(expectedEpoch) {1112 store.writeDecoySlot(stored)1113 } ?: false1114 } catch (_: Exception) {1115 false1116 } finally {1117 salt?.let(VaultCrypto::zero)1118 nonce?.let(VaultCrypto::zero)1119 ct?.let(VaultCrypto::zero)1120 tag?.let(VaultCrypto::zero)1121 blob?.let(VaultCrypto::zero)1122 }1123 }11241125 1126 suspend fun clearDuress(): Boolean = mutex.withLock {1127 1128 1129 1130 1131 val expectedEpoch = currentEpoch()1132 val replaced = withEpochCommit(expectedEpoch) {1133 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1134 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1135 } ?: false1136 if (!replaced) return@withLock false1137 1138 1139 if (!isSessionEpochCurrent(expectedEpoch)) return false1140 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1141 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1142 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1143 }1144 true1145 }11461147 1148 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1149 var current: Throwable? = error1150 while (current != null) {1151 if (current is javax.crypto.AEADBadTagException) return true1152 1153 1154 1155 1156 if (current is javax.crypto.BadPaddingException) return true1157 current = current.cause1158 }1159 return false1160 }11611162 1163 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1164 val expectedEpoch = currentEpoch()1165 val slotA = try {1166 store.loadSlotA()1167 } catch (_: Exception) {1168 return@withLock UnlockResult.Error("unlock_key_storage_failed")1169 } ?: return@withLock UnlockResult.Error("no_vault")1170 1171 1172 1173 1174 var stagedKey: ByteArray? = key.copyOf()1175 return try {1176 val encrypted = try {1177 unb64(slotA.blob)1178 } catch (_: Exception) {1179 return@withLock UnlockResult.Error("invalid_vault_blob")1180 }1181 1182 1183 1184 1185 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1186 return@withLock UnlockResult.Error("invalid_vault_blob")1187 }1188 val plaintext = try {1189 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1190 } catch (e: Exception) {1191 if (isGcmAuthenticationFailure(e)) {1192 return@withLock UnlockResult.WrongPassword1193 }1194 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1195 }1196 val payload = try {1197 VaultCodec.decode(plaintext)1198 } catch (_: Exception) {1199 return@withLock UnlockResult.Error("invalid_vault_payload")1200 } finally {1201 VaultCrypto.zero(plaintext)1202 }1203 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1204 val result = adoptSlotA(candidate, payload, expectedEpoch)1205 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1206 result1207 } finally {1208 stagedKey?.let(VaultCrypto::zero)1209 }1210 }12111212 fun lock() {1213 1214 1215 1216 1217 if (mutex.tryLock()) {1218 try {1219 runCatching { LockedSyncSpoolHooks.refresh(this) }1220 } finally {1221 mutex.unlock()1222 }1223 }1224 1225 1226 1227 1228 1229 1230 stateLock.lock()1231 try {1232 sessionEpoch = nextSessionEpoch(sessionEpoch)1233 clearSessionStateLocked()1234 _uiGateLocked.value = false1235 } finally {1236 stateLock.unlock()1237 }1238 }12391240 1241124212431244124512461247 fun lockUiGate() {1248 if (_isUnlocked.value) _uiGateLocked.value = true1249 }12501251 1252 fun clearUiGate() {1253 _uiGateLocked.value = false1254 }12551256 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12571258 1259 1260 @VisibleForTesting1261 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12621263 126412651266126712681269127012711272127312741275 fun expectTransientBackground() {1276 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1277 }12781279 12801281128212831284 fun consumeTransientBackgroundSuppression(): Boolean {1285 val until = suppressNextBackgroundLockUntil1286 val active = until != 0L && nowMs() < until1287 suppressNextBackgroundLockUntil = 0L1288 return active1289 }12901291 12921293 12941295129612971298 12991300130113021303130413051306130713081309 1310 1311 1312 1313 1314 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13151316 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1317 if (decoyMode || !_isUnlocked.value) null1318 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1319 }13201321 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1322 mutateState { sessionEpoch == epoch }13231324 internal fun unlockedSessionEpoch(): Long? = mutateState {1325 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1326 }13271328 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13291330 133113321333133413351336133713381339 fun sessionRootKey(): ByteArray? = mutateState {1340 if (!sessionDataAvailable) null else rootKey?.copyOf()1341 }13421343 13441345 fun syncVaultId(): String? = mutateState {1346 if (decoyMode || !_isUnlocked.value) null1347 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1348 }13491350 13511352135313541355 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1356 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1357 ?: return false1358 installRootKey(generateRecoverableRootKey(), expectedEpoch)1359 }13601361 13621363136413651366136713681369137013711372137313741375137613771378 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1379 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1380 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1381 }1382 val expectedEpoch = mutateState {1383 when {1384 derivedKey == null -> null1385 decoyMode -> Long.MIN_VALUE1386 else -> sessionEpoch1387 }1388 }1389 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1390 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1391 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13921393 val incoming = hexToRootKey(rootKeyHex)1394 if (incoming.size != VaultCrypto.KEY_SIZE) {1395 VaultCrypto.zero(incoming)1396 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1397 }1398 1399 1400 1401 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1402 if (!roundTrips) {1403 VaultCrypto.zero(incoming)1404 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1405 }14061407 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1408 return if (installRootKey(incoming, expectedEpoch)) {1409 AdoptVaultResult.Success(vaultId)1410 } else {1411 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1412 }1413 }14141415 14161417141814191420 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1421 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1422 val bytes = hexToRootKey(rootKeyHex)1423 return try {1424 val words = rootKeyToWords(bytes)1425 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1426 } catch (_: Exception) {1427 null1428 } finally {1429 VaultCrypto.zero(bytes)1430 }1431 }14321433 14341435143614371438 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1439 1440 1441 1442 1443 1444 1445 data class Snapshot(1446 val key: ByteArray,1447 val priorRootKey: ByteArray,1448 val priorRecoveryHash: String?,1449 val priorRecoverySet: Boolean,1450 )1451 val snap = mutateIfCurrentEpoch(expectedEpoch) {1452 val k = derivedKey ?: return@mutateIfCurrentEpoch null1453 if (decoyMode) return@mutateIfCurrentEpoch null1454 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1455 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1456 } ?: run {1457 VaultCrypto.zero(nextRootKey)1458 return false1459 }1460 val priorSlot = store.loadSlotA() ?: run {1461 VaultCrypto.zero(snap.key)1462 VaultCrypto.zero(snap.priorRootKey)1463 VaultCrypto.zero(nextRootKey)1464 return false1465 }1466 val nextWords = rootKeyToWords(nextRootKey)14671468 return try {1469 beforeDurableCommit()1470 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1471 if (mutateIfCurrentEpoch(expectedEpoch) {1472 rootKey = nextRootKey1473 recoveryWordHash = recoveryWordsHash(nextWords)1474 recoveryWordsSet = false1475 } == null) throw IllegalStateException("stale_root_rotation")14761477 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1478 ?: throw IllegalStateException("recovery_escrow_failed")1479 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1480 ?: throw IllegalStateException("root_escrow_failed")1481 val plaintext = VaultCodec.encode(currentPayload())1482 val blob = try {1483 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1484 } finally {1485 VaultCrypto.zero(plaintext)1486 }1487 val nextSlot = priorSlot.copy(1488 blob = blob,1489 recoveryEscrow = nextRecoveryEscrow,1490 rootKeyEscrow = nextRootKeyEscrow,1491 )1492 val committed = withEpochCommit(expectedEpoch) {1493 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1494 try {1495 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1496 1497 1498 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1499 store.stampLocalMutation()1500 } catch (_: Exception) {1501 store.writeSlotA(priorSlot)1502 return@withEpochCommit false1503 }1504 true1505 } ?: false1506 if (!committed) throw IllegalStateException("storage_write_failed")1507 mutateIfCurrentEpoch(expectedEpoch) { publish() }1508 true1509 } catch (_: Exception) {1510 val restored = mutateIfCurrentEpoch(expectedEpoch) {1511 rootKey?.let(VaultCrypto::zero)1512 rootKey = snap.priorRootKey.copyOf()1513 recoveryWordHash = snap.priorRecoveryHash1514 recoveryWordsSet = snap.priorRecoverySet1515 }1516 if (restored != null) {1517 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1518 }1519 false1520 } finally {1521 VaultCrypto.zero(snap.key)1522 VaultCrypto.zero(snap.priorRootKey)1523 1524 1525 1526 val stillOwnedBySession = mutateState {1527 sessionEpoch == expectedEpoch && rootKey === nextRootKey1528 }1529 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1530 }1531 }15321533 15341535153615371538 fun currentVaultKey(): ByteArray? = mutateState {1539 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1540 }15411542 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1543 val expectedEpoch = currentEpoch()1544 val slotA = store.loadSlotA() ?: return false1545 var oldKey: ByteArray? = null1546 var newKey: ByteArray? = null1547 var plaintext: ByteArray? = null1548 return try {1549 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1550 1551 1552 1553 1554 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1555 val payload = try {1556 VaultCodec.decode(decrypted)1557 } finally {1558 VaultCrypto.zero(decrypted)1559 }1560 val newSalt = VaultCrypto.generateSalt()1561 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1562 1563 plaintext = VaultCodec.encode(payload)1564 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1565 1566 1567 1568 1569 1570 1571 1572 val next = StoredVault(1573 version = slotA.version,1574 salt = b64(newSalt),1575 blob = blob,1576 recoveryEscrow = slotA.recoveryEscrow,1577 rootKeyEscrow = slotA.rootKeyEscrow,1578 )1579 1580 1581 1582 1583 val priorB = store.loadSlotB()1584 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1585 1586 1587 1588 if (mutateIfCurrentEpoch(expectedEpoch) {1589 oldKey?.let(VaultCrypto::zero); oldKey = null1590 derivedKey?.let(VaultCrypto::zero)1591 derivedKey = newKey; newKey = null1592 } == null) return false1593 true1594 } catch (_: Exception) {1595 false1596 } finally {1597 oldKey?.let(VaultCrypto::zero)1598 newKey?.let(VaultCrypto::zero)1599 plaintext?.let(VaultCrypto::zero)1600 }1601 }16021603 1604160516061607 suspend fun destroyVault() = mutex.withLock {1608 lock()1609 store.clearVault()1610 store.clearDecoySlot()1611 }16121613 16141615 1616161716181619162016211622162316241625162616271628 private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")16291630 private fun String?.normalizedEntryId(): String =1631 if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()16321633 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1634 val epoch = requireUnlocked()1635 val stamp = VaultCrypto.monotonicEntryTimestamp()1636 val now = System.currentTimeMillis()1637 val entry = withDerivedLast4(draft.copy(1638 id = draft.id.normalizedEntryId(),1639 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1640 modifiedAt = stamp,1641 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1642 ))1643 1644 1645 val rollback = mutateCurrentSession(epoch) {1646 val r = entryList.toMutableList()1647 entryList.add(entry)1648 r1649 } ?: throw IllegalStateException("vault_locked")1650 persistOrRollback(rollback, epoch)1651 entry1652 }16531654 165516561657165816591660166116621663 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1664 val epoch = requireUnlocked()1665 if (drafts.isEmpty()) return 01666 val now = System.currentTimeMillis()1667 val ready = drafts.map { draft ->1668 val stamp = VaultCrypto.monotonicEntryTimestamp()1669 withDerivedLast4(1670 draft.copy(1671 id = draft.id.normalizedEntryId(),1672 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1673 modifiedAt = stamp,1674 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1675 ),1676 )1677 }1678 1679 val rollback = mutateCurrentSession(epoch) {1680 val r = entryList.toMutableList()1681 entryList.addAll(ready)1682 r1683 } ?: throw IllegalStateException("vault_locked")1684 persistOrRollback(rollback, epoch)1685 return drafts.size1686 }16871688 1689169016911692169316941695169616971698169917001701 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1702 val epoch = requireUnlocked()1703 val now = System.currentTimeMillis()1704 val ready = newEntries.map { draft ->1705 val stamp = VaultCrypto.monotonicEntryTimestamp()1706 withDerivedLast4(1707 draft.copy(1708 id = draft.id.normalizedEntryId(),1709 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1710 modifiedAt = stamp,1711 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1712 ),1713 )1714 }1715 1716 1717 1718 1719 1720 1721 val incomingIds = ready.map { it.id }.toHashSet()1722 val rollback = mutateCurrentSession(epoch) {1723 val r = entryList.toMutableList()1724 val replaced = entryList.map { entry ->1725 if (entry.deletedAt != null) return@map entry1726 if (entry.id in incomingIds) return@map null1727 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1728 entry.copy(deletedAt = stamp, modifiedAt = stamp)1729 }.filterNotNull()1730 entryList = (replaced + ready).toMutableList()1731 r1732 } ?: throw IllegalStateException("vault_locked")1733 persistOrRollback(rollback, epoch)1734 return ready.size1735 }17361737 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1738 val epoch = requireUnlocked()1739 1740 1741 1742 1743 val prepared = mutateCurrentSession(epoch) {1744 val idx = entryList.indexOfFirst { it.id == patch.id }1745 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1746 val current = entryList[idx]1747 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1748 1749 val passwordChanged = patch.password != null && patch.password != current.password1750 1751 1752 1753 1754 1755 1756 val priorPassword = current.password1757 val shouldCapture = passwordChanged &&1758 !priorPassword.isNullOrEmpty() &&1759 current.passwordHistory?.firstOrNull()?.password != priorPassword1760 val nextHistory = if (shouldCapture) {1761 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1762 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1763 } else {1764 current.passwordHistory1765 }1766 val updated = withDerivedLast4(patch.copy(1767 createdAt = current.createdAt,1768 modifiedAt = stamp,1769 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1770 passwordHistory = nextHistory,1771 ))1772 val rollback = entryList.toMutableList()1773 entryList[idx] = updated1774 updated to rollback1775 } ?: throw IllegalStateException("vault_locked")1776 val (updated, rollback) = prepared1777 persistOrRollback(rollback, epoch)1778 updated1779 }17801781 1782 suspend fun deleteEntry(id: String) = mutex.withLock {1783 val epoch = requireUnlocked()1784 1785 1786 val rollback = mutateCurrentSession(epoch) {1787 val idx = entryList.indexOfFirst { it.id == id }1788 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1789 val current = entryList[idx]1790 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1791 val prior = entryList.toMutableList()1792 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1793 prior1794 } ?: throw IllegalStateException("vault_locked")1795 persistOrRollback(rollback, epoch)1796 }17971798 suspend fun restoreEntry(id: String) = mutex.withLock {1799 val epoch = requireUnlocked()1800 val rollback = mutateCurrentSession(epoch) {1801 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1802 if (idx == -1) throw IllegalStateException("deleted entry not found")1803 val current = entryList[idx]1804 val prior = entryList.toMutableList()1805 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1806 prior1807 } ?: throw IllegalStateException("vault_locked")1808 persistOrRollback(rollback, epoch)1809 }18101811 1812 suspend fun purgeEntry(id: String) = mutex.withLock {1813 val epoch = requireUnlocked()1814 val rollback = mutateCurrentSession(epoch) {1815 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1816 if (idx == -1) throw IllegalStateException("deleted entry not found")1817 val prior = entryList.toMutableList()1818 entryList.removeAt(idx)1819 prior1820 } ?: throw IllegalStateException("vault_locked")1821 persistOrRollback(rollback, epoch)1822 }18231824 1825 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1826 val epoch = requireUnlocked()1827 1828 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1829 val r = mutateCurrentSession(epoch) {1830 val rollback = entryList.toMutableList()1831 val before = entryList.size1832 entryList.removeAll { it.deletedAt != null }1833 R(rollback, before - entryList.size)1834 } ?: throw IllegalStateException("vault_locked")1835 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1836 r.removed1837 }18381839 1840 suspend fun countDuplicateEntries(): Int = mutex.withLock {1841 val epoch = requireUnlocked()1842 1843 1844 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1845 ?: throw IllegalStateException("vault_locked")1846 }18471848 18491850185118521853 suspend fun deduplicateEntries(): Int = mutex.withLock {1854 val epoch = requireUnlocked()1855 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1856 val ready = mutateCurrentSession(epoch) {1857 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1858 val active = entryList.filter { it.deletedAt == null }1859 val keepIds = mutableSetOf<String>()1860 active.groupBy { it.duplicateKey() }.values.forEach { group ->1861 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1862 }1863 val rollback = entryList.toMutableList()1864 var removed = 01865 entryList = entryList.map { entry ->1866 if (entry.deletedAt == null && entry.id !in keepIds) {1867 removed++1868 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1869 entry.copy(deletedAt = stamp, modifiedAt = stamp)1870 } else {1871 entry1872 }1873 }.toMutableList()1874 R(rollback, removed)1875 } ?: throw IllegalStateException("vault_locked")1876 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1877 ready.removed1878 }18791880 fun getEntries(): List<VaultEntry> = mutateState {1881 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1882 }1883 fun getEntry(id: String): VaultEntry? = mutateState {1884 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1885 }1886 fun getDeletedEntries(): List<VaultEntry> = mutateState {1887 if (!sessionDataAvailable) emptyList()1888 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1889 }18901891 1892 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1893 return mutateState {1894 if (!sessionDataAvailable) return@mutateState emptyList()1895 val active = entryList.filter { it.deletedAt == null }1896 if (tagFilter == null) return@mutateState active1897 val needle = tagFilter.trim().lowercase()1898 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1899 }1900 }19011902 1903 fun getDistinctTags(): List<String> = mutateState {1904 if (!sessionDataAvailable) return@mutateState emptyList()1905 entryList.asSequence()1906 .filter { it.deletedAt == null }1907 .flatMap { it.tags?.asSequence() ?: emptySequence() }1908 .map { it.trim().lowercase() }1909 .filter { it.isNotEmpty() }1910 .distinct()1911 .sorted()1912 .toList()1913 }19141915 191619171918191919201921 fun getDistinctFieldValues(field: String): List<String> {1922 val select: (VaultEntry) -> String? = when (field) {1923 "username" -> { e -> e.username }1924 "email" -> { e -> e.email }1925 else -> return emptyList()1926 }1927 return mutateState {1928 if (!sessionDataAvailable) return@mutateState emptyList()1929 entryList.asSequence()1930 .filter { it.deletedAt == null }1931 .mapNotNull { select(it)?.trim() }1932 .filter { it.isNotEmpty() }1933 .groupingBy { it.lowercase() }1934 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1935 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1936 }1937 .values1938 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1939 .map { it.first }1940 }1941 }19421943 fun getTombstones(): List<Tombstone> = mutateState {1944 if (!sessionDataAvailable) emptyList()1945 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1946 }1947 fun getVaultVersion(): Long = store.version()1948 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19491950 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1951 val expectedEpoch = currentEpoch()1952 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1953 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1954 1955 1956 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1957 val priorA = store.loadSlotA()1958 val priorB = store.loadSlotB()1959 val priorD = store.loadDecoySlot()1960 1961 1962 1963 1964 1965 val priorHistory = store.loadVaultHistory()1966 1967 1968 1969 1970 beforeDurableCommit()1971 if (!takeVaultSnapshot("restore", expectedEpoch)) {1972 return false1973 }1974 1975 1976 1977 val nextD: StoredVault = restoredD ?: run {1978 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161979 val fakeBlob = ByteArray(target).also(rng::nextBytes)1980 val fakeSalt = VaultCrypto.generateSalt()1981 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1982 }1983 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1984 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1985 return false1986 }1987 lock()1988 true1989 }19901991 19921993199419951996199719981999200020012002 private fun commitRestoreAtomic(2003 nextA: StoredVault,2004 nextB: String,2005 nextD: StoredVault,2006 priorA: StoredVault?,2007 priorB: String?,2008 priorD: StoredVault?,2009 expectedEpoch: Long,2010 ): Boolean {2011 return withEpochCommit(expectedEpoch) {2012 if (!store.writeSlotA(nextA)) return@withEpochCommit false2013 2014 2015 2016 try {2017 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")2018 2019 2020 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2021 store.stampLocalMutation()2022 } catch (_: Exception) {2023 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2024 return@withEpochCommit false2025 }2026 true2027 } ?: false2028 }20292030 20312032 20332034203520362037 suspend fun applyMergedRemoteEntries(2038 remoteEntries: List<VaultEntry>,2039 remoteTombstones: List<Tombstone>,2040 expectedEpoch: Long? = null,2041 ): Int = mutex.withLock {2042 val epoch = expectedEpoch ?: requireUnlocked()2043 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02044 2045 2046 2047 2048 2049 val mergeNow = System.currentTimeMillis()2050 val clamped = remoteEntries.mapNotNull { entry ->2051 2052 2053 2054 SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2055 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2056 deletedAt = entry.deletedAt?.let {2057 SyncLimits.clampSyncTimestamp(it, mergeNow)2058 },2059 )2060 }2061 val tombMap = remoteTombstones.associate {2062 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2063 }2064 2065 2066 val applied = mutateCurrentSession(epoch) {2067 var n = 02068 val byId = entryList.associateBy { it.id }.toMutableMap()20692070 for (remote in clamped) {2071 val tomb = tombMap[remote.id]2072 if (tomb != null && tomb >= remote.modifiedAt) continue2073 val local = byId[remote.id]2074 if (local == null) {2075 entryList.add(remote); byId[remote.id] = remote; n++2076 } else if (remote.modifiedAt > local.modifiedAt) {2077 val i = entryList.indexOfFirst { it.id == local.id }2078 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2079 }2080 }20812082 for ((id, deletedAt) in tombMap) {2083 val local = byId[id] ?: continue2084 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2085 if (deletedAt < local.modifiedAt) continue2086 val i = entryList.indexOfFirst { it.id == id }2087 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2088 }2089 n2090 } ?: return@withLock 020912092 if (applied > 0) {2093 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2094 mutateIfCurrentEpoch(epoch) { publish() }2095 runCatching { LockedSyncSpoolHooks.refresh(this) }2096 }2097 applied2098 }20992100 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2101 2102 2103 2104 val epoch = requireUnlocked()2105 val prior = mutateCurrentSession(epoch) {2106 val old = _settings.value2107 _settings.value = settings2108 old2109 } ?: throw IllegalStateException("vault_locked")21102111 val committed = try {2112 beforeDurableCommit()2113 2114 2115 2116 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2117 } catch (_: Exception) {2118 false2119 }2120 if (!committed) {2121 2122 2123 2124 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2125 throw IllegalStateException("settings_write_failed")2126 }2127 }21282129 21302131 2132 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21332134 21352136213721382139 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2140 val epoch = requireUnlocked()2141 2142 2143 2144 2145 2146 2147 val prior = mutateCurrentSession(epoch) {2148 val priorPair = recoveryWordHash to recoveryWordsSet2149 recoveryWordHash = recoveryWordsHash(words)2150 recoveryWordsSet = true2151 priorPair2152 } ?: return@withLock false2153 2154 2155 2156 2157 val persisted = runCatching {2158 persistSlotAWithEscrows(2159 buildRecoveryEscrow(words),2160 buildRootKeyEscrow(words),2161 expectedEpoch = epoch,2162 )2163 }.getOrDefault(false)2164 if (!persisted) {2165 mutateIfCurrentEpoch(epoch) {2166 recoveryWordHash = prior.first2167 recoveryWordsSet = prior.second2168 }2169 }2170 persisted2171 }21722173 2174 fun verifyRecoveryWords(words: List<String>): Boolean {2175 return mutateState {2176 val stored = recoveryWordHash ?: return@mutateState false2177 stored == recoveryWordsHash(words)2178 }2179 }21802181 21822183 2184 fun hasRecoveryEscrow(): Boolean =2185 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21862187 21882189219021912192 private fun ensureRecoveryEscrow() {2193 if (hasRecoveryEscrow()) return2194 val rk = mutateState { rootKey?.copyOf() } ?: return2195 runCatching {2196 val words = rootKeyToWords(rk)2197 val escrow = buildRecoveryEscrow(words)2198 val rkEscrow = buildRootKeyEscrow(words)2199 if (escrow != null || rkEscrow != null) {2200 val prior = store.loadSlotA() ?: return@runCatching2201 store.writeSlotA(prior.copy(2202 recoveryEscrow = escrow ?: prior.recoveryEscrow,2203 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2204 ))2205 mutateState {2206 recoveryWordHash = recoveryWordsHash(words)2207 recoveryWordsSet = true2208 }2209 }2210 }.also { VaultCrypto.zero(rk) }2211 }22122213 221422152216221722182219222022212222222322242225 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2226 val expectedEpoch = currentEpoch()2227 val slotA = store.loadSlotA() ?: return false2228 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22292230 2231 2232 val fullEscrow = slotA.recoveryEscrow2233 if (fullEscrow != null) {2234 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2235 if (material != null) {2236 var keyBytes: ByteArray? = null2237 var plaintext: ByteArray? = null2238 var candidateRoot: ByteArray? = null2239 var stateCommitStarted = false2240 try {2241 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2242 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2243 val payload = VaultCodec.decode(plaintext)2244 2245 2246 2247 2248 val candidateSettings = store.loadSettings()2249 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2250 val decodedRoot = unb64(payload.rootKeyB64)2251 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2252 2253 2254 2255 VaultCrypto.zero(decodedRoot)2256 throw IllegalArgumentException("invalid recovery root key")2257 }2258 decodedRoot2259 } else {2260 generateRecoverableRootKey()2261 }2262 stateCommitStarted = true2263 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2264 clearSessionStateLocked()2265 entryList = payload.entries.toMutableList()2266 duressConfigured = payload.duressConfigured2267 recoveryWordHash = payload.recoveryWordHash2268 recoveryWordsSet = payload.recoveryWordsSet2269 rootKey = candidateRoot2270 candidateRoot = null2271 _settings.value = candidateSettings2272 publish()2273 2274 2275 sessionDataAvailable = true2276 _isUnlocked.value = false2277 true2278 }2279 if (adopted == true) return@withLock true2280 } catch (_: Exception) {2281 2282 2283 2284 2285 if (stateCommitStarted) {2286 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2287 }2288 } finally {2289 plaintext?.let(VaultCrypto::zero)2290 VaultCrypto.zero(material)2291 keyBytes?.let(VaultCrypto::zero)2292 candidateRoot?.let(VaultCrypto::zero)2293 }2294 }2295 }22962297 2298 2299 2300 val rootEscrow = slotA.rootKeyEscrow2301 if (rootEscrow != null) {2302 var candidateRoot: ByteArray? = null2303 var stateCommitStarted = false2304 try {2305 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2306 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2307 val candidateSettings = store.loadSettings()2308 stateCommitStarted = true2309 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2310 clearSessionStateLocked()2311 entryList = mutableListOf()2312 duressConfigured = false2313 recoveryWordHash = recoveryWordsHash(words)2314 recoveryWordsSet = true2315 rootKey = candidateRoot2316 candidateRoot = null2317 _settings.value = candidateSettings2318 publish()2319 sessionDataAvailable = true2320 _isUnlocked.value = false2321 true2322 }2323 if (adopted == true) return@withLock true2324 } catch (_: Exception) {2325 if (stateCommitStarted) {2326 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2327 }2328 } finally {2329 candidateRoot?.let(VaultCrypto::zero)2330 }2331 }2332 return false2333 }23342335 233623372338233923402341234223432344 suspend fun resetPasswordWithRecoveryWords(2345 words: List<String>,2346 newPassword: String,2347 ): Boolean = mutex.withLock {2348 val expectedEpoch = currentEpoch()2349 2350 2351 2352 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2353 val priorA = store.loadSlotA() ?: return false2354 var newKey: ByteArray? = null2355 var plaintext: ByteArray? = null2356 return try {2357 val newSalt = VaultCrypto.generateSalt()2358 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2359 plaintext = VaultCodec.encode(currentPayload())2360 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2361 2362 2363 2364 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2365 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2366 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2367 val next = StoredVault(2368 version = priorA.version,2369 salt = b64(newSalt),2370 blob = blob,2371 recoveryEscrow = freshRecovery,2372 rootKeyEscrow = freshRootKey,2373 )2374 2375 2376 2377 val priorB = store.loadSlotB()2378 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2379 2380 2381 2382 if (mutateIfCurrentEpoch(expectedEpoch) {2383 derivedKey?.let(VaultCrypto::zero)2384 derivedKey = newKey; newKey = null2385 publish()2386 _isUnlocked.value = true2387 } == null) return false2388 true2389 } catch (_: Exception) {2390 false2391 } finally {2392 newKey?.let(VaultCrypto::zero)2393 plaintext?.let(VaultCrypto::zero)2394 }2395 }23962397 239823992400240124022403 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2404 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2405 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2406 var keyBytes: ByteArray? = null2407 var plaintext: ByteArray? = null2408 return try {2409 val salt = VaultCrypto.generateSalt()2410 val recoveryKey = deriveRecoveryKey(material, salt)2411 keyBytes = recoveryKey.encoded2412 plaintext = VaultCodec.encode(currentPayload())2413 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2414 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2415 } finally {2416 VaultCrypto.zero(material)2417 keyBytes?.let(VaultCrypto::zero)2418 plaintext?.let(VaultCrypto::zero)2419 }2420 }24212422 24232424242524262427242824292430 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2431 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2432 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2433 val rk = mutateState { rootKey?.copyOf() } ?: run {2434 VaultCrypto.zero(material)2435 return null2436 }2437 var keyBytes: ByteArray? = null2438 var plaintext: ByteArray? = null2439 return try {2440 val salt = VaultCrypto.generateSalt()2441 val recoveryKey = deriveRecoveryKey(material, salt)2442 keyBytes = recoveryKey.encoded2443 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2444 2445 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2446 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2447 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2448 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2449 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2450 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2451 } finally {2452 VaultCrypto.zero(rk)2453 VaultCrypto.zero(material)2454 keyBytes?.let(VaultCrypto::zero)2455 plaintext?.let(VaultCrypto::zero)2456 }2457 }24582459 2460 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2461 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2462 var keyBytes: ByteArray? = null2463 var pt: ByteArray? = null2464 return try {2465 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2466 keyBytes = recoveryKey.encoded2467 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2468 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2469 hexToRootKey(String(pt, Charsets.US_ASCII))2470 } catch (_: Exception) {2471 null2472 } finally {2473 2474 2475 2476 VaultCrypto.zero(material)2477 keyBytes?.let(VaultCrypto::zero)2478 pt?.let(VaultCrypto::zero)2479 }2480 }24812482 248324842485248624872488 private fun persistSlotAWithEscrows(2489 recoveryEscrow: RecoveryEscrow?,2490 rootKeyEscrow: RootKeyEscrow?,2491 expectedEpoch: Long? = null,2492 ): Boolean {2493 val snapshot = snapshotForPersist(expectedEpoch)2494 ?: throw IllegalStateException("vault_locked_or_stale")2495 if (snapshot.decoy) {2496 VaultCrypto.zero(snapshot.key)2497 return false2498 }2499 try {2500 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2501 val plaintext = VaultCodec.encode(snapshot.payload)2502 val blob = try {2503 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2504 } finally {2505 2506 2507 VaultCrypto.zero(plaintext)2508 }2509 val next = prior.copy(2510 blob = blob,2511 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2512 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2513 )2514 beforeDurableCommit()2515 return withEpochCommit(snapshot.epoch) {2516 if (!store.writeSlotA(next)) return@withEpochCommit false2517 try {2518 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2519 2520 2521 2522 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2523 } catch (e: Exception) {2524 store.writeSlotA(prior)2525 throw e2526 }2527 true2528 } ?: false2529 } finally {2530 VaultCrypto.zero(snapshot.key)2531 }2532 }25332534 25352536 private fun requireUnlocked(): Long {2537 2538 2539 2540 stateLock.lock()2541 try {2542 if (derivedKey == null) throw IllegalStateException("vault_locked")2543 return sessionEpoch2544 } finally {2545 stateLock.unlock()2546 }2547 }25482549 2550 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25512552 private fun countDuplicateEntriesLocked(): Int {2553 val seen = mutableMapOf<String, VaultEntry>()2554 var duplicates = 02555 for (entry in entryList.filter { it.deletedAt == null }) {2556 val key = entry.duplicateKey()2557 val existing = seen[key]2558 if (existing == null) {2559 seen[key] = entry2560 } else {2561 duplicates++2562 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2563 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2564 if (entryStamp > existingStamp) seen[key] = entry2565 }2566 }2567 return duplicates2568 }25692570 private fun VaultEntry.duplicateKey(): String {2571 if (type == "note") return "note|${title.lowercase()}"2572 val passkeyData = passkey2573 if (passkeyData != null) {2574 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2575 }2576 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2577 }25782579 258025812582258325842585 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2586 val epoch = expectedEpoch ?: currentEpoch()2587 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2588 val slotA = store.loadSlotA() ?: return false2589 val vaultA = VaultCodec.encodeSlot(slotA)2590 val vaultB = store.loadSlotB() ?: slotA.blob2591 2592 2593 2594 2595 2596 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2597 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162598 val fakeBlob = ByteArray(target).also(rng::nextBytes)2599 val fakeSalt = VaultCrypto.generateSalt()2600 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2601 }2602 val history = store.loadVaultHistory()2603 val latest = history.snapshots.firstOrNull()2604 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2605 val entryCount = mutateIfCurrentEpoch(epoch) {2606 if (derivedKey != null && !decoyMode) {2607 bucketEntryCount(entryList.count { it.deletedAt == null })2608 } else {2609 null2610 }2611 } ?: return false2612 return try {2613 val snapshot = VaultSnapshot(2614 id = UUID.randomUUID().toString(),2615 savedAt = System.currentTimeMillis(),2616 reason = reason,2617 vault_a = vaultA,2618 vault_b = vaultB,2619 vault_d = vaultD,2620 entryCount = entryCount,2621 )2622 2623 2624 2625 withEpochCommit(epoch) {2626 store.writeVaultHistory(2627 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2628 )2629 } ?: false2630 } catch (_: Exception) {2631 false2632 }2633 }26342635 private fun bucketEntryCount(n: Int): Int = when {2636 n <= 0 -> 02637 n <= 10 -> 102638 n <= 25 -> 252639 n <= 50 -> 502640 n <= 100 -> 1002641 n <= 250 -> 2502642 n <= 500 -> 5002643 else -> ((n + 499) / 500) * 5002644 }26452646 264726482649265026512652265326542655 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2656 if (!entry.isCard()) return entry2657 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2658 val derived = if (digits.length >= 4) digits.takeLast(4) else null2659 return entry.copy(last4 = derived ?: entry.last4)2660 }26612662 266326642665266626672668 private fun autoPurgeExpiredTrash() {2669 val days = _settings.value.trashRetentionDays ?: return2670 if (days <= 0) return2671 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2672 val before = entryList.size2673 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2674 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2675 }26762677 267826792680268126822683 private fun consolidatePasskeyEntries() {2684 val passkeyOnly = entryList.filter { entry ->2685 entry.deletedAt == null &&2686 entry.isLogin() &&2687 entry.password.isNullOrBlank() &&2688 !entry.rpId.isNullOrBlank() &&2689 !entry.credentialId.isNullOrBlank()2690 }2691 if (passkeyOnly.isEmpty()) return2692 var changed = false2693 val now = System.currentTimeMillis()2694 for (pkEntry in passkeyOnly) {2695 val rpId = pkEntry.rpId ?: continue2696 val target = entryList.firstOrNull { candidate ->2697 candidate.deletedAt == null &&2698 candidate.id != pkEntry.id &&2699 candidate.isLogin() &&2700 candidate.passkey == null &&2701 candidate.credentialId.isNullOrBlank() &&2702 !candidate.password.isNullOrBlank() &&2703 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2704 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2705 } ?: continue2706 val idx = entryList.indexOfFirst { it.id == target.id }2707 if (idx < 0) continue2708 entryList[idx] = target.copy(2709 rpId = pkEntry.rpId,2710 rpName = pkEntry.rpName ?: target.rpName,2711 credentialId = pkEntry.credentialId,2712 publicKeyCose = pkEntry.publicKeyCose,2713 privateKeyJwk = pkEntry.privateKeyJwk,2714 userHandle = pkEntry.userHandle,2715 userName = pkEntry.userName,2716 userDisplayName = pkEntry.userDisplayName,2717 algorithm = pkEntry.algorithm,2718 signCount = pkEntry.signCount,2719 lastUsedAt = pkEntry.lastUsedAt,2720 modifiedAt = now,2721 )2722 2723 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2724 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2725 changed = true2726 }2727 if (changed) runCatching { persistSlotA(bumpVersion = false) }2728 }27292730 2731 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27322733 private fun currentPayloadLocked(): VaultPayload =2734 VaultPayload(2735 entries = entryList.toList(),2736 duressConfigured = duressConfigured,2737 rootKeyB64 = rootKey?.let(::b64) ?: "",2738 recoveryWordHash = recoveryWordHash,2739 recoveryWordsSet = recoveryWordsSet,2740 )27412742 27432744274527462747 private fun ensureRootKey(payload: VaultPayload) {2748 if (payload.rootKeyB64.isNotBlank()) {2749 rootKey = unb64(payload.rootKeyB64)2750 return2751 }2752 rootKey = generateRecoverableRootKey()2753 runCatching { persistSlotA(bumpVersion = false) }2754 }27552756 private fun publish() {2757 _entries.value = entryList.filter { it.deletedAt == null }2758 }27592760 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2761 var plaintext = VaultCodec.encode(currentPayload())2762 return try {2763 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2764 StoredVault(version = 1, salt = b64(salt), blob = blob)2765 } finally {2766 2767 2768 2769 VaultCrypto.zero(plaintext)2770 }2771 }27722773 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2774 2775 2776 2777 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2778 val noise = ByteArray(approxBytes).also(rng::nextBytes)2779 return store.writeSlotB(b64(noise))2780 }27812782 278327842785 private fun matchingCiphertextLength(slotA: StoredVault): Int =2786 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27872788 27892790279127922793 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2794 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162795 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2796 val fakeSalt = VaultCrypto.generateSalt()2797 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2798 }27992800 280128022803280428052806 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2807 val isConfigured = mutateState { duressConfigured }2808 return if (isConfigured) true else writeFakeDuressSlot(slotA)2809 }28102811 2812281328142815281628172818 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2819 val salt = unhex(b.salt)2820 val nonce = unhex(b.nonce)2821 val ct = unhex(b.ciphertext)2822 val tag = unhex(b.tag)2823 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2824 System.arraycopy(nonce, 0, it, 0, nonce.size)2825 System.arraycopy(ct, 0, it, nonce.size, ct.size)2826 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2827 }2828 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2829 }28302831 2832 private fun persistSlotA(2833 bumpVersion: Boolean,2834 snapshotReason: String? = "local_save",2835 expectedEpoch: Long? = null,2836 ): Boolean {2837 val snapshot = snapshotForPersist(expectedEpoch)2838 ?: throw IllegalStateException("vault_locked_or_stale")2839 return try {2840 2841 if (snapshot.decoy) {2842 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2843 val plaintext = VaultCodec.encode(snapshot.payload)2844 val blob = try {2845 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2846 } finally {2847 2848 2849 VaultCrypto.zero(plaintext)2850 }2851 beforeDurableCommit()2852 return withEpochCommit(snapshot.epoch) {2853 store.writeDecoySlot(prior.copy(blob = blob))2854 } ?: false2855 }2856 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2857 2858 2859 2860 2861 val priorB = store.loadSlotB()2862 beforeDurableCommit()2863 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2864 val plaintext = VaultCodec.encode(snapshot.payload)2865 val blob = try {2866 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2867 } finally {2868 2869 2870 VaultCrypto.zero(plaintext)2871 }2872 val next = prior.copy(blob = blob)2873 2874 2875 2876 2877 withEpochCommit(snapshot.epoch) {2878 if (!store.writeSlotA(next)) return@withEpochCommit false2879 try {2880 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2881 2882 2883 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2884 if (bumpVersion) store.stampLocalMutation()2885 } catch (e: Exception) {2886 store.writeSlotA(prior)2887 if (priorB != null) runCatching { store.writeSlotB(priorB) }2888 throw e2889 }2890 true2891 } ?: false2892 } finally {2893 2894 2895 VaultCrypto.zero(snapshot.key)2896 }2897 }28982899 2900290129022903290429052906 private fun commitVaultRotation(2907 priorA: StoredVault,2908 priorB: String?,2909 nextA: StoredVault,2910 expectedEpoch: Long,2911 ): Boolean {2912 beforeDurableCommit()2913 return withEpochCommit(expectedEpoch) {2914 if (!store.writeSlotA(nextA)) return@withEpochCommit false2915 2916 2917 2918 try {2919 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2920 2921 2922 2923 2924 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2925 store.stampLocalMutation()2926 } catch (_: Exception) {2927 rollbackRotation(priorA, priorB)2928 return@withEpochCommit false2929 }2930 true2931 } ?: false2932 }29332934 29352936293729382939 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2940 val failures = mutableListOf<String>()2941 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2942 .onFailure { failures += "writeSlotA: $it" }2943 if (priorB != null) {2944 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2945 .onFailure { failures += "writeSlotB: $it" }2946 }2947 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2948 }29492950 2951 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2952 try {2953 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2954 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2955 throw IllegalStateException("storage_write_failed")2956 }2957 } catch (e: Exception) {2958 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2959 throw e2960 }2961 2962 2963 2964 mutateIfCurrentEpoch(expectedEpoch) { publish() }2965 runCatching { LockedSyncSpoolHooks.refresh(this) }2966 }2967}