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)343536private const val PASSWORD_HISTORY_MAX = 5037private const val VAULT_HISTORY_MAX = 538394041424344private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4546sealed interface UnlockResult {47 data object Success : UnlockResult48 data object WrongPassword : UnlockResult49 data class Error(val reason: String) : UnlockResult50}5152sealed interface CreateVaultResult {53 data object Success : CreateVaultResult54 data class Error(val reason: String) : CreateVaultResult55}5657sealed interface AdoptVaultResult {58 data class Success(val vaultId: String) : AdoptVaultResult59 data class Error(val reason: String) : AdoptVaultResult60}616263646566676869707172737475@Singleton76class VaultRepository @Inject constructor(77 private val store: VaultStorage,78) {79 private val mutex = Mutex()80 81 82 83 84 85 86 87 private val stateLock = ReentrantLock()88 private val rng = SecureRandom()8990 9192939495 private var sessionEpoch: Long = 0L9697 private fun nextSessionEpoch(epoch: Long): Long =98 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L99100 101 @VisibleForTesting102 internal var beforeDurableCommit: () -> Unit = {}103104 105 @VisibleForTesting106 internal var afterCodexRollback: () -> Unit = {}107108 109110111112113 private inline fun <T> mutateState(block: () -> T): T {114 stateLock.lock()115 return try { block() } finally { stateLock.unlock() }116 }117118 119 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {120 stateLock.lock()121 return try {122 if (sessionEpoch != epoch) null else block()123 } finally {124 stateLock.unlock()125 }126 }127128 129 @VisibleForTesting130 internal var beforeSessionEpochCommit: () -> Unit = {}131132 133134135136137 @VisibleForTesting138 internal var beforeSessionStateMutation: () -> Unit = {}139140 141142143144145146 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {147 beforeSessionEpochCommit()148 return withEpochCommit(epoch, block)149 }150151 private fun currentEpoch(): Long = mutateState { sessionEpoch }152153 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =154 mutateState { if (sessionEpoch == epoch) block() else null }155156 157 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {158 beforeSessionStateMutation()159 return mutateIfCurrentEpoch(epoch, block)160 }161162 private data class PersistSnapshot(163 val epoch: Long,164 val key: ByteArray,165 val payload: VaultPayload,166 val decoy: Boolean,167 )168169 170 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {171 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null172 val liveKey = derivedKey ?: return@mutateState null173 PersistSnapshot(174 epoch = sessionEpoch,175 key = liveKey.copyOf(),176 payload = currentPayloadLocked(),177 decoy = decoyMode,178 )179 }180181 private var derivedKey: ByteArray? = null182 private var rootKey: ByteArray? = null183 private var entryList: MutableList<VaultEntry> = mutableListOf()184 private var duressConfigured: Boolean = false185 private var recoveryWordHash: String? = null186 private var recoveryWordsSet: Boolean = false187 188 private var sessionDataAvailable: Boolean = false189 190 private var decoyMode: Boolean = false191192 private val _isUnlocked = MutableStateFlow(false)193 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()194195 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())196 197 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()198199 private val _settings = MutableStateFlow(VaultSettings())200 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()201202 203 private fun clearSessionStateLocked() {204 derivedKey?.let(VaultCrypto::zero)205 rootKey?.let(VaultCrypto::zero)206 derivedKey = null207 rootKey = null208 entryList = mutableListOf()209 duressConfigured = false210 recoveryWordHash = null211 recoveryWordsSet = false212 sessionDataAvailable = false213 decoyMode = false214 _settings.value = VaultSettings()215 _entries.value = emptyList()216 _isUnlocked.value = false217 }218219 fun hasVault(): Boolean = store.hasVault()220221 222223 224225226227228229230 fun codexVaultBlobBase64(): String? {231 val slotA = store.loadSlotA() ?: return null232 233 234 val slotD = store.loadDecoySlot() ?: return null235 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""236 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))237 }238239 240241242243244 245 private fun JsonElement?.asSlotJson(): String? = when {246 this == null || this is JsonNull -> null247 this is JsonPrimitive && isString -> content248 else -> toString()249 }250251 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {252 val expectedEpoch = currentEpoch()253 var candidateKey: ByteArray? = null254 var plaintext: ByteArray? = null255 return try {256 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)257 258 259 260 261 262 263 264 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject265 ?: return UnlockResult.Error("invalid_codex_blob")266 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")267 268 269 270 271 272 val slotA = VaultCodec.decodeSlot(slotJson)273 ?: BrowserSlot.parse(slotJson)?.let {274 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword275 }276 ?: return UnlockResult.Error("invalid_codex_blob")277 278 279 280 281 282 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))283 plaintext = decryptSlotABlob(slotA, candidateKey)284 285 286 287 288 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {289 VaultCrypto.zero(candidateKey)290 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))291 plaintext = decryptSlotABlob(slotA, candidateKey)292 }293 if (plaintext == null) return UnlockResult.WrongPassword294 val payload = try {295 VaultCodec.decode(plaintext)296 } catch (_: Exception) {297 return UnlockResult.Error("invalid_codex_blob")298 }299300 val priorA = store.loadSlotA()301 val priorB = store.loadSlotB()302 val priorD = store.loadDecoySlot()303 304 305 306 307 308 val codexDuressJson = wrapper["b"].asSlotJson()309 val codexDuress: StoredVault? = codexDuressJson?.let {310 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->311 312 313 314 315 316 317 browserDuressPassthrough(b)318 }319 }320321 beforeDurableCommit()322 val committed = runCatching {323 withEpochCommit(expectedEpoch) {324 try {325 326 327 328 329 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")330 331 332 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")333 334 335 val duressOk = if (codexDuress != null) {336 store.writeDecoySlot(codexDuress)337 } else {338 writeFakeDuressSlot(slotA)339 }340 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")341 store.stampLocalMutation()342 true343 } catch (_: Exception) {344 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))345 346 347 348 afterCodexRollback()349 false350 }351 } ?: false352 }.getOrDefault(false)353 if (!committed) {354 return UnlockResult.Error("storage_write_failed")355 }356357 val ownedKey = candidateKey!!358 candidateKey = null 359 adoptSlotA(ownedKey, payload, expectedEpoch)360 } catch (e: Exception) {361 362 363 364 365 366 367 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))368 UnlockResult.Error(e.message ?: "restore_failed")369 } finally {370 plaintext?.let(VaultCrypto::zero)371 candidateKey?.let(VaultCrypto::zero)372 }373 }374375 376 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =377 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()378379 380381382383384385386387388 private fun rollbackRestoreLocked(389 priorA: StoredVault?,390 priorB: String?,391 priorD: StoredVault? = null,392 ): List<String> {393 val failures = mutableListOf<String>()394 if (priorA == null) {395 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }396 } else {397 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }398 .onFailure { failures += "writeSlotA: $it" }399 if (priorB != null) {400 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }401 .onFailure { failures += "writeSlotB: $it" }402 }403 404 405 406 if (priorD != null) {407 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }408 .onFailure { failures += "writeDecoySlot: $it" }409 } else {410 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }411 }412 }413 return failures414 }415416 private fun reportRollbackFailures(failures: List<String>) {417 418 419 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }420 }421422 423 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {424 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)425 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()426 val window = offset?.let {427 val from = (it - 40).coerceAtLeast(0)428 val to = (it + 40).coerceAtMost(wrapperJson.length)429 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")430 }431 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()432 val slot = wrapper?.get("a")433 buildString {434 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)435 append(" | blobBase64=").append(vaultBlobBase64.length)436 append(" wrapperChars=").append(wrapperJson.length)437 append(" wrapperParses=").append(wrapper != null)438 append(" keys=").append(wrapper?.keys)439 append(" slotAKind=").append(440 when {441 slot == null -> "absent"442 slot is JsonPrimitive && slot.isString -> "string"443 slot is JsonObject -> "object:" + slot.keys444 else -> slot::class.simpleName445 },446 )447 if (window != null) append(" masked@").append(offset).append("=").append(window)448 }449 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }450451 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {452 val expectedEpoch = currentEpoch()453 var key: ByteArray? = null454 return try {455 val salt = VaultCrypto.generateSalt()456 key = VaultCrypto.deriveKey(password, salt)457 if (mutateIfCurrentEpoch(expectedEpoch) {458 entryList = mutableListOf()459 duressConfigured = false460 recoveryWordHash = null461 recoveryWordsSet = false462 decoyMode = false463 sessionDataAvailable = false464 rootKey = generateRecoverableRootKey()465 } == null) return CreateVaultResult.Error("stale_create")466 467 468 469 470 val slotA = sealSlotA(key!!, salt)471 472 473 beforeDurableCommit()474 val created = withEpochCommit(expectedEpoch) {475 if (!store.writeSlotA(slotA)) return@withEpochCommit false476 try {477 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")478 479 480 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")481 store.stampLocalMutation()482 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")483 true484 } catch (_: Exception) {485 486 487 store.clearVault()488 false489 }490 } ?: false491 if (!created) {492 lock()493 return CreateVaultResult.Error("storage_write_failed")494 }495 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")496 if (mutateIfCurrentEpoch(expectedEpoch) {497 derivedKey = sessionKey498 key = null499 publish()500 _isUnlocked.value = true501 sessionDataAvailable = true502 sessionEpoch = nextSessionEpoch(sessionEpoch)503 } == null) return CreateVaultResult.Error("stale_create")504 CreateVaultResult.Success505 } catch (e: Exception) {506 lock()507 CreateVaultResult.Error(e.message ?: "create_failed")508 } finally {509 key?.let(VaultCrypto::zero)510 }511 }512513 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {514 val expectedEpoch = currentEpoch()515 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")516 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")517 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {518 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")519 }520 var adoptedRootKey: ByteArray? = null521 var nextDerivedKey: ByteArray? = null522 var sessionRootKey: ByteArray? = null523 return try {524 adoptedRootKey = hexToRootKey(rootKeyHex)525 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {526 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")527 }528 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)529 val salt = VaultCrypto.generateSalt()530 nextDerivedKey = VaultCrypto.deriveKey(password, salt)531 sessionRootKey = adoptedRootKey.copyOf()532 val payload = VaultPayload(533 entries = emptyList(),534 duressConfigured = false,535 rootKeyB64 = b64(sessionRootKey),536 recoveryWordHash = null,537 recoveryWordsSet = false,538 )539 val plaintext = VaultCodec.encode(payload)540 val blob = try {541 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))542 } finally {543 VaultCrypto.zero(plaintext)544 }545 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)546 547 548 beforeDurableCommit()549 val created = withEpochCommit(expectedEpoch) {550 if (!store.writeSlotA(slotA)) return@withEpochCommit false551 try {552 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")553 554 555 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")556 store.stampLocalMutation()557 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")558 true559 } catch (_: Exception) {560 store.clearVault()561 false562 }563 } ?: false564 if (!created) return AdoptVaultResult.Error("storage_write_failed")565566 val adopted = mutateIfCurrentEpoch(expectedEpoch) {567 val previousDerivedKey = derivedKey568 val previousRootKey = rootKey569 derivedKey = nextDerivedKey570 rootKey = sessionRootKey571 nextDerivedKey = null572 sessionRootKey = null573 entryList = mutableListOf()574 duressConfigured = false575 recoveryWordHash = null576 recoveryWordsSet = false577 decoyMode = false578 sessionDataAvailable = true579 publish()580 _isUnlocked.value = true581 sessionEpoch = nextSessionEpoch(sessionEpoch)582 previousDerivedKey to previousRootKey583 }584 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")585 val (previousDerivedKey, previousRootKey) = adopted586 previousDerivedKey?.let(VaultCrypto::zero)587 previousRootKey?.let(VaultCrypto::zero)588 AdoptVaultResult.Success(vaultId)589 } catch (e: Exception) {590 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")591 } finally {592 adoptedRootKey?.let(VaultCrypto::zero)593 nextDerivedKey?.let(VaultCrypto::zero)594 sessionRootKey?.let(VaultCrypto::zero)595 }596 }597598 suspend fun unlock(password: String): UnlockResult = mutex.withLock {599 unlockLocked(password, currentEpoch())600 }601602 603 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {604 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")605 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))606 val payload = try {607 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)608 try {609 VaultCodec.decode(plaintext)610 } finally {611 VaultCrypto.zero(plaintext)612 }613 } catch (_: Exception) {614 VaultCrypto.zero(key)615 616 617 618 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {619 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)620 if (legacy != null) return legacy621 }622 return UnlockResult.WrongPassword623 }624 return adoptSlotA(key, payload, expectedEpoch)625 }626627 628629630631632633634635 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {636 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))637 var newKey: ByteArray? = null638 return try {639 val payload = try {640 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)641 try {642 VaultCodec.decode(plaintext)643 } finally {644 VaultCrypto.zero(plaintext)645 }646 } catch (_: Exception) {647 return null648 }649 650 651 val newSalt = VaultCrypto.generateSalt()652 newKey = VaultCrypto.deriveKey(password, newSalt)653 val plaintext = VaultCodec.encode(payload)654 val blob = try {655 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))656 } finally {657 VaultCrypto.zero(plaintext)658 }659 val next = StoredVault(660 version = slotA.version,661 salt = b64(newSalt),662 blob = blob,663 recoveryEscrow = slotA.recoveryEscrow,664 rootKeyEscrow = slotA.rootKeyEscrow,665 )666 667 668 669 val priorB = store.loadSlotB()670 beforeDurableCommit()671 val healed = withEpochCommit(expectedEpoch) {672 if (!store.writeSlotA(next)) {673 false674 } else try {675 if (!writeMatchingNoiseSlotB(next)) {676 runCatching { store.writeSlotA(slotA) }677 if (priorB != null) runCatching { store.writeSlotB(priorB) }678 false679 } else if (!writeCompanionDuressSlot(next)) {680 runCatching { store.writeSlotA(slotA) }681 if (priorB != null) runCatching { store.writeSlotB(priorB) }682 false683 } else {684 store.stampLocalMutation()685 true686 }687 } catch (_: Exception) {688 runCatching { store.writeSlotA(slotA) }689 if (priorB != null) runCatching { store.writeSlotB(priorB) }690 false691 }692 } ?: false693 if (healed) {694 VaultCrypto.zero(rawKey!!)695 rawKey = null696 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 697 newKey = null698 result699 } else {700 VaultCrypto.zero(newKey!!)701 newKey = null702 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 703 rawKey = null704 result705 }706 } finally {707 708 709 rawKey?.let(VaultCrypto::zero)710 newKey?.let(VaultCrypto::zero)711 }712 }713714 715 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {716 return try {717 718 719 720 721 722 val adopted = mutateIfCurrentEpoch(expectedEpoch) {723 derivedKey = key724 decoyMode = false725 entryList = payload.entries.toMutableList()726 duressConfigured = payload.duressConfigured727 recoveryWordHash = payload.recoveryWordHash728 recoveryWordsSet = payload.recoveryWordsSet729 sessionDataAvailable = true730 _settings.value = store.loadSettings()731 ensureRootKey(payload)732 autoPurgeExpiredTrash()733 publish()734 _isUnlocked.value = true735 736 737 738 sessionEpoch = nextSessionEpoch(sessionEpoch)739 }740 if (adopted == null) {741 VaultCrypto.zero(key)742 UnlockResult.Error("stale_unlock")743 } else {744 UnlockResult.Success745 }746 } catch (e: Exception) {747 748 749 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }750 VaultCrypto.zero(key)751 UnlockResult.Error(e.message ?: "unlock_failed")752 }753 }754755 756757 758759760761762763 class SlotAUnlock internal constructor(764 key: ByteArray,765 internal val payload: VaultPayload,766 internal val epoch: Long,767 ) {768 private var ownedKey: ByteArray? = key769 private var consumed = false770771 772 internal fun takeKey(): ByteArray? = synchronized(this) {773 if (consumed) return@synchronized null774 consumed = true775 ownedKey.also { ownedKey = null }776 }777778 779 internal fun discard() = synchronized(this) {780 if (consumed) return@synchronized781 consumed = true782 ownedKey?.let(VaultCrypto::zero)783 ownedKey = null784 }785 }786787 788789790791792793 fun tryUnlockSlotA(password: String): SlotAUnlock? {794 val epoch = currentEpoch()795 val slotA = store.loadSlotA() ?: return null796 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))797 return try {798 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)799 val payload = try {800 VaultCodec.decode(plaintext)801 } finally {802 VaultCrypto.zero(plaintext)803 }804 SlotAUnlock(key, payload, epoch)805 } catch (_: Exception) {806 VaultCrypto.zero(key)807 null808 }809 }810811 812 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {813 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")814 adoptSlotA(key, unlock.payload, unlock.epoch)815 }816817 818 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()819820 821822 823824825826827 class DuressUnlock internal constructor(828 key: ByteArray,829 internal val payload: VaultPayload,830 internal val epoch: Long,831 ) {832 private var ownedKey: ByteArray? = key833 private var consumed = false834835 internal fun takeKey(): ByteArray? = synchronized(this) {836 if (consumed) return@synchronized null837 consumed = true838 ownedKey.also { ownedKey = null }839 }840841 internal fun discard() = synchronized(this) {842 if (consumed) return@synchronized843 consumed = true844 ownedKey?.let(VaultCrypto::zero)845 ownedKey = null846 }847 }848849 850851852853854855856 fun tryUnlockDuress(password: String): DuressUnlock? {857 val epoch = currentEpoch()858 val slot = store.loadDecoySlot() ?: return null859 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))860 return try {861 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)862 val payload = try {863 VaultCodec.decode(plaintext)864 } finally {865 VaultCrypto.zero(plaintext)866 }867 DuressUnlock(key, payload, epoch)868 } catch (_: Exception) {869 VaultCrypto.zero(key)870 null871 }872 }873874 875 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {876 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")877 if (currentEpoch() != unlock.epoch) {878 VaultCrypto.zero(key)879 return@withLock UnlockResult.Error("stale_unlock")880 }881 return try {882 val adopted = mutateIfCurrentEpoch(unlock.epoch) {883 derivedKey = key884 decoyMode = true885 entryList = unlock.payload.entries.toMutableList()886 duressConfigured = false887 recoveryWordHash = unlock.payload.recoveryWordHash888 recoveryWordsSet = unlock.payload.recoveryWordsSet889 sessionDataAvailable = true890 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)891 else VaultCrypto.generateRootKey()892 _settings.value = store.loadSettings()893 publish()894 _isUnlocked.value = true895 sessionEpoch = nextSessionEpoch(sessionEpoch)896 }897 if (adopted == null) {898 VaultCrypto.zero(key)899 UnlockResult.Error("stale_unlock")900 } else {901 UnlockResult.Success902 }903 } catch (e: Exception) {904 905 906 907 908 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }909 VaultCrypto.zero(key)910 UnlockResult.Error(e.message ?: "decoy_unlock_failed")911 }912 }913914 915 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()916917 918919920921922923 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {924 925 val expectedEpoch = mutateState {926 if (derivedKey != null && !decoyMode) sessionEpoch else null927 } ?: return false928 929 930 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }931 var key: ByteArray? = null932 var basePlaintext: ByteArray? = null933 return try {934 val salt = VaultCrypto.generateSalt()935 key = VaultCrypto.deriveKey(duressPassphrase, salt)936 937 val decoyRoot = VaultCrypto.generateRootKey()938 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }939 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)940 basePlaintext = VaultCodec.encode(decoyPayload)941 942 943 944 945 946 947 948 949 val slotA = store.loadSlotA()950 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0951 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!952 val blob = try {953 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))954 } finally {955 VaultCrypto.zero(plaintext)956 957 958 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)959 basePlaintext = null960 key?.let(VaultCrypto::zero)961 key = null962 }963 beforeDurableCommit()964 val ok = withEpochCommit(expectedEpoch) {965 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))966 } ?: false967 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {968 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }969 } else if (ok) {970 return false971 }972 ok973 } catch (_: Exception) {974 false975 } finally {976 977 978 key?.let(VaultCrypto::zero)979 basePlaintext?.let(VaultCrypto::zero)980 }981 }982983984 985986987988989990991992993 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {994 if (!duressConfigured) return null995 val slot = store.loadDecoySlot() ?: return null996 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()997 }998999 1000100110021003100410051006 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1007 1008 1009 1010 1011 1012 var salt: ByteArray? = null1013 var blob: ByteArray? = null1014 var nonce: ByteArray? = null1015 var ctAndTag: ByteArray? = null1016 var ct: ByteArray? = null1017 var tag: ByteArray? = null1018 try {1019 salt = unb64(slot.salt)1020 blob = unb64(slot.blob)1021 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1022 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1023 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1024 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1025 val obj = JsonObject(mapOf(1026 "version" to JsonPrimitive(1),1027 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1028 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1029 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1030 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1031 ))1032 return obj.toString()1033 } finally {1034 salt?.let(VaultCrypto::zero)1035 blob?.let(VaultCrypto::zero)1036 nonce?.let(VaultCrypto::zero)1037 ctAndTag?.let(VaultCrypto::zero)1038 ct?.let(VaultCrypto::zero)1039 tag?.let(VaultCrypto::zero)1040 }1041 }10421043 1044104510461047104810491050105110521053 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1054 val b = BrowserSlot.parse(json) ?: return false1055 var stored: StoredVault? = null1056 var salt: ByteArray? = null1057 var nonce: ByteArray? = null1058 var ct: ByteArray? = null1059 var tag: ByteArray? = null1060 var blob: ByteArray? = null1061 try {1062 salt = unhex(b.salt)1063 nonce = unhex(b.nonce)1064 ct = unhex(b.ciphertext)1065 tag = unhex(b.tag)1066 blob = ByteArray(nonce.size + ct.size + tag.size).also {1067 System.arraycopy(nonce, 0, it, 0, nonce.size)1068 System.arraycopy(ct, 0, it, nonce.size, ct.size)1069 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1070 }1071 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1072 val expectedEpoch = sessionEpoch1073 beforeDurableCommit()1074 withEpochCommit(expectedEpoch) {1075 store.writeDecoySlot(stored)1076 } ?: false1077 } catch (_: Exception) {1078 false1079 } finally {1080 salt?.let(VaultCrypto::zero)1081 nonce?.let(VaultCrypto::zero)1082 ct?.let(VaultCrypto::zero)1083 tag?.let(VaultCrypto::zero)1084 blob?.let(VaultCrypto::zero)1085 }1086 }10871088 1089 suspend fun clearDuress(): Boolean = mutex.withLock {1090 1091 1092 1093 1094 val expectedEpoch = currentEpoch()1095 val replaced = withEpochCommit(expectedEpoch) {1096 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1097 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1098 } ?: false1099 if (!replaced) return@withLock false1100 1101 1102 if (!isSessionEpochCurrent(expectedEpoch)) return false1103 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1104 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1105 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1106 }1107 true1108 }11091110 1111 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1112 var current: Throwable? = error1113 while (current != null) {1114 if (current is javax.crypto.AEADBadTagException) return true1115 1116 1117 1118 1119 if (current is javax.crypto.BadPaddingException) return true1120 current = current.cause1121 }1122 return false1123 }11241125 1126 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1127 val expectedEpoch = currentEpoch()1128 val slotA = try {1129 store.loadSlotA()1130 } catch (_: Exception) {1131 return@withLock UnlockResult.Error("unlock_key_storage_failed")1132 } ?: return@withLock UnlockResult.Error("no_vault")1133 1134 1135 1136 1137 var stagedKey: ByteArray? = key.copyOf()1138 return try {1139 val encrypted = try {1140 unb64(slotA.blob)1141 } catch (_: Exception) {1142 return@withLock UnlockResult.Error("invalid_vault_blob")1143 }1144 1145 1146 1147 1148 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1149 return@withLock UnlockResult.Error("invalid_vault_blob")1150 }1151 val plaintext = try {1152 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1153 } catch (e: Exception) {1154 if (isGcmAuthenticationFailure(e)) {1155 return@withLock UnlockResult.WrongPassword1156 }1157 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1158 }1159 val payload = try {1160 VaultCodec.decode(plaintext)1161 } catch (_: Exception) {1162 return@withLock UnlockResult.Error("invalid_vault_payload")1163 } finally {1164 VaultCrypto.zero(plaintext)1165 }1166 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1167 val result = adoptSlotA(candidate, payload, expectedEpoch)1168 if (result is UnlockResult.Success) stagedKey = null1169 result1170 } finally {1171 stagedKey?.let(VaultCrypto::zero)1172 }1173 }11741175 fun lock() {1176 1177 1178 1179 1180 1181 1182 stateLock.lock()1183 try {1184 sessionEpoch = nextSessionEpoch(sessionEpoch)1185 clearSessionStateLocked()1186 } finally {1187 stateLock.unlock()1188 }1189 }11901191 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11921193 1194 1195 @VisibleForTesting1196 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11971198 119912001201120212031204120512061207120812091210 fun expectTransientBackground() {1211 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1212 }12131214 12151216121712181219 fun consumeTransientBackgroundSuppression(): Boolean {1220 val until = suppressNextBackgroundLockUntil1221 val active = until != 0L && nowMs() < until1222 suppressNextBackgroundLockUntil = 0L1223 return active1224 }12251226 12271228 12291230123112321233 12341235123612371238123912401241124212431244 1245 1246 1247 1248 1249 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)12501251 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1252 if (decoyMode || !_isUnlocked.value) null1253 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1254 }12551256 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1257 mutateState { sessionEpoch == epoch }12581259 internal fun unlockedSessionEpoch(): Long? = mutateState {1260 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1261 }12621263 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12641265 126612671268126912701271127212731274 fun sessionRootKey(): ByteArray? = mutateState {1275 if (!sessionDataAvailable) null else rootKey?.copyOf()1276 }12771278 12791280 fun syncVaultId(): String? = mutateState {1281 if (decoyMode || !_isUnlocked.value) null1282 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1283 }12841285 12861287128812891290 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1291 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1292 ?: return false1293 installRootKey(generateRecoverableRootKey(), expectedEpoch)1294 }12951296 12971298129913001301130213031304130513061307130813091310131113121313 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1314 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1315 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1316 }1317 val expectedEpoch = mutateState {1318 when {1319 derivedKey == null -> null1320 decoyMode -> Long.MIN_VALUE1321 else -> sessionEpoch1322 }1323 }1324 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1325 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1326 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13271328 val incoming = hexToRootKey(rootKeyHex)1329 if (incoming.size != VaultCrypto.KEY_SIZE) {1330 VaultCrypto.zero(incoming)1331 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1332 }1333 1334 1335 1336 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1337 if (!roundTrips) {1338 VaultCrypto.zero(incoming)1339 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1340 }13411342 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1343 return if (installRootKey(incoming, expectedEpoch)) {1344 AdoptVaultResult.Success(vaultId)1345 } else {1346 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1347 }1348 }13491350 13511352135313541355 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1356 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1357 val bytes = hexToRootKey(rootKeyHex)1358 return try {1359 val words = rootKeyToWords(bytes)1360 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1361 } catch (_: Exception) {1362 null1363 } finally {1364 VaultCrypto.zero(bytes)1365 }1366 }13671368 13691370137113721373 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1374 1375 1376 1377 1378 1379 1380 data class Snapshot(1381 val key: ByteArray,1382 val priorRootKey: ByteArray,1383 val priorRecoveryHash: String?,1384 val priorRecoverySet: Boolean,1385 )1386 val snap = mutateIfCurrentEpoch(expectedEpoch) {1387 val k = derivedKey ?: return@mutateIfCurrentEpoch null1388 if (decoyMode) return@mutateIfCurrentEpoch null1389 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1390 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1391 } ?: run {1392 VaultCrypto.zero(nextRootKey)1393 return false1394 }1395 val priorSlot = store.loadSlotA() ?: run {1396 VaultCrypto.zero(snap.key)1397 VaultCrypto.zero(snap.priorRootKey)1398 VaultCrypto.zero(nextRootKey)1399 return false1400 }1401 val nextWords = rootKeyToWords(nextRootKey)14021403 return try {1404 beforeDurableCommit()1405 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1406 if (mutateIfCurrentEpoch(expectedEpoch) {1407 rootKey = nextRootKey1408 recoveryWordHash = recoveryWordsHash(nextWords)1409 recoveryWordsSet = false1410 } == null) throw IllegalStateException("stale_root_rotation")14111412 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1413 ?: throw IllegalStateException("recovery_escrow_failed")1414 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1415 ?: throw IllegalStateException("root_escrow_failed")1416 val plaintext = VaultCodec.encode(currentPayload())1417 val blob = try {1418 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1419 } finally {1420 VaultCrypto.zero(plaintext)1421 }1422 val nextSlot = priorSlot.copy(1423 blob = blob,1424 recoveryEscrow = nextRecoveryEscrow,1425 rootKeyEscrow = nextRootKeyEscrow,1426 )1427 val committed = withEpochCommit(expectedEpoch) {1428 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1429 try {1430 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1431 1432 1433 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1434 store.stampLocalMutation()1435 } catch (_: Exception) {1436 store.writeSlotA(priorSlot)1437 return@withEpochCommit false1438 }1439 true1440 } ?: false1441 if (!committed) throw IllegalStateException("storage_write_failed")1442 mutateIfCurrentEpoch(expectedEpoch) { publish() }1443 true1444 } catch (_: Exception) {1445 val restored = mutateIfCurrentEpoch(expectedEpoch) {1446 rootKey?.let(VaultCrypto::zero)1447 rootKey = snap.priorRootKey.copyOf()1448 recoveryWordHash = snap.priorRecoveryHash1449 recoveryWordsSet = snap.priorRecoverySet1450 }1451 if (restored != null) {1452 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1453 }1454 false1455 } finally {1456 VaultCrypto.zero(snap.key)1457 VaultCrypto.zero(snap.priorRootKey)1458 1459 1460 1461 val stillOwnedBySession = mutateState {1462 sessionEpoch == expectedEpoch && rootKey === nextRootKey1463 }1464 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1465 }1466 }14671468 14691470147114721473 fun currentVaultKey(): ByteArray? = mutateState {1474 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1475 }14761477 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1478 val expectedEpoch = currentEpoch()1479 val slotA = store.loadSlotA() ?: return false1480 var oldKey: ByteArray? = null1481 var newKey: ByteArray? = null1482 var plaintext: ByteArray? = null1483 return try {1484 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1485 1486 1487 1488 1489 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1490 val payload = try {1491 VaultCodec.decode(decrypted)1492 } finally {1493 VaultCrypto.zero(decrypted)1494 }1495 val newSalt = VaultCrypto.generateSalt()1496 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1497 1498 plaintext = VaultCodec.encode(payload)1499 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1500 1501 1502 1503 1504 1505 1506 1507 val next = StoredVault(1508 version = slotA.version,1509 salt = b64(newSalt),1510 blob = blob,1511 recoveryEscrow = slotA.recoveryEscrow,1512 rootKeyEscrow = slotA.rootKeyEscrow,1513 )1514 1515 1516 1517 1518 val priorB = store.loadSlotB()1519 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1520 1521 1522 1523 if (mutateIfCurrentEpoch(expectedEpoch) {1524 oldKey?.let(VaultCrypto::zero); oldKey = null1525 derivedKey?.let(VaultCrypto::zero)1526 derivedKey = newKey; newKey = null1527 } == null) return false1528 true1529 } catch (_: Exception) {1530 false1531 } finally {1532 oldKey?.let(VaultCrypto::zero)1533 newKey?.let(VaultCrypto::zero)1534 plaintext?.let(VaultCrypto::zero)1535 }1536 }15371538 1539154015411542 suspend fun destroyVault() = mutex.withLock {1543 lock()1544 store.clearVault()1545 store.clearDecoySlot()1546 }15471548 15491550 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1551 val epoch = requireUnlocked()1552 val stamp = VaultCrypto.monotonicEntryTimestamp()1553 val now = System.currentTimeMillis()1554 val entry = withDerivedLast4(draft.copy(1555 id = draft.id.ifBlank { UUID.randomUUID().toString() },1556 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1557 modifiedAt = stamp,1558 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1559 ))1560 1561 1562 val rollback = mutateCurrentSession(epoch) {1563 val r = entryList.toMutableList()1564 entryList.add(entry)1565 r1566 } ?: throw IllegalStateException("vault_locked")1567 persistOrRollback(rollback, epoch)1568 entry1569 }15701571 157215731574157515761577157815791580 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1581 val epoch = requireUnlocked()1582 if (drafts.isEmpty()) return 01583 val now = System.currentTimeMillis()1584 val ready = drafts.map { draft ->1585 val stamp = VaultCrypto.monotonicEntryTimestamp()1586 withDerivedLast4(1587 draft.copy(1588 id = draft.id.ifBlank { UUID.randomUUID().toString() },1589 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1590 modifiedAt = stamp,1591 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1592 ),1593 )1594 }1595 1596 val rollback = mutateCurrentSession(epoch) {1597 val r = entryList.toMutableList()1598 entryList.addAll(ready)1599 r1600 } ?: throw IllegalStateException("vault_locked")1601 persistOrRollback(rollback, epoch)1602 return drafts.size1603 }16041605 1606160716081609161016111612161316141615161616171618 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1619 val epoch = requireUnlocked()1620 val now = System.currentTimeMillis()1621 val ready = newEntries.map { draft ->1622 val stamp = VaultCrypto.monotonicEntryTimestamp()1623 withDerivedLast4(1624 draft.copy(1625 id = draft.id.ifBlank { UUID.randomUUID().toString() },1626 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1627 modifiedAt = stamp,1628 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1629 ),1630 )1631 }1632 1633 1634 1635 1636 1637 1638 val incomingIds = ready.map { it.id }.toHashSet()1639 val rollback = mutateCurrentSession(epoch) {1640 val r = entryList.toMutableList()1641 val replaced = entryList.map { entry ->1642 if (entry.deletedAt != null) return@map entry1643 if (entry.id in incomingIds) return@map null1644 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1645 entry.copy(deletedAt = stamp, modifiedAt = stamp)1646 }.filterNotNull()1647 entryList = (replaced + ready).toMutableList()1648 r1649 } ?: throw IllegalStateException("vault_locked")1650 persistOrRollback(rollback, epoch)1651 return ready.size1652 }16531654 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1655 val epoch = requireUnlocked()1656 1657 1658 1659 1660 val prepared = mutateCurrentSession(epoch) {1661 val idx = entryList.indexOfFirst { it.id == patch.id }1662 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1663 val current = entryList[idx]1664 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1665 1666 val passwordChanged = patch.password != null && patch.password != current.password1667 1668 1669 1670 1671 1672 1673 val priorPassword = current.password1674 val shouldCapture = passwordChanged &&1675 !priorPassword.isNullOrEmpty() &&1676 current.passwordHistory?.firstOrNull()?.password != priorPassword1677 val nextHistory = if (shouldCapture) {1678 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1679 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1680 } else {1681 current.passwordHistory1682 }1683 val updated = withDerivedLast4(patch.copy(1684 createdAt = current.createdAt,1685 modifiedAt = stamp,1686 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1687 passwordHistory = nextHistory,1688 ))1689 val rollback = entryList.toMutableList()1690 entryList[idx] = updated1691 updated to rollback1692 } ?: throw IllegalStateException("vault_locked")1693 val (updated, rollback) = prepared1694 persistOrRollback(rollback, epoch)1695 updated1696 }16971698 1699 suspend fun deleteEntry(id: String) = mutex.withLock {1700 val epoch = requireUnlocked()1701 1702 1703 val rollback = mutateCurrentSession(epoch) {1704 val idx = entryList.indexOfFirst { it.id == id }1705 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1706 val current = entryList[idx]1707 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1708 val prior = entryList.toMutableList()1709 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1710 prior1711 } ?: throw IllegalStateException("vault_locked")1712 persistOrRollback(rollback, epoch)1713 }17141715 suspend fun restoreEntry(id: String) = mutex.withLock {1716 val epoch = requireUnlocked()1717 val rollback = mutateCurrentSession(epoch) {1718 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1719 if (idx == -1) throw IllegalStateException("deleted entry not found")1720 val current = entryList[idx]1721 val prior = entryList.toMutableList()1722 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1723 prior1724 } ?: throw IllegalStateException("vault_locked")1725 persistOrRollback(rollback, epoch)1726 }17271728 1729 suspend fun purgeEntry(id: String) = mutex.withLock {1730 val epoch = requireUnlocked()1731 val rollback = mutateCurrentSession(epoch) {1732 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1733 if (idx == -1) throw IllegalStateException("deleted entry not found")1734 val prior = entryList.toMutableList()1735 entryList.removeAt(idx)1736 prior1737 } ?: throw IllegalStateException("vault_locked")1738 persistOrRollback(rollback, epoch)1739 }17401741 1742 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1743 val epoch = requireUnlocked()1744 1745 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1746 val r = mutateCurrentSession(epoch) {1747 val rollback = entryList.toMutableList()1748 val before = entryList.size1749 entryList.removeAll { it.deletedAt != null }1750 R(rollback, before - entryList.size)1751 } ?: throw IllegalStateException("vault_locked")1752 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1753 r.removed1754 }17551756 1757 suspend fun countDuplicateEntries(): Int = mutex.withLock {1758 val epoch = requireUnlocked()1759 1760 1761 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1762 ?: throw IllegalStateException("vault_locked")1763 }17641765 17661767176817691770 suspend fun deduplicateEntries(): Int = mutex.withLock {1771 val epoch = requireUnlocked()1772 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1773 val ready = mutateCurrentSession(epoch) {1774 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1775 val active = entryList.filter { it.deletedAt == null }1776 val keepIds = mutableSetOf<String>()1777 active.groupBy { it.duplicateKey() }.values.forEach { group ->1778 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1779 }1780 val rollback = entryList.toMutableList()1781 var removed = 01782 entryList = entryList.map { entry ->1783 if (entry.deletedAt == null && entry.id !in keepIds) {1784 removed++1785 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1786 entry.copy(deletedAt = stamp, modifiedAt = stamp)1787 } else {1788 entry1789 }1790 }.toMutableList()1791 R(rollback, removed)1792 } ?: throw IllegalStateException("vault_locked")1793 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1794 ready.removed1795 }17961797 fun getEntries(): List<VaultEntry> = mutateState {1798 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1799 }1800 fun getEntry(id: String): VaultEntry? = mutateState {1801 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1802 }1803 fun getDeletedEntries(): List<VaultEntry> = mutateState {1804 if (!sessionDataAvailable) emptyList()1805 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1806 }18071808 1809 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1810 return mutateState {1811 if (!sessionDataAvailable) return@mutateState emptyList()1812 val active = entryList.filter { it.deletedAt == null }1813 if (tagFilter == null) return@mutateState active1814 val needle = tagFilter.trim().lowercase()1815 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1816 }1817 }18181819 1820 fun getDistinctTags(): List<String> = mutateState {1821 if (!sessionDataAvailable) return@mutateState emptyList()1822 entryList.asSequence()1823 .filter { it.deletedAt == null }1824 .flatMap { it.tags?.asSequence() ?: emptySequence() }1825 .map { it.trim().lowercase() }1826 .filter { it.isNotEmpty() }1827 .distinct()1828 .sorted()1829 .toList()1830 }18311832 183318341835183618371838 fun getDistinctFieldValues(field: String): List<String> {1839 val select: (VaultEntry) -> String? = when (field) {1840 "username" -> { e -> e.username }1841 "email" -> { e -> e.email }1842 else -> return emptyList()1843 }1844 return mutateState {1845 if (!sessionDataAvailable) return@mutateState emptyList()1846 entryList.asSequence()1847 .filter { it.deletedAt == null }1848 .mapNotNull { select(it)?.trim() }1849 .filter { it.isNotEmpty() }1850 .groupingBy { it.lowercase() }1851 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1852 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1853 }1854 .values1855 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1856 .map { it.first }1857 }1858 }18591860 fun getTombstones(): List<Tombstone> = mutateState {1861 if (!sessionDataAvailable) emptyList()1862 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1863 }1864 fun getVaultVersion(): Long = store.version()1865 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18661867 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1868 val expectedEpoch = currentEpoch()1869 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1870 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1871 1872 1873 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1874 val priorA = store.loadSlotA()1875 val priorB = store.loadSlotB()1876 val priorD = store.loadDecoySlot()1877 1878 1879 1880 1881 1882 val priorHistory = store.loadVaultHistory()1883 1884 1885 1886 1887 beforeDurableCommit()1888 if (!takeVaultSnapshot("restore", expectedEpoch)) {1889 return false1890 }1891 1892 1893 1894 val nextD: StoredVault = restoredD ?: run {1895 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161896 val fakeBlob = ByteArray(target).also(rng::nextBytes)1897 val fakeSalt = VaultCrypto.generateSalt()1898 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1899 }1900 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1901 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1902 return false1903 }1904 lock()1905 true1906 }19071908 19091910191119121913191419151916191719181919 private fun commitRestoreAtomic(1920 nextA: StoredVault,1921 nextB: String,1922 nextD: StoredVault,1923 priorA: StoredVault?,1924 priorB: String?,1925 priorD: StoredVault?,1926 expectedEpoch: Long,1927 ): Boolean {1928 return withEpochCommit(expectedEpoch) {1929 if (!store.writeSlotA(nextA)) return@withEpochCommit false1930 1931 1932 1933 try {1934 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1935 1936 1937 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1938 store.stampLocalMutation()1939 } catch (_: Exception) {1940 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1941 return@withEpochCommit false1942 }1943 true1944 } ?: false1945 }19461947 19481949 19501951195219531954 suspend fun applyMergedRemoteEntries(1955 remoteEntries: List<VaultEntry>,1956 remoteTombstones: List<Tombstone>,1957 expectedEpoch: Long? = null,1958 ): Int = mutex.withLock {1959 val epoch = expectedEpoch ?: requireUnlocked()1960 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01961 1962 1963 1964 1965 1966 val mergeNow = System.currentTimeMillis()1967 val clamped = remoteEntries.map { entry ->1968 entry.copy(1969 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1970 deletedAt = entry.deletedAt?.let {1971 SyncLimits.clampSyncTimestamp(it, mergeNow)1972 },1973 )1974 }1975 val tombMap = remoteTombstones.associate {1976 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1977 }1978 1979 1980 val applied = mutateCurrentSession(epoch) {1981 var n = 01982 val byId = entryList.associateBy { it.id }.toMutableMap()19831984 for (remote in clamped) {1985 val tomb = tombMap[remote.id]1986 if (tomb != null && tomb >= remote.modifiedAt) continue1987 val local = byId[remote.id]1988 if (local == null) {1989 entryList.add(remote); byId[remote.id] = remote; n++1990 } else if (remote.modifiedAt > local.modifiedAt) {1991 val i = entryList.indexOfFirst { it.id == local.id }1992 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1993 }1994 }19951996 for ((id, deletedAt) in tombMap) {1997 val local = byId[id] ?: continue1998 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1999 if (deletedAt < local.modifiedAt) continue2000 val i = entryList.indexOfFirst { it.id == id }2001 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2002 }2003 n2004 } ?: return@withLock 020052006 if (applied > 0) {2007 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2008 mutateIfCurrentEpoch(epoch) { publish() }2009 }2010 applied2011 }20122013 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2014 2015 2016 2017 val epoch = requireUnlocked()2018 val prior = mutateCurrentSession(epoch) {2019 val old = _settings.value2020 _settings.value = settings2021 old2022 } ?: throw IllegalStateException("vault_locked")20232024 val committed = try {2025 beforeDurableCommit()2026 2027 2028 2029 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2030 } catch (_: Exception) {2031 false2032 }2033 if (!committed) {2034 2035 2036 2037 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2038 throw IllegalStateException("settings_write_failed")2039 }2040 }20412042 20432044 2045 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }20462047 20482049205020512052 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2053 val epoch = requireUnlocked()2054 2055 2056 2057 2058 2059 2060 val prior = mutateCurrentSession(epoch) {2061 val priorPair = recoveryWordHash to recoveryWordsSet2062 recoveryWordHash = recoveryWordsHash(words)2063 recoveryWordsSet = true2064 priorPair2065 } ?: return@withLock false2066 2067 2068 2069 2070 val persisted = runCatching {2071 persistSlotAWithEscrows(2072 buildRecoveryEscrow(words),2073 buildRootKeyEscrow(words),2074 expectedEpoch = epoch,2075 )2076 }.getOrDefault(false)2077 if (!persisted) {2078 mutateIfCurrentEpoch(epoch) {2079 recoveryWordHash = prior.first2080 recoveryWordsSet = prior.second2081 }2082 }2083 persisted2084 }20852086 2087 fun verifyRecoveryWords(words: List<String>): Boolean {2088 return mutateState {2089 val stored = recoveryWordHash ?: return@mutateState false2090 stored == recoveryWordsHash(words)2091 }2092 }20932094 20952096 2097 fun hasRecoveryEscrow(): Boolean =2098 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20992100 210121022103210421052106210721082109211021112112 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2113 val expectedEpoch = currentEpoch()2114 val slotA = store.loadSlotA() ?: return false2115 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false21162117 2118 2119 val fullEscrow = slotA.recoveryEscrow2120 if (fullEscrow != null) {2121 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2122 if (material != null) {2123 var keyBytes: ByteArray? = null2124 var plaintext: ByteArray? = null2125 var candidateRoot: ByteArray? = null2126 var stateCommitStarted = false2127 try {2128 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2129 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2130 val payload = VaultCodec.decode(plaintext)2131 2132 2133 2134 2135 val candidateSettings = store.loadSettings()2136 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2137 val decodedRoot = unb64(payload.rootKeyB64)2138 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2139 2140 2141 2142 VaultCrypto.zero(decodedRoot)2143 throw IllegalArgumentException("invalid recovery root key")2144 }2145 decodedRoot2146 } else {2147 generateRecoverableRootKey()2148 }2149 stateCommitStarted = true2150 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2151 clearSessionStateLocked()2152 entryList = payload.entries.toMutableList()2153 duressConfigured = payload.duressConfigured2154 recoveryWordHash = payload.recoveryWordHash2155 recoveryWordsSet = payload.recoveryWordsSet2156 rootKey = candidateRoot2157 candidateRoot = null2158 _settings.value = candidateSettings2159 publish()2160 2161 2162 sessionDataAvailable = true2163 _isUnlocked.value = false2164 true2165 }2166 if (adopted == true) return@withLock true2167 } catch (_: Exception) {2168 2169 2170 2171 2172 if (stateCommitStarted) {2173 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2174 }2175 } finally {2176 plaintext?.let(VaultCrypto::zero)2177 VaultCrypto.zero(material)2178 keyBytes?.let(VaultCrypto::zero)2179 candidateRoot?.let(VaultCrypto::zero)2180 }2181 }2182 }21832184 2185 2186 2187 val rootEscrow = slotA.rootKeyEscrow2188 if (rootEscrow != null) {2189 var candidateRoot: ByteArray? = null2190 var stateCommitStarted = false2191 try {2192 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2193 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2194 val candidateSettings = store.loadSettings()2195 stateCommitStarted = true2196 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2197 clearSessionStateLocked()2198 entryList = mutableListOf()2199 duressConfigured = false2200 recoveryWordHash = recoveryWordsHash(words)2201 recoveryWordsSet = true2202 rootKey = candidateRoot2203 candidateRoot = null2204 _settings.value = candidateSettings2205 publish()2206 sessionDataAvailable = true2207 _isUnlocked.value = false2208 true2209 }2210 if (adopted == true) return@withLock true2211 } catch (_: Exception) {2212 if (stateCommitStarted) {2213 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2214 }2215 } finally {2216 candidateRoot?.let(VaultCrypto::zero)2217 }2218 }2219 return false2220 }22212222 222322242225222622272228222922302231 suspend fun resetPasswordWithRecoveryWords(2232 words: List<String>,2233 newPassword: String,2234 ): Boolean = mutex.withLock {2235 val expectedEpoch = currentEpoch()2236 2237 2238 2239 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2240 val priorA = store.loadSlotA() ?: return false2241 var newKey: ByteArray? = null2242 var plaintext: ByteArray? = null2243 return try {2244 val newSalt = VaultCrypto.generateSalt()2245 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2246 plaintext = VaultCodec.encode(currentPayload())2247 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2248 2249 2250 2251 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2252 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2253 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2254 val next = StoredVault(2255 version = priorA.version,2256 salt = b64(newSalt),2257 blob = blob,2258 recoveryEscrow = freshRecovery,2259 rootKeyEscrow = freshRootKey,2260 )2261 2262 2263 2264 val priorB = store.loadSlotB()2265 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2266 2267 2268 2269 if (mutateIfCurrentEpoch(expectedEpoch) {2270 derivedKey?.let(VaultCrypto::zero)2271 derivedKey = newKey; newKey = null2272 publish()2273 _isUnlocked.value = true2274 } == null) return false2275 true2276 } catch (_: Exception) {2277 false2278 } finally {2279 newKey?.let(VaultCrypto::zero)2280 plaintext?.let(VaultCrypto::zero)2281 }2282 }22832284 228522862287228822892290 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2291 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2292 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2293 var keyBytes: ByteArray? = null2294 var plaintext: ByteArray? = null2295 return try {2296 val salt = VaultCrypto.generateSalt()2297 val recoveryKey = deriveRecoveryKey(material, salt)2298 keyBytes = recoveryKey.encoded2299 plaintext = VaultCodec.encode(currentPayload())2300 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2301 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2302 } finally {2303 VaultCrypto.zero(material)2304 keyBytes?.let(VaultCrypto::zero)2305 plaintext?.let(VaultCrypto::zero)2306 }2307 }23082309 23102311231223132314231523162317 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2318 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2319 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2320 val rk = mutateState { rootKey?.copyOf() } ?: run {2321 VaultCrypto.zero(material)2322 return null2323 }2324 var keyBytes: ByteArray? = null2325 var plaintext: ByteArray? = null2326 return try {2327 val salt = VaultCrypto.generateSalt()2328 val recoveryKey = deriveRecoveryKey(material, salt)2329 keyBytes = recoveryKey.encoded2330 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2331 2332 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2333 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2334 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2335 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2336 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2337 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2338 } finally {2339 VaultCrypto.zero(rk)2340 VaultCrypto.zero(material)2341 keyBytes?.let(VaultCrypto::zero)2342 plaintext?.let(VaultCrypto::zero)2343 }2344 }23452346 2347 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2348 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2349 var keyBytes: ByteArray? = null2350 var pt: ByteArray? = null2351 return try {2352 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2353 keyBytes = recoveryKey.encoded2354 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2355 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2356 hexToRootKey(String(pt, Charsets.US_ASCII))2357 } catch (_: Exception) {2358 null2359 } finally {2360 2361 2362 2363 VaultCrypto.zero(material)2364 keyBytes?.let(VaultCrypto::zero)2365 pt?.let(VaultCrypto::zero)2366 }2367 }23682369 237023712372237323742375 private fun persistSlotAWithEscrows(2376 recoveryEscrow: RecoveryEscrow?,2377 rootKeyEscrow: RootKeyEscrow?,2378 expectedEpoch: Long? = null,2379 ): Boolean {2380 val snapshot = snapshotForPersist(expectedEpoch)2381 ?: throw IllegalStateException("vault_locked_or_stale")2382 if (snapshot.decoy) {2383 VaultCrypto.zero(snapshot.key)2384 return false2385 }2386 try {2387 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2388 val plaintext = VaultCodec.encode(snapshot.payload)2389 val blob = try {2390 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2391 } finally {2392 2393 2394 VaultCrypto.zero(plaintext)2395 }2396 val next = prior.copy(2397 blob = blob,2398 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2399 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2400 )2401 beforeDurableCommit()2402 return withEpochCommit(snapshot.epoch) {2403 if (!store.writeSlotA(next)) return@withEpochCommit false2404 try {2405 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2406 2407 2408 2409 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2410 } catch (e: Exception) {2411 store.writeSlotA(prior)2412 throw e2413 }2414 true2415 } ?: false2416 } finally {2417 VaultCrypto.zero(snapshot.key)2418 }2419 }24202421 24222423 private fun requireUnlocked(): Long {2424 2425 2426 2427 stateLock.lock()2428 try {2429 if (derivedKey == null) throw IllegalStateException("vault_locked")2430 return sessionEpoch2431 } finally {2432 stateLock.unlock()2433 }2434 }24352436 2437 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null24382439 private fun countDuplicateEntriesLocked(): Int {2440 val seen = mutableMapOf<String, VaultEntry>()2441 var duplicates = 02442 for (entry in entryList.filter { it.deletedAt == null }) {2443 val key = entry.duplicateKey()2444 val existing = seen[key]2445 if (existing == null) {2446 seen[key] = entry2447 } else {2448 duplicates++2449 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2450 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2451 if (entryStamp > existingStamp) seen[key] = entry2452 }2453 }2454 return duplicates2455 }24562457 private fun VaultEntry.duplicateKey(): String {2458 if (type == "note") return "note|${title.lowercase()}"2459 val passkeyData = passkey2460 if (passkeyData != null) {2461 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2462 }2463 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2464 }24652466 246724682469247024712472 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2473 val epoch = expectedEpoch ?: currentEpoch()2474 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2475 val slotA = store.loadSlotA() ?: return false2476 val vaultA = VaultCodec.encodeSlot(slotA)2477 val vaultB = store.loadSlotB() ?: slotA.blob2478 2479 2480 2481 2482 2483 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2484 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162485 val fakeBlob = ByteArray(target).also(rng::nextBytes)2486 val fakeSalt = VaultCrypto.generateSalt()2487 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2488 }2489 val history = store.loadVaultHistory()2490 val latest = history.snapshots.firstOrNull()2491 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2492 val entryCount = mutateIfCurrentEpoch(epoch) {2493 if (derivedKey != null && !decoyMode) {2494 bucketEntryCount(entryList.count { it.deletedAt == null })2495 } else {2496 null2497 }2498 } ?: return false2499 return try {2500 val snapshot = VaultSnapshot(2501 id = UUID.randomUUID().toString(),2502 savedAt = System.currentTimeMillis(),2503 reason = reason,2504 vault_a = vaultA,2505 vault_b = vaultB,2506 vault_d = vaultD,2507 entryCount = entryCount,2508 )2509 2510 2511 2512 withEpochCommit(epoch) {2513 store.writeVaultHistory(2514 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2515 )2516 } ?: false2517 } catch (_: Exception) {2518 false2519 }2520 }25212522 private fun bucketEntryCount(n: Int): Int = when {2523 n <= 0 -> 02524 n <= 10 -> 102525 n <= 25 -> 252526 n <= 50 -> 502527 n <= 100 -> 1002528 n <= 250 -> 2502529 n <= 500 -> 5002530 else -> ((n + 499) / 500) * 5002531 }25322533 253425352536253725382539254025412542 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2543 if (!entry.isCard()) return entry2544 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2545 val derived = if (digits.length >= 4) digits.takeLast(4) else null2546 return entry.copy(last4 = derived ?: entry.last4)2547 }25482549 255025512552255325542555 private fun autoPurgeExpiredTrash() {2556 val days = _settings.value.trashRetentionDays ?: return2557 if (days <= 0) return2558 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2559 val before = entryList.size2560 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2561 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2562 }25632564 2565 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25662567 private fun currentPayloadLocked(): VaultPayload =2568 VaultPayload(2569 entries = entryList.toList(),2570 duressConfigured = duressConfigured,2571 rootKeyB64 = rootKey?.let(::b64) ?: "",2572 recoveryWordHash = recoveryWordHash,2573 recoveryWordsSet = recoveryWordsSet,2574 )25752576 25772578257925802581 private fun ensureRootKey(payload: VaultPayload) {2582 if (payload.rootKeyB64.isNotBlank()) {2583 rootKey = unb64(payload.rootKeyB64)2584 return2585 }2586 rootKey = generateRecoverableRootKey()2587 runCatching { persistSlotA(bumpVersion = false) }2588 }25892590 private fun publish() {2591 _entries.value = entryList.filter { it.deletedAt == null }2592 }25932594 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2595 var plaintext = VaultCodec.encode(currentPayload())2596 return try {2597 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2598 StoredVault(version = 1, salt = b64(salt), blob = blob)2599 } finally {2600 2601 2602 2603 VaultCrypto.zero(plaintext)2604 }2605 }26062607 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2608 2609 2610 2611 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2612 val noise = ByteArray(approxBytes).also(rng::nextBytes)2613 return store.writeSlotB(b64(noise))2614 }26152616 261726182619 private fun matchingCiphertextLength(slotA: StoredVault): Int =2620 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)26212622 26232624262526262627 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2628 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162629 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2630 val fakeSalt = VaultCrypto.generateSalt()2631 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2632 }26332634 263526362637263826392640 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2641 val isConfigured = mutateState { duressConfigured }2642 return if (isConfigured) true else writeFakeDuressSlot(slotA)2643 }26442645 2646264726482649265026512652 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2653 val salt = unhex(b.salt)2654 val nonce = unhex(b.nonce)2655 val ct = unhex(b.ciphertext)2656 val tag = unhex(b.tag)2657 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2658 System.arraycopy(nonce, 0, it, 0, nonce.size)2659 System.arraycopy(ct, 0, it, nonce.size, ct.size)2660 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2661 }2662 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2663 }26642665 2666 private fun persistSlotA(2667 bumpVersion: Boolean,2668 snapshotReason: String? = "local_save",2669 expectedEpoch: Long? = null,2670 ): Boolean {2671 val snapshot = snapshotForPersist(expectedEpoch)2672 ?: throw IllegalStateException("vault_locked_or_stale")2673 return try {2674 2675 if (snapshot.decoy) {2676 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2677 val plaintext = VaultCodec.encode(snapshot.payload)2678 val blob = try {2679 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2680 } finally {2681 2682 2683 VaultCrypto.zero(plaintext)2684 }2685 beforeDurableCommit()2686 return withEpochCommit(snapshot.epoch) {2687 store.writeDecoySlot(prior.copy(blob = blob))2688 } ?: false2689 }2690 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2691 2692 2693 2694 2695 val priorB = store.loadSlotB()2696 beforeDurableCommit()2697 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2698 val plaintext = VaultCodec.encode(snapshot.payload)2699 val blob = try {2700 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2701 } finally {2702 2703 2704 VaultCrypto.zero(plaintext)2705 }2706 val next = prior.copy(blob = blob)2707 2708 2709 2710 2711 withEpochCommit(snapshot.epoch) {2712 if (!store.writeSlotA(next)) return@withEpochCommit false2713 try {2714 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2715 2716 2717 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2718 if (bumpVersion) store.stampLocalMutation()2719 } catch (e: Exception) {2720 store.writeSlotA(prior)2721 if (priorB != null) runCatching { store.writeSlotB(priorB) }2722 throw e2723 }2724 true2725 } ?: false2726 } finally {2727 2728 2729 VaultCrypto.zero(snapshot.key)2730 }2731 }27322733 2734273527362737273827392740 private fun commitVaultRotation(2741 priorA: StoredVault,2742 priorB: String?,2743 nextA: StoredVault,2744 expectedEpoch: Long,2745 ): Boolean {2746 beforeDurableCommit()2747 return withEpochCommit(expectedEpoch) {2748 if (!store.writeSlotA(nextA)) return@withEpochCommit false2749 2750 2751 2752 try {2753 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2754 2755 2756 2757 2758 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2759 store.stampLocalMutation()2760 } catch (_: Exception) {2761 rollbackRotation(priorA, priorB)2762 return@withEpochCommit false2763 }2764 true2765 } ?: false2766 }27672768 27692770277127722773 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2774 val failures = mutableListOf<String>()2775 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2776 .onFailure { failures += "writeSlotA: $it" }2777 if (priorB != null) {2778 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2779 .onFailure { failures += "writeSlotB: $it" }2780 }2781 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2782 }27832784 2785 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2786 try {2787 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2788 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2789 throw IllegalStateException("storage_write_failed")2790 }2791 } catch (e: Exception) {2792 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2793 throw e2794 }2795 2796 2797 2798 mutateIfCurrentEpoch(expectedEpoch) { publish() }2799 }2800}