Skip to content

feat(validator): add the unpaired, pairing_psk and post-pairing suites - #164

Merged
chrisuthe merged 3 commits into
Sendspin:mainfrom
chrisuthe:chrisuthe/task/add-unpaired-pairing-token-and-post-pairing
Oct 14, 2026
Merged

chrisuthe merged 3 commits into
Sendspin:mainfrom
chrisuthe:chrisuthe/task/add-unpaired-pairing-token-and-post-pairing

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Phase 2a of sendspin-validate: the unpaired, pairing_psk and post-pairing suites (validator spec §5.3, §5.4 P1, §5.5). Part of Sendspin/backlog#84.

What it does

After the opening checks, --suite unpaired,pairing,post-pairing (default: all three, in that order) walks the user through:

Step Checks What is judged
U1 UNPAIRED-001 The first handshake completes on the Sentinel PSK
U2 UNPAIRED-002 With unpaired access off, a forced ['playback'] activation gets client/goodbye pairing_required (M:251-253)
U4 UNPAIRED-004 Opt-in via --fault-injection; N/A without it. An inadmissible activation (['playback','pairing'], method pairing_psk, Sentinel session) is rejected per the rule order
U3 UNPAIRED-003 With unpaired access on, roles activate and client/state follows
P1 PAIR-PSK-001…007 Pasted token decodes leniently and names the connected client_id; re-handshake onto pr; client/pair-init (index, no commit_B); client/pair-finalize{long_term_psk}; re-handshake to lt; the paired activation is accepted
R1 POSTPAIR-001 A paired reconnect matches the long-term PSK. R2 (stored server_id, C:121) is covered by the same check
R3 POSTPAIR-002 The validator deletes its record but keeps its identity. The client accepts the Sentinel handshake, and the validator activates nothing
R5 POSTPAIR-005, -006 After a full restart the pairing survives, and so does an output delay the validator set (only when the client offers set_output_delay and echoes it; otherwise N/A)
R4 POSTPAIR-003, -004 server/unpair gets client/goodbye unpaired. The validator then references the old record again: a client that really dropped it falls back to the Sentinel, and one that kept it matches lt and FAILs

U4 runs before U3 because it needs unpaired access off. R5 runs before R4 because it needs the pairing. Every step prompts through the Prompter (skip, retry, not sure). U2, U3 and R5 rest on something the user did, so their findings carry evidence: "human". The opening checks still judge only the first connection, but a strict-mode rejection on any of the client's connections is now reported.

aiosendspin hooks: none needed

I checked whether the spec's planned "externally driven pairing" and "forget client" patches are needed. The bundled aiosendspin 10.0.2 already covers both through public API, so this PR adds no patch:

  • Pairing driven from the validator's prompt: SendspinServer.initiate_pairing(client_id, PairingAttempt(method=PAIRING_PSK, pairing_psk=…, client_id=…)). It re-handshakes onto the pairing PSK, runs pair-init/finalize, stores the record, re-handshakes to the long-term PSK and re-activates.
  • Forget the client but keep the identity (R3), and restore the record (R4): pairing_store.remove_record / store_record.
  • Unpair (R4): SendspinServer.unpair.
  • Which PSK keyed a handshake: no frame shows this, because the Noise payload naming it is encrypted. The session reads it from ClientConnectedEvent → connection.psk_category and from ClientCredentialMismatchEvent, and records it as connection events next to the frames, so checks stay pure functions. After initiate_pairing it reads psk_category again.
  • U2/U4's forced activations: aiosendspin never sends these itself, so they go out through the public SendspinConnection.send_priority_message. That does not cover U4's pair/abort method_not_supported branch (unpaired access on). The strict server rejects a pair/abort it never activated pairing for, so that branch cannot be reached without the raw-send patch planned for phase 5.

The bundled-aiosendspin patch list therefore stays at 0001–0002. The spec's §7/§8 list still names the forget-client and pairing-driver patches; they are not needed for rc1.

Interpretation call: U4's rule order

For the U4 activation with unpaired access off, I read rc1 literally. The pairing method is part of "admissible" (M:247, M:251), and no unpaired access makes pairing_psk valid on a Sentinel session. So rule 1 (pairing_required) does not apply, and rule 2's unauthorized does. Both the bundled aiosendspin client and sendspin-cpp-cli answer pairing_required, deciding rule 1 on the activity set and roles alone. U4 FAILs that, and test_inadmissible_activation pins the verdict. Either the clients or the spec's wording of rule 1 should change.

Tests

  • tests/test_validator_suite_checks.py: every new check against hand-built frame sequences, pass and fail, including the pairing.md:97-101 token vector.
  • tests/test_validator_suites.py: --suite parsing. Also checks that the pasted token, and its PSK, are registered for redaction before anything is recorded, typos included.
  • tests/test_validator_selftest.py (runs in ci.yml): the real console script with --auto-answer --verbose, against the bundled client in both connection directions, through every suite. A scripted operator reacts to the step tags ([U2], [U3], [R5 restart]). Each run asserts zero FAIL/TOOL_ERROR, and that neither the token, the pairing PSK nor any long-term PSK appears in report.json, report.html, protocol.log.jsonl or the verbose output. Broken cases: a client that ignores server/unpair FAILs POSTPAIR-003 and -004, and the U4 case above FAILs too.
  • python -m unittest discover -s tests: 482 tests pass. scripts/vendor_aiosendspin.py --check passes. Nothing outside the validator, tests and README changed, so publish.yml does not run.

Manual run against sendspin-cpp-cli

The CLI was built from main (7501898) on macOS arm64 with -o null, server-initiated over localhost, with --fault-injection. A scripted operator answered the real terminal prompts:

  • it restarted the actual process without and then with --allow-unpaired for U2/U3;
  • it pasted sendspin-cli pair-token for P1;
  • it killed and relaunched the process on the same state directory for R5.

Result: 34 PASS, 1 FAIL, 1 N/A.

  • FAIL: UNPAIRED-004, the rule-order divergence above.
  • N/A: CORE-HELLO-007, because mDNS was not used.

Pairing, all post-pairing steps (including output-delay persistence across a real restart) and the unpair round trip pass. The run's report and log contain neither the token nor the pairing PSK.

Not run against the CLI: client-initiated mode (-s) and mDNS discovery. The self-test covers client-initiated mode against aiosendspin.

Walk a client through U1-U4, the pairing_psk walkthrough (P1) and R1-R5
after the opening checks, selected with --suite. U4 sends an activation no
client may accept and runs only with --fault-injection.

Which PSK keyed each handshake is not in any frame, so the session records
it from aiosendspin's ClientConnectedEvent and ClientCredentialMismatchEvent
as connection events beside the frames. Pairing runs through the bundled
SDK's initiate_pairing, and forgetting and unpairing through its pairing
store and unpair, so no new aiosendspin patch is needed.

The self-test now drives the bundled client through every suite in both
directions, and shows that a client ignoring server/unpair, or answering
U4 with the wrong goodbye reason, fails.
… before redialing

POSTPAIR-006 now passes only when the delay the validator commanded and
the client reported is reported again after the restart; a client whose
delay could not be set is N/A rather than passing on matching defaults.
A step waiting for the user to change a setting reconnects once and then
asks before each further attempt, instead of redialing every second. U1
puts back a pairing an earlier run left once the unpaired steps are done,
and the restart prompt carries its own [R5 restart] tag.
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.

1 participant