SyncMessage.kt

6.0 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json67/**8 * Sync wire protocol — exact port of the browser `SyncMessage`9 * union (shared/sync-types.ts). Each frame is JSON with a `type` discriminator,10 * sent over the Noise-NK-encrypted session. Entries travel as opaque base6411 * ciphertext envelopes; the coordinator never sees plaintext.12 */13@Serializable14sealed class SyncMessage {15    @Serializable16    @SerialName("SYNC_HELLO")17    data class Hello(18        val vaultId: String,19        val deviceId: String,20        val version: Long,21        val deviceTime: Long,22        /**23         * Advertises equal-version device-graph exchange support. Older peers24         * omit this field and retain the legacy SYNC_DONE fast path.25         */26        val deviceManifestVersion: Int? = null,27    ) : SyncMessage()2829    @Serializable30    @SerialName("SYNC_MANIFEST")31    data class Manifest(32        val entries: Map<String, Long>,33        val tombstones: Map<String, Long>,34    ) : SyncMessage()3536    @Serializable37    @SerialName("SYNC_REQUEST")38    data class Request(val entryIds: List<String>) : SyncMessage()3940    @Serializable41    @SerialName("SYNC_ENTRIES")42    data class Entries(val entries: Map<String, String>) : SyncMessage()4344    @Serializable45    @SerialName("SYNC_TOMBSTONES")46    data class Tombstones(val tombstones: Map<String, Long>) : SyncMessage()4748    @Serializable49    @SerialName("SYNC_DEVICE_MANIFEST")50    data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage()5152    @Serializable53    @SerialName("SYNC_DONE")54    data class Done(55        val entriesReceived: Int = 0,56        val entriesSent: Int = 0,57    ) : SyncMessage()5859    @Serializable60    @SerialName("SYNC_ERROR")61    data class Error(val error: String) : SyncMessage()62}6364@Serializable65data class ManifestDeviceEntry(66    val deviceId: String,67    val deviceName: String,68    val deviceType: String,69    val longTermPublicKeyHex: String,70    val signingPublicKeyHex: String,71    val wsPort: Int? = null,72    val relayUrl: String? = null,73    val pairedAt: Long,74)7576@Serializable77data class DeviceManifestBody(78    val manifestVersion: Int,79    val issuerDeviceId: String,80    val issuedAt: Long,81    val devices: List<ManifestDeviceEntry>,82    val revoked: List<DeviceRevocation> = emptyList(),83)8485/**86 * A device that must no longer be synced with. Revocation is TERMINAL: once a87 * deviceId OR its long-term key appears here, no manifest may re-vouch it and88 * no pending entry carrying it may be confirmed. Tombstone-style89 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in90 * a manifest is peer-supplied, so a peer could resurrect a revoked device just91 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed92 * re-pair with a physically present user.93 *94 * HONEST LIMIT: signing keys derive from the shared vault root key, so these95 * signatures prove vault membership, not device identity. A revoked device96 * still holds the root key and, running modified software, can ignore its own97 * revocation. This stops honest peers talking to it; it is not a cryptographic98 * ejection. A genuinely compromised device needs the master password and99 * recovery words rotated.100 */101@Serializable102data class DeviceRevocation(103    val deviceId: String,104    /** Load-bearing, not descriptive: `deviceId` is whatever an issuing105     *  manifest says it is, so the key is what re-vouching is matched against.106     *  [NO_KEY_SENTINEL] means no key was ever stored and matches nothing. */107    val longTermPublicKeyHex: String,108    val revokedAt: Long,109    val revokedByDeviceId: String,110)111112@Serializable113data class SignedDeviceManifest(114    val body: DeviceManifestBody,115    /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */116    val signatureHex: String,117)118119/**120 * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat121 * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the122 * optional SYNC_DONE counters on the wire.123 */124val SyncJson: Json = Json {125    classDiscriminator = "type"126    encodeDefaults = true127    ignoreUnknownKeys = true128}129130fun SyncMessage.encodeToBytes(): ByteArray =131    SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8)132133fun decodeSyncMessage(bytes: ByteArray): SyncMessage =134    SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))135136/**137 * Reject a decoded frame whose declared structure exceeds the protocol's138 * limits, before any diff or persistence work touches it (PAI-2026-003).139 *140 * [requireSyncFrameSize] bounds a frame's bytes, not its shape: a 24 MiB frame141 * of one-character keys carries millions of map entries, and the String and142 * Map allocation for those — plus the diff and apply passes that follow —143 * dwarfs the frame itself. LockedSyncSpoolStore already enforced these same144 * bounds on the locked spool; the unlocked coordinator had none.145 */146fun SyncMessage.requireBounded() {147    fun checkIds(ids: Collection<String>, cap: Int, what: String) {148        if (ids.size > cap) throw SyncStructureTooLargeException(what)149        if (ids.any { it.length > SyncLimits.MAX_ID_LENGTH }) {150            throw SyncStructureTooLargeException("$what identifier length")151        }152    }153    when (this) {154        is SyncMessage.Manifest -> {155            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries")156            checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones")157        }158        is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_REQUEST_ENTRY_COUNT, "request ids")159        is SyncMessage.Entries -> {160            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries")161            if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) {162                throw SyncStructureTooLargeException("entry envelope")163            }164        }165        is SyncMessage.Tombstones -> checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "tombstones")166        else -> Unit167    }168}