From 5c0ddf00c705ee8ddeb187d60ba12c7cfe14c266 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 22 Jul 2026 09:41:04 +0200 Subject: [PATCH 1/5] Define the pairing token format --- README.md | 40 +++++++++++++++++++++++++++++++++++++++- pairing.md | 38 ++++++++++++++++++++++++++++++++++++++ template.md | 2 +- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f97e625..07b7a7f 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ sequenceDiagram - **Sendspin Stream** - client-specific details on how the server is formatting and sending binary data. Each role's stream is managed separately. Each client receives its own independently encoded stream based on its capabilities and preferences. For players, the server sends audio chunks as far ahead as the client's buffer capacity allows. For artwork clients, the server sends album artwork and other visual images through the stream - **Sendspin Identity** - a Curve25519 keypair used to identify a client or server in the [Noise](#encryption) handshake. The base64url-encoded public key (43 characters, no padding) serves as the `client_id` or `server_id`. Persistent across reboots - **Sendspin PSK** - a 32-byte pre-shared symmetric secret shared between a (client, server) pair, established during [pairing](#pairing) and mixed into the [Noise](#encryption) handshake state for every subsequent connection. Must be drawn from a CSPRNG or equivalent high-entropy source. -- **Sendspin Pairing PSK** - a 32-byte symmetric secret used as the PSK in the [Pairing PSK pairing method](#pairing). It is always distributed alongside the client's static public key (`client_id`), which the server needs to verify the client identity. The operator enters it into the server by copying a string or scanning a QR code. Distinct from the per-pair Sendspin PSK that pairing produces. Must be drawn from a CSPRNG or equivalent high-entropy source. +- **Sendspin Pairing PSK** - a 32-byte symmetric secret used as the PSK in the [Pairing PSK pairing method](#pairing). It is always distributed alongside the client's static public key (`client_id`), which the server needs to verify the client identity. The operator enters it into the server as a [pairing token](#pairing-token), copied as text or scanned as a QR code. Distinct from the per-pair Sendspin PSK that pairing produces. Must be drawn from a CSPRNG or equivalent high-entropy source. - **Sendspin Pairing PIN** - a decimal-digit value used in PIN-based [pairing](#pairing) methods. The static-PIN method uses a fixed 8-digit value; the dynamic-PIN method uses a per-session generated value of variable length (see [Dynamic PIN Pairing Flow](#dynamic-pin-pairing-flow)). - **Sendspin Trust Level** - one of `user` or `none`, expressing the trust the client extends to the server. Ordered `none < user`. `user` means a pairing record exists for the server; `none` means none does, restricting the server to a pairing exchange or, when [unpaired access](#unpaired-access) is enabled, normal playback and control flows. @@ -732,6 +732,44 @@ sequenceDiagram 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. +#### Pairing Token + +A server needs both the [Sendspin Pairing PSK](#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan, read aloud) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. + +A token is a fixed `SP:` prefix, a version, and a base32-encoded body: + +``` +token = "SP:" || version || body +payload = client_key (32 bytes) || pairing_psk (32 bytes) +``` + +- `version` - a single decimal digit. This document defines version `1`. +- `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](#identities). +- `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](#definitions). + +The 64-byte `payload` becomes `body` by: + +1. base32-encoding it per [RFC 4648](https://www.rfc-editor.org/rfc/rfc4648#section-6) (alphabet `A–Z`, `2–7`), +2. stripping the `=` padding, then +3. transliterating every `2` to `9`. + +A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. + +Decoding reverses the transform and MUST be lenient with operator-supplied input: + +1. Trim surrounding whitespace and upper-case it. +2. If present, strip a leading `SP:`. The first character is the `version`; reject an unrecognized version. +3. Transliterate every `9` back to `2`, re-pad with `=` to a multiple of 8 characters, and base32-decode. +4. Reject the token unless the payload is exactly 64 bytes, then split it into `client_key` and `pairing_psk`. + +A decoder MUST reject malformed input. Before pairing, the server MUST confirm the decoded `client_key` matches the `client_id` presented on the connection. + +**Reference vector.** `client_key = 0x00 0x01 … 0x1f`, `pairing_psk = 0xe0 0xe1 … 0xff`: + +``` +SP:1AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y +``` + ### Dynamic PIN Pairing Flow Pairing with a per-session PIN derived from the Noise handshake and emitted by the client via its out-channel. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. diff --git a/pairing.md b/pairing.md index 841a94e..8dc1f95 100644 --- a/pairing.md +++ b/pairing.md @@ -55,6 +55,44 @@ sequenceDiagram 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. +#### Pairing Token + +A server needs both the [Sendspin Pairing PSK](README.md#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan, read aloud) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. + +A token is a fixed `SP:` prefix, a version, and a base32-encoded body: + +``` +token = "SP:" || version || body +payload = client_key (32 bytes) || pairing_psk (32 bytes) +``` + +- `version` - a single decimal digit. This document defines version `1`. +- `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](connection.md#identities). +- `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](README.md#definitions). + +The 64-byte `payload` becomes `body` by: + +1. base32-encoding it per [RFC 4648](https://www.rfc-editor.org/rfc/rfc4648#section-6) (alphabet `A–Z`, `2–7`), +2. stripping the `=` padding, then +3. transliterating every `2` to `9`. + +A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. + +Decoding reverses the transform and MUST be lenient with operator-supplied input: + +1. Trim surrounding whitespace and upper-case it. +2. If present, strip a leading `SP:`. The first character is the `version`; reject an unrecognized version. +3. Transliterate every `9` back to `2`, re-pad with `=` to a multiple of 8 characters, and base32-decode. +4. Reject the token unless the payload is exactly 64 bytes, then split it into `client_key` and `pairing_psk`. + +A decoder MUST reject malformed input. Before pairing, the server MUST confirm the decoded `client_key` matches the `client_id` presented on the connection. + +**Reference vector.** `client_key = 0x00 0x01 … 0x1f`, `pairing_psk = 0xe0 0xe1 … 0xff`: + +``` +SP:1AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y +``` + ### Dynamic PIN Pairing Flow Pairing with a per-session PIN derived from the Noise handshake and emitted by the client via its out-channel. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. diff --git a/template.md b/template.md index 49303d2..850594a 100644 --- a/template.md +++ b/template.md @@ -101,7 +101,7 @@ sequenceDiagram - **Sendspin Stream** - client-specific details on how the server is formatting and sending binary data. Each role's stream is managed separately. Each client receives its own independently encoded stream based on its capabilities and preferences. For players, the server sends audio chunks as far ahead as the client's buffer capacity allows. For artwork clients, the server sends album artwork and other visual images through the stream - **Sendspin Identity** - a Curve25519 keypair used to identify a client or server in the [Noise](connection.md#encryption) handshake. The base64url-encoded public key (43 characters, no padding) serves as the `client_id` or `server_id`. Persistent across reboots - **Sendspin PSK** - a 32-byte pre-shared symmetric secret shared between a (client, server) pair, established during [pairing](pairing.md#pairing) and mixed into the [Noise](connection.md#encryption) handshake state for every subsequent connection. Must be drawn from a CSPRNG or equivalent high-entropy source. -- **Sendspin Pairing PSK** - a 32-byte symmetric secret used as the PSK in the [Pairing PSK pairing method](pairing.md#pairing). It is always distributed alongside the client's static public key (`client_id`), which the server needs to verify the client identity. The operator enters it into the server by copying a string or scanning a QR code. Distinct from the per-pair Sendspin PSK that pairing produces. Must be drawn from a CSPRNG or equivalent high-entropy source. +- **Sendspin Pairing PSK** - a 32-byte symmetric secret used as the PSK in the [Pairing PSK pairing method](pairing.md#pairing). It is always distributed alongside the client's static public key (`client_id`), which the server needs to verify the client identity. The operator enters it into the server as a [pairing token](pairing.md#pairing-token), copied as text or scanned as a QR code. Distinct from the per-pair Sendspin PSK that pairing produces. Must be drawn from a CSPRNG or equivalent high-entropy source. - **Sendspin Pairing PIN** - a decimal-digit value used in PIN-based [pairing](pairing.md#pairing) methods. The static-PIN method uses a fixed 8-digit value; the dynamic-PIN method uses a per-session generated value of variable length (see [Dynamic PIN Pairing Flow](pairing.md#dynamic-pin-pairing-flow)). - **Sendspin Trust Level** - one of `user` or `none`, expressing the trust the client extends to the server. Ordered `none < user`. `user` means a pairing record exists for the server; `none` means none does, restricting the server to a pairing exchange or, when [unpaired access](pairing.md#unpaired-access) is enabled, normal playback and control flows. From fe6ee5b1618131e659877134d5467137f0fa7306 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 22 Jul 2026 09:41:19 +0200 Subject: [PATCH 2/5] Define the QR code payload --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 07b7a7f..360af6d 100644 --- a/README.md +++ b/README.md @@ -753,7 +753,7 @@ The 64-byte `payload` becomes `body` by: 2. stripping the `=` padding, then 3. transliterating every `2` to `9`. -A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. +A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. Decoding reverses the transform and MUST be lenient with operator-supplied input: diff --git a/pairing.md b/pairing.md index 8dc1f95..b9d30e5 100644 --- a/pairing.md +++ b/pairing.md @@ -76,7 +76,7 @@ The 64-byte `payload` becomes `body` by: 2. stripping the `=` padding, then 3. transliterating every `2` to `9`. -A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. +A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. Decoding reverses the transform and MUST be lenient with operator-supplied input: From 0d52fa9128c34ea1e7efbd8f54534ddc3acf879a Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 23:15:08 +0200 Subject: [PATCH 3/5] Drop read-aloud as a token transfer method --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 360af6d..9ad8f68 100644 --- a/README.md +++ b/README.md @@ -734,7 +734,7 @@ If a Sentinel-keyed connection is already open when the operator picks `pairing_ #### Pairing Token -A server needs both the [Sendspin Pairing PSK](#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan, read aloud) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. +A server needs both the [Sendspin Pairing PSK](#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. A token is a fixed `SP:` prefix, a version, and a base32-encoded body: diff --git a/pairing.md b/pairing.md index b9d30e5..f1c8d40 100644 --- a/pairing.md +++ b/pairing.md @@ -57,7 +57,7 @@ If a Sentinel-keyed connection is already open when the operator picks `pairing_ #### Pairing Token -A server needs both the [Sendspin Pairing PSK](README.md#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan, read aloud) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. +A server needs both the [Sendspin Pairing PSK](README.md#definitions) and the client's static public key to select and verify the client's Noise identity. The two are distributed together as a **pairing token**: a single case-insensitive ASCII string the operator transfers out of band (copy/paste, QR scan) into the server to begin the [Pairing PSK Flow](#pairing-psk-flow). A client offering `pairing_psk` SHOULD surface the token rather than the bare PSK. A token is a fixed `SP:` prefix, a version, and a base32-encoded body: From 19982ce47757044be87804fb9d27a1bae3d1027c Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 23:15:22 +0200 Subject: [PATCH 4/5] Allow alphanumeric version characters --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9ad8f68..82018b7 100644 --- a/README.md +++ b/README.md @@ -743,7 +743,7 @@ token = "SP:" || version || body payload = client_key (32 bytes) || pairing_psk (32 bytes) ``` -- `version` - a single decimal digit. This document defines version `1`. +- `version` - a single alphanumeric character. This document defines version `1`. - `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](#identities). - `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](#definitions). diff --git a/pairing.md b/pairing.md index f1c8d40..6be5ec2 100644 --- a/pairing.md +++ b/pairing.md @@ -66,7 +66,7 @@ token = "SP:" || version || body payload = client_key (32 bytes) || pairing_psk (32 bytes) ``` -- `version` - a single decimal digit. This document defines version `1`. +- `version` - a single alphanumeric character. This document defines version `1`. - `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](connection.md#identities). - `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](README.md#definitions). From 83b08a2916f2b7dfb3281ccde8c36770ed1081c3 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 23 Jul 2026 23:16:55 +0200 Subject: [PATCH 5/5] Start token versioning at 0 --- README.md | 6 +++--- pairing.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82018b7..0563042 100644 --- a/README.md +++ b/README.md @@ -743,7 +743,7 @@ token = "SP:" || version || body payload = client_key (32 bytes) || pairing_psk (32 bytes) ``` -- `version` - a single alphanumeric character. This document defines version `1`. +- `version` - a single alphanumeric character. This document defines version `0`. - `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](#identities). - `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](#definitions). @@ -753,7 +753,7 @@ The 64-byte `payload` becomes `body` by: 2. stripping the `=` padding, then 3. transliterating every `2` to `9`. -A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. +A version-0 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. Decoding reverses the transform and MUST be lenient with operator-supplied input: @@ -767,7 +767,7 @@ A decoder MUST reject malformed input. Before pairing, the server MUST confirm t **Reference vector.** `client_key = 0x00 0x01 … 0x1f`, `pairing_psk = 0xe0 0xe1 … 0xff`: ``` -SP:1AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y +SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y ``` ### Dynamic PIN Pairing Flow diff --git a/pairing.md b/pairing.md index 6be5ec2..95f512a 100644 --- a/pairing.md +++ b/pairing.md @@ -66,7 +66,7 @@ token = "SP:" || version || body payload = client_key (32 bytes) || pairing_psk (32 bytes) ``` -- `version` - a single alphanumeric character. This document defines version `1`. +- `version` - a single alphanumeric character. This document defines version `0`. - `client_key` - the raw 32-byte Curve25519 public key whose base64url form is the [`client_id`](connection.md#identities). - `pairing_psk` - the raw 32-byte [Sendspin Pairing PSK](README.md#definitions). @@ -76,7 +76,7 @@ The 64-byte `payload` becomes `body` by: 2. stripping the `=` padding, then 3. transliterating every `2` to `9`. -A version-1 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. +A version-0 token is 107 characters drawn only from the QR code alphanumeric set (`0–9`, `A–Z`, `:`), so it renders as a compact QR code and survives manual transcription. A QR code carries the token string verbatim, with no URI scheme or wrapper, so a scan and a copy/paste yield identical input. Decoding reverses the transform and MUST be lenient with operator-supplied input: @@ -90,7 +90,7 @@ A decoder MUST reject malformed input. Before pairing, the server MUST confirm t **Reference vector.** `client_key = 0x00 0x01 … 0x1f`, `pairing_psk = 0xe0 0xe1 … 0xff`: ``` -SP:1AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y +SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO74HR6LZ7J5PW674PT6X37T6757Y ``` ### Dynamic PIN Pairing Flow