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 javax.inject.Inject21import javax.inject.Singleton222324private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2526private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)27private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)282930private const val PASSWORD_HISTORY_MAX = 5031private const val VAULT_HISTORY_MAX = 53233sealed interface UnlockResult {34 data object Success : UnlockResult35 data object WrongPassword : UnlockResult36 data class Error(val reason: String) : UnlockResult37}3839sealed interface CreateVaultResult {40 data object Success : CreateVaultResult41 data class Error(val reason: String) : CreateVaultResult42}4344sealed interface AdoptVaultResult {45 data class Success(val vaultId: String) : AdoptVaultResult46 data class Error(val reason: String) : AdoptVaultResult47}484950515253545556575859606162@Singleton63class VaultRepository @Inject constructor(64 private val store: VaultStorage,65) {66 private val mutex = Mutex()67 private val rng = SecureRandom()6869 private var derivedKey: ByteArray? = null70 private var rootKey: ByteArray? = null71 private var entryList: MutableList<VaultEntry> = mutableListOf()72 private var duressConfigured: Boolean = false73 private var recoveryWordHash: String? = null74 private var recoveryWordsSet: Boolean = false75 76 private var decoyMode: Boolean = false7778 private val _isUnlocked = MutableStateFlow(false)79 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()8081 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())82 83 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8485 private val _settings = MutableStateFlow(VaultSettings())86 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8788 fun hasVault(): Boolean = store.hasVault()8990 9192 9394959697 fun codexVaultBlobBase64(): String? {98 val slotA = store.loadSlotA() ?: return null99 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""100 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))101 }102103 104105106107108 109 private fun JsonElement?.asSlotJson(): String? = when {110 this == null || this is JsonNull -> null111 this is JsonPrimitive && isString -> content112 else -> toString()113 }114115 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {116 return try {117 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)118 119 120 121 122 123 124 125 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject126 ?: return UnlockResult.Error("invalid_codex_blob")127 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")128 129 130 131 132 133 val slotA = VaultCodec.decodeSlot(slotJson)134 ?: BrowserSlot.parse(slotJson)?.let {135 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword136 }137 ?: return UnlockResult.Error("invalid_codex_blob")138 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")139 140 141 val slotB = wrapper["b"].asSlotJson()142 if (slotB != null) {143 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))144 } else {145 writeMatchingNoiseSlotB(slotA)146 }147 store.stampLocalMutation()148 unlockLocked(password)149 } catch (e: Exception) {150 151 152 153 154 155 156 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))157 UnlockResult.Error(e.message ?: "restore_failed")158 }159 }160161 162 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {163 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)164 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()165 val window = offset?.let {166 val from = (it - 40).coerceAtLeast(0)167 val to = (it + 40).coerceAtMost(wrapperJson.length)168 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")169 }170 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()171 val slot = wrapper?.get("a")172 buildString {173 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)174 append(" | blobBase64=").append(vaultBlobBase64.length)175 append(" wrapperChars=").append(wrapperJson.length)176 append(" wrapperParses=").append(wrapper != null)177 append(" keys=").append(wrapper?.keys)178 append(" slotAKind=").append(179 when {180 slot == null -> "absent"181 slot is JsonPrimitive && slot.isString -> "string"182 slot is JsonObject -> "object:" + slot.keys183 else -> slot::class.simpleName184 },185 )186 if (window != null) append(" masked@").append(offset).append("=").append(window)187 }188 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }189190 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {191 return try {192 val salt = VaultCrypto.generateSalt()193 val key = VaultCrypto.deriveKey(password, salt)194 entryList = mutableListOf()195 duressConfigured = false196 recoveryWordHash = null197 recoveryWordsSet = false198 decoyMode = false199 rootKey = generateRecoverableRootKey()200 val slotA = sealSlotA(key, salt)201 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")202 writeMatchingNoiseSlotB(slotA)203 store.stampLocalMutation()204 store.writeSettings(_settings.value)205 derivedKey = key206 publish()207 _isUnlocked.value = true208 CreateVaultResult.Success209 } catch (e: Exception) {210 CreateVaultResult.Error(e.message ?: "create_failed")211 }212 }213214 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {215 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")216 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")217 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {218 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")219 }220 var adoptedRootKey: ByteArray? = null221 var nextDerivedKey: ByteArray? = null222 var sessionRootKey: ByteArray? = null223 return try {224 adoptedRootKey = hexToRootKey(rootKeyHex)225 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {226 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")227 }228 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)229 val salt = VaultCrypto.generateSalt()230 nextDerivedKey = VaultCrypto.deriveKey(password, salt)231 sessionRootKey = adoptedRootKey.copyOf()232 val payload = VaultPayload(233 entries = emptyList(),234 duressConfigured = false,235 rootKeyB64 = b64(sessionRootKey),236 recoveryWordHash = null,237 recoveryWordsSet = false,238 )239 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))240 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)241 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")242 writeMatchingNoiseSlotB(slotA)243 store.stampLocalMutation()244 store.writeSettings(_settings.value)245246 val previousDerivedKey = derivedKey247 val previousRootKey = rootKey248 derivedKey = nextDerivedKey249 rootKey = sessionRootKey250 nextDerivedKey = null251 sessionRootKey = null252 entryList = mutableListOf()253 duressConfigured = false254 recoveryWordHash = null255 recoveryWordsSet = false256 decoyMode = false257 publish()258 _isUnlocked.value = true259 previousDerivedKey?.let(VaultCrypto::zero)260 previousRootKey?.let(VaultCrypto::zero)261 AdoptVaultResult.Success(vaultId)262 } catch (e: Exception) {263 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")264 } finally {265 adoptedRootKey?.let(VaultCrypto::zero)266 nextDerivedKey?.let(VaultCrypto::zero)267 sessionRootKey?.let(VaultCrypto::zero)268 }269 }270271 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }272273 274 private fun unlockLocked(password: String): UnlockResult {275 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")276 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))277 val payload = try {278 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))279 } catch (_: Exception) {280 VaultCrypto.zero(key)281 282 283 284 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {285 val legacy = tryLegacyRawUnlock(password, slotA)286 if (legacy != null) return legacy287 }288 return UnlockResult.WrongPassword289 }290 return adoptSlotA(key, payload)291 }292293 294295296297298299 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {300 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))301 val payload = try {302 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))303 } catch (_: Exception) {304 VaultCrypto.zero(rawKey)305 return null306 }307 308 309 val newSalt = VaultCrypto.generateSalt()310 val newKey = VaultCrypto.deriveKey(password, newSalt)311 val healed = try {312 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))313 val next = StoredVault(314 version = slotA.version,315 salt = b64(newSalt),316 blob = blob,317 recoveryEscrow = slotA.recoveryEscrow,318 rootKeyEscrow = slotA.rootKeyEscrow,319 )320 if (store.writeSlotA(next)) {321 writeMatchingNoiseSlotB(next)322 store.stampLocalMutation()323 true324 } else {325 false326 }327 } catch (_: Exception) {328 false329 }330 return if (healed) {331 VaultCrypto.zero(rawKey)332 adoptSlotA(newKey, payload) 333 } else {334 VaultCrypto.zero(newKey)335 adoptSlotA(rawKey, payload) 336 }337 }338339 340 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {341 return try {342 derivedKey = key343 decoyMode = false344 entryList = payload.entries.toMutableList()345 duressConfigured = payload.duressConfigured346 recoveryWordHash = payload.recoveryWordHash347 recoveryWordsSet = payload.recoveryWordsSet348 _settings.value = store.loadSettings()349 ensureRootKey(payload)350 autoPurgeExpiredTrash()351 publish()352 _isUnlocked.value = true353 UnlockResult.Success354 } catch (e: Exception) {355 UnlockResult.Error(e.message ?: "unlock_failed")356 }357 }358359 360361 362363364365366367 class SlotAUnlock internal constructor(368 internal val key: ByteArray,369 internal val payload: VaultPayload,370 )371372 373374375376377378 fun tryUnlockSlotA(password: String): SlotAUnlock? {379 val slotA = store.loadSlotA() ?: return null380 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))381 return try {382 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))383 SlotAUnlock(key, payload)384 } catch (_: Exception) {385 VaultCrypto.zero(key)386 null387 }388 }389390 391 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {392 adoptSlotA(unlock.key, unlock.payload)393 }394395 396 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)397398 399400 401402403404405 class DuressUnlock internal constructor(406 internal val key: ByteArray,407 internal val payload: VaultPayload,408 )409410 411412413414415416417 fun tryUnlockDuress(password: String): DuressUnlock? {418 val slot = store.loadDecoySlot() ?: return null419 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))420 return try {421 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))422 DuressUnlock(key, payload)423 } catch (_: Exception) {424 VaultCrypto.zero(key)425 null426 }427 }428429 430 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {431 return try {432 derivedKey = unlock.key433 decoyMode = true434 entryList = unlock.payload.entries.toMutableList()435 duressConfigured = false436 recoveryWordHash = unlock.payload.recoveryWordHash437 recoveryWordsSet = unlock.payload.recoveryWordsSet438 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)439 else VaultCrypto.generateRootKey()440 _settings.value = store.loadSettings()441 publish()442 _isUnlocked.value = true443 UnlockResult.Success444 } catch (e: Exception) {445 UnlockResult.Error(e.message ?: "decoy_unlock_failed")446 }447 }448449 450 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)451452 453454455456457458 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {459 if (derivedKey == null || decoyMode) return false460 461 462 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }463 return try {464 val salt = VaultCrypto.generateSalt()465 val key = VaultCrypto.deriveKey(duressPassphrase, salt)466 467 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))468 val plaintext = VaultCodec.encode(decoyPayload)469 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))470 VaultCrypto.zero(key)471 VaultCrypto.zero(plaintext)472 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))473 if (ok) {474 duressConfigured = true475 runCatching { persistSlotA(bumpVersion = false) }476 }477 ok478 } catch (_: Exception) {479 false480 }481 }482483 484 suspend fun clearDuress(): Boolean = mutex.withLock {485 store.clearDecoySlot()486 if (derivedKey != null && !decoyMode) {487 duressConfigured = false488 runCatching { persistSlotA(bumpVersion = false) }489 }490 true491 }492493 494 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {495 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")496 return try {497 val payload = VaultCodec.decode(498 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),499 )500 derivedKey = key.copyOf()501 decoyMode = false502 entryList = payload.entries.toMutableList()503 duressConfigured = payload.duressConfigured504 recoveryWordHash = payload.recoveryWordHash505 recoveryWordsSet = payload.recoveryWordsSet506 _settings.value = store.loadSettings()507 ensureRootKey(payload)508 autoPurgeExpiredTrash()509 publish()510 _isUnlocked.value = true511 UnlockResult.Success512 } catch (_: Exception) {513 UnlockResult.WrongPassword514 }515 }516517 fun lock() {518 derivedKey?.let(VaultCrypto::zero)519 rootKey?.let(VaultCrypto::zero)520 derivedKey = null521 rootKey = null522 entryList = mutableListOf()523 duressConfigured = false524 recoveryWordHash = null525 recoveryWordsSet = false526 decoyMode = false527 _entries.value = emptyList()528 _isUnlocked.value = false529 }530531 @Volatile private var suppressNextBackgroundLock = false532533 534535536537538539540541542 fun expectTransientBackground() {543 suppressNextBackgroundLock = true544 }545546 547 fun consumeTransientBackgroundSuppression(): Boolean {548 val suppressed = suppressNextBackgroundLock549 suppressNextBackgroundLock = false550 return suppressed551 }552553 554555 556557558559560 561562563564565566567568569570571 fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()572573 574575576577578579580581582 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()583584 585586 fun syncVaultId(): String? =587 if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }588589 590591592593594 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {595 installRootKey(generateRecoverableRootKey())596 }597598 599600601602603604605606607608609610611612613614615 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {616 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {617 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")618 }619 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")620 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")621 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")622623 val incoming = hexToRootKey(rootKeyHex)624 if (incoming.size != VaultCrypto.KEY_SIZE) {625 VaultCrypto.zero(incoming)626 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")627 }628 629 630 631 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess632 if (!roundTrips) {633 VaultCrypto.zero(incoming)634 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")635 }636637 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)638 return if (installRootKey(incoming)) {639 AdoptVaultResult.Success(vaultId)640 } else {641 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")642 }643 }644645 646647648649650 fun previewRecoveryWords(rootKeyHex: String): List<String>? {651 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null652 val bytes = hexToRootKey(rootKeyHex)653 return try {654 val words = rootKeyToWords(bytes)655 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null656 } catch (_: Exception) {657 null658 } finally {659 VaultCrypto.zero(bytes)660 }661 }662663 664665666667668 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {669 val key = derivedKey ?: return false670 if (decoyMode) return false671 val priorSlot = store.loadSlotA() ?: return false672 val priorRootKey = rootKey ?: return false673 val priorRecoveryHash = recoveryWordHash674 val priorRecoverySet = recoveryWordsSet675 val nextWords = rootKeyToWords(nextRootKey)676677 return try {678 takeVaultSnapshot("local_save")679 rootKey = nextRootKey680 recoveryWordHash = recoveryWordsHash(nextWords)681 recoveryWordsSet = false682683 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)684 ?: throw IllegalStateException("recovery_escrow_failed")685 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)686 ?: throw IllegalStateException("root_escrow_failed")687 val plaintext = VaultCodec.encode(currentPayload())688 val blob = try {689 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))690 } finally {691 VaultCrypto.zero(plaintext)692 }693 val nextSlot = priorSlot.copy(694 blob = blob,695 recoveryEscrow = nextRecoveryEscrow,696 rootKeyEscrow = nextRootKeyEscrow,697 )698 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")699 try {700 writeMatchingNoiseSlotB(nextSlot)701 store.stampLocalMutation()702 } catch (e: Exception) {703 store.writeSlotA(priorSlot)704 throw e705 }706 VaultCrypto.zero(priorRootKey)707 publish()708 true709 } catch (_: Exception) {710 rootKey = priorRootKey711 recoveryWordHash = priorRecoveryHash712 recoveryWordsSet = priorRecoverySet713 VaultCrypto.zero(nextRootKey)714 store.writeSlotA(priorSlot)715 false716 }717 }718719 720721722723724 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()725726 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {727 val slotA = store.loadSlotA() ?: return false728 return try {729 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))730 val payload = VaultCodec.decode(731 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),732 )733 val newSalt = VaultCrypto.generateSalt()734 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)735 736 val plaintext = VaultCodec.encode(payload)737 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))738 739 740 741 742 743 744 745 val next = StoredVault(746 version = slotA.version,747 salt = b64(newSalt),748 blob = blob,749 recoveryEscrow = slotA.recoveryEscrow,750 rootKeyEscrow = slotA.rootKeyEscrow,751 )752 if (!store.writeSlotA(next)) return false753 writeMatchingNoiseSlotB(next)754 store.stampLocalMutation()755 VaultCrypto.zero(oldKey)756 derivedKey?.let(VaultCrypto::zero)757 derivedKey = newKey758 true759 } catch (_: Exception) {760 false761 }762 }763764 765766767768 suspend fun destroyVault() = mutex.withLock {769 lock()770 store.clearVault()771 store.clearDecoySlot()772 }773774 775776 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {777 requireUnlocked()778 val stamp = VaultCrypto.monotonicEntryTimestamp()779 val now = System.currentTimeMillis()780 val entry = withDerivedLast4(draft.copy(781 id = draft.id.ifBlank { UUID.randomUUID().toString() },782 createdAt = if (draft.createdAt > 0) draft.createdAt else now,783 modifiedAt = stamp,784 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,785 ))786 val rollback = entryList.toMutableList()787 entryList.add(entry)788 persistOrRollback(rollback)789 entry790 }791792 793794795796797798799800801 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {802 requireUnlocked()803 if (drafts.isEmpty()) return 0804 val rollback = entryList.toMutableList()805 val now = System.currentTimeMillis()806 for (draft in drafts) {807 val stamp = VaultCrypto.monotonicEntryTimestamp()808 entryList.add(809 withDerivedLast4(810 draft.copy(811 id = draft.id.ifBlank { UUID.randomUUID().toString() },812 createdAt = if (draft.createdAt > 0) draft.createdAt else now,813 modifiedAt = stamp,814 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,815 ),816 ),817 )818 }819 persistOrRollback(rollback)820 return drafts.size821 }822823 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {824 requireUnlocked()825 val idx = entryList.indexOfFirst { it.id == patch.id }826 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")827 val current = entryList[idx]828 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)829 830 val passwordChanged = patch.password != null && patch.password != current.password831 832 833 834 835 836 837 val priorPassword = current.password838 val shouldCapture = passwordChanged &&839 !priorPassword.isNullOrEmpty() &&840 current.passwordHistory?.firstOrNull()?.password != priorPassword841 val nextHistory = if (shouldCapture) {842 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +843 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)844 } else {845 current.passwordHistory846 }847 val updated = withDerivedLast4(patch.copy(848 createdAt = current.createdAt,849 modifiedAt = stamp,850 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,851 passwordHistory = nextHistory,852 ))853 val rollback = entryList.toMutableList()854 entryList[idx] = updated855 persistOrRollback(rollback)856 updated857 }858859 860 suspend fun deleteEntry(id: String) = mutex.withLock {861 requireUnlocked()862 val idx = entryList.indexOfFirst { it.id == id }863 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")864 val current = entryList[idx]865 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)866 val rollback = entryList.toMutableList()867 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)868 persistOrRollback(rollback)869 }870871 suspend fun restoreEntry(id: String) = mutex.withLock {872 requireUnlocked()873 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }874 if (idx == -1) throw IllegalStateException("deleted entry not found")875 val current = entryList[idx]876 val rollback = entryList.toMutableList()877 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))878 persistOrRollback(rollback)879 }880881 882 suspend fun purgeEntry(id: String) = mutex.withLock {883 requireUnlocked()884 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }885 if (idx == -1) throw IllegalStateException("deleted entry not found")886 val rollback = entryList.toMutableList()887 entryList.removeAt(idx)888 persistOrRollback(rollback)889 }890891 892 suspend fun purgeDeletedEntries(): Int = mutex.withLock {893 requireUnlocked()894 val rollback = entryList.toMutableList()895 val before = entryList.size896 entryList.removeAll { it.deletedAt != null }897 val removed = before - entryList.size898 if (removed > 0) persistOrRollback(rollback)899 removed900 }901902 903 suspend fun countDuplicateEntries(): Int = mutex.withLock {904 requireUnlocked()905 countDuplicateEntriesLocked()906 }907908 909910911912913 suspend fun deduplicateEntries(): Int = mutex.withLock {914 requireUnlocked()915 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")916 val active = entryList.filter { it.deletedAt == null }917 val keepIds = mutableSetOf<String>()918 active.groupBy { it.duplicateKey() }.values.forEach { group ->919 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id920 }921922 val rollback = entryList.toMutableList()923 var removed = 0924 entryList = entryList.map { entry ->925 if (entry.deletedAt == null && entry.id !in keepIds) {926 removed++927 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)928 entry.copy(deletedAt = stamp, modifiedAt = stamp)929 } else {930 entry931 }932 }.toMutableList()933 if (removed > 0) persistOrRollback(rollback)934 removed935 }936937 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }938 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }939 fun getDeletedEntries(): List<VaultEntry> =940 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }941942 943 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {944 val active = entryList.filter { it.deletedAt == null }945 if (tagFilter == null) return active946 val needle = tagFilter.trim().lowercase()947 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }948 }949950 951 fun getDistinctTags(): List<String> =952 entryList.asSequence()953 .filter { it.deletedAt == null }954 .flatMap { it.tags?.asSequence() ?: emptySequence() }955 .map { it.trim().lowercase() }956 .filter { it.isNotEmpty() }957 .distinct()958 .sorted()959 .toList()960961 962963964965966967 fun getDistinctFieldValues(field: String): List<String> {968 val select: (VaultEntry) -> String? = when (field) {969 "username" -> { e -> e.username }970 "email" -> { e -> e.email }971 else -> return emptyList()972 }973 return entryList.asSequence()974 .filter { it.deletedAt == null }975 .mapNotNull { select(it)?.trim() }976 .filter { it.isNotEmpty() }977 .groupingBy { it.lowercase() }978 .aggregate { _, acc: Pair<String, Int>?, value, _ ->979 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)980 }981 .values982 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })983 .map { it.first }984 }985986 fun getTombstones(): List<Tombstone> =987 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }988 fun getVaultVersion(): Long = store.version()989 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots990991 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {992 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false993 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false994 takeVaultSnapshot("restore")995 if (!store.writeSlotA(restoredA)) return false996 if (!store.writeSlotB(snapshot.vault_b)) return false997 store.stampLocalMutation()998 lock()999 true1000 }10011002 10031004 10051006100710081009 suspend fun applyMergedRemoteEntries(1010 remoteEntries: List<VaultEntry>,1011 remoteTombstones: List<Tombstone>,1012 ): Int = mutex.withLock {1013 requireUnlocked()1014 1015 1016 1017 1018 1019 val mergeNow = System.currentTimeMillis()1020 val clamped = remoteEntries.map { entry ->1021 entry.copy(1022 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1023 deletedAt = entry.deletedAt?.let {1024 SyncLimits.clampSyncTimestamp(it, mergeNow)1025 },1026 )1027 }1028 val tombMap = remoteTombstones.associate {1029 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1030 }1031 var applied = 01032 val byId = entryList.associateBy { it.id }.toMutableMap()10331034 for (remote in clamped) {1035 val tomb = tombMap[remote.id]1036 if (tomb != null && tomb >= remote.modifiedAt) continue1037 val local = byId[remote.id]1038 if (local == null) {1039 entryList.add(remote); byId[remote.id] = remote; applied++1040 } else if (remote.modifiedAt > local.modifiedAt) {1041 val i = entryList.indexOfFirst { it.id == local.id }1042 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1043 }1044 }10451046 for ((id, deletedAt) in tombMap) {1047 val local = byId[id] ?: continue1048 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1049 if (deletedAt < local.modifiedAt) continue1050 val i = entryList.indexOfFirst { it.id == id }1051 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1052 }10531054 if (applied > 0) {1055 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1056 publish()1057 }1058 applied1059 }10601061 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1062 _settings.value = settings1063 store.writeSettings(settings)1064 }10651066 10671068 1069 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet10701071 10721073107410751076 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1077 requireUnlocked()1078 recoveryWordHash = recoveryWordsHash(words)1079 recoveryWordsSet = true1080 1081 1082 1083 1084 runCatching {1085 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1086 }.getOrDefault(false)1087 }10881089 1090 fun verifyRecoveryWords(words: List<String>): Boolean {1091 val stored = recoveryWordHash ?: return false1092 return stored == recoveryWordsHash(words)1093 }10941095 10961097 1098 fun hasRecoveryEscrow(): Boolean =1099 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false11001101 110211031104110511061107110811091110111111121113 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1114 val slotA = store.loadSlotA() ?: return false1115 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false11161117 1118 1119 slotA.recoveryEscrow?.let { escrow ->1120 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1121 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1122 val keyBytes = recoveryKey.encoded1123 try {1124 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1125 val payload = VaultCodec.decode(plaintext)1126 decoyMode = false1127 entryList = payload.entries.toMutableList()1128 duressConfigured = payload.duressConfigured1129 recoveryWordHash = payload.recoveryWordHash1130 recoveryWordsSet = payload.recoveryWordsSet1131 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1132 else generateRecoverableRootKey()1133 _settings.value = store.loadSettings()1134 VaultCrypto.zero(plaintext)1135 return true1136 } catch (_: Exception) {1137 1138 } finally {1139 VaultCrypto.zero(material)1140 VaultCrypto.zero(keyBytes)1141 }1142 }11431144 1145 1146 1147 slotA.rootKeyEscrow?.let { escrow ->1148 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1149 decoyMode = false1150 entryList = mutableListOf()1151 duressConfigured = false1152 recoveryWordHash = recoveryWordsHash(words)1153 recoveryWordsSet = true1154 rootKey = rk1155 _settings.value = store.loadSettings()1156 return true1157 }1158 return false1159 }11601161 116211631164116511661167116811691170 suspend fun resetPasswordWithRecoveryWords(1171 words: List<String>,1172 newPassword: String,1173 ): Boolean = mutex.withLock {1174 if (rootKey == null) return false 1175 return try {1176 val newSalt = VaultCrypto.generateSalt()1177 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1178 val plaintext = VaultCodec.encode(currentPayload())1179 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1180 val prior = store.loadSlotA()1181 1182 1183 1184 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1185 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1186 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1187 val next = StoredVault(1188 version = prior?.version ?: 1,1189 salt = b64(newSalt),1190 blob = blob,1191 recoveryEscrow = freshRecovery,1192 rootKeyEscrow = freshRootKey,1193 )1194 if (!store.writeSlotA(next)) return false1195 writeMatchingNoiseSlotB(next)1196 store.stampLocalMutation()1197 derivedKey?.let(VaultCrypto::zero)1198 derivedKey = newKey1199 VaultCrypto.zero(plaintext)1200 publish()1201 _isUnlocked.value = true1202 true1203 } catch (_: Exception) {1204 false1205 }1206 }12071208 120912101211121212131214 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1215 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1216 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1217 val salt = VaultCrypto.generateSalt()1218 val recoveryKey = deriveRecoveryKey(material, salt)1219 val keyBytes = recoveryKey.encoded1220 val plaintext = VaultCodec.encode(currentPayload())1221 return try {1222 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1223 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1224 } finally {1225 VaultCrypto.zero(material)1226 VaultCrypto.zero(keyBytes)1227 VaultCrypto.zero(plaintext)1228 }1229 }12301231 12321233123412351236123712381239 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1240 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1241 val rk = rootKey ?: return null1242 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1243 val salt = VaultCrypto.generateSalt()1244 val recoveryKey = deriveRecoveryKey(material, salt)1245 val keyBytes = recoveryKey.encoded1246 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1247 return try {1248 1249 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1250 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1251 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1252 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1253 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1254 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1255 } finally {1256 VaultCrypto.zero(material)1257 VaultCrypto.zero(keyBytes)1258 VaultCrypto.zero(plaintext)1259 }1260 }12611262 1263 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1264 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1265 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1266 val keyBytes = recoveryKey.encoded1267 return try {1268 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1269 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1270 hexToRootKey(String(pt, Charsets.US_ASCII))1271 } catch (_: Exception) {1272 null1273 } finally {1274 VaultCrypto.zero(material)1275 VaultCrypto.zero(keyBytes)1276 }1277 }12781279 128012811282128312841285 private fun persistSlotAWithEscrows(1286 recoveryEscrow: RecoveryEscrow?,1287 rootKeyEscrow: RootKeyEscrow?,1288 ): Boolean {1289 val key = derivedKey ?: throw IllegalStateException("vault_locked")1290 if (decoyMode) return false1291 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1292 val plaintext = VaultCodec.encode(currentPayload())1293 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1294 val next = prior.copy(1295 blob = blob,1296 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1297 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1298 )1299 if (!store.writeSlotA(next)) return false1300 try {1301 writeMatchingNoiseSlotB(next)1302 } catch (e: Exception) {1303 store.writeSlotA(prior)1304 throw e1305 }1306 return true1307 }13081309 13101311 private fun requireUnlocked() {1312 if (derivedKey == null) throw IllegalStateException("vault_locked")1313 }13141315 private fun countDuplicateEntriesLocked(): Int {1316 val seen = mutableMapOf<String, VaultEntry>()1317 var duplicates = 01318 for (entry in entryList.filter { it.deletedAt == null }) {1319 val key = entry.duplicateKey()1320 val existing = seen[key]1321 if (existing == null) {1322 seen[key] = entry1323 } else {1324 duplicates++1325 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1326 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1327 if (entryStamp > existingStamp) seen[key] = entry1328 }1329 }1330 return duplicates1331 }13321333 private fun VaultEntry.duplicateKey(): String {1334 if (type == "note") return "note|${title.lowercase()}"1335 val passkeyData = passkey1336 if (passkeyData != null) {1337 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1338 }1339 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1340 }13411342 private fun takeVaultSnapshot(reason: String) {1343 runCatching {1344 val slotA = store.loadSlotA() ?: return1345 val vaultA = VaultCodec.encodeSlot(slotA)1346 val vaultB = store.loadSlotB() ?: slotA.blob1347 val history = store.loadVaultHistory()1348 val latest = history.snapshots.firstOrNull()1349 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1350 val entryCount = if (derivedKey != null && !decoyMode) {1351 bucketEntryCount(entryList.count { it.deletedAt == null })1352 } else {1353 null1354 }1355 val snapshot = VaultSnapshot(1356 id = UUID.randomUUID().toString(),1357 savedAt = System.currentTimeMillis(),1358 reason = reason,1359 vault_a = vaultA,1360 vault_b = vaultB,1361 entryCount = entryCount,1362 )1363 store.writeVaultHistory(1364 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1365 )1366 }1367 }13681369 private fun bucketEntryCount(n: Int): Int = when {1370 n <= 0 -> 01371 n <= 10 -> 101372 n <= 25 -> 251373 n <= 50 -> 501374 n <= 100 -> 1001375 n <= 250 -> 2501376 n <= 500 -> 5001377 else -> ((n + 499) / 500) * 5001378 }13791380 13811382138313841385 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1386 if (!entry.isCard()) return entry1387 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1388 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1389 }13901391 139213931394139513961397 private fun autoPurgeExpiredTrash() {1398 val days = _settings.value.trashRetentionDays ?: return1399 if (days <= 0) return1400 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1401 val before = entryList.size1402 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1403 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1404 }14051406 1407 private fun currentPayload(): VaultPayload =1408 VaultPayload(1409 entries = entryList.toList(),1410 duressConfigured = duressConfigured,1411 rootKeyB64 = rootKey?.let(::b64) ?: "",1412 recoveryWordHash = recoveryWordHash,1413 recoveryWordsSet = recoveryWordsSet,1414 )14151416 14171418141914201421 private fun ensureRootKey(payload: VaultPayload) {1422 if (payload.rootKeyB64.isNotBlank()) {1423 rootKey = unb64(payload.rootKeyB64)1424 return1425 }1426 rootKey = generateRecoverableRootKey()1427 runCatching { persistSlotA(bumpVersion = false) }1428 }14291430 private fun publish() {1431 _entries.value = entryList.filter { it.deletedAt == null }1432 }14331434 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1435 val plaintext = VaultCodec.encode(currentPayload())1436 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1437 return StoredVault(version = 1, salt = b64(salt), blob = blob)1438 }14391440 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1441 1442 1443 1444 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1445 val noise = ByteArray(approxBytes).also(rng::nextBytes)1446 store.writeSlotB(b64(noise))1447 }14481449 1450 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1451 val key = derivedKey ?: throw IllegalStateException("vault_locked")1452 1453 if (decoyMode) {1454 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1455 val plaintext = VaultCodec.encode(currentPayload())1456 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1457 return store.writeDecoySlot(prior.copy(blob = blob))1458 }1459 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1460 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1461 val plaintext = VaultCodec.encode(currentPayload())1462 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1463 val next = prior.copy(blob = blob)1464 if (!store.writeSlotA(next)) return false1465 1466 1467 try {1468 writeMatchingNoiseSlotB(next)1469 if (bumpVersion) store.stampLocalMutation()1470 } catch (e: Exception) {1471 store.writeSlotA(prior)1472 throw e1473 }1474 return true1475 }14761477 1478 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1479 try {1480 if (!persistSlotA(bumpVersion = true)) {1481 entryList = rollback1482 throw IllegalStateException("storage_write_failed")1483 }1484 } catch (e: Exception) {1485 entryList = rollback1486 throw e1487 }1488 publish()1489 }1490}