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 }282930private val BrowserSlotJson = Json { encodeDefaults = false }3132private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)33private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)34private fun unhex(s: String): ByteArray {35 require(s.length % 2 == 0) { "odd-length hex" }36 return ByteArray(s.length / 2) { i ->37 val hi = Character.digit(s[i * 2], 16)38 val lo = Character.digit(s[i * 2 + 1], 16)39 require(hi >= 0 && lo >= 0) { "bad hex digit" }40 ((hi shl 4) or lo).toByte()41 }42}434445private const val PASSWORD_HISTORY_MAX = 5046private const val VAULT_HISTORY_MAX = 547484950515253private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L5455sealed interface UnlockResult {56 data object Success : UnlockResult57 data object WrongPassword : UnlockResult58 data class Error(val reason: String) : UnlockResult59}6061sealed interface CreateVaultResult {62 data object Success : CreateVaultResult63 data class Error(val reason: String) : CreateVaultResult64}6566sealed interface AdoptVaultResult {67 data class Success(val vaultId: String) : AdoptVaultResult68 data class Error(val reason: String) : AdoptVaultResult69}707172737475767778798081828384@Singleton85class VaultRepository @Inject constructor(86 private val store: VaultStorage,87) {88 private val mutex = Mutex()89 90 91 92 93 94 95 96 private val stateLock = ReentrantLock()97 private val rng = SecureRandom()9899 100101102103104 private var sessionEpoch: Long = 0L105106 private fun nextSessionEpoch(epoch: Long): Long =107 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L108109 110 @VisibleForTesting111 internal var beforeDurableCommit: () -> Unit = {}112113 114 @VisibleForTesting115 internal var afterCodexRollback: () -> Unit = {}116117 118119120121122 private inline fun <T> mutateState(block: () -> T): T {123 stateLock.lock()124 return try { block() } finally { stateLock.unlock() }125 }126127 128 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {129 stateLock.lock()130 return try {131 if (sessionEpoch != epoch) null else block()132 } finally {133 stateLock.unlock()134 }135 }136137 138 @VisibleForTesting139 internal var beforeSessionEpochCommit: () -> Unit = {}140141 142143144145146 @VisibleForTesting147 internal var beforeSessionStateMutation: () -> Unit = {}148149 150151152153154155 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {156 beforeSessionEpochCommit()157 return withEpochCommit(epoch, block)158 }159160 private fun currentEpoch(): Long = mutateState { sessionEpoch }161162 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =163 mutateState { if (sessionEpoch == epoch) block() else null }164165 166 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {167 beforeSessionStateMutation()168 return mutateIfCurrentEpoch(epoch, block)169 }170171 private data class PersistSnapshot(172 val epoch: Long,173 val key: ByteArray,174 val payload: VaultPayload,175 val decoy: Boolean,176 )177178 179 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {180 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null181 val liveKey = derivedKey ?: return@mutateState null182 PersistSnapshot(183 epoch = sessionEpoch,184 key = liveKey.copyOf(),185 payload = currentPayloadLocked(),186 decoy = decoyMode,187 )188 }189190 private var derivedKey: ByteArray? = null191 private var rootKey: ByteArray? = null192 private var entryList: MutableList<VaultEntry> = mutableListOf()193 private var duressConfigured: Boolean = false194 private var recoveryWordHash: String? = null195 private var recoveryWordsSet: Boolean = false196 197 private var sessionDataAvailable: Boolean = false198 199 private var decoyMode: Boolean = false200201 private val _isUnlocked = MutableStateFlow(false)202 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()203204 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())205 206 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()207208 private val _settings = MutableStateFlow(VaultSettings())209 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()210211 212 private fun clearSessionStateLocked() {213 derivedKey?.let(VaultCrypto::zero)214 rootKey?.let(VaultCrypto::zero)215 derivedKey = null216 rootKey = null217 entryList = mutableListOf()218 duressConfigured = false219 recoveryWordHash = null220 recoveryWordsSet = false221 sessionDataAvailable = false222 decoyMode = false223 _settings.value = VaultSettings()224 _entries.value = emptyList()225 _isUnlocked.value = false226 }227228 fun hasVault(): Boolean = store.hasVault()229230 231232 233234235236237238239 fun codexVaultBlobBase64(): String? {240 val slotA = store.loadSlotA() ?: return null241 242 243 val slotD = store.loadDecoySlot() ?: return null244 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""245 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))246 }247248 249250251252253 254 private fun JsonElement?.asSlotJson(): String? = when {255 this == null || this is JsonNull -> null256 this is JsonPrimitive && isString -> content257 else -> toString()258 }259260 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {261 val expectedEpoch = currentEpoch()262 var candidateKey: ByteArray? = null263 var plaintext: ByteArray? = null264 return try {265 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)266 267 268 269 270 271 272 273 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject274 ?: return UnlockResult.Error("invalid_codex_blob")275 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")276 277 278 279 280 281 val slotA = VaultCodec.decodeSlot(slotJson)282 ?: BrowserSlot.parse(slotJson)?.let {283 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword284 }285 ?: return UnlockResult.Error("invalid_codex_blob")286 287 288 289 290 291 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))292 plaintext = decryptSlotABlob(slotA, candidateKey)293 294 295 296 297 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {298 VaultCrypto.zero(candidateKey)299 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))300 plaintext = decryptSlotABlob(slotA, candidateKey)301 }302 if (plaintext == null) return UnlockResult.WrongPassword303 val payload = try {304 VaultCodec.decode(plaintext)305 } catch (_: Exception) {306 return UnlockResult.Error("invalid_codex_blob")307 }308309 val priorA = store.loadSlotA()310 val priorB = store.loadSlotB()311 val priorD = store.loadDecoySlot()312 313 314 315 316 317 val codexDuressJson = wrapper["b"].asSlotJson()318 val codexDuress: StoredVault? = codexDuressJson?.let {319 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->320 321 322 323 324 325 326 browserDuressPassthrough(b)327 }328 }329330 beforeDurableCommit()331 val committed = runCatching {332 withEpochCommit(expectedEpoch) {333 try {334 335 336 337 338 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")339 340 341 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")342 343 344 val duressOk = if (codexDuress != null) {345 store.writeDecoySlot(codexDuress)346 } else {347 writeFakeDuressSlot(slotA)348 }349 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")350 store.stampLocalMutation()351 true352 } catch (_: Exception) {353 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))354 355 356 357 afterCodexRollback()358 false359 }360 } ?: false361 }.getOrDefault(false)362 if (!committed) {363 return UnlockResult.Error("storage_write_failed")364 }365366 val ownedKey = candidateKey!!367 candidateKey = null 368 adoptSlotA(ownedKey, payload, expectedEpoch)369 } catch (e: Exception) {370 371 372 373 374 375 376 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))377 UnlockResult.Error(e.message ?: "restore_failed")378 } finally {379 plaintext?.let(VaultCrypto::zero)380 candidateKey?.let(VaultCrypto::zero)381 }382 }383384 385 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =386 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()387388 389390391392393394395396397 private fun rollbackRestoreLocked(398 priorA: StoredVault?,399 priorB: String?,400 priorD: StoredVault? = null,401 ): List<String> {402 val failures = mutableListOf<String>()403 if (priorA == null) {404 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }405 } else {406 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }407 .onFailure { failures += "writeSlotA: $it" }408 if (priorB != null) {409 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }410 .onFailure { failures += "writeSlotB: $it" }411 }412 413 414 415 if (priorD != null) {416 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }417 .onFailure { failures += "writeDecoySlot: $it" }418 } else {419 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }420 }421 }422 return failures423 }424425 private fun reportRollbackFailures(failures: List<String>) {426 427 428 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }429 }430431 432 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {433 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)434 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()435 val window = offset?.let {436 val from = (it - 40).coerceAtLeast(0)437 val to = (it + 40).coerceAtMost(wrapperJson.length)438 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")439 }440 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()441 val slot = wrapper?.get("a")442 buildString {443 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)444 append(" | blobBase64=").append(vaultBlobBase64.length)445 append(" wrapperChars=").append(wrapperJson.length)446 append(" wrapperParses=").append(wrapper != null)447 append(" keys=").append(wrapper?.keys)448 append(" slotAKind=").append(449 when {450 slot == null -> "absent"451 slot is JsonPrimitive && slot.isString -> "string"452 slot is JsonObject -> "object:" + slot.keys453 else -> slot::class.simpleName454 },455 )456 if (window != null) append(" masked@").append(offset).append("=").append(window)457 }458 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }459460 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {461 val expectedEpoch = currentEpoch()462 var key: ByteArray? = null463 return try {464 val salt = VaultCrypto.generateSalt()465 key = VaultCrypto.deriveKey(password, salt)466 if (mutateIfCurrentEpoch(expectedEpoch) {467 entryList = mutableListOf()468 duressConfigured = false469 recoveryWordHash = null470 recoveryWordsSet = false471 decoyMode = false472 sessionDataAvailable = false473 rootKey = generateRecoverableRootKey()474 } == null) return CreateVaultResult.Error("stale_create")475 476 477 478 479 val slotA = sealSlotA(key!!, salt)480 481 482 beforeDurableCommit()483 val created = withEpochCommit(expectedEpoch) {484 if (!store.writeSlotA(slotA)) return@withEpochCommit false485 try {486 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")487 488 489 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")490 store.stampLocalMutation()491 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")492 true493 } catch (_: Exception) {494 495 496 store.clearVault()497 false498 }499 } ?: false500 if (!created) {501 lock()502 return CreateVaultResult.Error("storage_write_failed")503 }504 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")505 if (mutateIfCurrentEpoch(expectedEpoch) {506 derivedKey = sessionKey507 key = null508 publish()509 _isUnlocked.value = true510 sessionDataAvailable = true511 sessionEpoch = nextSessionEpoch(sessionEpoch)512 } == null) return CreateVaultResult.Error("stale_create")513 CreateVaultResult.Success514 } catch (e: Exception) {515 lock()516 CreateVaultResult.Error(e.message ?: "create_failed")517 } finally {518 key?.let(VaultCrypto::zero)519 }520 }521522 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {523 val expectedEpoch = currentEpoch()524 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")525 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")526 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {527 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")528 }529 var adoptedRootKey: ByteArray? = null530 var nextDerivedKey: ByteArray? = null531 var sessionRootKey: ByteArray? = null532 return try {533 adoptedRootKey = hexToRootKey(rootKeyHex)534 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {535 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")536 }537 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)538 val salt = VaultCrypto.generateSalt()539 nextDerivedKey = VaultCrypto.deriveKey(password, salt)540 sessionRootKey = adoptedRootKey.copyOf()541 val payload = VaultPayload(542 entries = emptyList(),543 duressConfigured = false,544 rootKeyB64 = b64(sessionRootKey),545 recoveryWordHash = null,546 recoveryWordsSet = false,547 )548 val plaintext = VaultCodec.encode(payload)549 val blob = try {550 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))551 } finally {552 VaultCrypto.zero(plaintext)553 }554 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)555 556 557 beforeDurableCommit()558 val created = withEpochCommit(expectedEpoch) {559 if (!store.writeSlotA(slotA)) return@withEpochCommit false560 try {561 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")562 563 564 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")565 store.stampLocalMutation()566 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")567 true568 } catch (_: Exception) {569 store.clearVault()570 false571 }572 } ?: false573 if (!created) return AdoptVaultResult.Error("storage_write_failed")574575 val adopted = mutateIfCurrentEpoch(expectedEpoch) {576 val previousDerivedKey = derivedKey577 val previousRootKey = rootKey578 derivedKey = nextDerivedKey579 rootKey = sessionRootKey580 nextDerivedKey = null581 sessionRootKey = null582 entryList = mutableListOf()583 duressConfigured = false584 recoveryWordHash = null585 recoveryWordsSet = false586 decoyMode = false587 sessionDataAvailable = true588 publish()589 _isUnlocked.value = true590 sessionEpoch = nextSessionEpoch(sessionEpoch)591 previousDerivedKey to previousRootKey592 }593 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")594 val (previousDerivedKey, previousRootKey) = adopted595 previousDerivedKey?.let(VaultCrypto::zero)596 previousRootKey?.let(VaultCrypto::zero)597 AdoptVaultResult.Success(vaultId)598 } catch (e: Exception) {599 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")600 } finally {601 adoptedRootKey?.let(VaultCrypto::zero)602 nextDerivedKey?.let(VaultCrypto::zero)603 sessionRootKey?.let(VaultCrypto::zero)604 }605 }606607 suspend fun unlock(password: String): UnlockResult = mutex.withLock {608 unlockLocked(password, currentEpoch())609 }610611 612 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {613 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")614 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))615 val payload = try {616 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)617 try {618 VaultCodec.decode(plaintext)619 } finally {620 VaultCrypto.zero(plaintext)621 }622 } catch (_: Exception) {623 VaultCrypto.zero(key)624 625 626 627 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {628 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)629 if (legacy != null) return legacy630 }631 return UnlockResult.WrongPassword632 }633 return adoptSlotA(key, payload, expectedEpoch)634 }635636 637638639640641642643644 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {645 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))646 var newKey: ByteArray? = null647 return try {648 val payload = try {649 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)650 try {651 VaultCodec.decode(plaintext)652 } finally {653 VaultCrypto.zero(plaintext)654 }655 } catch (_: Exception) {656 return null657 }658 659 660 val newSalt = VaultCrypto.generateSalt()661 newKey = VaultCrypto.deriveKey(password, newSalt)662 val plaintext = VaultCodec.encode(payload)663 val blob = try {664 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))665 } finally {666 VaultCrypto.zero(plaintext)667 }668 val next = StoredVault(669 version = slotA.version,670 salt = b64(newSalt),671 blob = blob,672 recoveryEscrow = slotA.recoveryEscrow,673 rootKeyEscrow = slotA.rootKeyEscrow,674 )675 676 677 678 val priorB = store.loadSlotB()679 beforeDurableCommit()680 val healed = withEpochCommit(expectedEpoch) {681 if (!store.writeSlotA(next)) {682 false683 } else try {684 if (!writeMatchingNoiseSlotB(next)) {685 runCatching { store.writeSlotA(slotA) }686 if (priorB != null) runCatching { store.writeSlotB(priorB) }687 false688 } else if (!writeCompanionDuressSlot(next)) {689 runCatching { store.writeSlotA(slotA) }690 if (priorB != null) runCatching { store.writeSlotB(priorB) }691 false692 } else {693 store.stampLocalMutation()694 true695 }696 } catch (_: Exception) {697 runCatching { store.writeSlotA(slotA) }698 if (priorB != null) runCatching { store.writeSlotB(priorB) }699 false700 }701 } ?: false702 if (healed) {703 VaultCrypto.zero(rawKey!!)704 rawKey = null705 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 706 newKey = null707 result708 } else {709 VaultCrypto.zero(newKey!!)710 newKey = null711 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 712 rawKey = null713 result714 }715 } finally {716 717 718 rawKey?.let(VaultCrypto::zero)719 newKey?.let(VaultCrypto::zero)720 }721 }722723 724 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {725 return try {726 727 728 729 730 731 val adopted = mutateIfCurrentEpoch(expectedEpoch) {732 derivedKey = key733 decoyMode = false734 entryList = payload.entries.toMutableList()735 duressConfigured = payload.duressConfigured736 recoveryWordHash = payload.recoveryWordHash737 recoveryWordsSet = payload.recoveryWordsSet738 sessionDataAvailable = true739 _settings.value = store.loadSettings()740 ensureRootKey(payload)741 autoPurgeExpiredTrash()742 publish()743 _isUnlocked.value = true744 745 746 747 sessionEpoch = nextSessionEpoch(sessionEpoch)748 }749 if (adopted == null) {750 VaultCrypto.zero(key)751 UnlockResult.Error("stale_unlock")752 } else {753 UnlockResult.Success754 }755 } catch (e: Exception) {756 757 758 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }759 VaultCrypto.zero(key)760 UnlockResult.Error(e.message ?: "unlock_failed")761 }762 }763764 765766 767768769770771772 class SlotAUnlock internal constructor(773 key: ByteArray,774 internal val payload: VaultPayload,775 internal val epoch: Long,776 ) {777 private var ownedKey: ByteArray? = key778 private var consumed = false779780 781 internal fun takeKey(): ByteArray? = synchronized(this) {782 if (consumed) return@synchronized null783 consumed = true784 ownedKey.also { ownedKey = null }785 }786787 788 internal fun discard() = synchronized(this) {789 if (consumed) return@synchronized790 consumed = true791 ownedKey?.let(VaultCrypto::zero)792 ownedKey = null793 }794 }795796 797798799800801802 fun tryUnlockSlotA(password: String): SlotAUnlock? {803 val epoch = currentEpoch()804 val slotA = store.loadSlotA() ?: return null805 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))806 return try {807 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)808 val payload = try {809 VaultCodec.decode(plaintext)810 } finally {811 VaultCrypto.zero(plaintext)812 }813 SlotAUnlock(key, payload, epoch)814 } catch (_: Exception) {815 VaultCrypto.zero(key)816 null817 }818 }819820 821 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {822 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")823 adoptSlotA(key, unlock.payload, unlock.epoch)824 }825826 827 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()828829 830831 832833834835836 class DuressUnlock internal constructor(837 key: ByteArray,838 internal val payload: VaultPayload,839 internal val epoch: Long,840 ) {841 private var ownedKey: ByteArray? = key842 private var consumed = false843844 internal fun takeKey(): ByteArray? = synchronized(this) {845 if (consumed) return@synchronized null846 consumed = true847 ownedKey.also { ownedKey = null }848 }849850 internal fun discard() = synchronized(this) {851 if (consumed) return@synchronized852 consumed = true853 ownedKey?.let(VaultCrypto::zero)854 ownedKey = null855 }856 }857858 859860861862863864865 fun tryUnlockDuress(password: String): DuressUnlock? {866 val epoch = currentEpoch()867 val slot = store.loadDecoySlot() ?: return null868 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))869 return try {870 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)871 val payload = try {872 VaultCodec.decode(plaintext)873 } finally {874 VaultCrypto.zero(plaintext)875 }876 DuressUnlock(key, payload, epoch)877 } catch (_: Exception) {878 VaultCrypto.zero(key)879 null880 }881 }882883 884 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {885 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")886 if (currentEpoch() != unlock.epoch) {887 VaultCrypto.zero(key)888 return@withLock UnlockResult.Error("stale_unlock")889 }890 return try {891 val adopted = mutateIfCurrentEpoch(unlock.epoch) {892 derivedKey = key893 decoyMode = true894 entryList = unlock.payload.entries.toMutableList()895 duressConfigured = false896 recoveryWordHash = unlock.payload.recoveryWordHash897 recoveryWordsSet = unlock.payload.recoveryWordsSet898 sessionDataAvailable = true899 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)900 else VaultCrypto.generateRootKey()901 _settings.value = store.loadSettings()902 publish()903 _isUnlocked.value = true904 sessionEpoch = nextSessionEpoch(sessionEpoch)905 }906 if (adopted == null) {907 VaultCrypto.zero(key)908 UnlockResult.Error("stale_unlock")909 } else {910 UnlockResult.Success911 }912 } catch (e: Exception) {913 914 915 916 917 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }918 VaultCrypto.zero(key)919 UnlockResult.Error(e.message ?: "decoy_unlock_failed")920 }921 }922923 924 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()925926 927928929930931932 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {933 934 val expectedEpoch = mutateState {935 if (derivedKey != null && !decoyMode) sessionEpoch else null936 } ?: return false937 938 939 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }940 var key: ByteArray? = null941 var basePlaintext: ByteArray? = null942 return try {943 val salt = VaultCrypto.generateSalt()944 key = VaultCrypto.deriveKey(duressPassphrase, salt)945 946 val decoyRoot = VaultCrypto.generateRootKey()947 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }948 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)949 basePlaintext = VaultCodec.encode(decoyPayload)950 951 952 953 954 955 956 957 958 val slotA = store.loadSlotA()959 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0960 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!961 val blob = try {962 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))963 } finally {964 VaultCrypto.zero(plaintext)965 966 967 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)968 basePlaintext = null969 key?.let(VaultCrypto::zero)970 key = null971 }972 beforeDurableCommit()973 val ok = withEpochCommit(expectedEpoch) {974 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))975 } ?: false976 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {977 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }978 } else if (ok) {979 return false980 }981 ok982 } catch (_: Exception) {983 false984 } finally {985 986 987 key?.let(VaultCrypto::zero)988 basePlaintext?.let(VaultCrypto::zero)989 }990 }991992993 994995996997998999100010011002 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1003 if (!duressConfigured) return null1004 val slot = store.loadDecoySlot() ?: return null1005 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1006 }10071008 1009101010111012101310141015 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1016 var salt: ByteArray? = null1017 var blob: ByteArray? = null1018 var nonce: ByteArray? = null1019 var ct: ByteArray? = null1020 var tag: ByteArray? = null1021 try {1022 salt = unb64(slot.salt)1023 blob = unb64(slot.blob)1024 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1025 val ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1026 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1027 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1028 val browserSlot = BrowserSlotA(1029 version = slot.version,1030 salt = salt.joinToString("") { "%02x".format(it) },1031 nonce = nonce.joinToString("") { "%02x".format(it) },1032 ciphertext = ct.joinToString("") { "%02x".format(it) },1033 tag = tag.joinToString("") { "%02x".format(it) },1034 )1035 return BrowserSlotJson.encodeToString(BrowserSlotA.serializer(), browserSlot)1036 } finally {1037 salt?.let(VaultCrypto::zero)1038 blob?.let(VaultCrypto::zero)1039 nonce?.let(VaultCrypto::zero)1040 ct?.let(VaultCrypto::zero)1041 tag?.let(VaultCrypto::zero)1042 }1043 }10441045 1046104710481049105010511052105310541055 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1056 val b = BrowserSlot.parse(json) ?: return false1057 var stored: StoredVault? = null1058 var salt: ByteArray? = null1059 var nonce: ByteArray? = null1060 var ct: ByteArray? = null1061 var tag: ByteArray? = null1062 var blob: ByteArray? = null1063 try {1064 salt = unhex(b.salt)1065 nonce = unhex(b.nonce)1066 ct = unhex(b.ciphertext)1067 tag = unhex(b.tag)1068 blob = ByteArray(nonce.size + ct.size + tag.size).also {1069 System.arraycopy(nonce, 0, it, 0, nonce.size)1070 System.arraycopy(ct, 0, it, nonce.size, ct.size)1071 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1072 }1073 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1074 val expectedEpoch = sessionEpoch1075 beforeDurableCommit()1076 withEpochCommit(expectedEpoch) {1077 store.writeDecoySlot(stored)1078 } ?: false1079 } catch (_: Exception) {1080 false1081 } finally {1082 salt?.let(VaultCrypto::zero)1083 nonce?.let(VaultCrypto::zero)1084 ct?.let(VaultCrypto::zero)1085 tag?.let(VaultCrypto::zero)1086 blob?.let(VaultCrypto::zero)1087 }1088 }10891090 1091 suspend fun clearDuress(): Boolean = mutex.withLock {1092 1093 1094 1095 1096 val expectedEpoch = currentEpoch()1097 val replaced = withEpochCommit(expectedEpoch) {1098 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1099 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1100 } ?: false1101 if (!replaced) return@withLock false1102 1103 1104 if (!isSessionEpochCurrent(expectedEpoch)) return false1105 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1106 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1107 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1108 }1109 true1110 }11111112 1113 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1114 var current: Throwable? = error1115 while (current != null) {1116 if (current is javax.crypto.AEADBadTagException) return true1117 1118 1119 1120 1121 if (current is javax.crypto.BadPaddingException) return true1122 current = current.cause1123 }1124 return false1125 }11261127 1128 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1129 val expectedEpoch = currentEpoch()1130 val slotA = try {1131 store.loadSlotA()1132 } catch (_: Exception) {1133 return@withLock UnlockResult.Error("unlock_key_storage_failed")1134 } ?: return@withLock UnlockResult.Error("no_vault")1135 1136 1137 1138 1139 var stagedKey: ByteArray? = key.copyOf()1140 return try {1141 val encrypted = try {1142 unb64(slotA.blob)1143 } catch (_: Exception) {1144 return@withLock UnlockResult.Error("invalid_vault_blob")1145 }1146 1147 1148 1149 1150 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1151 return@withLock UnlockResult.Error("invalid_vault_blob")1152 }1153 val plaintext = try {1154 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1155 } catch (e: Exception) {1156 if (isGcmAuthenticationFailure(e)) {1157 return@withLock UnlockResult.WrongPassword1158 }1159 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1160 }1161 val payload = try {1162 VaultCodec.decode(plaintext)1163 } catch (_: Exception) {1164 return@withLock UnlockResult.Error("invalid_vault_payload")1165 } finally {1166 VaultCrypto.zero(plaintext)1167 }1168 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1169 val result = adoptSlotA(candidate, payload, expectedEpoch)1170 if (result is UnlockResult.Success) stagedKey = null1171 result1172 } finally {1173 stagedKey?.let(VaultCrypto::zero)1174 }1175 }11761177 fun lock() {1178 1179 1180 1181 1182 1183 1184 stateLock.lock()1185 try {1186 sessionEpoch = nextSessionEpoch(sessionEpoch)1187 clearSessionStateLocked()1188 } finally {1189 stateLock.unlock()1190 }1191 }11921193 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11941195 1196 1197 @VisibleForTesting1198 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11991200 120112021203120412051206120712081209121012111212 fun expectTransientBackground() {1213 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1214 }12151216 12171218121912201221 fun consumeTransientBackgroundSuppression(): Boolean {1222 val until = suppressNextBackgroundLockUntil1223 val active = until != 0L && nowMs() < until1224 suppressNextBackgroundLockUntil = 0L1225 return active1226 }12271228 12291230 12311232123312341235 12361237123812391240124112421243124412451246 1247 1248 1249 1250 1251 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12521253 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1254 if (decoyMode || !_isUnlocked.value) null1255 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1256 }12571258 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1259 mutateState { sessionEpoch == epoch }12601261 internal fun unlockedSessionEpoch(): Long? = mutateState {1262 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1263 }12641265 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12661267 126812691270127112721273127412751276 fun sessionRootKey(): ByteArray? = mutateState {1277 if (!sessionDataAvailable) null else rootKey?.copyOf()1278 }12791280 12811282 fun syncVaultId(): String? = mutateState {1283 if (decoyMode || !_isUnlocked.value) null1284 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1285 }12861287 12881289129012911292 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1293 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1294 ?: return false1295 installRootKey(generateRecoverableRootKey(), expectedEpoch)1296 }12971298 12991300130113021303130413051306130713081309131013111312131313141315 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1316 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1317 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1318 }1319 val expectedEpoch = mutateState {1320 when {1321 derivedKey == null -> null1322 decoyMode -> Long.MIN_VALUE1323 else -> sessionEpoch1324 }1325 }1326 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1327 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1328 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13291330 val incoming = hexToRootKey(rootKeyHex)1331 if (incoming.size != VaultCrypto.KEY_SIZE) {1332 VaultCrypto.zero(incoming)1333 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1334 }1335 1336 1337 1338 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1339 if (!roundTrips) {1340 VaultCrypto.zero(incoming)1341 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1342 }13431344 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1345 return if (installRootKey(incoming, expectedEpoch)) {1346 AdoptVaultResult.Success(vaultId)1347 } else {1348 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1349 }1350 }13511352 13531354135513561357 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1358 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1359 val bytes = hexToRootKey(rootKeyHex)1360 return try {1361 val words = rootKeyToWords(bytes)1362 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1363 } catch (_: Exception) {1364 null1365 } finally {1366 VaultCrypto.zero(bytes)1367 }1368 }13691370 13711372137313741375 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1376 1377 1378 1379 1380 1381 1382 data class Snapshot(1383 val key: ByteArray,1384 val priorRootKey: ByteArray,1385 val priorRecoveryHash: String?,1386 val priorRecoverySet: Boolean,1387 )1388 val snap = mutateIfCurrentEpoch(expectedEpoch) {1389 val k = derivedKey ?: return@mutateIfCurrentEpoch null1390 if (decoyMode) return@mutateIfCurrentEpoch null1391 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1392 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1393 } ?: run {1394 VaultCrypto.zero(nextRootKey)1395 return false1396 }1397 val priorSlot = store.loadSlotA() ?: run {1398 VaultCrypto.zero(snap.key)1399 VaultCrypto.zero(snap.priorRootKey)1400 VaultCrypto.zero(nextRootKey)1401 return false1402 }1403 val nextWords = rootKeyToWords(nextRootKey)14041405 return try {1406 beforeDurableCommit()1407 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1408 if (mutateIfCurrentEpoch(expectedEpoch) {1409 rootKey = nextRootKey1410 recoveryWordHash = recoveryWordsHash(nextWords)1411 recoveryWordsSet = false1412 } == null) throw IllegalStateException("stale_root_rotation")14131414 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1415 ?: throw IllegalStateException("recovery_escrow_failed")1416 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1417 ?: throw IllegalStateException("root_escrow_failed")1418 val plaintext = VaultCodec.encode(currentPayload())1419 val blob = try {1420 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1421 } finally {1422 VaultCrypto.zero(plaintext)1423 }1424 val nextSlot = priorSlot.copy(1425 blob = blob,1426 recoveryEscrow = nextRecoveryEscrow,1427 rootKeyEscrow = nextRootKeyEscrow,1428 )1429 val committed = withEpochCommit(expectedEpoch) {1430 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1431 try {1432 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1433 1434 1435 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1436 store.stampLocalMutation()1437 } catch (_: Exception) {1438 store.writeSlotA(priorSlot)1439 return@withEpochCommit false1440 }1441 true1442 } ?: false1443 if (!committed) throw IllegalStateException("storage_write_failed")1444 mutateIfCurrentEpoch(expectedEpoch) { publish() }1445 true1446 } catch (_: Exception) {1447 val restored = mutateIfCurrentEpoch(expectedEpoch) {1448 rootKey?.let(VaultCrypto::zero)1449 rootKey = snap.priorRootKey.copyOf()1450 recoveryWordHash = snap.priorRecoveryHash1451 recoveryWordsSet = snap.priorRecoverySet1452 }1453 if (restored != null) {1454 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1455 }1456 false1457 } finally {1458 VaultCrypto.zero(snap.key)1459 VaultCrypto.zero(snap.priorRootKey)1460 1461 1462 1463 val stillOwnedBySession = mutateState {1464 sessionEpoch == expectedEpoch && rootKey === nextRootKey1465 }1466 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1467 }1468 }14691470 14711472147314741475 fun currentVaultKey(): ByteArray? = mutateState {1476 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1477 }14781479 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1480 val expectedEpoch = currentEpoch()1481 val slotA = store.loadSlotA() ?: return false1482 var oldKey: ByteArray? = null1483 var newKey: ByteArray? = null1484 var plaintext: ByteArray? = null1485 return try {1486 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1487 1488 1489 1490 1491 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1492 val payload = try {1493 VaultCodec.decode(decrypted)1494 } finally {1495 VaultCrypto.zero(decrypted)1496 }1497 val newSalt = VaultCrypto.generateSalt()1498 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1499 1500 plaintext = VaultCodec.encode(payload)1501 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1502 1503 1504 1505 1506 1507 1508 1509 val next = StoredVault(1510 version = slotA.version,1511 salt = b64(newSalt),1512 blob = blob,1513 recoveryEscrow = slotA.recoveryEscrow,1514 rootKeyEscrow = slotA.rootKeyEscrow,1515 )1516 1517 1518 1519 1520 val priorB = store.loadSlotB()1521 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1522 1523 1524 1525 if (mutateIfCurrentEpoch(expectedEpoch) {1526 oldKey?.let(VaultCrypto::zero); oldKey = null1527 derivedKey?.let(VaultCrypto::zero)1528 derivedKey = newKey; newKey = null1529 } == null) return false1530 true1531 } catch (_: Exception) {1532 false1533 } finally {1534 oldKey?.let(VaultCrypto::zero)1535 newKey?.let(VaultCrypto::zero)1536 plaintext?.let(VaultCrypto::zero)1537 }1538 }15391540 1541154215431544 suspend fun destroyVault() = mutex.withLock {1545 lock()1546 store.clearVault()1547 store.clearDecoySlot()1548 }15491550 15511552 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1553 val epoch = requireUnlocked()1554 val stamp = VaultCrypto.monotonicEntryTimestamp()1555 val now = System.currentTimeMillis()1556 val entry = withDerivedLast4(draft.copy(1557 id = draft.id.ifBlank { UUID.randomUUID().toString() },1558 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1559 modifiedAt = stamp,1560 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1561 ))1562 1563 1564 val rollback = mutateCurrentSession(epoch) {1565 val r = entryList.toMutableList()1566 entryList.add(entry)1567 r1568 } ?: throw IllegalStateException("vault_locked")1569 persistOrRollback(rollback, epoch)1570 entry1571 }15721573 157415751576157715781579158015811582 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1583 val epoch = requireUnlocked()1584 if (drafts.isEmpty()) return 01585 val now = System.currentTimeMillis()1586 val ready = drafts.map { draft ->1587 val stamp = VaultCrypto.monotonicEntryTimestamp()1588 withDerivedLast4(1589 draft.copy(1590 id = draft.id.ifBlank { UUID.randomUUID().toString() },1591 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1592 modifiedAt = stamp,1593 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1594 ),1595 )1596 }1597 1598 val rollback = mutateCurrentSession(epoch) {1599 val r = entryList.toMutableList()1600 entryList.addAll(ready)1601 r1602 } ?: throw IllegalStateException("vault_locked")1603 persistOrRollback(rollback, epoch)1604 return drafts.size1605 }16061607 1608160916101611161216131614161516161617161816191620 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1621 val epoch = requireUnlocked()1622 val now = System.currentTimeMillis()1623 val ready = newEntries.map { draft ->1624 val stamp = VaultCrypto.monotonicEntryTimestamp()1625 withDerivedLast4(1626 draft.copy(1627 id = draft.id.ifBlank { UUID.randomUUID().toString() },1628 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1629 modifiedAt = stamp,1630 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1631 ),1632 )1633 }1634 1635 1636 1637 1638 1639 1640 val incomingIds = ready.map { it.id }.toHashSet()1641 val rollback = mutateCurrentSession(epoch) {1642 val r = entryList.toMutableList()1643 val replaced = entryList.map { entry ->1644 if (entry.deletedAt != null) return@map entry1645 if (entry.id in incomingIds) return@map null1646 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1647 entry.copy(deletedAt = stamp, modifiedAt = stamp)1648 }.filterNotNull()1649 entryList = (replaced + ready).toMutableList()1650 r1651 } ?: throw IllegalStateException("vault_locked")1652 persistOrRollback(rollback, epoch)1653 return ready.size1654 }16551656 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1657 val epoch = requireUnlocked()1658 1659 1660 1661 1662 val prepared = mutateCurrentSession(epoch) {1663 val idx = entryList.indexOfFirst { it.id == patch.id }1664 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1665 val current = entryList[idx]1666 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1667 1668 val passwordChanged = patch.password != null && patch.password != current.password1669 1670 1671 1672 1673 1674 1675 val priorPassword = current.password1676 val shouldCapture = passwordChanged &&1677 !priorPassword.isNullOrEmpty() &&1678 current.passwordHistory?.firstOrNull()?.password != priorPassword1679 val nextHistory = if (shouldCapture) {1680 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1681 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1682 } else {1683 current.passwordHistory1684 }1685 val updated = withDerivedLast4(patch.copy(1686 createdAt = current.createdAt,1687 modifiedAt = stamp,1688 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1689 passwordHistory = nextHistory,1690 ))1691 val rollback = entryList.toMutableList()1692 entryList[idx] = updated1693 updated to rollback1694 } ?: throw IllegalStateException("vault_locked")1695 val (updated, rollback) = prepared1696 persistOrRollback(rollback, epoch)1697 updated1698 }16991700 1701 suspend fun deleteEntry(id: String) = mutex.withLock {1702 val epoch = requireUnlocked()1703 1704 1705 val rollback = mutateCurrentSession(epoch) {1706 val idx = entryList.indexOfFirst { it.id == id }1707 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1708 val current = entryList[idx]1709 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1710 val prior = entryList.toMutableList()1711 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1712 prior1713 } ?: throw IllegalStateException("vault_locked")1714 persistOrRollback(rollback, epoch)1715 }17161717 suspend fun restoreEntry(id: String) = mutex.withLock {1718 val epoch = requireUnlocked()1719 val rollback = mutateCurrentSession(epoch) {1720 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1721 if (idx == -1) throw IllegalStateException("deleted entry not found")1722 val current = entryList[idx]1723 val prior = entryList.toMutableList()1724 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1725 prior1726 } ?: throw IllegalStateException("vault_locked")1727 persistOrRollback(rollback, epoch)1728 }17291730 1731 suspend fun purgeEntry(id: String) = mutex.withLock {1732 val epoch = requireUnlocked()1733 val rollback = mutateCurrentSession(epoch) {1734 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1735 if (idx == -1) throw IllegalStateException("deleted entry not found")1736 val prior = entryList.toMutableList()1737 entryList.removeAt(idx)1738 prior1739 } ?: throw IllegalStateException("vault_locked")1740 persistOrRollback(rollback, epoch)1741 }17421743 1744 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1745 val epoch = requireUnlocked()1746 1747 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1748 val r = mutateCurrentSession(epoch) {1749 val rollback = entryList.toMutableList()1750 val before = entryList.size1751 entryList.removeAll { it.deletedAt != null }1752 R(rollback, before - entryList.size)1753 } ?: throw IllegalStateException("vault_locked")1754 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1755 r.removed1756 }17571758 1759 suspend fun countDuplicateEntries(): Int = mutex.withLock {1760 val epoch = requireUnlocked()1761 1762 1763 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1764 ?: throw IllegalStateException("vault_locked")1765 }17661767 17681769177017711772 suspend fun deduplicateEntries(): Int = mutex.withLock {1773 val epoch = requireUnlocked()1774 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1775 val ready = mutateCurrentSession(epoch) {1776 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1777 val active = entryList.filter { it.deletedAt == null }1778 val keepIds = mutableSetOf<String>()1779 active.groupBy { it.duplicateKey() }.values.forEach { group ->1780 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1781 }1782 val rollback = entryList.toMutableList()1783 var removed = 01784 entryList = entryList.map { entry ->1785 if (entry.deletedAt == null && entry.id !in keepIds) {1786 removed++1787 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1788 entry.copy(deletedAt = stamp, modifiedAt = stamp)1789 } else {1790 entry1791 }1792 }.toMutableList()1793 R(rollback, removed)1794 } ?: throw IllegalStateException("vault_locked")1795 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1796 ready.removed1797 }17981799 fun getEntries(): List<VaultEntry> = mutateState {1800 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1801 }1802 fun getEntry(id: String): VaultEntry? = mutateState {1803 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1804 }1805 fun getDeletedEntries(): List<VaultEntry> = mutateState {1806 if (!sessionDataAvailable) emptyList()1807 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1808 }18091810 1811 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1812 return mutateState {1813 if (!sessionDataAvailable) return@mutateState emptyList()1814 val active = entryList.filter { it.deletedAt == null }1815 if (tagFilter == null) return@mutateState active1816 val needle = tagFilter.trim().lowercase()1817 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1818 }1819 }18201821 1822 fun getDistinctTags(): List<String> = mutateState {1823 if (!sessionDataAvailable) return@mutateState emptyList()1824 entryList.asSequence()1825 .filter { it.deletedAt == null }1826 .flatMap { it.tags?.asSequence() ?: emptySequence() }1827 .map { it.trim().lowercase() }1828 .filter { it.isNotEmpty() }1829 .distinct()1830 .sorted()1831 .toList()1832 }18331834 183518361837183818391840 fun getDistinctFieldValues(field: String): List<String> {1841 val select: (VaultEntry) -> String? = when (field) {1842 "username" -> { e -> e.username }1843 "email" -> { e -> e.email }1844 else -> return emptyList()1845 }1846 return mutateState {1847 if (!sessionDataAvailable) return@mutateState emptyList()1848 entryList.asSequence()1849 .filter { it.deletedAt == null }1850 .mapNotNull { select(it)?.trim() }1851 .filter { it.isNotEmpty() }1852 .groupingBy { it.lowercase() }1853 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1854 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1855 }1856 .values1857 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1858 .map { it.first }1859 }1860 }18611862 fun getTombstones(): List<Tombstone> = mutateState {1863 if (!sessionDataAvailable) emptyList()1864 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1865 }1866 fun getVaultVersion(): Long = store.version()1867 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18681869 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1870 val expectedEpoch = currentEpoch()1871 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1872 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1873 1874 1875 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1876 val priorA = store.loadSlotA()1877 val priorB = store.loadSlotB()1878 val priorD = store.loadDecoySlot()1879 1880 1881 1882 1883 1884 val priorHistory = store.loadVaultHistory()1885 1886 1887 1888 1889 beforeDurableCommit()1890 if (!takeVaultSnapshot("restore", expectedEpoch)) {1891 return false1892 }1893 1894 1895 1896 val nextD: StoredVault = restoredD ?: run {1897 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161898 val fakeBlob = ByteArray(target).also(rng::nextBytes)1899 val fakeSalt = VaultCrypto.generateSalt()1900 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1901 }1902 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1903 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1904 return false1905 }1906 lock()1907 true1908 }19091910 19111912191319141915191619171918191919201921 private fun commitRestoreAtomic(1922 nextA: StoredVault,1923 nextB: String,1924 nextD: StoredVault,1925 priorA: StoredVault?,1926 priorB: String?,1927 priorD: StoredVault?,1928 expectedEpoch: Long,1929 ): Boolean {1930 return withEpochCommit(expectedEpoch) {1931 if (!store.writeSlotA(nextA)) return@withEpochCommit false1932 1933 1934 1935 try {1936 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1937 1938 1939 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1940 store.stampLocalMutation()1941 } catch (_: Exception) {1942 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1943 return@withEpochCommit false1944 }1945 true1946 } ?: false1947 }19481949 19501951 19521953195419551956 suspend fun applyMergedRemoteEntries(1957 remoteEntries: List<VaultEntry>,1958 remoteTombstones: List<Tombstone>,1959 expectedEpoch: Long? = null,1960 ): Int = mutex.withLock {1961 val epoch = expectedEpoch ?: requireUnlocked()1962 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01963 1964 1965 1966 1967 1968 val mergeNow = System.currentTimeMillis()1969 val clamped = remoteEntries.map { entry ->1970 entry.copy(1971 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1972 deletedAt = entry.deletedAt?.let {1973 SyncLimits.clampSyncTimestamp(it, mergeNow)1974 },1975 )1976 }1977 val tombMap = remoteTombstones.associate {1978 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1979 }1980 1981 1982 val applied = mutateCurrentSession(epoch) {1983 var n = 01984 val byId = entryList.associateBy { it.id }.toMutableMap()19851986 for (remote in clamped) {1987 val tomb = tombMap[remote.id]1988 if (tomb != null && tomb >= remote.modifiedAt) continue1989 val local = byId[remote.id]1990 if (local == null) {1991 entryList.add(remote); byId[remote.id] = remote; n++1992 } else if (remote.modifiedAt > local.modifiedAt) {1993 val i = entryList.indexOfFirst { it.id == local.id }1994 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1995 }1996 }19971998 for ((id, deletedAt) in tombMap) {1999 val local = byId[id] ?: continue2000 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2001 if (deletedAt < local.modifiedAt) continue2002 val i = entryList.indexOfFirst { it.id == id }2003 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2004 }2005 n2006 } ?: return@withLock 020072008 if (applied > 0) {2009 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2010 mutateIfCurrentEpoch(epoch) { publish() }2011 }2012 applied2013 }20142015 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2016 2017 2018 2019 val epoch = requireUnlocked()2020 val prior = mutateCurrentSession(epoch) {2021 val old = _settings.value2022 _settings.value = settings2023 old2024 } ?: throw IllegalStateException("vault_locked")20252026 val committed = try {2027 beforeDurableCommit()2028 2029 2030 2031 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2032 } catch (_: Exception) {2033 false2034 }2035 if (!committed) {2036 2037 2038 2039 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2040 throw IllegalStateException("settings_write_failed")2041 }2042 }20432044 20452046 2047 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20482049 20502051205220532054 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2055 val epoch = requireUnlocked()2056 2057 2058 2059 2060 2061 2062 val prior = mutateCurrentSession(epoch) {2063 val priorPair = recoveryWordHash to recoveryWordsSet2064 recoveryWordHash = recoveryWordsHash(words)2065 recoveryWordsSet = true2066 priorPair2067 } ?: return@withLock false2068 2069 2070 2071 2072 val persisted = runCatching {2073 persistSlotAWithEscrows(2074 buildRecoveryEscrow(words),2075 buildRootKeyEscrow(words),2076 expectedEpoch = epoch,2077 )2078 }.getOrDefault(false)2079 if (!persisted) {2080 mutateIfCurrentEpoch(epoch) {2081 recoveryWordHash = prior.first2082 recoveryWordsSet = prior.second2083 }2084 }2085 persisted2086 }20872088 2089 fun verifyRecoveryWords(words: List<String>): Boolean {2090 return mutateState {2091 val stored = recoveryWordHash ?: return@mutateState false2092 stored == recoveryWordsHash(words)2093 }2094 }20952096 20972098 2099 fun hasRecoveryEscrow(): Boolean =2100 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21012102 210321042105210621072108210921102111211221132114 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2115 val expectedEpoch = currentEpoch()2116 val slotA = store.loadSlotA() ?: return false2117 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21182119 2120 2121 val fullEscrow = slotA.recoveryEscrow2122 if (fullEscrow != null) {2123 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2124 if (material != null) {2125 var keyBytes: ByteArray? = null2126 var plaintext: ByteArray? = null2127 var candidateRoot: ByteArray? = null2128 var stateCommitStarted = false2129 try {2130 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2131 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2132 val payload = VaultCodec.decode(plaintext)2133 2134 2135 2136 2137 val candidateSettings = store.loadSettings()2138 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2139 val decodedRoot = unb64(payload.rootKeyB64)2140 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2141 2142 2143 2144 VaultCrypto.zero(decodedRoot)2145 throw IllegalArgumentException("invalid recovery root key")2146 }2147 decodedRoot2148 } else {2149 generateRecoverableRootKey()2150 }2151 stateCommitStarted = true2152 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2153 clearSessionStateLocked()2154 entryList = payload.entries.toMutableList()2155 duressConfigured = payload.duressConfigured2156 recoveryWordHash = payload.recoveryWordHash2157 recoveryWordsSet = payload.recoveryWordsSet2158 rootKey = candidateRoot2159 candidateRoot = null2160 _settings.value = candidateSettings2161 publish()2162 2163 2164 sessionDataAvailable = true2165 _isUnlocked.value = false2166 true2167 }2168 if (adopted == true) return@withLock true2169 } catch (_: Exception) {2170 2171 2172 2173 2174 if (stateCommitStarted) {2175 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2176 }2177 } finally {2178 plaintext?.let(VaultCrypto::zero)2179 VaultCrypto.zero(material)2180 keyBytes?.let(VaultCrypto::zero)2181 candidateRoot?.let(VaultCrypto::zero)2182 }2183 }2184 }21852186 2187 2188 2189 val rootEscrow = slotA.rootKeyEscrow2190 if (rootEscrow != null) {2191 var candidateRoot: ByteArray? = null2192 var stateCommitStarted = false2193 try {2194 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2195 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2196 val candidateSettings = store.loadSettings()2197 stateCommitStarted = true2198 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2199 clearSessionStateLocked()2200 entryList = mutableListOf()2201 duressConfigured = false2202 recoveryWordHash = recoveryWordsHash(words)2203 recoveryWordsSet = true2204 rootKey = candidateRoot2205 candidateRoot = null2206 _settings.value = candidateSettings2207 publish()2208 sessionDataAvailable = true2209 _isUnlocked.value = false2210 true2211 }2212 if (adopted == true) return@withLock true2213 } catch (_: Exception) {2214 if (stateCommitStarted) {2215 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2216 }2217 } finally {2218 candidateRoot?.let(VaultCrypto::zero)2219 }2220 }2221 return false2222 }22232224 222522262227222822292230223122322233 suspend fun resetPasswordWithRecoveryWords(2234 words: List<String>,2235 newPassword: String,2236 ): Boolean = mutex.withLock {2237 val expectedEpoch = currentEpoch()2238 2239 2240 2241 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2242 val priorA = store.loadSlotA() ?: return false2243 var newKey: ByteArray? = null2244 var plaintext: ByteArray? = null2245 return try {2246 val newSalt = VaultCrypto.generateSalt()2247 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2248 plaintext = VaultCodec.encode(currentPayload())2249 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2250 2251 2252 2253 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2254 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2255 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2256 val next = StoredVault(2257 version = priorA.version,2258 salt = b64(newSalt),2259 blob = blob,2260 recoveryEscrow = freshRecovery,2261 rootKeyEscrow = freshRootKey,2262 )2263 2264 2265 2266 val priorB = store.loadSlotB()2267 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2268 2269 2270 2271 if (mutateIfCurrentEpoch(expectedEpoch) {2272 derivedKey?.let(VaultCrypto::zero)2273 derivedKey = newKey; newKey = null2274 publish()2275 _isUnlocked.value = true2276 } == null) return false2277 true2278 } catch (_: Exception) {2279 false2280 } finally {2281 newKey?.let(VaultCrypto::zero)2282 plaintext?.let(VaultCrypto::zero)2283 }2284 }22852286 228722882289229022912292 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2293 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2294 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2295 var keyBytes: ByteArray? = null2296 var plaintext: ByteArray? = null2297 return try {2298 val salt = VaultCrypto.generateSalt()2299 val recoveryKey = deriveRecoveryKey(material, salt)2300 keyBytes = recoveryKey.encoded2301 plaintext = VaultCodec.encode(currentPayload())2302 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2303 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2304 } finally {2305 VaultCrypto.zero(material)2306 keyBytes?.let(VaultCrypto::zero)2307 plaintext?.let(VaultCrypto::zero)2308 }2309 }23102311 23122313231423152316231723182319 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2320 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2321 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2322 val rk = mutateState { rootKey?.copyOf() } ?: run {2323 VaultCrypto.zero(material)2324 return null2325 }2326 var keyBytes: ByteArray? = null2327 var plaintext: ByteArray? = null2328 return try {2329 val salt = VaultCrypto.generateSalt()2330 val recoveryKey = deriveRecoveryKey(material, salt)2331 keyBytes = recoveryKey.encoded2332 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2333 2334 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2335 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2336 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2337 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2338 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2339 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2340 } finally {2341 VaultCrypto.zero(rk)2342 VaultCrypto.zero(material)2343 keyBytes?.let(VaultCrypto::zero)2344 plaintext?.let(VaultCrypto::zero)2345 }2346 }23472348 2349 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2350 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2351 var keyBytes: ByteArray? = null2352 var pt: ByteArray? = null2353 return try {2354 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2355 keyBytes = recoveryKey.encoded2356 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2357 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2358 hexToRootKey(String(pt, Charsets.US_ASCII))2359 } catch (_: Exception) {2360 null2361 } finally {2362 2363 2364 2365 VaultCrypto.zero(material)2366 keyBytes?.let(VaultCrypto::zero)2367 pt?.let(VaultCrypto::zero)2368 }2369 }23702371 237223732374237523762377 private fun persistSlotAWithEscrows(2378 recoveryEscrow: RecoveryEscrow?,2379 rootKeyEscrow: RootKeyEscrow?,2380 expectedEpoch: Long? = null,2381 ): Boolean {2382 val snapshot = snapshotForPersist(expectedEpoch)2383 ?: throw IllegalStateException("vault_locked_or_stale")2384 if (snapshot.decoy) {2385 VaultCrypto.zero(snapshot.key)2386 return false2387 }2388 try {2389 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2390 val plaintext = VaultCodec.encode(snapshot.payload)2391 val blob = try {2392 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2393 } finally {2394 2395 2396 VaultCrypto.zero(plaintext)2397 }2398 val next = prior.copy(2399 blob = blob,2400 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2401 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2402 )2403 beforeDurableCommit()2404 return withEpochCommit(snapshot.epoch) {2405 if (!store.writeSlotA(next)) return@withEpochCommit false2406 try {2407 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2408 2409 2410 2411 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2412 } catch (e: Exception) {2413 store.writeSlotA(prior)2414 throw e2415 }2416 true2417 } ?: false2418 } finally {2419 VaultCrypto.zero(snapshot.key)2420 }2421 }24222423 24242425 private fun requireUnlocked(): Long {2426 2427 2428 2429 stateLock.lock()2430 try {2431 if (derivedKey == null) throw IllegalStateException("vault_locked")2432 return sessionEpoch2433 } finally {2434 stateLock.unlock()2435 }2436 }24372438 2439 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24402441 private fun countDuplicateEntriesLocked(): Int {2442 val seen = mutableMapOf<String, VaultEntry>()2443 var duplicates = 02444 for (entry in entryList.filter { it.deletedAt == null }) {2445 val key = entry.duplicateKey()2446 val existing = seen[key]2447 if (existing == null) {2448 seen[key] = entry2449 } else {2450 duplicates++2451 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2452 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2453 if (entryStamp > existingStamp) seen[key] = entry2454 }2455 }2456 return duplicates2457 }24582459 private fun VaultEntry.duplicateKey(): String {2460 if (type == "note") return "note|${title.lowercase()}"2461 val passkeyData = passkey2462 if (passkeyData != null) {2463 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2464 }2465 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2466 }24672468 246924702471247224732474 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2475 val epoch = expectedEpoch ?: currentEpoch()2476 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2477 val slotA = store.loadSlotA() ?: return false2478 val vaultA = VaultCodec.encodeSlot(slotA)2479 val vaultB = store.loadSlotB() ?: slotA.blob2480 2481 2482 2483 2484 2485 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2486 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162487 val fakeBlob = ByteArray(target).also(rng::nextBytes)2488 val fakeSalt = VaultCrypto.generateSalt()2489 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2490 }2491 val history = store.loadVaultHistory()2492 val latest = history.snapshots.firstOrNull()2493 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2494 val entryCount = mutateIfCurrentEpoch(epoch) {2495 if (derivedKey != null && !decoyMode) {2496 bucketEntryCount(entryList.count { it.deletedAt == null })2497 } else {2498 null2499 }2500 } ?: return false2501 return try {2502 val snapshot = VaultSnapshot(2503 id = UUID.randomUUID().toString(),2504 savedAt = System.currentTimeMillis(),2505 reason = reason,2506 vault_a = vaultA,2507 vault_b = vaultB,2508 vault_d = vaultD,2509 entryCount = entryCount,2510 )2511 2512 2513 2514 withEpochCommit(epoch) {2515 store.writeVaultHistory(2516 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2517 )2518 } ?: false2519 } catch (_: Exception) {2520 false2521 }2522 }25232524 private fun bucketEntryCount(n: Int): Int = when {2525 n <= 0 -> 02526 n <= 10 -> 102527 n <= 25 -> 252528 n <= 50 -> 502529 n <= 100 -> 1002530 n <= 250 -> 2502531 n <= 500 -> 5002532 else -> ((n + 499) / 500) * 5002533 }25342535 253625372538253925402541254225432544 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2545 if (!entry.isCard()) return entry2546 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2547 val derived = if (digits.length >= 4) digits.takeLast(4) else null2548 return entry.copy(last4 = derived ?: entry.last4)2549 }25502551 255225532554255525562557 private fun autoPurgeExpiredTrash() {2558 val days = _settings.value.trashRetentionDays ?: return2559 if (days <= 0) return2560 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2561 val before = entryList.size2562 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2563 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2564 }25652566 2567 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25682569 private fun currentPayloadLocked(): VaultPayload =2570 VaultPayload(2571 entries = entryList.toList(),2572 duressConfigured = duressConfigured,2573 rootKeyB64 = rootKey?.let(::b64) ?: "",2574 recoveryWordHash = recoveryWordHash,2575 recoveryWordsSet = recoveryWordsSet,2576 )25772578 25792580258125822583 private fun ensureRootKey(payload: VaultPayload) {2584 if (payload.rootKeyB64.isNotBlank()) {2585 rootKey = unb64(payload.rootKeyB64)2586 return2587 }2588 rootKey = generateRecoverableRootKey()2589 runCatching { persistSlotA(bumpVersion = false) }2590 }25912592 private fun publish() {2593 _entries.value = entryList.filter { it.deletedAt == null }2594 }25952596 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2597 var plaintext = VaultCodec.encode(currentPayload())2598 return try {2599 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2600 StoredVault(version = 1, salt = b64(salt), blob = blob)2601 } finally {2602 2603 2604 2605 VaultCrypto.zero(plaintext)2606 }2607 }26082609 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2610 2611 2612 2613 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2614 val noise = ByteArray(approxBytes).also(rng::nextBytes)2615 return store.writeSlotB(b64(noise))2616 }26172618 261926202621 private fun matchingCiphertextLength(slotA: StoredVault): Int =2622 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26232624 26252626262726282629 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2630 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162631 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2632 val fakeSalt = VaultCrypto.generateSalt()2633 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2634 }26352636 263726382639264026412642 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2643 val isConfigured = mutateState { duressConfigured }2644 return if (isConfigured) true else writeFakeDuressSlot(slotA)2645 }26462647 2648264926502651265226532654 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2655 val salt = unhex(b.salt)2656 val nonce = unhex(b.nonce)2657 val ct = unhex(b.ciphertext)2658 val tag = unhex(b.tag)2659 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2660 System.arraycopy(nonce, 0, it, 0, nonce.size)2661 System.arraycopy(ct, 0, it, nonce.size, ct.size)2662 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2663 }2664 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2665 }26662667 2668 private fun persistSlotA(2669 bumpVersion: Boolean,2670 snapshotReason: String? = "local_save",2671 expectedEpoch: Long? = null,2672 ): Boolean {2673 val snapshot = snapshotForPersist(expectedEpoch)2674 ?: throw IllegalStateException("vault_locked_or_stale")2675 return try {2676 2677 if (snapshot.decoy) {2678 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2679 val plaintext = VaultCodec.encode(snapshot.payload)2680 val blob = try {2681 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2682 } finally {2683 2684 2685 VaultCrypto.zero(plaintext)2686 }2687 beforeDurableCommit()2688 return withEpochCommit(snapshot.epoch) {2689 store.writeDecoySlot(prior.copy(blob = blob))2690 } ?: false2691 }2692 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2693 2694 2695 2696 2697 val priorB = store.loadSlotB()2698 beforeDurableCommit()2699 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2700 val plaintext = VaultCodec.encode(snapshot.payload)2701 val blob = try {2702 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2703 } finally {2704 2705 2706 VaultCrypto.zero(plaintext)2707 }2708 val next = prior.copy(blob = blob)2709 2710 2711 2712 2713 withEpochCommit(snapshot.epoch) {2714 if (!store.writeSlotA(next)) return@withEpochCommit false2715 try {2716 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2717 2718 2719 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2720 if (bumpVersion) store.stampLocalMutation()2721 } catch (e: Exception) {2722 store.writeSlotA(prior)2723 if (priorB != null) runCatching { store.writeSlotB(priorB) }2724 throw e2725 }2726 true2727 } ?: false2728 } finally {2729 2730 2731 VaultCrypto.zero(snapshot.key)2732 }2733 }27342735 2736273727382739274027412742 private fun commitVaultRotation(2743 priorA: StoredVault,2744 priorB: String?,2745 nextA: StoredVault,2746 expectedEpoch: Long,2747 ): Boolean {2748 beforeDurableCommit()2749 return withEpochCommit(expectedEpoch) {2750 if (!store.writeSlotA(nextA)) return@withEpochCommit false2751 2752 2753 2754 try {2755 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2756 2757 2758 2759 2760 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2761 store.stampLocalMutation()2762 } catch (_: Exception) {2763 rollbackRotation(priorA, priorB)2764 return@withEpochCommit false2765 }2766 true2767 } ?: false2768 }27692770 27712772277327742775 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2776 val failures = mutableListOf<String>()2777 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2778 .onFailure { failures += "writeSlotA: $it" }2779 if (priorB != null) {2780 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2781 .onFailure { failures += "writeSlotB: $it" }2782 }2783 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2784 }27852786 2787 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2788 try {2789 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2790 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2791 throw IllegalStateException("storage_write_failed")2792 }2793 } catch (e: Exception) {2794 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2795 throw e2796 }2797 2798 2799 2800 mutateIfCurrentEpoch(expectedEpoch) { publish() }2801 }2802}