From dde018926ddc56ac50e57ee571829295f00af6e3 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 16 Jul 2026 15:55:10 +0200 Subject: [PATCH 01/13] Resolve pre-1.0 spec gaps in roles and preamble Clarifications and fixes across the role definitions (player, source, controller, metadata, artwork, visualizer) and the preamble: codec framing, source format and trust rules, group volume/mute for mixed and player-less groups, artwork channel and lateness semantics, metadata clock domain, normative-language boilerplate, and role-versioning/binary-ID notes. Core-file changes (connection, messaging, pairing, management) are not included here and land separately. --- README.md | 78 +++++++++++++++++++++++++++++++++--------- roles/artwork/v1.md | 8 +++-- roles/controller/v1.md | 6 ++-- roles/metadata/v1.md | 2 ++ roles/player/v1.md | 20 ++++++++--- roles/source/v1.md | 24 ++++++++++--- roles/visualizer/v1.md | 8 +++-- template.md | 10 +++++- 8 files changed, 122 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f97e625..9f00986 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls. +## Normative Language + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + ## Protocol overview A typical session, from handshake through playback to disconnect: @@ -122,12 +126,16 @@ Message object keys (e.g., `player?`, `controller?`) use unversioned role names. ### Detecting Outdated Servers -Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated. +Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports newer role versions than the server and the server needs to be updated. + +This mechanism only detects role-version skew, and only because roles are exchanged after the handshake. A newer core `version`, cipher suite, or handshake (a cipher or handshake change is itself a core `version` bump) makes the [handshake](#failure-handling) abort before roles are exchanged, so that skew surfaces as a failed connection rather than through this role-request signal. ### Application-Specific Roles Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`. To avoid collisions between independent vendors, custom role names SHOULD include a vendor-specific prefix (e.g., `_vendorname_role`). +Their binary message IDs come from the unmanaged 192-255 range: an application-specific role's own definition assigns its IDs, and a client MUST NOT advertise two roles with conflicting IDs. + ## Establishing a Connection Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments. @@ -1118,6 +1126,8 @@ This section describes messages specific to clients with the `player` role, whic **Note:** To avoid audible clicks, clients SHOULD apply volume changes over a short ramp. +**Note:** `volume` and `muted` are independent: a volume change (via [`server/command`](#server--client-servercommand), a group volume command, or device controls) MUST NOT clear the mute state. + ### Client → Server: `client/hello` player@v1 support object The `player@v1_support` object in [`client/hello`](#client--server-clienthello) has this structure: @@ -1127,12 +1137,14 @@ The `player@v1_support` object in [`client/hello`](#client--server-clienthello) - `codec`: 'opus' | 'flac' | 'pcm' - codec identifier - `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo) - `sample_rate`: integer - sample rate in Hz (e.g., 44100) - - `bit_depth`: integer - bit depth for this format (e.g., 16, 24) + - `bit_depth`: integer - bit depth for this format (e.g., 16, 24); meaningful for `pcm` and `flac` only, ignored for `opus` - `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played - `supported_commands`: string[] - subset of: 'volume', 'mute' **Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'. +**Note:** For the initial [`stream/start`](#server--client-streamstart) the server SHOULD select the highest-priority `supported_formats` entry it can produce for the current track. It MAY select a lower-priority entry when warranted, for example to match a track's native sample rate and avoid resampling, and MAY switch formats on a later track by sending a new `stream/start`. + **Note:** [`required_lead_time_ms`](#client--server-clientstate-player-object) and [`min_buffer_ms`](#client--server-clientstate-player-object) are reported via [`client/state`](#client--server-clientstate-player-object). Players should report the lowest values that reliably prevent buffer underruns and start-of-stream truncation under expected conditions, to ensure the lowest possible latency for real-time applications. Both should factor in expected network delay/jitter (small on LAN/Wi-Fi, larger for remote or high-latency clients). Do not include `static_delay_ms` in these values; the server applies `static_delay_ms` separately when calculating send-ahead. **Server behavior:** @@ -1146,6 +1158,14 @@ The `player@v1_support` object in [`client/hello`](#client--server-clienthello) **PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. +**Codec framing:** Each binary audio chunk carries whole codec units; a unit never spans chunks. Per codec: + +- `pcm`: any whole number of PCM frames (one frame = one sample across all channels), interleaved by channel, encoded per the convention above. `codec_header` is absent. +- `flac`: one or more complete FLAC frames. `codec_header` is required and carries the `fLaC` stream marker followed by the STREAMINFO metadata block. +- `opus`: exactly one Opus packet ([RFC 6716](https://www.rfc-editor.org/rfc/rfc6716)) per chunk, with no container. `codec_header` is absent; the decoder is configured from the negotiated `sample_rate` and `channels` in [`stream/start`](#server--client-streamstart). + +`codec_header` uses standard Base64 ([RFC 4648 section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4), padding included). + ### Client → Server: `client/state` player object The `player` object in [`client/state`](#client--server-clientstate) has this structure: @@ -1203,10 +1223,10 @@ Request the player to perform an action, e.g., change volume or mute state. The `player` object in [`stream/start`](#server--client-streamstart) has this structure: - `player`: object - - `codec`: string - codec to be used + - `codec`: 'opus' | 'flac' | 'pcm' - codec to be used - `sample_rate`: integer - sample rate to be used - `channels`: integer - channels to be used - - `bit_depth`: integer - bit depth to be used + - `bit_depth`: integer - bit depth to be used; ignored for `opus` - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) The format MUST be one the client listed in its [`supported_formats`](#client--server-clienthello-playerv1-support-object). @@ -1253,7 +1273,7 @@ Each client is responsible for maintaining its own synchronization with the serv - **Chunk duration bounds:** A server MUST NOT send an audio chunk longer than 150 ms, and SHOULD NOT send one shorter than 15 ms (the final chunk of a stream or the chunk before a format change MAY be shorter). - The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients. -- After sending [`stream/start`](#server--client-streamstart) or [`stream/clear`](#server--client-streamclear) messages, servers must schedule the first audio timestamp far enough in the future to satisfy each player's lead (see [Server behavior](#client--server-clienthello-playerv1-support-object)). For live streams the buffer cannot grow after playback begins, so the lead must already be reached before the first chunk plays. +- After a [`stream/start`](#server--client-streamstart) that begins buffering from empty (a new stream, or the first after a [`stream/end`](#server--client-streamend)) or a [`stream/clear`](#server--client-streamclear), servers must schedule the first audio timestamp far enough in the future to satisfy each player's lead (see [Server behavior](#client--server-clienthello-playerv1-support-object)). An in-place `stream/start` configuration update on an active stream continues the existing timeline and does not re-apply the startup lead. For live streams the buffer cannot grow after playback begins, so the lead must already be reached before the first chunk plays. - Servers factor in each client's [`static_delay_ms`](#client--server-clientstate-player-object) when calculating how far ahead to send audio, keeping effective buffer headroom constant. ### Suggested correction strategy @@ -1285,9 +1305,9 @@ A device MAY implement both the `source` and `player` roles (e.g., a speaker wit **Note:** The `source` role (capturing input *into* Sendspin) is distinct from a client reporting [`available: false`](#external-source-handling), which marks a client whose *output* has been taken over by a non-Sendspin system. -A source client uses the same [clock synchronization](#clock-synchronization) mechanism as all clients. Binary source audio messages are timestamped in the server time domain by converting the local capture time with the offset the time filter tracks. +A source client uses the same [clock synchronization](#clock-synchronization) mechanism as all clients. It timestamps each binary source audio message in the server time domain by inverting the filter's server-to-local mapping (`t_local = compute_client_time(t_server)`): for a capture at local time `t_capture` it sends the `t_server` that maps to it. The mapping is linear in the filter's offset and drift, so this inverse is well-defined; apply both offset and drift, not offset alone. -**Pairing required:** A source streams captured audio (potentially from a microphone or line-in) to the server, so it MUST only run on a paired connection ([trust level](#definitions) `user`). Servers MUST NOT activate `source@v1` over [unpaired access](#unpaired-access), and a source client MUST refuse to stream when the connection's trust level is `none`. +**Pairing required:** A source captures potentially sensitive audio (microphone, line-in), so `source@v1` MUST only run on a paired connection ([trust level](#definitions) `user`) and a source client MUST NOT stream when the trust level is `none`. If a server activates `source@v1` at trust level `none`, the client refuses it and closes the connection, following the central rules in [`server/activate`](#server--client-serveractivate). ### Client → Server: `client/hello` source@v1 support object @@ -1299,6 +1319,8 @@ The `source@v1_support` object in [`client/hello`](#client--server-clienthello) **Note:** Servers MUST support all audio codecs: 'opus', 'flac', and 'pcm'. +**Note:** A source announces its input format in [`client_stream/start`](#client--server-client_streamstart); there is no pre-negotiation. Since the server centrally resamples and transcodes source audio, it SHOULD accept whatever format a source announces. + ### Client → Server: `client/state` source object The `source` object in [`client/state`](#client--server-clientstate) has this structure: @@ -1321,12 +1343,14 @@ The `source` object in [`server/command`](#server--client-servercommand) has thi #### Default streaming behavior -The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. +The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. An unsolicited `client_stream/start` (received when the server has not issued `start`) is a protocol error: the server MUST NOT treat the input stream as open and should close the connection, consistent with the binary-chunk rejection rule below. A source that supports line sensing reports `signal` in [`client/state`](#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. When the server removes `source` from [`active_roles`](#server--client-serveractivate), the client sends `client_stream/end` and stops sending chunks. +A source with an open input stream that becomes [`available: false`](#external-source-handling) sends `client_stream/end` before it reports `available: false` in `client/state`; the server treats the transition as an implicit `stop`. + ### Client → Server: `client_stream/start` The `client_stream/start` message announces the active input stream format and provides any required codec header data. @@ -1335,9 +1359,21 @@ The `client_stream/start` message announces the active input stream format and p - `codec`: 'opus' | 'flac' | 'pcm' - `channels`: integer - `sample_rate`: integer - - `bit_depth`: integer + - `bit_depth`: integer - ignored for `opus` - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) +A `client_stream/start` received while an input stream is already open replaces the stream format in place. + +**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. + +**Codec framing:** Each binary source audio chunk carries whole codec units; a unit never spans chunks. Per codec: + +- `pcm`: any whole number of PCM frames (one frame = one sample across all channels), interleaved by channel, encoded per the convention above. `codec_header` is absent. +- `flac`: one or more complete FLAC frames. `codec_header` is required and carries the `fLaC` stream marker followed by the STREAMINFO metadata block. +- `opus`: exactly one Opus packet ([RFC 6716](https://www.rfc-editor.org/rfc/rfc6716)) per chunk, with no container. `codec_header` is absent; the decoder is configured from the negotiated `sample_rate` and `channels` in `client_stream/start`. + +`codec_header` uses standard Base64 ([RFC 4648 section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4), padding included). + ### Client → Server: `client_stream/end` The client ends the current input stream. After this message, no more source audio chunks SHOULD be sent until a new `client_stream/start`. @@ -1407,7 +1443,7 @@ Control the group that's playing and switch groups. Only valid from clients with This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow. -**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. +**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. Group volume changes do not affect any player's `muted` state (see the [player role](#player-messages)). #### Switch command cycle @@ -1434,9 +1470,9 @@ The `controller` object in [`server/state`](#server--client-serverstate) has thi - `shuffle`: boolean - shuffle mode enabled/disabled - `seek_max_ms?`: integer - maximum absolute position in milliseconds a 'seek' may target (e.g., the end of the current track). The server MUST include this when 'seek' is in `supported_commands`, and MUST omit 'seek' when the seekable range is unknown (e.g., live streams); 'seek_relative' MAY still be offered -**Reading group volume:** Group volume is calculated as the average of all player volumes in the group. +**Reading group volume:** Group volume is the average of the volumes of players in the group that support the `volume` command. Players without volume support are excluded from the calculation. If no player in the group supports `volume`, group volume is reported as 100 and `'volume'` is dropped from the controller `supported_commands`. -**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`. +**Reading group mute:** Group mute is `true` only when all mute-supporting players in the group are muted. Players without mute support are excluded. If some supporting players are muted and others are not, group mute is `false`. If no player in the group supports `mute`, group mute is reported as `false` and `'mute'` is dropped from the controller `supported_commands`. ## Metadata messages This section describes messages specific to clients with the `metadata` role, which handle display of track information and playback progress. Metadata clients receive state updates with track details. @@ -1472,6 +1508,8 @@ else: current_track_progress_ms = max(calculated_progress, 0) ``` +`current_time` and `metadata.timestamp` must share a clock domain. `metadata.timestamp` is in the server domain, so convert it to local time via the [time filter](#clock-synchronization) before subtracting the local `current_time` (converting `current_time` the other way is equivalent). + ## Artwork messages This section describes messages specific to clients with the `artwork` role, which handle display of artwork images. Artwork clients receive images in their preferred format and resolution. @@ -1514,12 +1552,16 @@ Response when an `artwork` stream is active: [`stream/start`](#server--client-st The `artwork` object in [`stream/start`](#server--client-streamstart) has this structure: - `artwork`: object - - `channels`: object[] - configuration for each active artwork channel, array index is the channel number + - `channels`: object[] - configuration for each artwork channel, array index is the channel number - `source`: 'album' | 'artist' | 'none' - artwork source type - `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image - `width`: integer - width in pixels of the encoded image - `height`: integer - height in pixels of the encoded image +The `channels` array covers every channel index the client declared in [`artwork@v1_support`](#client--server-clienthello-artworkv1-support-object) in the same order. A channel the server is not streaming is represented as `source: 'none'`. + +Each channel's configuration MUST stay within the client's current capability for that channel: the [`client/hello`](#client--server-clienthello-artworkv1-support-object) declaration, as later modified by [`stream/request-format`](#client--server-streamrequest-format-artwork-object). The `source` and `format` MUST match the declaration, and `width`/`height` MUST NOT exceed the declared `media_width`/`media_height`. + **Late join:** After an artwork `stream/start` (initial or after a reconnection), the server SHOULD immediately send the current image for each channel whose `source` is not `'none'`, so a client joining mid-track does not stay blank until the next track change. ### Server → Client: Artwork (Binary) @@ -1536,7 +1578,7 @@ The message type determines which artwork channel this image is for: - Type `10`: Channel 2 (Artwork role, slot 2) - Type `11`: Channel 3 (Artwork role, slot 3) -The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. +The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. A timestamp already in the past on arrival means the image is displayed immediately, unless a newer image for the same channel has already superseded it (latest wins). Artwork is never dropped for lateness. **Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel. @@ -1566,10 +1608,10 @@ The `visualizer@v1_support` object in [`client/hello`](#client--server-clienthel The `visualizer` object in [`stream/start`](#server--client-streamstart) has this structure: - `visualizer`: object - - `types`: string[] - visualization data types the server will stream - - `rate_max`: integer - periodic frames per second the server will emit + - `types`: string[] - visualization data types the server will stream. MUST be a subset of the types the client requested (in [`client/hello`](#client--server-clienthello-visualizerv1-support-object) or the latest [`stream/request-format`](#client--server-streamrequest-format-visualizer-object)) + - `rate_max`: integer - periodic frames per second the server will emit. MUST NOT exceed the client's requested `rate_max` - `tracks_downbeats`: boolean - only if `types` includes 'beat'. True if the server's beat tracker also identifies bar starts (downbeats). When false, the downbeat flag on `beat` messages is always 0 - - `spectrum?`: object - spectrum configuration, only if `types` includes 'spectrum' + - `spectrum?`: object - spectrum configuration, only if `types` includes 'spectrum'. MUST match the client's current requested configuration - `n_disp_bins`: integer - number of display bins - `scale`: 'mel' | 'log' | 'lin' - mapping from FFT frequencies to display bins - `f_min`: integer - lowest frequency in Hz @@ -1600,6 +1642,8 @@ Binary messages SHOULD be rejected if there is no active stream or the client is - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when this data should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization - Remaining bytes: data, layout per type below +Data whose timestamp is already in the past on arrival is dropped; stale visualization frames are never rendered. + `loudness`, `spectrum` bins, and the `f_peak` amplitude use the full `uint16` range 0-65535, where 0 = silence and 65535 = full scale. Values are A-weighted and dB-scaled: -60 dB → 0, 0 dB → 65535, mapped linearly across that range. Message types `21`, `22`, and `23` are reserved for future visualizer types within the role's 16-23 allocation and must not be used by implementations. diff --git a/roles/artwork/v1.md b/roles/artwork/v1.md index 648784b..2043e68 100644 --- a/roles/artwork/v1.md +++ b/roles/artwork/v1.md @@ -40,12 +40,16 @@ Response when an `artwork` stream is active: [`stream/start`](../../messaging.md The `artwork` object in [`stream/start`](../../messaging.md#server--client-streamstart) has this structure: - `artwork`: object - - `channels`: object[] - configuration for each active artwork channel, array index is the channel number + - `channels`: object[] - configuration for each artwork channel, array index is the channel number - `source`: 'album' | 'artist' | 'none' - artwork source type - `format`: 'jpeg' | 'png' | 'bmp' - format of the encoded image - `width`: integer - width in pixels of the encoded image - `height`: integer - height in pixels of the encoded image +The `channels` array covers every channel index the client declared in [`artwork@v1_support`](#client--server-clienthello-artworkv1-support-object) in the same order. A channel the server is not streaming is represented as `source: 'none'`. + +Each channel's configuration MUST stay within the client's current capability for that channel: the [`client/hello`](#client--server-clienthello-artworkv1-support-object) declaration, as later modified by [`stream/request-format`](#client--server-streamrequest-format-artwork-object). The `source` and `format` MUST match the declaration, and `width`/`height` MUST NOT exceed the declared `media_width`/`media_height`. + **Late join:** After an artwork `stream/start` (initial or after a reconnection), the server SHOULD immediately send the current image for each channel whose `source` is not `'none'`, so a client joining mid-track does not stay blank until the next track change. ### Server → Client: Artwork (Binary) @@ -62,6 +66,6 @@ The message type determines which artwork channel this image is for: - Type `10`: Channel 2 (Artwork role, slot 2) - Type `11`: Channel 3 (Artwork role, slot 3) -The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. +The timestamp indicates when this artwork should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization. A timestamp already in the past on arrival means the image is displayed immediately, unless a newer image for the same channel has already superseded it (latest wins). Artwork is never dropped for lateness. **Clearing artwork:** To clear the currently displayed artwork on a specific channel, the server sends an empty binary message (only the message type byte and timestamp, with no image data) for that channel. diff --git a/roles/controller/v1.md b/roles/controller/v1.md index 577b171..7bc5dda 100644 --- a/roles/controller/v1.md +++ b/roles/controller/v1.md @@ -48,7 +48,7 @@ Control the group that's playing and switch groups. Only valid from clients with This ensures that when setting group volume to 100%, all players will reach 100% if possible, and the final group volume matches the requested volume as closely as player boundaries allow. -**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. +**Setting group mute:** When setting group mute via the 'mute' command, the server applies the mute state to all players in the group. Group volume changes do not affect any player's `muted` state (see the [player role](../player/v1.md#player-messages)). #### Switch command cycle @@ -75,6 +75,6 @@ The `controller` object in [`server/state`](../../messaging.md#server--client-se - `shuffle`: boolean - shuffle mode enabled/disabled - `seek_max_ms?`: integer - maximum absolute position in milliseconds a 'seek' may target (e.g., the end of the current track). The server MUST include this when 'seek' is in `supported_commands`, and MUST omit 'seek' when the seekable range is unknown (e.g., live streams); 'seek_relative' MAY still be offered -**Reading group volume:** Group volume is calculated as the average of all player volumes in the group. +**Reading group volume:** Group volume is the average of the volumes of players in the group that support the `volume` command. Players without volume support are excluded from the calculation. If no player in the group supports `volume`, group volume is reported as 100 and `'volume'` is dropped from the controller `supported_commands`. -**Reading group mute:** Group mute is `true` only when all players in the group are muted. If some players are muted and others are not, group mute is `false`. +**Reading group mute:** Group mute is `true` only when all mute-supporting players in the group are muted. Players without mute support are excluded. If some supporting players are muted and others are not, group mute is `false`. If no player in the group supports `mute`, group mute is reported as `false` and `'mute'` is dropped from the controller `supported_commands`. diff --git a/roles/metadata/v1.md b/roles/metadata/v1.md index bf38273..9b85f81 100644 --- a/roles/metadata/v1.md +++ b/roles/metadata/v1.md @@ -31,3 +31,5 @@ if metadata.progress.track_duration != 0: else: current_track_progress_ms = max(calculated_progress, 0) ``` + +`current_time` and `metadata.timestamp` must share a clock domain. `metadata.timestamp` is in the server domain, so convert it to local time via the [time filter](../../messaging.md#clock-synchronization) before subtracting the local `current_time` (converting `current_time` the other way is equivalent). diff --git a/roles/player/v1.md b/roles/player/v1.md index e8ba4c2..cacef1a 100644 --- a/roles/player/v1.md +++ b/roles/player/v1.md @@ -5,6 +5,8 @@ This section describes messages specific to clients with the `player` role, whic **Note:** To avoid audible clicks, clients SHOULD apply volume changes over a short ramp. +**Note:** `volume` and `muted` are independent: a volume change (via [`server/command`](../../messaging.md#server--client-servercommand), a group volume command, or device controls) MUST NOT clear the mute state. + ### Client → Server: `client/hello` player@v1 support object The `player@v1_support` object in [`client/hello`](../../messaging.md#client--server-clienthello) has this structure: @@ -14,12 +16,14 @@ The `player@v1_support` object in [`client/hello`](../../messaging.md#client--se - `codec`: 'opus' | 'flac' | 'pcm' - codec identifier - `channels`: integer - supported number of channels (e.g., 1 = mono, 2 = stereo) - `sample_rate`: integer - sample rate in Hz (e.g., 44100) - - `bit_depth`: integer - bit depth for this format (e.g., 16, 24) + - `bit_depth`: integer - bit depth for this format (e.g., 16, 24); meaningful for `pcm` and `flac` only, ignored for `opus` - `buffer_capacity`: integer - max size in bytes of compressed audio messages in the buffer that are yet to be played - `supported_commands`: string[] - subset of: 'volume', 'mute' **Note:** Servers must support all audio codecs: 'opus', 'flac', and 'pcm'. +**Note:** For the initial [`stream/start`](../../messaging.md#server--client-streamstart) the server SHOULD select the highest-priority `supported_formats` entry it can produce for the current track. It MAY select a lower-priority entry when warranted, for example to match a track's native sample rate and avoid resampling, and MAY switch formats on a later track by sending a new `stream/start`. + **Note:** [`required_lead_time_ms`](#client--server-clientstate-player-object) and [`min_buffer_ms`](#client--server-clientstate-player-object) are reported via [`client/state`](#client--server-clientstate-player-object). Players should report the lowest values that reliably prevent buffer underruns and start-of-stream truncation under expected conditions, to ensure the lowest possible latency for real-time applications. Both should factor in expected network delay/jitter (small on LAN/Wi-Fi, larger for remote or high-latency clients). Do not include `static_delay_ms` in these values; the server applies `static_delay_ms` separately when calculating send-ahead. **Server behavior:** @@ -33,6 +37,14 @@ The `player@v1_support` object in [`client/hello`](../../messaging.md#client--se **PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. +**Codec framing:** Each binary audio chunk carries whole codec units; a unit never spans chunks. Per codec: + +- `pcm`: any whole number of PCM frames (one frame = one sample across all channels), interleaved by channel, encoded per the convention above. `codec_header` is absent. +- `flac`: one or more complete FLAC frames. `codec_header` is required and carries the `fLaC` stream marker followed by the STREAMINFO metadata block. +- `opus`: exactly one Opus packet ([RFC 6716](https://www.rfc-editor.org/rfc/rfc6716)) per chunk, with no container. `codec_header` is absent; the decoder is configured from the negotiated `sample_rate` and `channels` in [`stream/start`](../../messaging.md#server--client-streamstart). + +`codec_header` uses standard Base64 ([RFC 4648 section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4), padding included). + ### Client → Server: `client/state` player object The `player` object in [`client/state`](../../messaging.md#client--server-clientstate) has this structure: @@ -90,10 +102,10 @@ Request the player to perform an action, e.g., change volume or mute state. The `player` object in [`stream/start`](../../messaging.md#server--client-streamstart) has this structure: - `player`: object - - `codec`: string - codec to be used + - `codec`: 'opus' | 'flac' | 'pcm' - codec to be used - `sample_rate`: integer - sample rate to be used - `channels`: integer - channels to be used - - `bit_depth`: integer - bit depth to be used + - `bit_depth`: integer - bit depth to be used; ignored for `opus` - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) The format MUST be one the client listed in its [`supported_formats`](#client--server-clienthello-playerv1-support-object). @@ -140,7 +152,7 @@ Each client is responsible for maintaining its own synchronization with the serv - **Chunk duration bounds:** A server MUST NOT send an audio chunk longer than 150 ms, and SHOULD NOT send one shorter than 15 ms (the final chunk of a stream or the chunk before a format change MAY be shorter). - The server sends audio to late-joining clients with future timestamps only, allowing them to buffer and start playback in sync with existing clients. -- After sending [`stream/start`](../../messaging.md#server--client-streamstart) or [`stream/clear`](../../messaging.md#server--client-streamclear) messages, servers must schedule the first audio timestamp far enough in the future to satisfy each player's lead (see [Server behavior](#client--server-clienthello-playerv1-support-object)). For live streams the buffer cannot grow after playback begins, so the lead must already be reached before the first chunk plays. +- After a [`stream/start`](../../messaging.md#server--client-streamstart) that begins buffering from empty (a new stream, or the first after a [`stream/end`](../../messaging.md#server--client-streamend)) or a [`stream/clear`](../../messaging.md#server--client-streamclear), servers must schedule the first audio timestamp far enough in the future to satisfy each player's lead (see [Server behavior](#client--server-clienthello-playerv1-support-object)). An in-place `stream/start` configuration update on an active stream continues the existing timeline and does not re-apply the startup lead. For live streams the buffer cannot grow after playback begins, so the lead must already be reached before the first chunk plays. - Servers factor in each client's [`static_delay_ms`](#client--server-clientstate-player-object) when calculating how far ahead to send audio, keeping effective buffer headroom constant. ### Suggested correction strategy diff --git a/roles/source/v1.md b/roles/source/v1.md index 3f89e5a..2b5b1cd 100644 --- a/roles/source/v1.md +++ b/roles/source/v1.md @@ -5,9 +5,9 @@ A device MAY implement both the `source` and `player` roles (e.g., a speaker wit **Note:** The `source` role (capturing input *into* Sendspin) is distinct from a client reporting [`available: false`](../../messaging.md#external-source-handling), which marks a client whose *output* has been taken over by a non-Sendspin system. -A source client uses the same [clock synchronization](../../messaging.md#clock-synchronization) mechanism as all clients. Binary source audio messages are timestamped in the server time domain by converting the local capture time with the offset the time filter tracks. +A source client uses the same [clock synchronization](../../messaging.md#clock-synchronization) mechanism as all clients. It timestamps each binary source audio message in the server time domain by inverting the filter's server-to-local mapping (`t_local = compute_client_time(t_server)`): for a capture at local time `t_capture` it sends the `t_server` that maps to it. The mapping is linear in the filter's offset and drift, so this inverse is well-defined; apply both offset and drift, not offset alone. -**Pairing required:** A source streams captured audio (potentially from a microphone or line-in) to the server, so it MUST only run on a paired connection ([trust level](../../README.md#definitions) `user`). Servers MUST NOT activate `source@v1` over [unpaired access](../../pairing.md#unpaired-access), and a source client MUST refuse to stream when the connection's trust level is `none`. +**Pairing required:** A source captures potentially sensitive audio (microphone, line-in), so `source@v1` MUST only run on a paired connection ([trust level](../../README.md#definitions) `user`) and a source client MUST NOT stream when the trust level is `none`. If a server activates `source@v1` at trust level `none`, the client refuses it and closes the connection, following the central rules in [`server/activate`](../../messaging.md#server--client-serveractivate). ### Client → Server: `client/hello` source@v1 support object @@ -19,6 +19,8 @@ The `source@v1_support` object in [`client/hello`](../../messaging.md#client--se **Note:** Servers MUST support all audio codecs: 'opus', 'flac', and 'pcm'. +**Note:** A source announces its input format in [`client_stream/start`](#client--server-client_streamstart); there is no pre-negotiation. Since the server centrally resamples and transcodes source audio, it SHOULD accept whatever format a source announces. + ### Client → Server: `client/state` source object The `source` object in [`client/state`](../../messaging.md#client--server-clientstate) has this structure: @@ -41,12 +43,14 @@ The `source` object in [`server/command`](../../messaging.md#server--client-serv #### Default streaming behavior -The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. +The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. An unsolicited `client_stream/start` (received when the server has not issued `start`) is a protocol error: the server MUST NOT treat the input stream as open and should close the connection, consistent with the binary-chunk rejection rule below. A source that supports line sensing reports `signal` in [`client/state`](../../messaging.md#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. When the server removes `source` from [`active_roles`](../../messaging.md#server--client-serveractivate), the client sends `client_stream/end` and stops sending chunks. +A source with an open input stream that becomes [`available: false`](../../messaging.md#external-source-handling) sends `client_stream/end` before it reports `available: false` in `client/state`; the server treats the transition as an implicit `stop`. + ### Client → Server: `client_stream/start` The `client_stream/start` message announces the active input stream format and provides any required codec header data. @@ -55,9 +59,21 @@ The `client_stream/start` message announces the active input stream format and p - `codec`: 'opus' | 'flac' | 'pcm' - `channels`: integer - `sample_rate`: integer - - `bit_depth`: integer + - `bit_depth`: integer - ignored for `opus` - `codec_header?`: string - codec header encoded as standard Base64, if necessary (e.g., FLAC) +A `client_stream/start` received while an input stream is already open replaces the stream format in place. + +**PCM Encoding Convention:** For the `pcm` codec, samples are encoded as little-endian signed integers (two's complement). 24-bit samples are packed as 3 bytes per sample. + +**Codec framing:** Each binary source audio chunk carries whole codec units; a unit never spans chunks. Per codec: + +- `pcm`: any whole number of PCM frames (one frame = one sample across all channels), interleaved by channel, encoded per the convention above. `codec_header` is absent. +- `flac`: one or more complete FLAC frames. `codec_header` is required and carries the `fLaC` stream marker followed by the STREAMINFO metadata block. +- `opus`: exactly one Opus packet ([RFC 6716](https://www.rfc-editor.org/rfc/rfc6716)) per chunk, with no container. `codec_header` is absent; the decoder is configured from the negotiated `sample_rate` and `channels` in `client_stream/start`. + +`codec_header` uses standard Base64 ([RFC 4648 section 4](https://www.rfc-editor.org/rfc/rfc4648#section-4), padding included). + ### Client → Server: `client_stream/end` The client ends the current input stream. After this message, no more source audio chunks SHOULD be sent until a new `client_stream/start`. diff --git a/roles/visualizer/v1.md b/roles/visualizer/v1.md index db99cfb..5c3ffd7 100644 --- a/roles/visualizer/v1.md +++ b/roles/visualizer/v1.md @@ -24,10 +24,10 @@ The `visualizer@v1_support` object in [`client/hello`](../../messaging.md#client The `visualizer` object in [`stream/start`](../../messaging.md#server--client-streamstart) has this structure: - `visualizer`: object - - `types`: string[] - visualization data types the server will stream - - `rate_max`: integer - periodic frames per second the server will emit + - `types`: string[] - visualization data types the server will stream. MUST be a subset of the types the client requested (in [`client/hello`](#client--server-clienthello-visualizerv1-support-object) or the latest [`stream/request-format`](#client--server-streamrequest-format-visualizer-object)) + - `rate_max`: integer - periodic frames per second the server will emit. MUST NOT exceed the client's requested `rate_max` - `tracks_downbeats`: boolean - only if `types` includes 'beat'. True if the server's beat tracker also identifies bar starts (downbeats). When false, the downbeat flag on `beat` messages is always 0 - - `spectrum?`: object - spectrum configuration, only if `types` includes 'spectrum' + - `spectrum?`: object - spectrum configuration, only if `types` includes 'spectrum'. MUST match the client's current requested configuration - `n_disp_bins`: integer - number of display bins - `scale`: 'mel' | 'log' | 'lin' - mapping from FFT frequencies to display bins - `f_min`: integer - lowest frequency in Hz @@ -58,6 +58,8 @@ Binary messages SHOULD be rejected if there is no active stream or the client is - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when this data should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization - Remaining bytes: data, layout per type below +Data whose timestamp is already in the past on arrival is dropped; stale visualization frames are never rendered. + `loudness`, `spectrum` bins, and the `f_peak` amplitude use the full `uint16` range 0-65535, where 0 = silence and 65535 = full scale. Values are A-weighted and dB-scaled: -60 dB → 0, 0 dB → 65535, mapped linearly across that range. Message types `21`, `22`, and `23` are reserved for future visualizer types within the role's 16-23 allocation and must not be used by implementations. diff --git a/template.md b/template.md index 49303d2..be8ec48 100644 --- a/template.md +++ b/template.md @@ -11,6 +11,10 @@ Sendspin is a multi-room music experience protocol. The goal of the protocol is to orchestrate all devices that make up the music listening experience. This includes outputting audio on multiple speakers simultaneously, screens and lights visualizing the audio or album art, and wall tablets providing media controls. +## Normative Language + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + ## Protocol overview A typical session, from handshake through playback to disconnect: @@ -123,12 +127,16 @@ Message object keys (e.g., `player?`, `controller?`) use unversioned role names. ### Detecting Outdated Servers -Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports a newer version of the specification and the server needs to be updated. +Servers should track when clients request roles or role versions they don't implement (excluding those starting with `_`). This indicates the client supports newer role versions than the server and the server needs to be updated. + +This mechanism only detects role-version skew, and only because roles are exchanged after the handshake. A newer core `version`, cipher suite, or handshake (a cipher or handshake change is itself a core `version` bump) makes the [handshake](connection.md#failure-handling) abort before roles are exchanged, so that skew surfaces as a failed connection rather than through this role-request signal. ### Application-Specific Roles Custom roles outside the specification start with `_` (e.g., `_myapp_controller`, `_custom_display`). Application-specific roles can also be versioned: `_myapp_visualizer@v2`. To avoid collisions between independent vendors, custom role names SHOULD include a vendor-specific prefix (e.g., `_vendorname_role`). +Their binary message IDs come from the unmanaged 192-255 range: an application-specific role's own definition assigns its IDs, and a client MUST NOT advertise two roles with conflicting IDs. + From 6e94c5b56b53872acfa9b80eb68b23f59af588bd Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 16 Jul 2026 23:26:19 +0200 Subject: [PATCH 02/13] Resolve pre-1.0 spec gaps in connection and management --- README.md | 35 ++++++++++++++++++++++++++--------- connection.md | 22 ++++++++++++++++------ management.md | 13 ++++++++++--- 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 9f00986..6ff70d7 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,9 @@ Their binary message IDs come from the unmanaged 192-255 range: an application-s Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments. -Sendspin Servers must support both methods described below. +Sendspin Servers must support both methods described below. Clients MUST use exactly one of the two methods at a time, advertising or discovering accordingly. + +The WebSocket transport MUST be plain `ws://`. Confidentiality and integrity are provided end to end by the [Noise layer](#encryption) inside the WebSocket payloads. ### Server Initiated Connections @@ -152,6 +154,8 @@ Clients announce their presence via mDNS using: The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path. +**Note:** The TXT `name` SHOULD match the `name` the client sends in [`client/hello`](#client--server-clienthello). It is only a discovery-time hint; if the two differ, the `client/hello` value is authoritative. + **Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) @@ -171,7 +175,7 @@ When a new server connects, the client lets the handshake complete before applyi - A [pairing attempt](#entering-and-leaving-pairing) is not displaced by an incoming `'playback'` or `'pairing'` connection. - When both the current holder and the incoming connection have empty `activities`, the incoming is admitted only if its `server_id` matches the last-playback server (and the existing one's does not); otherwise the existing is kept. -Subsequent `server/activate` updates do not trigger arbitration. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. +Subsequent `server/activate` updates do not trigger arbitration, even when a connection escalates its activities. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. A displaced connection receives [`client/goodbye`](#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected incoming receives [`client/goodbye`](#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](#client--server-pairabort) reason `concurrent_attempt` for pairings). The client then closes the connection. @@ -185,6 +189,8 @@ If clients prefer to initiate the connection instead of waiting for the server t Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path. +**Note:** The TXT `name` SHOULD match the `name` the server sends in [`server/hello`](#server--client-serverhello). It is only a discovery-time hint; if the two differ, the `server/hello` value is authoritative. + **Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection. #### Multiple servers (client-initiated) @@ -203,7 +209,7 @@ Sendspin uses the `KKpsk2` Noise pattern. Both static keys are pre-known to both The **server is the Noise initiator**, the **client is the Noise responder**, regardless of which side initiated the WebSocket connection. -**Security properties.** Forward secrecy is provided by the ephemeral-key DH in each handshake: compromise of static keys or the PSK does not retroactively decrypt prior sessions. Replay protection is provided by Noise's per-direction transport counter; a repeated or out-of-order ciphertext fails AEAD decryption and aborts the connection. +**Security properties.** Forward secrecy is provided by the ephemeral-key DH in each handshake: compromise of static keys or the PSK does not retroactively decrypt prior sessions' transport traffic (the exception is the first handshake message's `psk_id` payload, recoverable with the client's static key). Replay protection is provided by Noise's per-direction transport counter; a repeated or out-of-order ciphertext fails AEAD decryption and aborts the connection. ### Cipher Suites @@ -224,7 +230,7 @@ The `client_id` and `server_id` fields are the base64url-encoded (no padding) Cu ### Pre-Shared Key -The PSK is mixed into the handshake state at the end of the second handshake message (the `psk2` modifier). The transport-mode keys derived after the handshake therefore include the PSK, but the first handshake message's payload (sent by the server) is encrypted under static-key DH only. +The PSK is mixed into the handshake state at the end of the second handshake message (the `psk2` modifier). The transport-mode keys derived after the handshake therefore include the PSK, but the first handshake message's payload (sent by the server) is encrypted without the PSK mixed in. To let the client select the right PSK before the PSK must be mixed in, the server includes a `psk_id` in the first handshake message's payload. The identifier is a 43-character base64url-encoded value (no padding) of a 32-byte SHA-256 output, derived deterministically from the PSK: @@ -234,6 +240,8 @@ psk_id = base64url(SHA-256("sendspin-psk-id-v1" || PSK)) The label is the UTF-8 byte sequence of the literal characters shown (no NUL terminator, no surrounding quotes); `||` denotes byte concatenation. The same formula applies to all three PSK categories (long-term, Pairing, Sentinel); the client stores each of its PSKs tagged with its category and, on match, the stored category determines how to proceed. The single handshake pattern (`KKpsk2`) is used in all three cases; only the PSK input differs. +The three PSK categories share one `psk_id` namespace, so a `psk_id` must be unique across them. Two categories sharing one would make a single wire `psk_id` map to two trust levels. Clients enforce this when records are configured (see [Management](#records)). + The **Sentinel PSK** is a published constant used as the PSK input whenever no other PSK applies - i.e., before any pairing record exists. It provides no authentication on its own (its value is public); authentication, when needed, is established later during [Pairing](#pairing). The sentinel value is: ``` @@ -248,7 +256,7 @@ Sentinel psk_id = 0x185b15f6d2da4909bd1dc156a4ab206103abef0153bcd52d926170b95cf7 = base64url "GFsV9tLaSQm9HcFWpKsgYQOr7wFTvNUtkmFwuVz3zoo" ``` -The client decrypts the first handshake message's payload using only the static keys, compares the included `psk_id` to hashes of each of its candidate PSKs, and selects the PSK whose hash matches. It then mixes that PSK as required to process the second handshake message. If no candidate matches, the handshake fails. +The client decrypts the first handshake message's payload (possible without a PSK, as noted above), compares the included `psk_id` to the hash of each candidate PSK, and selects the one that matches. It then mixes that PSK in to process the second handshake message. If no candidate matches, the handshake fails. A PSK for a pairing method disabled in the client's [pairing config](#server--client-managementset-pairing-config) is excluded from the candidate set, so a handshake referencing it fails as a lookup miss. Two storage variants are supported for long-term [Sendspin PSK](#definitions) records, distinguished by whether the client also stores the server's `server_id`. The wire bytes and `psk_id` lookup are identical; only the post-match check differs. @@ -259,13 +267,15 @@ Two storage variants are supported for long-term [Sendspin PSK](#definitions) re The prologue mixed into the Noise handshake state on both sides is the concatenation of the exact bytes of [`client/init`](#client--server-clientinit) followed by the exact bytes of [`server/init`](#server--client-serverinit), as transmitted on the wire (the JSON-encoded UTF-8 message body, without the WebSocket framing). This binds the cleartext init exchange to the handshake; tampering causes the handshake to fail. +Both sides MUST hash the raw message bytes exactly as sent and received, not a re-encoding of the parsed message. + ### Failure Handling Any handshake-phase failure - malformed cleartext message, unsupported `version`, unknown `suite`, handshake timeout, `psk_id` lookup miss, Noise AEAD failure, or AEAD failure once in transport mode - closes the WebSocket without sending any application-level error message. Implementations SHOULD apply a timeout (e.g., 30 seconds) for each side to receive the next expected message during the prologue and Noise-handshake phases. ### Re-handshake -The server may rerun the Noise handshake in transport mode to swap session keys without closing the WebSocket - typically to promote the trust class after a successful [pairing](#pairing), to switch from Sentinel to a Pairing PSK, or to rotate session keys on long-running connections. +The server may rerun the Noise handshake in transport mode to swap session keys without closing the WebSocket - typically to promote the trust level after a successful [pairing](#pairing), to switch from Sentinel to a Pairing PSK, or to rotate session keys on long-running connections. The server initiates, as in the original handshake. The two [`noise/handshake`](#client--server-noisehandshake) messages are sent as encrypted binary frames inside the current channel; `psk_id` in noise message 1 selects the PSK for the new session. `client/init` and `server/init` are not re-sent - `client_id`, `server_id`, and `suite` carry over. The new handshake's prologue is the prior handshake's hash `h`. No other messages flow during the exchange; once the new keys are in place, the connection continues with the usual [`server/hello`](#server--client-serverhello) → [`client/hello`](#client--server-clienthello) (the client re-asserts `trust_level`) → [`server/activate`](#server--client-serveractivate). @@ -1013,6 +1023,8 @@ Add a pairing record directly. - `psk`: string - 43-character base64url-encoded 32-byte [Sendspin PSK](#definitions) (no padding) - `server_id?`: string - present for stored-pubkey records, absent for shared-PSK records +A `psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or the client's Pairing PSK; see [Pre-Shared Key](#pre-shared-key)) is rejected as `invalid`. The same PSK already present as a record is `already_exists`. + Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. #### Server → Client: `management/remove-record` @@ -1075,9 +1087,12 @@ Modify pairing config. - `unpaired_access?`: object - see [Unpaired Access](#unpaired-access) - `enabled?`: boolean -The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Setting fields on a method the client does not implement returns `invalid`. +The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Two cases are rejected as `invalid`: -Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. +- fields set on a method the client does not implement +- a `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](#pre-shared-key)) + +Possible outcomes: `ok`, `permission_denied`, `invalid`, `storage_exhausted`. #### Record mode @@ -1090,6 +1105,8 @@ The client creates a stored-pubkey record bound to the server, holding a freshly `psk_id` MUST reference a shared-PSK record. This constraint is enforced at configuration time: any management request that would set `psk_id` to a missing or stored-pubkey record is rejected, and the referenced shared-PSK record cannot be removed while the reference exists. Both operations are rejected as `invalid`. By default, `psk_id` points to a pre-provisioned shared-PSK record. +The pre-provisioned record's PSK MUST be device-specific (randomly generated, unique per device) and MUST NOT be a fixed default shared across devices, mirroring the static-PIN rule in [Pairing](#pairing). + ### Client → Server: `management/result` Response to a `management/*` request. The at-most-one-in-flight rule (see [Management](#management)) lets the server match each reply to its request by ordering alone, so no request-identifier field is carried. @@ -1113,7 +1130,7 @@ Records (and, on some clients, operator-set pairing secrets) share one storage p - `cost_individual`: integer - what a new stored-pubkey record consumes. - `cost_shared`: integer - what a new shared-PSK record consumes. -All four use one client-chosen unit (bytes, slots, ...), treated as opaque - a server uses only ratios and quotients, e.g. `(capacity - free) / capacity` or `free / cost_individual`. A record of a kind can persist when `free` is at least its cost; `storage_exhausted` however stays authoritative. +All four use one client-chosen unit (bytes, slots, ...), treated as opaque - a server uses only ratios and quotients, e.g. `(capacity - free) / capacity` or `free / cost_individual`. A record of a given kind can persist when `free` is at least that kind's cost; `storage_exhausted` however stays authoritative. A secret set via [`set-pairing-config`](#server--client-managementset-pairing-config) may also draw on the pool but isn't covered by these costs. diff --git a/connection.md b/connection.md index 4139b6f..1f275c2 100644 --- a/connection.md +++ b/connection.md @@ -2,7 +2,9 @@ Sendspin has two standard ways to establish connections: Server and Client initiated. Server Initiated connections are recommended as they provide standardized multi-server behavior, but require mDNS which may not be available in all environments. -Sendspin Servers must support both methods described below. +Sendspin Servers must support both methods described below. Clients MUST use exactly one of the two methods at a time, advertising or discovering accordingly. + +The WebSocket transport MUST be plain `ws://`. Confidentiality and integrity are provided end to end by the [Noise layer](#encryption) inside the WebSocket payloads. ### Server Initiated Connections @@ -14,6 +16,8 @@ Clients announce their presence via mDNS using: The server discovers available clients through mDNS and connects to each client via WebSocket using the advertised address and path. +**Note:** The TXT `name` SHOULD match the `name` the client sends in [`client/hello`](messaging.md#client--server-clienthello). It is only a discovery-time hint; if the two differ, the `client/hello` value is authoritative. + **Note:** Do not manually connect to servers if you are advertising `_sendspin._tcp`. #### Multiple servers (server-initiated) @@ -33,7 +37,7 @@ When a new server connects, the client lets the handshake complete before applyi - A [pairing attempt](pairing.md#entering-and-leaving-pairing) is not displaced by an incoming `'playback'` or `'pairing'` connection. - When both the current holder and the incoming connection have empty `activities`, the incoming is admitted only if its `server_id` matches the last-playback server (and the existing one's does not); otherwise the existing is kept. -Subsequent `server/activate` updates do not trigger arbitration. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. +Subsequent `server/activate` updates do not trigger arbitration, even when a connection escalates its activities. A provisional connection that has not sent `server/activate` within 30 seconds is dropped. Clients MAY cap how many provisional connections they hold at once, rejecting further incoming connections as if they were lower priority. A displaced connection receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'another_server'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` if it is a pairing handshake). A rejected incoming receives [`client/goodbye`](messaging.md#client--server-clientgoodbye) reason `'concurrent_attempt'` (or [`pair/abort`](pairing.md#client--server-pairabort) reason `concurrent_attempt` for pairings). The client then closes the connection. @@ -47,6 +51,8 @@ If clients prefer to initiate the connection instead of waiting for the server t Clients discover the server through mDNS and initiate a WebSocket connection using the advertised address and path. +**Note:** The TXT `name` SHOULD match the `name` the server sends in [`server/hello`](messaging.md#server--client-serverhello). It is only a discovery-time hint; if the two differ, the `server/hello` value is authoritative. + **Note:** Do not advertise `_sendspin._tcp` if the client plans to initiate the connection. #### Multiple servers (client-initiated) @@ -65,7 +71,7 @@ Sendspin uses the `KKpsk2` Noise pattern. Both static keys are pre-known to both The **server is the Noise initiator**, the **client is the Noise responder**, regardless of which side initiated the WebSocket connection. -**Security properties.** Forward secrecy is provided by the ephemeral-key DH in each handshake: compromise of static keys or the PSK does not retroactively decrypt prior sessions. Replay protection is provided by Noise's per-direction transport counter; a repeated or out-of-order ciphertext fails AEAD decryption and aborts the connection. +**Security properties.** Forward secrecy is provided by the ephemeral-key DH in each handshake: compromise of static keys or the PSK does not retroactively decrypt prior sessions' transport traffic (the exception is the first handshake message's `psk_id` payload, recoverable with the client's static key). Replay protection is provided by Noise's per-direction transport counter; a repeated or out-of-order ciphertext fails AEAD decryption and aborts the connection. ### Cipher Suites @@ -86,7 +92,7 @@ The `client_id` and `server_id` fields are the base64url-encoded (no padding) Cu ### Pre-Shared Key -The PSK is mixed into the handshake state at the end of the second handshake message (the `psk2` modifier). The transport-mode keys derived after the handshake therefore include the PSK, but the first handshake message's payload (sent by the server) is encrypted under static-key DH only. +The PSK is mixed into the handshake state at the end of the second handshake message (the `psk2` modifier). The transport-mode keys derived after the handshake therefore include the PSK, but the first handshake message's payload (sent by the server) is encrypted without the PSK mixed in. To let the client select the right PSK before the PSK must be mixed in, the server includes a `psk_id` in the first handshake message's payload. The identifier is a 43-character base64url-encoded value (no padding) of a 32-byte SHA-256 output, derived deterministically from the PSK: @@ -96,6 +102,8 @@ psk_id = base64url(SHA-256("sendspin-psk-id-v1" || PSK)) The label is the UTF-8 byte sequence of the literal characters shown (no NUL terminator, no surrounding quotes); `||` denotes byte concatenation. The same formula applies to all three PSK categories (long-term, Pairing, Sentinel); the client stores each of its PSKs tagged with its category and, on match, the stored category determines how to proceed. The single handshake pattern (`KKpsk2`) is used in all three cases; only the PSK input differs. +The three PSK categories share one `psk_id` namespace, so a `psk_id` must be unique across them. Two categories sharing one would make a single wire `psk_id` map to two trust levels. Clients enforce this when records are configured (see [Management](management.md#records)). + The **Sentinel PSK** is a published constant used as the PSK input whenever no other PSK applies - i.e., before any pairing record exists. It provides no authentication on its own (its value is public); authentication, when needed, is established later during [Pairing](pairing.md#pairing). The sentinel value is: ``` @@ -110,7 +118,7 @@ Sentinel psk_id = 0x185b15f6d2da4909bd1dc156a4ab206103abef0153bcd52d926170b95cf7 = base64url "GFsV9tLaSQm9HcFWpKsgYQOr7wFTvNUtkmFwuVz3zoo" ``` -The client decrypts the first handshake message's payload using only the static keys, compares the included `psk_id` to hashes of each of its candidate PSKs, and selects the PSK whose hash matches. It then mixes that PSK as required to process the second handshake message. If no candidate matches, the handshake fails. +The client decrypts the first handshake message's payload (possible without a PSK, as noted above), compares the included `psk_id` to the hash of each candidate PSK, and selects the one that matches. It then mixes that PSK in to process the second handshake message. If no candidate matches, the handshake fails. A PSK for a pairing method disabled in the client's [pairing config](management.md#server--client-managementset-pairing-config) is excluded from the candidate set, so a handshake referencing it fails as a lookup miss. Two storage variants are supported for long-term [Sendspin PSK](README.md#definitions) records, distinguished by whether the client also stores the server's `server_id`. The wire bytes and `psk_id` lookup are identical; only the post-match check differs. @@ -121,12 +129,14 @@ Two storage variants are supported for long-term [Sendspin PSK](README.md#defini The prologue mixed into the Noise handshake state on both sides is the concatenation of the exact bytes of [`client/init`](messaging.md#client--server-clientinit) followed by the exact bytes of [`server/init`](messaging.md#server--client-serverinit), as transmitted on the wire (the JSON-encoded UTF-8 message body, without the WebSocket framing). This binds the cleartext init exchange to the handshake; tampering causes the handshake to fail. +Both sides MUST hash the raw message bytes exactly as sent and received, not a re-encoding of the parsed message. + ### Failure Handling Any handshake-phase failure - malformed cleartext message, unsupported `version`, unknown `suite`, handshake timeout, `psk_id` lookup miss, Noise AEAD failure, or AEAD failure once in transport mode - closes the WebSocket without sending any application-level error message. Implementations SHOULD apply a timeout (e.g., 30 seconds) for each side to receive the next expected message during the prologue and Noise-handshake phases. ### Re-handshake -The server may rerun the Noise handshake in transport mode to swap session keys without closing the WebSocket - typically to promote the trust class after a successful [pairing](pairing.md#pairing), to switch from Sentinel to a Pairing PSK, or to rotate session keys on long-running connections. +The server may rerun the Noise handshake in transport mode to swap session keys without closing the WebSocket - typically to promote the trust level after a successful [pairing](pairing.md#pairing), to switch from Sentinel to a Pairing PSK, or to rotate session keys on long-running connections. The server initiates, as in the original handshake. The two [`noise/handshake`](messaging.md#client--server-noisehandshake) messages are sent as encrypted binary frames inside the current channel; `psk_id` in noise message 1 selects the PSK for the new session. `client/init` and `server/init` are not re-sent - `client_id`, `server_id`, and `suite` carry over. The new handshake's prologue is the prior handshake's hash `h`. No other messages flow during the exchange; once the new keys are in place, the connection continues with the usual [`server/hello`](messaging.md#server--client-serverhello) → [`client/hello`](messaging.md#client--server-clienthello) (the client re-asserts `trust_level`) → [`server/activate`](messaging.md#server--client-serveractivate). diff --git a/management.md b/management.md index ae418c8..0e753c0 100644 --- a/management.md +++ b/management.md @@ -34,6 +34,8 @@ Add a pairing record directly. - `psk`: string - 43-character base64url-encoded 32-byte [Sendspin PSK](README.md#definitions) (no padding) - `server_id?`: string - present for stored-pubkey records, absent for shared-PSK records +A `psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or the client's Pairing PSK; see [Pre-Shared Key](connection.md#pre-shared-key)) is rejected as `invalid`. The same PSK already present as a record is `already_exists`. + Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. #### Server → Client: `management/remove-record` @@ -96,9 +98,12 @@ Modify pairing config. - `unpaired_access?`: object - see [Unpaired Access](pairing.md#unpaired-access) - `enabled?`: boolean -The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Setting fields on a method the client does not implement returns `invalid`. +The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Two cases are rejected as `invalid`: + +- fields set on a method the client does not implement +- a `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](connection.md#pre-shared-key)) -Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. +Possible outcomes: `ok`, `permission_denied`, `invalid`, `storage_exhausted`. #### Record mode @@ -111,6 +116,8 @@ The client creates a stored-pubkey record bound to the server, holding a freshly `psk_id` MUST reference a shared-PSK record. This constraint is enforced at configuration time: any management request that would set `psk_id` to a missing or stored-pubkey record is rejected, and the referenced shared-PSK record cannot be removed while the reference exists. Both operations are rejected as `invalid`. By default, `psk_id` points to a pre-provisioned shared-PSK record. +The pre-provisioned record's PSK MUST be device-specific (randomly generated, unique per device) and MUST NOT be a fixed default shared across devices, mirroring the static-PIN rule in [Pairing](pairing.md#pairing). + ### Client → Server: `management/result` Response to a `management/*` request. The at-most-one-in-flight rule (see [Management](#management)) lets the server match each reply to its request by ordering alone, so no request-identifier field is carried. @@ -134,7 +141,7 @@ Records (and, on some clients, operator-set pairing secrets) share one storage p - `cost_individual`: integer - what a new stored-pubkey record consumes. - `cost_shared`: integer - what a new shared-PSK record consumes. -All four use one client-chosen unit (bytes, slots, ...), treated as opaque - a server uses only ratios and quotients, e.g. `(capacity - free) / capacity` or `free / cost_individual`. A record of a kind can persist when `free` is at least its cost; `storage_exhausted` however stays authoritative. +All four use one client-chosen unit (bytes, slots, ...), treated as opaque - a server uses only ratios and quotients, e.g. `(capacity - free) / capacity` or `free / cost_individual`. A record of a given kind can persist when `free` is at least that kind's cost; `storage_exhausted` however stays authoritative. A secret set via [`set-pairing-config`](#server--client-managementset-pairing-config) may also draw on the pool but isn't covered by these costs. From 97c137e199a0a00fc7306b0819c2ae4316faf841 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 21 Jul 2026 11:02:05 +0200 Subject: [PATCH 03/13] Return already_exists for psk_id collisions in management commands --- README.md | 9 +++------ management.md | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6ff70d7..7abea09 100644 --- a/README.md +++ b/README.md @@ -1023,7 +1023,7 @@ Add a pairing record directly. - `psk`: string - 43-character base64url-encoded 32-byte [Sendspin PSK](#definitions) (no padding) - `server_id?`: string - present for stored-pubkey records, absent for shared-PSK records -A `psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or the client's Pairing PSK; see [Pre-Shared Key](#pre-shared-key)) is rejected as `invalid`. The same PSK already present as a record is `already_exists`. +A `psk` whose `psk_id` is already known, whether as a record or as the Sentinel PSK or the client's Pairing PSK (see [Pre-Shared Key](#pre-shared-key)), is rejected as `already_exists`. Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. @@ -1087,12 +1087,9 @@ Modify pairing config. - `unpaired_access?`: object - see [Unpaired Access](#unpaired-access) - `enabled?`: boolean -The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Two cases are rejected as `invalid`: +The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Fields set on a method the client does not implement are rejected as `invalid`. A `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](#pre-shared-key)) is rejected as `already_exists`. -- fields set on a method the client does not implement -- a `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](#pre-shared-key)) - -Possible outcomes: `ok`, `permission_denied`, `invalid`, `storage_exhausted`. +Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. #### Record mode diff --git a/management.md b/management.md index 0e753c0..a889051 100644 --- a/management.md +++ b/management.md @@ -34,7 +34,7 @@ Add a pairing record directly. - `psk`: string - 43-character base64url-encoded 32-byte [Sendspin PSK](README.md#definitions) (no padding) - `server_id?`: string - present for stored-pubkey records, absent for shared-PSK records -A `psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or the client's Pairing PSK; see [Pre-Shared Key](connection.md#pre-shared-key)) is rejected as `invalid`. The same PSK already present as a record is `already_exists`. +A `psk` whose `psk_id` is already known, whether as a record or as the Sentinel PSK or the client's Pairing PSK (see [Pre-Shared Key](connection.md#pre-shared-key)), is rejected as `already_exists`. Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. @@ -98,12 +98,9 @@ Modify pairing config. - `unpaired_access?`: object - see [Unpaired Access](pairing.md#unpaired-access) - `enabled?`: boolean -The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Two cases are rejected as `invalid`: +The request applies as a patch: only fields present in the payload are written, and any absent field (including an absent method object) leaves the corresponding stored value unchanged. Fields set on a method the client does not implement are rejected as `invalid`. A `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](connection.md#pre-shared-key)) is rejected as `already_exists`. -- fields set on a method the client does not implement -- a `pairing_psk.psk` whose `psk_id` collides with a candidate PSK in a different category (the Sentinel PSK or a stored record; see [Pre-Shared Key](connection.md#pre-shared-key)) - -Possible outcomes: `ok`, `permission_denied`, `invalid`, `storage_exhausted`. +Possible outcomes: `ok`, `permission_denied`, `already_exists`, `invalid`, `storage_exhausted`. #### Record mode From e9f145edaf19f56737a62bef6117c70c463ca4d9 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 21 Jul 2026 13:59:09 +0200 Subject: [PATCH 04/13] Resolve pre-1.0 spec gaps in messaging --- README.md | 70 ++++++++++++++++++++++++++++++++++++---------------- messaging.md | 70 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 98 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 7abea09..9bd0eee 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ Once the WebSocket connection is established, Client and Server perform an initi 2. Server → Client: [`server/init`](#server--client-serverinit) (cleartext) 3. Server → Client: [`noise/handshake`](#client--server-noisehandshake) - Noise message 1 (cleartext) 4. Client → Server: [`noise/handshake`](#client--server-noisehandshake) - Noise message 2 (cleartext) -5. Both sides switch to Noise transport mode. From this point, all WebSocket frames are binary, and all payloads are Noise transport ciphertexts. +5. Both sides switch to Noise transport mode. From this point, all Sendspin application data is sent as WebSocket binary frames whose payloads are Noise transport ciphertexts. 6. Server → Client: [`server/hello`](#server--client-serverhello) (encrypted) 7. Client → Server: [`client/hello`](#client--server-clienthello) (encrypted) 8. Server → Client: [`server/activate`](#server--client-serveractivate) (encrypted) @@ -296,6 +296,8 @@ No other messages should be sent before the initial [`server/activate`](#server- Cleartext handshake messages (`client/init`, `server/init`, `noise/handshake`) are sent as WebSocket **text** frames containing JSON. After the encrypted channel is established, all messages are sent as WebSocket **binary** frames carrying Noise transport ciphertexts. +WebSocket control frames (Ping, Pong, Close; RFC 6455) are not Sendspin messages: they remain valid at any time, are not encrypted at the Noise layer, and Ping/Pong is the expected connection-liveness mechanism. + **Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted). All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below. @@ -377,14 +379,17 @@ The concatenated `data` from all fragments yields the original message's payload **Constraints:** -- Only one message may be in flight at a time across the entire connection. If a message is fragmented, the sender must finish sending it (with a fragment-end frame) before starting another. +- Only one fragmented message may be in flight at a time per direction. A sender must finish a fragmented message with a fragment-end frame before sending any other frame in that direction, whether fragmented or not. - Senders should not fragment messages that fit in a single non-fragmented frame. +- A sender MUST NOT use a fragment type (`2` or `3`) as `orig_type`. **Receiver behavior:** maintain a single reassembly buffer along with the in-flight `orig_type`. On a fragment-more frame when no message is in flight, read `orig_type` from byte 1, then start a new buffer with the rest of the frame. On a fragment-more frame when a message is in flight, append the frame's data to the buffer. On a fragment-end frame, append the frame's data and dispatch the result as a single message of type `orig_type`, then clear the buffer. +**Malformed sequences** are protocol errors; the receiver MUST close the connection. They are: a fragment-end frame received with no fragmented message in flight, a non-fragment frame received while a fragmented message is in flight in the same direction, and an `orig_type` of `2` or `3`. + ## Clock Synchronization -Clients send `client/time` messages to maintain an accurate offset from the server's clock. Implementations MUST send these messages frequently enough to keep the filter convergent. See the time-filter library's [Recommended Usage](https://github.com/Sendspin-Protocol/time-filter#recommended-usage) section for a known-good burst-strategy baseline. +Clients send `client/time` messages to maintain an accurate offset from the server's clock. Implementations MUST send these messages frequently enough to keep the filter convergent. The time-filter library's [Recommended Usage](https://github.com/Sendspin-Protocol/time-filter#recommended-usage) section describes a known-good burst-strategy baseline. Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients MUST use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation. @@ -405,7 +410,9 @@ First message sent by the client after the WebSocket connection is established. - `client_id`: string - client's static public key (43-character base64url-encoded Curve25519, no padding). See [Identities](#identities). Persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue) - `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions) -- `suite`: string - Noise cipher suite the client picked for this connection. See [Cipher Suites](#cipher-suites) +- `suite`: '25519_ChaChaPoly_SHA256' | '25519_AESGCM_SHA256' - Noise cipher suite the client picked for this connection. See [Cipher Suites](#cipher-suites) + +**Note:** `version` (here and in [`server/init`](#server--client-serverinit)) is an exact-match field naming the single core message format the sender speaks, not a minimum-supported version. Under this specification both sides send `1` and abort the handshake on any other value (see [Failure Handling](#failure-handling)); a future revision that changes the core format will bump the value and define its own negotiation semantics. ### Server → Client: `server/init` @@ -425,12 +432,14 @@ Carries one Noise handshake message. Sent twice during the handshake: once by th The encrypted payload carried inside each Noise handshake message is a UTF-8 JSON object: - **Noise message 1 payload** (server → client): - - `psk_id`: string - 43-character base64url-encoded SHA-256 hash derived from the PSK. Used by the client to select the PSK before processing message 2. See [Pre-Shared Key](#pre-shared-key). -- **Noise message 2 payload** (client → server): empty object `{}` + - `psk_id`: string - 43-character base64url-encoded SHA-256 hash derived from the PSK. Used by the client to select the PSK before processing message 2; the message-1 payload is decryptable without the PSK (see [Pre-Shared Key](#pre-shared-key)). +- **Noise message 2 payload** (client → server): the empty object as the literal two bytes `{}` (not a zero-length Noise payload) + +A malformed inner handshake payload (not valid UTF-8 JSON of the shape above) is a handshake failure and closes the WebSocket (see [Failure Handling](#failure-handling)). -After both handshake messages have been exchanged, both sides switch to Noise transport mode. All subsequent WebSocket frames are binary, and all payloads are Noise transport ciphertexts. +After both handshake messages have been exchanged, both sides switch to Noise transport mode (all subsequent messages travel as the binary Noise-ciphertext frames described above). -The same `noise/handshake` message is used for the in-band [re-handshake](#re-handshake): the two messages then travel as binary frames encrypted under the current transport keys rather than as cleartext text frames. +The same `noise/handshake` message is used for the in-band [re-handshake](#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after performing `split()` uses the new keys. ### Server → Client: `server/hello` @@ -459,16 +468,18 @@ Players that can output audio should have the role `player`. - `artwork@v1` - displays artwork images - `visualizer@v1` - visualizes audio - `color@v1` - receives colors derived from the current audio -- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object)) -- `source@v1_support?`: object - only if `source@v1` is listed ([see source@v1 support object details](#client--server-clienthello-sourcev1-support-object)) -- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object)) -- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object)) -- `supported_pair_methods?`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](#client--server-clienthello-pair-method-descriptor). +- `player@v1_support?`: object - required if `player@v1` is listed, absent otherwise ([see player@v1 support object details](#client--server-clienthello-playerv1-support-object)) +- `source@v1_support?`: object - required if `source@v1` is listed, absent otherwise ([see source@v1 support object details](#client--server-clienthello-sourcev1-support-object)) +- `artwork@v1_support?`: object - required if `artwork@v1` is listed, absent otherwise ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object)) +- `visualizer@v1_support?`: object - required if `visualizer@v1` is listed, absent otherwise ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object)) +- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](#client--server-clienthello-pair-method-descriptor). Never empty: every client implements at least the Pairing PSK method (see [Pairing](#pairing)). - `unpaired_access`: object - whether this client currently admits [unpaired access](#unpaired-access) - `enabled`: boolean **Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`). +A server MUST NOT activate a role version that was listed in `supported_roles` without its support object. + ### Server → Client: `server/activate` Declares the server's current purpose on this connection. Sent as an encrypted message (binary frame, message type `0`). May be re-sent any time to change the activity set. @@ -476,8 +487,8 @@ Declares the server's current purpose on this connection. Sent as an encrypted m Only after receiving the initial `server/activate` should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates). - `activities`: ('playback' | 'pairing' | 'management')[] - the set of currently-active purposes on this connection. May be empty. Members are unordered and unique. -- `active_roles?`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`). Required on the first `server/activate`; persists across subsequent `server/activate` messages that omit it. MUST be empty on connections not capable of playback (see below). -- `selected_pair_method?`: 'dynamic_pin' | 'pairing_psk' | 'static_pin' - pairing method the server picked, drawn from the client's `supported_pair_methods`. Required when `'pairing'` is in activities; absent otherwise. +- `active_roles?`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`). Required on the first `server/activate`; persists across subsequent `server/activate` messages that omit it. MUST be empty on connections not capable of playback (see below). A client treats a first `server/activate` that omits it as carrying an empty `active_roles`. +- `selected_pair_method?`: 'dynamic_pin' | 'pairing_psk' | 'static_pin' - pairing method the server picked, drawn from the client's `supported_pair_methods`. Required when `'pairing'` is in activities; absent otherwise. A client ignores this field when `activities` does not include `'pairing'`. The activity sets the server may legitimately declare are constrained by which PSK matched during the [Noise handshake](#encryption): @@ -491,14 +502,18 @@ The activity sets the server may legitimately declare are constrained by which P `selected_pair_method` MUST be `'pairing_psk'` if and only if the matched PSK is the [Sendspin Pairing PSK](#definitions). It MUST also be a method the client listed in [`supported_pair_methods`](#client--server-clienthello). -**Playback-capable connections.** A connection is *playback-capable* when its `activities` extended with `'playback'` are an allowed set for the matched PSK; a connection already declaring `'playback'` is therefore playback-capable exactly when its `activities` are an allowed set. Only a playback-capable connection MAY carry a non-empty `active_roles`, and it may do so even when `'playback'` is not currently in `activities`. +Per-role trust also bounds `active_roles`: `source@v1` MUST NOT be activated at [trust level](#definitions) `'none'` (see [Pairing required](#source-messages)); no other role carries a trust constraint. + +**Playback-capable connections.** A connection is *playback-capable* when its `activities` extended with `'playback'` are an allowed set for the matched PSK; a connection already declaring `'playback'` is therefore playback-capable exactly when its `activities` are an allowed set. Only a playback-capable connection MAY carry a non-empty `active_roles`, and it may do so even when `'playback'` is not currently in `activities`. The client re-evaluates this constraint on every `server/activate` against the persisted `active_roles`: if a later activation changes `activities` so the connection is no longer playback-capable without explicitly sending `active_roles`, the persisted roles are treated as empty rather than the message rejected. `server/activate` is *admissible* when it satisfies the constraints above. When one is not admissible, the client rejects it, selecting the response by the first rule that applies: - If the matched PSK is the [Sentinel PSK](#pre-shared-key), the client does not have [unpaired access](#unpaired-access) enabled, and enabling unpaired access would make the activation admissible - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'pairing_required'`. -- If `activities` is not an allowed set for the matched PSK, or `active_roles` is non-empty on a connection that is not playback-capable - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'unauthorized'`. +- If `activities` is not an allowed set for the matched PSK, `active_roles` is non-empty on a connection that is not playback-capable, or `active_roles` includes a role forbidden at the session's trust level (`source@v1` at `'none'`) - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'unauthorized'`. - If `'pairing'` is in `activities` with a `selected_pair_method` the matched PSK disallows or the client does not currently offer - reply with [`pair/abort`](#client--server-pairabort) reason `method_not_supported`, leaving the connection open. The check uses the live pairing configuration, which may have drifted from [`supported_pair_methods`](#client--server-clienthello); the server may re-activate, or [re-handshake](#re-handshake) for a fresh advertisement. +**Worked example (`pairing_required` vs `unauthorized`).** A Sentinel-keyed connection to a client with unpaired access disabled receives `activities: ['playback']` and `active_roles: ['player@v1']`. Under a hypothetical `unpaired_access: enabled`, `['playback']` would be an allowed set for the Sentinel PSK and the connection would be playback-capable, so the activation would be admissible: the client closes with `'pairing_required'`. If the same connection instead received `activities: ['playback', 'management']`, no unpaired-access setting makes that set allowed on the Sentinel PSK, so the reason is `'unauthorized'`. + **Note:** Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. **Note:** Servers normally activate the client's [preferred](#priority-and-activation) version of each role, but MAY omit a role at their discretion (e.g., based on trust level, deployment context, or operator policy). Checking `active_roles` is therefore required to determine what the client may actually use on this session. @@ -528,6 +543,8 @@ Client sends state updates to the server. Contains client-level state and role-s Sent once the client is ready to report its operational status (`available`), and whenever any state changes thereafter. A player reports `available: true` only after it has established [clock synchronization](#clock-synchronization). The server MUST NOT send binary data to a client before that client has sent its initial `client/state`. When a role becomes active in `active_roles`, send its full state. +A client whose `active_roles` include `artwork` or `visualizer` sends the initial `client/state` even when none of its roles defines a state object; `available` alone unlocks the server's streams. + The initial message MUST include all state fields. In subsequent messages, the client MAY send only the fields that have changed; the server MUST merge each update into existing state, retaining the last value of any field that is absent. A client MAY instead resend unchanged fields, up to its full state. - `available`: boolean - whether the client is available to participate in Sendspin playback @@ -564,6 +581,7 @@ If the client is in a multi-client group: If the client is already in a solo group: - Stop playback and send [`stream/end`](#server--client-streamend) for all active streams +- If `playback_state` was not already `'stopped'`, send [`group/update`](#server--client-groupupdate) with `playback_state: 'stopped'` When a client returns to `available: true`, the server MUST NOT auto-rejoin it to its previous group or restart playback; the client remains in the solo group and rejoins only via an explicit [`switch`](#switch-command-cycle). @@ -581,6 +599,12 @@ Server sends state updates to the client. Contains role-specific state objects. Only include fields that have changed. The client will merge these updates into existing state. A leaf field set to `null` should be cleared from the client's state; a whole role object set to `null` clears all of that role's state. +The merge is shallow: a nested object (e.g., `metadata.progress`) is replaced or cleared as a whole, never deep-merged, so nested objects are always sent complete. + +The first `server/state` sent for a role on a connection, and the first after that role is re-added to `active_roles`, MUST carry the role's full state. + +**Note:** The asymmetry with [`client/state`](#client--server-clientstate) is deliberate: server-to-client updates carry only changed fields; clients MAY resend unchanged fields. + - `metadata?`: object | null - only sent to clients with `metadata` role ([see metadata state object details](#server--client-serverstate-metadata-object)) - `controller?`: object | null - only sent to clients with `controller` role ([see controller state object details](#server--client-serverstate-controller-object)) - `color?`: object | null - only sent to clients with `color` role ([see color state object details](#server--client-serverstate-color-object)) @@ -598,7 +622,7 @@ Server sends commands to the client. Contains role-specific command objects. ### Server → Client: `stream/start` -Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. If a parameter change requires rebuffering (e.g., a sample rate change), the receiver handles this internally — the default behavior is to not clear unless the implementation requires it. Implementations may document their specific behavior. +Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. If a parameter change requires rebuffering (e.g., a sample rate change), the receiver handles this internally: it does not clear buffers unless its implementation requires it, and may document its specific behavior. - `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds - `player?`: object - only sent to clients with the `player` role ([see player object details](#server--client-streamstart-player-object)) @@ -640,7 +664,7 @@ Ends the stream for one or more roles. When received, clients should stop output - **Track transitions** (a track ends and the next begins naturally): no stream commands should be sent. The stream continues uninterrupted to support gapless playback and server-inserted crossfade. - **Seeks** (jumping to a position within the current track): send `stream/clear` instead. -- **Track jumps** (skipping to a different track): treat identically to a seek — send `stream/clear` instead of `stream/end`. Conceptually, the entire queue is a single continuous stream. +- **Track jumps** (skipping to a different track): treat identically to a seek, sending `stream/clear` instead of `stream/end`. Conceptually, the entire queue is a single continuous stream. Sending `stream/end` in these cases is explicitly prohibited because it signals actual playback termination, causing clients to stop output entirely rather than continue playing. @@ -655,6 +679,8 @@ State update of the group this client is part of. Contains delta updates with only the changed fields. The client should merge these updates into existing state. +The first `group/update` on a connection MUST carry the full group state (all fields below), so the client has a baseline to merge later deltas into. + - `playback_state?`: 'playing' | 'stopped' - playback state of the group - `group_id?`: string - group identifier - `group_name?`: string - friendly name of the group @@ -667,7 +693,7 @@ Client behavior: - Remove the matched pairing record, send [`client/goodbye`](#client--server-clientgoodbye) reason `'unpaired'`, and close the connection. - If the matched record is a **shared-PSK record** (not bound to a `server_id`; may back other servers - see [Records](#records)), the client MUST NOT remove it. It still sends `client/goodbye` reason `'unpaired'` and closes. Wholesale removal of a shared record requires [`management/remove-record`](#server--client-managementremove-record). -- If the connection's `trust_level` is `'none'` (e.g., a pairing handshake), ignore the message and continue unchanged. +- If the connection's `trust_level` is `'none'` (e.g., an in-flight pairing handshake), ignore the message and continue unchanged. ### Client → Server: `client/goodbye` @@ -680,13 +706,15 @@ Upon receiving this message, the server should initiate the disconnect. - `shutdown` - client is shutting down. Server should not auto-reconnect - `restart` - client is restarting and will reconnect. Server should auto-reconnect - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect - - `unauthorized` - the client refused the connection because the server declared an activity set it is not authorized for (e.g., `'management'` without `'user'` [trust level](#definitions)). Server should not auto-reconnect with the same activity set + - `unauthorized` - the client is no longer authorized for the connection: either the server declared an activity set the client is not authorized for (e.g., `'management'` without `'user'` [trust level](#definitions)), or the client removed its own pairing record (see [`management/remove-record`](#server--client-managementremove-record)) and can no longer authenticate. Server should not auto-reconnect with the same activity set - `pairing_required` - the client refused an [unpaired access](#unpaired-access) connection because it does not have unpaired access enabled. Server should not auto-reconnect without pairing first - `concurrent_attempt` - the client refused the connection because a higher-or-equal-priority connection is already active (e.g., one with `'management'` in its activity set, or a pairing handshake when the incoming connection is also pairing). Server may retry later - `unpaired` - the client has processed [`server/unpair`](#server--client-serverunpair) from this server. Server should not auto-reconnect **Note:** When the device is powering off or otherwise not coming back and no more specific reason applies, clients SHOULD send `shutdown`. +**Note:** On a client-initiated connection the server cannot reconnect; the reconnect guidance then applies to the client re-establishing the connection. + **Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`: - On a connection whose `activities` are empty, or include `'playback'`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect. diff --git a/messaging.md b/messaging.md index 8766514..031a642 100644 --- a/messaging.md +++ b/messaging.md @@ -6,7 +6,7 @@ Once the WebSocket connection is established, Client and Server perform an initi 2. Server → Client: [`server/init`](#server--client-serverinit) (cleartext) 3. Server → Client: [`noise/handshake`](#client--server-noisehandshake) - Noise message 1 (cleartext) 4. Client → Server: [`noise/handshake`](#client--server-noisehandshake) - Noise message 2 (cleartext) -5. Both sides switch to Noise transport mode. From this point, all WebSocket frames are binary, and all payloads are Noise transport ciphertexts. +5. Both sides switch to Noise transport mode. From this point, all Sendspin application data is sent as WebSocket binary frames whose payloads are Noise transport ciphertexts. 6. Server → Client: [`server/hello`](#server--client-serverhello) (encrypted) 7. Client → Server: [`client/hello`](#client--server-clienthello) (encrypted) 8. Server → Client: [`server/activate`](#server--client-serveractivate) (encrypted) @@ -15,6 +15,8 @@ No other messages should be sent before the initial [`server/activate`](#server- Cleartext handshake messages (`client/init`, `server/init`, `noise/handshake`) are sent as WebSocket **text** frames containing JSON. After the encrypted channel is established, all messages are sent as WebSocket **binary** frames carrying Noise transport ciphertexts. +WebSocket control frames (Ping, Pong, Close; RFC 6455) are not Sendspin messages: they remain valid at any time, are not encrypted at the Noise layer, and Ping/Pong is the expected connection-liveness mechanism. + **Note:** In field definitions, `?` indicates an optional field (e.g., `field?`: type means the field may be omitted). All messages have a `type` field identifying the message and a `payload` object containing message-specific data. The payload structure varies by message type and is detailed in each message section below. @@ -96,14 +98,17 @@ The concatenated `data` from all fragments yields the original message's payload **Constraints:** -- Only one message may be in flight at a time across the entire connection. If a message is fragmented, the sender must finish sending it (with a fragment-end frame) before starting another. +- Only one fragmented message may be in flight at a time per direction. A sender must finish a fragmented message with a fragment-end frame before sending any other frame in that direction, whether fragmented or not. - Senders should not fragment messages that fit in a single non-fragmented frame. +- A sender MUST NOT use a fragment type (`2` or `3`) as `orig_type`. **Receiver behavior:** maintain a single reassembly buffer along with the in-flight `orig_type`. On a fragment-more frame when no message is in flight, read `orig_type` from byte 1, then start a new buffer with the rest of the frame. On a fragment-more frame when a message is in flight, append the frame's data to the buffer. On a fragment-end frame, append the frame's data and dispatch the result as a single message of type `orig_type`, then clear the buffer. +**Malformed sequences** are protocol errors; the receiver MUST close the connection. They are: a fragment-end frame received with no fragmented message in flight, a non-fragment frame received while a fragmented message is in flight in the same direction, and an `orig_type` of `2` or `3`. + ## Clock Synchronization -Clients send `client/time` messages to maintain an accurate offset from the server's clock. Implementations MUST send these messages frequently enough to keep the filter convergent. See the time-filter library's [Recommended Usage](https://github.com/Sendspin-Protocol/time-filter#recommended-usage) section for a known-good burst-strategy baseline. +Clients send `client/time` messages to maintain an accurate offset from the server's clock. Implementations MUST send these messages frequently enough to keep the filter convergent. The time-filter library's [Recommended Usage](https://github.com/Sendspin-Protocol/time-filter#recommended-usage) section describes a known-good burst-strategy baseline. Binary audio messages contain timestamps in the server's time domain indicating when the audio should be played. Clients MUST use the [time-filter](https://github.com/Sendspin-Protocol/time-filter) algorithm to translate server timestamps to their local clock for synchronized playback. The time filter is a two-dimensional Kalman filter that tracks both clock offset and drift. See the [time-filter](https://github.com/Sendspin-Protocol/time-filter) repository for a C++ reference implementation and [aiosendspin](https://github.com/Sendspin-Protocol/aiosendspin/blob/main/aiosendspin/client/time_sync.py) for a Python implementation. @@ -124,7 +129,9 @@ First message sent by the client after the WebSocket connection is established. - `client_id`: string - client's static public key (43-character base64url-encoded Curve25519, no padding). See [Identities](connection.md#identities). Persistent across reconnections so servers can associate clients with previous sessions (e.g., remembering group membership, settings, playback queue) - `version`: integer (must be `1`) - version of the core message format that the Sendspin client implements (independent of role versions) -- `suite`: string - Noise cipher suite the client picked for this connection. See [Cipher Suites](connection.md#cipher-suites) +- `suite`: '25519_ChaChaPoly_SHA256' | '25519_AESGCM_SHA256' - Noise cipher suite the client picked for this connection. See [Cipher Suites](connection.md#cipher-suites) + +**Note:** `version` (here and in [`server/init`](#server--client-serverinit)) is an exact-match field naming the single core message format the sender speaks, not a minimum-supported version. Under this specification both sides send `1` and abort the handshake on any other value (see [Failure Handling](connection.md#failure-handling)); a future revision that changes the core format will bump the value and define its own negotiation semantics. ### Server → Client: `server/init` @@ -144,12 +151,14 @@ Carries one Noise handshake message. Sent twice during the handshake: once by th The encrypted payload carried inside each Noise handshake message is a UTF-8 JSON object: - **Noise message 1 payload** (server → client): - - `psk_id`: string - 43-character base64url-encoded SHA-256 hash derived from the PSK. Used by the client to select the PSK before processing message 2. See [Pre-Shared Key](connection.md#pre-shared-key). -- **Noise message 2 payload** (client → server): empty object `{}` + - `psk_id`: string - 43-character base64url-encoded SHA-256 hash derived from the PSK. Used by the client to select the PSK before processing message 2; the message-1 payload is decryptable without the PSK (see [Pre-Shared Key](connection.md#pre-shared-key)). +- **Noise message 2 payload** (client → server): the empty object as the literal two bytes `{}` (not a zero-length Noise payload) + +A malformed inner handshake payload (not valid UTF-8 JSON of the shape above) is a handshake failure and closes the WebSocket (see [Failure Handling](connection.md#failure-handling)). -After both handshake messages have been exchanged, both sides switch to Noise transport mode. All subsequent WebSocket frames are binary, and all payloads are Noise transport ciphertexts. +After both handshake messages have been exchanged, both sides switch to Noise transport mode (all subsequent messages travel as the binary Noise-ciphertext frames described above). -The same `noise/handshake` message is used for the in-band [re-handshake](connection.md#re-handshake): the two messages then travel as binary frames encrypted under the current transport keys rather than as cleartext text frames. +The same `noise/handshake` message is used for the in-band [re-handshake](connection.md#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after performing `split()` uses the new keys. ### Server → Client: `server/hello` @@ -178,16 +187,18 @@ Players that can output audio should have the role `player`. - `artwork@v1` - displays artwork images - `visualizer@v1` - visualizes audio - `color@v1` - receives colors derived from the current audio -- `player@v1_support?`: object - only if `player@v1` is listed ([see player@v1 support object details](roles/player/v1.md#client--server-clienthello-playerv1-support-object)) -- `source@v1_support?`: object - only if `source@v1` is listed ([see source@v1 support object details](roles/source/v1.md#client--server-clienthello-sourcev1-support-object)) -- `artwork@v1_support?`: object - only if `artwork@v1` is listed ([see artwork@v1 support object details](roles/artwork/v1.md#client--server-clienthello-artworkv1-support-object)) -- `visualizer@v1_support?`: object - only if `visualizer@v1` is listed ([see visualizer@v1 support object details](roles/visualizer/v1.md#client--server-clienthello-visualizerv1-support-object)) -- `supported_pair_methods?`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](pairing.md#client--server-clienthello-pair-method-descriptor). +- `player@v1_support?`: object - required if `player@v1` is listed, absent otherwise ([see player@v1 support object details](roles/player/v1.md#client--server-clienthello-playerv1-support-object)) +- `source@v1_support?`: object - required if `source@v1` is listed, absent otherwise ([see source@v1 support object details](roles/source/v1.md#client--server-clienthello-sourcev1-support-object)) +- `artwork@v1_support?`: object - required if `artwork@v1` is listed, absent otherwise ([see artwork@v1 support object details](roles/artwork/v1.md#client--server-clienthello-artworkv1-support-object)) +- `visualizer@v1_support?`: object - required if `visualizer@v1` is listed, absent otherwise ([see visualizer@v1 support object details](roles/visualizer/v1.md#client--server-clienthello-visualizerv1-support-object)) +- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](pairing.md#client--server-clienthello-pair-method-descriptor). Never empty: every client implements at least the Pairing PSK method (see [Pairing](pairing.md#pairing)). - `unpaired_access`: object - whether this client currently admits [unpaired access](pairing.md#unpaired-access) - `enabled`: boolean **Note:** Each role version may have its own support object (e.g., `player@v1_support`, `player@v2_support`). Application-specific roles or role versions follow the same pattern (e.g., `_myapp_display@v1_support`, `player@_experimental_support`). +A server MUST NOT activate a role version that was listed in `supported_roles` without its support object. + ### Server → Client: `server/activate` Declares the server's current purpose on this connection. Sent as an encrypted message (binary frame, message type `0`). May be re-sent any time to change the activity set. @@ -195,8 +206,8 @@ Declares the server's current purpose on this connection. Sent as an encrypted m Only after receiving the initial `server/activate` should the client send any other messages (including [`client/time`](#client--server-clienttime) and the initial [`client/state`](#client--server-clientstate) message if the client has roles that require state updates). - `activities`: ('playback' | 'pairing' | 'management')[] - the set of currently-active purposes on this connection. May be empty. Members are unordered and unique. -- `active_roles?`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`). Required on the first `server/activate`; persists across subsequent `server/activate` messages that omit it. MUST be empty on connections not capable of playback (see below). -- `selected_pair_method?`: 'dynamic_pin' | 'pairing_psk' | 'static_pin' - pairing method the server picked, drawn from the client's `supported_pair_methods`. Required when `'pairing'` is in activities; absent otherwise. +- `active_roles?`: string[] - versioned roles that are active for this client (e.g., `player@v1`, `controller@v1`). Required on the first `server/activate`; persists across subsequent `server/activate` messages that omit it. MUST be empty on connections not capable of playback (see below). A client treats a first `server/activate` that omits it as carrying an empty `active_roles`. +- `selected_pair_method?`: 'dynamic_pin' | 'pairing_psk' | 'static_pin' - pairing method the server picked, drawn from the client's `supported_pair_methods`. Required when `'pairing'` is in activities; absent otherwise. A client ignores this field when `activities` does not include `'pairing'`. The activity sets the server may legitimately declare are constrained by which PSK matched during the [Noise handshake](connection.md#encryption): @@ -210,14 +221,18 @@ The activity sets the server may legitimately declare are constrained by which P `selected_pair_method` MUST be `'pairing_psk'` if and only if the matched PSK is the [Sendspin Pairing PSK](README.md#definitions). It MUST also be a method the client listed in [`supported_pair_methods`](#client--server-clienthello). -**Playback-capable connections.** A connection is *playback-capable* when its `activities` extended with `'playback'` are an allowed set for the matched PSK; a connection already declaring `'playback'` is therefore playback-capable exactly when its `activities` are an allowed set. Only a playback-capable connection MAY carry a non-empty `active_roles`, and it may do so even when `'playback'` is not currently in `activities`. +Per-role trust also bounds `active_roles`: `source@v1` MUST NOT be activated at [trust level](README.md#definitions) `'none'` (see [Pairing required](roles/source/v1.md#source-messages)); no other role carries a trust constraint. + +**Playback-capable connections.** A connection is *playback-capable* when its `activities` extended with `'playback'` are an allowed set for the matched PSK; a connection already declaring `'playback'` is therefore playback-capable exactly when its `activities` are an allowed set. Only a playback-capable connection MAY carry a non-empty `active_roles`, and it may do so even when `'playback'` is not currently in `activities`. The client re-evaluates this constraint on every `server/activate` against the persisted `active_roles`: if a later activation changes `activities` so the connection is no longer playback-capable without explicitly sending `active_roles`, the persisted roles are treated as empty rather than the message rejected. `server/activate` is *admissible* when it satisfies the constraints above. When one is not admissible, the client rejects it, selecting the response by the first rule that applies: - If the matched PSK is the [Sentinel PSK](connection.md#pre-shared-key), the client does not have [unpaired access](pairing.md#unpaired-access) enabled, and enabling unpaired access would make the activation admissible - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'pairing_required'`. -- If `activities` is not an allowed set for the matched PSK, or `active_roles` is non-empty on a connection that is not playback-capable - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'unauthorized'`. +- If `activities` is not an allowed set for the matched PSK, `active_roles` is non-empty on a connection that is not playback-capable, or `active_roles` includes a role forbidden at the session's trust level (`source@v1` at `'none'`) - close the connection with [`client/goodbye`](#client--server-clientgoodbye) reason `'unauthorized'`. - If `'pairing'` is in `activities` with a `selected_pair_method` the matched PSK disallows or the client does not currently offer - reply with [`pair/abort`](pairing.md#client--server-pairabort) reason `method_not_supported`, leaving the connection open. The check uses the live pairing configuration, which may have drifted from [`supported_pair_methods`](#client--server-clienthello); the server may re-activate, or [re-handshake](connection.md#re-handshake) for a fresh advertisement. +**Worked example (`pairing_required` vs `unauthorized`).** A Sentinel-keyed connection to a client with unpaired access disabled receives `activities: ['playback']` and `active_roles: ['player@v1']`. Under a hypothetical `unpaired_access: enabled`, `['playback']` would be an allowed set for the Sentinel PSK and the connection would be playback-capable, so the activation would be admissible: the client closes with `'pairing_required'`. If the same connection instead received `activities: ['playback', 'management']`, no unpaired-access setting makes that set allowed on the Sentinel PSK, so the reason is `'unauthorized'`. + **Note:** Servers SHOULD declare the minimal set of activities that reflects the connection's current purpose, and drop an activity as soon as that purpose ends. Admission between competing connections is decided by the highest-ranked declared activity (see [Multiple servers](connection.md#multiple-servers-server-initiated)), so keeping an unused activity declared would degrade multi-server cooperation. **Note:** Servers normally activate the client's [preferred](README.md#priority-and-activation) version of each role, but MAY omit a role at their discretion (e.g., based on trust level, deployment context, or operator policy). Checking `active_roles` is therefore required to determine what the client may actually use on this session. @@ -247,6 +262,8 @@ Client sends state updates to the server. Contains client-level state and role-s Sent once the client is ready to report its operational status (`available`), and whenever any state changes thereafter. A player reports `available: true` only after it has established [clock synchronization](#clock-synchronization). The server MUST NOT send binary data to a client before that client has sent its initial `client/state`. When a role becomes active in `active_roles`, send its full state. +A client whose `active_roles` include `artwork` or `visualizer` sends the initial `client/state` even when none of its roles defines a state object; `available` alone unlocks the server's streams. + The initial message MUST include all state fields. In subsequent messages, the client MAY send only the fields that have changed; the server MUST merge each update into existing state, retaining the last value of any field that is absent. A client MAY instead resend unchanged fields, up to its full state. - `available`: boolean - whether the client is available to participate in Sendspin playback @@ -283,6 +300,7 @@ If the client is in a multi-client group: If the client is already in a solo group: - Stop playback and send [`stream/end`](#server--client-streamend) for all active streams +- If `playback_state` was not already `'stopped'`, send [`group/update`](#server--client-groupupdate) with `playback_state: 'stopped'` When a client returns to `available: true`, the server MUST NOT auto-rejoin it to its previous group or restart playback; the client remains in the solo group and rejoins only via an explicit [`switch`](roles/controller/v1.md#switch-command-cycle). @@ -300,6 +318,12 @@ Server sends state updates to the client. Contains role-specific state objects. Only include fields that have changed. The client will merge these updates into existing state. A leaf field set to `null` should be cleared from the client's state; a whole role object set to `null` clears all of that role's state. +The merge is shallow: a nested object (e.g., `metadata.progress`) is replaced or cleared as a whole, never deep-merged, so nested objects are always sent complete. + +The first `server/state` sent for a role on a connection, and the first after that role is re-added to `active_roles`, MUST carry the role's full state. + +**Note:** The asymmetry with [`client/state`](#client--server-clientstate) is deliberate: server-to-client updates carry only changed fields; clients MAY resend unchanged fields. + - `metadata?`: object | null - only sent to clients with `metadata` role ([see metadata state object details](roles/metadata/v1.md#server--client-serverstate-metadata-object)) - `controller?`: object | null - only sent to clients with `controller` role ([see controller state object details](roles/controller/v1.md#server--client-serverstate-controller-object)) - `color?`: object | null - only sent to clients with `color` role ([see color state object details](roles/color/v1.md#server--client-serverstate-color-object)) @@ -317,7 +341,7 @@ Server sends commands to the client. Contains role-specific command objects. ### Server → Client: `stream/start` -Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. If a parameter change requires rebuffering (e.g., a sample rate change), the receiver handles this internally — the default behavior is to not clear unless the implementation requires it. Implementations may document their specific behavior. +Starts a stream for one or more roles. If sent for a role that already has an active stream, updates the stream configuration without clearing buffers. If a parameter change requires rebuffering (e.g., a sample rate change), the receiver handles this internally: it does not clear buffers unless its implementation requires it, and may document its specific behavior. - `server_transmitted`: integer - timestamp that the server transmitted this message in microseconds - `player?`: object - only sent to clients with the `player` role ([see player object details](roles/player/v1.md#server--client-streamstart-player-object)) @@ -359,7 +383,7 @@ Ends the stream for one or more roles. When received, clients should stop output - **Track transitions** (a track ends and the next begins naturally): no stream commands should be sent. The stream continues uninterrupted to support gapless playback and server-inserted crossfade. - **Seeks** (jumping to a position within the current track): send `stream/clear` instead. -- **Track jumps** (skipping to a different track): treat identically to a seek — send `stream/clear` instead of `stream/end`. Conceptually, the entire queue is a single continuous stream. +- **Track jumps** (skipping to a different track): treat identically to a seek, sending `stream/clear` instead of `stream/end`. Conceptually, the entire queue is a single continuous stream. Sending `stream/end` in these cases is explicitly prohibited because it signals actual playback termination, causing clients to stop output entirely rather than continue playing. @@ -374,6 +398,8 @@ State update of the group this client is part of. Contains delta updates with only the changed fields. The client should merge these updates into existing state. +The first `group/update` on a connection MUST carry the full group state (all fields below), so the client has a baseline to merge later deltas into. + - `playback_state?`: 'playing' | 'stopped' - playback state of the group - `group_id?`: string - group identifier - `group_name?`: string - friendly name of the group @@ -386,7 +412,7 @@ Client behavior: - Remove the matched pairing record, send [`client/goodbye`](#client--server-clientgoodbye) reason `'unpaired'`, and close the connection. - If the matched record is a **shared-PSK record** (not bound to a `server_id`; may back other servers - see [Records](management.md#records)), the client MUST NOT remove it. It still sends `client/goodbye` reason `'unpaired'` and closes. Wholesale removal of a shared record requires [`management/remove-record`](management.md#server--client-managementremove-record). -- If the connection's `trust_level` is `'none'` (e.g., a pairing handshake), ignore the message and continue unchanged. +- If the connection's `trust_level` is `'none'` (e.g., an in-flight pairing handshake), ignore the message and continue unchanged. ### Client → Server: `client/goodbye` @@ -399,13 +425,15 @@ Upon receiving this message, the server should initiate the disconnect. - `shutdown` - client is shutting down. Server should not auto-reconnect - `restart` - client is restarting and will reconnect. Server should auto-reconnect - `user_request` - user explicitly requested to disconnect from this server. Server should not auto-reconnect - - `unauthorized` - the client refused the connection because the server declared an activity set it is not authorized for (e.g., `'management'` without `'user'` [trust level](README.md#definitions)). Server should not auto-reconnect with the same activity set + - `unauthorized` - the client is no longer authorized for the connection: either the server declared an activity set the client is not authorized for (e.g., `'management'` without `'user'` [trust level](README.md#definitions)), or the client removed its own pairing record (see [`management/remove-record`](management.md#server--client-managementremove-record)) and can no longer authenticate. Server should not auto-reconnect with the same activity set - `pairing_required` - the client refused an [unpaired access](pairing.md#unpaired-access) connection because it does not have unpaired access enabled. Server should not auto-reconnect without pairing first - `concurrent_attempt` - the client refused the connection because a higher-or-equal-priority connection is already active (e.g., one with `'management'` in its activity set, or a pairing handshake when the incoming connection is also pairing). Server may retry later - `unpaired` - the client has processed [`server/unpair`](#server--client-serverunpair) from this server. Server should not auto-reconnect **Note:** When the device is powering off or otherwise not coming back and no more specific reason applies, clients SHOULD send `shutdown`. +**Note:** On a client-initiated connection the server cannot reconnect; the reconnect guidance then applies to the client re-establishing the connection. + **Note:** Clients may close the connection without sending this message (e.g., crash, network loss), or immediately after sending `client/goodbye` without waiting for the server to disconnect. When a client disconnects without sending `client/goodbye`: - On a connection whose `activities` are empty, or include `'playback'`, servers should assume the disconnect reason is `restart` and attempt to auto-reconnect. From bca64456f4775787e6a6184e295de4f89c595520 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 21 Jul 2026 13:59:15 +0200 Subject: [PATCH 05/13] Resolve pre-1.0 spec gaps in pairing --- README.md | 48 ++++++++++++++++++++++++++++++++---------------- pairing.md | 48 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9bd0eee..d950b33 100644 --- a/README.md +++ b/README.md @@ -723,16 +723,21 @@ Upon receiving this message, the server should initiate the disconnect. ## Pairing -Pairing is the one-time setup that mutually authenticates a client and a server. The pairing flow uses the same WebSocket endpoint and [`KKpsk2`](#encryption) Noise pattern as every other connection; only the PSK fed into the handshake and the client's post-handshake routing differ (see [Pre-Shared Key](#pre-shared-key)). After any successful pairing both sides persist the new pairing record, then the server initiates an in-band [re-handshake](#re-handshake) to the newly delivered PSK, bringing the channel under the new trust ceiling without closing the WebSocket. +Pairing is the one-time setup that mutually authenticates a client and a server. The pairing flow uses the same WebSocket endpoint and [`KKpsk2`](#encryption) Noise pattern as every other connection; only the PSK fed into the handshake and the client's post-handshake routing differ (see [Pre-Shared Key](#pre-shared-key)). After any successful pairing both sides persist the new pairing record, then the server initiates an in-band [re-handshake](#re-handshake) to the newly delivered `long_term_psk`, bringing the channel to the new trust level without closing the WebSocket. This specification defines three pairing methods. Servers must implement all three; clients must implement Pairing PSK and may additionally implement either or both PIN methods. ### Methods 1. **Pairing PSK** - pairing authenticated by a [Sendspin Pairing PSK](#definitions); no PAKE round, no PIN. See [Pairing PSK Flow](#pairing-psk-flow). -2. **Dynamic PIN** - pairing with a per-session [Sendspin Pairing PIN](#definitions); the client derives the PIN from a commit-and-reveal binding to the Noise handshake and emits it via an out-channel (display, speaker, etc.) for the operator to enter into the server. See [Dynamic PIN Pairing Flow](#dynamic-pin-pairing-flow). +2. **Dynamic PIN** - pairing with a per-session [Sendspin Pairing PIN](#definitions) that the client derives from a commit-and-reveal binding to the Noise handshake and emits via an out-channel (display, speaker, etc.) for the operator to enter into the server. See [Dynamic PIN Pairing Flow](#dynamic-pin-pairing-flow). 3. **Static PIN** - pairing with a fixed [Sendspin Pairing PIN](#definitions). Appropriate for devices with no out-channel; vulnerable to MITM if the PIN is disclosed. See [Static PIN Pairing Flow](#static-pin-pairing-flow). +- **Unpaired.** Sentinel PSK; the channel is unauthenticated until the CPace round completes. The round establishes trust from scratch and produces a new [long-term PSK](#definitions). +- **Already paired.** The server moves the established connection into pairing (see [Entering and leaving pairing](#entering-and-leaving-pairing)) and runs the round over the existing long-term Sendspin PSK. + +The client reveals the new PSK only after `server_kc` verifies, and only as `wrapped_psk` [sealed under the CPace output](#psk-wrapping): a peer that cannot complete the PAKE - wrong PIN, or a man in the middle relaying between two handshakes, whose differing `h` gives each leg a different `sid` - neither triggers the reveal nor can unwrap it. + Static pairing methods (Pairing PSK, static PIN) do not take over the device's out-channel. Dynamic pairing (dynamic PIN) takes over the out-channel - typically the audio output or display - to emit the per-session PIN, so it cannot run while audio is playing on the same device. A pairing attempt that arrives while another connection is playing is rejected (see [Multiple servers](#multiple-servers-server-initiated)); the operator must stop playback before initiating pairing. Clients with a usable out-channel (display, speaker, etc.) SHOULD implement `dynamic_pin` rather than `static_pin`. `static_pin` is intended only for devices that genuinely cannot emit a per-session value. @@ -741,7 +746,7 @@ The initial static PIN MUST be device-specific (e.g., randomly generated and pri ### Entering and leaving pairing -Pairing and playback are mutually exclusive on a connection. When a server moves an established connection into pairing it first quiesces it exactly as a transition to [`available: false`](#external-source-handling) does, and sends the pairing [`server/activate`](#server--client-serveractivate) with empty `active_roles`. +Pairing and playback are mutually exclusive on a connection. When a server moves an established connection into pairing it first quiesces the client's streams - sending [`stream/end`](#server--client-streamend) for active stream roles and a [`server/state`](#server--client-serverstate) with null role objects for state roles, as when a role is removed from `active_roles` - and then sends the pairing [`server/activate`](#server--client-serveractivate) with empty `active_roles`. The quiesce is stream-only: unlike an [`available: false`](#external-source-handling) transition, the client keeps its group membership and queued group state through the pairing activity - no move to a solo group, no previous-group memory, no bar on resuming in place. Each pairing `server/activate` admits one **pairing attempt**, in progress from its first pairing message - [`client/pair-init`](#client--server-clientpair-init) (PIN methods) or [`client/pair-finalize`](#client--server-clientpair-finalize) (Pairing PSK) - until success or [`pair/abort`](#client--server-pairabort). The client bounds each attempt with an **attempt timeout** measured from its first message (recommended 2 minutes); on expiry it sends `pair/abort` with reason `attempt_timeout`. @@ -751,9 +756,11 @@ The same `server/activate` can also end a pairing attempt without finalizing: se After leaving pairing, a server silently discards pairing messages still in flight from the client - messages sent before the client observed the leave `server/activate`. A client that has aborted an attempt likewise silently discards pairing messages received before the next `server/activate`. +A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. + ### Unpaired Access -A client MAY admit a server with no pairing record to activate roles or declare the `'playback'` activity. The session's [trust level](#definitions) is `'none'`, so [management](#management) operations remain unavailable. Servers SHOULD consider their role-activation policy on such sessions in light of the MITM exposure described below. The default is the manufacturer's choice. The client's toggle is exposed at runtime via [`management/set-pairing-config`](#server--client-managementset-pairing-config), and its current setting is advertised in [`client/hello`](#client--server-clienthello) as `unpaired_access.enabled`. Servers must likewise allow their operator to enable or disable offering unpaired access; the offer is conveyed to the client through [`active_roles`](#server--client-serveractivate), not a separate flag. +A client MAY admit a server with no pairing record to activate roles or declare the `'playback'` activity. The session's [trust level](#definitions) is `'none'`, so [management](#management) operations remain unavailable. Servers SHOULD consider their role-activation policy on such sessions in light of the MITM exposure described below. The default is the manufacturer's choice. The client's toggle is exposed at runtime via [`management/set-pairing-config`](#server--client-managementset-pairing-config), and its current setting is advertised in [`client/hello`](#client--server-clienthello) as `unpaired_access.enabled`. Servers must likewise allow their operator to enable or disable offering unpaired access. The offer is conveyed to the client through [`active_roles`](#server--client-serveractivate), not a separate flag. **Security.** Unpaired playback connections are vulnerable to **man-in-the-middle attacks**. The Sentinel PSK is a published constant, and in the unpaired case neither peer's static key is bound to its identity by any authenticated out-of-band exchange; an attacker on the local network may therefore impersonate either side. The Noise handshake still provides confidentiality and replay protection for the session itself, but offers no assurance about which peer it was established with. @@ -761,6 +768,8 @@ A client MAY admit a server with no pairing record to activate roles or declare The Noise handshake completes using the Pairing PSK, authenticating both sides. The client proceeds straight to [`client/pair-finalize`](#client--server-clientpair-finalize). +**Lifecycle.** The client generates its Pairing PSK from a CSPRNG and persists it across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](#definitions)), so it can pair the client with any number of servers. The client MAY rotate it at any time, and a paired server can rotate it via [`management/set-pairing-config`](#server--client-managementset-pairing-config) (`pairing_psk.psk`); rotation invalidates previously distributed copies but leaves established pairing records untouched. + ```mermaid sequenceDiagram participant Client @@ -776,7 +785,12 @@ sequenceDiagram Note over Client,Server: Both sides persist the pairing record. Server re-handshakes to the new PSK. ``` -If a Sentinel-keyed connection is already open when the operator picks `pairing_psk`, the server first [re-handshakes](#re-handshake) to the Pairing PSK before sending the `server/activate` shown above. +If a connection is already open under any other PSK - Sentinel or a long-term [Sendspin PSK](#definitions) - when the operator picks `pairing_psk`, the server first [re-handshakes](#re-handshake) to the Pairing PSK before sending the `server/activate` shown above. + +Two standing client obligations follow from this flow: + +1. The client MUST keep its Pairing PSK among its handshake PSK candidates whenever the method is [enabled](#server--client-managementset-pairing-config), not only while a pairing activity is running: the server's re-handshake to the Pairing PSK succeeds only if the client already recognizes its `psk_id`. +2. Before sending [`client/pair-finalize`](#client--server-clientpair-finalize), the client MUST verify that the connection's matched PSK is the Pairing PSK (the receiving side of the `selected_pair_method` invariant in [`server/activate`](#server--client-serveractivate)); on mismatch it aborts with [`pair/abort`](#client--server-pairabort) reason `method_not_supported`. ### Dynamic PIN Pairing Flow @@ -787,7 +801,7 @@ sequenceDiagram participant Client participant Server - Note over Client,Server: Noise handshake completes with Sentinel PSK + Note over Client,Server: Noise handshake completes (Sentinel PSK when unpaired; long-term Sendspin PSK when re-verifying a paired device) Server->>Client: server/hello (name) Client->>Server: client/hello (supported_pair_methods) @@ -795,7 +809,7 @@ sequenceDiagram Server->>Client: server/activate (activities=['pairing'], active_roles=[], selected_pair_method=dynamic_pin) Client->>Server: client/pair-init (commit_B) Server->>Client: server/pair-init (nonce_A) - Note over Client: Derive PIN from (h, nonce_B, nonce_A), emit via out-channel + Note over Client: Derive PIN from (h, nonce_A, nonce_B), emit via out-channel Note over Server: Operator enters PIN Server->>Client: server/pair-auth (pake_msg_1) Client->>Server: client/pair-auth (pake_msg_2) @@ -833,9 +847,9 @@ The hash input is the UTF-8 bytes of the literal label `"sendspin-pin-derive-v1" 1. `SHA-256("sendspin-pair-commit-v1" || nonce_B) == commit_B` 2. CPace MCF tag `client_kc` -3. `derived_PIN(h, nonce_B, nonce_A) == PIN_typed` +3. `derived_PIN(h, nonce_A, nonce_B) == PIN_typed` -A revealed `nonce_B` that does not match `commit_B` is a [protocol error](#protocol-errors). A failed key confirmation or PIN binding check results in [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. Any failure discards the received `wrapped_psk`; only when all three checks pass does the server process [`client/pair-finalize`](#client--server-clientpair-finalize), [unwrapping](#psk-wrapping) the PSK. +A revealed `nonce_B` that does not match `commit_B` is a [protocol error](#protocol-errors). A failed key confirmation or PIN binding check results in [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. Any failure discards the received `wrapped_psk`. Only when all three checks pass does the server process [`client/pair-finalize`](#client--server-clientpair-finalize), [unwrapping](#psk-wrapping) the PSK. **Device-presence verification.** When the server [leaves pairing](#entering-and-leaving-pairing) instead of finalizing, this flow doubles as a device-presence verification: the PIN is emitted through the device's own out-channel, so a successful round confirms the device on the connection is the one the operator is observing - useful on top of static pairing methods, which establish cryptographic identity but do not bind it to a specific physical device. @@ -848,7 +862,7 @@ sequenceDiagram participant Client participant Server - Note over Client,Server: Noise handshake completes (Sentinel PSK) + Note over Client,Server: Noise handshake completes (Sentinel PSK when unpaired; long-term Sendspin PSK when re-verifying a paired device) Server->>Client: server/hello (name) Client->>Server: client/hello (supported_pair_methods) @@ -871,7 +885,7 @@ sequenceDiagram **Client verification.** On receipt of [`server/pair-confirm`](#server--client-serverpair-confirm), the client verifies the CPace MCF tag `server_kc`. On failure the client sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. -**Server verification.** When [`client/pair-confirm`](#client--server-clientpair-confirm) arrives, the server verifies the CPace MCF tag `client_kc` before processing [`client/pair-finalize`](#client--server-clientpair-finalize). On failure the server sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` and discards the received `wrapped_psk`; on success it processes `client/pair-finalize`, [unwrapping](#psk-wrapping) the PSK. +**Server verification.** When [`client/pair-confirm`](#client--server-clientpair-confirm) arrives, the server verifies the CPace MCF tag `client_kc` before processing [`client/pair-finalize`](#client--server-clientpair-finalize). On failure the server sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` and discards the received `wrapped_psk`. On success it processes `client/pair-finalize`, [unwrapping](#psk-wrapping) the PSK. #### Pairing window @@ -883,7 +897,7 @@ Static PIN pairing gates each attempt on a **pairing window**: a state in which ### PAKE -The PIN pairing flows use **CPACE-X25519-SHA512** as the PAKE construction, defined in [draft-irtf-cfrg-cpace](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/). The protocol runs in initiator-responder mode with explicit Mutual Confirmation Flow (MCF). The server takes role `A` (initiator); the client takes role `B` (responder). +The PIN pairing flows use **CPACE-X25519-SHA512** as the PAKE construction, defined in [draft-irtf-cfrg-cpace-21](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/21/). The protocol runs in initiator-responder mode with explicit Mutual Confirmation Flow (MCF). The server takes role `A` (initiator); the client takes role `B` (responder). Sendspin instantiates CPace's inputs as follows: @@ -925,7 +939,7 @@ PIN-pairing brute-force protection is built around a per-method failure counter The following rules are mandatory for clients implementing `static_pin` or `dynamic_pin`: - **Per-method failure counter.** The client maintains a failure counter for each PIN-pairing method family (`static_pin` and `dynamic_pin` tracked independently). The counter is persisted across reboots. It is not partitioned by `server_id` or source IP: a single per-method counter for the device. -- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow. +- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow: the client's own verification of `server_kc` fails, or [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` arrives. No other event increments the counter. - **Reset.** The counter for a method resets to zero when that method's inner authentication succeeds. - **Terminal lockout.** When a method's counter reaches **10**, the method enters a **terminal lockout** state: the client refuses all pairing attempts for that method indefinitely. Exit requires a deliberate, local operator action (manufacturer-defined), or writing `locked_out: false` for the method via [`management/set-pairing-config`](#server--client-managementset-pairing-config) from a paired server; on successful exit the counter resets to zero. A client MAY surface the lockout to the operator through a device-local mechanism (LED, on-screen indicator, audible cue), but SHOULD NOT use a persistent indicator for it, a transient cue suffices. If a server initiates a pairing-mode connection during terminal lockout, the client sends [`pair/abort`](#client--server-pairabort) with reason `locked_out`. @@ -942,6 +956,8 @@ Each entry in `supported_pair_methods` in [`client/hello`](#client--server-clien The pairing messages below are listed in the order they appear in the dynamic PIN flow (the most complete sequence). Static PIN pairing omits the [`server/pair-init`](#server--client-serverpair-init) message and the `commit_B` / `nonce_B` fields, but still uses [`client/pair-init`](#client--server-clientpair-init) as the pairing-window-opened signal; the Pairing PSK Flow additionally omits all `pair-init`, `pair-auth`, and `pair-confirm` messages. +**Sequence violations.** A pairing message that is out of sequence for the selected method and current state - and not covered by the silent-discard rules in [Entering and leaving pairing](#entering-and-leaving-pairing) - is a [protocol error](#protocol-errors). + #### Client → Server: `client/pair-init` Starts the PIN-pairing [attempt](#entering-and-leaving-pairing). In static PIN, sent after the operator gesture opens the [pairing window](#pairing-window). In dynamic PIN, sent immediately after [`server/activate`](#server--client-serveractivate). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static PIN) or [`server/pair-init`](#server--client-serverpair-init) (dynamic PIN) before receiving this message. @@ -962,7 +978,7 @@ Upon receipt, the client validates `pin_length` against its own `min_pin_length` #### Server → Client: `server/pair-auth` -Server's CPace public share. Sent once the server has both received [`client/pair-init`](#client--server-clientpair-init) (confirming the pairing window is open) and has the PIN - i.e., once the operator has entered the PIN (static PIN: the PIN is printed and available to the operator from the start; dynamic PIN: the PIN is emitted by the client after [`server/pair-init`](#server--client-serverpair-init)). +Server's CPace public share. Sent once the server has both received [`client/pair-init`](#client--server-clientpair-init) (confirming the pairing window is open) and obtained the PIN from the operator. In static PIN the PIN is printed and available from the start; in dynamic PIN the client emits it after [`server/pair-init`](#server--client-serverpair-init). - `pake_msg_1`: string - server's CPace public share `Ya` (32 bytes base64url-encoded, 43 chars). See [PAKE](#pake) @@ -1009,11 +1025,11 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client - - `locked_out` (client) - the client is in [terminal lockout](#pin-pairing-lockout) for the selected pairing method + - `locked_out` (client) - the client is in [terminal lockout#pin-pairing-lockout) for the selected pairing method - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed - - `user_cancelled` (client or server) - operator aborted the pairing + - `user_cancelled` (client) - operator aborted the pairing through a local UI ## Management diff --git a/pairing.md b/pairing.md index 841a94e..20e8b6f 100644 --- a/pairing.md +++ b/pairing.md @@ -1,15 +1,20 @@ ## Pairing -Pairing is the one-time setup that mutually authenticates a client and a server. The pairing flow uses the same WebSocket endpoint and [`KKpsk2`](connection.md#encryption) Noise pattern as every other connection; only the PSK fed into the handshake and the client's post-handshake routing differ (see [Pre-Shared Key](connection.md#pre-shared-key)). After any successful pairing both sides persist the new pairing record, then the server initiates an in-band [re-handshake](connection.md#re-handshake) to the newly delivered PSK, bringing the channel under the new trust ceiling without closing the WebSocket. +Pairing is the one-time setup that mutually authenticates a client and a server. The pairing flow uses the same WebSocket endpoint and [`KKpsk2`](connection.md#encryption) Noise pattern as every other connection; only the PSK fed into the handshake and the client's post-handshake routing differ (see [Pre-Shared Key](connection.md#pre-shared-key)). After any successful pairing both sides persist the new pairing record, then the server initiates an in-band [re-handshake](connection.md#re-handshake) to the newly delivered `long_term_psk`, bringing the channel to the new trust level without closing the WebSocket. This specification defines three pairing methods. Servers must implement all three; clients must implement Pairing PSK and may additionally implement either or both PIN methods. ### Methods 1. **Pairing PSK** - pairing authenticated by a [Sendspin Pairing PSK](README.md#definitions); no PAKE round, no PIN. See [Pairing PSK Flow](#pairing-psk-flow). -2. **Dynamic PIN** - pairing with a per-session [Sendspin Pairing PIN](README.md#definitions); the client derives the PIN from a commit-and-reveal binding to the Noise handshake and emits it via an out-channel (display, speaker, etc.) for the operator to enter into the server. See [Dynamic PIN Pairing Flow](#dynamic-pin-pairing-flow). +2. **Dynamic PIN** - pairing with a per-session [Sendspin Pairing PIN](README.md#definitions) that the client derives from a commit-and-reveal binding to the Noise handshake and emits via an out-channel (display, speaker, etc.) for the operator to enter into the server. See [Dynamic PIN Pairing Flow](#dynamic-pin-pairing-flow). 3. **Static PIN** - pairing with a fixed [Sendspin Pairing PIN](README.md#definitions). Appropriate for devices with no out-channel; vulnerable to MITM if the PIN is disclosed. See [Static PIN Pairing Flow](#static-pin-pairing-flow). +- **Unpaired.** Sentinel PSK; the channel is unauthenticated until the CPace round completes. The round establishes trust from scratch and produces a new [long-term PSK](README.md#definitions). +- **Already paired.** The server moves the established connection into pairing (see [Entering and leaving pairing](#entering-and-leaving-pairing)) and runs the round over the existing long-term Sendspin PSK. + +The client reveals the new PSK only after `server_kc` verifies, and only as `wrapped_psk` [sealed under the CPace output](#psk-wrapping): a peer that cannot complete the PAKE - wrong PIN, or a man in the middle relaying between two handshakes, whose differing `h` gives each leg a different `sid` - neither triggers the reveal nor can unwrap it. + Static pairing methods (Pairing PSK, static PIN) do not take over the device's out-channel. Dynamic pairing (dynamic PIN) takes over the out-channel - typically the audio output or display - to emit the per-session PIN, so it cannot run while audio is playing on the same device. A pairing attempt that arrives while another connection is playing is rejected (see [Multiple servers](connection.md#multiple-servers-server-initiated)); the operator must stop playback before initiating pairing. Clients with a usable out-channel (display, speaker, etc.) SHOULD implement `dynamic_pin` rather than `static_pin`. `static_pin` is intended only for devices that genuinely cannot emit a per-session value. @@ -18,7 +23,7 @@ The initial static PIN MUST be device-specific (e.g., randomly generated and pri ### Entering and leaving pairing -Pairing and playback are mutually exclusive on a connection. When a server moves an established connection into pairing it first quiesces it exactly as a transition to [`available: false`](messaging.md#external-source-handling) does, and sends the pairing [`server/activate`](messaging.md#server--client-serveractivate) with empty `active_roles`. +Pairing and playback are mutually exclusive on a connection. When a server moves an established connection into pairing it first quiesces the client's streams - sending [`stream/end`](messaging.md#server--client-streamend) for active stream roles and a [`server/state`](messaging.md#server--client-serverstate) with null role objects for state roles, as when a role is removed from `active_roles` - and then sends the pairing [`server/activate`](messaging.md#server--client-serveractivate) with empty `active_roles`. The quiesce is stream-only: unlike an [`available: false`](messaging.md#external-source-handling) transition, the client keeps its group membership and queued group state through the pairing activity - no move to a solo group, no previous-group memory, no bar on resuming in place. Each pairing `server/activate` admits one **pairing attempt**, in progress from its first pairing message - [`client/pair-init`](#client--server-clientpair-init) (PIN methods) or [`client/pair-finalize`](#client--server-clientpair-finalize) (Pairing PSK) - until success or [`pair/abort`](#client--server-pairabort). The client bounds each attempt with an **attempt timeout** measured from its first message (recommended 2 minutes); on expiry it sends `pair/abort` with reason `attempt_timeout`. @@ -28,9 +33,11 @@ The same `server/activate` can also end a pairing attempt without finalizing: se After leaving pairing, a server silently discards pairing messages still in flight from the client - messages sent before the client observed the leave `server/activate`. A client that has aborted an attempt likewise silently discards pairing messages received before the next `server/activate`. +A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. + ### Unpaired Access -A client MAY admit a server with no pairing record to activate roles or declare the `'playback'` activity. The session's [trust level](README.md#definitions) is `'none'`, so [management](management.md#management) operations remain unavailable. Servers SHOULD consider their role-activation policy on such sessions in light of the MITM exposure described below. The default is the manufacturer's choice. The client's toggle is exposed at runtime via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config), and its current setting is advertised in [`client/hello`](messaging.md#client--server-clienthello) as `unpaired_access.enabled`. Servers must likewise allow their operator to enable or disable offering unpaired access; the offer is conveyed to the client through [`active_roles`](messaging.md#server--client-serveractivate), not a separate flag. +A client MAY admit a server with no pairing record to activate roles or declare the `'playback'` activity. The session's [trust level](README.md#definitions) is `'none'`, so [management](management.md#management) operations remain unavailable. Servers SHOULD consider their role-activation policy on such sessions in light of the MITM exposure described below. The default is the manufacturer's choice. The client's toggle is exposed at runtime via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config), and its current setting is advertised in [`client/hello`](messaging.md#client--server-clienthello) as `unpaired_access.enabled`. Servers must likewise allow their operator to enable or disable offering unpaired access. The offer is conveyed to the client through [`active_roles`](messaging.md#server--client-serveractivate), not a separate flag. **Security.** Unpaired playback connections are vulnerable to **man-in-the-middle attacks**. The Sentinel PSK is a published constant, and in the unpaired case neither peer's static key is bound to its identity by any authenticated out-of-band exchange; an attacker on the local network may therefore impersonate either side. The Noise handshake still provides confidentiality and replay protection for the session itself, but offers no assurance about which peer it was established with. @@ -38,6 +45,8 @@ A client MAY admit a server with no pairing record to activate roles or declare The Noise handshake completes using the Pairing PSK, authenticating both sides. The client proceeds straight to [`client/pair-finalize`](#client--server-clientpair-finalize). +**Lifecycle.** The client generates its Pairing PSK from a CSPRNG and persists it across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](README.md#definitions)), so it can pair the client with any number of servers. The client MAY rotate it at any time, and a paired server can rotate it via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config) (`pairing_psk.psk`); rotation invalidates previously distributed copies but leaves established pairing records untouched. + ```mermaid sequenceDiagram participant Client @@ -53,7 +62,12 @@ sequenceDiagram Note over Client,Server: Both sides persist the pairing record. Server re-handshakes to the new PSK. ``` -If a Sentinel-keyed connection is already open when the operator picks `pairing_psk`, the server first [re-handshakes](connection.md#re-handshake) to the Pairing PSK before sending the `server/activate` shown above. +If a connection is already open under any other PSK - Sentinel or a long-term [Sendspin PSK](README.md#definitions) - when the operator picks `pairing_psk`, the server first [re-handshakes](connection.md#re-handshake) to the Pairing PSK before sending the `server/activate` shown above. + +Two standing client obligations follow from this flow: + +1. The client MUST keep its Pairing PSK among its handshake PSK candidates whenever the method is [enabled](management.md#server--client-managementset-pairing-config), not only while a pairing activity is running: the server's re-handshake to the Pairing PSK succeeds only if the client already recognizes its `psk_id`. +2. Before sending [`client/pair-finalize`](#client--server-clientpair-finalize), the client MUST verify that the connection's matched PSK is the Pairing PSK (the receiving side of the `selected_pair_method` invariant in [`server/activate`](messaging.md#server--client-serveractivate)); on mismatch it aborts with [`pair/abort`](#client--server-pairabort) reason `method_not_supported`. ### Dynamic PIN Pairing Flow @@ -64,7 +78,7 @@ sequenceDiagram participant Client participant Server - Note over Client,Server: Noise handshake completes with Sentinel PSK + Note over Client,Server: Noise handshake completes (Sentinel PSK when unpaired; long-term Sendspin PSK when re-verifying a paired device) Server->>Client: server/hello (name) Client->>Server: client/hello (supported_pair_methods) @@ -72,7 +86,7 @@ sequenceDiagram Server->>Client: server/activate (activities=['pairing'], active_roles=[], selected_pair_method=dynamic_pin) Client->>Server: client/pair-init (commit_B) Server->>Client: server/pair-init (nonce_A) - Note over Client: Derive PIN from (h, nonce_B, nonce_A), emit via out-channel + Note over Client: Derive PIN from (h, nonce_A, nonce_B), emit via out-channel Note over Server: Operator enters PIN Server->>Client: server/pair-auth (pake_msg_1) Client->>Server: client/pair-auth (pake_msg_2) @@ -110,9 +124,9 @@ The hash input is the UTF-8 bytes of the literal label `"sendspin-pin-derive-v1" 1. `SHA-256("sendspin-pair-commit-v1" || nonce_B) == commit_B` 2. CPace MCF tag `client_kc` -3. `derived_PIN(h, nonce_B, nonce_A) == PIN_typed` +3. `derived_PIN(h, nonce_A, nonce_B) == PIN_typed` -A revealed `nonce_B` that does not match `commit_B` is a [protocol error](#protocol-errors). A failed key confirmation or PIN binding check results in [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. Any failure discards the received `wrapped_psk`; only when all three checks pass does the server process [`client/pair-finalize`](#client--server-clientpair-finalize), [unwrapping](#psk-wrapping) the PSK. +A revealed `nonce_B` that does not match `commit_B` is a [protocol error](#protocol-errors). A failed key confirmation or PIN binding check results in [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. Any failure discards the received `wrapped_psk`. Only when all three checks pass does the server process [`client/pair-finalize`](#client--server-clientpair-finalize), [unwrapping](#psk-wrapping) the PSK. **Device-presence verification.** When the server [leaves pairing](#entering-and-leaving-pairing) instead of finalizing, this flow doubles as a device-presence verification: the PIN is emitted through the device's own out-channel, so a successful round confirms the device on the connection is the one the operator is observing - useful on top of static pairing methods, which establish cryptographic identity but do not bind it to a specific physical device. @@ -125,7 +139,7 @@ sequenceDiagram participant Client participant Server - Note over Client,Server: Noise handshake completes (Sentinel PSK) + Note over Client,Server: Noise handshake completes (Sentinel PSK when unpaired; long-term Sendspin PSK when re-verifying a paired device) Server->>Client: server/hello (name) Client->>Server: client/hello (supported_pair_methods) @@ -148,7 +162,7 @@ sequenceDiagram **Client verification.** On receipt of [`server/pair-confirm`](#server--client-serverpair-confirm), the client verifies the CPace MCF tag `server_kc`. On failure the client sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch`. -**Server verification.** When [`client/pair-confirm`](#client--server-clientpair-confirm) arrives, the server verifies the CPace MCF tag `client_kc` before processing [`client/pair-finalize`](#client--server-clientpair-finalize). On failure the server sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` and discards the received `wrapped_psk`; on success it processes `client/pair-finalize`, [unwrapping](#psk-wrapping) the PSK. +**Server verification.** When [`client/pair-confirm`](#client--server-clientpair-confirm) arrives, the server verifies the CPace MCF tag `client_kc` before processing [`client/pair-finalize`](#client--server-clientpair-finalize). On failure the server sends [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` and discards the received `wrapped_psk`. On success it processes `client/pair-finalize`, [unwrapping](#psk-wrapping) the PSK. #### Pairing window @@ -160,7 +174,7 @@ Static PIN pairing gates each attempt on a **pairing window**: a state in which ### PAKE -The PIN pairing flows use **CPACE-X25519-SHA512** as the PAKE construction, defined in [draft-irtf-cfrg-cpace](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/). The protocol runs in initiator-responder mode with explicit Mutual Confirmation Flow (MCF). The server takes role `A` (initiator); the client takes role `B` (responder). +The PIN pairing flows use **CPACE-X25519-SHA512** as the PAKE construction, defined in [draft-irtf-cfrg-cpace-21](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/21/). The protocol runs in initiator-responder mode with explicit Mutual Confirmation Flow (MCF). The server takes role `A` (initiator); the client takes role `B` (responder). Sendspin instantiates CPace's inputs as follows: @@ -202,7 +216,7 @@ PIN-pairing brute-force protection is built around a per-method failure counter The following rules are mandatory for clients implementing `static_pin` or `dynamic_pin`: - **Per-method failure counter.** The client maintains a failure counter for each PIN-pairing method family (`static_pin` and `dynamic_pin` tracked independently). The counter is persisted across reboots. It is not partitioned by `server_id` or source IP: a single per-method counter for the device. -- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow. +- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow: the client's own verification of `server_kc` fails, or [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` arrives. No other event increments the counter. - **Reset.** The counter for a method resets to zero when that method's inner authentication succeeds. - **Terminal lockout.** When a method's counter reaches **10**, the method enters a **terminal lockout** state: the client refuses all pairing attempts for that method indefinitely. Exit requires a deliberate, local operator action (manufacturer-defined), or writing `locked_out: false` for the method via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config) from a paired server; on successful exit the counter resets to zero. A client MAY surface the lockout to the operator through a device-local mechanism (LED, on-screen indicator, audible cue), but SHOULD NOT use a persistent indicator for it, a transient cue suffices. If a server initiates a pairing-mode connection during terminal lockout, the client sends [`pair/abort`](#client--server-pairabort) with reason `locked_out`. @@ -219,6 +233,8 @@ Each entry in `supported_pair_methods` in [`client/hello`](messaging.md#client-- The pairing messages below are listed in the order they appear in the dynamic PIN flow (the most complete sequence). Static PIN pairing omits the [`server/pair-init`](#server--client-serverpair-init) message and the `commit_B` / `nonce_B` fields, but still uses [`client/pair-init`](#client--server-clientpair-init) as the pairing-window-opened signal; the Pairing PSK Flow additionally omits all `pair-init`, `pair-auth`, and `pair-confirm` messages. +**Sequence violations.** A pairing message that is out of sequence for the selected method and current state - and not covered by the silent-discard rules in [Entering and leaving pairing](#entering-and-leaving-pairing) - is a [protocol error](#protocol-errors). + #### Client → Server: `client/pair-init` Starts the PIN-pairing [attempt](#entering-and-leaving-pairing). In static PIN, sent after the operator gesture opens the [pairing window](#pairing-window). In dynamic PIN, sent immediately after [`server/activate`](messaging.md#server--client-serveractivate). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static PIN) or [`server/pair-init`](#server--client-serverpair-init) (dynamic PIN) before receiving this message. @@ -239,7 +255,7 @@ Upon receipt, the client validates `pin_length` against its own `min_pin_length` #### Server → Client: `server/pair-auth` -Server's CPace public share. Sent once the server has both received [`client/pair-init`](#client--server-clientpair-init) (confirming the pairing window is open) and has the PIN - i.e., once the operator has entered the PIN (static PIN: the PIN is printed and available to the operator from the start; dynamic PIN: the PIN is emitted by the client after [`server/pair-init`](#server--client-serverpair-init)). +Server's CPace public share. Sent once the server has both received [`client/pair-init`](#client--server-clientpair-init) (confirming the pairing window is open) and obtained the PIN from the operator. In static PIN the PIN is printed and available from the start; in dynamic PIN the client emits it after [`server/pair-init`](#server--client-serverpair-init). - `pake_msg_1`: string - server's CPace public share `Ya` (32 bytes base64url-encoded, 43 chars). See [PAKE](#pake) @@ -286,8 +302,8 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client - - `locked_out` (client) - the client is in [terminal lockout](#pin-pairing-lockout) for the selected pairing method + - `locked_out` (client) - the client is in [terminal lockout#pin-pairing-lockout) for the selected pairing method - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed - - `user_cancelled` (client or server) - operator aborted the pairing + - `user_cancelled` (client) - operator aborted the pairing through a local UI From fe8080808c90706a267446da88f12d2cd40a1935 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 21 Jul 2026 14:00:31 +0200 Subject: [PATCH 06/13] Define endianness in the visualizer role --- README.md | 2 +- roles/visualizer/v1.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d950b33..28e8a7e 100644 --- a/README.md +++ b/README.md @@ -1698,7 +1698,7 @@ Binary messages SHOULD be rejected if there is no active stream or the client is - Byte 0: message type (uint8, one of the types listed below) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when this data should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization -- Remaining bytes: data, layout per type below +- Remaining bytes: data, layout per type below; all `uint16` fields are big-endian Data whose timestamp is already in the past on arrival is dropped; stale visualization frames are never rendered. diff --git a/roles/visualizer/v1.md b/roles/visualizer/v1.md index 5c3ffd7..0f53eef 100644 --- a/roles/visualizer/v1.md +++ b/roles/visualizer/v1.md @@ -56,7 +56,7 @@ Binary messages SHOULD be rejected if there is no active stream or the client is - Byte 0: message type (uint8, one of the types listed below) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when this data should be displayed. Clients must translate this server timestamp to their local clock using the offset computed from clock synchronization -- Remaining bytes: data, layout per type below +- Remaining bytes: data, layout per type below; all `uint16` fields are big-endian Data whose timestamp is already in the past on arrival is dropped; stale visualization frames are never rendered. From 75f2cb59f151ec49f3f9eca240e4f47616895a0f Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 21 Jul 2026 16:57:24 +0200 Subject: [PATCH 07/13] Refine source role streaming and timestamp semantics Found while implementing the source role. --- README.md | 16 ++++++++++------ roles/source/v1.md | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 28e8a7e..fd7280a 100644 --- a/README.md +++ b/README.md @@ -1359,7 +1359,7 @@ The player renders decoded frames at their server timestamps translated to local ## Source messages This section describes messages specific to clients with the `source` role, which capture audio from a local input (e.g., AUX/line-in, turntable preamp, Bluetooth receiver, or microphone) and stream it to the server. Unlike other roles, a source sends audio to the server; the server remains the single place that resamples, transcodes, mixes, buffers, and distributes audio to output players. Sources stay simple: they capture and encode audio, optionally report basic signal presence (line sensing), and stream timestamped audio frames. -A device MAY implement both the `source` and `player` roles (e.g., a speaker with a local AUX input forwarded into Sendspin). +A device MAY implement both the `source` and `player` roles (e.g., a speaker with a local AUX input forwarded into Sendspin). Such a device MUST NOT play its captured input locally. Like every player, it outputs only the stream the server distributes, so its output stays in sync with the rest of the group. **Note:** The `source` role (capturing input *into* Sendspin) is distinct from a client reporting [`available: false`](#external-source-handling), which marks a client whose *output* has been taken over by a non-Sendspin system. @@ -1396,13 +1396,17 @@ The `source` object in [`server/command`](#server--client-servercommand) has thi #### Source command semantics - `command` controls whether this source streams to the server: - - `start`: server requests the source to begin streaming. The client SHOULD send `client_stream/start` and then send source audio chunks. + - `start`: server requests the source to begin streaming. The client SHOULD promptly send `client_stream/start` and then send source audio chunks. - `stop`: server requests the source to stop streaming. The client SHOULD send `client_stream/end` and stop sending source audio chunks. +Both commands are idempotent: a `start` received while the input stream is open MUST NOT restart the stream, and a `stop` received while already stopped is ignored. + #### Default streaming behavior The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. An unsolicited `client_stream/start` (received when the server has not issued `start`) is a protocol error: the server MUST NOT treat the input stream as open and should close the connection, consistent with the binary-chunk rejection rule below. +Streaming state is per-connection: a previously sent `start` does not survive reconnection, and a server that still wants the stream MUST send `command: "start"` again. + A source that supports line sensing reports `signal` in [`client/state`](#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. When the server removes `source` from [`active_roles`](#server--client-serveractivate), the client sends `client_stream/end` and stops sending chunks. @@ -1439,17 +1443,17 @@ The client ends the current input stream. After this message, no more source aud ### Client → Server: Source Audio Chunks (Binary) Binary messages SHOULD be rejected by the server if there is no open input stream (i.e., received before a `client_stream/start` or after a `client_stream/end`) or the client is not [`available`](#client--server-clientstate). -Clients MUST send `client_stream/start` before the first audio chunk. +Clients MUST send `client_stream/start` before the first audio chunk. After the server sends `command: "stop"`, chunks may keep arriving until the client processes the command and sends `client_stream/end`; servers MUST tolerate these and MAY discard them. - Byte 0: message type `12` (uint8) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample was captured - Rest of bytes: encoded audio frame -The timestamp indicates when the first audio sample in this chunk was captured (in server time domain). The server MAY resample/transcode and then distribute the audio to players with its normal buffering and synchronization strategy. +The timestamp indicates when the first audio sample in this chunk was captured (in server time domain). It is the best-effort time the audio reached the input (ADC). For codecs with encoder delay, it refers to the capture time of the first sample the decoder will emit for the chunk. The server MAY resample/transcode and then distribute the audio to players with its normal buffering and synchronization strategy. -A device that implements both `source` and `player` MUST NOT play its captured input locally. Like every player, it outputs only the stream the server distributes, so its output stays in sync with the rest of the group. +A source MUST NOT send a chunk longer than 150 ms, and SHOULD NOT send one shorter than 5 ms (the final chunk before a `client_stream/end` MAY be shorter). After a network stall, clients SHOULD drop buffered backlog beyond a small bound and resume from live capture rather than burst stale audio. -**Note:** Source timestamps are derived from the client's clock offset, which the time filter keeps re-estimating, so they may show discontinuities or drift (e.g., ADC clock variance). Server implementations SHOULD NOT assume perfectly continuous timestamps; the audio sample stream itself SHOULD remain continuous. +**Note:** Source timestamps are derived from the client's clock offset, which the time filter keeps re-estimating, so they may show discontinuities or drift (e.g., ADC clock variance). Server implementations SHOULD NOT assume perfectly continuous timestamps; the audio sample stream itself SHOULD remain continuous. Servers SHOULD estimate the source's effective sample rate from the delivered sample stream and use timestamps to anchor the stream in time and to detect gaps and discontinuities, not as per-chunk cut points. Servers SHOULD absorb rate deviations by resampling, keeping the correction inaudible. ## Controller messages This section describes messages specific to clients with the `controller` role, which enables the client to control the Sendspin group this client is part of, and switch between groups. diff --git a/roles/source/v1.md b/roles/source/v1.md index 2b5b1cd..66185a8 100644 --- a/roles/source/v1.md +++ b/roles/source/v1.md @@ -1,7 +1,7 @@ ## Source messages This section describes messages specific to clients with the `source` role, which capture audio from a local input (e.g., AUX/line-in, turntable preamp, Bluetooth receiver, or microphone) and stream it to the server. Unlike other roles, a source sends audio to the server; the server remains the single place that resamples, transcodes, mixes, buffers, and distributes audio to output players. Sources stay simple: they capture and encode audio, optionally report basic signal presence (line sensing), and stream timestamped audio frames. -A device MAY implement both the `source` and `player` roles (e.g., a speaker with a local AUX input forwarded into Sendspin). +A device MAY implement both the `source` and `player` roles (e.g., a speaker with a local AUX input forwarded into Sendspin). Such a device MUST NOT play its captured input locally. Like every player, it outputs only the stream the server distributes, so its output stays in sync with the rest of the group. **Note:** The `source` role (capturing input *into* Sendspin) is distinct from a client reporting [`available: false`](../../messaging.md#external-source-handling), which marks a client whose *output* has been taken over by a non-Sendspin system. @@ -38,13 +38,17 @@ The `source` object in [`server/command`](../../messaging.md#server--client-serv #### Source command semantics - `command` controls whether this source streams to the server: - - `start`: server requests the source to begin streaming. The client SHOULD send `client_stream/start` and then send source audio chunks. + - `start`: server requests the source to begin streaming. The client SHOULD promptly send `client_stream/start` and then send source audio chunks. - `stop`: server requests the source to stop streaming. The client SHOULD send `client_stream/end` and stop sending source audio chunks. +Both commands are idempotent: a `start` received while the input stream is open MUST NOT restart the stream, and a `stop` received while already stopped is ignored. + #### Default streaming behavior The default after the handshake is `stop`: a source MUST NOT stream until the server sends `command: "start"`. The server is the only party that initiates streaming. An unsolicited `client_stream/start` (received when the server has not issued `start`) is a protocol error: the server MUST NOT treat the input stream as open and should close the connection, consistent with the binary-chunk rejection rule below. +Streaming state is per-connection: a previously sent `start` does not survive reconnection, and a server that still wants the stream MUST send `command: "start"` again. + A source that supports line sensing reports `signal` in [`client/state`](../../messaging.md#client--server-clientstate). The server MAY use it as a hint for when to send `command: "start"` or `command: "stop"`, but the decision is server policy. When the server removes `source` from [`active_roles`](../../messaging.md#server--client-serveractivate), the client sends `client_stream/end` and stops sending chunks. @@ -81,14 +85,14 @@ The client ends the current input stream. After this message, no more source aud ### Client → Server: Source Audio Chunks (Binary) Binary messages SHOULD be rejected by the server if there is no open input stream (i.e., received before a `client_stream/start` or after a `client_stream/end`) or the client is not [`available`](../../messaging.md#client--server-clientstate). -Clients MUST send `client_stream/start` before the first audio chunk. +Clients MUST send `client_stream/start` before the first audio chunk. After the server sends `command: "stop"`, chunks may keep arriving until the client processes the command and sends `client_stream/end`; servers MUST tolerate these and MAY discard them. - Byte 0: message type `12` (uint8) - Bytes 1-8: timestamp (big-endian int64) - server clock time in microseconds when the first sample was captured - Rest of bytes: encoded audio frame -The timestamp indicates when the first audio sample in this chunk was captured (in server time domain). The server MAY resample/transcode and then distribute the audio to players with its normal buffering and synchronization strategy. +The timestamp indicates when the first audio sample in this chunk was captured (in server time domain). It is the best-effort time the audio reached the input (ADC). For codecs with encoder delay, it refers to the capture time of the first sample the decoder will emit for the chunk. The server MAY resample/transcode and then distribute the audio to players with its normal buffering and synchronization strategy. -A device that implements both `source` and `player` MUST NOT play its captured input locally. Like every player, it outputs only the stream the server distributes, so its output stays in sync with the rest of the group. +A source MUST NOT send a chunk longer than 150 ms, and SHOULD NOT send one shorter than 5 ms (the final chunk before a `client_stream/end` MAY be shorter). After a network stall, clients SHOULD drop buffered backlog beyond a small bound and resume from live capture rather than burst stale audio. -**Note:** Source timestamps are derived from the client's clock offset, which the time filter keeps re-estimating, so they may show discontinuities or drift (e.g., ADC clock variance). Server implementations SHOULD NOT assume perfectly continuous timestamps; the audio sample stream itself SHOULD remain continuous. +**Note:** Source timestamps are derived from the client's clock offset, which the time filter keeps re-estimating, so they may show discontinuities or drift (e.g., ADC clock variance). Server implementations SHOULD NOT assume perfectly continuous timestamps; the audio sample stream itself SHOULD remain continuous. Servers SHOULD estimate the source's effective sample rate from the delivered sample stream and use timestamps to anchor the stream in time and to detect gaps and discontinuities, not as per-chunk cut points. Servers SHOULD absorb rate deviations by resampling, keeping the correction inaudible. From bd72bd6ae9a838ef00c0d357c83593862c33153a Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 22:47:09 +0200 Subject: [PATCH 08/13] Drop the never-empty claim from supported_pair_methods --- README.md | 2 +- messaging.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd7280a..1d832e3 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ Players that can output audio should have the role `player`. - `source@v1_support?`: object - required if `source@v1` is listed, absent otherwise ([see source@v1 support object details](#client--server-clienthello-sourcev1-support-object)) - `artwork@v1_support?`: object - required if `artwork@v1` is listed, absent otherwise ([see artwork@v1 support object details](#client--server-clienthello-artworkv1-support-object)) - `visualizer@v1_support?`: object - required if `visualizer@v1` is listed, absent otherwise ([see visualizer@v1 support object details](#client--server-clienthello-visualizerv1-support-object)) -- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](#client--server-clienthello-pair-method-descriptor). Never empty: every client implements at least the Pairing PSK method (see [Pairing](#pairing)). +- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](#client--server-clienthello-pair-method-descriptor). Every client implements at least the Pairing PSK method (see [Pairing](#pairing)). - `unpaired_access`: object - whether this client currently admits [unpaired access](#unpaired-access) - `enabled`: boolean diff --git a/messaging.md b/messaging.md index 031a642..b0b5a0f 100644 --- a/messaging.md +++ b/messaging.md @@ -191,7 +191,7 @@ Players that can output audio should have the role `player`. - `source@v1_support?`: object - required if `source@v1` is listed, absent otherwise ([see source@v1 support object details](roles/source/v1.md#client--server-clienthello-sourcev1-support-object)) - `artwork@v1_support?`: object - required if `artwork@v1` is listed, absent otherwise ([see artwork@v1 support object details](roles/artwork/v1.md#client--server-clienthello-artworkv1-support-object)) - `visualizer@v1_support?`: object - required if `visualizer@v1` is listed, absent otherwise ([see visualizer@v1 support object details](roles/visualizer/v1.md#client--server-clienthello-visualizerv1-support-object)) -- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](pairing.md#client--server-clienthello-pair-method-descriptor). Never empty: every client implements at least the Pairing PSK method (see [Pairing](pairing.md#pairing)). +- `supported_pair_methods`: object[] - pairing methods this client offers, each described by a [pair-method descriptor](pairing.md#client--server-clienthello-pair-method-descriptor). Every client implements at least the Pairing PSK method (see [Pairing](pairing.md#pairing)). - `unpaired_access`: object - whether this client currently admits [unpaired access](pairing.md#unpaired-access) - `enabled`: boolean From 2f16a452d6c754d0601543f7b22fa34fdd4230cd Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 22:47:26 +0200 Subject: [PATCH 09/13] Increment the lockout counter only on the client's own failures --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d832e3..f31d34b 100644 --- a/README.md +++ b/README.md @@ -939,7 +939,7 @@ PIN-pairing brute-force protection is built around a per-method failure counter The following rules are mandatory for clients implementing `static_pin` or `dynamic_pin`: - **Per-method failure counter.** The client maintains a failure counter for each PIN-pairing method family (`static_pin` and `dynamic_pin` tracked independently). The counter is persisted across reboots. It is not partitioned by `server_id` or source IP: a single per-method counter for the device. -- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow: the client's own verification of `server_kc` fails, or [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` arrives. No other event increments the counter. +- **Increment.** The counter for a method increments on each inner-authentication failure the client itself detects in that method's flow: its own verification of `server_kc` fails. No other event increments the counter. - **Reset.** The counter for a method resets to zero when that method's inner authentication succeeds. - **Terminal lockout.** When a method's counter reaches **10**, the method enters a **terminal lockout** state: the client refuses all pairing attempts for that method indefinitely. Exit requires a deliberate, local operator action (manufacturer-defined), or writing `locked_out: false` for the method via [`management/set-pairing-config`](#server--client-managementset-pairing-config) from a paired server; on successful exit the counter resets to zero. A client MAY surface the lockout to the operator through a device-local mechanism (LED, on-screen indicator, audible cue), but SHOULD NOT use a persistent indicator for it, a transient cue suffices. If a server initiates a pairing-mode connection during terminal lockout, the client sends [`pair/abort`](#client--server-pairabort) with reason `locked_out`. diff --git a/pairing.md b/pairing.md index 20e8b6f..fd5cc75 100644 --- a/pairing.md +++ b/pairing.md @@ -216,7 +216,7 @@ PIN-pairing brute-force protection is built around a per-method failure counter The following rules are mandatory for clients implementing `static_pin` or `dynamic_pin`: - **Per-method failure counter.** The client maintains a failure counter for each PIN-pairing method family (`static_pin` and `dynamic_pin` tracked independently). The counter is persisted across reboots. It is not partitioned by `server_id` or source IP: a single per-method counter for the device. -- **Increment.** The counter for a method increments on each inner-authentication failure observed in that method's flow: the client's own verification of `server_kc` fails, or [`pair/abort`](#client--server-pairabort) with reason `pin_mismatch` arrives. No other event increments the counter. +- **Increment.** The counter for a method increments on each inner-authentication failure the client itself detects in that method's flow: its own verification of `server_kc` fails. No other event increments the counter. - **Reset.** The counter for a method resets to zero when that method's inner authentication succeeds. - **Terminal lockout.** When a method's counter reaches **10**, the method enters a **terminal lockout** state: the client refuses all pairing attempts for that method indefinitely. Exit requires a deliberate, local operator action (manufacturer-defined), or writing `locked_out: false` for the method via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config) from a paired server; on successful exit the counter resets to zero. A client MAY surface the lockout to the operator through a device-local mechanism (LED, on-screen indicator, audible cue), but SHOULD NOT use a persistent indicator for it, a transient cue suffices. If a server initiates a pairing-mode connection during terminal lockout, the client sends [`pair/abort`](#client--server-pairabort) with reason `locked_out`. From 8a7ebb1f6a3a80707c9841019557a96850364826 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 22:47:46 +0200 Subject: [PATCH 10/13] Fix the terminal lockout link in the locked_out abort reason --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f31d34b..0c3f99f 100644 --- a/README.md +++ b/README.md @@ -1025,7 +1025,7 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client - - `locked_out` (client) - the client is in [terminal lockout#pin-pairing-lockout) for the selected pairing method + - `locked_out` (client) - the client is in [terminal lockout](#pin-pairing-lockout) for the selected pairing method - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed diff --git a/pairing.md b/pairing.md index fd5cc75..ed44a9f 100644 --- a/pairing.md +++ b/pairing.md @@ -302,7 +302,7 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `reason`: string - one of: - `attempt_timeout` (client) - the pairing attempt did not complete within the [attempt timeout](#entering-and-leaving-pairing) - `concurrent_attempt` (client) - another pairing attempt is already in progress with this client - - `locked_out` (client) - the client is in [terminal lockout#pin-pairing-lockout) for the selected pairing method + - `locked_out` (client) - the client is in [terminal lockout](#pin-pairing-lockout) for the selected pairing method - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed From f9c66693b7f83219ae678bbec88c96381f6c3ad8 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 22:48:07 +0200 Subject: [PATCH 11/13] Provision or rotate the Pairing PSK only by operator or server action --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c3f99f..b7a3eb9 100644 --- a/README.md +++ b/README.md @@ -768,7 +768,7 @@ A client MAY admit a server with no pairing record to activate roles or declare The Noise handshake completes using the Pairing PSK, authenticating both sides. The client proceeds straight to [`client/pair-finalize`](#client--server-clientpair-finalize). -**Lifecycle.** The client generates its Pairing PSK from a CSPRNG and persists it across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](#definitions)), so it can pair the client with any number of servers. The client MAY rotate it at any time, and a paired server can rotate it via [`management/set-pairing-config`](#server--client-managementset-pairing-config) (`pairing_psk.psk`); rotation invalidates previously distributed copies but leaves established pairing records untouched. +**Lifecycle.** The client's Pairing PSK is generated from a CSPRNG, provisioned at manufacture or generated by the client, and persists across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](#definitions)), so it can pair the client with any number of servers. The client MUST NOT rotate it on its own; rotation happens through a deliberate local operator action (manufacturer-defined) or via [`management/set-pairing-config`](#server--client-managementset-pairing-config) (`pairing_psk.psk`) from a paired server. Rotation invalidates previously distributed copies but leaves established pairing records untouched. ```mermaid sequenceDiagram diff --git a/pairing.md b/pairing.md index ed44a9f..f3e141e 100644 --- a/pairing.md +++ b/pairing.md @@ -45,7 +45,7 @@ A client MAY admit a server with no pairing record to activate roles or declare The Noise handshake completes using the Pairing PSK, authenticating both sides. The client proceeds straight to [`client/pair-finalize`](#client--server-clientpair-finalize). -**Lifecycle.** The client generates its Pairing PSK from a CSPRNG and persists it across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](README.md#definitions)), so it can pair the client with any number of servers. The client MAY rotate it at any time, and a paired server can rotate it via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config) (`pairing_psk.psk`); rotation invalidates previously distributed copies but leaves established pairing records untouched. +**Lifecycle.** The client's Pairing PSK is generated from a CSPRNG, provisioned at manufacture or generated by the client, and persists across reboots. It is per-client and long-lived: a successful pairing does not consume or rotate it (pairing produces a separate long-term [Sendspin PSK](README.md#definitions)), so it can pair the client with any number of servers. The client MUST NOT rotate it on its own; rotation happens through a deliberate local operator action (manufacturer-defined) or via [`management/set-pairing-config`](management.md#server--client-managementset-pairing-config) (`pairing_psk.psk`) from a paired server. Rotation invalidates previously distributed copies but leaves established pairing records untouched. ```mermaid sequenceDiagram From f7ee1a7460d3146137aaef45121d8f60b1519753 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 23:00:59 +0200 Subject: [PATCH 12/13] Drop the split() mention from the re-handshake key-swap rule --- README.md | 2 +- messaging.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b7a3eb9..4f7f33b 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ A malformed inner handshake payload (not valid UTF-8 JSON of the shape above) is After both handshake messages have been exchanged, both sides switch to Noise transport mode (all subsequent messages travel as the binary Noise-ciphertext frames described above). -The same `noise/handshake` message is used for the in-band [re-handshake](#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after performing `split()` uses the new keys. +The same `noise/handshake` message is used for the in-band [re-handshake](#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after the handshake completes uses the new keys. ### Server → Client: `server/hello` diff --git a/messaging.md b/messaging.md index b0b5a0f..5ce7365 100644 --- a/messaging.md +++ b/messaging.md @@ -158,7 +158,7 @@ A malformed inner handshake payload (not valid UTF-8 JSON of the shape above) is After both handshake messages have been exchanged, both sides switch to Noise transport mode (all subsequent messages travel as the binary Noise-ciphertext frames described above). -The same `noise/handshake` message is used for the in-band [re-handshake](connection.md#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after performing `split()` uses the new keys. +The same `noise/handshake` message is used for the in-band [re-handshake](connection.md#re-handshake): the two messages then travel as ordinary encrypted JSON messages (binary frames, message type `0`), not bare Noise bytes. Noise message 2 is still encrypted under the pre-re-handshake transport keys; the first frame each side sends after the handshake completes uses the new keys. ### Server → Client: `server/hello` From 59c5184c0fc09ddb1e726fbca3d6af0438520d39 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 31 Jul 2026 09:11:50 +0200 Subject: [PATCH 13/13] Restore the server-side `user_cancelled` abort reason --- README.md | 4 ++-- pairing.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4f7f33b..bb8a698 100644 --- a/README.md +++ b/README.md @@ -756,7 +756,7 @@ The same `server/activate` can also end a pairing attempt without finalizing: se After leaving pairing, a server silently discards pairing messages still in flight from the client - messages sent before the client observed the leave `server/activate`. A client that has aborted an attempt likewise silently discards pairing messages received before the next `server/activate`. -A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. +A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). A server cancelling on operator action SHOULD first send [`pair/abort`](#client--server-pairabort) with reason `user_cancelled`, so the client can surface why the attempt ended. Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. ### Unpaired Access @@ -1029,7 +1029,7 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed - - `user_cancelled` (client) - operator aborted the pairing through a local UI + - `user_cancelled` (client or server) - operator aborted the pairing through a local UI ## Management diff --git a/pairing.md b/pairing.md index f3e141e..ce01708 100644 --- a/pairing.md +++ b/pairing.md @@ -33,7 +33,7 @@ The same `server/activate` can also end a pairing attempt without finalizing: se After leaving pairing, a server silently discards pairing messages still in flight from the client - messages sent before the client observed the leave `server/activate`. A client that has aborted an attempt likewise silently discards pairing messages received before the next `server/activate`. -A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. +A server MAY send such a cancelling `server/activate` at any point during a pairing attempt. On receipt the client abandons the attempt, discarding all pairing state, and proceeds under the declared activities; an abandoned attempt is not an inner-authentication failure and does not touch the [lockout counter](#pin-pairing-lockout). A server cancelling on operator action SHOULD first send [`pair/abort`](#client--server-pairabort) with reason `user_cancelled`, so the client can surface why the attempt ended. Servers SHOULD apply their own timeout while waiting for [`client/pair-init`](#client--server-clientpair-init) in the static-PIN flow, cancelling the attempt as above on expiry. ### Unpaired Access @@ -306,4 +306,4 @@ Aborts a pairing attempt, started or not. With reason `concurrent_attempt` the s - `method_not_supported` (client) - the server's activity set and `selected_pair_method` are not a permitted combination for the matched PSK, or `selected_pair_method` names a method the client does not currently offer - `pin_length_unacceptable` (client) - the `pin_length` in [`server/pair-init`](#server--client-serverpair-init) is below the client's `min_pin_length` or outside the 4–12 range - `pin_mismatch` (client or server) - PAKE key-confirmation failed, or (in dynamic PIN pairing) the PIN binding check failed - - `user_cancelled` (client) - operator aborted the pairing through a local UI + - `user_cancelled` (client or server) - operator aborted the pairing through a local UI