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 112113114115116117118 fun codexVaultBlobBase64(): String? {119 val slotA = store.loadSlotA() ?: return null120 121 122 val slotD = store.loadDecoySlot() ?: return null123 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""124 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))125 }126127 128129130131132 133 private fun JsonElement?.asSlotJson(): String? = when {134 this == null || this is JsonNull -> null135 this is JsonPrimitive && isString -> content136 else -> toString()137 }138139 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {140 var candidateKey: ByteArray? = null141 var plaintext: ByteArray? = null142 return try {143 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)144 145 146 147 148 149 150 151 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject152 ?: return UnlockResult.Error("invalid_codex_blob")153 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")154 155 156 157 158 159 val slotA = VaultCodec.decodeSlot(slotJson)160 ?: BrowserSlot.parse(slotJson)?.let {161 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword162 }163 ?: return UnlockResult.Error("invalid_codex_blob")164 165 166 167 168 169 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))170 plaintext = decryptSlotABlob(slotA, candidateKey)171 172 173 174 175 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {176 VaultCrypto.zero(candidateKey)177 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))178 plaintext = decryptSlotABlob(slotA, candidateKey)179 }180 if (plaintext == null) return UnlockResult.WrongPassword181 val payload = try {182 VaultCodec.decode(plaintext)183 } catch (_: Exception) {184 return UnlockResult.Error("invalid_codex_blob")185 }186187 val priorA = store.loadSlotA()188 val priorB = store.loadSlotB()189 val priorD = store.loadDecoySlot()190 191 192 193 194 195 val codexDuressJson = wrapper["b"].asSlotJson()196 val codexDuress: StoredVault? = codexDuressJson?.let {197 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->198 199 200 201 BrowserSlot.toAndroidSlot(b, password)202 }203 }204205 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")206 val committed = runCatching {207 208 209 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")210 211 212 val duressOk = if (codexDuress != null) {213 store.writeDecoySlot(codexDuress)214 } else {215 writeFakeDuressSlot(slotA)216 }217 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")218 store.stampLocalMutation()219 }.isSuccess220 if (!committed) {221 rollbackRestore(priorA, priorB, priorD)222 return UnlockResult.Error("storage_write_failed")223 }224225 val ownedKey = candidateKey!!226 candidateKey = null 227 adoptSlotA(ownedKey, payload)228 } catch (e: Exception) {229 230 231 232 233 234 235 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))236 UnlockResult.Error(e.message ?: "restore_failed")237 } finally {238 plaintext?.let(VaultCrypto::zero)239 candidateKey?.let(VaultCrypto::zero)240 }241 }242243 244 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =245 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()246247 248249250251252253 private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {254 val failures = mutableListOf<String>()255 if (priorA == null) {256 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }257 } else {258 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }259 .onFailure { failures += "writeSlotA: $it" }260 if (priorB != null) {261 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }262 .onFailure { failures += "writeSlotB: $it" }263 }264 265 266 267 if (priorD != null) {268 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }269 .onFailure { failures += "writeDecoySlot: $it" }270 } else {271 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }272 }273 }274 275 276 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }277 }278279 280 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {281 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)282 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()283 val window = offset?.let {284 val from = (it - 40).coerceAtLeast(0)285 val to = (it + 40).coerceAtMost(wrapperJson.length)286 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")287 }288 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()289 val slot = wrapper?.get("a")290 buildString {291 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)292 append(" | blobBase64=").append(vaultBlobBase64.length)293 append(" wrapperChars=").append(wrapperJson.length)294 append(" wrapperParses=").append(wrapper != null)295 append(" keys=").append(wrapper?.keys)296 append(" slotAKind=").append(297 when {298 slot == null -> "absent"299 slot is JsonPrimitive && slot.isString -> "string"300 slot is JsonObject -> "object:" + slot.keys301 else -> slot::class.simpleName302 },303 )304 if (window != null) append(" masked@").append(offset).append("=").append(window)305 }306 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }307308 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {309 return try {310 val salt = VaultCrypto.generateSalt()311 val key = VaultCrypto.deriveKey(password, salt)312 mutateState {313 entryList = mutableListOf()314 duressConfigured = false315 recoveryWordHash = null316 recoveryWordsSet = false317 decoyMode = false318 rootKey = generateRecoverableRootKey()319 }320 321 322 323 324 val slotA = sealSlotA(key, salt)325 if (!store.writeSlotA(slotA)) {326 lock()327 return CreateVaultResult.Error("storage_write_failed")328 }329 330 331 val created = try {332 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")333 334 335 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")336 store.stampLocalMutation()337 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")338 true339 } catch (_: Exception) {340 341 342 store.clearVault()343 false344 }345 if (!created) {346 lock()347 return CreateVaultResult.Error("storage_write_failed")348 }349 mutateState {350 derivedKey = key351 publish()352 _isUnlocked.value = true353 }354 CreateVaultResult.Success355 } catch (e: Exception) {356 lock()357 CreateVaultResult.Error(e.message ?: "create_failed")358 }359 }360361 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {362 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")363 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")364 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {365 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")366 }367 var adoptedRootKey: ByteArray? = null368 var nextDerivedKey: ByteArray? = null369 var sessionRootKey: ByteArray? = null370 return try {371 adoptedRootKey = hexToRootKey(rootKeyHex)372 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {373 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")374 }375 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)376 val salt = VaultCrypto.generateSalt()377 nextDerivedKey = VaultCrypto.deriveKey(password, salt)378 sessionRootKey = adoptedRootKey.copyOf()379 val payload = VaultPayload(380 entries = emptyList(),381 duressConfigured = false,382 rootKeyB64 = b64(sessionRootKey),383 recoveryWordHash = null,384 recoveryWordsSet = false,385 )386 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))387 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)388 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")389 390 391 val created = try {392 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")393 394 395 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")396 store.stampLocalMutation()397 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")398 true399 } catch (_: Exception) {400 store.clearVault()401 false402 }403 if (!created) return AdoptVaultResult.Error("storage_write_failed")404405 val previousDerivedKey = derivedKey406 val previousRootKey = rootKey407 mutateState {408 derivedKey = nextDerivedKey409 rootKey = sessionRootKey410 nextDerivedKey = null411 sessionRootKey = null412 entryList = mutableListOf()413 duressConfigured = false414 recoveryWordHash = null415 recoveryWordsSet = false416 decoyMode = false417 publish()418 _isUnlocked.value = true419 }420 previousDerivedKey?.let(VaultCrypto::zero)421 previousRootKey?.let(VaultCrypto::zero)422 AdoptVaultResult.Success(vaultId)423 } catch (e: Exception) {424 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")425 } finally {426 adoptedRootKey?.let(VaultCrypto::zero)427 nextDerivedKey?.let(VaultCrypto::zero)428 sessionRootKey?.let(VaultCrypto::zero)429 }430 }431432 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }433434 435 private fun unlockLocked(password: String): UnlockResult {436 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")437 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))438 val payload = try {439 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))440 } catch (_: Exception) {441 VaultCrypto.zero(key)442 443 444 445 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {446 val legacy = tryLegacyRawUnlock(password, slotA)447 if (legacy != null) return legacy448 }449 return UnlockResult.WrongPassword450 }451 return adoptSlotA(key, payload)452 }453454 455456457458459460461462 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {463 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))464 val payload = try {465 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))466 } catch (_: Exception) {467 VaultCrypto.zero(rawKey)468 return null469 }470 471 472 val newSalt = VaultCrypto.generateSalt()473 val newKey = VaultCrypto.deriveKey(password, newSalt)474 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))475 val next = StoredVault(476 version = slotA.version,477 salt = b64(newSalt),478 blob = blob,479 recoveryEscrow = slotA.recoveryEscrow,480 rootKeyEscrow = slotA.rootKeyEscrow,481 )482 483 484 485 val priorB = store.loadSlotB()486 val healed = if (!store.writeSlotA(next)) {487 false488 } else try {489 if (!writeMatchingNoiseSlotB(next)) {490 runCatching { store.writeSlotA(slotA) }491 if (priorB != null) runCatching { store.writeSlotB(priorB) }492 false493 } else if (!writeCompanionDuressSlot(next)) {494 runCatching { store.writeSlotA(slotA) }495 if (priorB != null) runCatching { store.writeSlotB(priorB) }496 false497 } else {498 store.stampLocalMutation()499 true500 }501 } catch (_: Exception) {502 runCatching { store.writeSlotA(slotA) }503 if (priorB != null) runCatching { store.writeSlotB(priorB) }504 false505 }506 return if (healed) {507 VaultCrypto.zero(rawKey)508 adoptSlotA(newKey, payload) 509 } else {510 VaultCrypto.zero(newKey)511 adoptSlotA(rawKey, payload) 512 }513 }514515 516 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {517 return try {518 519 520 521 522 523 mutateState {524 derivedKey = key525 decoyMode = false526 entryList = payload.entries.toMutableList()527 duressConfigured = payload.duressConfigured528 recoveryWordHash = payload.recoveryWordHash529 recoveryWordsSet = payload.recoveryWordsSet530 _settings.value = store.loadSettings()531 ensureRootKey(payload)532 autoPurgeExpiredTrash()533 publish()534 _isUnlocked.value = true535 }536 UnlockResult.Success537 } catch (e: Exception) {538 lock()539 UnlockResult.Error(e.message ?: "unlock_failed")540 }541 }542543 544545 546547548549550551 class SlotAUnlock internal constructor(552 internal val key: ByteArray,553 internal val payload: VaultPayload,554 )555556 557558559560561562 fun tryUnlockSlotA(password: String): SlotAUnlock? {563 val slotA = store.loadSlotA() ?: return null564 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))565 return try {566 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))567 SlotAUnlock(key, payload)568 } catch (_: Exception) {569 VaultCrypto.zero(key)570 null571 }572 }573574 575 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {576 adoptSlotA(unlock.key, unlock.payload)577 }578579 580 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)581582 583584 585586587588589 class DuressUnlock internal constructor(590 internal val key: ByteArray,591 internal val payload: VaultPayload,592 )593594 595596597598599600601 fun tryUnlockDuress(password: String): DuressUnlock? {602 val slot = store.loadDecoySlot() ?: return null603 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))604 return try {605 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))606 DuressUnlock(key, payload)607 } catch (_: Exception) {608 VaultCrypto.zero(key)609 null610 }611 }612613 614 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {615 return try {616 mutateState {617 derivedKey = unlock.key618 decoyMode = true619 entryList = unlock.payload.entries.toMutableList()620 duressConfigured = false621 recoveryWordHash = unlock.payload.recoveryWordHash622 recoveryWordsSet = unlock.payload.recoveryWordsSet623 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)624 else VaultCrypto.generateRootKey()625 _settings.value = store.loadSettings()626 publish()627 _isUnlocked.value = true628 }629 UnlockResult.Success630 } catch (e: Exception) {631 UnlockResult.Error(e.message ?: "decoy_unlock_failed")632 }633 }634635 636 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)637638 639640641642643644 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {645 646 val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }647 if (!unlockedAndReal) return false648 649 650 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }651 return try {652 val salt = VaultCrypto.generateSalt()653 val key = VaultCrypto.deriveKey(duressPassphrase, salt)654 655 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))656 val basePlaintext = VaultCodec.encode(decoyPayload)657 658 659 660 661 662 663 664 665 val slotA = store.loadSlotA()666 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0667 val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext668 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))669 VaultCrypto.zero(key)670 VaultCrypto.zero(plaintext)671 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))672 if (ok) {673 mutateState { duressConfigured = true }674 runCatching { persistSlotA(bumpVersion = false) }675 }676 ok677 } catch (_: Exception) {678 false679 }680 }681682 683 suspend fun clearDuress(): Boolean = mutex.withLock {684 685 686 687 688 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }689 ?: store.clearDecoySlot()690 691 692 val shouldClear = mutateState { derivedKey != null && !decoyMode }693 if (shouldClear) {694 mutateState { duressConfigured = false }695 runCatching { persistSlotA(bumpVersion = false) }696 }697 true698 }699700 701 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {702 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")703 return try {704 val payload = VaultCodec.decode(705 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),706 )707 mutateState {708 derivedKey = key.copyOf()709 decoyMode = false710 entryList = payload.entries.toMutableList()711 duressConfigured = payload.duressConfigured712 recoveryWordHash = payload.recoveryWordHash713 recoveryWordsSet = payload.recoveryWordsSet714 _settings.value = store.loadSettings()715 ensureRootKey(payload)716 autoPurgeExpiredTrash()717 publish()718 _isUnlocked.value = true719 }720 UnlockResult.Success721 } catch (_: Exception) {722 UnlockResult.WrongPassword723 }724 }725726 fun lock() {727 728 729 730 731 stateLock.lock()732 try {733 derivedKey?.let(VaultCrypto::zero)734 rootKey?.let(VaultCrypto::zero)735 derivedKey = null736 rootKey = null737 entryList = mutableListOf()738 duressConfigured = false739 recoveryWordHash = null740 recoveryWordsSet = false741 decoyMode = false742 _entries.value = emptyList()743 _isUnlocked.value = false744 } finally {745 stateLock.unlock()746 }747 }748749 @Volatile private var suppressNextBackgroundLock = false750751 752753754755756757758759760 fun expectTransientBackground() {761 suppressNextBackgroundLock = true762 }763764 765 fun consumeTransientBackgroundSuppression(): Boolean {766 val suppressed = suppressNextBackgroundLock767 suppressNextBackgroundLock = false768 return suppressed769 }770771 772773 774775776777778 779780781782783784785786787788789 790 791 792 793 794 fun syncMasterKey(): ByteArray? =795 if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()796797 798799800801802803804805806 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()807808 809810 fun syncVaultId(): String? =811 if (decoyMode || !_isUnlocked.value) null812 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }813814 815816817818819 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {820 installRootKey(generateRecoverableRootKey())821 }822823 824825826827828829830831832833834835836837838839840 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {841 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {842 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")843 }844 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")845 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")846 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")847848 val incoming = hexToRootKey(rootKeyHex)849 if (incoming.size != VaultCrypto.KEY_SIZE) {850 VaultCrypto.zero(incoming)851 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")852 }853 854 855 856 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess857 if (!roundTrips) {858 VaultCrypto.zero(incoming)859 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")860 }861862 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)863 return if (installRootKey(incoming)) {864 AdoptVaultResult.Success(vaultId)865 } else {866 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")867 }868 }869870 871872873874875 fun previewRecoveryWords(rootKeyHex: String): List<String>? {876 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null877 val bytes = hexToRootKey(rootKeyHex)878 return try {879 val words = rootKeyToWords(bytes)880 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null881 } catch (_: Exception) {882 null883 } finally {884 VaultCrypto.zero(bytes)885 }886 }887888 889890891892893 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {894 895 896 897 898 899 900 data class Snapshot(901 val key: ByteArray,902 val priorRootKey: ByteArray,903 val priorRecoveryHash: String?,904 val priorRecoverySet: Boolean,905 )906 val snap = mutateState {907 val k = derivedKey ?: return false908 if (decoyMode) return false909 val priorRoot = rootKey ?: return false910 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)911 } as Snapshot? ?: return false912 val priorSlot = store.loadSlotA() ?: run {913 VaultCrypto.zero(snap.priorRootKey)914 return false915 }916 val nextWords = rootKeyToWords(nextRootKey)917918 return try {919 takeVaultSnapshot("local_save")920 mutateState {921 rootKey = nextRootKey922 recoveryWordHash = recoveryWordsHash(nextWords)923 recoveryWordsSet = false924 }925926 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)927 ?: throw IllegalStateException("recovery_escrow_failed")928 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)929 ?: throw IllegalStateException("root_escrow_failed")930 val plaintext = VaultCodec.encode(currentPayload())931 val blob = try {932 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))933 } finally {934 VaultCrypto.zero(plaintext)935 }936 val nextSlot = priorSlot.copy(937 blob = blob,938 recoveryEscrow = nextRecoveryEscrow,939 rootKeyEscrow = nextRootKeyEscrow,940 )941 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")942 try {943 writeMatchingNoiseSlotB(nextSlot)944 945 946 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")947 store.stampLocalMutation()948 } catch (e: Exception) {949 store.writeSlotA(priorSlot)950 throw e951 }952 VaultCrypto.zero(snap.priorRootKey)953 mutateState { publish() }954 true955 } catch (_: Exception) {956 mutateState {957 rootKey = snap.priorRootKey958 recoveryWordHash = snap.priorRecoveryHash959 recoveryWordsSet = snap.priorRecoverySet960 }961 VaultCrypto.zero(nextRootKey)962 store.writeSlotA(priorSlot)963 false964 }965 }966967 968969970971972 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()973974 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {975 val slotA = store.loadSlotA() ?: return false976 var oldKey: ByteArray? = null977 var newKey: ByteArray? = null978 var plaintext: ByteArray? = null979 return try {980 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))981 val payload = VaultCodec.decode(982 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),983 )984 val newSalt = VaultCrypto.generateSalt()985 newKey = VaultCrypto.deriveKey(newPassword, newSalt)986 987 plaintext = VaultCodec.encode(payload)988 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))989 990 991 992 993 994 995 996 val next = StoredVault(997 version = slotA.version,998 salt = b64(newSalt),999 blob = blob,1000 recoveryEscrow = slotA.recoveryEscrow,1001 rootKeyEscrow = slotA.rootKeyEscrow,1002 )1003 1004 1005 1006 1007 val priorB = store.loadSlotB()1008 if (!commitVaultRotation(slotA, priorB, next)) return false1009 1010 1011 1012 mutateState {1013 oldKey?.let(VaultCrypto::zero); oldKey = null1014 derivedKey?.let(VaultCrypto::zero)1015 derivedKey = newKey; newKey = null1016 }1017 true1018 } catch (_: Exception) {1019 false1020 } finally {1021 oldKey?.let(VaultCrypto::zero)1022 newKey?.let(VaultCrypto::zero)1023 plaintext?.let(VaultCrypto::zero)1024 }1025 }10261027 1028102910301031 suspend fun destroyVault() = mutex.withLock {1032 lock()1033 store.clearVault()1034 store.clearDecoySlot()1035 }10361037 10381039 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1040 requireUnlocked()1041 val stamp = VaultCrypto.monotonicEntryTimestamp()1042 val now = System.currentTimeMillis()1043 val entry = withDerivedLast4(draft.copy(1044 id = draft.id.ifBlank { UUID.randomUUID().toString() },1045 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1046 modifiedAt = stamp,1047 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1048 ))1049 1050 1051 val rollback = mutateState {1052 val r = entryList.toMutableList()1053 entryList.add(entry)1054 r1055 }1056 persistOrRollback(rollback)1057 entry1058 }10591060 106110621063106410651066106710681069 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1070 requireUnlocked()1071 if (drafts.isEmpty()) return 01072 val now = System.currentTimeMillis()1073 val ready = drafts.map { draft ->1074 val stamp = VaultCrypto.monotonicEntryTimestamp()1075 withDerivedLast4(1076 draft.copy(1077 id = draft.id.ifBlank { UUID.randomUUID().toString() },1078 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1079 modifiedAt = stamp,1080 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1081 ),1082 )1083 }1084 1085 val rollback = mutateState {1086 val r = entryList.toMutableList()1087 entryList.addAll(ready)1088 r1089 }1090 persistOrRollback(rollback)1091 return drafts.size1092 }10931094 1095109610971098109911001101110211031104110511061107 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1108 requireUnlocked()1109 val now = System.currentTimeMillis()1110 val ready = newEntries.map { draft ->1111 val stamp = VaultCrypto.monotonicEntryTimestamp()1112 withDerivedLast4(1113 draft.copy(1114 id = draft.id.ifBlank { UUID.randomUUID().toString() },1115 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1116 modifiedAt = stamp,1117 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1118 ),1119 )1120 }1121 1122 1123 1124 1125 1126 1127 val incomingIds = ready.map { it.id }.toHashSet()1128 val rollback = mutateState {1129 val r = entryList.toMutableList()1130 val replaced = entryList.map { entry ->1131 if (entry.deletedAt != null) return@map entry1132 if (entry.id in incomingIds) return@map null1133 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1134 entry.copy(deletedAt = stamp, modifiedAt = stamp)1135 }.filterNotNull()1136 entryList = (replaced + ready).toMutableList()1137 r1138 }1139 persistOrRollback(rollback)1140 return ready.size1141 }11421143 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1144 requireUnlocked()1145 1146 1147 1148 1149 var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1150 mutateState {1151 val idx = entryList.indexOfFirst { it.id == patch.id }1152 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1153 val current = entryList[idx]1154 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1155 1156 val passwordChanged = patch.password != null && patch.password != current.password1157 1158 1159 1160 1161 1162 1163 val priorPassword = current.password1164 val shouldCapture = passwordChanged &&1165 !priorPassword.isNullOrEmpty() &&1166 current.passwordHistory?.firstOrNull()?.password != priorPassword1167 val nextHistory = if (shouldCapture) {1168 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1169 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1170 } else {1171 current.passwordHistory1172 }1173 val updated = withDerivedLast4(patch.copy(1174 createdAt = current.createdAt,1175 modifiedAt = stamp,1176 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1177 passwordHistory = nextHistory,1178 ))1179 val rollback = entryList.toMutableList()1180 entryList[idx] = updated1181 prepared = updated to rollback1182 }1183 val (updated, rollback) = prepared!!1184 persistOrRollback(rollback)1185 updated1186 }11871188 1189 suspend fun deleteEntry(id: String) = mutex.withLock {1190 requireUnlocked()1191 1192 1193 var rollback: MutableList<VaultEntry>? = null1194 mutateState {1195 val idx = entryList.indexOfFirst { it.id == id }1196 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1197 val current = entryList[idx]1198 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1199 rollback = entryList.toMutableList()1200 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1201 }1202 persistOrRollback(rollback!!)1203 }12041205 suspend fun restoreEntry(id: String) = mutex.withLock {1206 requireUnlocked()1207 var rollback: MutableList<VaultEntry>? = null1208 mutateState {1209 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1210 if (idx == -1) throw IllegalStateException("deleted entry not found")1211 val current = entryList[idx]1212 rollback = entryList.toMutableList()1213 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1214 }1215 persistOrRollback(rollback!!)1216 }12171218 1219 suspend fun purgeEntry(id: String) = mutex.withLock {1220 requireUnlocked()1221 var rollback: MutableList<VaultEntry>? = null1222 mutateState {1223 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1224 if (idx == -1) throw IllegalStateException("deleted entry not found")1225 rollback = entryList.toMutableList()1226 entryList.removeAt(idx)1227 }1228 persistOrRollback(rollback!!)1229 }12301231 1232 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1233 requireUnlocked()1234 1235 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1236 val r = mutateState {1237 val rollback = entryList.toMutableList()1238 val before = entryList.size1239 entryList.removeAll { it.deletedAt != null }1240 R(rollback, before - entryList.size)1241 }1242 if (r.removed > 0) persistOrRollback(r.rollback)1243 r.removed1244 }12451246 1247 suspend fun countDuplicateEntries(): Int = mutex.withLock {1248 requireUnlocked()1249 1250 1251 mutateState { countDuplicateEntriesLocked() }1252 }12531254 12551256125712581259 suspend fun deduplicateEntries(): Int = mutex.withLock {1260 requireUnlocked()1261 1262 1263 val decoy = mutateState { decoyMode }1264 if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1265 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1266 val ready = mutateState {1267 val active = entryList.filter { it.deletedAt == null }1268 val keepIds = mutableSetOf<String>()1269 active.groupBy { it.duplicateKey() }.values.forEach { group ->1270 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1271 }1272 val rollback = entryList.toMutableList()1273 var removed = 01274 entryList = entryList.map { entry ->1275 if (entry.deletedAt == null && entry.id !in keepIds) {1276 removed++1277 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1278 entry.copy(deletedAt = stamp, modifiedAt = stamp)1279 } else {1280 entry1281 }1282 }.toMutableList()1283 R(rollback, removed)1284 }1285 if (ready.removed > 0) persistOrRollback(ready.rollback)1286 ready.removed1287 }12881289 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1290 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1291 fun getDeletedEntries(): List<VaultEntry> =1292 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }12931294 1295 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1296 val active = entryList.filter { it.deletedAt == null }1297 if (tagFilter == null) return active1298 val needle = tagFilter.trim().lowercase()1299 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1300 }13011302 1303 fun getDistinctTags(): List<String> =1304 entryList.asSequence()1305 .filter { it.deletedAt == null }1306 .flatMap { it.tags?.asSequence() ?: emptySequence() }1307 .map { it.trim().lowercase() }1308 .filter { it.isNotEmpty() }1309 .distinct()1310 .sorted()1311 .toList()13121313 131413151316131713181319 fun getDistinctFieldValues(field: String): List<String> {1320 val select: (VaultEntry) -> String? = when (field) {1321 "username" -> { e -> e.username }1322 "email" -> { e -> e.email }1323 else -> return emptyList()1324 }1325 return entryList.asSequence()1326 .filter { it.deletedAt == null }1327 .mapNotNull { select(it)?.trim() }1328 .filter { it.isNotEmpty() }1329 .groupingBy { it.lowercase() }1330 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1331 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1332 }1333 .values1334 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1335 .map { it.first }1336 }13371338 fun getTombstones(): List<Tombstone> =1339 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1340 fun getVaultVersion(): Long = store.version()1341 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots13421343 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1344 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1345 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1346 1347 1348 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1349 val priorA = store.loadSlotA()1350 val priorB = store.loadSlotB()1351 val priorD = store.loadDecoySlot()1352 1353 1354 1355 1356 1357 val priorHistory = store.loadVaultHistory()1358 1359 1360 1361 1362 if (!takeVaultSnapshot("restore")) {1363 return false1364 }1365 1366 1367 1368 val nextD: StoredVault = restoredD ?: run {1369 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161370 val fakeBlob = ByteArray(target).also(rng::nextBytes)1371 val fakeSalt = VaultCrypto.generateSalt()1372 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1373 }1374 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {1375 store.writeVaultHistory(priorHistory)1376 return false1377 }1378 lock()1379 true1380 }13811382 13831384138513861387138813891390139113921393 private fun commitRestoreAtomic(1394 nextA: StoredVault,1395 nextB: String,1396 nextD: StoredVault,1397 priorA: StoredVault?,1398 priorB: String?,1399 priorD: StoredVault?,1400 ): Boolean {1401 if (!store.writeSlotA(nextA)) return false1402 1403 1404 1405 try {1406 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1407 1408 1409 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1410 store.stampLocalMutation()1411 } catch (_: Exception) {1412 rollbackRestore(priorA, priorB, priorD)1413 return false1414 }1415 return true1416 }14171418 14191420 14211422142314241425 suspend fun applyMergedRemoteEntries(1426 remoteEntries: List<VaultEntry>,1427 remoteTombstones: List<Tombstone>,1428 ): Int = mutex.withLock {1429 requireUnlocked()1430 1431 1432 1433 1434 1435 val mergeNow = System.currentTimeMillis()1436 val clamped = remoteEntries.map { entry ->1437 entry.copy(1438 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1439 deletedAt = entry.deletedAt?.let {1440 SyncLimits.clampSyncTimestamp(it, mergeNow)1441 },1442 )1443 }1444 val tombMap = remoteTombstones.associate {1445 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1446 }1447 1448 1449 val applied = mutateState {1450 var n = 01451 val byId = entryList.associateBy { it.id }.toMutableMap()14521453 for (remote in clamped) {1454 val tomb = tombMap[remote.id]1455 if (tomb != null && tomb >= remote.modifiedAt) continue1456 val local = byId[remote.id]1457 if (local == null) {1458 entryList.add(remote); byId[remote.id] = remote; n++1459 } else if (remote.modifiedAt > local.modifiedAt) {1460 val i = entryList.indexOfFirst { it.id == local.id }1461 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1462 }1463 }14641465 for ((id, deletedAt) in tombMap) {1466 val local = byId[id] ?: continue1467 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1468 if (deletedAt < local.modifiedAt) continue1469 val i = entryList.indexOfFirst { it.id == id }1470 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1471 }1472 n1473 }14741475 if (applied > 0) {1476 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1477 mutateState { publish() }1478 }1479 applied1480 }14811482 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1483 1484 1485 1486 mutateState { _settings.value = settings }1487 store.writeSettings(settings)1488 }14891490 14911492 1493 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet14941495 14961497149814991500 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1501 requireUnlocked()1502 1503 1504 1505 1506 1507 1508 val prior = mutateState {1509 val priorPair = recoveryWordHash to recoveryWordsSet1510 recoveryWordHash = recoveryWordsHash(words)1511 recoveryWordsSet = true1512 priorPair1513 }1514 1515 1516 1517 1518 val persisted = runCatching {1519 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1520 }.getOrDefault(false)1521 if (!persisted) {1522 mutateState {1523 recoveryWordHash = prior.first1524 recoveryWordsSet = prior.second1525 }1526 }1527 persisted1528 }15291530 1531 fun verifyRecoveryWords(words: List<String>): Boolean {1532 val stored = recoveryWordHash ?: return false1533 return stored == recoveryWordsHash(words)1534 }15351536 15371538 1539 fun hasRecoveryEscrow(): Boolean =1540 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false15411542 154315441545154615471548154915501551155215531554 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1555 val slotA = store.loadSlotA() ?: return false1556 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false15571558 1559 1560 slotA.recoveryEscrow?.let { escrow ->1561 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1562 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1563 val keyBytes = recoveryKey.encoded1564 try {1565 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1566 val payload = VaultCodec.decode(plaintext)1567 1568 mutateState {1569 decoyMode = false1570 entryList = payload.entries.toMutableList()1571 duressConfigured = payload.duressConfigured1572 recoveryWordHash = payload.recoveryWordHash1573 recoveryWordsSet = payload.recoveryWordsSet1574 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1575 else generateRecoverableRootKey()1576 _settings.value = store.loadSettings()1577 }1578 VaultCrypto.zero(plaintext)1579 return true1580 } catch (_: Exception) {1581 1582 } finally {1583 VaultCrypto.zero(material)1584 VaultCrypto.zero(keyBytes)1585 }1586 }15871588 1589 1590 1591 slotA.rootKeyEscrow?.let { escrow ->1592 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1593 mutateState {1594 decoyMode = false1595 entryList = mutableListOf()1596 duressConfigured = false1597 recoveryWordHash = recoveryWordsHash(words)1598 recoveryWordsSet = true1599 rootKey = rk1600 _settings.value = store.loadSettings()1601 }1602 return true1603 }1604 return false1605 }16061607 160816091610161116121613161416151616 suspend fun resetPasswordWithRecoveryWords(1617 words: List<String>,1618 newPassword: String,1619 ): Boolean = mutex.withLock {1620 1621 1622 1623 if (!mutateState { rootKey != null }) return false1624 val priorA = store.loadSlotA() ?: return false1625 var newKey: ByteArray? = null1626 var plaintext: ByteArray? = null1627 return try {1628 val newSalt = VaultCrypto.generateSalt()1629 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1630 plaintext = VaultCodec.encode(currentPayload())1631 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1632 1633 1634 1635 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1636 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1637 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1638 val next = StoredVault(1639 version = priorA.version,1640 salt = b64(newSalt),1641 blob = blob,1642 recoveryEscrow = freshRecovery,1643 rootKeyEscrow = freshRootKey,1644 )1645 1646 1647 1648 val priorB = store.loadSlotB()1649 if (!commitVaultRotation(priorA, priorB, next)) return false1650 1651 1652 1653 mutateState {1654 derivedKey?.let(VaultCrypto::zero)1655 derivedKey = newKey; newKey = null1656 publish()1657 _isUnlocked.value = true1658 }1659 true1660 } catch (_: Exception) {1661 false1662 } finally {1663 newKey?.let(VaultCrypto::zero)1664 plaintext?.let(VaultCrypto::zero)1665 }1666 }16671668 166916701671167216731674 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1675 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1676 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1677 val salt = VaultCrypto.generateSalt()1678 val recoveryKey = deriveRecoveryKey(material, salt)1679 val keyBytes = recoveryKey.encoded1680 val plaintext = VaultCodec.encode(currentPayload())1681 return try {1682 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1683 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1684 } finally {1685 VaultCrypto.zero(material)1686 VaultCrypto.zero(keyBytes)1687 VaultCrypto.zero(plaintext)1688 }1689 }16901691 16921693169416951696169716981699 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1700 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1701 val rk = rootKey ?: return null1702 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1703 val salt = VaultCrypto.generateSalt()1704 val recoveryKey = deriveRecoveryKey(material, salt)1705 val keyBytes = recoveryKey.encoded1706 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1707 return try {1708 1709 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1710 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1711 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1712 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1713 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1714 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1715 } finally {1716 VaultCrypto.zero(material)1717 VaultCrypto.zero(keyBytes)1718 VaultCrypto.zero(plaintext)1719 }1720 }17211722 1723 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1724 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1725 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1726 val keyBytes = recoveryKey.encoded1727 var pt: ByteArray? = null1728 return try {1729 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1730 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1731 hexToRootKey(String(pt, Charsets.US_ASCII))1732 } catch (_: Exception) {1733 null1734 } finally {1735 1736 1737 1738 VaultCrypto.zero(material)1739 VaultCrypto.zero(keyBytes)1740 pt?.let(VaultCrypto::zero)1741 }1742 }17431744 174517461747174817491750 private fun persistSlotAWithEscrows(1751 recoveryEscrow: RecoveryEscrow?,1752 rootKeyEscrow: RootKeyEscrow?,1753 ): Boolean {1754 val key = derivedKey ?: throw IllegalStateException("vault_locked")1755 if (decoyMode) return false1756 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1757 val plaintext = VaultCodec.encode(currentPayload())1758 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1759 val next = prior.copy(1760 blob = blob,1761 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1762 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1763 )1764 if (!store.writeSlotA(next)) return false1765 try {1766 writeMatchingNoiseSlotB(next)1767 1768 1769 1770 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")1771 } catch (e: Exception) {1772 store.writeSlotA(prior)1773 throw e1774 }1775 return true1776 }17771778 17791780 private fun requireUnlocked() {1781 1782 1783 1784 stateLock.lock()1785 try {1786 if (derivedKey == null) throw IllegalStateException("vault_locked")1787 } finally {1788 stateLock.unlock()1789 }1790 }17911792 private fun countDuplicateEntriesLocked(): Int {1793 val seen = mutableMapOf<String, VaultEntry>()1794 var duplicates = 01795 for (entry in entryList.filter { it.deletedAt == null }) {1796 val key = entry.duplicateKey()1797 val existing = seen[key]1798 if (existing == null) {1799 seen[key] = entry1800 } else {1801 duplicates++1802 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1803 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1804 if (entryStamp > existingStamp) seen[key] = entry1805 }1806 }1807 return duplicates1808 }18091810 private fun VaultEntry.duplicateKey(): String {1811 if (type == "note") return "note|${title.lowercase()}"1812 val passkeyData = passkey1813 if (passkeyData != null) {1814 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1815 }1816 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1817 }18181819 182018211822182318241825 private fun takeVaultSnapshot(reason: String): Boolean {1826 val slotA = store.loadSlotA() ?: return false1827 val vaultA = VaultCodec.encodeSlot(slotA)1828 val vaultB = store.loadSlotB() ?: slotA.blob1829 1830 1831 1832 1833 1834 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {1835 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161836 val fakeBlob = ByteArray(target).also(rng::nextBytes)1837 val fakeSalt = VaultCrypto.generateSalt()1838 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1839 }1840 val history = store.loadVaultHistory()1841 val latest = history.snapshots.firstOrNull()1842 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true1843 val entryCount = if (derivedKey != null && !decoyMode) {1844 bucketEntryCount(entryList.count { it.deletedAt == null })1845 } else {1846 null1847 }1848 return try {1849 val snapshot = VaultSnapshot(1850 id = UUID.randomUUID().toString(),1851 savedAt = System.currentTimeMillis(),1852 reason = reason,1853 vault_a = vaultA,1854 vault_b = vaultB,1855 vault_d = vaultD,1856 entryCount = entryCount,1857 )1858 1859 1860 1861 store.writeVaultHistory(1862 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1863 )1864 } catch (_: Exception) {1865 false1866 }1867 }18681869 private fun bucketEntryCount(n: Int): Int = when {1870 n <= 0 -> 01871 n <= 10 -> 101872 n <= 25 -> 251873 n <= 50 -> 501874 n <= 100 -> 1001875 n <= 250 -> 2501876 n <= 500 -> 5001877 else -> ((n + 499) / 500) * 5001878 }18791880 188118821883188418851886188718881889 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1890 if (!entry.isCard()) return entry1891 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1892 val derived = if (digits.length >= 4) digits.takeLast(4) else null1893 return entry.copy(last4 = derived ?: entry.last4)1894 }18951896 189718981899190019011902 private fun autoPurgeExpiredTrash() {1903 val days = _settings.value.trashRetentionDays ?: return1904 if (days <= 0) return1905 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1906 val before = entryList.size1907 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1908 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1909 }19101911 1912 private fun currentPayload(): VaultPayload =1913 VaultPayload(1914 entries = entryList.toList(),1915 duressConfigured = duressConfigured,1916 rootKeyB64 = rootKey?.let(::b64) ?: "",1917 recoveryWordHash = recoveryWordHash,1918 recoveryWordsSet = recoveryWordsSet,1919 )19201921 19221923192419251926 private fun ensureRootKey(payload: VaultPayload) {1927 if (payload.rootKeyB64.isNotBlank()) {1928 rootKey = unb64(payload.rootKeyB64)1929 return1930 }1931 rootKey = generateRecoverableRootKey()1932 runCatching { persistSlotA(bumpVersion = false) }1933 }19341935 private fun publish() {1936 _entries.value = entryList.filter { it.deletedAt == null }1937 }19381939 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1940 var plaintext = VaultCodec.encode(currentPayload())1941 return try {1942 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1943 StoredVault(version = 1, salt = b64(salt), blob = blob)1944 } finally {1945 1946 1947 1948 VaultCrypto.zero(plaintext)1949 }1950 }19511952 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1953 1954 1955 1956 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1957 val noise = ByteArray(approxBytes).also(rng::nextBytes)1958 return store.writeSlotB(b64(noise))1959 }19601961 196219631964 private fun matchingCiphertextLength(slotA: StoredVault): Int =1965 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)19661967 19681969197019711972 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {1973 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161974 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)1975 val fakeSalt = VaultCrypto.generateSalt()1976 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1977 }19781979 198019811982198319841985 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {1986 val isConfigured = mutateState { duressConfigured }1987 return if (isConfigured) true else writeFakeDuressSlot(slotA)1988 }19891990 1991 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1992 val key = derivedKey ?: throw IllegalStateException("vault_locked")1993 var plaintext: ByteArray? = null1994 return try {1995 1996 if (decoyMode) {1997 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1998 plaintext = VaultCodec.encode(currentPayload())1999 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2000 plaintext = null2001 return store.writeDecoySlot(prior.copy(blob = blob))2002 }2003 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2004 2005 2006 2007 2008 val priorB = store.loadSlotB()2009 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)2010 plaintext = VaultCodec.encode(currentPayload())2011 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2012 plaintext = null2013 val next = prior.copy(blob = blob)2014 if (!store.writeSlotA(next)) return false2015 2016 2017 2018 2019 try {2020 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2021 2022 2023 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2024 if (bumpVersion) store.stampLocalMutation()2025 } catch (e: Exception) {2026 store.writeSlotA(prior)2027 if (priorB != null) runCatching { store.writeSlotB(priorB) }2028 throw e2029 }2030 true2031 } finally {2032 2033 2034 2035 plaintext?.let(VaultCrypto::zero)2036 }2037 }20382039 2040204120422043204420452046 private fun commitVaultRotation(2047 priorA: StoredVault,2048 priorB: String?,2049 nextA: StoredVault,2050 ): Boolean {2051 if (!store.writeSlotA(nextA)) return false2052 2053 2054 2055 try {2056 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2057 2058 2059 2060 2061 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2062 store.stampLocalMutation()2063 } catch (_: Exception) {2064 rollbackRotation(priorA, priorB)2065 return false2066 }2067 return true2068 }20692070 20712072207320742075 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2076 val failures = mutableListOf<String>()2077 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2078 .onFailure { failures += "writeSlotA: $it" }2079 if (priorB != null) {2080 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2081 .onFailure { failures += "writeSlotB: $it" }2082 }2083 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2084 }20852086 2087 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {2088 try {2089 if (!persistSlotA(bumpVersion = true)) {2090 mutateState { entryList = rollback }2091 throw IllegalStateException("storage_write_failed")2092 }2093 } catch (e: Exception) {2094 mutateState { entryList = rollback }2095 throw e2096 }2097 mutateState { publish() }2098 }2099}