// 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 kotlinx.serialization.EncodeDefault import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** Port of the relevant parts of `Sync/SyncModels.swift`. Field names match the wire format * exactly — these payloads are shared with the Apple clients over the same relay/SGK. */ enum class SyncOp(val rawValue: String) { UPSERT("upsert"), DELETE("delete"), DEVICE_HELLO("device_hello"), DEVICE_LEAVE("device_leave"), DEVICE_REVOKE("device_revoke"), KEY_ROTATE("key_rotate"), HEARTBEAT("heartbeat"); companion object { fun fromRawValue(value: String): SyncOp? = entries.firstOrNull { it.rawValue == value } } } /** One archived past password, carried inside a `SyncPayload` upsert — see Swift's * `SyncPasswordHistoryItem`. Sending the whole history (not just the latest change) means a * device that joins the group late, or was offline through several password changes, still ends * up with the complete trail. */ @Serializable data class SyncPasswordHistoryItem( val id: String, @SerialName("changed_at") val changedAt: String, val password: String, ) @Serializable data class SyncPayload( // Swift's `var schema: Int = 1` has a default at the property level, but its auto-synthesized // Decodable still REQUIRES the "schema" key present in JSON regardless (Swift's Codable // doesn't treat a default value as making a key optional to decode — a real gotcha, caught by // a live cross-device test: kotlinx.serialization's own default (encodeDefaults = false) // silently omitted this field since it always equaled its default, and Swift rejected every // message with "keyNotFound: schema"). @EncodeDefault forces it to always be written. @OptIn(ExperimentalSerializationApi::class) @EncodeDefault(EncodeDefault.Mode.ALWAYS) val schema: Int = 1, @SerialName("event_id") val eventId: String, @SerialName("sender_device_id") val senderDeviceId: String, @SerialName("sender_device_name") val senderDeviceName: String? = null, @SerialName("sender_counter") val senderCounter: Int, val op: String, @SerialName("entry_id") val entryId: String? = null, @SerialName("updated_at") val updatedAt: String? = null, @SerialName("created_at") val createdAt: String? = null, val name: String? = null, val username: String? = null, val website: String? = null, val websites: List? = null, val password: String? = null, @SerialName("previous_password") val previousPassword: String? = null, @SerialName("password_history") val passwordHistory: List? = null, val note: String? = null, @SerialName("card_number") val cardNumber: String? = null, @SerialName("card_expiry") val cardExpiry: String? = null, @SerialName("card_cvv") val cardCvv: String? = null, @SerialName("card_pin") val cardPin: String? = null, @SerialName("totp_secret") val totpSecret: String? = null, @SerialName("entry_type") val entryType: String? = null, @SerialName("is_favorite") val isFavorite: Boolean? = null, // Bin state — non-nil means the entry is in the Bin as of this timestamp (ISO8601), nil // means active/restored. Syncs like any other field via upsert, so all devices agree on Bin // membership; permanent removal still goes through the existing .delete op (either the user // emptying the Bin, or the 30-day auto-purge — see BinPurger). Matches Swift's // SyncPayload.deletedAt exactly. @SerialName("deleted_at") val deletedAt: String? = null, @SerialName("passkey_rp_id") val passkeyRpId: String? = null, @SerialName("passkey_credential_id") val passkeyCredentialId: String? = null, @SerialName("passkey_user_handle") val passkeyUserHandle: String? = null, // Sender's own inbox ID — set on device_hello, device_leave, heartbeat, upsert, and delete // (added 2026-07-27 to the latter three: senderDeviceId, the relay-assigned identifier // previously used to match a message to a known peer for "last seen" tracking, was observed // live to silently drift out of sync with what a peer actually sends, permanently blocking // that peer's last-seen from ever advancing again — inboxId is authoritative and never // drifts, since it's the same identifier that already, correctly, routes every message). // EXCEPTION: on device_revoke this instead carries the TARGET's inbox (the device being // revoked) — Swift's SyncPayload has no separate "target_inbox_id" field (there is no such // wire field at all; a Kotlin-only field of that name here previously meant // applyDeviceRevoke could never match a real Swift-originated revoke — see // SyncManager.applyDeviceRevoke). On a *relayed* device_hello introduction // (sendPeerIntroduction), it names the third peer being introduced, not whoever actually // relayed the message — see the allow-list in pollOnce/touchPeerLastSeen. @SerialName("inbox_id") val inboxId: String? = null, @SerialName("device_name") val deviceName: String? = null, @SerialName("new_sgk") val newSgk: String? = null, ) @Serializable data class PairingCode( // Same gotcha as SyncPayload.schema above: this always equals its default, so // kotlinx.serialization's encodeDefaults=false silently drops it from the wire JSON — but // Swift's Codable still requires the "v" key present regardless of its own default, so a // Kotlin-generated pairing code failed to decode on every Swift device ("missing data" / // "cannot find data", the French/OS-localized text for DecodingError.keyNotFound). Reported // live (2026-07-30). @EncodeDefault forces it to always be written, exactly like schema. @OptIn(ExperimentalSerializationApi::class) @EncodeDefault(EncodeDefault.Mode.ALWAYS) val v: Int = 1, val url: String, @SerialName("stream_id") val streamId: String, val sgk: String, @SerialName("inbox_id") val inboxId: String, @SerialName("device_name") val deviceName: String, ) @Serializable data class SyncPeer( @SerialName("inbox_id") val inboxId: String, @SerialName("device_name") val deviceName: String, @SerialName("added_at") val addedAt: String, @SerialName("relay_device_id") val relayDeviceId: String? = null, @SerialName("last_seen_at") val lastSeenAt: String? = null, @SerialName("is_suspended") val isSuspended: Boolean = false, ) sealed class SyncError(message: String) : Exception(message) { object InvalidPairingCode : SyncError("Invalid pairing code") object NotConfigured : SyncError("Sync is not configured") // Windows/Linux has no purchase flow at all, so every Kotlin device always falls into the // one-shot branch below — see SyncManager.canShowPairingCode (matches Swift's identical // check, gated there by StoreManager.isSyncPurchased instead). Designed live 2026-07-30. object OneShotInviteAlreadyUsed : SyncError( "You've already used this device's one-time invite — subscribe to MyPwdTool sync on iOS/macOS, or use the device with which you subscribed to add more devices." ) }