// 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.vault.VaultDatabase import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json /** * 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: VaultDatabase, 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 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)) } /** 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, "") // 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() } }