package com.peachpasswords.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json /** * Chirp Sync wire protocol — exact port of the browser `ChirpSyncMessage` * 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, ) : 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 pairedAt: Long, ) @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 appears here no manifest may re-vouch it. 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, val longTermPublicKeyHex: String, val revokedAt: Long, val revokedByDeviceId: String, ) @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))