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 312 313 314 browserDuressPassthrough(b)315 }316 }317318 beforeDurableCommit()319 val committed = runCatching {320 withEpochCommit(expectedEpoch) {321 try {322 323 324 325 326 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")327 328 329 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")330 331 332 val duressOk = if (codexDuress != null) {333 store.writeDecoySlot(codexDuress)334 } else {335 writeFakeDuressSlot(slotA)336 }337 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")338 store.stampLocalMutation()339 true340 } catch (_: Exception) {341 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))342 343 344 345 afterCodexRollback()346 false347 }348 } ?: false349 }.getOrDefault(false)350 if (!committed) {351 return UnlockResult.Error("storage_write_failed")352 }353354 val ownedKey = candidateKey!!355 candidateKey = null 356 adoptSlotA(ownedKey, payload, expectedEpoch)357 } catch (e: Exception) {358 359 360 361 362 363 364 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))365 UnlockResult.Error(e.message ?: "restore_failed")366 } finally {367 plaintext?.let(VaultCrypto::zero)368 candidateKey?.let(VaultCrypto::zero)369 }370 }371372 373 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =374 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()375376 377378379380381382383384385 private fun rollbackRestoreLocked(386 priorA: StoredVault?,387 priorB: String?,388 priorD: StoredVault? = null,389 ): List<String> {390 val failures = mutableListOf<String>()391 if (priorA == null) {392 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }393 } else {394 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }395 .onFailure { failures += "writeSlotA: $it" }396 if (priorB != null) {397 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }398 .onFailure { failures += "writeSlotB: $it" }399 }400 401 402 403 if (priorD != null) {404 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }405 .onFailure { failures += "writeDecoySlot: $it" }406 } else {407 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }408 }409 }410 return failures411 }412413 private fun reportRollbackFailures(failures: List<String>) {414 415 416 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }417 }418419 420 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {421 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)422 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()423 val window = offset?.let {424 val from = (it - 40).coerceAtLeast(0)425 val to = (it + 40).coerceAtMost(wrapperJson.length)426 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")427 }428 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()429 val slot = wrapper?.get("a")430 buildString {431 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)432 append(" | blobBase64=").append(vaultBlobBase64.length)433 append(" wrapperChars=").append(wrapperJson.length)434 append(" wrapperParses=").append(wrapper != null)435 append(" keys=").append(wrapper?.keys)436 append(" slotAKind=").append(437 when {438 slot == null -> "absent"439 slot is JsonPrimitive && slot.isString -> "string"440 slot is JsonObject -> "object:" + slot.keys441 else -> slot::class.simpleName442 },443 )444 if (window != null) append(" masked@").append(offset).append("=").append(window)445 }446 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }447448 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {449 val expectedEpoch = currentEpoch()450 var key: ByteArray? = null451 return try {452 val salt = VaultCrypto.generateSalt()453 key = VaultCrypto.deriveKey(password, salt)454 if (mutateIfCurrentEpoch(expectedEpoch) {455 entryList = mutableListOf()456 duressConfigured = false457 recoveryWordHash = null458 recoveryWordsSet = false459 decoyMode = false460 sessionDataAvailable = false461 rootKey = generateRecoverableRootKey()462 } == null) return CreateVaultResult.Error("stale_create")463 464 465 466 467 val slotA = sealSlotA(key!!, salt)468 469 470 beforeDurableCommit()471 val created = withEpochCommit(expectedEpoch) {472 if (!store.writeSlotA(slotA)) return@withEpochCommit false473 try {474 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")475 476 477 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")478 store.stampLocalMutation()479 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")480 true481 } catch (_: Exception) {482 483 484 store.clearVault()485 false486 }487 } ?: false488 if (!created) {489 lock()490 return CreateVaultResult.Error("storage_write_failed")491 }492 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")493 if (mutateIfCurrentEpoch(expectedEpoch) {494 derivedKey = sessionKey495 key = null496 publish()497 _isUnlocked.value = true498 sessionDataAvailable = true499 sessionEpoch = nextSessionEpoch(sessionEpoch)500 } == null) return CreateVaultResult.Error("stale_create")501 CreateVaultResult.Success502 } catch (e: Exception) {503 lock()504 CreateVaultResult.Error(e.message ?: "create_failed")505 } finally {506 key?.let(VaultCrypto::zero)507 }508 }509510 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {511 val expectedEpoch = currentEpoch()512 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")513 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")514 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {515 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")516 }517 var adoptedRootKey: ByteArray? = null518 var nextDerivedKey: ByteArray? = null519 var sessionRootKey: ByteArray? = null520 return try {521 adoptedRootKey = hexToRootKey(rootKeyHex)522 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {523 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")524 }525 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)526 val salt = VaultCrypto.generateSalt()527 nextDerivedKey = VaultCrypto.deriveKey(password, salt)528 sessionRootKey = adoptedRootKey.copyOf()529 val payload = VaultPayload(530 entries = emptyList(),531 duressConfigured = false,532 rootKeyB64 = b64(sessionRootKey),533 recoveryWordHash = null,534 recoveryWordsSet = false,535 )536 val plaintext = VaultCodec.encode(payload)537 val blob = try {538 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))539 } finally {540 VaultCrypto.zero(plaintext)541 }542 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)543 544 545 beforeDurableCommit()546 val created = withEpochCommit(expectedEpoch) {547 if (!store.writeSlotA(slotA)) return@withEpochCommit false548 try {549 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")550 551 552 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")553 store.stampLocalMutation()554 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")555 true556 } catch (_: Exception) {557 store.clearVault()558 false559 }560 } ?: false561 if (!created) return AdoptVaultResult.Error("storage_write_failed")562563 val adopted = mutateIfCurrentEpoch(expectedEpoch) {564 val previousDerivedKey = derivedKey565 val previousRootKey = rootKey566 derivedKey = nextDerivedKey567 rootKey = sessionRootKey568 nextDerivedKey = null569 sessionRootKey = null570 entryList = mutableListOf()571 duressConfigured = false572 recoveryWordHash = null573 recoveryWordsSet = false574 decoyMode = false575 sessionDataAvailable = true576 publish()577 _isUnlocked.value = true578 sessionEpoch = nextSessionEpoch(sessionEpoch)579 previousDerivedKey to previousRootKey580 }581 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")582 val (previousDerivedKey, previousRootKey) = adopted583 previousDerivedKey?.let(VaultCrypto::zero)584 previousRootKey?.let(VaultCrypto::zero)585 AdoptVaultResult.Success(vaultId)586 } catch (e: Exception) {587 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")588 } finally {589 adoptedRootKey?.let(VaultCrypto::zero)590 nextDerivedKey?.let(VaultCrypto::zero)591 sessionRootKey?.let(VaultCrypto::zero)592 }593 }594595 suspend fun unlock(password: String): UnlockResult = mutex.withLock {596 unlockLocked(password, currentEpoch())597 }598599 600 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {601 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")602 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))603 val payload = try {604 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)605 try {606 VaultCodec.decode(plaintext)607 } finally {608 VaultCrypto.zero(plaintext)609 }610 } catch (_: Exception) {611 VaultCrypto.zero(key)612 613 614 615 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {616 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)617 if (legacy != null) return legacy618 }619 return UnlockResult.WrongPassword620 }621 return adoptSlotA(key, payload, expectedEpoch)622 }623624 625626627628629630631632 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {633 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))634 var newKey: ByteArray? = null635 return try {636 val payload = try {637 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)638 try {639 VaultCodec.decode(plaintext)640 } finally {641 VaultCrypto.zero(plaintext)642 }643 } catch (_: Exception) {644 return null645 }646 647 648 val newSalt = VaultCrypto.generateSalt()649 newKey = VaultCrypto.deriveKey(password, newSalt)650 val plaintext = VaultCodec.encode(payload)651 val blob = try {652 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))653 } finally {654 VaultCrypto.zero(plaintext)655 }656 val next = StoredVault(657 version = slotA.version,658 salt = b64(newSalt),659 blob = blob,660 recoveryEscrow = slotA.recoveryEscrow,661 rootKeyEscrow = slotA.rootKeyEscrow,662 )663 664 665 666 val priorB = store.loadSlotB()667 beforeDurableCommit()668 val healed = withEpochCommit(expectedEpoch) {669 if (!store.writeSlotA(next)) {670 false671 } else try {672 if (!writeMatchingNoiseSlotB(next)) {673 runCatching { store.writeSlotA(slotA) }674 if (priorB != null) runCatching { store.writeSlotB(priorB) }675 false676 } else if (!writeCompanionDuressSlot(next)) {677 runCatching { store.writeSlotA(slotA) }678 if (priorB != null) runCatching { store.writeSlotB(priorB) }679 false680 } else {681 store.stampLocalMutation()682 true683 }684 } catch (_: Exception) {685 runCatching { store.writeSlotA(slotA) }686 if (priorB != null) runCatching { store.writeSlotB(priorB) }687 false688 }689 } ?: false690 if (healed) {691 VaultCrypto.zero(rawKey!!)692 rawKey = null693 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 694 newKey = null695 result696 } else {697 VaultCrypto.zero(newKey!!)698 newKey = null699 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 700 rawKey = null701 result702 }703 } finally {704 705 706 rawKey?.let(VaultCrypto::zero)707 newKey?.let(VaultCrypto::zero)708 }709 }710711 712 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {713 return try {714 715 716 717 718 719 val adopted = mutateIfCurrentEpoch(expectedEpoch) {720 derivedKey = key721 decoyMode = false722 entryList = payload.entries.toMutableList()723 duressConfigured = payload.duressConfigured724 recoveryWordHash = payload.recoveryWordHash725 recoveryWordsSet = payload.recoveryWordsSet726 sessionDataAvailable = true727 _settings.value = store.loadSettings()728 ensureRootKey(payload)729 autoPurgeExpiredTrash()730 publish()731 _isUnlocked.value = true732 733 734 735 sessionEpoch = nextSessionEpoch(sessionEpoch)736 }737 if (adopted == null) {738 VaultCrypto.zero(key)739 UnlockResult.Error("stale_unlock")740 } else {741 UnlockResult.Success742 }743 } catch (e: Exception) {744 745 746 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }747 VaultCrypto.zero(key)748 UnlockResult.Error(e.message ?: "unlock_failed")749 }750 }751752 753754 755756757758759760 class SlotAUnlock internal constructor(761 key: ByteArray,762 internal val payload: VaultPayload,763 internal val epoch: Long,764 ) {765 private var ownedKey: ByteArray? = key766 private var consumed = false767768 769 internal fun takeKey(): ByteArray? = synchronized(this) {770 if (consumed) return@synchronized null771 consumed = true772 ownedKey.also { ownedKey = null }773 }774775 776 internal fun discard() = synchronized(this) {777 if (consumed) return@synchronized778 consumed = true779 ownedKey?.let(VaultCrypto::zero)780 ownedKey = null781 }782 }783784 785786787788789790 fun tryUnlockSlotA(password: String): SlotAUnlock? {791 val epoch = currentEpoch()792 val slotA = store.loadSlotA() ?: return null793 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))794 return try {795 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)796 val payload = try {797 VaultCodec.decode(plaintext)798 } finally {799 VaultCrypto.zero(plaintext)800 }801 SlotAUnlock(key, payload, epoch)802 } catch (_: Exception) {803 VaultCrypto.zero(key)804 null805 }806 }807808 809 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {810 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")811 adoptSlotA(key, unlock.payload, unlock.epoch)812 }813814 815 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()816817 818819 820821822823824 class DuressUnlock internal constructor(825 key: ByteArray,826 internal val payload: VaultPayload,827 internal val epoch: Long,828 ) {829 private var ownedKey: ByteArray? = key830 private var consumed = false831832 internal fun takeKey(): ByteArray? = synchronized(this) {833 if (consumed) return@synchronized null834 consumed = true835 ownedKey.also { ownedKey = null }836 }837838 internal fun discard() = synchronized(this) {839 if (consumed) return@synchronized840 consumed = true841 ownedKey?.let(VaultCrypto::zero)842 ownedKey = null843 }844 }845846 847848849850851852853 fun tryUnlockDuress(password: String): DuressUnlock? {854 val epoch = currentEpoch()855 val slot = store.loadDecoySlot() ?: return null856 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))857 return try {858 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)859 val payload = try {860 VaultCodec.decode(plaintext)861 } finally {862 VaultCrypto.zero(plaintext)863 }864 DuressUnlock(key, payload, epoch)865 } catch (_: Exception) {866 VaultCrypto.zero(key)867 null868 }869 }870871 872 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {873 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")874 if (currentEpoch() != unlock.epoch) {875 VaultCrypto.zero(key)876 return@withLock UnlockResult.Error("stale_unlock")877 }878 return try {879 val adopted = mutateIfCurrentEpoch(unlock.epoch) {880 derivedKey = key881 decoyMode = true882 entryList = unlock.payload.entries.toMutableList()883 duressConfigured = false884 recoveryWordHash = unlock.payload.recoveryWordHash885 recoveryWordsSet = unlock.payload.recoveryWordsSet886 sessionDataAvailable = true887 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)888 else VaultCrypto.generateRootKey()889 _settings.value = store.loadSettings()890 publish()891 _isUnlocked.value = true892 sessionEpoch = nextSessionEpoch(sessionEpoch)893 }894 if (adopted == null) {895 VaultCrypto.zero(key)896 UnlockResult.Error("stale_unlock")897 } else {898 UnlockResult.Success899 }900 } catch (e: Exception) {901 902 903 904 905 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }906 VaultCrypto.zero(key)907 UnlockResult.Error(e.message ?: "decoy_unlock_failed")908 }909 }910911 912 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()913914 915916917918919920 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {921 922 val expectedEpoch = mutateState {923 if (derivedKey != null && !decoyMode) sessionEpoch else null924 } ?: return false925 926 927 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }928 var key: ByteArray? = null929 var basePlaintext: ByteArray? = null930 return try {931 val salt = VaultCrypto.generateSalt()932 key = VaultCrypto.deriveKey(duressPassphrase, salt)933 934 val decoyRoot = VaultCrypto.generateRootKey()935 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }936 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)937 basePlaintext = VaultCodec.encode(decoyPayload)938 939 940 941 942 943 944 945 946 val slotA = store.loadSlotA()947 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0948 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!949 val blob = try {950 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))951 } finally {952 VaultCrypto.zero(plaintext)953 954 955 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)956 basePlaintext = null957 key?.let(VaultCrypto::zero)958 key = null959 }960 beforeDurableCommit()961 val ok = withEpochCommit(expectedEpoch) {962 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))963 } ?: false964 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {965 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }966 } else if (ok) {967 return false968 }969 ok970 } catch (_: Exception) {971 false972 } finally {973 974 975 key?.let(VaultCrypto::zero)976 basePlaintext?.let(VaultCrypto::zero)977 }978 }979980981 982983984985986987988989990 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {991 if (!duressConfigured) return null992 val slot = store.loadDecoySlot() ?: return null993 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()994 }995996 9979989991000100110021003 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1004 1005 1006 1007 1008 1009 var salt: ByteArray? = null1010 var blob: ByteArray? = null1011 var nonce: ByteArray? = null1012 var ctAndTag: ByteArray? = null1013 var ct: ByteArray? = null1014 var tag: ByteArray? = null1015 try {1016 salt = unb64(slot.salt)1017 blob = unb64(slot.blob)1018 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1019 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1020 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1021 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1022 val obj = JsonObject(mapOf(1023 "version" to JsonPrimitive(1),1024 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1025 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1026 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1027 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1028 ))1029 return obj.toString()1030 } finally {1031 salt?.let(VaultCrypto::zero)1032 blob?.let(VaultCrypto::zero)1033 nonce?.let(VaultCrypto::zero)1034 ctAndTag?.let(VaultCrypto::zero)1035 ct?.let(VaultCrypto::zero)1036 tag?.let(VaultCrypto::zero)1037 }1038 }10391040 1041104210431044104510461047104810491050 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1051 val b = BrowserSlot.parse(json) ?: return false1052 var stored: StoredVault? = null1053 var salt: ByteArray? = null1054 var nonce: ByteArray? = null1055 var ct: ByteArray? = null1056 var tag: ByteArray? = null1057 var blob: ByteArray? = null1058 try {1059 salt = unhex(b.salt)1060 nonce = unhex(b.nonce)1061 ct = unhex(b.ciphertext)1062 tag = unhex(b.tag)1063 blob = ByteArray(nonce.size + ct.size + tag.size).also {1064 System.arraycopy(nonce, 0, it, 0, nonce.size)1065 System.arraycopy(ct, 0, it, nonce.size, ct.size)1066 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1067 }1068 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1069 val expectedEpoch = sessionEpoch1070 beforeDurableCommit()1071 withEpochCommit(expectedEpoch) {1072 store.writeDecoySlot(stored)1073 } ?: false1074 } catch (_: Exception) {1075 false1076 } finally {1077 salt?.let(VaultCrypto::zero)1078 nonce?.let(VaultCrypto::zero)1079 ct?.let(VaultCrypto::zero)1080 tag?.let(VaultCrypto::zero)1081 blob?.let(VaultCrypto::zero)1082 }1083 }10841085 1086 suspend fun clearDuress(): Boolean = mutex.withLock {1087 1088 1089 1090 1091 val expectedEpoch = currentEpoch()1092 val replaced = withEpochCommit(expectedEpoch) {1093 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1094 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1095 } ?: false1096 if (!replaced) return@withLock false1097 1098 1099 if (!isSessionEpochCurrent(expectedEpoch)) return false1100 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1101 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1102 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1103 }1104 true1105 }11061107 1108 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1109 var current: Throwable? = error1110 while (current != null) {1111 if (current is javax.crypto.AEADBadTagException) return true1112 1113 1114 1115 1116 if (current is javax.crypto.BadPaddingException) return true1117 current = current.cause1118 }1119 return false1120 }11211122 1123 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1124 val expectedEpoch = currentEpoch()1125 val slotA = try {1126 store.loadSlotA()1127 } catch (_: Exception) {1128 return@withLock UnlockResult.Error("unlock_key_storage_failed")1129 } ?: return@withLock UnlockResult.Error("no_vault")1130 1131 1132 1133 1134 var stagedKey: ByteArray? = key.copyOf()1135 return try {1136 val encrypted = try {1137 unb64(slotA.blob)1138 } catch (_: Exception) {1139 return@withLock UnlockResult.Error("invalid_vault_blob")1140 }1141 1142 1143 1144 1145 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1146 return@withLock UnlockResult.Error("invalid_vault_blob")1147 }1148 val plaintext = try {1149 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1150 } catch (e: Exception) {1151 if (isGcmAuthenticationFailure(e)) {1152 return@withLock UnlockResult.WrongPassword1153 }1154 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1155 }1156 val payload = try {1157 VaultCodec.decode(plaintext)1158 } catch (_: Exception) {1159 return@withLock UnlockResult.Error("invalid_vault_payload")1160 } finally {1161 VaultCrypto.zero(plaintext)1162 }1163 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1164 val result = adoptSlotA(candidate, payload, expectedEpoch)1165 if (result is UnlockResult.Success) stagedKey = null1166 result1167 } finally {1168 stagedKey?.let(VaultCrypto::zero)1169 }1170 }11711172 fun lock() {1173 1174 1175 1176 1177 1178 1179 stateLock.lock()1180 try {1181 sessionEpoch = nextSessionEpoch(sessionEpoch)1182 clearSessionStateLocked()1183 } finally {1184 stateLock.unlock()1185 }1186 }11871188 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11891190 1191 1192 @VisibleForTesting1193 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11941195 119611971198119912001201120212031204120512061207 fun expectTransientBackground() {1208 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1209 }12101211 12121213121412151216 fun consumeTransientBackgroundSuppression(): Boolean {1217 val until = suppressNextBackgroundLockUntil1218 val active = until != 0L && nowMs() < until1219 suppressNextBackgroundLockUntil = 0L1220 return active1221 }12221223 12241225 12261227122812291230 12311232123312341235123612371238123912401241 1242 1243 1244 1245 1246 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12471248 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1249 if (decoyMode || !_isUnlocked.value) null1250 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1251 }12521253 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1254 mutateState { sessionEpoch == epoch }12551256 internal fun unlockedSessionEpoch(): Long? = mutateState {1257 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1258 }12591260 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12611262 126312641265126612671268126912701271 fun sessionRootKey(): ByteArray? = mutateState {1272 if (!sessionDataAvailable) null else rootKey?.copyOf()1273 }12741275 12761277 fun syncVaultId(): String? = mutateState {1278 if (decoyMode || !_isUnlocked.value) null1279 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1280 }12811282 12831284128512861287 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1288 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1289 ?: return false1290 installRootKey(generateRecoverableRootKey(), expectedEpoch)1291 }12921293 12941295129612971298129913001301130213031304130513061307130813091310 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1311 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1312 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1313 }1314 val expectedEpoch = mutateState {1315 when {1316 derivedKey == null -> null1317 decoyMode -> Long.MIN_VALUE1318 else -> sessionEpoch1319 }1320 }1321 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1322 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1323 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13241325 val incoming = hexToRootKey(rootKeyHex)1326 if (incoming.size != VaultCrypto.KEY_SIZE) {1327 VaultCrypto.zero(incoming)1328 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1329 }1330 1331 1332 1333 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1334 if (!roundTrips) {1335 VaultCrypto.zero(incoming)1336 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1337 }13381339 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1340 return if (installRootKey(incoming, expectedEpoch)) {1341 AdoptVaultResult.Success(vaultId)1342 } else {1343 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1344 }1345 }13461347 13481349135013511352 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1353 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1354 val bytes = hexToRootKey(rootKeyHex)1355 return try {1356 val words = rootKeyToWords(bytes)1357 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1358 } catch (_: Exception) {1359 null1360 } finally {1361 VaultCrypto.zero(bytes)1362 }1363 }13641365 13661367136813691370 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1371 1372 1373 1374 1375 1376 1377 data class Snapshot(1378 val key: ByteArray,1379 val priorRootKey: ByteArray,1380 val priorRecoveryHash: String?,1381 val priorRecoverySet: Boolean,1382 )1383 val snap = mutateIfCurrentEpoch(expectedEpoch) {1384 val k = derivedKey ?: return@mutateIfCurrentEpoch null1385 if (decoyMode) return@mutateIfCurrentEpoch null1386 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1387 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1388 } ?: run {1389 VaultCrypto.zero(nextRootKey)1390 return false1391 }1392 val priorSlot = store.loadSlotA() ?: run {1393 VaultCrypto.zero(snap.key)1394 VaultCrypto.zero(snap.priorRootKey)1395 VaultCrypto.zero(nextRootKey)1396 return false1397 }1398 val nextWords = rootKeyToWords(nextRootKey)13991400 return try {1401 beforeDurableCommit()1402 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1403 if (mutateIfCurrentEpoch(expectedEpoch) {1404 rootKey = nextRootKey1405 recoveryWordHash = recoveryWordsHash(nextWords)1406 recoveryWordsSet = false1407 } == null) throw IllegalStateException("stale_root_rotation")14081409 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1410 ?: throw IllegalStateException("recovery_escrow_failed")1411 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1412 ?: throw IllegalStateException("root_escrow_failed")1413 val plaintext = VaultCodec.encode(currentPayload())1414 val blob = try {1415 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1416 } finally {1417 VaultCrypto.zero(plaintext)1418 }1419 val nextSlot = priorSlot.copy(1420 blob = blob,1421 recoveryEscrow = nextRecoveryEscrow,1422 rootKeyEscrow = nextRootKeyEscrow,1423 )1424 val committed = withEpochCommit(expectedEpoch) {1425 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1426 try {1427 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1428 1429 1430 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1431 store.stampLocalMutation()1432 } catch (_: Exception) {1433 store.writeSlotA(priorSlot)1434 return@withEpochCommit false1435 }1436 true1437 } ?: false1438 if (!committed) throw IllegalStateException("storage_write_failed")1439 mutateIfCurrentEpoch(expectedEpoch) { publish() }1440 true1441 } catch (_: Exception) {1442 val restored = mutateIfCurrentEpoch(expectedEpoch) {1443 rootKey?.let(VaultCrypto::zero)1444 rootKey = snap.priorRootKey.copyOf()1445 recoveryWordHash = snap.priorRecoveryHash1446 recoveryWordsSet = snap.priorRecoverySet1447 }1448 if (restored != null) {1449 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1450 }1451 false1452 } finally {1453 VaultCrypto.zero(snap.key)1454 VaultCrypto.zero(snap.priorRootKey)1455 1456 1457 1458 val stillOwnedBySession = mutateState {1459 sessionEpoch == expectedEpoch && rootKey === nextRootKey1460 }1461 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1462 }1463 }14641465 14661467146814691470 fun currentVaultKey(): ByteArray? = mutateState {1471 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1472 }14731474 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1475 val expectedEpoch = currentEpoch()1476 val slotA = store.loadSlotA() ?: return false1477 var oldKey: ByteArray? = null1478 var newKey: ByteArray? = null1479 var plaintext: ByteArray? = null1480 return try {1481 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1482 1483 1484 1485 1486 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1487 val payload = try {1488 VaultCodec.decode(decrypted)1489 } finally {1490 VaultCrypto.zero(decrypted)1491 }1492 val newSalt = VaultCrypto.generateSalt()1493 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1494 1495 plaintext = VaultCodec.encode(payload)1496 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1497 1498 1499 1500 1501 1502 1503 1504 val next = StoredVault(1505 version = slotA.version,1506 salt = b64(newSalt),1507 blob = blob,1508 recoveryEscrow = slotA.recoveryEscrow,1509 rootKeyEscrow = slotA.rootKeyEscrow,1510 )1511 1512 1513 1514 1515 val priorB = store.loadSlotB()1516 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1517 1518 1519 1520 if (mutateIfCurrentEpoch(expectedEpoch) {1521 oldKey?.let(VaultCrypto::zero); oldKey = null1522 derivedKey?.let(VaultCrypto::zero)1523 derivedKey = newKey; newKey = null1524 } == null) return false1525 true1526 } catch (_: Exception) {1527 false1528 } finally {1529 oldKey?.let(VaultCrypto::zero)1530 newKey?.let(VaultCrypto::zero)1531 plaintext?.let(VaultCrypto::zero)1532 }1533 }15341535 1536153715381539 suspend fun destroyVault() = mutex.withLock {1540 lock()1541 store.clearVault()1542 store.clearDecoySlot()1543 }15441545 15461547 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1548 val epoch = requireUnlocked()1549 val stamp = VaultCrypto.monotonicEntryTimestamp()1550 val now = System.currentTimeMillis()1551 val entry = withDerivedLast4(draft.copy(1552 id = draft.id.ifBlank { UUID.randomUUID().toString() },1553 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1554 modifiedAt = stamp,1555 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1556 ))1557 1558 1559 val rollback = mutateCurrentSession(epoch) {1560 val r = entryList.toMutableList()1561 entryList.add(entry)1562 r1563 } ?: throw IllegalStateException("vault_locked")1564 persistOrRollback(rollback, epoch)1565 entry1566 }15671568 156915701571157215731574157515761577 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1578 val epoch = requireUnlocked()1579 if (drafts.isEmpty()) return 01580 val now = System.currentTimeMillis()1581 val ready = drafts.map { draft ->1582 val stamp = VaultCrypto.monotonicEntryTimestamp()1583 withDerivedLast4(1584 draft.copy(1585 id = draft.id.ifBlank { UUID.randomUUID().toString() },1586 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1587 modifiedAt = stamp,1588 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1589 ),1590 )1591 }1592 1593 val rollback = mutateCurrentSession(epoch) {1594 val r = entryList.toMutableList()1595 entryList.addAll(ready)1596 r1597 } ?: throw IllegalStateException("vault_locked")1598 persistOrRollback(rollback, epoch)1599 return drafts.size1600 }16011602 1603160416051606160716081609161016111612161316141615 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1616 val epoch = requireUnlocked()1617 val now = System.currentTimeMillis()1618 val ready = newEntries.map { draft ->1619 val stamp = VaultCrypto.monotonicEntryTimestamp()1620 withDerivedLast4(1621 draft.copy(1622 id = draft.id.ifBlank { UUID.randomUUID().toString() },1623 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1624 modifiedAt = stamp,1625 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1626 ),1627 )1628 }1629 1630 1631 1632 1633 1634 1635 val incomingIds = ready.map { it.id }.toHashSet()1636 val rollback = mutateCurrentSession(epoch) {1637 val r = entryList.toMutableList()1638 val replaced = entryList.map { entry ->1639 if (entry.deletedAt != null) return@map entry1640 if (entry.id in incomingIds) return@map null1641 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1642 entry.copy(deletedAt = stamp, modifiedAt = stamp)1643 }.filterNotNull()1644 entryList = (replaced + ready).toMutableList()1645 r1646 } ?: throw IllegalStateException("vault_locked")1647 persistOrRollback(rollback, epoch)1648 return ready.size1649 }16501651 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1652 val epoch = requireUnlocked()1653 1654 1655 1656 1657 val prepared = mutateCurrentSession(epoch) {1658 val idx = entryList.indexOfFirst { it.id == patch.id }1659 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1660 val current = entryList[idx]1661 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1662 1663 val passwordChanged = patch.password != null && patch.password != current.password1664 1665 1666 1667 1668 1669 1670 val priorPassword = current.password1671 val shouldCapture = passwordChanged &&1672 !priorPassword.isNullOrEmpty() &&1673 current.passwordHistory?.firstOrNull()?.password != priorPassword1674 val nextHistory = if (shouldCapture) {1675 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1676 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1677 } else {1678 current.passwordHistory1679 }1680 val updated = withDerivedLast4(patch.copy(1681 createdAt = current.createdAt,1682 modifiedAt = stamp,1683 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1684 passwordHistory = nextHistory,1685 ))1686 val rollback = entryList.toMutableList()1687 entryList[idx] = updated1688 updated to rollback1689 } ?: throw IllegalStateException("vault_locked")1690 val (updated, rollback) = prepared1691 persistOrRollback(rollback, epoch)1692 updated1693 }16941695 1696 suspend fun deleteEntry(id: String) = mutex.withLock {1697 val epoch = requireUnlocked()1698 1699 1700 val rollback = mutateCurrentSession(epoch) {1701 val idx = entryList.indexOfFirst { it.id == id }1702 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1703 val current = entryList[idx]1704 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1705 val prior = entryList.toMutableList()1706 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1707 prior1708 } ?: throw IllegalStateException("vault_locked")1709 persistOrRollback(rollback, epoch)1710 }17111712 suspend fun restoreEntry(id: String) = mutex.withLock {1713 val epoch = requireUnlocked()1714 val rollback = mutateCurrentSession(epoch) {1715 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1716 if (idx == -1) throw IllegalStateException("deleted entry not found")1717 val current = entryList[idx]1718 val prior = entryList.toMutableList()1719 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1720 prior1721 } ?: throw IllegalStateException("vault_locked")1722 persistOrRollback(rollback, epoch)1723 }17241725 1726 suspend fun purgeEntry(id: String) = mutex.withLock {1727 val epoch = requireUnlocked()1728 val rollback = mutateCurrentSession(epoch) {1729 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1730 if (idx == -1) throw IllegalStateException("deleted entry not found")1731 val prior = entryList.toMutableList()1732 entryList.removeAt(idx)1733 prior1734 } ?: throw IllegalStateException("vault_locked")1735 persistOrRollback(rollback, epoch)1736 }17371738 1739 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1740 val epoch = requireUnlocked()1741 1742 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1743 val r = mutateCurrentSession(epoch) {1744 val rollback = entryList.toMutableList()1745 val before = entryList.size1746 entryList.removeAll { it.deletedAt != null }1747 R(rollback, before - entryList.size)1748 } ?: throw IllegalStateException("vault_locked")1749 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1750 r.removed1751 }17521753 1754 suspend fun countDuplicateEntries(): Int = mutex.withLock {1755 val epoch = requireUnlocked()1756 1757 1758 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1759 ?: throw IllegalStateException("vault_locked")1760 }17611762 17631764176517661767 suspend fun deduplicateEntries(): Int = mutex.withLock {1768 val epoch = requireUnlocked()1769 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1770 val ready = mutateCurrentSession(epoch) {1771 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1772 val active = entryList.filter { it.deletedAt == null }1773 val keepIds = mutableSetOf<String>()1774 active.groupBy { it.duplicateKey() }.values.forEach { group ->1775 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1776 }1777 val rollback = entryList.toMutableList()1778 var removed = 01779 entryList = entryList.map { entry ->1780 if (entry.deletedAt == null && entry.id !in keepIds) {1781 removed++1782 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1783 entry.copy(deletedAt = stamp, modifiedAt = stamp)1784 } else {1785 entry1786 }1787 }.toMutableList()1788 R(rollback, removed)1789 } ?: throw IllegalStateException("vault_locked")1790 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1791 ready.removed1792 }17931794 fun getEntries(): List<VaultEntry> = mutateState {1795 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1796 }1797 fun getEntry(id: String): VaultEntry? = mutateState {1798 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1799 }1800 fun getDeletedEntries(): List<VaultEntry> = mutateState {1801 if (!sessionDataAvailable) emptyList()1802 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1803 }18041805 1806 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1807 return mutateState {1808 if (!sessionDataAvailable) return@mutateState emptyList()1809 val active = entryList.filter { it.deletedAt == null }1810 if (tagFilter == null) return@mutateState active1811 val needle = tagFilter.trim().lowercase()1812 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1813 }1814 }18151816 1817 fun getDistinctTags(): List<String> = mutateState {1818 if (!sessionDataAvailable) return@mutateState emptyList()1819 entryList.asSequence()1820 .filter { it.deletedAt == null }1821 .flatMap { it.tags?.asSequence() ?: emptySequence() }1822 .map { it.trim().lowercase() }1823 .filter { it.isNotEmpty() }1824 .distinct()1825 .sorted()1826 .toList()1827 }18281829 183018311832183318341835 fun getDistinctFieldValues(field: String): List<String> {1836 val select: (VaultEntry) -> String? = when (field) {1837 "username" -> { e -> e.username }1838 "email" -> { e -> e.email }1839 else -> return emptyList()1840 }1841 return mutateState {1842 if (!sessionDataAvailable) return@mutateState emptyList()1843 entryList.asSequence()1844 .filter { it.deletedAt == null }1845 .mapNotNull { select(it)?.trim() }1846 .filter { it.isNotEmpty() }1847 .groupingBy { it.lowercase() }1848 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1849 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1850 }1851 .values1852 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1853 .map { it.first }1854 }1855 }18561857 fun getTombstones(): List<Tombstone> = mutateState {1858 if (!sessionDataAvailable) emptyList()1859 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1860 }1861 fun getVaultVersion(): Long = store.version()1862 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18631864 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1865 val expectedEpoch = currentEpoch()1866 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1867 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1868 1869 1870 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1871 val priorA = store.loadSlotA()1872 val priorB = store.loadSlotB()1873 val priorD = store.loadDecoySlot()1874 1875 1876 1877 1878 1879 val priorHistory = store.loadVaultHistory()1880 1881 1882 1883 1884 beforeDurableCommit()1885 if (!takeVaultSnapshot("restore", expectedEpoch)) {1886 return false1887 }1888 1889 1890 1891 val nextD: StoredVault = restoredD ?: run {1892 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161893 val fakeBlob = ByteArray(target).also(rng::nextBytes)1894 val fakeSalt = VaultCrypto.generateSalt()1895 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1896 }1897 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1898 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1899 return false1900 }1901 lock()1902 true1903 }19041905 19061907190819091910191119121913191419151916 private fun commitRestoreAtomic(1917 nextA: StoredVault,1918 nextB: String,1919 nextD: StoredVault,1920 priorA: StoredVault?,1921 priorB: String?,1922 priorD: StoredVault?,1923 expectedEpoch: Long,1924 ): Boolean {1925 return withEpochCommit(expectedEpoch) {1926 if (!store.writeSlotA(nextA)) return@withEpochCommit false1927 1928 1929 1930 try {1931 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1932 1933 1934 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1935 store.stampLocalMutation()1936 } catch (_: Exception) {1937 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1938 return@withEpochCommit false1939 }1940 true1941 } ?: false1942 }19431944 19451946 19471948194919501951 suspend fun applyMergedRemoteEntries(1952 remoteEntries: List<VaultEntry>,1953 remoteTombstones: List<Tombstone>,1954 expectedEpoch: Long? = null,1955 ): Int = mutex.withLock {1956 val epoch = expectedEpoch ?: requireUnlocked()1957 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01958 1959 1960 1961 1962 1963 val mergeNow = System.currentTimeMillis()1964 val clamped = remoteEntries.map { entry ->1965 entry.copy(1966 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1967 deletedAt = entry.deletedAt?.let {1968 SyncLimits.clampSyncTimestamp(it, mergeNow)1969 },1970 )1971 }1972 val tombMap = remoteTombstones.associate {1973 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1974 }1975 1976 1977 val applied = mutateCurrentSession(epoch) {1978 var n = 01979 val byId = entryList.associateBy { it.id }.toMutableMap()19801981 for (remote in clamped) {1982 val tomb = tombMap[remote.id]1983 if (tomb != null && tomb >= remote.modifiedAt) continue1984 val local = byId[remote.id]1985 if (local == null) {1986 entryList.add(remote); byId[remote.id] = remote; n++1987 } else if (remote.modifiedAt > local.modifiedAt) {1988 val i = entryList.indexOfFirst { it.id == local.id }1989 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1990 }1991 }19921993 for ((id, deletedAt) in tombMap) {1994 val local = byId[id] ?: continue1995 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1996 if (deletedAt < local.modifiedAt) continue1997 val i = entryList.indexOfFirst { it.id == id }1998 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1999 }2000 n2001 } ?: return@withLock 020022003 if (applied > 0) {2004 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2005 mutateIfCurrentEpoch(epoch) { publish() }2006 }2007 applied2008 }20092010 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2011 2012 2013 2014 val epoch = requireUnlocked()2015 val prior = mutateCurrentSession(epoch) {2016 val old = _settings.value2017 _settings.value = settings2018 old2019 } ?: throw IllegalStateException("vault_locked")20202021 val committed = try {2022 beforeDurableCommit()2023 2024 2025 2026 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2027 } catch (_: Exception) {2028 false2029 }2030 if (!committed) {2031 2032 2033 2034 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2035 throw IllegalStateException("settings_write_failed")2036 }2037 }20382039 20402041 2042 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20432044 20452046204720482049 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2050 val epoch = requireUnlocked()2051 2052 2053 2054 2055 2056 2057 val prior = mutateCurrentSession(epoch) {2058 val priorPair = recoveryWordHash to recoveryWordsSet2059 recoveryWordHash = recoveryWordsHash(words)2060 recoveryWordsSet = true2061 priorPair2062 } ?: return@withLock false2063 2064 2065 2066 2067 val persisted = runCatching {2068 persistSlotAWithEscrows(2069 buildRecoveryEscrow(words),2070 buildRootKeyEscrow(words),2071 expectedEpoch = epoch,2072 )2073 }.getOrDefault(false)2074 if (!persisted) {2075 mutateIfCurrentEpoch(epoch) {2076 recoveryWordHash = prior.first2077 recoveryWordsSet = prior.second2078 }2079 }2080 persisted2081 }20822083 2084 fun verifyRecoveryWords(words: List<String>): Boolean {2085 return mutateState {2086 val stored = recoveryWordHash ?: return@mutateState false2087 stored == recoveryWordsHash(words)2088 }2089 }20902091 20922093 2094 fun hasRecoveryEscrow(): Boolean =2095 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20962097 209820992100210121022103210421052106210721082109 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2110 val expectedEpoch = currentEpoch()2111 val slotA = store.loadSlotA() ?: return false2112 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21132114 2115 2116 val fullEscrow = slotA.recoveryEscrow2117 if (fullEscrow != null) {2118 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2119 if (material != null) {2120 var keyBytes: ByteArray? = null2121 var plaintext: ByteArray? = null2122 var candidateRoot: ByteArray? = null2123 var stateCommitStarted = false2124 try {2125 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2126 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2127 val payload = VaultCodec.decode(plaintext)2128 2129 2130 2131 2132 val candidateSettings = store.loadSettings()2133 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2134 val decodedRoot = unb64(payload.rootKeyB64)2135 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2136 2137 2138 2139 VaultCrypto.zero(decodedRoot)2140 throw IllegalArgumentException("invalid recovery root key")2141 }2142 decodedRoot2143 } else {2144 generateRecoverableRootKey()2145 }2146 stateCommitStarted = true2147 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2148 clearSessionStateLocked()2149 entryList = payload.entries.toMutableList()2150 duressConfigured = payload.duressConfigured2151 recoveryWordHash = payload.recoveryWordHash2152 recoveryWordsSet = payload.recoveryWordsSet2153 rootKey = candidateRoot2154 candidateRoot = null2155 _settings.value = candidateSettings2156 publish()2157 2158 2159 sessionDataAvailable = true2160 _isUnlocked.value = false2161 true2162 }2163 if (adopted == true) return@withLock true2164 } catch (_: Exception) {2165 2166 2167 2168 2169 if (stateCommitStarted) {2170 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2171 }2172 } finally {2173 plaintext?.let(VaultCrypto::zero)2174 VaultCrypto.zero(material)2175 keyBytes?.let(VaultCrypto::zero)2176 candidateRoot?.let(VaultCrypto::zero)2177 }2178 }2179 }21802181 2182 2183 2184 val rootEscrow = slotA.rootKeyEscrow2185 if (rootEscrow != null) {2186 var candidateRoot: ByteArray? = null2187 var stateCommitStarted = false2188 try {2189 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2190 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2191 val candidateSettings = store.loadSettings()2192 stateCommitStarted = true2193 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2194 clearSessionStateLocked()2195 entryList = mutableListOf()2196 duressConfigured = false2197 recoveryWordHash = recoveryWordsHash(words)2198 recoveryWordsSet = true2199 rootKey = candidateRoot2200 candidateRoot = null2201 _settings.value = candidateSettings2202 publish()2203 sessionDataAvailable = true2204 _isUnlocked.value = false2205 true2206 }2207 if (adopted == true) return@withLock true2208 } catch (_: Exception) {2209 if (stateCommitStarted) {2210 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2211 }2212 } finally {2213 candidateRoot?.let(VaultCrypto::zero)2214 }2215 }2216 return false2217 }22182219 222022212222222322242225222622272228 suspend fun resetPasswordWithRecoveryWords(2229 words: List<String>,2230 newPassword: String,2231 ): Boolean = mutex.withLock {2232 val expectedEpoch = currentEpoch()2233 2234 2235 2236 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2237 val priorA = store.loadSlotA() ?: return false2238 var newKey: ByteArray? = null2239 var plaintext: ByteArray? = null2240 return try {2241 val newSalt = VaultCrypto.generateSalt()2242 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2243 plaintext = VaultCodec.encode(currentPayload())2244 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2245 2246 2247 2248 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2249 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2250 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2251 val next = StoredVault(2252 version = priorA.version,2253 salt = b64(newSalt),2254 blob = blob,2255 recoveryEscrow = freshRecovery,2256 rootKeyEscrow = freshRootKey,2257 )2258 2259 2260 2261 val priorB = store.loadSlotB()2262 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2263 2264 2265 2266 if (mutateIfCurrentEpoch(expectedEpoch) {2267 derivedKey?.let(VaultCrypto::zero)2268 derivedKey = newKey; newKey = null2269 publish()2270 _isUnlocked.value = true2271 } == null) return false2272 true2273 } catch (_: Exception) {2274 false2275 } finally {2276 newKey?.let(VaultCrypto::zero)2277 plaintext?.let(VaultCrypto::zero)2278 }2279 }22802281 228222832284228522862287 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2288 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2289 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2290 var keyBytes: ByteArray? = null2291 var plaintext: ByteArray? = null2292 return try {2293 val salt = VaultCrypto.generateSalt()2294 val recoveryKey = deriveRecoveryKey(material, salt)2295 keyBytes = recoveryKey.encoded2296 plaintext = VaultCodec.encode(currentPayload())2297 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2298 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2299 } finally {2300 VaultCrypto.zero(material)2301 keyBytes?.let(VaultCrypto::zero)2302 plaintext?.let(VaultCrypto::zero)2303 }2304 }23052306 23072308230923102311231223132314 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2315 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2316 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2317 val rk = mutateState { rootKey?.copyOf() } ?: run {2318 VaultCrypto.zero(material)2319 return null2320 }2321 var keyBytes: ByteArray? = null2322 var plaintext: ByteArray? = null2323 return try {2324 val salt = VaultCrypto.generateSalt()2325 val recoveryKey = deriveRecoveryKey(material, salt)2326 keyBytes = recoveryKey.encoded2327 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2328 2329 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2330 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2331 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2332 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2333 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2334 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2335 } finally {2336 VaultCrypto.zero(rk)2337 VaultCrypto.zero(material)2338 keyBytes?.let(VaultCrypto::zero)2339 plaintext?.let(VaultCrypto::zero)2340 }2341 }23422343 2344 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2345 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2346 var keyBytes: ByteArray? = null2347 var pt: ByteArray? = null2348 return try {2349 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2350 keyBytes = recoveryKey.encoded2351 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2352 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2353 hexToRootKey(String(pt, Charsets.US_ASCII))2354 } catch (_: Exception) {2355 null2356 } finally {2357 2358 2359 2360 VaultCrypto.zero(material)2361 keyBytes?.let(VaultCrypto::zero)2362 pt?.let(VaultCrypto::zero)2363 }2364 }23652366 236723682369237023712372 private fun persistSlotAWithEscrows(2373 recoveryEscrow: RecoveryEscrow?,2374 rootKeyEscrow: RootKeyEscrow?,2375 expectedEpoch: Long? = null,2376 ): Boolean {2377 val snapshot = snapshotForPersist(expectedEpoch)2378 ?: throw IllegalStateException("vault_locked_or_stale")2379 if (snapshot.decoy) {2380 VaultCrypto.zero(snapshot.key)2381 return false2382 }2383 try {2384 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2385 val plaintext = VaultCodec.encode(snapshot.payload)2386 val blob = try {2387 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2388 } finally {2389 2390 2391 VaultCrypto.zero(plaintext)2392 }2393 val next = prior.copy(2394 blob = blob,2395 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2396 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2397 )2398 beforeDurableCommit()2399 return withEpochCommit(snapshot.epoch) {2400 if (!store.writeSlotA(next)) return@withEpochCommit false2401 try {2402 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2403 2404 2405 2406 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2407 } catch (e: Exception) {2408 store.writeSlotA(prior)2409 throw e2410 }2411 true2412 } ?: false2413 } finally {2414 VaultCrypto.zero(snapshot.key)2415 }2416 }24172418 24192420 private fun requireUnlocked(): Long {2421 2422 2423 2424 stateLock.lock()2425 try {2426 if (derivedKey == null) throw IllegalStateException("vault_locked")2427 return sessionEpoch2428 } finally {2429 stateLock.unlock()2430 }2431 }24322433 2434 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24352436 private fun countDuplicateEntriesLocked(): Int {2437 val seen = mutableMapOf<String, VaultEntry>()2438 var duplicates = 02439 for (entry in entryList.filter { it.deletedAt == null }) {2440 val key = entry.duplicateKey()2441 val existing = seen[key]2442 if (existing == null) {2443 seen[key] = entry2444 } else {2445 duplicates++2446 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2447 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2448 if (entryStamp > existingStamp) seen[key] = entry2449 }2450 }2451 return duplicates2452 }24532454 private fun VaultEntry.duplicateKey(): String {2455 if (type == "note") return "note|${title.lowercase()}"2456 val passkeyData = passkey2457 if (passkeyData != null) {2458 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2459 }2460 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2461 }24622463 246424652466246724682469 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2470 val epoch = expectedEpoch ?: currentEpoch()2471 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2472 val slotA = store.loadSlotA() ?: return false2473 val vaultA = VaultCodec.encodeSlot(slotA)2474 val vaultB = store.loadSlotB() ?: slotA.blob2475 2476 2477 2478 2479 2480 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2481 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162482 val fakeBlob = ByteArray(target).also(rng::nextBytes)2483 val fakeSalt = VaultCrypto.generateSalt()2484 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2485 }2486 val history = store.loadVaultHistory()2487 val latest = history.snapshots.firstOrNull()2488 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2489 val entryCount = mutateIfCurrentEpoch(epoch) {2490 if (derivedKey != null && !decoyMode) {2491 bucketEntryCount(entryList.count { it.deletedAt == null })2492 } else {2493 null2494 }2495 } ?: return false2496 return try {2497 val snapshot = VaultSnapshot(2498 id = UUID.randomUUID().toString(),2499 savedAt = System.currentTimeMillis(),2500 reason = reason,2501 vault_a = vaultA,2502 vault_b = vaultB,2503 vault_d = vaultD,2504 entryCount = entryCount,2505 )2506 2507 2508 2509 withEpochCommit(epoch) {2510 store.writeVaultHistory(2511 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2512 )2513 } ?: false2514 } catch (_: Exception) {2515 false2516 }2517 }25182519 private fun bucketEntryCount(n: Int): Int = when {2520 n <= 0 -> 02521 n <= 10 -> 102522 n <= 25 -> 252523 n <= 50 -> 502524 n <= 100 -> 1002525 n <= 250 -> 2502526 n <= 500 -> 5002527 else -> ((n + 499) / 500) * 5002528 }25292530 253125322533253425352536253725382539 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2540 if (!entry.isCard()) return entry2541 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2542 val derived = if (digits.length >= 4) digits.takeLast(4) else null2543 return entry.copy(last4 = derived ?: entry.last4)2544 }25452546 254725482549255025512552 private fun autoPurgeExpiredTrash() {2553 val days = _settings.value.trashRetentionDays ?: return2554 if (days <= 0) return2555 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2556 val before = entryList.size2557 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2558 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2559 }25602561 2562 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25632564 private fun currentPayloadLocked(): VaultPayload =2565 VaultPayload(2566 entries = entryList.toList(),2567 duressConfigured = duressConfigured,2568 rootKeyB64 = rootKey?.let(::b64) ?: "",2569 recoveryWordHash = recoveryWordHash,2570 recoveryWordsSet = recoveryWordsSet,2571 )25722573 25742575257625772578 private fun ensureRootKey(payload: VaultPayload) {2579 if (payload.rootKeyB64.isNotBlank()) {2580 rootKey = unb64(payload.rootKeyB64)2581 return2582 }2583 rootKey = generateRecoverableRootKey()2584 runCatching { persistSlotA(bumpVersion = false) }2585 }25862587 private fun publish() {2588 _entries.value = entryList.filter { it.deletedAt == null }2589 }25902591 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2592 var plaintext = VaultCodec.encode(currentPayload())2593 return try {2594 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2595 StoredVault(version = 1, salt = b64(salt), blob = blob)2596 } finally {2597 2598 2599 2600 VaultCrypto.zero(plaintext)2601 }2602 }26032604 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2605 2606 2607 2608 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2609 val noise = ByteArray(approxBytes).also(rng::nextBytes)2610 return store.writeSlotB(b64(noise))2611 }26122613 261426152616 private fun matchingCiphertextLength(slotA: StoredVault): Int =2617 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26182619 26202621262226232624 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2625 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162626 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2627 val fakeSalt = VaultCrypto.generateSalt()2628 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2629 }26302631 263226332634263526362637 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2638 val isConfigured = mutateState { duressConfigured }2639 return if (isConfigured) true else writeFakeDuressSlot(slotA)2640 }26412642 2643264426452646264726482649 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2650 val salt = unhex(b.salt)2651 val nonce = unhex(b.nonce)2652 val ct = unhex(b.ciphertext)2653 val tag = unhex(b.tag)2654 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2655 System.arraycopy(nonce, 0, it, 0, nonce.size)2656 System.arraycopy(ct, 0, it, nonce.size, ct.size)2657 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2658 }2659 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2660 }26612662 2663 private fun persistSlotA(2664 bumpVersion: Boolean,2665 snapshotReason: String? = "local_save",2666 expectedEpoch: Long? = null,2667 ): Boolean {2668 val snapshot = snapshotForPersist(expectedEpoch)2669 ?: throw IllegalStateException("vault_locked_or_stale")2670 return try {2671 2672 if (snapshot.decoy) {2673 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2674 val plaintext = VaultCodec.encode(snapshot.payload)2675 val blob = try {2676 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2677 } finally {2678 2679 2680 VaultCrypto.zero(plaintext)2681 }2682 beforeDurableCommit()2683 return withEpochCommit(snapshot.epoch) {2684 store.writeDecoySlot(prior.copy(blob = blob))2685 } ?: false2686 }2687 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2688 2689 2690 2691 2692 val priorB = store.loadSlotB()2693 beforeDurableCommit()2694 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2695 val plaintext = VaultCodec.encode(snapshot.payload)2696 val blob = try {2697 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2698 } finally {2699 2700 2701 VaultCrypto.zero(plaintext)2702 }2703 val next = prior.copy(blob = blob)2704 2705 2706 2707 2708 withEpochCommit(snapshot.epoch) {2709 if (!store.writeSlotA(next)) return@withEpochCommit false2710 try {2711 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2712 2713 2714 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2715 if (bumpVersion) store.stampLocalMutation()2716 } catch (e: Exception) {2717 store.writeSlotA(prior)2718 if (priorB != null) runCatching { store.writeSlotB(priorB) }2719 throw e2720 }2721 true2722 } ?: false2723 } finally {2724 2725 2726 VaultCrypto.zero(snapshot.key)2727 }2728 }27292730 2731273227332734273527362737 private fun commitVaultRotation(2738 priorA: StoredVault,2739 priorB: String?,2740 nextA: StoredVault,2741 expectedEpoch: Long,2742 ): Boolean {2743 beforeDurableCommit()2744 return withEpochCommit(expectedEpoch) {2745 if (!store.writeSlotA(nextA)) return@withEpochCommit false2746 2747 2748 2749 try {2750 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2751 2752 2753 2754 2755 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2756 store.stampLocalMutation()2757 } catch (_: Exception) {2758 rollbackRotation(priorA, priorB)2759 return@withEpochCommit false2760 }2761 true2762 } ?: false2763 }27642765 27662767276827692770 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2771 val failures = mutableListOf<String>()2772 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2773 .onFailure { failures += "writeSlotA: $it" }2774 if (priorB != null) {2775 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2776 .onFailure { failures += "writeSlotB: $it" }2777 }2778 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2779 }27802781 2782 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2783 try {2784 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2785 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2786 throw IllegalStateException("storage_write_failed")2787 }2788 } catch (e: Exception) {2789 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2790 throw e2791 }2792 2793 2794 2795 mutateIfCurrentEpoch(expectedEpoch) { publish() }2796 }2797}