// 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://mypwdtool.com/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.vault.VaultStorage import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import java.util.UUID /** * Port of `Sync/SyncStore.swift`, minus vault-scoping (v1 scope is single-vault only, see * `project_windows_client_scope.md`). Plaintext state lives in [VaultDatabase]'s `settings` * table (the UserDefaults equivalent). The three real secrets (`send_token`, `recv_token`, * `sgk`) — Keychain-backed on Apple — are stored in the same `settings` table but encrypted at * rest under the vault DEK via [CryptoManager], the same protection the vault already gives * every other sensitive field; there is no OS keychain to lean on generically across * Windows/Linux/Android. */ class SyncStore(private val db: VaultStorage, private val dek: ByteArray) { private val json = Json { ignoreUnknownKeys = true } private companion object Keys { const val ENABLED = "sync_enabled" const val STREAM_ID = "sync_stream_id" const val DEVICE_ID = "sync_device_id" const val INBOX_ID = "sync_inbox_id" const val DEVICE_NAME = "sync_device_name" const val LAST_DEVICE_NAME_DRAFT = "sync_last_device_name_draft" const val PEERS = "sync_peers" const val REVOKED_INBOX_IDS = "sync_revoked_inbox_ids" const val CURSOR = "sync_cursor" const val SENDER_COUNTER = "sync_sender_counter" const val APPLIED_EVENTS = "sync_applied_events" const val SEND_TOKEN = "sync_send_token" const val RECV_TOKEN = "sync_recv_token" const val SGK = "sync_sgk" const val CREATED_BY_THIS_DEVICE = "sync_created_by_this_device" const val SYNC_PURCHASED = "sync_purchased" const val LICENSE_KEY = "sync_license_key" const val LICENSE_INSTANCE_ID = "sync_license_instance_id" const val PENDING_TRANSFER_IDS = "sync_pending_transfer_ids" const val MAX_APPLIED_EVENTS = 10_000 // Pseudo entry id for AAD scoping of sync secrets — these aren't vault entries, but // reuse CryptoManager's per-field AAD scheme for consistency with how entries are // protected (see project_windows_client_progress.md). const val AAD_ENTRY_ID = "sync-secrets" } private fun encryptSecret(fieldName: String, value: String): String = CryptoManager.encrypt(value, dek, CryptoManager.aad(vaultIdString(), AAD_ENTRY_ID, fieldName)) private fun decryptSecret(fieldName: String, blob: String): String = CryptoManager.decrypt(blob, dek, CryptoManager.aad(vaultIdString(), AAD_ENTRY_ID, fieldName)) private fun vaultIdString(): String = db.vaultId?.toString() ?: "unknown-vault" var isEnabled: Boolean get() = db.getSetting(ENABLED) == "true" set(value) = db.setSetting(ENABLED, value.toString()) var streamId: String? get() = db.getSetting(STREAM_ID) set(value) { if (value != null) db.setSetting(STREAM_ID, value) } var deviceId: String? get() = db.getSetting(DEVICE_ID) set(value) { if (value != null) db.setSetting(DEVICE_ID, value) } var inboxId: String? get() = db.getSetting(INBOX_ID) set(value) { if (value != null) db.setSetting(INBOX_ID, value) } var deviceName: String? get() = db.getSetting(DEVICE_NAME) set(value) { if (value != null) db.setSetting(DEVICE_NAME, value) } /** Remembers whatever the user last typed into the join-group dialog's device-name field — * separate from [deviceName], which is only set once a join actually succeeds. Not cleared * by [clearAll], deliberately: leaving a group and reopening the dialog to rejoin (or join a * different group) shouldn't make the user retype their own device's name. */ var lastDeviceNameDraft: String? get() = db.getSetting(LAST_DEVICE_NAME_DRAFT) set(value) { if (value != null) db.setSetting(LAST_DEVICE_NAME_DRAFT, value) } var sendToken: String? get() = db.getSetting(SEND_TOKEN)?.takeIf { it.isNotEmpty() }?.let { decryptSecret("sendToken", it) } set(value) { if (value != null) db.setSetting(SEND_TOKEN, encryptSecret("sendToken", value)) } var recvToken: String? get() = db.getSetting(RECV_TOKEN)?.takeIf { it.isNotEmpty() }?.let { decryptSecret("recvToken", it) } set(value) { if (value != null) db.setSetting(RECV_TOKEN, encryptSecret("recvToken", value)) } /** Raw 32-byte SGK. Stored hex-encoded, encrypted at rest. */ var sgk: ByteArray? get() = db.getSetting(SGK)?.takeIf { it.isNotEmpty() }?.let { hexToBytes(decryptSecret("sgk", it)) } set(value) { if (value != null) db.setSetting(SGK, encryptSecret("sgk", bytesToHex(value))) } var cursor: String? get() = db.getSetting(CURSOR) set(value) { if (value != null) db.setSetting(CURSOR, value) } var senderCounter: Int get() = db.getSetting(SENDER_COUNTER)?.toIntOrNull() ?: 0 set(value) = db.setSetting(SENDER_COUNTER, value.toString()) fun incrementSenderCounter(): Int { val next = senderCounter + 1 senderCounter = next return next } var peers: List get() = db.getSetting(PEERS)?.let { try { json.decodeFromString(ListSerializer(SyncPeer.serializer()), it) } catch (e: Exception) { emptyList() } } ?: emptyList() set(value) = db.setSetting(PEERS, json.encodeToString(ListSerializer(SyncPeer.serializer()), value)) fun upsertPeer(peer: SyncPeer) { val updated = peers.filterNot { it.inboxId == peer.inboxId } + peer peers = updated } fun removePeer(inboxId: String) { peers = peers.filterNot { it.inboxId == inboxId } } var revokedInboxIds: Set get() = db.getSetting(REVOKED_INBOX_IDS)?.let { try { json.decodeFromString(ListSerializer(String.serializer()), it).toSet() } catch (e: Exception) { emptySet() } } ?: emptySet() set(value) = db.setSetting( REVOKED_INBOX_IDS, json.encodeToString(ListSerializer(String.serializer()), value.toList()), ) fun markRevoked(inboxId: String) { revokedInboxIds = revokedInboxIds + inboxId } private fun loadAppliedEvents(): List = db.getSetting(APPLIED_EVENTS)?.let { try { json.decodeFromString(ListSerializer(String.serializer()), it) } catch (e: Exception) { emptyList() } } ?: emptyList() fun hasApplied(eventId: String): Boolean = loadAppliedEvents().contains(eventId) fun markApplied(eventId: String) { val updated = (loadAppliedEvents() + eventId).takeLast(MAX_APPLIED_EVENTS) db.setSetting(APPLIED_EVENTS, json.encodeToString(ListSerializer(String.serializer()), updated)) } /** Distinguishes "this device started the group" from "this device joined a group someone * else started" — both `createGroup()` and `joinGroup()` otherwise leave [isConfigured] in an * identical state. Only ever set `true` by `createGroup()` (gated at the call site by * `canCreateGroup()` in MainActivity.kt); used by `VaultScreen`'s expiry check to decide * whether it, specifically, should self-revoke once `canCreateGroup()` stops returning true. */ var createdByThisDevice: Boolean get() = db.getSetting(CREATED_BY_THIS_DEVICE) == "true" set(value) = db.setSetting(CREATED_BY_THIS_DEVICE, value.toString()) /** Android's counterpart to Swift's `StoreManager.isSyncPurchased` — cached locally so * `canCreateGroup()` (MainActivity.kt) can be a plain synchronous check, refreshed whenever * `BillingManager` confirms or revokes the Play Billing sync subscription entitlement. * Deliberately NOT cleared by [clearAll]: leaving a group doesn't cancel the subscription, * and the next `queryPurchasesAsync` call will just confirm it's still active anyway — same * reasoning as `revokedInboxIds` staying put across a leave/rejoin cycle. */ var isSyncPurchased: Boolean get() = db.getSetting(SYNC_PURCHASED) == "true" set(value) = db.setSetting(SYNC_PURCHASED, value.toString()) /** Windows/Linux's counterpart to [isSyncPurchased] — the Lemon Squeezy license key and the * per-device activation id it returned, persisted so `LicenseManager` (desktopApp) can * re-validate against Lemon Squeezy's `/v1/licenses/validate` on every launch without asking * the user to paste the key again. `licenseInstanceId` scopes that validate call to this * specific device's activation (out of the product's activation-limit pool), matching what * `/v1/licenses/activate` returned when this device first activated. Neither is cleared by * [clearAll] — same reasoning as [isSyncPurchased]: leaving a sync group doesn't cancel the * underlying subscription. */ var licenseKey: String? get() = db.getSetting(LICENSE_KEY)?.takeIf { it.isNotEmpty() } set(value) = db.setSetting(LICENSE_KEY, value ?: "") var licenseInstanceId: String? get() = db.getSetting(LICENSE_INSTANCE_ID)?.takeIf { it.isNotEmpty() } set(value) = db.setSetting(LICENSE_INSTANCE_ID, value ?: "") /** Entries written by [fr.tducray.mypwdtool.vault.VaultTransferService] into this vault while * it was inactive (i.e. not the currently unlocked one) — drained by [SyncManager.startEngine] * on the next unlock so a copied/moved entry actually reaches the sync group instead of * silently staying local until some unrelated edit touches it. Persisted inside this vault's * own settings table (via [db]), not a separate keyed-by-vault-id store — so, unlike Swift's * equivalent (`SyncStore.addPendingTransferEntryID`/`drainPendingTransferEntryIDs`, scoped by * `UserDefaults` and vulnerable to a registry-id/file-internal-id mix-up, see the 2026-09 * Swift bugfix), there is no second id to keep in sync with this one: whichever [VaultStorage] * this class is constructed with is unambiguously the vault this list belongs to. */ fun addPendingTransferEntryID(id: UUID) { val ids = pendingTransferEntryIds if (id !in ids) { db.setSetting(PENDING_TRANSFER_IDS, json.encodeToString(ListSerializer(String.serializer()), (ids + id).map { it.toString() })) } } private val pendingTransferEntryIds: List get() = db.getSetting(PENDING_TRANSFER_IDS)?.let { try { json.decodeFromString(ListSerializer(String.serializer()), it).mapNotNull { s -> runCatching { UUID.fromString(s) }.getOrNull() } } catch (e: Exception) { emptyList() } } ?: emptyList() fun drainPendingTransferEntryIDs(): List { val ids = pendingTransferEntryIds if (ids.isNotEmpty()) db.setSetting(PENDING_TRANSFER_IDS, json.encodeToString(ListSerializer(String.serializer()), emptyList())) return ids } /** True once every field required to run the sync engine is present. */ val isConfigured: Boolean get() = isEnabled && !streamId.isNullOrEmpty() && !deviceId.isNullOrEmpty() && !inboxId.isNullOrEmpty() && sendToken != null && recvToken != null && sgk != null fun clearAll() { isEnabled = false db.setSetting(STREAM_ID, "") db.setSetting(DEVICE_ID, "") db.setSetting(INBOX_ID, "") db.setSetting(DEVICE_NAME, "") db.setSetting(PEERS, "[]") db.setSetting(CURSOR, "") db.setSetting(SENDER_COUNTER, "0") db.setSetting(APPLIED_EVENTS, "[]") db.setSetting(SEND_TOKEN, "") db.setSetting(RECV_TOKEN, "") db.setSetting(SGK, "") createdByThisDevice = false // revokedInboxIds intentionally NOT cleared — matches Swift's clearAll(), so tombstones // survive a leave/rejoin cycle. } private fun bytesToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } }