1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton252627private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)313233private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 535363738394041private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44 data object Success : UnlockResult45 data object WrongPassword : UnlockResult46 data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50 data object Success : CreateVaultResult51 data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55 data class Success(val vaultId: String) : AdoptVaultResult56 data class Error(val reason: String) : AdoptVaultResult57}585960616263646566676869707172@Singleton73class VaultRepository @Inject constructor(74 private val store: VaultStorage,75) {76 private val mutex = Mutex()77 78 79 80 81 82 83 84 private val stateLock = ReentrantLock()85 private val rng = SecureRandom()8687 8889909192 private var sessionEpoch: Long = 0L9394 private fun nextSessionEpoch(epoch: Long): Long =95 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9697 98 @VisibleForTesting99 internal var beforeDurableCommit: () -> Unit = {}100101 102 @VisibleForTesting103 internal var afterCodexRollback: () -> Unit = {}104105 106107108109110 private inline fun <T> mutateState(block: () -> T): T {111 stateLock.lock()112 return try { block() } finally { stateLock.unlock() }113 }114115 116 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {117 stateLock.lock()118 return try {119 if (sessionEpoch != epoch) null else block()120 } finally {121 stateLock.unlock()122 }123 }124125 126 @VisibleForTesting127 internal var beforeSessionEpochCommit: () -> Unit = {}128129 130131132133134 @VisibleForTesting135 internal var beforeSessionStateMutation: () -> Unit = {}136137 138139140141142143 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {144 beforeSessionEpochCommit()145 return withEpochCommit(epoch, block)146 }147148 private fun currentEpoch(): Long = mutateState { sessionEpoch }149150 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =151 mutateState { if (sessionEpoch == epoch) block() else null }152153 154 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {155 beforeSessionStateMutation()156 return mutateIfCurrentEpoch(epoch, block)157 }158159 private data class PersistSnapshot(160 val epoch: Long,161 val key: ByteArray,162 val payload: VaultPayload,163 val decoy: Boolean,164 )165166 167 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {168 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null169 val liveKey = derivedKey ?: return@mutateState null170 PersistSnapshot(171 epoch = sessionEpoch,172 key = liveKey.copyOf(),173 payload = currentPayloadLocked(),174 decoy = decoyMode,175 )176 }177178 private var derivedKey: ByteArray? = null179 private var rootKey: ByteArray? = null180 private var entryList: MutableList<VaultEntry> = mutableListOf()181 private var duressConfigured: Boolean = false182 private var recoveryWordHash: String? = null183 private var recoveryWordsSet: Boolean = false184 185 private var sessionDataAvailable: Boolean = false186 187 private var decoyMode: Boolean = false188189 private val _isUnlocked = MutableStateFlow(false)190 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()191192 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())193 194 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()195196 private val _settings = MutableStateFlow(VaultSettings())197 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()198199 200 private fun clearSessionStateLocked() {201 derivedKey?.let(VaultCrypto::zero)202 rootKey?.let(VaultCrypto::zero)203 derivedKey = null204 rootKey = null205 entryList = mutableListOf()206 duressConfigured = false207 recoveryWordHash = null208 recoveryWordsSet = false209 sessionDataAvailable = false210 decoyMode = false211 _settings.value = VaultSettings()212 _entries.value = emptyList()213 _isUnlocked.value = false214 }215216 fun hasVault(): Boolean = store.hasVault()217218 219220 221222223224225226227 fun codexVaultBlobBase64(): String? {228 val slotA = store.loadSlotA() ?: return null229 230 231 val slotD = store.loadDecoySlot() ?: return null232 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""233 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))234 }235236 237238239240241 242 private fun JsonElement?.asSlotJson(): String? = when {243 this == null || this is JsonNull -> null244 this is JsonPrimitive && isString -> content245 else -> toString()246 }247248 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {249 val expectedEpoch = currentEpoch()250 var candidateKey: ByteArray? = null251 var plaintext: ByteArray? = null252 return try {253 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)254 255 256 257 258 259 260 261 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject262 ?: return UnlockResult.Error("invalid_codex_blob")263 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")264 265 266 267 268 269 val slotA = VaultCodec.decodeSlot(slotJson)270 ?: BrowserSlot.parse(slotJson)?.let {271 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword272 }273 ?: return UnlockResult.Error("invalid_codex_blob")274 275 276 277 278 279 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))280 plaintext = decryptSlotABlob(slotA, candidateKey)281 282 283 284 285 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {286 VaultCrypto.zero(candidateKey)287 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))288 plaintext = decryptSlotABlob(slotA, candidateKey)289 }290 if (plaintext == null) return UnlockResult.WrongPassword291 val payload = try {292 VaultCodec.decode(plaintext)293 } catch (_: Exception) {294 return UnlockResult.Error("invalid_codex_blob")295 }296297 val priorA = store.loadSlotA()298 val priorB = store.loadSlotB()299 val priorD = store.loadDecoySlot()300 301 302 303 304 305 val codexDuressJson = wrapper["b"].asSlotJson()306 val codexDuress: StoredVault? = codexDuressJson?.let {307 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->308 309 310 311 BrowserSlot.toAndroidSlot(b, password)312 }313 }314315 beforeDurableCommit()316 val committed = runCatching {317 withEpochCommit(expectedEpoch) {318 try {319 320 321 322 323 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")324 325 326 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")327 328 329 val duressOk = if (codexDuress != null) {330 store.writeDecoySlot(codexDuress)331 } else {332 writeFakeDuressSlot(slotA)333 }334 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")335 store.stampLocalMutation()336 true337 } catch (_: Exception) {338 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))339 340 341 342 afterCodexRollback()343 false344 }345 } ?: false346 }.getOrDefault(false)347 if (!committed) {348 return UnlockResult.Error("storage_write_failed")349 }350351 val ownedKey = candidateKey!!352 candidateKey = null 353 adoptSlotA(ownedKey, payload, expectedEpoch)354 } catch (e: Exception) {355 356 357 358 359 360 361 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))362 UnlockResult.Error(e.message ?: "restore_failed")363 } finally {364 plaintext?.let(VaultCrypto::zero)365 candidateKey?.let(VaultCrypto::zero)366 }367 }368369 370 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =371 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()372373 374375376377378379380381382 private fun rollbackRestoreLocked(383 priorA: StoredVault?,384 priorB: String?,385 priorD: StoredVault? = null,386 ): List<String> {387 val failures = mutableListOf<String>()388 if (priorA == null) {389 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }390 } else {391 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }392 .onFailure { failures += "writeSlotA: $it" }393 if (priorB != null) {394 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }395 .onFailure { failures += "writeSlotB: $it" }396 }397 398 399 400 if (priorD != null) {401 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }402 .onFailure { failures += "writeDecoySlot: $it" }403 } else {404 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }405 }406 }407 return failures408 }409410 private fun reportRollbackFailures(failures: List<String>) {411 412 413 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }414 }415416 417 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {418 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)419 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()420 val window = offset?.let {421 val from = (it - 40).coerceAtLeast(0)422 val to = (it + 40).coerceAtMost(wrapperJson.length)423 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")424 }425 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()426 val slot = wrapper?.get("a")427 buildString {428 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)429 append(" | blobBase64=").append(vaultBlobBase64.length)430 append(" wrapperChars=").append(wrapperJson.length)431 append(" wrapperParses=").append(wrapper != null)432 append(" keys=").append(wrapper?.keys)433 append(" slotAKind=").append(434 when {435 slot == null -> "absent"436 slot is JsonPrimitive && slot.isString -> "string"437 slot is JsonObject -> "object:" + slot.keys438 else -> slot::class.simpleName439 },440 )441 if (window != null) append(" masked@").append(offset).append("=").append(window)442 }443 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }444445 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {446 val expectedEpoch = currentEpoch()447 var key: ByteArray? = null448 return try {449 val salt = VaultCrypto.generateSalt()450 key = VaultCrypto.deriveKey(password, salt)451 if (mutateIfCurrentEpoch(expectedEpoch) {452 entryList = mutableListOf()453 duressConfigured = false454 recoveryWordHash = null455 recoveryWordsSet = false456 decoyMode = false457 sessionDataAvailable = false458 rootKey = generateRecoverableRootKey()459 } == null) return CreateVaultResult.Error("stale_create")460 461 462 463 464 val slotA = sealSlotA(key!!, salt)465 466 467 beforeDurableCommit()468 val created = withEpochCommit(expectedEpoch) {469 if (!store.writeSlotA(slotA)) return@withEpochCommit false470 try {471 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")472 473 474 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")475 store.stampLocalMutation()476 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")477 true478 } catch (_: Exception) {479 480 481 store.clearVault()482 false483 }484 } ?: false485 if (!created) {486 lock()487 return CreateVaultResult.Error("storage_write_failed")488 }489 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")490 if (mutateIfCurrentEpoch(expectedEpoch) {491 derivedKey = sessionKey492 key = null493 publish()494 _isUnlocked.value = true495 sessionDataAvailable = true496 sessionEpoch = nextSessionEpoch(sessionEpoch)497 } == null) return CreateVaultResult.Error("stale_create")498 CreateVaultResult.Success499 } catch (e: Exception) {500 lock()501 CreateVaultResult.Error(e.message ?: "create_failed")502 } finally {503 key?.let(VaultCrypto::zero)504 }505 }506507 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {508 val expectedEpoch = currentEpoch()509 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")510 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")511 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {512 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")513 }514 var adoptedRootKey: ByteArray? = null515 var nextDerivedKey: ByteArray? = null516 var sessionRootKey: ByteArray? = null517 return try {518 adoptedRootKey = hexToRootKey(rootKeyHex)519 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {520 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")521 }522 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)523 val salt = VaultCrypto.generateSalt()524 nextDerivedKey = VaultCrypto.deriveKey(password, salt)525 sessionRootKey = adoptedRootKey.copyOf()526 val payload = VaultPayload(527 entries = emptyList(),528 duressConfigured = false,529 rootKeyB64 = b64(sessionRootKey),530 recoveryWordHash = null,531 recoveryWordsSet = false,532 )533 val plaintext = VaultCodec.encode(payload)534 val blob = try {535 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))536 } finally {537 VaultCrypto.zero(plaintext)538 }539 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)540 541 542 beforeDurableCommit()543 val created = withEpochCommit(expectedEpoch) {544 if (!store.writeSlotA(slotA)) return@withEpochCommit false545 try {546 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")547 548 549 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")550 store.stampLocalMutation()551 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")552 true553 } catch (_: Exception) {554 store.clearVault()555 false556 }557 } ?: false558 if (!created) return AdoptVaultResult.Error("storage_write_failed")559560 val adopted = mutateIfCurrentEpoch(expectedEpoch) {561 val previousDerivedKey = derivedKey562 val previousRootKey = rootKey563 derivedKey = nextDerivedKey564 rootKey = sessionRootKey565 nextDerivedKey = null566 sessionRootKey = null567 entryList = mutableListOf()568 duressConfigured = false569 recoveryWordHash = null570 recoveryWordsSet = false571 decoyMode = false572 sessionDataAvailable = true573 publish()574 _isUnlocked.value = true575 sessionEpoch = nextSessionEpoch(sessionEpoch)576 previousDerivedKey to previousRootKey577 }578 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")579 val (previousDerivedKey, previousRootKey) = adopted580 previousDerivedKey?.let(VaultCrypto::zero)581 previousRootKey?.let(VaultCrypto::zero)582 AdoptVaultResult.Success(vaultId)583 } catch (e: Exception) {584 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")585 } finally {586 adoptedRootKey?.let(VaultCrypto::zero)587 nextDerivedKey?.let(VaultCrypto::zero)588 sessionRootKey?.let(VaultCrypto::zero)589 }590 }591592 suspend fun unlock(password: String): UnlockResult = mutex.withLock {593 unlockLocked(password, currentEpoch())594 }595596 597 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {598 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")599 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))600 val payload = try {601 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)602 try {603 VaultCodec.decode(plaintext)604 } finally {605 VaultCrypto.zero(plaintext)606 }607 } catch (_: Exception) {608 VaultCrypto.zero(key)609 610 611 612 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {613 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)614 if (legacy != null) return legacy615 }616 return UnlockResult.WrongPassword617 }618 return adoptSlotA(key, payload, expectedEpoch)619 }620621 622623624625626627628629 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {630 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))631 var newKey: ByteArray? = null632 return try {633 val payload = try {634 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)635 try {636 VaultCodec.decode(plaintext)637 } finally {638 VaultCrypto.zero(plaintext)639 }640 } catch (_: Exception) {641 return null642 }643 644 645 val newSalt = VaultCrypto.generateSalt()646 newKey = VaultCrypto.deriveKey(password, newSalt)647 val plaintext = VaultCodec.encode(payload)648 val blob = try {649 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))650 } finally {651 VaultCrypto.zero(plaintext)652 }653 val next = StoredVault(654 version = slotA.version,655 salt = b64(newSalt),656 blob = blob,657 recoveryEscrow = slotA.recoveryEscrow,658 rootKeyEscrow = slotA.rootKeyEscrow,659 )660 661 662 663 val priorB = store.loadSlotB()664 beforeDurableCommit()665 val healed = withEpochCommit(expectedEpoch) {666 if (!store.writeSlotA(next)) {667 false668 } else try {669 if (!writeMatchingNoiseSlotB(next)) {670 runCatching { store.writeSlotA(slotA) }671 if (priorB != null) runCatching { store.writeSlotB(priorB) }672 false673 } else if (!writeCompanionDuressSlot(next)) {674 runCatching { store.writeSlotA(slotA) }675 if (priorB != null) runCatching { store.writeSlotB(priorB) }676 false677 } else {678 store.stampLocalMutation()679 true680 }681 } catch (_: Exception) {682 runCatching { store.writeSlotA(slotA) }683 if (priorB != null) runCatching { store.writeSlotB(priorB) }684 false685 }686 } ?: false687 if (healed) {688 VaultCrypto.zero(rawKey!!)689 rawKey = null690 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 691 newKey = null692 result693 } else {694 VaultCrypto.zero(newKey!!)695 newKey = null696 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 697 rawKey = null698 result699 }700 } finally {701 702 703 rawKey?.let(VaultCrypto::zero)704 newKey?.let(VaultCrypto::zero)705 }706 }707708 709 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {710 return try {711 712 713 714 715 716 val adopted = mutateIfCurrentEpoch(expectedEpoch) {717 derivedKey = key718 decoyMode = false719 entryList = payload.entries.toMutableList()720 duressConfigured = payload.duressConfigured721 recoveryWordHash = payload.recoveryWordHash722 recoveryWordsSet = payload.recoveryWordsSet723 sessionDataAvailable = true724 _settings.value = store.loadSettings()725 ensureRootKey(payload)726 autoPurgeExpiredTrash()727 publish()728 _isUnlocked.value = true729 730 731 732 sessionEpoch = nextSessionEpoch(sessionEpoch)733 }734 if (adopted == null) {735 VaultCrypto.zero(key)736 UnlockResult.Error("stale_unlock")737 } else {738 UnlockResult.Success739 }740 } catch (e: Exception) {741 742 743 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }744 VaultCrypto.zero(key)745 UnlockResult.Error(e.message ?: "unlock_failed")746 }747 }748749 750751 752753754755756757 class SlotAUnlock internal constructor(758 key: ByteArray,759 internal val payload: VaultPayload,760 internal val epoch: Long,761 ) {762 private var ownedKey: ByteArray? = key763 private var consumed = false764765 766 internal fun takeKey(): ByteArray? = synchronized(this) {767 if (consumed) return@synchronized null768 consumed = true769 ownedKey.also { ownedKey = null }770 }771772 773 internal fun discard() = synchronized(this) {774 if (consumed) return@synchronized775 consumed = true776 ownedKey?.let(VaultCrypto::zero)777 ownedKey = null778 }779 }780781 782783784785786787 fun tryUnlockSlotA(password: String): SlotAUnlock? {788 val epoch = currentEpoch()789 val slotA = store.loadSlotA() ?: return null790 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))791 return try {792 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)793 val payload = try {794 VaultCodec.decode(plaintext)795 } finally {796 VaultCrypto.zero(plaintext)797 }798 SlotAUnlock(key, payload, epoch)799 } catch (_: Exception) {800 VaultCrypto.zero(key)801 null802 }803 }804805 806 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {807 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")808 adoptSlotA(key, unlock.payload, unlock.epoch)809 }810811 812 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()813814 815816 817818819820821 class DuressUnlock internal constructor(822 key: ByteArray,823 internal val payload: VaultPayload,824 internal val epoch: Long,825 ) {826 private var ownedKey: ByteArray? = key827 private var consumed = false828829 internal fun takeKey(): ByteArray? = synchronized(this) {830 if (consumed) return@synchronized null831 consumed = true832 ownedKey.also { ownedKey = null }833 }834835 internal fun discard() = synchronized(this) {836 if (consumed) return@synchronized837 consumed = true838 ownedKey?.let(VaultCrypto::zero)839 ownedKey = null840 }841 }842843 844845846847848849850 fun tryUnlockDuress(password: String): DuressUnlock? {851 val epoch = currentEpoch()852 val slot = store.loadDecoySlot() ?: return null853 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))854 return try {855 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)856 val payload = try {857 VaultCodec.decode(plaintext)858 } finally {859 VaultCrypto.zero(plaintext)860 }861 DuressUnlock(key, payload, epoch)862 } catch (_: Exception) {863 VaultCrypto.zero(key)864 null865 }866 }867868 869 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {870 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")871 if (currentEpoch() != unlock.epoch) {872 VaultCrypto.zero(key)873 return@withLock UnlockResult.Error("stale_unlock")874 }875 return try {876 val adopted = mutateIfCurrentEpoch(unlock.epoch) {877 derivedKey = key878 decoyMode = true879 entryList = unlock.payload.entries.toMutableList()880 duressConfigured = false881 recoveryWordHash = unlock.payload.recoveryWordHash882 recoveryWordsSet = unlock.payload.recoveryWordsSet883 sessionDataAvailable = true884 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)885 else VaultCrypto.generateRootKey()886 _settings.value = store.loadSettings()887 publish()888 _isUnlocked.value = true889 sessionEpoch = nextSessionEpoch(sessionEpoch)890 }891 if (adopted == null) {892 VaultCrypto.zero(key)893 UnlockResult.Error("stale_unlock")894 } else {895 UnlockResult.Success896 }897 } catch (e: Exception) {898 899 900 901 902 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }903 VaultCrypto.zero(key)904 UnlockResult.Error(e.message ?: "decoy_unlock_failed")905 }906 }907908 909 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()910911 912913914915916917 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {918 919 val expectedEpoch = mutateState {920 if (derivedKey != null && !decoyMode) sessionEpoch else null921 } ?: return false922 923 924 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }925 var key: ByteArray? = null926 var basePlaintext: ByteArray? = null927 return try {928 val salt = VaultCrypto.generateSalt()929 key = VaultCrypto.deriveKey(duressPassphrase, salt)930 931 val decoyRoot = VaultCrypto.generateRootKey()932 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }933 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)934 basePlaintext = VaultCodec.encode(decoyPayload)935 936 937 938 939 940 941 942 943 val slotA = store.loadSlotA()944 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0945 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!946 val blob = try {947 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))948 } finally {949 VaultCrypto.zero(plaintext)950 951 952 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)953 basePlaintext = null954 key?.let(VaultCrypto::zero)955 key = null956 }957 beforeDurableCommit()958 val ok = withEpochCommit(expectedEpoch) {959 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))960 } ?: false961 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {962 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }963 } else if (ok) {964 return false965 }966 ok967 } catch (_: Exception) {968 false969 } finally {970 971 972 key?.let(VaultCrypto::zero)973 basePlaintext?.let(VaultCrypto::zero)974 }975 }976977 978 suspend fun clearDuress(): Boolean = mutex.withLock {979 980 981 982 983 val expectedEpoch = currentEpoch()984 val replaced = withEpochCommit(expectedEpoch) {985 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }986 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)987 } ?: false988 if (!replaced) return@withLock false989 990 991 if (!isSessionEpochCurrent(expectedEpoch)) return false992 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true993 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {994 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }995 }996 true997 }998999 1000 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1001 var current: Throwable? = error1002 while (current != null) {1003 if (current is javax.crypto.AEADBadTagException) return true1004 1005 1006 1007 1008 if (current is javax.crypto.BadPaddingException) return true1009 current = current.cause1010 }1011 return false1012 }10131014 1015 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1016 val expectedEpoch = currentEpoch()1017 val slotA = try {1018 store.loadSlotA()1019 } catch (_: Exception) {1020 return@withLock UnlockResult.Error("unlock_key_storage_failed")1021 } ?: return@withLock UnlockResult.Error("no_vault")1022 1023 1024 1025 1026 var stagedKey: ByteArray? = key.copyOf()1027 return try {1028 val encrypted = try {1029 unb64(slotA.blob)1030 } catch (_: Exception) {1031 return@withLock UnlockResult.Error("invalid_vault_blob")1032 }1033 1034 1035 1036 1037 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1038 return@withLock UnlockResult.Error("invalid_vault_blob")1039 }1040 val plaintext = try {1041 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1042 } catch (e: Exception) {1043 if (isGcmAuthenticationFailure(e)) {1044 return@withLock UnlockResult.WrongPassword1045 }1046 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1047 }1048 val payload = try {1049 VaultCodec.decode(plaintext)1050 } catch (_: Exception) {1051 return@withLock UnlockResult.Error("invalid_vault_payload")1052 } finally {1053 VaultCrypto.zero(plaintext)1054 }1055 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1056 val result = adoptSlotA(candidate, payload, expectedEpoch)1057 if (result is UnlockResult.Success) stagedKey = null1058 result1059 } finally {1060 stagedKey?.let(VaultCrypto::zero)1061 }1062 }10631064 fun lock() {1065 1066 1067 1068 1069 1070 1071 stateLock.lock()1072 try {1073 sessionEpoch = nextSessionEpoch(sessionEpoch)1074 clearSessionStateLocked()1075 } finally {1076 stateLock.unlock()1077 }1078 }10791080 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L10811082 1083 1084 @VisibleForTesting1085 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }10861087 108810891090109110921093109410951096109710981099 fun expectTransientBackground() {1100 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1101 }11021103 11041105110611071108 fun consumeTransientBackgroundSuppression(): Boolean {1109 val until = suppressNextBackgroundLockUntil1110 val active = until != 0L && nowMs() < until1111 suppressNextBackgroundLockUntil = 0L1112 return active1113 }11141115 11161117 11181119112011211122 11231124112511261127112811291130113111321133 1134 1135 1136 1137 1138 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11391140 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1141 if (decoyMode || !_isUnlocked.value) null1142 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1143 }11441145 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1146 mutateState { sessionEpoch == epoch }11471148 internal fun unlockedSessionEpoch(): Long? = mutateState {1149 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1150 }11511152 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key11531154 115511561157115811591160116111621163 fun sessionRootKey(): ByteArray? = mutateState {1164 if (!sessionDataAvailable) null else rootKey?.copyOf()1165 }11661167 11681169 fun syncVaultId(): String? = mutateState {1170 if (decoyMode || !_isUnlocked.value) null1171 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1172 }11731174 11751176117711781179 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1180 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1181 ?: return false1182 installRootKey(generateRecoverableRootKey(), expectedEpoch)1183 }11841185 11861187118811891190119111921193119411951196119711981199120012011202 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1203 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1204 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1205 }1206 val expectedEpoch = mutateState {1207 when {1208 derivedKey == null -> null1209 decoyMode -> Long.MIN_VALUE1210 else -> sessionEpoch1211 }1212 }1213 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1214 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1215 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12161217 val incoming = hexToRootKey(rootKeyHex)1218 if (incoming.size != VaultCrypto.KEY_SIZE) {1219 VaultCrypto.zero(incoming)1220 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1221 }1222 1223 1224 1225 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1226 if (!roundTrips) {1227 VaultCrypto.zero(incoming)1228 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1229 }12301231 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1232 return if (installRootKey(incoming, expectedEpoch)) {1233 AdoptVaultResult.Success(vaultId)1234 } else {1235 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1236 }1237 }12381239 12401241124212431244 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1245 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1246 val bytes = hexToRootKey(rootKeyHex)1247 return try {1248 val words = rootKeyToWords(bytes)1249 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1250 } catch (_: Exception) {1251 null1252 } finally {1253 VaultCrypto.zero(bytes)1254 }1255 }12561257 12581259126012611262 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1263 1264 1265 1266 1267 1268 1269 data class Snapshot(1270 val key: ByteArray,1271 val priorRootKey: ByteArray,1272 val priorRecoveryHash: String?,1273 val priorRecoverySet: Boolean,1274 )1275 val snap = mutateIfCurrentEpoch(expectedEpoch) {1276 val k = derivedKey ?: return@mutateIfCurrentEpoch null1277 if (decoyMode) return@mutateIfCurrentEpoch null1278 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1279 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1280 } ?: run {1281 VaultCrypto.zero(nextRootKey)1282 return false1283 }1284 val priorSlot = store.loadSlotA() ?: run {1285 VaultCrypto.zero(snap.key)1286 VaultCrypto.zero(snap.priorRootKey)1287 VaultCrypto.zero(nextRootKey)1288 return false1289 }1290 val nextWords = rootKeyToWords(nextRootKey)12911292 return try {1293 beforeDurableCommit()1294 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1295 if (mutateIfCurrentEpoch(expectedEpoch) {1296 rootKey = nextRootKey1297 recoveryWordHash = recoveryWordsHash(nextWords)1298 recoveryWordsSet = false1299 } == null) throw IllegalStateException("stale_root_rotation")13001301 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1302 ?: throw IllegalStateException("recovery_escrow_failed")1303 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1304 ?: throw IllegalStateException("root_escrow_failed")1305 val plaintext = VaultCodec.encode(currentPayload())1306 val blob = try {1307 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1308 } finally {1309 VaultCrypto.zero(plaintext)1310 }1311 val nextSlot = priorSlot.copy(1312 blob = blob,1313 recoveryEscrow = nextRecoveryEscrow,1314 rootKeyEscrow = nextRootKeyEscrow,1315 )1316 val committed = withEpochCommit(expectedEpoch) {1317 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1318 try {1319 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1320 1321 1322 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1323 store.stampLocalMutation()1324 } catch (_: Exception) {1325 store.writeSlotA(priorSlot)1326 return@withEpochCommit false1327 }1328 true1329 } ?: false1330 if (!committed) throw IllegalStateException("storage_write_failed")1331 mutateIfCurrentEpoch(expectedEpoch) { publish() }1332 true1333 } catch (_: Exception) {1334 val restored = mutateIfCurrentEpoch(expectedEpoch) {1335 rootKey?.let(VaultCrypto::zero)1336 rootKey = snap.priorRootKey.copyOf()1337 recoveryWordHash = snap.priorRecoveryHash1338 recoveryWordsSet = snap.priorRecoverySet1339 }1340 if (restored != null) {1341 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1342 }1343 false1344 } finally {1345 VaultCrypto.zero(snap.key)1346 VaultCrypto.zero(snap.priorRootKey)1347 1348 1349 1350 val stillOwnedBySession = mutateState {1351 sessionEpoch == expectedEpoch && rootKey === nextRootKey1352 }1353 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1354 }1355 }13561357 13581359136013611362 fun currentVaultKey(): ByteArray? = mutateState {1363 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1364 }13651366 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1367 val expectedEpoch = currentEpoch()1368 val slotA = store.loadSlotA() ?: return false1369 var oldKey: ByteArray? = null1370 var newKey: ByteArray? = null1371 var plaintext: ByteArray? = null1372 return try {1373 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1374 1375 1376 1377 1378 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1379 val payload = try {1380 VaultCodec.decode(decrypted)1381 } finally {1382 VaultCrypto.zero(decrypted)1383 }1384 val newSalt = VaultCrypto.generateSalt()1385 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1386 1387 plaintext = VaultCodec.encode(payload)1388 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1389 1390 1391 1392 1393 1394 1395 1396 val next = StoredVault(1397 version = slotA.version,1398 salt = b64(newSalt),1399 blob = blob,1400 recoveryEscrow = slotA.recoveryEscrow,1401 rootKeyEscrow = slotA.rootKeyEscrow,1402 )1403 1404 1405 1406 1407 val priorB = store.loadSlotB()1408 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1409 1410 1411 1412 if (mutateIfCurrentEpoch(expectedEpoch) {1413 oldKey?.let(VaultCrypto::zero); oldKey = null1414 derivedKey?.let(VaultCrypto::zero)1415 derivedKey = newKey; newKey = null1416 } == null) return false1417 true1418 } catch (_: Exception) {1419 false1420 } finally {1421 oldKey?.let(VaultCrypto::zero)1422 newKey?.let(VaultCrypto::zero)1423 plaintext?.let(VaultCrypto::zero)1424 }1425 }14261427 1428142914301431 suspend fun destroyVault() = mutex.withLock {1432 lock()1433 store.clearVault()1434 store.clearDecoySlot()1435 }14361437 14381439 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1440 val epoch = requireUnlocked()1441 val stamp = VaultCrypto.monotonicEntryTimestamp()1442 val now = System.currentTimeMillis()1443 val entry = withDerivedLast4(draft.copy(1444 id = draft.id.ifBlank { UUID.randomUUID().toString() },1445 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1446 modifiedAt = stamp,1447 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1448 ))1449 1450 1451 val rollback = mutateCurrentSession(epoch) {1452 val r = entryList.toMutableList()1453 entryList.add(entry)1454 r1455 } ?: throw IllegalStateException("vault_locked")1456 persistOrRollback(rollback, epoch)1457 entry1458 }14591460 146114621463146414651466146714681469 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1470 val epoch = requireUnlocked()1471 if (drafts.isEmpty()) return 01472 val now = System.currentTimeMillis()1473 val ready = drafts.map { draft ->1474 val stamp = VaultCrypto.monotonicEntryTimestamp()1475 withDerivedLast4(1476 draft.copy(1477 id = draft.id.ifBlank { UUID.randomUUID().toString() },1478 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1479 modifiedAt = stamp,1480 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1481 ),1482 )1483 }1484 1485 val rollback = mutateCurrentSession(epoch) {1486 val r = entryList.toMutableList()1487 entryList.addAll(ready)1488 r1489 } ?: throw IllegalStateException("vault_locked")1490 persistOrRollback(rollback, epoch)1491 return drafts.size1492 }14931494 1495149614971498149915001501150215031504150515061507 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1508 val epoch = requireUnlocked()1509 val now = System.currentTimeMillis()1510 val ready = newEntries.map { draft ->1511 val stamp = VaultCrypto.monotonicEntryTimestamp()1512 withDerivedLast4(1513 draft.copy(1514 id = draft.id.ifBlank { UUID.randomUUID().toString() },1515 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1516 modifiedAt = stamp,1517 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1518 ),1519 )1520 }1521 1522 1523 1524 1525 1526 1527 val incomingIds = ready.map { it.id }.toHashSet()1528 val rollback = mutateCurrentSession(epoch) {1529 val r = entryList.toMutableList()1530 val replaced = entryList.map { entry ->1531 if (entry.deletedAt != null) return@map entry1532 if (entry.id in incomingIds) return@map null1533 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1534 entry.copy(deletedAt = stamp, modifiedAt = stamp)1535 }.filterNotNull()1536 entryList = (replaced + ready).toMutableList()1537 r1538 } ?: throw IllegalStateException("vault_locked")1539 persistOrRollback(rollback, epoch)1540 return ready.size1541 }15421543 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1544 val epoch = requireUnlocked()1545 1546 1547 1548 1549 val prepared = mutateCurrentSession(epoch) {1550 val idx = entryList.indexOfFirst { it.id == patch.id }1551 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1552 val current = entryList[idx]1553 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1554 1555 val passwordChanged = patch.password != null && patch.password != current.password1556 1557 1558 1559 1560 1561 1562 val priorPassword = current.password1563 val shouldCapture = passwordChanged &&1564 !priorPassword.isNullOrEmpty() &&1565 current.passwordHistory?.firstOrNull()?.password != priorPassword1566 val nextHistory = if (shouldCapture) {1567 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1568 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1569 } else {1570 current.passwordHistory1571 }1572 val updated = withDerivedLast4(patch.copy(1573 createdAt = current.createdAt,1574 modifiedAt = stamp,1575 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1576 passwordHistory = nextHistory,1577 ))1578 val rollback = entryList.toMutableList()1579 entryList[idx] = updated1580 updated to rollback1581 } ?: throw IllegalStateException("vault_locked")1582 val (updated, rollback) = prepared1583 persistOrRollback(rollback, epoch)1584 updated1585 }15861587 1588 suspend fun deleteEntry(id: String) = mutex.withLock {1589 val epoch = requireUnlocked()1590 1591 1592 val rollback = mutateCurrentSession(epoch) {1593 val idx = entryList.indexOfFirst { it.id == id }1594 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1595 val current = entryList[idx]1596 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1597 val prior = entryList.toMutableList()1598 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1599 prior1600 } ?: throw IllegalStateException("vault_locked")1601 persistOrRollback(rollback, epoch)1602 }16031604 suspend fun restoreEntry(id: String) = mutex.withLock {1605 val epoch = requireUnlocked()1606 val rollback = mutateCurrentSession(epoch) {1607 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1608 if (idx == -1) throw IllegalStateException("deleted entry not found")1609 val current = entryList[idx]1610 val prior = entryList.toMutableList()1611 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1612 prior1613 } ?: throw IllegalStateException("vault_locked")1614 persistOrRollback(rollback, epoch)1615 }16161617 1618 suspend fun purgeEntry(id: String) = mutex.withLock {1619 val epoch = requireUnlocked()1620 val rollback = mutateCurrentSession(epoch) {1621 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1622 if (idx == -1) throw IllegalStateException("deleted entry not found")1623 val prior = entryList.toMutableList()1624 entryList.removeAt(idx)1625 prior1626 } ?: throw IllegalStateException("vault_locked")1627 persistOrRollback(rollback, epoch)1628 }16291630 1631 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1632 val epoch = requireUnlocked()1633 1634 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1635 val r = mutateCurrentSession(epoch) {1636 val rollback = entryList.toMutableList()1637 val before = entryList.size1638 entryList.removeAll { it.deletedAt != null }1639 R(rollback, before - entryList.size)1640 } ?: throw IllegalStateException("vault_locked")1641 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1642 r.removed1643 }16441645 1646 suspend fun countDuplicateEntries(): Int = mutex.withLock {1647 val epoch = requireUnlocked()1648 1649 1650 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1651 ?: throw IllegalStateException("vault_locked")1652 }16531654 16551656165716581659 suspend fun deduplicateEntries(): Int = mutex.withLock {1660 val epoch = requireUnlocked()1661 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1662 val ready = mutateCurrentSession(epoch) {1663 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1664 val active = entryList.filter { it.deletedAt == null }1665 val keepIds = mutableSetOf<String>()1666 active.groupBy { it.duplicateKey() }.values.forEach { group ->1667 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1668 }1669 val rollback = entryList.toMutableList()1670 var removed = 01671 entryList = entryList.map { entry ->1672 if (entry.deletedAt == null && entry.id !in keepIds) {1673 removed++1674 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1675 entry.copy(deletedAt = stamp, modifiedAt = stamp)1676 } else {1677 entry1678 }1679 }.toMutableList()1680 R(rollback, removed)1681 } ?: throw IllegalStateException("vault_locked")1682 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1683 ready.removed1684 }16851686 fun getEntries(): List<VaultEntry> = mutateState {1687 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1688 }1689 fun getEntry(id: String): VaultEntry? = mutateState {1690 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1691 }1692 fun getDeletedEntries(): List<VaultEntry> = mutateState {1693 if (!sessionDataAvailable) emptyList()1694 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1695 }16961697 1698 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1699 return mutateState {1700 if (!sessionDataAvailable) return@mutateState emptyList()1701 val active = entryList.filter { it.deletedAt == null }1702 if (tagFilter == null) return@mutateState active1703 val needle = tagFilter.trim().lowercase()1704 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1705 }1706 }17071708 1709 fun getDistinctTags(): List<String> = mutateState {1710 if (!sessionDataAvailable) return@mutateState emptyList()1711 entryList.asSequence()1712 .filter { it.deletedAt == null }1713 .flatMap { it.tags?.asSequence() ?: emptySequence() }1714 .map { it.trim().lowercase() }1715 .filter { it.isNotEmpty() }1716 .distinct()1717 .sorted()1718 .toList()1719 }17201721 172217231724172517261727 fun getDistinctFieldValues(field: String): List<String> {1728 val select: (VaultEntry) -> String? = when (field) {1729 "username" -> { e -> e.username }1730 "email" -> { e -> e.email }1731 else -> return emptyList()1732 }1733 return mutateState {1734 if (!sessionDataAvailable) return@mutateState emptyList()1735 entryList.asSequence()1736 .filter { it.deletedAt == null }1737 .mapNotNull { select(it)?.trim() }1738 .filter { it.isNotEmpty() }1739 .groupingBy { it.lowercase() }1740 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1741 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1742 }1743 .values1744 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1745 .map { it.first }1746 }1747 }17481749 fun getTombstones(): List<Tombstone> = mutateState {1750 if (!sessionDataAvailable) emptyList()1751 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1752 }1753 fun getVaultVersion(): Long = store.version()1754 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots17551756 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1757 val expectedEpoch = currentEpoch()1758 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1759 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1760 1761 1762 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1763 val priorA = store.loadSlotA()1764 val priorB = store.loadSlotB()1765 val priorD = store.loadDecoySlot()1766 1767 1768 1769 1770 1771 val priorHistory = store.loadVaultHistory()1772 1773 1774 1775 1776 beforeDurableCommit()1777 if (!takeVaultSnapshot("restore", expectedEpoch)) {1778 return false1779 }1780 1781 1782 1783 val nextD: StoredVault = restoredD ?: run {1784 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161785 val fakeBlob = ByteArray(target).also(rng::nextBytes)1786 val fakeSalt = VaultCrypto.generateSalt()1787 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1788 }1789 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1790 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1791 return false1792 }1793 lock()1794 true1795 }17961797 17981799180018011802180318041805180618071808 private fun commitRestoreAtomic(1809 nextA: StoredVault,1810 nextB: String,1811 nextD: StoredVault,1812 priorA: StoredVault?,1813 priorB: String?,1814 priorD: StoredVault?,1815 expectedEpoch: Long,1816 ): Boolean {1817 return withEpochCommit(expectedEpoch) {1818 if (!store.writeSlotA(nextA)) return@withEpochCommit false1819 1820 1821 1822 try {1823 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1824 1825 1826 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1827 store.stampLocalMutation()1828 } catch (_: Exception) {1829 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1830 return@withEpochCommit false1831 }1832 true1833 } ?: false1834 }18351836 18371838 18391840184118421843 suspend fun applyMergedRemoteEntries(1844 remoteEntries: List<VaultEntry>,1845 remoteTombstones: List<Tombstone>,1846 expectedEpoch: Long? = null,1847 ): Int = mutex.withLock {1848 val epoch = expectedEpoch ?: requireUnlocked()1849 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01850 1851 1852 1853 1854 1855 val mergeNow = System.currentTimeMillis()1856 val clamped = remoteEntries.map { entry ->1857 entry.copy(1858 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1859 deletedAt = entry.deletedAt?.let {1860 SyncLimits.clampSyncTimestamp(it, mergeNow)1861 },1862 )1863 }1864 val tombMap = remoteTombstones.associate {1865 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1866 }1867 1868 1869 val applied = mutateCurrentSession(epoch) {1870 var n = 01871 val byId = entryList.associateBy { it.id }.toMutableMap()18721873 for (remote in clamped) {1874 val tomb = tombMap[remote.id]1875 if (tomb != null && tomb >= remote.modifiedAt) continue1876 val local = byId[remote.id]1877 if (local == null) {1878 entryList.add(remote); byId[remote.id] = remote; n++1879 } else if (remote.modifiedAt > local.modifiedAt) {1880 val i = entryList.indexOfFirst { it.id == local.id }1881 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1882 }1883 }18841885 for ((id, deletedAt) in tombMap) {1886 val local = byId[id] ?: continue1887 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1888 if (deletedAt < local.modifiedAt) continue1889 val i = entryList.indexOfFirst { it.id == id }1890 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1891 }1892 n1893 } ?: return@withLock 018941895 if (applied > 0) {1896 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1897 mutateIfCurrentEpoch(epoch) { publish() }1898 }1899 applied1900 }19011902 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1903 1904 1905 1906 val epoch = requireUnlocked()1907 val prior = mutateCurrentSession(epoch) {1908 val old = _settings.value1909 _settings.value = settings1910 old1911 } ?: throw IllegalStateException("vault_locked")19121913 val committed = try {1914 beforeDurableCommit()1915 1916 1917 1918 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1919 } catch (_: Exception) {1920 false1921 }1922 if (!committed) {1923 1924 1925 1926 mutateIfCurrentEpoch(epoch) { _settings.value = prior }1927 throw IllegalStateException("settings_write_failed")1928 }1929 }19301931 19321933 1934 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19351936 19371938193919401941 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1942 val epoch = requireUnlocked()1943 1944 1945 1946 1947 1948 1949 val prior = mutateCurrentSession(epoch) {1950 val priorPair = recoveryWordHash to recoveryWordsSet1951 recoveryWordHash = recoveryWordsHash(words)1952 recoveryWordsSet = true1953 priorPair1954 } ?: return@withLock false1955 1956 1957 1958 1959 val persisted = runCatching {1960 persistSlotAWithEscrows(1961 buildRecoveryEscrow(words),1962 buildRootKeyEscrow(words),1963 expectedEpoch = epoch,1964 )1965 }.getOrDefault(false)1966 if (!persisted) {1967 mutateIfCurrentEpoch(epoch) {1968 recoveryWordHash = prior.first1969 recoveryWordsSet = prior.second1970 }1971 }1972 persisted1973 }19741975 1976 fun verifyRecoveryWords(words: List<String>): Boolean {1977 return mutateState {1978 val stored = recoveryWordHash ?: return@mutateState false1979 stored == recoveryWordsHash(words)1980 }1981 }19821983 19841985 1986 fun hasRecoveryEscrow(): Boolean =1987 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false19881989 199019911992199319941995199619971998199920002001 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2002 val expectedEpoch = currentEpoch()2003 val slotA = store.loadSlotA() ?: return false2004 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20052006 2007 2008 val fullEscrow = slotA.recoveryEscrow2009 if (fullEscrow != null) {2010 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2011 if (material != null) {2012 var keyBytes: ByteArray? = null2013 var plaintext: ByteArray? = null2014 var candidateRoot: ByteArray? = null2015 var stateCommitStarted = false2016 try {2017 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2018 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2019 val payload = VaultCodec.decode(plaintext)2020 2021 2022 2023 2024 val candidateSettings = store.loadSettings()2025 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2026 val decodedRoot = unb64(payload.rootKeyB64)2027 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2028 2029 2030 2031 VaultCrypto.zero(decodedRoot)2032 throw IllegalArgumentException("invalid recovery root key")2033 }2034 decodedRoot2035 } else {2036 generateRecoverableRootKey()2037 }2038 stateCommitStarted = true2039 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2040 clearSessionStateLocked()2041 entryList = payload.entries.toMutableList()2042 duressConfigured = payload.duressConfigured2043 recoveryWordHash = payload.recoveryWordHash2044 recoveryWordsSet = payload.recoveryWordsSet2045 rootKey = candidateRoot2046 candidateRoot = null2047 _settings.value = candidateSettings2048 publish()2049 2050 2051 sessionDataAvailable = true2052 _isUnlocked.value = false2053 true2054 }2055 if (adopted == true) return@withLock true2056 } catch (_: Exception) {2057 2058 2059 2060 2061 if (stateCommitStarted) {2062 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2063 }2064 } finally {2065 plaintext?.let(VaultCrypto::zero)2066 VaultCrypto.zero(material)2067 keyBytes?.let(VaultCrypto::zero)2068 candidateRoot?.let(VaultCrypto::zero)2069 }2070 }2071 }20722073 2074 2075 2076 val rootEscrow = slotA.rootKeyEscrow2077 if (rootEscrow != null) {2078 var candidateRoot: ByteArray? = null2079 var stateCommitStarted = false2080 try {2081 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2082 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2083 val candidateSettings = store.loadSettings()2084 stateCommitStarted = true2085 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2086 clearSessionStateLocked()2087 entryList = mutableListOf()2088 duressConfigured = false2089 recoveryWordHash = recoveryWordsHash(words)2090 recoveryWordsSet = true2091 rootKey = candidateRoot2092 candidateRoot = null2093 _settings.value = candidateSettings2094 publish()2095 sessionDataAvailable = true2096 _isUnlocked.value = false2097 true2098 }2099 if (adopted == true) return@withLock true2100 } catch (_: Exception) {2101 if (stateCommitStarted) {2102 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2103 }2104 } finally {2105 candidateRoot?.let(VaultCrypto::zero)2106 }2107 }2108 return false2109 }21102111 211221132114211521162117211821192120 suspend fun resetPasswordWithRecoveryWords(2121 words: List<String>,2122 newPassword: String,2123 ): Boolean = mutex.withLock {2124 val expectedEpoch = currentEpoch()2125 2126 2127 2128 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2129 val priorA = store.loadSlotA() ?: return false2130 var newKey: ByteArray? = null2131 var plaintext: ByteArray? = null2132 return try {2133 val newSalt = VaultCrypto.generateSalt()2134 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2135 plaintext = VaultCodec.encode(currentPayload())2136 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2137 2138 2139 2140 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2141 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2142 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2143 val next = StoredVault(2144 version = priorA.version,2145 salt = b64(newSalt),2146 blob = blob,2147 recoveryEscrow = freshRecovery,2148 rootKeyEscrow = freshRootKey,2149 )2150 2151 2152 2153 val priorB = store.loadSlotB()2154 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2155 2156 2157 2158 if (mutateIfCurrentEpoch(expectedEpoch) {2159 derivedKey?.let(VaultCrypto::zero)2160 derivedKey = newKey; newKey = null2161 publish()2162 _isUnlocked.value = true2163 } == null) return false2164 true2165 } catch (_: Exception) {2166 false2167 } finally {2168 newKey?.let(VaultCrypto::zero)2169 plaintext?.let(VaultCrypto::zero)2170 }2171 }21722173 217421752176217721782179 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2180 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2181 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2182 var keyBytes: ByteArray? = null2183 var plaintext: ByteArray? = null2184 return try {2185 val salt = VaultCrypto.generateSalt()2186 val recoveryKey = deriveRecoveryKey(material, salt)2187 keyBytes = recoveryKey.encoded2188 plaintext = VaultCodec.encode(currentPayload())2189 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2190 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2191 } finally {2192 VaultCrypto.zero(material)2193 keyBytes?.let(VaultCrypto::zero)2194 plaintext?.let(VaultCrypto::zero)2195 }2196 }21972198 21992200220122022203220422052206 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2207 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2208 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2209 val rk = mutateState { rootKey?.copyOf() } ?: run {2210 VaultCrypto.zero(material)2211 return null2212 }2213 var keyBytes: ByteArray? = null2214 var plaintext: ByteArray? = null2215 return try {2216 val salt = VaultCrypto.generateSalt()2217 val recoveryKey = deriveRecoveryKey(material, salt)2218 keyBytes = recoveryKey.encoded2219 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2220 2221 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2222 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2223 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2224 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2225 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2226 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2227 } finally {2228 VaultCrypto.zero(rk)2229 VaultCrypto.zero(material)2230 keyBytes?.let(VaultCrypto::zero)2231 plaintext?.let(VaultCrypto::zero)2232 }2233 }22342235 2236 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2237 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2238 var keyBytes: ByteArray? = null2239 var pt: ByteArray? = null2240 return try {2241 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2242 keyBytes = recoveryKey.encoded2243 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2244 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2245 hexToRootKey(String(pt, Charsets.US_ASCII))2246 } catch (_: Exception) {2247 null2248 } finally {2249 2250 2251 2252 VaultCrypto.zero(material)2253 keyBytes?.let(VaultCrypto::zero)2254 pt?.let(VaultCrypto::zero)2255 }2256 }22572258 225922602261226222632264 private fun persistSlotAWithEscrows(2265 recoveryEscrow: RecoveryEscrow?,2266 rootKeyEscrow: RootKeyEscrow?,2267 expectedEpoch: Long? = null,2268 ): Boolean {2269 val snapshot = snapshotForPersist(expectedEpoch)2270 ?: throw IllegalStateException("vault_locked_or_stale")2271 if (snapshot.decoy) {2272 VaultCrypto.zero(snapshot.key)2273 return false2274 }2275 try {2276 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2277 val plaintext = VaultCodec.encode(snapshot.payload)2278 val blob = try {2279 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2280 } finally {2281 2282 2283 VaultCrypto.zero(plaintext)2284 }2285 val next = prior.copy(2286 blob = blob,2287 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2288 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2289 )2290 beforeDurableCommit()2291 return withEpochCommit(snapshot.epoch) {2292 if (!store.writeSlotA(next)) return@withEpochCommit false2293 try {2294 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2295 2296 2297 2298 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2299 } catch (e: Exception) {2300 store.writeSlotA(prior)2301 throw e2302 }2303 true2304 } ?: false2305 } finally {2306 VaultCrypto.zero(snapshot.key)2307 }2308 }23092310 23112312 private fun requireUnlocked(): Long {2313 2314 2315 2316 stateLock.lock()2317 try {2318 if (derivedKey == null) throw IllegalStateException("vault_locked")2319 return sessionEpoch2320 } finally {2321 stateLock.unlock()2322 }2323 }23242325 2326 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23272328 private fun countDuplicateEntriesLocked(): Int {2329 val seen = mutableMapOf<String, VaultEntry>()2330 var duplicates = 02331 for (entry in entryList.filter { it.deletedAt == null }) {2332 val key = entry.duplicateKey()2333 val existing = seen[key]2334 if (existing == null) {2335 seen[key] = entry2336 } else {2337 duplicates++2338 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2339 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2340 if (entryStamp > existingStamp) seen[key] = entry2341 }2342 }2343 return duplicates2344 }23452346 private fun VaultEntry.duplicateKey(): String {2347 if (type == "note") return "note|${title.lowercase()}"2348 val passkeyData = passkey2349 if (passkeyData != null) {2350 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2351 }2352 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2353 }23542355 235623572358235923602361 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2362 val epoch = expectedEpoch ?: currentEpoch()2363 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2364 val slotA = store.loadSlotA() ?: return false2365 val vaultA = VaultCodec.encodeSlot(slotA)2366 val vaultB = store.loadSlotB() ?: slotA.blob2367 2368 2369 2370 2371 2372 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2373 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162374 val fakeBlob = ByteArray(target).also(rng::nextBytes)2375 val fakeSalt = VaultCrypto.generateSalt()2376 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2377 }2378 val history = store.loadVaultHistory()2379 val latest = history.snapshots.firstOrNull()2380 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2381 val entryCount = mutateIfCurrentEpoch(epoch) {2382 if (derivedKey != null && !decoyMode) {2383 bucketEntryCount(entryList.count { it.deletedAt == null })2384 } else {2385 null2386 }2387 } ?: return false2388 return try {2389 val snapshot = VaultSnapshot(2390 id = UUID.randomUUID().toString(),2391 savedAt = System.currentTimeMillis(),2392 reason = reason,2393 vault_a = vaultA,2394 vault_b = vaultB,2395 vault_d = vaultD,2396 entryCount = entryCount,2397 )2398 2399 2400 2401 withEpochCommit(epoch) {2402 store.writeVaultHistory(2403 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2404 )2405 } ?: false2406 } catch (_: Exception) {2407 false2408 }2409 }24102411 private fun bucketEntryCount(n: Int): Int = when {2412 n <= 0 -> 02413 n <= 10 -> 102414 n <= 25 -> 252415 n <= 50 -> 502416 n <= 100 -> 1002417 n <= 250 -> 2502418 n <= 500 -> 5002419 else -> ((n + 499) / 500) * 5002420 }24212422 242324242425242624272428242924302431 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2432 if (!entry.isCard()) return entry2433 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2434 val derived = if (digits.length >= 4) digits.takeLast(4) else null2435 return entry.copy(last4 = derived ?: entry.last4)2436 }24372438 243924402441244224432444 private fun autoPurgeExpiredTrash() {2445 val days = _settings.value.trashRetentionDays ?: return2446 if (days <= 0) return2447 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2448 val before = entryList.size2449 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2450 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2451 }24522453 2454 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }24552456 private fun currentPayloadLocked(): VaultPayload =2457 VaultPayload(2458 entries = entryList.toList(),2459 duressConfigured = duressConfigured,2460 rootKeyB64 = rootKey?.let(::b64) ?: "",2461 recoveryWordHash = recoveryWordHash,2462 recoveryWordsSet = recoveryWordsSet,2463 )24642465 24662467246824692470 private fun ensureRootKey(payload: VaultPayload) {2471 if (payload.rootKeyB64.isNotBlank()) {2472 rootKey = unb64(payload.rootKeyB64)2473 return2474 }2475 rootKey = generateRecoverableRootKey()2476 runCatching { persistSlotA(bumpVersion = false) }2477 }24782479 private fun publish() {2480 _entries.value = entryList.filter { it.deletedAt == null }2481 }24822483 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2484 var plaintext = VaultCodec.encode(currentPayload())2485 return try {2486 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2487 StoredVault(version = 1, salt = b64(salt), blob = blob)2488 } finally {2489 2490 2491 2492 VaultCrypto.zero(plaintext)2493 }2494 }24952496 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2497 2498 2499 2500 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2501 val noise = ByteArray(approxBytes).also(rng::nextBytes)2502 return store.writeSlotB(b64(noise))2503 }25042505 250625072508 private fun matchingCiphertextLength(slotA: StoredVault): Int =2509 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25102511 25122513251425152516 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2517 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162518 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2519 val fakeSalt = VaultCrypto.generateSalt()2520 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2521 }25222523 252425252526252725282529 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2530 val isConfigured = mutateState { duressConfigured }2531 return if (isConfigured) true else writeFakeDuressSlot(slotA)2532 }25332534 2535 private fun persistSlotA(2536 bumpVersion: Boolean,2537 snapshotReason: String? = "local_save",2538 expectedEpoch: Long? = null,2539 ): Boolean {2540 val snapshot = snapshotForPersist(expectedEpoch)2541 ?: throw IllegalStateException("vault_locked_or_stale")2542 return try {2543 2544 if (snapshot.decoy) {2545 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2546 val plaintext = VaultCodec.encode(snapshot.payload)2547 val blob = try {2548 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2549 } finally {2550 2551 2552 VaultCrypto.zero(plaintext)2553 }2554 beforeDurableCommit()2555 return withEpochCommit(snapshot.epoch) {2556 store.writeDecoySlot(prior.copy(blob = blob))2557 } ?: false2558 }2559 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2560 2561 2562 2563 2564 val priorB = store.loadSlotB()2565 beforeDurableCommit()2566 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2567 val plaintext = VaultCodec.encode(snapshot.payload)2568 val blob = try {2569 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2570 } finally {2571 2572 2573 VaultCrypto.zero(plaintext)2574 }2575 val next = prior.copy(blob = blob)2576 2577 2578 2579 2580 withEpochCommit(snapshot.epoch) {2581 if (!store.writeSlotA(next)) return@withEpochCommit false2582 try {2583 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2584 2585 2586 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2587 if (bumpVersion) store.stampLocalMutation()2588 } catch (e: Exception) {2589 store.writeSlotA(prior)2590 if (priorB != null) runCatching { store.writeSlotB(priorB) }2591 throw e2592 }2593 true2594 } ?: false2595 } finally {2596 2597 2598 VaultCrypto.zero(snapshot.key)2599 }2600 }26012602 2603260426052606260726082609 private fun commitVaultRotation(2610 priorA: StoredVault,2611 priorB: String?,2612 nextA: StoredVault,2613 expectedEpoch: Long,2614 ): Boolean {2615 beforeDurableCommit()2616 return withEpochCommit(expectedEpoch) {2617 if (!store.writeSlotA(nextA)) return@withEpochCommit false2618 2619 2620 2621 try {2622 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2623 2624 2625 2626 2627 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2628 store.stampLocalMutation()2629 } catch (_: Exception) {2630 rollbackRotation(priorA, priorB)2631 return@withEpochCommit false2632 }2633 true2634 } ?: false2635 }26362637 26382639264026412642 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2643 val failures = mutableListOf<String>()2644 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2645 .onFailure { failures += "writeSlotA: $it" }2646 if (priorB != null) {2647 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2648 .onFailure { failures += "writeSlotB: $it" }2649 }2650 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2651 }26522653 2654 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2655 try {2656 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2657 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2658 throw IllegalStateException("storage_write_failed")2659 }2660 } catch (e: Exception) {2661 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2662 throw e2663 }2664 2665 2666 2667 mutateIfCurrentEpoch(expectedEpoch) { publish() }2668 }2669}