Skip to content

Rework pairing attempt gating - #194

Open
balloobbot wants to merge 11 commits into
Sendspin:mainfrom
balloobbot:pairing-gating-rework
Open

Rework pairing attempt gating#194
balloobbot wants to merge 11 commits into
Sendspin:mainfrom
balloobbot:pairing-gating-rework

Conversation

@balloobbot

@balloobbot balloobbot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Reworks how code-based pairing attempts are gated, from the #183 lockout discussion.

Dynamic pairing code: the persisted failure counter and its escalation to gesture-gating are gone. A failed attempt is one where the client started emitting the code and the attempt ended without a successful server_kc verification. After 20 consecutive failed attempts the client MUST hold attempts back until a deliberate, manufacturer-defined operator action; which action is the manufacturer's choice, with guidance tracked in #205. The count is global across servers and addresses and resets on success or on that action. How a client paces attempts before the limit is its own choice, and it MAY be stricter, including a gesture from the first attempt. Since any deliberate action qualifies, no device is locked out, and since a client with no limit is not conformant, manufacturers have a spec section to point a test lab at (ETSI EN 303 645 provision 5.1-5, EN 18031-1 AUM-6).

Static pairing code: the window admits up to 5 attempts instead of one, all on the connection that carries the first, so a typo no longer costs a walk to the device. It closes on a completed pairing, the fifth failed attempt, drop of that connection, operator cancellation, or lifetime expiry, and the lifetime pauses while an attempt is in progress. The pairing window is now a static-code-only concept and its section moves under the Static Pairing Code Flow.

client/pair-pending now covers both cases, a gesture awaited or a dynamic attempt held back, and gains an optional message: a plain-text sentence for the operator of at most 200 characters, preferably in one of the server's languages, shown as device text with no markup interpreted. The server SHOULD surface the pending state and the message to the operator and apply its own timeout.

An abandoned attempt does not count against a window and counts as a failed dynamic attempt only if the code was already being emitted.

@maximmaxim345 maximmaxim345 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have two security concerns with this PR, left inline comments for both of them.

Comment thread pairing.md Outdated
Comment thread pairing.md Outdated

balloob commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Two UX gaps this PR opens or leaves, from reading the combined result of #183, #189, and this branch. One UX gap this PR opens (edited: struck the parts that turned out wrong, see the note at the end).

client/pair-pending now means two different things and the server cannot tell them apart. Before, pending meant one thing: a gesture is awaited. After this change it also means "in backoff, wait up to 15 minutes". The message still carries only pairing_index, so the server has to show the same generic waiting state for "walk to the device and press the button" and "come back in twelve minutes". The interplay with the server-side timeout is the problem: the server SHOULD time out while waiting for client/pair-init, and any sane timeout is shorter than a 15-minute backoff, so "pair-init follows once the backoff elapses" will almost never happen on the activation that saw the pending. The operator sees a generic failure, retries, hits backoff again, and nothing ever says why. Suggest adding reason: 'gesture_required' | 'backoff' and, for backoff, retry_after_s (the client knows the cooldown it applied) so the server can render an actionable state and size its own timeout.

The static pairing window still dies on connection drop. The window now admits five attempts, which is the right fix for typos. But "drop of the connection carrying its attempts" still closes it, so one Wi-Fi blip mid-pairing sends the operator back to the device to press the button again. The gesture proved presence; a transport hiccup does not un-prove it. Letting the window run to its lifetime across a reconnect (the client already tracks it locally; nothing on the wire changes) would remove that walk.

Edit: the reason field is redundant - after this PR, pending on static_pairing_code always means a gesture and pending on dynamic_pairing_code always means backoff, and the server picked the method. And the window must keep closing on connection drop: static pairing runs on the Sentinel PSK, so a reconnecting server cannot be verified as the same one, and close-on-drop is what keeps the window bound to the connection that was admitted when the operator gestured. Only retry_after_s stands.

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 <[email protected]>
@balloobbot
balloobbot force-pushed the pairing-gating-rework branch from 5390ded to 8260423 Compare September 2, 2026 13:14
@marcelveldt

Copy link
Copy Markdown
Contributor

Good example of something to add to the implementation guide.

cooldown strategy should be client concern so we should not mandate it at all

No escalation and no backoff: how a client throttles failed dynamic pairing attempts is its own concern.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@marcelveldt marcelveldt added this to the 1.0.0 milestone Sep 2, 2026
@arturpragacz

arturpragacz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

The change to static pairing code to 5 attempts per window is fine.

The change to dynamic pairing code going from gesture-gating after escalation to cool down is not a good idea. We could, if anything, in light of the other changes go in the opposite direction now and gesture-gate already from the first attempt.

@maximmaxim345

Copy link
Copy Markdown
Member

The change to dynamic pairing code going from gesture-gating after escalation to cool down is not a good idea. We could, if anything, in light of the other changes go in the opposite direction now and gesture-gate already from the first attempt.

@arturpragacz was thinking about this a bit. What I'm definitely against is 3067218 in its form. That's basically dropping the constants were IMO good enough, so a client implemented to spec can be brute forced in under a day.

Gesture gating is a good idea, but forcing it onto any manufacturer also feels invasive. What I'm fearing is that we have a protocol thats secure, but with limited adoption because of that. I think there is no "best" solution here so we need to compromise somewhere...
And in addition what we also need to factor in: Its very probably that spoken digits are a niche pairing mechanism. Almost all manufacturers have their own App where they want to be in full control and we will not be able to change that.

I have another idea though that should allow security conscious devices to stay secure (or even more secure as before this PR and other recent changes), but still allows manufacturers more flexibility. What do you think about ce32049:

What if we keep client/pair-pending for the dynamic flow and add two optional fields, retry_after_ms and message? Then the client can do whatever it wants (gesture from the first attempt, require a gesture after x attempts, cooldown after 5) and the server can actually show what's going on: a countdown, or "press the button on the bottom of the device".

With the recommended cooldown from ce32049, from my calculations it's a 50% chance of getting in after ~20 years. With no limit it's about 20 ms per attempt, so a 98.7% chance of getting in within a day.
Ok, before this PR it was required to be considerably more safer, but we don't have the management activity anymore so the attack surface is also a lot smaller now.

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`.
Comment thread pairing.md Outdated
Comment thread pairing.md Outdated
Comment thread pairing.md Outdated
Comment thread pairing.md

- `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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could define this as an enum with all the ways that we envision this can be reasonably implemented.

That would both make translations easier and allow us to show some graphics, etc.

But it would be more work, and there's a risk that we miss some use cases.

@maximmaxim345 maximmaxim345 Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, but I don't think we have enough information about this. We can think of ways that might happen (like popup on screen, press play button, hold play button, hold pairing button, hold power button, ...) but thats just too many possibilities to list out.
So we would need to at least group them to something simpler like "press button", but thats just not enough information to be useful.
We could add a reason and optional message, but I don't know how we might show that in a server UI... (since the message text is actually whats useful).

For translations, it's up to the client with the language sent through server/hello. (with highly specialized messages I don't think the server could even know about pre-translated versions for all cases)
Automatically translating it is an idea but I'm starting to overthink this problem.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants