package com.peachpasswords.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json /** * Sync wire protocol — exact port of the browser `SyncMessage` * union (shared/sync-types.ts). Each frame is JSON with a `type` discriminator, * sent over the Noise-NK-encrypted session. Entries travel as opaque base64 * ciphertext envelopes; the coordinator never sees plaintext. */ @Serializable sealed class SyncMessage { @Serializable @SerialName("SYNC_HELLO") data class Hello( val vaultId: String, val deviceId: String, val version: Long, val deviceTime: Long, /** * Wire protocol version, incremented when the sync sequence itself * changes. Distinct from [version] (vault data version) and * [deviceManifestVersion]. Old clients omit this field; treat missing * as 1. */ val protocolVersion: Int? = null, /** * Advertises device-graph exchange support. The step runs only when * both peers advertise a compatible version. */ val deviceManifestVersion: Int? = null, ) : SyncMessage() @Serializable @SerialName("SYNC_MANIFEST") data class Manifest( val entries: Map, val tombstones: Map, ) : SyncMessage() @Serializable @SerialName("SYNC_REQUEST") data class Request(val entryIds: List) : SyncMessage() @Serializable @SerialName("SYNC_ENTRIES") data class Entries(val entries: Map) : SyncMessage() @Serializable @SerialName("SYNC_TOMBSTONES") data class Tombstones(val tombstones: Map) : SyncMessage() @Serializable @SerialName("SYNC_DEVICE_MANIFEST") data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage() @Serializable @SerialName("SYNC_DONE") data class Done( val entriesReceived: Int = 0, val entriesSent: Int = 0, ) : SyncMessage() @Serializable @SerialName("SYNC_ERROR") data class Error(val error: String) : SyncMessage() } @Serializable data class ManifestDeviceEntry( val deviceId: String, val deviceName: String, val deviceType: String, val longTermPublicKeyHex: String, val signingPublicKeyHex: String, val wsPort: Int? = null, val relayUrl: String? = null, val pairedAt: Long, /** Number of vouching edges from a directly paired device. */ val vouchDepth: Int = 0, ) @Serializable data class DeviceManifestBody( val manifestVersion: Int, val issuerDeviceId: String, val issuedAt: Long, val devices: List, val revoked: List = emptyList(), ) /** * A device that must no longer be synced with. Revocation is TERMINAL: once a * deviceId OR its long-term key appears here, no manifest may re-vouch it and * no pending entry carrying it may be confirmed. Tombstone-style * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in * a manifest is peer-supplied, so a peer could resurrect a revoked device just * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed * re-pair with a physically present user. * * HONEST LIMIT: signing keys derive from the shared vault root key, so these * signatures prove vault membership, not device identity. A revoked device * still holds the root key and, running modified software, can ignore its own * revocation. This stops honest peers talking to it; it is not a cryptographic * ejection. A genuinely compromised device needs the master password and * recovery words rotated. */ @Serializable data class DeviceRevocation( val deviceId: String, /** Load-bearing identity key used to prevent re-vouching under a new id. */ val longTermPublicKeyHex: String, val revokedAt: Long, val revokedByDeviceId: String, /** * Frozen record version. Defaulted rather than required so a persisted * pre-migration record still decodes; such a record is rejected by * signature verification, which covers its different canonical bytes. */ val recordVersion: Int = DEVICE_REVOCATION_RECORD_VERSION, /** Ed25519 signature over [canonicalDeviceRevocation]. */ val signatureHex: String = "", ) /** * Frozen revocation record version (campaign §5.5, Amendment A-2). Every field * except [DeviceRevocation.signatureHex] is covered by the canonical bytes the * issuer signs — and `recordVersion` is part of those bytes, so a peer cannot * reinterpret the record under a different version. * * The record deliberately carries no issuer key: a receiver resolves * `revokedByDeviceId` through its own immutable known-key history, so a peer * that merely relays a manifest cannot authorise a revocation by supplying a * key of its own. */ const val DEVICE_REVOCATION_RECORD_VERSION = 2 @Serializable data class SignedDeviceManifest( val body: DeviceManifestBody, /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */ val signatureHex: String, ) /** * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the * optional SYNC_DONE counters on the wire. */ val SyncJson: Json = Json { classDiscriminator = "type" encodeDefaults = true ignoreUnknownKeys = true } fun SyncMessage.encodeToBytes(): ByteArray = SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8) fun decodeSyncMessage(bytes: ByteArray): SyncMessage = SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8)) /** * Reject a decoded frame whose declared structure exceeds the protocol's * limits, before any diff or persistence work touches it (PAI-2026-003). * * [requireSyncFrameSize] bounds a frame's bytes, not its shape: a 24 MiB frame * of one-character keys carries millions of map entries, and the String and * Map allocation for those — plus the diff and apply passes that follow — * dwarfs the frame itself. LockedSyncSpoolStore already enforced these same * bounds on the locked spool; the unlocked coordinator had none. */ fun SyncMessage.requireBounded() { fun checkIds(ids: Collection, cap: Int, what: String) { if (ids.size > cap) throw SyncStructureTooLargeException(what) if (ids.any { it.length > SyncLimits.MAX_ID_LENGTH }) { throw SyncStructureTooLargeException("$what identifier length") } } when (this) { is SyncMessage.Manifest -> { checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries") checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones") } is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_REQUEST_ENTRY_COUNT, "request ids") is SyncMessage.Entries -> { checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries") if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) { throw SyncStructureTooLargeException("entry envelope") } } is SyncMessage.Tombstones -> checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "tombstones") else -> Unit } }