SyncMessage.kt

3.9 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    ) : SyncMessage()2324    @Serializable25    @SerialName("SYNC_MANIFEST")26    data class Manifest(27        val entries: Map<String, Long>,28        val tombstones: Map<String, Long>,29    ) : SyncMessage()3031    @Serializable32    @SerialName("SYNC_REQUEST")33    data class Request(val entryIds: List<String>) : SyncMessage()3435    @Serializable36    @SerialName("SYNC_ENTRIES")37    data class Entries(val entries: Map<String, String>) : SyncMessage()3839    @Serializable40    @SerialName("SYNC_TOMBSTONES")41    data class Tombstones(val tombstones: Map<String, Long>) : SyncMessage()4243    @Serializable44    @SerialName("SYNC_DEVICE_MANIFEST")45    data class DeviceManifest(val manifest: SignedDeviceManifest?) : SyncMessage()4647    @Serializable48    @SerialName("SYNC_DONE")49    data class Done(50        val entriesReceived: Int = 0,51        val entriesSent: Int = 0,52    ) : SyncMessage()5354    @Serializable55    @SerialName("SYNC_ERROR")56    data class Error(val error: String) : SyncMessage()57}5859@Serializable60data class ManifestDeviceEntry(61    val deviceId: String,62    val deviceName: String,63    val deviceType: String,64    val longTermPublicKeyHex: String,65    val signingPublicKeyHex: String,66    val wsPort: Int? = null,67    val pairedAt: Long,68)6970@Serializable71data class DeviceManifestBody(72    val manifestVersion: Int,73    val issuerDeviceId: String,74    val issuedAt: Long,75    val devices: List<ManifestDeviceEntry>,76    val revoked: List<DeviceRevocation> = emptyList(),77)7879/**80 * A device that must no longer be synced with. Revocation is TERMINAL: once a81 * deviceId appears here no manifest may re-vouch it. Tombstone-style82 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in83 * a manifest is peer-supplied, so a peer could resurrect a revoked device just84 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed85 * re-pair with a physically present user.86 *87 * HONEST LIMIT: signing keys derive from the shared vault root key, so these88 * signatures prove vault membership, not device identity. A revoked device89 * still holds the root key and, running modified software, can ignore its own90 * revocation. This stops honest peers talking to it; it is not a cryptographic91 * ejection. A genuinely compromised device needs the master password and92 * recovery words rotated.93 */94@Serializable95data class DeviceRevocation(96    val deviceId: String,97    val longTermPublicKeyHex: String,98    val revokedAt: Long,99    val revokedByDeviceId: String,100)101102@Serializable103data class SignedDeviceManifest(104    val body: DeviceManifestBody,105    /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */106    val signatureHex: String,107)108109/**110 * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat111 * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the112 * optional SYNC_DONE counters on the wire.113 */114val SyncJson: Json = Json {115    classDiscriminator = "type"116    encodeDefaults = true117    ignoreUnknownKeys = true118}119120fun SyncMessage.encodeToBytes(): ByteArray =121    SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8)122123fun decodeSyncMessage(bytes: ByteArray): SyncMessage =124    SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))