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.util.Log12import com.peachpasswords.BuildConfig13import com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import java.util.concurrent.locks.ReentrantLock21import javax.inject.Inject22import javax.inject.Singleton232425private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2627private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)28private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)293031private const val PASSWORD_HISTORY_MAX = 5032private const val VAULT_HISTORY_MAX = 53334sealed interface UnlockResult {35 data object Success : UnlockResult36 data object WrongPassword : UnlockResult37 data class Error(val reason: String) : UnlockResult38}3940sealed interface CreateVaultResult {41 data object Success : CreateVaultResult42 data class Error(val reason: String) : CreateVaultResult43}4445sealed interface AdoptVaultResult {46 data class Success(val vaultId: String) : AdoptVaultResult47 data class Error(val reason: String) : AdoptVaultResult48}495051525354555657585960616263@Singleton64class VaultRepository @Inject constructor(65 private val store: VaultStorage,66) {67 private val mutex = Mutex()68 69 70 71 72 73 74 75 private val stateLock = ReentrantLock()76 private val rng = SecureRandom()7778 7980818283 private inline fun <T> mutateState(block: () -> T): T {84 stateLock.lock()85 return try { block() } finally { stateLock.unlock() }86 }8788 private var derivedKey: ByteArray? = null89 private var rootKey: ByteArray? = null90 private var entryList: MutableList<VaultEntry> = mutableListOf()91 private var duressConfigured: Boolean = false92 private var recoveryWordHash: String? = null93 private var recoveryWordsSet: Boolean = false94 95 private var decoyMode: Boolean = false9697 private val _isUnlocked = MutableStateFlow(false)98 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()99100 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())101 102 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()103104 private val _settings = MutableStateFlow(VaultSettings())105 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()106107 fun hasVault(): Boolean = store.hasVault()108109 110111 112113114115116 fun codexVaultBlobBase64(): String? {117 val slotA = store.loadSlotA() ?: return null118 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""119 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))120 }121122 123124125126127 128 private fun JsonElement?.asSlotJson(): String? = when {129 this == null || this is JsonNull -> null130 this is JsonPrimitive && isString -> content131 else -> toString()132 }133134 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {135 var candidateKey: ByteArray? = null136 var plaintext: ByteArray? = null137 return try {138 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)139 140 141 142 143 144 145 146 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject147 ?: return UnlockResult.Error("invalid_codex_blob")148 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")149 150 151 152 153 154 val slotA = VaultCodec.decodeSlot(slotJson)155 ?: BrowserSlot.parse(slotJson)?.let {156 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword157 }158 ?: return UnlockResult.Error("invalid_codex_blob")159 160 161 162 163 164 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))165 plaintext = decryptSlotABlob(slotA, candidateKey)166 167 168 169 170 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {171 VaultCrypto.zero(candidateKey)172 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))173 plaintext = decryptSlotABlob(slotA, candidateKey)174 }175 if (plaintext == null) return UnlockResult.WrongPassword176 val payload = try {177 VaultCodec.decode(plaintext)178 } catch (_: Exception) {179 return UnlockResult.Error("invalid_codex_blob")180 }181182 val priorA = store.loadSlotA()183 val priorB = store.loadSlotB()184 185 186 val codexSlotB = wrapper["b"].asSlotJson()187188 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")189 val committed = runCatching {190 val wroteB = if (codexSlotB != null) {191 store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))192 } else {193 writeMatchingNoiseSlotB(slotA)194 }195 if (!wroteB) throw IllegalStateException("storage_write_failed")196 store.stampLocalMutation()197 }.isSuccess198 if (!committed) {199 rollbackRestore(priorA, priorB)200 return UnlockResult.Error("storage_write_failed")201 }202203 val ownedKey = candidateKey!!204 candidateKey = null 205 adoptSlotA(ownedKey, payload)206 } catch (e: Exception) {207 208 209 210 211 212 213 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))214 UnlockResult.Error(e.message ?: "restore_failed")215 } finally {216 plaintext?.let(VaultCrypto::zero)217 candidateKey?.let(VaultCrypto::zero)218 }219 }220221 222 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =223 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()224225 226227228229230231 private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {232 val failures = mutableListOf<String>()233 if (priorA == null) {234 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }235 } else {236 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }237 .onFailure { failures += "writeSlotA: $it" }238 if (priorB != null) {239 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }240 .onFailure { failures += "writeSlotB: $it" }241 }242 }243 244 245 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }246 }247248 249 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {250 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)251 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()252 val window = offset?.let {253 val from = (it - 40).coerceAtLeast(0)254 val to = (it + 40).coerceAtMost(wrapperJson.length)255 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")256 }257 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()258 val slot = wrapper?.get("a")259 buildString {260 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)261 append(" | blobBase64=").append(vaultBlobBase64.length)262 append(" wrapperChars=").append(wrapperJson.length)263 append(" wrapperParses=").append(wrapper != null)264 append(" keys=").append(wrapper?.keys)265 append(" slotAKind=").append(266 when {267 slot == null -> "absent"268 slot is JsonPrimitive && slot.isString -> "string"269 slot is JsonObject -> "object:" + slot.keys270 else -> slot::class.simpleName271 },272 )273 if (window != null) append(" masked@").append(offset).append("=").append(window)274 }275 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }276277 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {278 return try {279 val salt = VaultCrypto.generateSalt()280 val key = VaultCrypto.deriveKey(password, salt)281 mutateState {282 entryList = mutableListOf()283 duressConfigured = false284 recoveryWordHash = null285 recoveryWordsSet = false286 decoyMode = false287 rootKey = generateRecoverableRootKey()288 }289 290 291 292 293 val slotA = sealSlotA(key, salt)294 if (!store.writeSlotA(slotA)) {295 lock()296 return CreateVaultResult.Error("storage_write_failed")297 }298 299 300 val created = try {301 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")302 store.stampLocalMutation()303 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")304 true305 } catch (_: Exception) {306 307 308 store.clearVault()309 false310 }311 if (!created) {312 lock()313 return CreateVaultResult.Error("storage_write_failed")314 }315 mutateState {316 derivedKey = key317 publish()318 _isUnlocked.value = true319 }320 CreateVaultResult.Success321 } catch (e: Exception) {322 lock()323 CreateVaultResult.Error(e.message ?: "create_failed")324 }325 }326327 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {328 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")329 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")330 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {331 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")332 }333 var adoptedRootKey: ByteArray? = null334 var nextDerivedKey: ByteArray? = null335 var sessionRootKey: ByteArray? = null336 return try {337 adoptedRootKey = hexToRootKey(rootKeyHex)338 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {339 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")340 }341 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)342 val salt = VaultCrypto.generateSalt()343 nextDerivedKey = VaultCrypto.deriveKey(password, salt)344 sessionRootKey = adoptedRootKey.copyOf()345 val payload = VaultPayload(346 entries = emptyList(),347 duressConfigured = false,348 rootKeyB64 = b64(sessionRootKey),349 recoveryWordHash = null,350 recoveryWordsSet = false,351 )352 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))353 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)354 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")355 356 357 val created = try {358 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")359 store.stampLocalMutation()360 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")361 true362 } catch (_: Exception) {363 store.clearVault()364 false365 }366 if (!created) return AdoptVaultResult.Error("storage_write_failed")367368 val previousDerivedKey = derivedKey369 val previousRootKey = rootKey370 mutateState {371 derivedKey = nextDerivedKey372 rootKey = sessionRootKey373 nextDerivedKey = null374 sessionRootKey = null375 entryList = mutableListOf()376 duressConfigured = false377 recoveryWordHash = null378 recoveryWordsSet = false379 decoyMode = false380 publish()381 _isUnlocked.value = true382 }383 previousDerivedKey?.let(VaultCrypto::zero)384 previousRootKey?.let(VaultCrypto::zero)385 AdoptVaultResult.Success(vaultId)386 } catch (e: Exception) {387 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")388 } finally {389 adoptedRootKey?.let(VaultCrypto::zero)390 nextDerivedKey?.let(VaultCrypto::zero)391 sessionRootKey?.let(VaultCrypto::zero)392 }393 }394395 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }396397 398 private fun unlockLocked(password: String): UnlockResult {399 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")400 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))401 val payload = try {402 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))403 } catch (_: Exception) {404 VaultCrypto.zero(key)405 406 407 408 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {409 val legacy = tryLegacyRawUnlock(password, slotA)410 if (legacy != null) return legacy411 }412 return UnlockResult.WrongPassword413 }414 return adoptSlotA(key, payload)415 }416417 418419420421422423424425 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {426 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))427 val payload = try {428 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))429 } catch (_: Exception) {430 VaultCrypto.zero(rawKey)431 return null432 }433 434 435 val newSalt = VaultCrypto.generateSalt()436 val newKey = VaultCrypto.deriveKey(password, newSalt)437 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))438 val next = StoredVault(439 version = slotA.version,440 salt = b64(newSalt),441 blob = blob,442 recoveryEscrow = slotA.recoveryEscrow,443 rootKeyEscrow = slotA.rootKeyEscrow,444 )445 446 447 448 val priorB = store.loadSlotB()449 val healed = if (!store.writeSlotA(next)) {450 false451 } else try {452 if (!writeMatchingNoiseSlotB(next)) {453 runCatching { store.writeSlotA(slotA) }454 if (priorB != null) runCatching { store.writeSlotB(priorB) }455 false456 } else {457 store.stampLocalMutation()458 true459 }460 } catch (_: Exception) {461 runCatching { store.writeSlotA(slotA) }462 if (priorB != null) runCatching { store.writeSlotB(priorB) }463 false464 }465 return if (healed) {466 VaultCrypto.zero(rawKey)467 adoptSlotA(newKey, payload) 468 } else {469 VaultCrypto.zero(newKey)470 adoptSlotA(rawKey, payload) 471 }472 }473474 475 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {476 return try {477 478 479 480 481 482 mutateState {483 derivedKey = key484 decoyMode = false485 entryList = payload.entries.toMutableList()486 duressConfigured = payload.duressConfigured487 recoveryWordHash = payload.recoveryWordHash488 recoveryWordsSet = payload.recoveryWordsSet489 _settings.value = store.loadSettings()490 ensureRootKey(payload)491 autoPurgeExpiredTrash()492 publish()493 _isUnlocked.value = true494 }495 UnlockResult.Success496 } catch (e: Exception) {497 lock()498 UnlockResult.Error(e.message ?: "unlock_failed")499 }500 }501502 503504 505506507508509510 class SlotAUnlock internal constructor(511 internal val key: ByteArray,512 internal val payload: VaultPayload,513 )514515 516517518519520521 fun tryUnlockSlotA(password: String): SlotAUnlock? {522 val slotA = store.loadSlotA() ?: return null523 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))524 return try {525 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))526 SlotAUnlock(key, payload)527 } catch (_: Exception) {528 VaultCrypto.zero(key)529 null530 }531 }532533 534 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {535 adoptSlotA(unlock.key, unlock.payload)536 }537538 539 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)540541 542543 544545546547548 class DuressUnlock internal constructor(549 internal val key: ByteArray,550 internal val payload: VaultPayload,551 )552553 554555556557558559560 fun tryUnlockDuress(password: String): DuressUnlock? {561 val slot = store.loadDecoySlot() ?: return null562 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))563 return try {564 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))565 DuressUnlock(key, payload)566 } catch (_: Exception) {567 VaultCrypto.zero(key)568 null569 }570 }571572 573 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {574 return try {575 mutateState {576 derivedKey = unlock.key577 decoyMode = true578 entryList = unlock.payload.entries.toMutableList()579 duressConfigured = false580 recoveryWordHash = unlock.payload.recoveryWordHash581 recoveryWordsSet = unlock.payload.recoveryWordsSet582 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)583 else VaultCrypto.generateRootKey()584 _settings.value = store.loadSettings()585 publish()586 _isUnlocked.value = true587 }588 UnlockResult.Success589 } catch (e: Exception) {590 UnlockResult.Error(e.message ?: "decoy_unlock_failed")591 }592 }593594 595 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)596597 598599600601602603 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {604 605 val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }606 if (!unlockedAndReal) return false607 608 609 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }610 return try {611 val salt = VaultCrypto.generateSalt()612 val key = VaultCrypto.deriveKey(duressPassphrase, salt)613 614 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))615 val plaintext = VaultCodec.encode(decoyPayload)616 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))617 VaultCrypto.zero(key)618 VaultCrypto.zero(plaintext)619 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))620 if (ok) {621 mutateState { duressConfigured = true }622 runCatching { persistSlotA(bumpVersion = false) }623 }624 ok625 } catch (_: Exception) {626 false627 }628 }629630 631 suspend fun clearDuress(): Boolean = mutex.withLock {632 store.clearDecoySlot()633 634 635 val shouldClear = mutateState { derivedKey != null && !decoyMode }636 if (shouldClear) {637 mutateState { duressConfigured = false }638 runCatching { persistSlotA(bumpVersion = false) }639 }640 true641 }642643 644 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {645 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")646 return try {647 val payload = VaultCodec.decode(648 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),649 )650 mutateState {651 derivedKey = key.copyOf()652 decoyMode = false653 entryList = payload.entries.toMutableList()654 duressConfigured = payload.duressConfigured655 recoveryWordHash = payload.recoveryWordHash656 recoveryWordsSet = payload.recoveryWordsSet657 _settings.value = store.loadSettings()658 ensureRootKey(payload)659 autoPurgeExpiredTrash()660 publish()661 _isUnlocked.value = true662 }663 UnlockResult.Success664 } catch (_: Exception) {665 UnlockResult.WrongPassword666 }667 }668669 fun lock() {670 671 672 673 674 stateLock.lock()675 try {676 derivedKey?.let(VaultCrypto::zero)677 rootKey?.let(VaultCrypto::zero)678 derivedKey = null679 rootKey = null680 entryList = mutableListOf()681 duressConfigured = false682 recoveryWordHash = null683 recoveryWordsSet = false684 decoyMode = false685 _entries.value = emptyList()686 _isUnlocked.value = false687 } finally {688 stateLock.unlock()689 }690 }691692 @Volatile private var suppressNextBackgroundLock = false693694 695696697698699700701702703 fun expectTransientBackground() {704 suppressNextBackgroundLock = true705 }706707 708 fun consumeTransientBackgroundSuppression(): Boolean {709 val suppressed = suppressNextBackgroundLock710 suppressNextBackgroundLock = false711 return suppressed712 }713714 715716 717718719720721 722723724725726727728729730731732 733 734 735 736 737 fun syncMasterKey(): ByteArray? =738 if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()739740 741742743744745746747748749 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()750751 752753 fun syncVaultId(): String? =754 if (decoyMode || !_isUnlocked.value) null755 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }756757 758759760761762 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {763 installRootKey(generateRecoverableRootKey())764 }765766 767768769770771772773774775776777778779780781782783 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {784 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {785 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")786 }787 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")788 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")789 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")790791 val incoming = hexToRootKey(rootKeyHex)792 if (incoming.size != VaultCrypto.KEY_SIZE) {793 VaultCrypto.zero(incoming)794 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")795 }796 797 798 799 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess800 if (!roundTrips) {801 VaultCrypto.zero(incoming)802 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")803 }804805 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)806 return if (installRootKey(incoming)) {807 AdoptVaultResult.Success(vaultId)808 } else {809 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")810 }811 }812813 814815816817818 fun previewRecoveryWords(rootKeyHex: String): List<String>? {819 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null820 val bytes = hexToRootKey(rootKeyHex)821 return try {822 val words = rootKeyToWords(bytes)823 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null824 } catch (_: Exception) {825 null826 } finally {827 VaultCrypto.zero(bytes)828 }829 }830831 832833834835836 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {837 838 839 840 841 842 843 data class Snapshot(844 val key: ByteArray,845 val priorRootKey: ByteArray,846 val priorRecoveryHash: String?,847 val priorRecoverySet: Boolean,848 )849 val snap = mutateState {850 val k = derivedKey ?: return false851 if (decoyMode) return false852 val priorRoot = rootKey ?: return false853 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)854 } as Snapshot? ?: return false855 val priorSlot = store.loadSlotA() ?: run {856 VaultCrypto.zero(snap.priorRootKey)857 return false858 }859 val nextWords = rootKeyToWords(nextRootKey)860861 return try {862 takeVaultSnapshot("local_save")863 mutateState {864 rootKey = nextRootKey865 recoveryWordHash = recoveryWordsHash(nextWords)866 recoveryWordsSet = false867 }868869 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)870 ?: throw IllegalStateException("recovery_escrow_failed")871 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)872 ?: throw IllegalStateException("root_escrow_failed")873 val plaintext = VaultCodec.encode(currentPayload())874 val blob = try {875 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))876 } finally {877 VaultCrypto.zero(plaintext)878 }879 val nextSlot = priorSlot.copy(880 blob = blob,881 recoveryEscrow = nextRecoveryEscrow,882 rootKeyEscrow = nextRootKeyEscrow,883 )884 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")885 try {886 writeMatchingNoiseSlotB(nextSlot)887 store.stampLocalMutation()888 } catch (e: Exception) {889 store.writeSlotA(priorSlot)890 throw e891 }892 VaultCrypto.zero(snap.priorRootKey)893 mutateState { publish() }894 true895 } catch (_: Exception) {896 mutateState {897 rootKey = snap.priorRootKey898 recoveryWordHash = snap.priorRecoveryHash899 recoveryWordsSet = snap.priorRecoverySet900 }901 VaultCrypto.zero(nextRootKey)902 store.writeSlotA(priorSlot)903 false904 }905 }906907 908909910911912 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()913914 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {915 val slotA = store.loadSlotA() ?: return false916 var oldKey: ByteArray? = null917 var newKey: ByteArray? = null918 var plaintext: ByteArray? = null919 return try {920 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))921 val payload = VaultCodec.decode(922 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),923 )924 val newSalt = VaultCrypto.generateSalt()925 newKey = VaultCrypto.deriveKey(newPassword, newSalt)926 927 plaintext = VaultCodec.encode(payload)928 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))929 930 931 932 933 934 935 936 val next = StoredVault(937 version = slotA.version,938 salt = b64(newSalt),939 blob = blob,940 recoveryEscrow = slotA.recoveryEscrow,941 rootKeyEscrow = slotA.rootKeyEscrow,942 )943 944 945 946 947 val priorB = store.loadSlotB()948 if (!commitVaultRotation(slotA, priorB, next)) return false949 950 951 952 mutateState {953 oldKey?.let(VaultCrypto::zero); oldKey = null954 derivedKey?.let(VaultCrypto::zero)955 derivedKey = newKey; newKey = null956 }957 true958 } catch (_: Exception) {959 false960 } finally {961 oldKey?.let(VaultCrypto::zero)962 newKey?.let(VaultCrypto::zero)963 plaintext?.let(VaultCrypto::zero)964 }965 }966967 968969970971 suspend fun destroyVault() = mutex.withLock {972 lock()973 store.clearVault()974 store.clearDecoySlot()975 }976977 978979 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {980 requireUnlocked()981 val stamp = VaultCrypto.monotonicEntryTimestamp()982 val now = System.currentTimeMillis()983 val entry = withDerivedLast4(draft.copy(984 id = draft.id.ifBlank { UUID.randomUUID().toString() },985 createdAt = if (draft.createdAt > 0) draft.createdAt else now,986 modifiedAt = stamp,987 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,988 ))989 990 991 val rollback = mutateState {992 val r = entryList.toMutableList()993 entryList.add(entry)994 r995 }996 persistOrRollback(rollback)997 entry998 }9991000 100110021003100410051006100710081009 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1010 requireUnlocked()1011 if (drafts.isEmpty()) return 01012 val now = System.currentTimeMillis()1013 val ready = drafts.map { draft ->1014 val stamp = VaultCrypto.monotonicEntryTimestamp()1015 withDerivedLast4(1016 draft.copy(1017 id = draft.id.ifBlank { UUID.randomUUID().toString() },1018 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1019 modifiedAt = stamp,1020 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1021 ),1022 )1023 }1024 1025 val rollback = mutateState {1026 val r = entryList.toMutableList()1027 entryList.addAll(ready)1028 r1029 }1030 persistOrRollback(rollback)1031 return drafts.size1032 }10331034 1035103610371038103910401041104210431044104510461047 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1048 requireUnlocked()1049 val now = System.currentTimeMillis()1050 val ready = newEntries.map { draft ->1051 val stamp = VaultCrypto.monotonicEntryTimestamp()1052 withDerivedLast4(1053 draft.copy(1054 id = draft.id.ifBlank { UUID.randomUUID().toString() },1055 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1056 modifiedAt = stamp,1057 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1058 ),1059 )1060 }1061 1062 1063 1064 1065 1066 1067 val incomingIds = ready.map { it.id }.toHashSet()1068 val rollback = mutateState {1069 val r = entryList.toMutableList()1070 val replaced = entryList.map { entry ->1071 if (entry.deletedAt != null) return@map entry1072 if (entry.id in incomingIds) return@map null1073 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1074 entry.copy(deletedAt = stamp, modifiedAt = stamp)1075 }.filterNotNull()1076 entryList = (replaced + ready).toMutableList()1077 r1078 }1079 persistOrRollback(rollback)1080 return ready.size1081 }10821083 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1084 requireUnlocked()1085 1086 1087 1088 1089 var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1090 mutateState {1091 val idx = entryList.indexOfFirst { it.id == patch.id }1092 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1093 val current = entryList[idx]1094 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1095 1096 val passwordChanged = patch.password != null && patch.password != current.password1097 1098 1099 1100 1101 1102 1103 val priorPassword = current.password1104 val shouldCapture = passwordChanged &&1105 !priorPassword.isNullOrEmpty() &&1106 current.passwordHistory?.firstOrNull()?.password != priorPassword1107 val nextHistory = if (shouldCapture) {1108 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1109 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1110 } else {1111 current.passwordHistory1112 }1113 val updated = withDerivedLast4(patch.copy(1114 createdAt = current.createdAt,1115 modifiedAt = stamp,1116 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1117 passwordHistory = nextHistory,1118 ))1119 val rollback = entryList.toMutableList()1120 entryList[idx] = updated1121 prepared = updated to rollback1122 }1123 val (updated, rollback) = prepared!!1124 persistOrRollback(rollback)1125 updated1126 }11271128 1129 suspend fun deleteEntry(id: String) = mutex.withLock {1130 requireUnlocked()1131 1132 1133 var rollback: MutableList<VaultEntry>? = null1134 mutateState {1135 val idx = entryList.indexOfFirst { it.id == id }1136 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1137 val current = entryList[idx]1138 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1139 rollback = entryList.toMutableList()1140 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1141 }1142 persistOrRollback(rollback!!)1143 }11441145 suspend fun restoreEntry(id: String) = mutex.withLock {1146 requireUnlocked()1147 var rollback: MutableList<VaultEntry>? = null1148 mutateState {1149 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1150 if (idx == -1) throw IllegalStateException("deleted entry not found")1151 val current = entryList[idx]1152 rollback = entryList.toMutableList()1153 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1154 }1155 persistOrRollback(rollback!!)1156 }11571158 1159 suspend fun purgeEntry(id: String) = mutex.withLock {1160 requireUnlocked()1161 var rollback: MutableList<VaultEntry>? = null1162 mutateState {1163 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1164 if (idx == -1) throw IllegalStateException("deleted entry not found")1165 rollback = entryList.toMutableList()1166 entryList.removeAt(idx)1167 }1168 persistOrRollback(rollback!!)1169 }11701171 1172 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1173 requireUnlocked()1174 1175 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1176 val r = mutateState {1177 val rollback = entryList.toMutableList()1178 val before = entryList.size1179 entryList.removeAll { it.deletedAt != null }1180 R(rollback, before - entryList.size)1181 }1182 if (r.removed > 0) persistOrRollback(r.rollback)1183 r.removed1184 }11851186 1187 suspend fun countDuplicateEntries(): Int = mutex.withLock {1188 requireUnlocked()1189 1190 1191 mutateState { countDuplicateEntriesLocked() }1192 }11931194 11951196119711981199 suspend fun deduplicateEntries(): Int = mutex.withLock {1200 requireUnlocked()1201 1202 1203 val decoy = mutateState { decoyMode }1204 if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1205 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1206 val ready = mutateState {1207 val active = entryList.filter { it.deletedAt == null }1208 val keepIds = mutableSetOf<String>()1209 active.groupBy { it.duplicateKey() }.values.forEach { group ->1210 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1211 }1212 val rollback = entryList.toMutableList()1213 var removed = 01214 entryList = entryList.map { entry ->1215 if (entry.deletedAt == null && entry.id !in keepIds) {1216 removed++1217 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1218 entry.copy(deletedAt = stamp, modifiedAt = stamp)1219 } else {1220 entry1221 }1222 }.toMutableList()1223 R(rollback, removed)1224 }1225 if (ready.removed > 0) persistOrRollback(ready.rollback)1226 ready.removed1227 }12281229 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1230 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1231 fun getDeletedEntries(): List<VaultEntry> =1232 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }12331234 1235 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1236 val active = entryList.filter { it.deletedAt == null }1237 if (tagFilter == null) return active1238 val needle = tagFilter.trim().lowercase()1239 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1240 }12411242 1243 fun getDistinctTags(): List<String> =1244 entryList.asSequence()1245 .filter { it.deletedAt == null }1246 .flatMap { it.tags?.asSequence() ?: emptySequence() }1247 .map { it.trim().lowercase() }1248 .filter { it.isNotEmpty() }1249 .distinct()1250 .sorted()1251 .toList()12521253 125412551256125712581259 fun getDistinctFieldValues(field: String): List<String> {1260 val select: (VaultEntry) -> String? = when (field) {1261 "username" -> { e -> e.username }1262 "email" -> { e -> e.email }1263 else -> return emptyList()1264 }1265 return entryList.asSequence()1266 .filter { it.deletedAt == null }1267 .mapNotNull { select(it)?.trim() }1268 .filter { it.isNotEmpty() }1269 .groupingBy { it.lowercase() }1270 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1271 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1272 }1273 .values1274 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1275 .map { it.first }1276 }12771278 fun getTombstones(): List<Tombstone> =1279 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1280 fun getVaultVersion(): Long = store.version()1281 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots12821283 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1284 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1285 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1286 val priorA = store.loadSlotA()1287 val priorB = store.loadSlotB()1288 1289 1290 1291 1292 1293 val priorHistory = store.loadVaultHistory()1294 1295 1296 1297 1298 if (!takeVaultSnapshot("restore")) {1299 return false1300 }1301 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1302 store.writeVaultHistory(priorHistory)1303 return false1304 }1305 lock()1306 true1307 }13081309 13101311131213131314131513161317 private fun commitRestoreAtomic(1318 nextA: StoredVault,1319 nextB: String,1320 priorA: StoredVault?,1321 priorB: String?,1322 ): Boolean {1323 if (!store.writeSlotA(nextA)) return false1324 1325 1326 1327 try {1328 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1329 store.stampLocalMutation()1330 } catch (_: Exception) {1331 rollbackRestore(priorA, priorB)1332 return false1333 }1334 return true1335 }13361337 13381339 13401341134213431344 suspend fun applyMergedRemoteEntries(1345 remoteEntries: List<VaultEntry>,1346 remoteTombstones: List<Tombstone>,1347 ): Int = mutex.withLock {1348 requireUnlocked()1349 1350 1351 1352 1353 1354 val mergeNow = System.currentTimeMillis()1355 val clamped = remoteEntries.map { entry ->1356 entry.copy(1357 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1358 deletedAt = entry.deletedAt?.let {1359 SyncLimits.clampSyncTimestamp(it, mergeNow)1360 },1361 )1362 }1363 val tombMap = remoteTombstones.associate {1364 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1365 }1366 1367 1368 val applied = mutateState {1369 var n = 01370 val byId = entryList.associateBy { it.id }.toMutableMap()13711372 for (remote in clamped) {1373 val tomb = tombMap[remote.id]1374 if (tomb != null && tomb >= remote.modifiedAt) continue1375 val local = byId[remote.id]1376 if (local == null) {1377 entryList.add(remote); byId[remote.id] = remote; n++1378 } else if (remote.modifiedAt > local.modifiedAt) {1379 val i = entryList.indexOfFirst { it.id == local.id }1380 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1381 }1382 }13831384 for ((id, deletedAt) in tombMap) {1385 val local = byId[id] ?: continue1386 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1387 if (deletedAt < local.modifiedAt) continue1388 val i = entryList.indexOfFirst { it.id == id }1389 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1390 }1391 n1392 }13931394 if (applied > 0) {1395 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1396 mutateState { publish() }1397 }1398 applied1399 }14001401 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1402 1403 1404 1405 mutateState { _settings.value = settings }1406 store.writeSettings(settings)1407 }14081409 14101411 1412 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet14131414 14151416141714181419 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1420 requireUnlocked()1421 1422 1423 1424 1425 1426 1427 val prior = mutateState {1428 val priorPair = recoveryWordHash to recoveryWordsSet1429 recoveryWordHash = recoveryWordsHash(words)1430 recoveryWordsSet = true1431 priorPair1432 }1433 1434 1435 1436 1437 val persisted = runCatching {1438 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1439 }.getOrDefault(false)1440 if (!persisted) {1441 mutateState {1442 recoveryWordHash = prior.first1443 recoveryWordsSet = prior.second1444 }1445 }1446 persisted1447 }14481449 1450 fun verifyRecoveryWords(words: List<String>): Boolean {1451 val stored = recoveryWordHash ?: return false1452 return stored == recoveryWordsHash(words)1453 }14541455 14561457 1458 fun hasRecoveryEscrow(): Boolean =1459 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false14601461 146214631464146514661467146814691470147114721473 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1474 val slotA = store.loadSlotA() ?: return false1475 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false14761477 1478 1479 slotA.recoveryEscrow?.let { escrow ->1480 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1481 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1482 val keyBytes = recoveryKey.encoded1483 try {1484 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1485 val payload = VaultCodec.decode(plaintext)1486 1487 mutateState {1488 decoyMode = false1489 entryList = payload.entries.toMutableList()1490 duressConfigured = payload.duressConfigured1491 recoveryWordHash = payload.recoveryWordHash1492 recoveryWordsSet = payload.recoveryWordsSet1493 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1494 else generateRecoverableRootKey()1495 _settings.value = store.loadSettings()1496 }1497 VaultCrypto.zero(plaintext)1498 return true1499 } catch (_: Exception) {1500 1501 } finally {1502 VaultCrypto.zero(material)1503 VaultCrypto.zero(keyBytes)1504 }1505 }15061507 1508 1509 1510 slotA.rootKeyEscrow?.let { escrow ->1511 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1512 mutateState {1513 decoyMode = false1514 entryList = mutableListOf()1515 duressConfigured = false1516 recoveryWordHash = recoveryWordsHash(words)1517 recoveryWordsSet = true1518 rootKey = rk1519 _settings.value = store.loadSettings()1520 }1521 return true1522 }1523 return false1524 }15251526 152715281529153015311532153315341535 suspend fun resetPasswordWithRecoveryWords(1536 words: List<String>,1537 newPassword: String,1538 ): Boolean = mutex.withLock {1539 1540 1541 1542 if (!mutateState { rootKey != null }) return false1543 val priorA = store.loadSlotA() ?: return false1544 var newKey: ByteArray? = null1545 var plaintext: ByteArray? = null1546 return try {1547 val newSalt = VaultCrypto.generateSalt()1548 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1549 plaintext = VaultCodec.encode(currentPayload())1550 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1551 1552 1553 1554 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1555 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1556 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1557 val next = StoredVault(1558 version = priorA.version,1559 salt = b64(newSalt),1560 blob = blob,1561 recoveryEscrow = freshRecovery,1562 rootKeyEscrow = freshRootKey,1563 )1564 1565 1566 1567 val priorB = store.loadSlotB()1568 if (!commitVaultRotation(priorA, priorB, next)) return false1569 1570 1571 1572 mutateState {1573 derivedKey?.let(VaultCrypto::zero)1574 derivedKey = newKey; newKey = null1575 publish()1576 _isUnlocked.value = true1577 }1578 true1579 } catch (_: Exception) {1580 false1581 } finally {1582 newKey?.let(VaultCrypto::zero)1583 plaintext?.let(VaultCrypto::zero)1584 }1585 }15861587 158815891590159115921593 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1594 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1595 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1596 val salt = VaultCrypto.generateSalt()1597 val recoveryKey = deriveRecoveryKey(material, salt)1598 val keyBytes = recoveryKey.encoded1599 val plaintext = VaultCodec.encode(currentPayload())1600 return try {1601 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1602 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1603 } finally {1604 VaultCrypto.zero(material)1605 VaultCrypto.zero(keyBytes)1606 VaultCrypto.zero(plaintext)1607 }1608 }16091610 16111612161316141615161616171618 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1619 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1620 val rk = rootKey ?: return null1621 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1622 val salt = VaultCrypto.generateSalt()1623 val recoveryKey = deriveRecoveryKey(material, salt)1624 val keyBytes = recoveryKey.encoded1625 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1626 return try {1627 1628 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1629 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1630 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1631 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1632 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1633 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1634 } finally {1635 VaultCrypto.zero(material)1636 VaultCrypto.zero(keyBytes)1637 VaultCrypto.zero(plaintext)1638 }1639 }16401641 1642 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1643 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1644 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1645 val keyBytes = recoveryKey.encoded1646 var pt: ByteArray? = null1647 return try {1648 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1649 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1650 hexToRootKey(String(pt, Charsets.US_ASCII))1651 } catch (_: Exception) {1652 null1653 } finally {1654 1655 1656 1657 VaultCrypto.zero(material)1658 VaultCrypto.zero(keyBytes)1659 pt?.let(VaultCrypto::zero)1660 }1661 }16621663 166416651666166716681669 private fun persistSlotAWithEscrows(1670 recoveryEscrow: RecoveryEscrow?,1671 rootKeyEscrow: RootKeyEscrow?,1672 ): Boolean {1673 val key = derivedKey ?: throw IllegalStateException("vault_locked")1674 if (decoyMode) return false1675 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1676 val plaintext = VaultCodec.encode(currentPayload())1677 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1678 val next = prior.copy(1679 blob = blob,1680 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1681 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1682 )1683 if (!store.writeSlotA(next)) return false1684 try {1685 writeMatchingNoiseSlotB(next)1686 } catch (e: Exception) {1687 store.writeSlotA(prior)1688 throw e1689 }1690 return true1691 }16921693 16941695 private fun requireUnlocked() {1696 1697 1698 1699 stateLock.lock()1700 try {1701 if (derivedKey == null) throw IllegalStateException("vault_locked")1702 } finally {1703 stateLock.unlock()1704 }1705 }17061707 private fun countDuplicateEntriesLocked(): Int {1708 val seen = mutableMapOf<String, VaultEntry>()1709 var duplicates = 01710 for (entry in entryList.filter { it.deletedAt == null }) {1711 val key = entry.duplicateKey()1712 val existing = seen[key]1713 if (existing == null) {1714 seen[key] = entry1715 } else {1716 duplicates++1717 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1718 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1719 if (entryStamp > existingStamp) seen[key] = entry1720 }1721 }1722 return duplicates1723 }17241725 private fun VaultEntry.duplicateKey(): String {1726 if (type == "note") return "note|${title.lowercase()}"1727 val passkeyData = passkey1728 if (passkeyData != null) {1729 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1730 }1731 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1732 }17331734 173517361737173817391740 private fun takeVaultSnapshot(reason: String): Boolean {1741 val slotA = store.loadSlotA() ?: return false1742 val vaultA = VaultCodec.encodeSlot(slotA)1743 val vaultB = store.loadSlotB() ?: slotA.blob1744 val history = store.loadVaultHistory()1745 val latest = history.snapshots.firstOrNull()1746 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1747 val entryCount = if (derivedKey != null && !decoyMode) {1748 bucketEntryCount(entryList.count { it.deletedAt == null })1749 } else {1750 null1751 }1752 return try {1753 val snapshot = VaultSnapshot(1754 id = UUID.randomUUID().toString(),1755 savedAt = System.currentTimeMillis(),1756 reason = reason,1757 vault_a = vaultA,1758 vault_b = vaultB,1759 entryCount = entryCount,1760 )1761 1762 1763 1764 store.writeVaultHistory(1765 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1766 )1767 } catch (_: Exception) {1768 false1769 }1770 }17711772 private fun bucketEntryCount(n: Int): Int = when {1773 n <= 0 -> 01774 n <= 10 -> 101775 n <= 25 -> 251776 n <= 50 -> 501777 n <= 100 -> 1001778 n <= 250 -> 2501779 n <= 500 -> 5001780 else -> ((n + 499) / 500) * 5001781 }17821783 178417851786178717881789179017911792 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1793 if (!entry.isCard()) return entry1794 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1795 val derived = if (digits.length >= 4) digits.takeLast(4) else null1796 return entry.copy(last4 = derived ?: entry.last4)1797 }17981799 180018011802180318041805 private fun autoPurgeExpiredTrash() {1806 val days = _settings.value.trashRetentionDays ?: return1807 if (days <= 0) return1808 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1809 val before = entryList.size1810 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1811 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1812 }18131814 1815 private fun currentPayload(): VaultPayload =1816 VaultPayload(1817 entries = entryList.toList(),1818 duressConfigured = duressConfigured,1819 rootKeyB64 = rootKey?.let(::b64) ?: "",1820 recoveryWordHash = recoveryWordHash,1821 recoveryWordsSet = recoveryWordsSet,1822 )18231824 18251826182718281829 private fun ensureRootKey(payload: VaultPayload) {1830 if (payload.rootKeyB64.isNotBlank()) {1831 rootKey = unb64(payload.rootKeyB64)1832 return1833 }1834 rootKey = generateRecoverableRootKey()1835 runCatching { persistSlotA(bumpVersion = false) }1836 }18371838 private fun publish() {1839 _entries.value = entryList.filter { it.deletedAt == null }1840 }18411842 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1843 var plaintext = VaultCodec.encode(currentPayload())1844 return try {1845 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1846 StoredVault(version = 1, salt = b64(salt), blob = blob)1847 } finally {1848 1849 1850 1851 VaultCrypto.zero(plaintext)1852 }1853 }18541855 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1856 1857 1858 1859 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1860 val noise = ByteArray(approxBytes).also(rng::nextBytes)1861 return store.writeSlotB(b64(noise))1862 }18631864 1865 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1866 val key = derivedKey ?: throw IllegalStateException("vault_locked")1867 var plaintext: ByteArray? = null1868 return try {1869 1870 if (decoyMode) {1871 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1872 plaintext = VaultCodec.encode(currentPayload())1873 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1874 plaintext = null1875 return store.writeDecoySlot(prior.copy(blob = blob))1876 }1877 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1878 1879 1880 1881 1882 val priorB = store.loadSlotB()1883 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1884 plaintext = VaultCodec.encode(currentPayload())1885 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1886 plaintext = null1887 val next = prior.copy(blob = blob)1888 if (!store.writeSlotA(next)) return false1889 1890 1891 1892 1893 try {1894 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1895 if (bumpVersion) store.stampLocalMutation()1896 } catch (e: Exception) {1897 store.writeSlotA(prior)1898 if (priorB != null) runCatching { store.writeSlotB(priorB) }1899 throw e1900 }1901 true1902 } finally {1903 1904 1905 1906 plaintext?.let(VaultCrypto::zero)1907 }1908 }19091910 1911191219131914191519161917 private fun commitVaultRotation(1918 priorA: StoredVault,1919 priorB: String?,1920 nextA: StoredVault,1921 ): Boolean {1922 if (!store.writeSlotA(nextA)) return false1923 1924 1925 1926 try {1927 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1928 store.stampLocalMutation()1929 } catch (_: Exception) {1930 rollbackRotation(priorA, priorB)1931 return false1932 }1933 return true1934 }19351936 19371938193919401941 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1942 val failures = mutableListOf<String>()1943 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1944 .onFailure { failures += "writeSlotA: $it" }1945 if (priorB != null) {1946 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1947 .onFailure { failures += "writeSlotB: $it" }1948 }1949 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1950 }19511952 1953 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1954 try {1955 if (!persistSlotA(bumpVersion = true)) {1956 mutateState { entryList = rollback }1957 throw IllegalStateException("storage_write_failed")1958 }1959 } catch (e: Exception) {1960 mutateState { entryList = rollback }1961 throw e1962 }1963 mutateState { publish() }1964 }1965}