// SPDX-License-Identifier: MIT // Copyright (c) 2026 Thibault Ducray // // This file is part of MyPwdTool's open-source sync/encryption core — see // LICENSE-SYNC-CRYPTO.md at the repo root and https://tducray.fr/mypwdtool/open-source/. // The rest of this application is proprietary and NOT covered by this license. package fr.tducray.mypwdtool.sync import fr.tducray.mypwdtool.crypto.CryptoManager import fr.tducray.mypwdtool.relay.Base64Url import fr.tducray.mypwdtool.relay.OutboundRelayMessage import fr.tducray.mypwdtool.relay.RelayAPIClient import fr.tducray.mypwdtool.relay.RelayError import fr.tducray.mypwdtool.vault.Entry import fr.tducray.mypwdtool.vault.EntryType import fr.tducray.mypwdtool.vault.PasswordHistoryEntry import fr.tducray.mypwdtool.vault.VaultDatabase import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import java.time.Instant import java.util.UUID /** * Port of the join-only subset of `Sync/SyncManager.swift` (see the "not ported" list in * `project_windows_client_progress.md`/README — sync group *creation*, device revocation * *initiation*, and vault-transfer are Apple-only per the agreed v1 scope; this client only * ever joins an existing group, but must correctly receive+apply device_hello/device_leave/ * device_revoke/key_rotate sent by any device in the group). * * Requires the vault to be unlocked — [dek] is the vault's data-encryption key, needed both to * decrypt entry fields before packing them into outbound sync payloads and to encrypt fields * from inbound payloads before writing them to [db]. Caller is responsible for calling * [stopEngine] on lock and constructing a fresh [SyncManager] (or providing an updated DEK) on * unlock — this class doesn't watch vault-lock state itself the way the Swift `@MainActor` * singleton observes `MasterPasswordManager.$isUnlocked`. */ class SyncManager( private val db: VaultDatabase, private val store: SyncStore, private val dek: ByteArray, private val platform: String, private val clientVersion: String, private val outbox: SyncOutbox = SyncOutbox(), private val scope: CoroutineScope = CoroutineScope(SupervisorJob()), /** Invoked after any inbound change actually lands in [db] (upsert/delete apply, or * self-revocation wiping the vault) — the JVM-only equivalent of Swift's `.databaseDidOpen` * notification. Called from whatever coroutine dispatcher the poll loop runs on, not * necessarily a UI thread; Compose's snapshot state system tolerates writes from background * threads, but other UI toolkits calling into this may need to hop threads themselves. */ private val onVaultChanged: () -> Unit = {}, /** Optional — records join/leave/revoke/key-rotation/undecryptable-message events to the * local activity log, mirroring `SecurityEventLogger` calls scattered through * `Sync/SyncManager.swift`. Nullable so existing tests don't need to construct one. */ private val securityLogger: fr.tducray.mypwdtool.security.SecurityEventLogger? = null, ) { companion object { // Matches Sync/SyncStore.swift's hardcoded serverURL — not persisted, same reasoning: // this is a fixed, developer-hosted relay, not a user setting. const val SERVER_URL = "https://relay.tducray.fr/relay/v3" private const val NORMAL_POLL_INTERVAL_MS = 60_000L private const val FAST_POLL_INTERVAL_MS = 10_000L private const val FAST_POLL_DURATION_MS = 3 * 60_000L private const val HEARTBEAT_INTERVAL_MS = 30 * 60_000L private const val SEND_DELAY_MS = 60L private const val POLL_BATCH_LIMIT = 100 } private val json = Json { ignoreUnknownKeys = true } private var client: RelayAPIClient = RelayAPIClient(SERVER_URL, platform, clientVersion) private var pollJob: Job? = null private var drainJob: Job? = null private var kickDrainJob: Job? = null private var fastPollUntil: Instant? = null private var lastHeartbeatSentAt: Instant? = null // MARK: - Joining a group suspend fun joinGroup(pairingCodeString: String, deviceName: String) { val decoded = Base64Url.decode(pairingCodeString.trim()) ?: throw SyncError.InvalidPairingCode val pairing = try { json.decodeFromString(PairingCode.serializer(), decoded.decodeToString()) } catch (e: Exception) { throw SyncError.InvalidPairingCode } val sgkBytes = Base64Url.decode(pairing.sgk) ?: throw SyncError.InvalidPairingCode require(sgkBytes.size == 32) { "SGK must be 32 bytes" } val joinClient = RelayAPIClient(pairing.url, platform, clientVersion) val response = joinClient.registerDevice(deviceName) println("[Sync] registered device_id=${response.deviceId} inbox_id=${response.inboxId} on ${pairing.url}") client = joinClient store.streamId = pairing.streamId store.deviceId = response.deviceId store.inboxId = response.inboxId store.deviceName = deviceName store.sendToken = response.sendToken store.recvToken = response.recvToken store.sgk = sgkBytes store.isEnabled = true store.upsertPeer(SyncPeer(inboxId = pairing.inboxId, deviceName = pairing.deviceName, addedAt = Instant.now().toString())) securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.SYNC_GROUP_JOINED, detail = pairing.deviceName) startEngine() enableFastPoll() scope.launch { println("[Sync] sending hello + bulk sync to initiator inbox=${pairing.inboxId}") sendHello(listOf(pairing.inboxId)) bulkSyncAllEntries(listOf(pairing.inboxId)) println("[Sync] hello + bulk sync sent") } } /** "Disconnect + revoke" — unlike Swift's `disableSync()` (which only sends `device_leave` * and deregisters, leaving the SGK usable by anyone who kept a copy), this also rotates the * SGK for the remaining peers first, mirroring what `revokeDevice(peer:)` does when revoking * *another* device. Requested explicitly (2026-07-24): a voluntary leave should burn the key * the leaving device held, not just poof out of the peer list. Worth porting the same * key-rotation-on-leave behavior back into Swift's `disableSync()` for parity — currently * only this Kotlin client does it. */ suspend fun leaveGroup() { val sendToken = store.sendToken val sgk = store.sgk val recipients = store.peers.map { it.inboxId } if (sendToken != null && sgk != null && recipients.isNotEmpty()) { try { sendLeaveMessage(recipients, sgk, sendToken) } catch (e: Exception) { println("[Sync] device_leave broadcast failed: $e — leaving locally regardless") } try { sendKeyRotationMessage(sgk, recipients, sendToken) } catch (e: Exception) { println("[Sync] key rotation on leave failed: $e — group keeps the old SGK") } } if (sendToken != null) { try { client.deregisterDevice(sendToken) } catch (e: Exception) { /* best-effort */ } } stopEngine() store.clearAll() } private suspend fun sendLeaveMessage(recipients: List, sgk: ByteArray, sendToken: String) { val streamId = store.streamId ?: return val deviceId = store.deviceId ?: return val payload = SyncPayload( eventId = UUID.randomUUID().toString(), senderDeviceId = deviceId, senderCounter = store.incrementSenderCounter(), op = SyncOp.DEVICE_LEAVE.rawValue, inboxId = store.inboxId, deviceName = store.deviceName, ) sendPayload(payload, recipients, sgk, streamId, deviceId, sendToken) } private suspend fun sendKeyRotationMessage(oldSgk: ByteArray, recipients: List, sendToken: String) { val streamId = store.streamId ?: return val deviceId = store.deviceId ?: return val newSgk = SyncCrypto.generateSGK() val payload = SyncPayload( eventId = UUID.randomUUID().toString(), senderDeviceId = deviceId, senderCounter = store.incrementSenderCounter(), op = SyncOp.KEY_ROTATE.rawValue, newSgk = Base64Url.encode(newSgk), ) // Sealed under the OLD sgk deliberately — that's the key every remaining peer still has. sendPayload(payload, recipients, oldSgk, streamId, deviceId, sendToken) // Only switch our own key once the announcement is durably queued on the relay — // matches Swift's sendKeyRotation. leaveGroup ignores this (it clears the whole store // right after anyway); revokeDevice needs it so this device keeps syncing with the // remaining peers under the new key. store.sgk = newSgk } /** The current device's own pairing code — lets an already-joined device invite another one, * without needing a fresh relay round-trip (unlike joining, generating an invite is purely * local: it's just this device's own streamId/sgk/inboxId/deviceName, re-encoded). Port of * Swift's `SyncManager.currentPairingCode()`. */ /** Whether this device may currently mint a pairing code to invite another device. Windows/ * Linux has no purchase flow at all, so this is always the one-shot-per-vault check — see the * doc comment on SyncError.OneShotInviteAlreadyUsed and Swift's identical * SyncManager.canShowPairingCode. */ val canShowPairingCode: Boolean get() = db.getSetting("sync_one_shot_invite_used") != "1" fun currentPairingCode(): String { if (!canShowPairingCode) throw SyncError.OneShotInviteAlreadyUsed val sgk = store.sgk ?: throw SyncError.InvalidPairingCode val streamId = store.streamId ?: throw SyncError.InvalidPairingCode val inboxId = store.inboxId ?: throw SyncError.InvalidPairingCode val pairing = PairingCode( url = SERVER_URL, streamId = streamId, sgk = Base64Url.encode(sgk), inboxId = inboxId, deviceName = store.deviceName ?: "", ) val code = Base64Url.encode(json.encodeToString(PairingCode.serializer(), pairing).encodeToByteArray()) db.setSetting("sync_one_shot_invite_used", "1") return code } /** Removes `peer` from the local group immediately and unconditionally — the local removal * is what actually enforces the revoke from this device's point of view (every outbound path * only ever sends to `store.peers`, so once `peer` is gone from that list this device never * queues anything addressed to it again, regardless of whether the relay is reachable right * now). The broadcast + key rotation below are still attempted, best-effort, since they're * what lets the target itself (self-wipe) and other peers learn about the revoke — but their * failure doesn't block the local suppression the user actually needs. Port of Swift's * `SyncManager.revokeDevice(peer:)`. */ suspend fun revokeDevice(peer: SyncPeer) { val allRecipients = store.peers.map { it.inboxId } store.peers = store.peers.filterNot { it.inboxId == peer.inboxId } securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.DEVICE_REVOKED, detail = peer.deviceName) val sendToken = store.sendToken val sgk = store.sgk if (sendToken == null || sgk == null || allRecipients.isEmpty()) return try { sendDeviceRevokeMessage(peer, allRecipients, sgk, sendToken) } catch (e: Exception) { println("[Sync] device_revoke broadcast failed: $e — revoked locally regardless") } val remaining = store.peers.map { it.inboxId } if (remaining.isNotEmpty()) { try { sendKeyRotationMessage(sgk, remaining, sendToken) } catch (e: Exception) { println("[Sync] key rotation after revoke failed: $e — group keeps the old SGK until next revoke") } } } private suspend fun sendDeviceRevokeMessage(peer: SyncPeer, recipients: List, sgk: ByteArray, sendToken: String) { val streamId = store.streamId ?: return val deviceId = store.deviceId ?: return val payload = SyncPayload( eventId = UUID.randomUUID().toString(), senderDeviceId = deviceId, senderCounter = store.incrementSenderCounter(), op = SyncOp.DEVICE_REVOKE.rawValue, inboxId = peer.inboxId, // target's inbox — receivers remove this device deviceName = peer.deviceName, ) sendPayload(payload, recipients, sgk, streamId, deviceId, sendToken) } // MARK: - Lifecycle fun startEngine() { if (!store.isConfigured) return stopEngine() pollJob = scope.launch { pollLoop() } drainJob = scope.launch { sendOutboxItems() } } fun stopEngine() { pollJob?.cancel(); pollJob = null drainJob?.cancel(); drainJob = null kickDrainJob?.cancel(); kickDrainJob = null } fun forceSyncNow() { if (store.isConfigured) startEngine() } fun resumeIfNeeded() { if (pollJob == null) startEngine() } private fun enableFastPoll() { fastPollUntil = Instant.now().plusMillis(FAST_POLL_DURATION_MS) } // MARK: - Outbox fun enqueueUpsert(entry: Entry) { println("[Sync] enqueueUpsert entry=${entry.id} name=${entry.name} updatedAt=${entry.updatedAt}") outbox.enqueue(SyncOutbox.Item(SyncOp.UPSERT, entry.id, VaultDatabase.formatInstant(entry.updatedAt), entry)) kickDrain() } fun enqueueDelete(entryId: UUID, updatedAt: Instant = Instant.now()) { println("[Sync] enqueueDelete entry=$entryId") outbox.enqueue(SyncOutbox.Item(SyncOp.DELETE, entryId, VaultDatabase.formatInstant(updatedAt), null)) kickDrain() } // enqueueUpsert (and therefore kickDrain) can now be called from the Chrome-extension // socket server's background threads, not just the Compose UI thread/sync coroutines — this // lock keeps the cancel-then-reassign of kickDrainJob atomic against that, so two // near-simultaneous callers can't race and silently drop the scheduled drain (same class of // bug as VaultDatabase's — see its own lock's doc comment for the live-observed symptom this // general pattern causes). private val kickDrainLock = Any() private fun kickDrain() { synchronized(kickDrainLock) { kickDrainJob?.cancel() kickDrainJob = scope.launch { delay(100) sendOutboxItems() } } } private suspend fun sendOutboxItems() { val sendToken = store.sendToken ?: run { println("[Sync] sendOutboxItems: no sendToken, skipping drain (items stay queued)") return } if (store.sgk == null) { println("[Sync] sendOutboxItems: no sgk, skipping drain (items stay queued)") return } val recipients = store.peers.filterNot { it.isSuspended }.map { it.inboxId } if (recipients.isEmpty()) { println("[Sync] sendOutboxItems: no non-suspended peers, skipping drain (items stay queued)") return } sendItems(outbox.drain(), recipients, sendToken) } private suspend fun bulkSyncAllEntries(recipientInboxIds: List) { val sendToken = store.sendToken ?: return if (recipientInboxIds.isEmpty()) return val items = db.fetchAllEntries().map { SyncOutbox.Item(SyncOp.UPSERT, it.id, VaultDatabase.formatInstant(it.updatedAt), it) } sendItems(items, recipientInboxIds, sendToken) } private suspend fun sendItems(items: List, recipients: List, sendToken: String) { val sgk = store.sgk ?: return val streamId = store.streamId ?: return val deviceId = store.deviceId ?: return for (item in items) { try { val payload = buildOutboundPayload(item, deviceId) sendPayload(payload, recipients, sgk, streamId, deviceId, sendToken) delay(SEND_DELAY_MS) } catch (e: CancellationException) { throw e } catch (e: Exception) { // Previously silent — a permanently-failing item (e.g. a decrypt failure on a // corrupted field) would re-enqueue here forever with zero diagnostic output, // which is exactly what made a real live bug (2026-07-29: an entry updated via // the Chrome extension stopped syncing, with "no sync log in IntelliJ, no // message on the relay") impossible to diagnose after the fact. println("[Sync] send failed for entry=${item.entryId} op=${item.op}, re-enqueuing: $e") outbox.enqueue(item) } } } private fun buildOutboundPayload(item: SyncOutbox.Item, deviceId: String): SyncPayload { val vaultId = db.vaultId?.toString() ?: "unknown-vault" val entry = item.entry fun dec(fieldName: String, blob: String?): String? = blob?.let { try { CryptoManager.decrypt(it, dek, CryptoManager.aad(vaultId, item.entryId.toString(), fieldName)) } catch (e: Exception) { println("[Sync] decrypt failed for entry=${item.entryId} field=$fieldName: $e") throw e } } return SyncPayload( eventId = UUID.randomUUID().toString(), senderDeviceId = deviceId, senderDeviceName = store.deviceName, // Authoritative identity for last-seen matching (added 2026-07-27) — see // touchPeerLastSeen: unlike senderDeviceId (relay-assigned, observed live to drift // out of sync with a peer's stored copy), inboxId is the same identifier that // already, correctly, routes every message, so it can't drift. inboxId = store.inboxId, senderCounter = store.incrementSenderCounter(), op = item.op.rawValue, entryId = item.entryId.toString(), updatedAt = item.updatedAt, createdAt = entry?.let { VaultDatabase.formatInstant(it.createdAt) }, name = entry?.name, username = entry?.username, websites = entry?.websites, password = entry?.let { dec("password", it.encryptedPassword) }, previousPassword = entry?.let { dec("previousPassword", it.encryptedPreviousPassword) }, // Send the complete history, not just this change — see SyncPasswordHistoryItem. // Well within the relay's 262144-byte message cap even for a long history. passwordHistory = entry?.let { e -> db.fetchPasswordHistory(e.id).mapNotNull { h -> val pwd = runCatching { CryptoManager.decrypt(h.encryptedPassword, dek, CryptoManager.aad(vaultId, e.id.toString(), "password")) }.getOrNull() ?: return@mapNotNull null SyncPasswordHistoryItem( id = h.id.toString(), changedAt = VaultDatabase.formatInstant(h.changedAt), password = pwd, ) } }, note = entry?.let { dec("note", it.encryptedNote) }, cardNumber = entry?.let { dec("cardNumber", it.encryptedCardNumber) }, cardExpiry = entry?.let { dec("cardExpiry", it.encryptedCardExpiry) }, cardCvv = entry?.let { dec("cardCvv", it.encryptedCardCVV) }, cardPin = entry?.let { dec("cardPin", it.encryptedCardPin) }, totpSecret = entry?.let { dec("totpSecret", it.encryptedTOTPSecret) }, entryType = entry?.entryType?.rawValue, isFavorite = entry?.isFavorite, deletedAt = entry?.deletedAt?.let { VaultDatabase.formatInstant(it) }, passkeyRpId = entry?.passkeyRelyingPartyId, passkeyCredentialId = entry?.let { dec("passkeyCredentialId", it.encryptedPasskeyCredentialId) }, passkeyUserHandle = entry?.let { dec("passkeyUserHandle", it.encryptedPasskeyUserHandle) }, ) } private suspend fun sendPayload( payload: SyncPayload, recipients: List, sgk: ByteArray, streamId: String, deviceId: String, sendToken: String, ) { val plaintext = json.encodeToString(SyncPayload.serializer(), payload).toByteArray() val messageId = UUID.randomUUID().toString() val ciphertext = SyncCrypto.encrypt(plaintext, sgk, messageId, streamId, deviceId, recipients) val msg = OutboundRelayMessage( messageId = messageId, streamId = streamId, senderDeviceId = deviceId, recipientInboxIds = recipients, ciphertext = ciphertext, ) client.sendMessage(msg, sendToken) println("[Sync] sent op=${payload.op} to ${recipients.size} recipient(s): $recipients") } private suspend fun sendProtocolMessage(op: SyncOp, recipients: List, configure: SyncPayload.() -> SyncPayload = { this }) { val sgk = store.sgk ?: return val streamId = store.streamId ?: return val deviceId = store.deviceId ?: return val sendToken = store.sendToken ?: return if (recipients.isEmpty()) return val base = SyncPayload( eventId = UUID.randomUUID().toString(), senderDeviceId = deviceId, senderDeviceName = store.deviceName, senderCounter = store.incrementSenderCounter(), op = op.rawValue, ).configure() try { sendPayload(base, recipients, sgk, streamId, deviceId, sendToken) } catch (e: CancellationException) { throw e } catch (e: Exception) { // Protocol messages (hello/heartbeat/leave) aren't retried through the outbox — // they're announcements, not durable state changes; a future poll/hello will // re-sync anything that matters. } } private suspend fun sendHello(recipients: List) { sendProtocolMessage(SyncOp.DEVICE_HELLO, recipients) { copy(inboxId = store.inboxId, deviceName = store.deviceName) } } private suspend fun sendHeartbeat(recipients: List) { sendProtocolMessage(SyncOp.HEARTBEAT, recipients) { copy(inboxId = store.inboxId) } } /** Announces `announcedInboxId`/`announcedDeviceName` (a peer) to `recipients`. */ private suspend fun sendPeerIntroduction(recipients: List, announcedInboxId: String, announcedDeviceName: String) { sendProtocolMessage(SyncOp.DEVICE_HELLO, recipients) { copy(inboxId = announcedInboxId, deviceName = announcedDeviceName) } } // MARK: - Poll loop private suspend fun pollLoop() { println("[Sync] poll loop started (interval ${NORMAL_POLL_INTERVAL_MS}ms normal / ${FAST_POLL_INTERVAL_MS}ms fast)") while (currentCoroutineContext().isActive) { try { pollOnce() } catch (e: CancellationException) { throw e } catch (e: RelayError.TokenExpired) { break } catch (e: RelayError.Unauthorized) { // Needs re-registration (re-join) — stop polling until that happens. break } catch (e: Exception) { delay(30_000) continue } val now = Instant.now() val fastPoll = fastPollUntil?.isAfter(now) == true if (lastHeartbeatSentAt == null || now.isAfter(lastHeartbeatSentAt!!.plusMillis(HEARTBEAT_INTERVAL_MS))) { val recipients = store.peers.filterNot { it.isSuspended }.map { it.inboxId } if (recipients.isNotEmpty()) sendHeartbeat(recipients) lastHeartbeatSentAt = now } delay(if (fastPoll) FAST_POLL_INTERVAL_MS else NORMAL_POLL_INTERVAL_MS) } } private suspend fun pollOnce() { val recvToken = store.recvToken ?: return val inboxId = store.inboxId ?: return val response = client.pollMessages(recvToken, inboxId, store.cursor, POLL_BATCH_LIMIT, 0) if (response.messages.isEmpty()) return println("[Sync] poll: received ${response.messages.size} message(s)") var allApplied = true val toAck = mutableListOf() for (msg in response.messages) { if (store.hasApplied(msg.messageId)) { toAck.add(msg.messageId) touchPeerLastSeen(msg.senderDeviceId, senderInboxId = null, senderDeviceName = null) continue } val plaintext = try { SyncCrypto.decrypt(msg.ciphertext, store.sgk ?: byteArrayOf(), msg.messageId, msg.streamId, msg.senderDeviceId, msg.recipientInboxIds) } catch (e: Exception) { // Permanent (AEAD is deterministic) — most likely SGK rotated since this was // sent. ACK it to clear it from the relay rather than retrying forever. securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.SYNC_MESSAGE_UNDECRYPTABLE) toAck.add(msg.messageId) continue } val payload = try { json.decodeFromString(SyncPayload.serializer(), plaintext.decodeToString()) } catch (e: Exception) { toAck.add(msg.messageId) continue } // Allow-list, not a deny-list: inboxId only reliably means "the sender's own inbox" // for heartbeat/upsert/delete (the three ops it's populated for in // buildOutboundPayload/sendHeartbeat). device_revoke repurposes it as the TARGET // being revoked; device_hello can be a *relayed* introduction naming a third peer, // not whoever actually relayed the message — trusting it there would credit the // wrong peer's last-seen. val senderOp = SyncOp.fromRawValue(payload.op) val senderInboxId = if (senderOp == SyncOp.HEARTBEAT || senderOp == SyncOp.UPSERT || senderOp == SyncOp.DELETE) payload.inboxId else null if (store.hasApplied(payload.eventId)) { toAck.add(msg.messageId) touchPeerLastSeen(msg.senderDeviceId, senderInboxId, payload.senderDeviceName) continue } val applied = applyPayload(payload) if (applied) { store.markApplied(payload.eventId) store.markApplied(msg.messageId) touchPeerLastSeen(msg.senderDeviceId, senderInboxId, payload.senderDeviceName) toAck.add(msg.messageId) } else { allApplied = false } } if (toAck.isNotEmpty()) client.ackMessages(recvToken, inboxId, toAck) if (allApplied && response.nextCursor.isNotEmpty()) store.cursor = response.nextCursor } /** Called on *every* ACKed message, including the two dedupe-and-skip paths above (a * message already marked applied, e.g. a redelivery after a prior ACK attempt failed) — * not just freshly-applied ones. A dedupe-skipped message still proves the sender is alive * and reachable right now; skipping the update there let a peer show a stale "last seen" * timestamp days in the past even while its redelivered messages were being ACKed * successfully in the present (matches the same fix in Swift's SyncManager.swift). * * `senderInboxId` is the authoritative match — it's the same identifier that already, * correctly, routes every message, so unlike `relayDeviceId` it can't drift. `relayDeviceId`, * then `senderDeviceName`, are fallbacks only for messages that predate `inboxId` being * carried on heartbeat/upsert/delete — matching by name risks a false match if two peers * share a display name, which `inboxId` can't, since it's relay-assigned and unique. */ private fun touchPeerLastSeen(relayDeviceId: String, senderInboxId: String?, senderDeviceName: String?) { var peer = senderInboxId?.let { id -> store.peers.firstOrNull { it.inboxId == id } } if (peer == null) peer = store.peers.firstOrNull { it.relayDeviceId == relayDeviceId } if (peer == null && senderDeviceName != null) { peer = store.peers.firstOrNull { it.deviceName == senderDeviceName } } if (peer == null) { println("[Sync] last-seen update skipped: no peer matched senderDeviceId=$relayDeviceId senderInboxId=$senderInboxId senderDeviceName=$senderDeviceName — known peers: ${store.peers.map { Triple(it.deviceName, it.inboxId, it.relayDeviceId) }}") return } store.upsertPeer(peer.copy(lastSeenAt = Instant.now().toString(), relayDeviceId = relayDeviceId)) } // MARK: - Applying inbound payloads internal suspend fun applyPayload(payload: SyncPayload): Boolean { println("[Sync] applying op=${payload.op} from sender_device_id=${payload.senderDeviceId}") return when (SyncOp.fromRawValue(payload.op)) { SyncOp.UPSERT -> applyUpsert(payload) SyncOp.DELETE -> applyDelete(payload) SyncOp.DEVICE_HELLO -> applyDeviceHello(payload) SyncOp.DEVICE_LEAVE -> applyDeviceLeave(payload) SyncOp.DEVICE_REVOKE -> applyDeviceRevoke(payload) SyncOp.KEY_ROTATE -> applyKeyRotate(payload) SyncOp.HEARTBEAT, null -> true } } internal fun applyUpsert(payload: SyncPayload): Boolean { val entryId = payload.entryId?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?: return true val updatedAt = payload.updatedAt?.let { runCatching { VaultDatabase.parseInstant(it) }.getOrNull() } ?: Instant.now() // Falls back to a fixed placeholder when the vault row doesn't exist yet (matches // buildOutboundPayload's fallback) rather than dropping the update — vaultId only scopes // AAD, it isn't a correctness requirement for applying an inbound entry. val vaultId = db.vaultId?.toString() ?: "unknown-vault" fun enc(fieldName: String, plain: String?): String? = plain?.let { CryptoManager.encrypt(it, dek, CryptoManager.aad(vaultId, entryId.toString(), fieldName)) } val resolvedWebsites = payload.websites ?: payload.website?.let { listOf(it) } val existing = db.fetchEntry(entryId) if (existing != null) { if (!updatedAt.isAfter(existing.updatedAt)) return true // stale — already applied a newer version val updated = existing.copy( name = payload.name ?: existing.name, username = payload.username ?: existing.username, websites = resolvedWebsites ?: existing.websites, encryptedPassword = enc("password", payload.password) ?: existing.encryptedPassword, encryptedPreviousPassword = enc("previousPassword", payload.previousPassword) ?: existing.encryptedPreviousPassword, encryptedNote = enc("note", payload.note) ?: existing.encryptedNote, encryptedCardNumber = enc("cardNumber", payload.cardNumber) ?: existing.encryptedCardNumber, encryptedCardExpiry = enc("cardExpiry", payload.cardExpiry) ?: existing.encryptedCardExpiry, encryptedCardCVV = enc("cardCvv", payload.cardCvv) ?: existing.encryptedCardCVV, encryptedCardPin = enc("cardPin", payload.cardPin) ?: existing.encryptedCardPin, encryptedTOTPSecret = enc("totpSecret", payload.totpSecret) ?: existing.encryptedTOTPSecret, passkeyRelyingPartyId = payload.passkeyRpId ?: existing.passkeyRelyingPartyId, encryptedPasskeyCredentialId = enc("passkeyCredentialId", payload.passkeyCredentialId) ?: existing.encryptedPasskeyCredentialId, encryptedPasskeyUserHandle = enc("passkeyUserHandle", payload.passkeyUserHandle) ?: existing.encryptedPasskeyUserHandle, entryType = payload.entryType?.let { EntryType.fromRawValue(it) } ?: existing.entryType, isFavorite = payload.isFavorite ?: existing.isFavorite, // Direct replacement, not a fallback-to-existing like the fields above: a // restore needs to explicitly clear this to null, which `?: existing.deletedAt` // could never do (nil-coalescing can't distinguish "absent" from "restored"). deletedAt = payload.deletedAt?.let { runCatching { VaultDatabase.parseInstant(it) }.getOrNull() }, updatedAt = updatedAt, ) db.updateEntry(updated) onVaultChanged() } else { val entry = Entry( id = entryId, name = payload.name ?: "", username = payload.username, websites = resolvedWebsites ?: emptyList(), encryptedPassword = enc("password", payload.password), encryptedPreviousPassword = enc("previousPassword", payload.previousPassword), encryptedNote = enc("note", payload.note), encryptedCardNumber = enc("cardNumber", payload.cardNumber), encryptedCardExpiry = enc("cardExpiry", payload.cardExpiry), encryptedCardCVV = enc("cardCvv", payload.cardCvv), encryptedCardPin = enc("cardPin", payload.cardPin), encryptedTOTPSecret = enc("totpSecret", payload.totpSecret), passkeyRelyingPartyId = payload.passkeyRpId, encryptedPasskeyCredentialId = enc("passkeyCredentialId", payload.passkeyCredentialId), encryptedPasskeyUserHandle = enc("passkeyUserHandle", payload.passkeyUserHandle), entryType = payload.entryType?.let { EntryType.fromRawValue(it) } ?: EntryType.LOGIN, isFavorite = payload.isFavorite ?: false, createdAt = payload.createdAt?.let { runCatching { VaultDatabase.parseInstant(it) }.getOrNull() } ?: updatedAt, updatedAt = updatedAt, deletedAt = payload.deletedAt?.let { runCatching { VaultDatabase.parseInstant(it) }.getOrNull() }, ) db.insertEntry(entry) onVaultChanged() } // Merge the sender's full history rather than just this one change, so a device that // joined the group late (or was offline for several password changes) still ends up with // every entry another device already has. insertPasswordHistory is INSERT OR IGNORE on // id (see VaultDatabase), so re-applying the same message is a no-op. payload.passwordHistory?.forEach { item -> val historyId = runCatching { UUID.fromString(item.id) }.getOrNull() ?: return@forEach val encPwd = enc("password", item.password) ?: return@forEach val changedAt = runCatching { VaultDatabase.parseInstant(item.changedAt) }.getOrNull() ?: Instant.now() db.insertPasswordHistory(PasswordHistoryEntry(id = historyId, entryId = entryId, encryptedPassword = encPwd, changedAt = changedAt)) } return true } internal fun applyDelete(payload: SyncPayload): Boolean { val entryId = payload.entryId?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?: return true db.deleteEntry(entryId) onVaultChanged() return true } private suspend fun applyDeviceHello(payload: SyncPayload): Boolean { val newInboxId = payload.inboxId ?: return true if (newInboxId in store.revokedInboxIds) return true val newDeviceName = payload.deviceName ?: "" val isNewPeer = store.peers.none { it.inboxId == newInboxId } store.upsertPeer(SyncPeer(inboxId = newInboxId, deviceName = newDeviceName, addedAt = Instant.now().toString())) enableFastPoll() if (isNewPeer && newInboxId != store.inboxId) { bulkSyncAllEntries(listOf(newInboxId)) // Full-mesh bootstrap: a joiner's pairing code only names the initiator, so relay // introductions both ways so every device eventually learns about every peer. val otherPeers = store.peers.filter { it.inboxId != newInboxId } for (peer in otherPeers) { sendPeerIntroduction(listOf(peer.inboxId), newInboxId, newDeviceName) sendPeerIntroduction(listOf(newInboxId), peer.inboxId, peer.deviceName) } } return true } private fun applyDeviceLeave(payload: SyncPayload): Boolean { val inboxId = payload.inboxId ?: return true store.removePeer(inboxId) securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.PEER_LEFT_SYNC_GROUP, detail = payload.deviceName) return true } private suspend fun applyDeviceRevoke(payload: SyncPayload): Boolean { // Swift's revokeDevice(peer:) reuses the device_hello fields (inbox_id/device_name) to // carry the revoked device's identity for device_revoke too — there is no dedicated // "target" field on the wire. val targetInboxId = payload.inboxId ?: return true if (targetInboxId == store.inboxId) { performSelfRevocation() } else { store.removePeer(targetInboxId) } store.markRevoked(targetInboxId) return true } private fun applyKeyRotate(payload: SyncPayload): Boolean { val newSgk = payload.newSgk?.let { Base64Url.decode(it) } ?: return true if (newSgk.size != 32) return true store.sgk = newSgk securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.SYNC_KEY_ROTATED) return true } private suspend fun performSelfRevocation() { db.deleteAllEntries() securityLogger?.log(fr.tducray.mypwdtool.security.SecurityEventType.SELF_REVOKED) val sendToken = store.sendToken if (sendToken != null) { try { client.deregisterDevice(sendToken) } catch (e: Exception) { /* best-effort */ } } stopEngine() store.clearAll() // Fired after clearAll(), not before — onVaultChanged is also what the UI uses to // refresh its mirrored "is sync configured" flag (see desktopApp's isSyncConfigured); // firing it while the store was still fully configured (only entries had been wiped so // far) left that flag stuck stale until some unrelated recomposition happened to catch up // — confirmed live: the sidebar cloud icon and its dialog kept showing the pre-revoke // configured state (stale device name, stale peer list) until the dialog was closed. onVaultChanged() } }