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 relayUrl: String? = null,68    val pairedAt: Long,69)7071@Serializable72data class DeviceManifestBody(73    val manifestVersion: Int,74    val issuerDeviceId: String,75    val issuedAt: Long,76    val devices: List<ManifestDeviceEntry>,77    val revoked: List<DeviceRevocation> = emptyList(),78)7980/**81 * A device that must no longer be synced with. Revocation is TERMINAL: once a82 * deviceId OR its long-term key appears here, no manifest may re-vouch it and83 * no pending entry carrying it may be confirmed. Tombstone-style84 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in85 * a manifest is peer-supplied, so a peer could resurrect a revoked device just86 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed87 * re-pair with a physically present user.88 *89 * HONEST LIMIT: signing keys derive from the shared vault root key, so these90 * signatures prove vault membership, not device identity. A revoked device91 * still holds the root key and, running modified software, can ignore its own92 * revocation. This stops honest peers talking to it; it is not a cryptographic93 * ejection. A genuinely compromised device needs the master password and94 * recovery words rotated.95 */96@Serializable97data class DeviceRevocation(98    val deviceId: String,99    /** Load-bearing, not descriptive: `deviceId` is whatever an issuing100     *  manifest says it is, so the key is what re-vouching is matched against.101     *  [NO_KEY_SENTINEL] means no key was ever stored and matches nothing. */102    val longTermPublicKeyHex: String,103    val revokedAt: Long,104    val revokedByDeviceId: String,105)106107@Serializable108data class SignedDeviceManifest(109    val body: DeviceManifestBody,110    /** Hex-encoded 64-byte Ed25519 signature over the canonicalised body. */111    val signatureHex: String,112)113114/**115 * The wire codec. `classDiscriminator = "type"` reproduces the browser's flat116 * `{ "type": "SYNC_HELLO", ... }` shape exactly; `encodeDefaults` keeps the117 * optional SYNC_DONE counters on the wire.118 */119val SyncJson: Json = Json {120    classDiscriminator = "type"121    encodeDefaults = true122    ignoreUnknownKeys = true123}124125fun SyncMessage.encodeToBytes(): ByteArray =126    SyncJson.encodeToString(SyncMessage.serializer(), this).toByteArray(Charsets.UTF_8)127128fun decodeSyncMessage(bytes: ByteArray): SyncMessage =129    SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))