From 82604236d476b9336319f8b27e7ff4c134c306eb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Sep 2026 11:01:59 +0200 Subject: [PATCH 01/11] Rework pairing attempt gating Dynamic pairing code: replace gesture-gated escalation with a time-based backoff after 5 failures, and drop the counter's persistence across reboots - rebooting requires the same physical presence a gesture proves. Static pairing code: the window admits up to 5 attempts per gesture instead of one, so a typo no longer costs a walk to the device. The pairing window is now a static-code-only concept. Co-Authored-By: Claude Fable 5 --- README.md | 25 +++++++++++-------------- pairing.md | 25 +++++++++++-------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e344313..66df7b3 100644 --- a/README.md +++ b/README.md @@ -815,7 +815,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is gesture-gated only when the method is [escalated](#failure-counter) (see [Pairing Window](#pairing-window)). +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is delayed only while the method is in [backoff](#failure-counter). ```mermaid sequenceDiagram @@ -828,9 +828,9 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) - opt gesture-gated attempt, no window open + opt method in backoff Client->>Server: client/pair-pending - Note over Client: Operator opens pairing window + Note over Client: Backoff period elapses end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client @@ -895,12 +895,12 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failure counter -Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that escalates the method to gesture-gating (see [Pairing Window](#pairing-window)). The following rules are mandatory for clients implementing `dynamic_pairing_code`: +Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that puts the method into a time-based **backoff**. The following rules are mandatory for clients implementing `dynamic_pairing_code`: -- **Counter.** The client maintains a single failure counter for the method, persisted across reboots. It is not partitioned by `server_id` or source IP. +- **Counter.** The client maintains a single failure counter for the method. It is not partitioned by `server_id` or source IP, and does not persist across reboots. - **Increment.** The counter increments when the client starts emitting the pairing code, at most once per attempt. No other event increments it. - **Reset.** The counter resets to zero when the client's verification of `server_kc` succeeds, whether or not the attempt finalizes. -- **Escalation.** When the counter reaches **5**, the method is **escalated**: every subsequent attempt is gesture-gated until a reset de-escalates it. Escalation is not an error state - the method stays offered. +- **Backoff.** While the counter is **5** or higher, the method is in **backoff**: the client withholds [`client/pair-init`](#client--server-clientpair-init), signaling [`client/pair-pending`](#client--server-clientpair-pending), until a cooldown has elapsed since the last increment. Recommended cooldown: 1 minute when the counter reaches 5, doubling with each further failure, capped at 15 minutes. Backoff is not an error state - the method stays offered. ### Static Pairing Code Flow @@ -973,17 +973,14 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -Code-based pairing gates some attempts on a **pairing window**: a state in which the client has decided to accept one pairing attempt. The window admits exactly one attempt and closes on completion, inner-authentication failure, [`pair/abort`](#client--server-pairabort), drop of the connection carrying its attempt, operator cancellation, window-lifetime expiry, or attempt-timeout expiry. - -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - per the selected method's policy: +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth inner-authentication failure, drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -- `static_pairing_code` - every attempt. -- `dynamic_pairing_code` - only when the method is [escalated](#failure-counter). +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated; their brute-force protection is the [failure counter's backoff](#failure-counter). Pairing Window mechanics: - **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening until [`client/pair-init`](#client--server-clientpair-init) is sent. Recommended 5 minutes. On expiry, the window closes silently. +- **Window lifetime.** From window opening. Recommended 5 minutes. On expiry, the window closes silently; an attempt already in progress runs to its own end. - **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. ### PAKE @@ -1052,13 +1049,13 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the selected attempt is gesture-gated and no [pairing window](#pairing-window) is open. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending gesture to the operator. +Reports that the client is not yet ready to start the selected attempt: no [pairing window](#pairing-window) is open (static pairing code), or the method is in [backoff](#failure-counter) (dynamic pairing code). Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens or the backoff elapses. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. - `pairing_index`: integer - see [Pairing index](#messages) #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and - when the attempt is gesture-gated (see [Pairing Window](#pairing-window)) - a window is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and the client is ready: for `static_pairing_code`, a [pairing window](#pairing-window) is open; for `dynamic_pairing_code`, any [backoff](#failure-counter) has elapsed; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). diff --git a/pairing.md b/pairing.md index ae4366d..b5bcfe6 100644 --- a/pairing.md +++ b/pairing.md @@ -97,7 +97,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is gesture-gated only when the method is [escalated](#failure-counter) (see [Pairing Window](#pairing-window)). +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is delayed only while the method is in [backoff](#failure-counter). ```mermaid sequenceDiagram @@ -110,9 +110,9 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) - opt gesture-gated attempt, no window open + opt method in backoff Client->>Server: client/pair-pending - Note over Client: Operator opens pairing window + Note over Client: Backoff period elapses end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client @@ -177,12 +177,12 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failure counter -Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that escalates the method to gesture-gating (see [Pairing Window](#pairing-window)). The following rules are mandatory for clients implementing `dynamic_pairing_code`: +Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that puts the method into a time-based **backoff**. The following rules are mandatory for clients implementing `dynamic_pairing_code`: -- **Counter.** The client maintains a single failure counter for the method, persisted across reboots. It is not partitioned by `server_id` or source IP. +- **Counter.** The client maintains a single failure counter for the method. It is not partitioned by `server_id` or source IP, and does not persist across reboots. - **Increment.** The counter increments when the client starts emitting the pairing code, at most once per attempt. No other event increments it. - **Reset.** The counter resets to zero when the client's verification of `server_kc` succeeds, whether or not the attempt finalizes. -- **Escalation.** When the counter reaches **5**, the method is **escalated**: every subsequent attempt is gesture-gated until a reset de-escalates it. Escalation is not an error state - the method stays offered. +- **Backoff.** While the counter is **5** or higher, the method is in **backoff**: the client withholds [`client/pair-init`](#client--server-clientpair-init), signaling [`client/pair-pending`](#client--server-clientpair-pending), until a cooldown has elapsed since the last increment. Recommended cooldown: 1 minute when the counter reaches 5, doubling with each further failure, capped at 15 minutes. Backoff is not an error state - the method stays offered. ### Static Pairing Code Flow @@ -255,17 +255,14 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -Code-based pairing gates some attempts on a **pairing window**: a state in which the client has decided to accept one pairing attempt. The window admits exactly one attempt and closes on completion, inner-authentication failure, [`pair/abort`](#client--server-pairabort), drop of the connection carrying its attempt, operator cancellation, window-lifetime expiry, or attempt-timeout expiry. - -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - per the selected method's policy: +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth inner-authentication failure, drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -- `static_pairing_code` - every attempt. -- `dynamic_pairing_code` - only when the method is [escalated](#failure-counter). +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated; their brute-force protection is the [failure counter's backoff](#failure-counter). Pairing Window mechanics: - **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening until [`client/pair-init`](#client--server-clientpair-init) is sent. Recommended 5 minutes. On expiry, the window closes silently. +- **Window lifetime.** From window opening. Recommended 5 minutes. On expiry, the window closes silently; an attempt already in progress runs to its own end. - **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](messaging.md#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. ### PAKE @@ -334,13 +331,13 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the selected attempt is gesture-gated and no [pairing window](#pairing-window) is open. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending gesture to the operator. +Reports that the client is not yet ready to start the selected attempt: no [pairing window](#pairing-window) is open (static pairing code), or the method is in [backoff](#failure-counter) (dynamic pairing code). Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens or the backoff elapses. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. - `pairing_index`: integer - see [Pairing index](#messages) #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and - when the attempt is gesture-gated (see [Pairing Window](#pairing-window)) - a window is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and the client is ready: for `static_pairing_code`, a [pairing window](#pairing-window) is open; for `dynamic_pairing_code`, any [backoff](#failure-counter) has elapsed; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). From 30672184b5b28b9fed13088e2ef367385bd38b5c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 2 Sep 2026 16:02:42 +0200 Subject: [PATCH 02/11] Drop the dynamic-code failure counter entirely No escalation and no backoff: how a client throttles failed dynamic pairing attempts is its own concern. Co-Authored-By: Claude Fable 5.1 --- README.md | 25 ++++++------------------- pairing.md | 25 ++++++------------------- 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 66df7b3..01c64fb 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. 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling 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 does not count against a [pairing window](#pairing-window). 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling as above on expiry. ### Unpaired Access @@ -815,7 +815,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is delayed only while the method is in [backoff](#failure-counter). +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. ```mermaid sequenceDiagram @@ -828,10 +828,6 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) - opt method in backoff - Client->>Server: client/pair-pending - Note over Client: Backoff period elapses - end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client Server->>Client: digit audio clip (binary), one per digit 0-9 @@ -893,15 +889,6 @@ SP:14DQ6FY7E4XTOP9HJ5LV6Z3PO57YPD4XT6T97N5Y A failed key confirmation results in [`pair/abort`](#client--server-pairabort) with reason `pairing_code_mismatch`. A `wrapped_nonce_B` that fails to decrypt, a recovered `nonce_B` that does not match `commit_B`, or an entered code that fails the binding check is a [protocol error](#protocol-errors). 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](#wrapping) the PSK. -#### Failure counter - -Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that puts the method into a time-based **backoff**. The following rules are mandatory for clients implementing `dynamic_pairing_code`: - -- **Counter.** The client maintains a single failure counter for the method. It is not partitioned by `server_id` or source IP, and does not persist across reboots. -- **Increment.** The counter increments when the client starts emitting the pairing code, at most once per attempt. No other event increments it. -- **Reset.** The counter resets to zero when the client's verification of `server_kc` succeeds, whether or not the attempt finalizes. -- **Backoff.** While the counter is **5** or higher, the method is in **backoff**: the client withholds [`client/pair-init`](#client--server-clientpair-init), signaling [`client/pair-pending`](#client--server-clientpair-pending), until a cooldown has elapsed since the last increment. Recommended cooldown: 1 minute when the counter reaches 5, doubling with each further failure, capped at 15 minutes. Backoff is not an error state - the method stays offered. - ### Static Pairing Code Flow Pairing with a fixed pairing code. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. Every attempt is gesture-gated by a [pairing window](#pairing-window). @@ -973,9 +960,9 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth inner-authentication failure, drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated; their brute-force protection is the [failure counter's backoff](#failure-counter). +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated. Pairing Window mechanics: @@ -1049,13 +1036,13 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is not yet ready to start the selected attempt: no [pairing window](#pairing-window) is open (static pairing code), or the method is in [backoff](#failure-counter) (dynamic pairing code). Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens or the backoff elapses. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. +Reports that no [pairing window](#pairing-window) is open for the selected static-pairing-code attempt. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. - `pairing_index`: integer - see [Pairing index](#messages) #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and the client is ready: for `static_pairing_code`, a [pairing window](#pairing-window) is open; for `dynamic_pairing_code`, any [backoff](#failure-counter) has elapsed; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and - for `static_pairing_code` - a [pairing window](#pairing-window) is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). diff --git a/pairing.md b/pairing.md index b5bcfe6..34e6a2e 100644 --- a/pairing.md +++ b/pairing.md @@ -38,7 +38,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. 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling 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 does not count against a [pairing window](#pairing-window). 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling as above on expiry. ### Unpaired Access @@ -97,7 +97,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt is delayed only while the method is in [backoff](#failure-counter). +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. ```mermaid sequenceDiagram @@ -110,10 +110,6 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) - opt method in backoff - Client->>Server: client/pair-pending - Note over Client: Backoff period elapses - end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client Server->>Client: digit audio clip (binary), one per digit 0-9 @@ -175,15 +171,6 @@ SP:14DQ6FY7E4XTOP9HJ5LV6Z3PO57YPD4XT6T97N5Y A failed key confirmation results in [`pair/abort`](#client--server-pairabort) with reason `pairing_code_mismatch`. A `wrapped_nonce_B` that fails to decrypt, a recovered `nonce_B` that does not match `commit_B`, or an entered code that fails the binding check is a [protocol error](#protocol-errors). 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](#wrapping) the PSK. -#### Failure counter - -Brute-force protection for the Dynamic Pairing Code Flow is built around a failure counter that puts the method into a time-based **backoff**. The following rules are mandatory for clients implementing `dynamic_pairing_code`: - -- **Counter.** The client maintains a single failure counter for the method. It is not partitioned by `server_id` or source IP, and does not persist across reboots. -- **Increment.** The counter increments when the client starts emitting the pairing code, at most once per attempt. No other event increments it. -- **Reset.** The counter resets to zero when the client's verification of `server_kc` succeeds, whether or not the attempt finalizes. -- **Backoff.** While the counter is **5** or higher, the method is in **backoff**: the client withholds [`client/pair-init`](#client--server-clientpair-init), signaling [`client/pair-pending`](#client--server-clientpair-pending), until a cooldown has elapsed since the last increment. Recommended cooldown: 1 minute when the counter reaches 5, doubling with each further failure, capped at 15 minutes. Backoff is not an error state - the method stays offered. - ### Static Pairing Code Flow Pairing with a fixed pairing code. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. Every attempt is gesture-gated by a [pairing window](#pairing-window). @@ -255,9 +242,9 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth inner-authentication failure, drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated; their brute-force protection is the [failure counter's backoff](#failure-counter). +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated. Pairing Window mechanics: @@ -331,13 +318,13 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is not yet ready to start the selected attempt: no [pairing window](#pairing-window) is open (static pairing code), or the method is in [backoff](#failure-counter) (dynamic pairing code). Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens or the backoff elapses. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. +Reports that no [pairing window](#pairing-window) is open for the selected static-pairing-code attempt. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. - `pairing_index`: integer - see [Pairing index](#messages) #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and the client is ready: for `static_pairing_code`, a [pairing window](#pairing-window) is open; for `dynamic_pairing_code`, any [backoff](#failure-counter) has elapsed; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and - for `static_pairing_code` - a [pairing window](#pairing-window) is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). From ce32049483c7126acb333c6adfc7c91d1ea64c19 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 2 Sep 2026 19:06:02 +0200 Subject: [PATCH 03/11] Signal held-back dynamic attempts via `client/pair-pending` --- README.md | 18 ++++++++++++++---- pairing.md | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 01c64fb..7c5342f 100644 --- a/README.md +++ b/README.md @@ -815,7 +815,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt may be [held back](#failed-attempts) before it starts. ```mermaid sequenceDiagram @@ -828,6 +828,10 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) + opt attempt held back + Client->>Server: client/pair-pending + Note over Client: Cooldown elapses or operator acts on the device + end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client Server->>Client: digit audio clip (binary), one per digit 0-9 @@ -889,6 +893,10 @@ SP:14DQ6FY7E4XTOP9HJ5LV6Z3PO57YPD4XT6T97N5Y A failed key confirmation results in [`pair/abort`](#client--server-pairabort) with reason `pairing_code_mismatch`. A `wrapped_nonce_B` that fails to decrypt, a recovered `nonce_B` that does not match `commit_B`, or an entered code that fails the binding check is a [protocol error](#protocol-errors). 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](#wrapping) the PSK. +#### Failed attempts + +How a client limits failed attempts is implementation-defined: it MAY hold attempts until an operator acts on the device, apply a cooldown after repeated failures, or both. Clients SHOULD apply some limit; a 6-digit code withstands guessing only as long as attempts cannot fail at wire speed. Recommended cooldown: after 5 consecutive failed attempts, counted from the code's emission and reset by a successful `server_kc` verification, 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). + ### Static Pairing Code Flow Pairing with a fixed pairing code. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. Every attempt is gesture-gated by a [pairing window](#pairing-window). @@ -962,7 +970,7 @@ A decoder MUST reject malformed input, including a payload shorter than its vers The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated. +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). Pairing Window mechanics: @@ -1036,13 +1044,15 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that no [pairing window](#pairing-window) is open for the selected static-pairing-code attempt. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. - `pairing_index`: integer - see [Pairing index](#messages) +- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an action on the device is needed +- `message?`: string - a short plain-text sentence for the operator, such as what to do on the device, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and - for `static_pairing_code` - a [pairing window](#pairing-window) is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](#server--client-serveractivate) has arrived and the client is not [holding the attempt back](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). diff --git a/pairing.md b/pairing.md index 34e6a2e..f9f8338 100644 --- a/pairing.md +++ b/pairing.md @@ -97,7 +97,7 @@ SP:0AAAQEAYEAUDAOCAJBIFQYDIOB4IBCEQTCQKRMFYYDENBWHA5DYP6BYPC4PSOLZXH5DU6V97M5XXO ### Dynamic Pairing Code Flow -Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. +Pairing with a per-session pairing code derived from the Noise handshake and emitted by the client via its out-channel, in one of two **emission formats** (the activation's [`format`](messaging.md#server--client-serveractivate)): `digits` - a decimal code the operator types into the server - or `qr_code` - a code rendered as a QR code that the operator scans into the server. Either way, a [PAKE](#pake) round authenticates both sides. An attempt may be [held back](#failed-attempts) before it starts. ```mermaid sequenceDiagram @@ -110,6 +110,10 @@ sequenceDiagram Client->>Server: client/hello (supported_pair_methods) Note over Server: Operator picks dynamic pairing code Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) + opt attempt held back + Client->>Server: client/pair-pending + Note over Client: Cooldown elapses or operator acts on the device + end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client Server->>Client: digit audio clip (binary), one per digit 0-9 @@ -171,6 +175,10 @@ SP:14DQ6FY7E4XTOP9HJ5LV6Z3PO57YPD4XT6T97N5Y A failed key confirmation results in [`pair/abort`](#client--server-pairabort) with reason `pairing_code_mismatch`. A `wrapped_nonce_B` that fails to decrypt, a recovered `nonce_B` that does not match `commit_B`, or an entered code that fails the binding check is a [protocol error](#protocol-errors). 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](#wrapping) the PSK. +#### Failed attempts + +How a client limits failed attempts is implementation-defined: it MAY hold attempts until an operator acts on the device, apply a cooldown after repeated failures, or both. Clients SHOULD apply some limit; a 6-digit code withstands guessing only as long as attempts cannot fail at wire speed. Recommended cooldown: after 5 consecutive failed attempts, counted from the code's emission and reset by a successful `server_kc` verification, 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). + ### Static Pairing Code Flow Pairing with a fixed pairing code. The operator types it into the server, where a [PAKE](#pake) round authenticates both sides. Every attempt is gesture-gated by a [pairing window](#pairing-window). @@ -244,7 +252,7 @@ A decoder MUST reject malformed input, including a payload shorter than its vers The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are never gesture-gated. +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). Pairing Window mechanics: @@ -318,13 +326,15 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that no [pairing window](#pairing-window) is open for the selected static-pairing-code attempt. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once a window opens. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. - `pairing_index`: integer - see [Pairing index](#messages) +- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an action on the device is needed +- `message?`: string - a short plain-text sentence for the operator, such as what to do on the device, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` -Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and - for `static_pairing_code` - a [pairing window](#pairing-window) is open; otherwise immediately. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. +Starts the code-based pairing [attempt](#entering-and-leaving-pairing). Sent once the pairing [`server/activate`](messaging.md#server--client-serveractivate) has arrived and the client is not [holding the attempt back](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) (static pairing code) or [`server/pair-init`](#server--client-serverpair-init) and the [digit audio clips](#server--client-digit-audio-clip-binary) (dynamic pairing code) before receiving this message. - `pairing_index`: integer - see [Pairing index](#messages); only a match starts the attempt - `commit_B?`: string - `SHA-256("sendspin-pair-commit-v1" || nonce_B)` (32 bytes base64url-encoded, 43 chars). Required in the [Dynamic Pairing Code Flow](#dynamic-pairing-code-flow); absent in the [Static Pairing Code Flow](#static-pairing-code-flow). From 16eff7b6ff9b613ee7642e8a6a9017f711e0e832 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 09:17:32 +0200 Subject: [PATCH 04/11] Clarify pairing window closing rules and failed-attempt counting --- README.md | 8 ++++---- pairing.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7c5342f..39cacda 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined: it MAY hold attempts until an operator acts on the device, apply a cooldown after repeated failures, or both. Clients SHOULD apply some limit; a 6-digit code withstands guessing only as long as attempts cannot fail at wire speed. Recommended cooldown: after 5 consecutive failed attempts, counted from the code's emission and reset by a successful `server_kc` verification, 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until an operator acts on the device or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, hold further attempts until an operator acts on the device where the device has a suitable gesture, otherwise a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow @@ -968,15 +968,15 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). Pairing Window mechanics: - **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening. Recommended 5 minutes. On expiry, the window closes silently; an attempt already in progress runs to its own end. -- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. +- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. +- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. ### PAKE diff --git a/pairing.md b/pairing.md index f9f8338..8cc3702 100644 --- a/pairing.md +++ b/pairing.md @@ -177,7 +177,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined: it MAY hold attempts until an operator acts on the device, apply a cooldown after repeated failures, or both. Clients SHOULD apply some limit; a 6-digit code withstands guessing only as long as attempts cannot fail at wire speed. Recommended cooldown: after 5 consecutive failed attempts, counted from the code's emission and reset by a successful `server_kc` verification, 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until an operator acts on the device or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, hold further attempts until an operator acts on the device where the device has a suitable gesture, otherwise a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow @@ -250,15 +250,15 @@ A decoder MUST reject malformed input, including a payload shorter than its vers ### Pairing Window -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of the connection carrying its attempts, operator cancellation, or window-lifetime expiry. An aborted or timed-out attempt only ends that attempt; the window stays open for another. +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). Pairing Window mechanics: - **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening. Recommended 5 minutes. On expiry, the window closes silently; an attempt already in progress runs to its own end. -- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](messaging.md#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending). The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. +- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. +- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](messaging.md#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. ### PAKE From a8167380c0edea890aa34c96868da9f594582b19 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 09:54:19 +0200 Subject: [PATCH 05/11] Recommend cooldown first, operator action after ten failures --- README.md | 8 ++++---- pairing.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 39cacda..6325ed1 100644 --- a/README.md +++ b/README.md @@ -830,7 +830,7 @@ sequenceDiagram Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) opt attempt held back Client->>Server: client/pair-pending - Note over Client: Cooldown elapses or operator acts on the device + Note over Client: Cooldown elapses or operator acts end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client @@ -895,7 +895,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until an operator acts on the device or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, hold further attempts until an operator acts on the device where the device has a suitable gesture, otherwise a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow @@ -1047,8 +1047,8 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. - `pairing_index`: integer - see [Pairing index](#messages) -- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an action on the device is needed -- `message?`: string - a short plain-text sentence for the operator, such as what to do on the device, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it +- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed +- `message?`: string - a short plain-text sentence for the operator, such as what to do to proceed, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` diff --git a/pairing.md b/pairing.md index 8cc3702..edb93c3 100644 --- a/pairing.md +++ b/pairing.md @@ -112,7 +112,7 @@ sequenceDiagram Server->>Client: server/activate (activities=['pairing'], active_roles=[], pairing={method: dynamic_pairing_code}) opt attempt held back Client->>Server: client/pair-pending - Note over Client: Cooldown elapses or operator acts on the device + Note over Client: Cooldown elapses or operator acts end Client->>Server: client/pair-init (commit_B) opt digits attempt, speaker client @@ -177,7 +177,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until an operator acts on the device or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, hold further attempts until an operator acts on the device where the device has a suitable gesture, otherwise a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow @@ -329,8 +329,8 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. - `pairing_index`: integer - see [Pairing index](#messages) -- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an action on the device is needed -- `message?`: string - a short plain-text sentence for the operator, such as what to do on the device, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it +- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed +- `message?`: string - a short plain-text sentence for the operator, such as what to do to proceed, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` From faa2c6581f20623d91529f7e895336c6abdc854f Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 10:09:43 +0200 Subject: [PATCH 06/11] Soften the failed-attempt limit wording to match or exceed --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6325ed1..f0f8f7d 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow diff --git a/pairing.md b/pairing.md index edb93c3..bb7eee9 100644 --- a/pairing.md +++ b/pairing.md @@ -177,7 +177,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, but clients SHOULD limit them at least as strictly as the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow From dacb2bcc30dce46c54199b26cae8f96eebf07b62 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 10:12:09 +0200 Subject: [PATCH 07/11] Make the post-cooldown operator action optional --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f0f8f7d..045095c 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. Where such an action is available, the client MAY additionally hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow diff --git a/pairing.md b/pairing.md index bb7eee9..e7d49aa 100644 --- a/pairing.md +++ b/pairing.md @@ -177,7 +177,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes; where such an action is available, hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. Where such an action is available, the client MAY additionally hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). ### Static Pairing Code Flow From 3a4f30e160aab3f310989b00ff5d36c854f9f054 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 14:08:43 +0200 Subject: [PATCH 08/11] Make the dynamic attempt limit a MUST with a 20-attempt ceiling As written a client with no limit at all is conformant, a server that aborts before the code finishes playing is never counted, and a count kept per server is bypassed by rotating keys. After 20 consecutive failed attempts the client now MUST hold attempts back until a deliberate operator action, with a power cycle qualifying so no device is locked out. Counting starts when emission starts and the count is global. The cooldown schedule is dropped: how a client paces attempts before the limit is its own choice, reported through `retry_after_ms` and `message`. Also caps `message` at 200 characters and lets a server cancel instead of outlasting `retry_after_ms`. --- README.md | 10 ++++++---- pairing.md | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 045095c..09cee73 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 does not count against a [pairing window](#pairing-window). 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling 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 does not count against a [pairing window](#pairing-window), and counts as a [failed attempt](#failed-attempts) only when the code was already being emitted. 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling as above on expiry. ### Unpaired Access @@ -895,7 +895,9 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. Where such an action is available, the client MAY additionally hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. + +The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), with `retry_after_ms` for a cooldown and optionally the operator action named in `message`. ### Static Pairing Code Flow @@ -1044,11 +1046,11 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)); when `retry_after_ms` is present it SHOULD either outlast it or cancel the attempt and show the operator when to retry. - `pairing_index`: integer - see [Pairing index](#messages) - `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed -- `message?`: string - a short plain-text sentence for the operator, such as what to do to proceed, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it +- `message?`: string - a short plain-text sentence for the operator, at most 200 characters, such as what to do to proceed, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device, truncates it to that length, and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` diff --git a/pairing.md b/pairing.md index e7d49aa..879ba1d 100644 --- a/pairing.md +++ b/pairing.md @@ -38,7 +38,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 does not count against a [pairing window](#pairing-window). 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling 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 does not count against a [pairing window](#pairing-window), and counts as a [failed attempt](#failed-attempts) only when the code was already being emitted. 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 the attempt's first pairing message - [`client/pair-init`](#client--server-clientpair-init) or, in the Pairing PSK Flow, [`client/pair-finalize`](#client--server-clientpair-finalize) - cancelling as above on expiry. ### Unpaired Access @@ -177,7 +177,9 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -How a client limits failed attempts is implementation-defined, and clients SHOULD match or exceed the recommended limit below, whether by holding attempts until a manufacturer-defined operator action, as for a [pairing window](#pairing-window), or by a cooldown. An attempt counts as failed once the client has emitted the code and the attempt ends without a successful verification of `server_kc`; a successful verification resets the count. Recommended limit: after 5 consecutive failed attempts, a cooldown of 1 minute before the next attempt, doubling with each further failure, capped at 15 minutes. Where such an action is available, the client MAY additionally hold attempts from the tenth consecutive failure until it occurs. A limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending). +An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. + +The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), with `retry_after_ms` for a cooldown and optionally the operator action named in `message`. ### Static Pairing Code Flow @@ -326,11 +328,11 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)), which SHOULD outlast `retry_after_ms`. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)); when `retry_after_ms` is present it SHOULD either outlast it or cancel the attempt and show the operator when to retry. - `pairing_index`: integer - see [Pairing index](#messages) - `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed -- `message?`: string - a short plain-text sentence for the operator, such as what to do to proceed, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device and MUST NOT interpret markup or links in it +- `message?`: string - a short plain-text sentence for the operator, at most 200 characters, such as what to do to proceed, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device, truncates it to that length, and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` From 38c14017ab980d7fcb112981a3aa4d2765eb7fee Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 16:45:22 +0200 Subject: [PATCH 09/11] Move the pairing window under the Static Pairing Code Flow --- README.md | 24 ++++++++++++------------ pairing.md | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 09cee73..4e05140 100644 --- a/README.md +++ b/README.md @@ -938,6 +938,18 @@ sequenceDiagram **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 `pairing_code_mismatch` and discards the received `wrapped_psk`. On success it processes `client/pair-finalize`, [unwrapping](#wrapping) the PSK. +#### Pairing Window + +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. + +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. + +Pairing Window mechanics: + +- **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. +- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. +- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. + ### Pairing Code Presentation Grouping is presentation-only: the pairing code value is the contiguous digits, and separators never enter derivation, entry, or `PRS`. The 6-digit dynamic pairing code SHOULD be presented grouped `3-3`, the 8-digit static pairing code `4-4`, with a hyphen between groups (`123-456`, `1234-5678`). Servers SHOULD present matching grouped entry that makes the expected length evident (e.g. one slot per digit) and SHOULD strip separator characters (spaces, hyphens) from typed input. @@ -968,18 +980,6 @@ Decoding reverses the transform and MUST be lenient with operator-supplied input A decoder MUST reject malformed input, including a payload shorter than its version defines. Payload bytes beyond those the version defines are reserved for future extension: a decoder MUST ignore them. -### Pairing Window - -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. - -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). - -Pairing Window mechanics: - -- **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. -- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. - ### PAKE The code-based 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). diff --git a/pairing.md b/pairing.md index 879ba1d..65637b7 100644 --- a/pairing.md +++ b/pairing.md @@ -220,6 +220,18 @@ sequenceDiagram **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 `pairing_code_mismatch` and discards the received `wrapped_psk`. On success it processes `client/pair-finalize`, [unwrapping](#wrapping) the PSK. +#### Pairing Window + +The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. + +An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. + +Pairing Window mechanics: + +- **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. +- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. +- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](messaging.md#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. + ### Pairing Code Presentation Grouping is presentation-only: the pairing code value is the contiguous digits, and separators never enter derivation, entry, or `PRS`. The 6-digit dynamic pairing code SHOULD be presented grouped `3-3`, the 8-digit static pairing code `4-4`, with a hyphen between groups (`123-456`, `1234-5678`). Servers SHOULD present matching grouped entry that makes the expected length evident (e.g. one slot per digit) and SHOULD strip separator characters (spaces, hyphens) from typed input. @@ -250,18 +262,6 @@ Decoding reverses the transform and MUST be lenient with operator-supplied input A decoder MUST reject malformed input, including a payload shorter than its version defines. Payload bytes beyond those the version defines are reserved for future extension: a decoder MUST ignore them. -### Pairing Window - -The Static Pairing Code Flow gates every attempt on a **pairing window**: a state in which the client has decided to accept pairing attempts. The window admits up to **5** attempts, all on the connection that carries its first, and closes on a completed pairing, its fifth failed attempt (the client's verification of `server_kc` fails), drop of that connection, operator cancellation, or window-lifetime expiry. An attempt that ends any other way - timed out or cancelled - only ends that attempt; the window stays open for another. - -An attempt is **gesture-gated** - the client withholds [`client/pair-init`](#client--server-clientpair-init) until a window is open - for every `static_pairing_code` attempt. Dynamic Pairing Code attempts are gated only by the client's [attempt limit](#failed-attempts). - -Pairing Window mechanics: - -- **Opening the window.** An operator gesture on the client - a physical button press, a reset-pinhole press, a button combo, a specific power-cycle pattern, a shake or motion gesture, or any equivalent implementation-defined action. Gestures SHOULD be deliberate and hard to induce remotely. -- **Window lifetime.** From window opening, paused while an attempt is in progress. Recommended 5 minutes. On expiry, the window closes silently. -- **Signal to the server.** The client sends [`client/pair-init`](#client--server-clientpair-init) once the window is open and the [`server/activate`](messaging.md#server--client-serveractivate) has arrived; while a gesture is awaited it signals [`client/pair-pending`](#client--server-clientpair-pending), optionally naming the gesture in `message`. The server must not send [`server/pair-auth`](#server--client-serverpair-auth) until it has received `client/pair-init`. - ### PAKE The code-based 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). From 6de353515879bd4632982920c484ec1dac920d9e Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 17:30:13 +0200 Subject: [PATCH 10/11] Drop `retry_after_ms` from `client/pair-pending` With no recommended cooldown and a hard stop at 20 attempts the duration served no security purpose, and `message` already tells the operator what the client waits for. --- README.md | 5 ++--- pairing.md | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4e05140..05eeda2 100644 --- a/README.md +++ b/README.md @@ -897,7 +897,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. -The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), with `retry_after_ms` for a cooldown and optionally the operator action named in `message`. +The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), optionally saying in `message` what it waits for. ### Static Pairing Code Flow @@ -1046,10 +1046,9 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)); when `retry_after_ms` is present it SHOULD either outlast it or cancel the attempt and show the operator when to retry. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state and any `message` to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)). - `pairing_index`: integer - see [Pairing index](#messages) -- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed - `message?`: string - a short plain-text sentence for the operator, at most 200 characters, such as what to do to proceed, preferably in one of the server's [`languages`](#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device, truncates it to that length, and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` diff --git a/pairing.md b/pairing.md index 65637b7..64acd3d 100644 --- a/pairing.md +++ b/pairing.md @@ -179,7 +179,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. -The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), with `retry_after_ms` for a cooldown and optionally the operator action named in `message`. +The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), optionally saying in `message` what it waits for. ### Static Pairing Code Flow @@ -328,10 +328,9 @@ The pairing messages below are listed in the order they appear in the Dynamic Pa #### Client → Server: `client/pair-pending` -Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)); when `retry_after_ms` is present it SHOULD either outlast it or cancel the attempt and show the operator when to retry. +Reports that the client is holding back the selected attempt: no [pairing window](#pairing-window) is open, or its [attempt limit](#failed-attempts) has not admitted it yet. Sent immediately on receiving such a pairing [`server/activate`](messaging.md#server--client-serveractivate); [`client/pair-init`](#client--server-clientpair-init) follows once the client is ready. Does not start the [attempt](#entering-and-leaving-pairing) or its timeout. The server SHOULD surface the pending state and any `message` to the operator and apply its own timeout (see [Entering and leaving pairing](#entering-and-leaving-pairing)). - `pairing_index`: integer - see [Pairing index](#messages) -- `retry_after_ms?`: integer - the client will be ready in this many milliseconds without operator action; absent when an operator action is needed - `message?`: string - a short plain-text sentence for the operator, at most 200 characters, such as what to do to proceed, preferably in one of the server's [`languages`](messaging.md#server--client-serverhello). It comes from an unauthenticated peer: the server shows it as text attributed to the device, truncates it to that length, and MUST NOT interpret markup or links in it #### Client → Server: `client/pair-init` From a8bbc45d462d800e450d495177172de0302d8825 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 3 Sep 2026 17:30:13 +0200 Subject: [PATCH 11/11] Drop the operator-action examples from the dynamic attempt limit --- README.md | 2 +- pairing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 05eeda2..07a4341 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. +An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), optionally saying in `message` what it waits for. diff --git a/pairing.md b/pairing.md index 64acd3d..3f7088e 100644 --- a/pairing.md +++ b/pairing.md @@ -177,7 +177,7 @@ A failed key confirmation results in [`pair/abort`](#client--server-pairabort) w #### Failed attempts -An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action, such as a gesture as for a [pairing window](#pairing-window), an action in the manufacturer's own app, or a power cycle. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. +An attempt counts as failed once the client has started emitting the code and the attempt ends without a successful verification of `server_kc`. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action. The count is not partitioned by `server_id` or source address and resets on a successful verification or on that action. A client MAY hold attempts back earlier, by a cooldown or by an operator action, including from the first attempt. The limit is not an error state - the method stays offered - and while it holds an attempt back the client sends [`client/pair-pending`](#client--server-clientpair-pending), optionally saying in `message` what it waits for.