1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton252627private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)313233private const val PASSWORD_HISTORY_MAX = 5034private const val VAULT_HISTORY_MAX = 535363738394041private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4243sealed interface UnlockResult {44 data object Success : UnlockResult45 data object WrongPassword : UnlockResult46 data class Error(val reason: String) : UnlockResult47}4849sealed interface CreateVaultResult {50 data object Success : CreateVaultResult51 data class Error(val reason: String) : CreateVaultResult52}5354sealed interface AdoptVaultResult {55 data class Success(val vaultId: String) : AdoptVaultResult56 data class Error(val reason: String) : AdoptVaultResult57}585960616263646566676869707172@Singleton73class VaultRepository @Inject constructor(74 private val store: VaultStorage,75) {76 private val mutex = Mutex()77 78 79 80 81 82 83 84 private val stateLock = ReentrantLock()85 private val rng = SecureRandom()8687 8889909192 private inline fun <T> mutateState(block: () -> T): T {93 stateLock.lock()94 return try { block() } finally { stateLock.unlock() }95 }9697 private var derivedKey: ByteArray? = null98 private var rootKey: ByteArray? = null99 private var entryList: MutableList<VaultEntry> = mutableListOf()100 private var duressConfigured: Boolean = false101 private var recoveryWordHash: String? = null102 private var recoveryWordsSet: Boolean = false103 104 private var decoyMode: Boolean = false105106 private val _isUnlocked = MutableStateFlow(false)107 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()108109 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())110 111 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()112113 private val _settings = MutableStateFlow(VaultSettings())114 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()115116 fun hasVault(): Boolean = store.hasVault()117118 119120 121122123124125126127 fun codexVaultBlobBase64(): String? {128 val slotA = store.loadSlotA() ?: return null129 130 131 val slotD = store.loadDecoySlot() ?: return null132 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""133 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))134 }135136 137138139140141 142 private fun JsonElement?.asSlotJson(): String? = when {143 this == null || this is JsonNull -> null144 this is JsonPrimitive && isString -> content145 else -> toString()146 }147148 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {149 var candidateKey: ByteArray? = null150 var plaintext: ByteArray? = null151 return try {152 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)153 154 155 156 157 158 159 160 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject161 ?: return UnlockResult.Error("invalid_codex_blob")162 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")163 164 165 166 167 168 val slotA = VaultCodec.decodeSlot(slotJson)169 ?: BrowserSlot.parse(slotJson)?.let {170 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword171 }172 ?: return UnlockResult.Error("invalid_codex_blob")173 174 175 176 177 178 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))179 plaintext = decryptSlotABlob(slotA, candidateKey)180 181 182 183 184 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {185 VaultCrypto.zero(candidateKey)186 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))187 plaintext = decryptSlotABlob(slotA, candidateKey)188 }189 if (plaintext == null) return UnlockResult.WrongPassword190 val payload = try {191 VaultCodec.decode(plaintext)192 } catch (_: Exception) {193 return UnlockResult.Error("invalid_codex_blob")194 }195196 val priorA = store.loadSlotA()197 val priorB = store.loadSlotB()198 val priorD = store.loadDecoySlot()199 200 201 202 203 204 val codexDuressJson = wrapper["b"].asSlotJson()205 val codexDuress: StoredVault? = codexDuressJson?.let {206 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->207 208 209 210 BrowserSlot.toAndroidSlot(b, password)211 }212 }213214 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")215 val committed = runCatching {216 217 218 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")219 220 221 val duressOk = if (codexDuress != null) {222 store.writeDecoySlot(codexDuress)223 } else {224 writeFakeDuressSlot(slotA)225 }226 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")227 store.stampLocalMutation()228 }.isSuccess229 if (!committed) {230 rollbackRestore(priorA, priorB, priorD)231 return UnlockResult.Error("storage_write_failed")232 }233234 val ownedKey = candidateKey!!235 candidateKey = null 236 adoptSlotA(ownedKey, payload)237 } catch (e: Exception) {238 239 240 241 242 243 244 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))245 UnlockResult.Error(e.message ?: "restore_failed")246 } finally {247 plaintext?.let(VaultCrypto::zero)248 candidateKey?.let(VaultCrypto::zero)249 }250 }251252 253 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =254 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()255256 257258259260261262 private fun rollbackRestore(priorA: StoredVault?, priorB: String?, priorD: StoredVault? = null) {263 val failures = mutableListOf<String>()264 if (priorA == null) {265 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }266 } else {267 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }268 .onFailure { failures += "writeSlotA: $it" }269 if (priorB != null) {270 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }271 .onFailure { failures += "writeSlotB: $it" }272 }273 274 275 276 if (priorD != null) {277 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }278 .onFailure { failures += "writeDecoySlot: $it" }279 } else {280 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }281 }282 }283 284 285 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }286 }287288 289 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {290 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)291 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()292 val window = offset?.let {293 val from = (it - 40).coerceAtLeast(0)294 val to = (it + 40).coerceAtMost(wrapperJson.length)295 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")296 }297 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()298 val slot = wrapper?.get("a")299 buildString {300 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)301 append(" | blobBase64=").append(vaultBlobBase64.length)302 append(" wrapperChars=").append(wrapperJson.length)303 append(" wrapperParses=").append(wrapper != null)304 append(" keys=").append(wrapper?.keys)305 append(" slotAKind=").append(306 when {307 slot == null -> "absent"308 slot is JsonPrimitive && slot.isString -> "string"309 slot is JsonObject -> "object:" + slot.keys310 else -> slot::class.simpleName311 },312 )313 if (window != null) append(" masked@").append(offset).append("=").append(window)314 }315 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }316317 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {318 return try {319 val salt = VaultCrypto.generateSalt()320 val key = VaultCrypto.deriveKey(password, salt)321 mutateState {322 entryList = mutableListOf()323 duressConfigured = false324 recoveryWordHash = null325 recoveryWordsSet = false326 decoyMode = false327 rootKey = generateRecoverableRootKey()328 }329 330 331 332 333 val slotA = sealSlotA(key, salt)334 if (!store.writeSlotA(slotA)) {335 lock()336 return CreateVaultResult.Error("storage_write_failed")337 }338 339 340 val created = try {341 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")342 343 344 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")345 store.stampLocalMutation()346 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")347 true348 } catch (_: Exception) {349 350 351 store.clearVault()352 false353 }354 if (!created) {355 lock()356 return CreateVaultResult.Error("storage_write_failed")357 }358 mutateState {359 derivedKey = key360 publish()361 _isUnlocked.value = true362 }363 CreateVaultResult.Success364 } catch (e: Exception) {365 lock()366 CreateVaultResult.Error(e.message ?: "create_failed")367 }368 }369370 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {371 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")372 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")373 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {374 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")375 }376 var adoptedRootKey: ByteArray? = null377 var nextDerivedKey: ByteArray? = null378 var sessionRootKey: ByteArray? = null379 return try {380 adoptedRootKey = hexToRootKey(rootKeyHex)381 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {382 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")383 }384 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)385 val salt = VaultCrypto.generateSalt()386 nextDerivedKey = VaultCrypto.deriveKey(password, salt)387 sessionRootKey = adoptedRootKey.copyOf()388 val payload = VaultPayload(389 entries = emptyList(),390 duressConfigured = false,391 rootKeyB64 = b64(sessionRootKey),392 recoveryWordHash = null,393 recoveryWordsSet = false,394 )395 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))396 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)397 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")398 399 400 val created = try {401 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")402 403 404 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")405 store.stampLocalMutation()406 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")407 true408 } catch (_: Exception) {409 store.clearVault()410 false411 }412 if (!created) return AdoptVaultResult.Error("storage_write_failed")413414 val previousDerivedKey = derivedKey415 val previousRootKey = rootKey416 mutateState {417 derivedKey = nextDerivedKey418 rootKey = sessionRootKey419 nextDerivedKey = null420 sessionRootKey = null421 entryList = mutableListOf()422 duressConfigured = false423 recoveryWordHash = null424 recoveryWordsSet = false425 decoyMode = false426 publish()427 _isUnlocked.value = true428 }429 previousDerivedKey?.let(VaultCrypto::zero)430 previousRootKey?.let(VaultCrypto::zero)431 AdoptVaultResult.Success(vaultId)432 } catch (e: Exception) {433 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")434 } finally {435 adoptedRootKey?.let(VaultCrypto::zero)436 nextDerivedKey?.let(VaultCrypto::zero)437 sessionRootKey?.let(VaultCrypto::zero)438 }439 }440441 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }442443 444 private fun unlockLocked(password: String): UnlockResult {445 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")446 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))447 val payload = try {448 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))449 } catch (_: Exception) {450 VaultCrypto.zero(key)451 452 453 454 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {455 val legacy = tryLegacyRawUnlock(password, slotA)456 if (legacy != null) return legacy457 }458 return UnlockResult.WrongPassword459 }460 return adoptSlotA(key, payload)461 }462463 464465466467468469470471 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {472 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))473 val payload = try {474 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))475 } catch (_: Exception) {476 VaultCrypto.zero(rawKey)477 return null478 }479 480 481 val newSalt = VaultCrypto.generateSalt()482 val newKey = VaultCrypto.deriveKey(password, newSalt)483 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))484 val next = StoredVault(485 version = slotA.version,486 salt = b64(newSalt),487 blob = blob,488 recoveryEscrow = slotA.recoveryEscrow,489 rootKeyEscrow = slotA.rootKeyEscrow,490 )491 492 493 494 val priorB = store.loadSlotB()495 val healed = if (!store.writeSlotA(next)) {496 false497 } else try {498 if (!writeMatchingNoiseSlotB(next)) {499 runCatching { store.writeSlotA(slotA) }500 if (priorB != null) runCatching { store.writeSlotB(priorB) }501 false502 } else if (!writeCompanionDuressSlot(next)) {503 runCatching { store.writeSlotA(slotA) }504 if (priorB != null) runCatching { store.writeSlotB(priorB) }505 false506 } else {507 store.stampLocalMutation()508 true509 }510 } catch (_: Exception) {511 runCatching { store.writeSlotA(slotA) }512 if (priorB != null) runCatching { store.writeSlotB(priorB) }513 false514 }515 return if (healed) {516 VaultCrypto.zero(rawKey)517 adoptSlotA(newKey, payload) 518 } else {519 VaultCrypto.zero(newKey)520 adoptSlotA(rawKey, payload) 521 }522 }523524 525 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {526 return try {527 528 529 530 531 532 mutateState {533 derivedKey = key534 decoyMode = false535 entryList = payload.entries.toMutableList()536 duressConfigured = payload.duressConfigured537 recoveryWordHash = payload.recoveryWordHash538 recoveryWordsSet = payload.recoveryWordsSet539 _settings.value = store.loadSettings()540 ensureRootKey(payload)541 autoPurgeExpiredTrash()542 publish()543 _isUnlocked.value = true544 }545 UnlockResult.Success546 } catch (e: Exception) {547 lock()548 UnlockResult.Error(e.message ?: "unlock_failed")549 }550 }551552 553554 555556557558559560 class SlotAUnlock internal constructor(561 internal val key: ByteArray,562 internal val payload: VaultPayload,563 )564565 566567568569570571 fun tryUnlockSlotA(password: String): SlotAUnlock? {572 val slotA = store.loadSlotA() ?: return null573 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))574 return try {575 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))576 SlotAUnlock(key, payload)577 } catch (_: Exception) {578 VaultCrypto.zero(key)579 null580 }581 }582583 584 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {585 adoptSlotA(unlock.key, unlock.payload)586 }587588 589 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)590591 592593 594595596597598 class DuressUnlock internal constructor(599 internal val key: ByteArray,600 internal val payload: VaultPayload,601 )602603 604605606607608609610 fun tryUnlockDuress(password: String): DuressUnlock? {611 val slot = store.loadDecoySlot() ?: return null612 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))613 return try {614 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))615 DuressUnlock(key, payload)616 } catch (_: Exception) {617 VaultCrypto.zero(key)618 null619 }620 }621622 623 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {624 return try {625 mutateState {626 derivedKey = unlock.key627 decoyMode = true628 entryList = unlock.payload.entries.toMutableList()629 duressConfigured = false630 recoveryWordHash = unlock.payload.recoveryWordHash631 recoveryWordsSet = unlock.payload.recoveryWordsSet632 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)633 else VaultCrypto.generateRootKey()634 _settings.value = store.loadSettings()635 publish()636 _isUnlocked.value = true637 }638 UnlockResult.Success639 } catch (e: Exception) {640 UnlockResult.Error(e.message ?: "decoy_unlock_failed")641 }642 }643644 645 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)646647 648649650651652653 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {654 655 val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }656 if (!unlockedAndReal) return false657 658 659 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }660 return try {661 val salt = VaultCrypto.generateSalt()662 val key = VaultCrypto.deriveKey(duressPassphrase, salt)663 664 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))665 val basePlaintext = VaultCodec.encode(decoyPayload)666 667 668 669 670 671 672 673 674 val slotA = store.loadSlotA()675 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0676 val plaintext = if (targetSize > basePlaintext.size) basePlaintext.copyOf(targetSize) else basePlaintext677 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))678 VaultCrypto.zero(key)679 VaultCrypto.zero(plaintext)680 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))681 if (ok) {682 mutateState { duressConfigured = true }683 runCatching { persistSlotA(bumpVersion = false) }684 }685 ok686 } catch (_: Exception) {687 false688 }689 }690691 692 suspend fun clearDuress(): Boolean = mutex.withLock {693 694 695 696 697 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) } }698 ?: store.clearDecoySlot()699 700 701 val shouldClear = mutateState { derivedKey != null && !decoyMode }702 if (shouldClear) {703 mutateState { duressConfigured = false }704 runCatching { persistSlotA(bumpVersion = false) }705 }706 true707 }708709 710 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {711 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")712 return try {713 val payload = VaultCodec.decode(714 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),715 )716 mutateState {717 derivedKey = key.copyOf()718 decoyMode = false719 entryList = payload.entries.toMutableList()720 duressConfigured = payload.duressConfigured721 recoveryWordHash = payload.recoveryWordHash722 recoveryWordsSet = payload.recoveryWordsSet723 _settings.value = store.loadSettings()724 ensureRootKey(payload)725 autoPurgeExpiredTrash()726 publish()727 _isUnlocked.value = true728 }729 UnlockResult.Success730 } catch (_: Exception) {731 UnlockResult.WrongPassword732 }733 }734735 fun lock() {736 737 738 739 740 stateLock.lock()741 try {742 derivedKey?.let(VaultCrypto::zero)743 rootKey?.let(VaultCrypto::zero)744 derivedKey = null745 rootKey = null746 entryList = mutableListOf()747 duressConfigured = false748 recoveryWordHash = null749 recoveryWordsSet = false750 decoyMode = false751 _entries.value = emptyList()752 _isUnlocked.value = false753 } finally {754 stateLock.unlock()755 }756 }757758 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L759760 761 762 @VisibleForTesting763 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }764765 766767768769770771772773774775776777 fun expectTransientBackground() {778 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS779 }780781 782783784785786 fun consumeTransientBackgroundSuppression(): Boolean {787 val until = suppressNextBackgroundLockUntil788 val active = until != 0L && nowMs() < until789 suppressNextBackgroundLockUntil = 0L790 return active791 }792793 794795 796797798799800 801802803804805806807808809810811 812 813 814 815 816 fun syncMasterKey(): ByteArray? =817 if (decoyMode || !_isUnlocked.value) null else rootKey?.copyOf()818819 820821822823824825826827828 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()829830 831832 fun syncVaultId(): String? =833 if (decoyMode || !_isUnlocked.value) null834 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }835836 837838839840841 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {842 installRootKey(generateRecoverableRootKey())843 }844845 846847848849850851852853854855856857858859860861862 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {863 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {864 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")865 }866 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")867 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")868 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")869870 val incoming = hexToRootKey(rootKeyHex)871 if (incoming.size != VaultCrypto.KEY_SIZE) {872 VaultCrypto.zero(incoming)873 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")874 }875 876 877 878 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess879 if (!roundTrips) {880 VaultCrypto.zero(incoming)881 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")882 }883884 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)885 return if (installRootKey(incoming)) {886 AdoptVaultResult.Success(vaultId)887 } else {888 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")889 }890 }891892 893894895896897 fun previewRecoveryWords(rootKeyHex: String): List<String>? {898 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null899 val bytes = hexToRootKey(rootKeyHex)900 return try {901 val words = rootKeyToWords(bytes)902 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null903 } catch (_: Exception) {904 null905 } finally {906 VaultCrypto.zero(bytes)907 }908 }909910 911912913914915 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {916 917 918 919 920 921 922 data class Snapshot(923 val key: ByteArray,924 val priorRootKey: ByteArray,925 val priorRecoveryHash: String?,926 val priorRecoverySet: Boolean,927 )928 val snap = mutateState {929 val k = derivedKey ?: return false930 if (decoyMode) return false931 val priorRoot = rootKey ?: return false932 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)933 } as Snapshot? ?: return false934 val priorSlot = store.loadSlotA() ?: run {935 VaultCrypto.zero(snap.priorRootKey)936 return false937 }938 val nextWords = rootKeyToWords(nextRootKey)939940 return try {941 takeVaultSnapshot("local_save")942 mutateState {943 rootKey = nextRootKey944 recoveryWordHash = recoveryWordsHash(nextWords)945 recoveryWordsSet = false946 }947948 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)949 ?: throw IllegalStateException("recovery_escrow_failed")950 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)951 ?: throw IllegalStateException("root_escrow_failed")952 val plaintext = VaultCodec.encode(currentPayload())953 val blob = try {954 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))955 } finally {956 VaultCrypto.zero(plaintext)957 }958 val nextSlot = priorSlot.copy(959 blob = blob,960 recoveryEscrow = nextRecoveryEscrow,961 rootKeyEscrow = nextRootKeyEscrow,962 )963 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")964 try {965 writeMatchingNoiseSlotB(nextSlot)966 967 968 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")969 store.stampLocalMutation()970 } catch (e: Exception) {971 store.writeSlotA(priorSlot)972 throw e973 }974 VaultCrypto.zero(snap.priorRootKey)975 mutateState { publish() }976 true977 } catch (_: Exception) {978 mutateState {979 rootKey = snap.priorRootKey980 recoveryWordHash = snap.priorRecoveryHash981 recoveryWordsSet = snap.priorRecoverySet982 }983 VaultCrypto.zero(nextRootKey)984 store.writeSlotA(priorSlot)985 false986 }987 }988989 990991992993994 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()995996 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {997 val slotA = store.loadSlotA() ?: return false998 var oldKey: ByteArray? = null999 var newKey: ByteArray? = null1000 var plaintext: ByteArray? = null1001 return try {1002 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1003 val payload = VaultCodec.decode(1004 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),1005 )1006 val newSalt = VaultCrypto.generateSalt()1007 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1008 1009 plaintext = VaultCodec.encode(payload)1010 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1011 1012 1013 1014 1015 1016 1017 1018 val next = StoredVault(1019 version = slotA.version,1020 salt = b64(newSalt),1021 blob = blob,1022 recoveryEscrow = slotA.recoveryEscrow,1023 rootKeyEscrow = slotA.rootKeyEscrow,1024 )1025 1026 1027 1028 1029 val priorB = store.loadSlotB()1030 if (!commitVaultRotation(slotA, priorB, next)) return false1031 1032 1033 1034 mutateState {1035 oldKey?.let(VaultCrypto::zero); oldKey = null1036 derivedKey?.let(VaultCrypto::zero)1037 derivedKey = newKey; newKey = null1038 }1039 true1040 } catch (_: Exception) {1041 false1042 } finally {1043 oldKey?.let(VaultCrypto::zero)1044 newKey?.let(VaultCrypto::zero)1045 plaintext?.let(VaultCrypto::zero)1046 }1047 }10481049 1050105110521053 suspend fun destroyVault() = mutex.withLock {1054 lock()1055 store.clearVault()1056 store.clearDecoySlot()1057 }10581059 10601061 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1062 requireUnlocked()1063 val stamp = VaultCrypto.monotonicEntryTimestamp()1064 val now = System.currentTimeMillis()1065 val entry = withDerivedLast4(draft.copy(1066 id = draft.id.ifBlank { UUID.randomUUID().toString() },1067 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1068 modifiedAt = stamp,1069 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1070 ))1071 1072 1073 val rollback = mutateState {1074 val r = entryList.toMutableList()1075 entryList.add(entry)1076 r1077 }1078 persistOrRollback(rollback)1079 entry1080 }10811082 108310841085108610871088108910901091 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1092 requireUnlocked()1093 if (drafts.isEmpty()) return 01094 val now = System.currentTimeMillis()1095 val ready = drafts.map { draft ->1096 val stamp = VaultCrypto.monotonicEntryTimestamp()1097 withDerivedLast4(1098 draft.copy(1099 id = draft.id.ifBlank { UUID.randomUUID().toString() },1100 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1101 modifiedAt = stamp,1102 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1103 ),1104 )1105 }1106 1107 val rollback = mutateState {1108 val r = entryList.toMutableList()1109 entryList.addAll(ready)1110 r1111 }1112 persistOrRollback(rollback)1113 return drafts.size1114 }11151116 1117111811191120112111221123112411251126112711281129 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1130 requireUnlocked()1131 val now = System.currentTimeMillis()1132 val ready = newEntries.map { draft ->1133 val stamp = VaultCrypto.monotonicEntryTimestamp()1134 withDerivedLast4(1135 draft.copy(1136 id = draft.id.ifBlank { UUID.randomUUID().toString() },1137 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1138 modifiedAt = stamp,1139 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1140 ),1141 )1142 }1143 1144 1145 1146 1147 1148 1149 val incomingIds = ready.map { it.id }.toHashSet()1150 val rollback = mutateState {1151 val r = entryList.toMutableList()1152 val replaced = entryList.map { entry ->1153 if (entry.deletedAt != null) return@map entry1154 if (entry.id in incomingIds) return@map null1155 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1156 entry.copy(deletedAt = stamp, modifiedAt = stamp)1157 }.filterNotNull()1158 entryList = (replaced + ready).toMutableList()1159 r1160 }1161 persistOrRollback(rollback)1162 return ready.size1163 }11641165 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1166 requireUnlocked()1167 1168 1169 1170 1171 var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1172 mutateState {1173 val idx = entryList.indexOfFirst { it.id == patch.id }1174 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1175 val current = entryList[idx]1176 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1177 1178 val passwordChanged = patch.password != null && patch.password != current.password1179 1180 1181 1182 1183 1184 1185 val priorPassword = current.password1186 val shouldCapture = passwordChanged &&1187 !priorPassword.isNullOrEmpty() &&1188 current.passwordHistory?.firstOrNull()?.password != priorPassword1189 val nextHistory = if (shouldCapture) {1190 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1191 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1192 } else {1193 current.passwordHistory1194 }1195 val updated = withDerivedLast4(patch.copy(1196 createdAt = current.createdAt,1197 modifiedAt = stamp,1198 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1199 passwordHistory = nextHistory,1200 ))1201 val rollback = entryList.toMutableList()1202 entryList[idx] = updated1203 prepared = updated to rollback1204 }1205 val (updated, rollback) = prepared!!1206 persistOrRollback(rollback)1207 updated1208 }12091210 1211 suspend fun deleteEntry(id: String) = mutex.withLock {1212 requireUnlocked()1213 1214 1215 var rollback: MutableList<VaultEntry>? = null1216 mutateState {1217 val idx = entryList.indexOfFirst { it.id == id }1218 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1219 val current = entryList[idx]1220 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1221 rollback = entryList.toMutableList()1222 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1223 }1224 persistOrRollback(rollback!!)1225 }12261227 suspend fun restoreEntry(id: String) = mutex.withLock {1228 requireUnlocked()1229 var rollback: MutableList<VaultEntry>? = null1230 mutateState {1231 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1232 if (idx == -1) throw IllegalStateException("deleted entry not found")1233 val current = entryList[idx]1234 rollback = entryList.toMutableList()1235 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1236 }1237 persistOrRollback(rollback!!)1238 }12391240 1241 suspend fun purgeEntry(id: String) = mutex.withLock {1242 requireUnlocked()1243 var rollback: MutableList<VaultEntry>? = null1244 mutateState {1245 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1246 if (idx == -1) throw IllegalStateException("deleted entry not found")1247 rollback = entryList.toMutableList()1248 entryList.removeAt(idx)1249 }1250 persistOrRollback(rollback!!)1251 }12521253 1254 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1255 requireUnlocked()1256 1257 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1258 val r = mutateState {1259 val rollback = entryList.toMutableList()1260 val before = entryList.size1261 entryList.removeAll { it.deletedAt != null }1262 R(rollback, before - entryList.size)1263 }1264 if (r.removed > 0) persistOrRollback(r.rollback)1265 r.removed1266 }12671268 1269 suspend fun countDuplicateEntries(): Int = mutex.withLock {1270 requireUnlocked()1271 1272 1273 mutateState { countDuplicateEntriesLocked() }1274 }12751276 12771278127912801281 suspend fun deduplicateEntries(): Int = mutex.withLock {1282 requireUnlocked()1283 1284 1285 val decoy = mutateState { decoyMode }1286 if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1287 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1288 val ready = mutateState {1289 val active = entryList.filter { it.deletedAt == null }1290 val keepIds = mutableSetOf<String>()1291 active.groupBy { it.duplicateKey() }.values.forEach { group ->1292 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1293 }1294 val rollback = entryList.toMutableList()1295 var removed = 01296 entryList = entryList.map { entry ->1297 if (entry.deletedAt == null && entry.id !in keepIds) {1298 removed++1299 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1300 entry.copy(deletedAt = stamp, modifiedAt = stamp)1301 } else {1302 entry1303 }1304 }.toMutableList()1305 R(rollback, removed)1306 }1307 if (ready.removed > 0) persistOrRollback(ready.rollback)1308 ready.removed1309 }13101311 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1312 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1313 fun getDeletedEntries(): List<VaultEntry> =1314 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }13151316 1317 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1318 val active = entryList.filter { it.deletedAt == null }1319 if (tagFilter == null) return active1320 val needle = tagFilter.trim().lowercase()1321 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1322 }13231324 1325 fun getDistinctTags(): List<String> =1326 entryList.asSequence()1327 .filter { it.deletedAt == null }1328 .flatMap { it.tags?.asSequence() ?: emptySequence() }1329 .map { it.trim().lowercase() }1330 .filter { it.isNotEmpty() }1331 .distinct()1332 .sorted()1333 .toList()13341335 133613371338133913401341 fun getDistinctFieldValues(field: String): List<String> {1342 val select: (VaultEntry) -> String? = when (field) {1343 "username" -> { e -> e.username }1344 "email" -> { e -> e.email }1345 else -> return emptyList()1346 }1347 return entryList.asSequence()1348 .filter { it.deletedAt == null }1349 .mapNotNull { select(it)?.trim() }1350 .filter { it.isNotEmpty() }1351 .groupingBy { it.lowercase() }1352 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1353 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1354 }1355 .values1356 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1357 .map { it.first }1358 }13591360 fun getTombstones(): List<Tombstone> =1361 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1362 fun getVaultVersion(): Long = store.version()1363 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots13641365 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1366 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1367 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1368 1369 1370 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1371 val priorA = store.loadSlotA()1372 val priorB = store.loadSlotB()1373 val priorD = store.loadDecoySlot()1374 1375 1376 1377 1378 1379 val priorHistory = store.loadVaultHistory()1380 1381 1382 1383 1384 if (!takeVaultSnapshot("restore")) {1385 return false1386 }1387 1388 1389 1390 val nextD: StoredVault = restoredD ?: run {1391 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161392 val fakeBlob = ByteArray(target).also(rng::nextBytes)1393 val fakeSalt = VaultCrypto.generateSalt()1394 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1395 }1396 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD)) {1397 store.writeVaultHistory(priorHistory)1398 return false1399 }1400 lock()1401 true1402 }14031404 14051406140714081409141014111412141314141415 private fun commitRestoreAtomic(1416 nextA: StoredVault,1417 nextB: String,1418 nextD: StoredVault,1419 priorA: StoredVault?,1420 priorB: String?,1421 priorD: StoredVault?,1422 ): Boolean {1423 if (!store.writeSlotA(nextA)) return false1424 1425 1426 1427 try {1428 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1429 1430 1431 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1432 store.stampLocalMutation()1433 } catch (_: Exception) {1434 rollbackRestore(priorA, priorB, priorD)1435 return false1436 }1437 return true1438 }14391440 14411442 14431444144514461447 suspend fun applyMergedRemoteEntries(1448 remoteEntries: List<VaultEntry>,1449 remoteTombstones: List<Tombstone>,1450 ): Int = mutex.withLock {1451 requireUnlocked()1452 1453 1454 1455 1456 1457 val mergeNow = System.currentTimeMillis()1458 val clamped = remoteEntries.map { entry ->1459 entry.copy(1460 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1461 deletedAt = entry.deletedAt?.let {1462 SyncLimits.clampSyncTimestamp(it, mergeNow)1463 },1464 )1465 }1466 val tombMap = remoteTombstones.associate {1467 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1468 }1469 1470 1471 val applied = mutateState {1472 var n = 01473 val byId = entryList.associateBy { it.id }.toMutableMap()14741475 for (remote in clamped) {1476 val tomb = tombMap[remote.id]1477 if (tomb != null && tomb >= remote.modifiedAt) continue1478 val local = byId[remote.id]1479 if (local == null) {1480 entryList.add(remote); byId[remote.id] = remote; n++1481 } else if (remote.modifiedAt > local.modifiedAt) {1482 val i = entryList.indexOfFirst { it.id == local.id }1483 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1484 }1485 }14861487 for ((id, deletedAt) in tombMap) {1488 val local = byId[id] ?: continue1489 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1490 if (deletedAt < local.modifiedAt) continue1491 val i = entryList.indexOfFirst { it.id == id }1492 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1493 }1494 n1495 }14961497 if (applied > 0) {1498 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1499 mutateState { publish() }1500 }1501 applied1502 }15031504 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1505 1506 1507 1508 mutateState { _settings.value = settings }1509 store.writeSettings(settings)1510 }15111512 15131514 1515 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet15161517 15181519152015211522 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1523 requireUnlocked()1524 1525 1526 1527 1528 1529 1530 val prior = mutateState {1531 val priorPair = recoveryWordHash to recoveryWordsSet1532 recoveryWordHash = recoveryWordsHash(words)1533 recoveryWordsSet = true1534 priorPair1535 }1536 1537 1538 1539 1540 val persisted = runCatching {1541 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1542 }.getOrDefault(false)1543 if (!persisted) {1544 mutateState {1545 recoveryWordHash = prior.first1546 recoveryWordsSet = prior.second1547 }1548 }1549 persisted1550 }15511552 1553 fun verifyRecoveryWords(words: List<String>): Boolean {1554 val stored = recoveryWordHash ?: return false1555 return stored == recoveryWordsHash(words)1556 }15571558 15591560 1561 fun hasRecoveryEscrow(): Boolean =1562 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false15631564 156515661567156815691570157115721573157415751576 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1577 val slotA = store.loadSlotA() ?: return false1578 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false15791580 1581 1582 slotA.recoveryEscrow?.let { escrow ->1583 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1584 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1585 val keyBytes = recoveryKey.encoded1586 try {1587 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1588 val payload = VaultCodec.decode(plaintext)1589 1590 mutateState {1591 decoyMode = false1592 entryList = payload.entries.toMutableList()1593 duressConfigured = payload.duressConfigured1594 recoveryWordHash = payload.recoveryWordHash1595 recoveryWordsSet = payload.recoveryWordsSet1596 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1597 else generateRecoverableRootKey()1598 _settings.value = store.loadSettings()1599 }1600 VaultCrypto.zero(plaintext)1601 return true1602 } catch (_: Exception) {1603 1604 } finally {1605 VaultCrypto.zero(material)1606 VaultCrypto.zero(keyBytes)1607 }1608 }16091610 1611 1612 1613 slotA.rootKeyEscrow?.let { escrow ->1614 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1615 mutateState {1616 decoyMode = false1617 entryList = mutableListOf()1618 duressConfigured = false1619 recoveryWordHash = recoveryWordsHash(words)1620 recoveryWordsSet = true1621 rootKey = rk1622 _settings.value = store.loadSettings()1623 }1624 return true1625 }1626 return false1627 }16281629 163016311632163316341635163616371638 suspend fun resetPasswordWithRecoveryWords(1639 words: List<String>,1640 newPassword: String,1641 ): Boolean = mutex.withLock {1642 1643 1644 1645 if (!mutateState { rootKey != null }) return false1646 val priorA = store.loadSlotA() ?: return false1647 var newKey: ByteArray? = null1648 var plaintext: ByteArray? = null1649 return try {1650 val newSalt = VaultCrypto.generateSalt()1651 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1652 plaintext = VaultCodec.encode(currentPayload())1653 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1654 1655 1656 1657 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1658 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1659 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1660 val next = StoredVault(1661 version = priorA.version,1662 salt = b64(newSalt),1663 blob = blob,1664 recoveryEscrow = freshRecovery,1665 rootKeyEscrow = freshRootKey,1666 )1667 1668 1669 1670 val priorB = store.loadSlotB()1671 if (!commitVaultRotation(priorA, priorB, next)) return false1672 1673 1674 1675 mutateState {1676 derivedKey?.let(VaultCrypto::zero)1677 derivedKey = newKey; newKey = null1678 publish()1679 _isUnlocked.value = true1680 }1681 true1682 } catch (_: Exception) {1683 false1684 } finally {1685 newKey?.let(VaultCrypto::zero)1686 plaintext?.let(VaultCrypto::zero)1687 }1688 }16891690 169116921693169416951696 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1697 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1698 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1699 val salt = VaultCrypto.generateSalt()1700 val recoveryKey = deriveRecoveryKey(material, salt)1701 val keyBytes = recoveryKey.encoded1702 val plaintext = VaultCodec.encode(currentPayload())1703 return try {1704 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1705 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1706 } finally {1707 VaultCrypto.zero(material)1708 VaultCrypto.zero(keyBytes)1709 VaultCrypto.zero(plaintext)1710 }1711 }17121713 17141715171617171718171917201721 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1722 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1723 val rk = rootKey ?: return null1724 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1725 val salt = VaultCrypto.generateSalt()1726 val recoveryKey = deriveRecoveryKey(material, salt)1727 val keyBytes = recoveryKey.encoded1728 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1729 return try {1730 1731 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1732 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1733 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1734 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1735 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1736 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1737 } finally {1738 VaultCrypto.zero(material)1739 VaultCrypto.zero(keyBytes)1740 VaultCrypto.zero(plaintext)1741 }1742 }17431744 1745 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1746 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1747 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1748 val keyBytes = recoveryKey.encoded1749 var pt: ByteArray? = null1750 return try {1751 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1752 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1753 hexToRootKey(String(pt, Charsets.US_ASCII))1754 } catch (_: Exception) {1755 null1756 } finally {1757 1758 1759 1760 VaultCrypto.zero(material)1761 VaultCrypto.zero(keyBytes)1762 pt?.let(VaultCrypto::zero)1763 }1764 }17651766 176717681769177017711772 private fun persistSlotAWithEscrows(1773 recoveryEscrow: RecoveryEscrow?,1774 rootKeyEscrow: RootKeyEscrow?,1775 ): Boolean {1776 val key = derivedKey ?: throw IllegalStateException("vault_locked")1777 if (decoyMode) return false1778 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1779 val plaintext = VaultCodec.encode(currentPayload())1780 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1781 val next = prior.copy(1782 blob = blob,1783 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1784 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1785 )1786 if (!store.writeSlotA(next)) return false1787 try {1788 writeMatchingNoiseSlotB(next)1789 1790 1791 1792 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")1793 } catch (e: Exception) {1794 store.writeSlotA(prior)1795 throw e1796 }1797 return true1798 }17991800 18011802 private fun requireUnlocked() {1803 1804 1805 1806 stateLock.lock()1807 try {1808 if (derivedKey == null) throw IllegalStateException("vault_locked")1809 } finally {1810 stateLock.unlock()1811 }1812 }18131814 private fun countDuplicateEntriesLocked(): Int {1815 val seen = mutableMapOf<String, VaultEntry>()1816 var duplicates = 01817 for (entry in entryList.filter { it.deletedAt == null }) {1818 val key = entry.duplicateKey()1819 val existing = seen[key]1820 if (existing == null) {1821 seen[key] = entry1822 } else {1823 duplicates++1824 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1825 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1826 if (entryStamp > existingStamp) seen[key] = entry1827 }1828 }1829 return duplicates1830 }18311832 private fun VaultEntry.duplicateKey(): String {1833 if (type == "note") return "note|${title.lowercase()}"1834 val passkeyData = passkey1835 if (passkeyData != null) {1836 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1837 }1838 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1839 }18401841 184218431844184518461847 private fun takeVaultSnapshot(reason: String): Boolean {1848 val slotA = store.loadSlotA() ?: return false1849 val vaultA = VaultCodec.encodeSlot(slotA)1850 val vaultB = store.loadSlotB() ?: slotA.blob1851 1852 1853 1854 1855 1856 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {1857 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161858 val fakeBlob = ByteArray(target).also(rng::nextBytes)1859 val fakeSalt = VaultCrypto.generateSalt()1860 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1861 }1862 val history = store.loadVaultHistory()1863 val latest = history.snapshots.firstOrNull()1864 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true1865 val entryCount = if (derivedKey != null && !decoyMode) {1866 bucketEntryCount(entryList.count { it.deletedAt == null })1867 } else {1868 null1869 }1870 return try {1871 val snapshot = VaultSnapshot(1872 id = UUID.randomUUID().toString(),1873 savedAt = System.currentTimeMillis(),1874 reason = reason,1875 vault_a = vaultA,1876 vault_b = vaultB,1877 vault_d = vaultD,1878 entryCount = entryCount,1879 )1880 1881 1882 1883 store.writeVaultHistory(1884 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1885 )1886 } catch (_: Exception) {1887 false1888 }1889 }18901891 private fun bucketEntryCount(n: Int): Int = when {1892 n <= 0 -> 01893 n <= 10 -> 101894 n <= 25 -> 251895 n <= 50 -> 501896 n <= 100 -> 1001897 n <= 250 -> 2501898 n <= 500 -> 5001899 else -> ((n + 499) / 500) * 5001900 }19011902 190319041905190619071908190919101911 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1912 if (!entry.isCard()) return entry1913 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1914 val derived = if (digits.length >= 4) digits.takeLast(4) else null1915 return entry.copy(last4 = derived ?: entry.last4)1916 }19171918 191919201921192219231924 private fun autoPurgeExpiredTrash() {1925 val days = _settings.value.trashRetentionDays ?: return1926 if (days <= 0) return1927 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1928 val before = entryList.size1929 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1930 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1931 }19321933 1934 private fun currentPayload(): VaultPayload =1935 VaultPayload(1936 entries = entryList.toList(),1937 duressConfigured = duressConfigured,1938 rootKeyB64 = rootKey?.let(::b64) ?: "",1939 recoveryWordHash = recoveryWordHash,1940 recoveryWordsSet = recoveryWordsSet,1941 )19421943 19441945194619471948 private fun ensureRootKey(payload: VaultPayload) {1949 if (payload.rootKeyB64.isNotBlank()) {1950 rootKey = unb64(payload.rootKeyB64)1951 return1952 }1953 rootKey = generateRecoverableRootKey()1954 runCatching { persistSlotA(bumpVersion = false) }1955 }19561957 private fun publish() {1958 _entries.value = entryList.filter { it.deletedAt == null }1959 }19601961 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1962 var plaintext = VaultCodec.encode(currentPayload())1963 return try {1964 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1965 StoredVault(version = 1, salt = b64(salt), blob = blob)1966 } finally {1967 1968 1969 1970 VaultCrypto.zero(plaintext)1971 }1972 }19731974 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1975 1976 1977 1978 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1979 val noise = ByteArray(approxBytes).also(rng::nextBytes)1980 return store.writeSlotB(b64(noise))1981 }19821983 198419851986 private fun matchingCiphertextLength(slotA: StoredVault): Int =1987 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)19881989 19901991199219931994 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {1995 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 161996 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)1997 val fakeSalt = VaultCrypto.generateSalt()1998 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))1999 }20002001 200220032004200520062007 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2008 val isConfigured = mutateState { duressConfigured }2009 return if (isConfigured) true else writeFakeDuressSlot(slotA)2010 }20112012 2013 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {2014 val key = derivedKey ?: throw IllegalStateException("vault_locked")2015 var plaintext: ByteArray? = null2016 return try {2017 2018 if (decoyMode) {2019 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2020 plaintext = VaultCodec.encode(currentPayload())2021 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2022 plaintext = null2023 return store.writeDecoySlot(prior.copy(blob = blob))2024 }2025 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2026 2027 2028 2029 2030 val priorB = store.loadSlotB()2031 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)2032 plaintext = VaultCodec.encode(currentPayload())2033 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2034 plaintext = null2035 val next = prior.copy(blob = blob)2036 if (!store.writeSlotA(next)) return false2037 2038 2039 2040 2041 try {2042 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2043 2044 2045 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2046 if (bumpVersion) store.stampLocalMutation()2047 } catch (e: Exception) {2048 store.writeSlotA(prior)2049 if (priorB != null) runCatching { store.writeSlotB(priorB) }2050 throw e2051 }2052 true2053 } finally {2054 2055 2056 2057 plaintext?.let(VaultCrypto::zero)2058 }2059 }20602061 2062206320642065206620672068 private fun commitVaultRotation(2069 priorA: StoredVault,2070 priorB: String?,2071 nextA: StoredVault,2072 ): Boolean {2073 if (!store.writeSlotA(nextA)) return false2074 2075 2076 2077 try {2078 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2079 2080 2081 2082 2083 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2084 store.stampLocalMutation()2085 } catch (_: Exception) {2086 rollbackRotation(priorA, priorB)2087 return false2088 }2089 return true2090 }20912092 20932094209520962097 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2098 val failures = mutableListOf<String>()2099 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2100 .onFailure { failures += "writeSlotA: $it" }2101 if (priorB != null) {2102 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2103 .onFailure { failures += "writeSlotB: $it" }2104 }2105 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2106 }21072108 2109 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {2110 try {2111 if (!persistSlotA(bumpVersion = true)) {2112 mutateState { entryList = rollback }2113 throw IllegalStateException("storage_write_failed")2114 }2115 } catch (e: Exception) {2116 mutateState { entryList = rollback }2117 throw e2118 }2119 mutateState { publish() }2120 }2121}