SyncMessage.kt

7.3 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         * Wire protocol version, incremented when the sync sequence itself24         * changes. Distinct from [version] (vault data version) and25         * [deviceManifestVersion]. Old clients omit this field; treat missing26         * as 1.27         */28        val protocolVersion: Int? = null,29        /**30         * Advertises device-graph exchange support. The step runs only when31         * both peers advertise a compatible version.32         */33        val deviceManifestVersion: Int? = null,34    ) : SyncMessage()3536    @Serializable37    @SerialName("SYNC_MANIFEST")38    data class Manifest(39        val entries: Map<String, Long>,40        val tombstones: Map<String, Long>,41    ) : SyncMessage()4243    @Serializable44    @SerialName("SYNC_REQUEST")45    data class Request(val entryIds: List<String>) : SyncMessage()4647    @Serializable48    @SerialName("SYNC_ENTRIES")49    data class Entries(val entries: Map<String, String>) : SyncMessage()5051    @Serializable52    @SerialName("SYNC_TOMBSTONES")53    data class Tombstones(val tombstones: Map<String, Long>) : SyncMessage()5455    @Serializable56    @SerialName("SYNC_DEVICE_MANIFEST")57    data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage()5859    @Serializable60    @SerialName("SYNC_DONE")61    data class Done(62        val entriesReceived: Int = 0,63        val entriesSent: Int = 0,64    ) : SyncMessage()6566    @Serializable67    @SerialName("SYNC_ERROR")68    data class Error(val error: String) : SyncMessage()69}7071@Serializable72data class ManifestDeviceEntry(73    val deviceId: String,74    val deviceName: String,75    val deviceType: String,76    val longTermPublicKeyHex: String,77    val signingPublicKeyHex: String,78    val wsPort: Int? = null,79    val relayUrl: String? = null,80    val pairedAt: Long,81    /** Number of vouching edges from a directly paired device. */82    val vouchDepth: Int = 0,83)8485@Serializable86data class DeviceManifestBody(87    val manifestVersion: Int,88    val issuerDeviceId: String,89    val issuedAt: Long,90    val devices: List<ManifestDeviceEntry>,91    val revoked: List<DeviceRevocation> = emptyList(),92)9394/**95 * A device that must no longer be synced with. Revocation is TERMINAL: once a96 * deviceId OR its long-term key appears here, no manifest may re-vouch it and97 * no pending entry carrying it may be confirmed. Tombstone-style98 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in99 * a manifest is peer-supplied, so a peer could resurrect a revoked device just100 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed101 * re-pair with a physically present user.102 *103 * HONEST LIMIT: signing keys derive from the shared vault root key, so these104 * signatures prove vault membership, not device identity. A revoked device105 * still holds the root key and, running modified software, can ignore its own106 * revocation. This stops honest peers talking to it; it is not a cryptographic107 * ejection. A genuinely compromised device needs the master password and108 * recovery words rotated.109 */110@Serializable111data class DeviceRevocation(112    val deviceId: String,113    /** Load-bearing identity key used to prevent re-vouching under a new id. */114    val longTermPublicKeyHex: String,115    val revokedAt: Long,116    val revokedByDeviceId: String,117    /**118     * Frozen record version. Defaulted rather than required so a persisted119     * pre-migration record still decodes; such a record is rejected by120     * signature verification, which covers its different canonical bytes.121     */122    val recordVersion: Int = DEVICE_REVOCATION_RECORD_VERSION,123    /** Ed25519 signature over [canonicalDeviceRevocation]. */124    val signatureHex: String = "",125)126127/**128 * Frozen revocation record version (campaign §5.5, Amendment A-2). Every field129 * except [DeviceRevocation.signatureHex] is covered by the canonical bytes the130 * issuer signs — and `recordVersion` is part of those bytes, so a peer cannot131 * reinterpret the record under a different version.132 *133 * The record deliberately carries no issuer key: a receiver resolves134 * `revokedByDeviceId` through its own immutable known-key history, so a peer135 * that merely relays a manifest cannot authorise a revocation by supplying a136 * key of its own.137 */138const val DEVICE_REVOCATION_RECORD_VERSION = 2139140@Serializable141data class SignedDeviceManifest(142    val body: DeviceManifestBody,143    /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */144    val signatureHex: String,145)146147/**148 * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat149 * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the150 * optional SYNC_DONE counters on the wire.151 */152val SyncJson: Json = Json {153    classDiscriminator = "type"154    encodeDefaults = true155    ignoreUnknownKeys = true156}157158fun SyncMessage.encodeToBytes(): ByteArray =159    SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8)160161fun decodeSyncMessage(bytes: ByteArray): SyncMessage =162    SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))163164/**165 * Reject a decoded frame whose declared structure exceeds the protocol's166 * limits, before any diff or persistence work touches it (PAI-2026-003).167 *168 * [requireSyncFrameSize] bounds a frame's bytes, not its shape: a 24 MiB frame169 * of one-character keys carries millions of map entries, and the String and170 * Map allocation for those — plus the diff and apply passes that follow —171 * dwarfs the frame itself. LockedSyncSpoolStore already enforced these same172 * bounds on the locked spool; the unlocked coordinator had none.173 */174fun SyncMessage.requireBounded() {175    fun checkIds(ids: Collection<String>, cap: Int, what: String) {176        if (ids.size > cap) throw SyncStructureTooLargeException(what)177        if (ids.any { it.length > SyncLimits.MAX_ID_LENGTH }) {178            throw SyncStructureTooLargeException("$what identifier length")179        }180    }181    when (this) {182        is SyncMessage.Manifest -> {183            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries")184            checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones")185        }186        is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_REQUEST_ENTRY_COUNT, "request ids")187        is SyncMessage.Entries -> {188            checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries")189            if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) {190                throw SyncStructureTooLargeException("entry envelope")191            }192        }193        is SyncMessage.Tombstones -> checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "tombstones")194        else -> Unit195    }196}