SyncMessage.kt

4.2 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 OR its long-term key appears here, no manifest may re-vouch it and82 * no pending entry carrying it may be confirmed. Tombstone-style83 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in84 * a manifest is peer-supplied, so a peer could resurrect a revoked device just85 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed86 * re-pair with a physically present user.87 *88 * HONEST LIMIT: signing keys derive from the shared vault root key, so these89 * signatures prove vault membership, not device identity. A revoked device90 * still holds the root key and, running modified software, can ignore its own91 * revocation. This stops honest peers talking to it; it is not a cryptographic92 * ejection. A genuinely compromised device needs the master password and93 * recovery words rotated.94 */95@Serializable96data class DeviceRevocation(97    val deviceId: String,98    /** Load-bearing, not descriptive: `deviceId` is whatever an issuing99     *  manifest says it is, so the key is what re-vouching is matched against.100     *  [NO_KEY_SENTINEL] means no key was ever stored and matches nothing. */101    val longTermPublicKeyHex: String,102    val revokedAt: Long,103    val revokedByDeviceId: String,104)105106@Serializable107data class SignedDeviceManifest(108    val body: DeviceManifestBody,109    /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */110    val signatureHex: String,111)112113/**114 * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat115 * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the116 * optional SYNC_DONE counters on the wire.117 */118val SyncJson: Json = Json {119    classDiscriminator = "type"120    encodeDefaults = true121    ignoreUnknownKeys = true122}123124fun SyncMessage.encodeToBytes(): ByteArray =125    SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8)126127fun decodeSyncMessage(bytes: ByteArray): SyncMessage =128    SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))