Skip to content

Require a pairing record store for the PIN methods - #167

Merged
chrisuthe merged 1 commit into
mainfrom
fix/pin-pairing-requires-record-store
Aug 12, 2026
Merged

Require a pairing record store for the PIN methods#167
chrisuthe merged 1 commit into
mainfrom
fix/pin-pairing-requires-record-store

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Closes #158.

The defect

CanOffer required an IPairingRecordStore for pairing_psk but not for dynamic_pin or static_pin. A client configured with a PIN method and no record store completed the entire PIN exchange, reached HandleServerPairFinalize, took the branch that logs "Pairing completed but no record store configured; record NOT persisted" — and then fell through and raised PairingCompleted anyway.

The server ends up holding a long-term record for a client that stored nothing. It is self-correcting on the next connection (pairing_required), but in the meantime the app has been told pairing succeeded.

Where the check goes, and why

In CanRun, not CanOffer. CanRun's own doc comment already states the rule:

implemented, enabled, and holding every dependency the method needs. A method that fails this must not be advertised in client/hello or reported enabled by get-pairing-config, or the server is told an offer exists that every attempt will refuse (#132).

A record store is a dependency in exactly that sense, and CanRun already carries _pinLockoutStore is not null for the same reason. Putting it there means an unrunnable PIN method is withheld rather than advertised-then-refused, so the server never renders pairing UX for a method that cannot work. CanOffer picks it up for free, since both PIN arms delegate to CanRun.

pairing_psk deliberately stays out of CanRun — it is mandatory, so folding it in would leave a store-less client advertising zero pair methods. The PIN methods are optional, so withholding an unusable one costs nothing.

The fall-through the issue asked about

Issue item 2 asked whether HandleServerPairFinalize's else should raise PairingCompleted at all once every method is gated. It should not, and the branch was doing more than it looked:

  • _pairingStore is null — now unreachable, since CanOffer gates all three methods on a store.
  • ServerId is null — still reachable. It comes from the Noise session, not from configuration.

Both were logging "no record store configured" (untrue for the second) and raising PairingCompleted with string.Empty. Restructured so there is one success path and it is the one that actually persisted; every other path returns without raising. ServerId is non-null by then, so the ?? string.Empty fudge goes too.

Behaviour change

A client with PIN methods and no record store stops advertising them and answers method_not_supported if asked. This is the visible change #158 flagged for its own review, and it is what accounted for 24 of the 25 initial test failures — harnesses that configured a PIN method without a store. PairingWindowEndToEndTests was checked and already supplies one, so it is not passing vacuously.

Documented in MIGRATION-10.0.0.md §3 and the NuGet README, both flagging that it fails silently: nothing throws, the method just never appears.

Verification

  • Clean dotnet build -c Release --no-incremental: 0 errors, both library TFMs.
  • Full suite: 736 passed, 0 failed (735 + 1 — count checked).
  • Discrimination proven both ways:
    • Reverting the CanRun clauses: 4 of 6 new tests fail. The 2 that pass are the WithAPairingRecordStore_IsAdvertised positive controls, which exist precisely so that a change refusing PIN methods outright cannot pass this block.
    • Reverting the HandleServerPairFinalize restructure: PairFinalize_WithAStoreButNoServerId_PersistsNothingAndDoesNotClaimSuccess fails. This one was added deliberately — the CanRun tests reach the abort and never enter the finalize handler, so without it the restructure would have been unpinned.

CanOffer required an IPairingRecordStore for pairing_psk but not for dynamic_pin
or static_pin. A client configured with a PIN method and no record store ran the
whole PIN exchange, reached HandleServerPairFinalize, logged "record NOT
persisted" - and raised PairingCompleted anyway. The server then held a long-term
record for a client that stored nothing, so the client failed to authenticate on
the next connection while the app had been told pairing succeeded.

The check goes in CanRun rather than CanOffer. CanRun already documents the rule -
"implemented, enabled, and holding every dependency the method needs... or the
server is told an offer exists that every attempt will refuse" - and already
carries the IPinLockoutStore requirement for exactly this reason. A record store
is a dependency in the same sense, so an unrunnable PIN method now also drops out
of client/hello and reports enabled: false from get-pairing-config, instead of
being advertised and then refused. pairing_psk stays out of CanRun: it is
mandatory, so withholding it would leave a store-less client advertising nothing.

HandleServerPairFinalize is restructured so the only path that raises
PairingCompleted is the one that actually persisted. The old else branch was
reachable in two ways, claimed "no record store configured" for both, and raised
the event with an empty server id. The null-store half is now unreachable; the
null-ServerId half is a degenerate peer and returns without claiming success.

Test harnesses that configured a PIN method without a record store now supply one
- that is the behaviour change, and it accounted for 24 of 25 initial failures.

New tests verified to discriminate: 4 of 6 fail with the CanRun clauses reverted
(the 2 that pass are the positive controls), and the pair-finalize test fails with
the restructure reverted. Suite 736 green, clean Release build on both TFMs.
@chrisuthe
chrisuthe merged commit ad159a1 into main Aug 12, 2026
5 checks passed
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.

PIN pairing completes without a record store, claiming a pairing the client cannot authenticate

1 participant