Judge an arriving request's freshness once it has verified (#26) - #310
Open
iderex wants to merge 3 commits into
Open
Judge an arriving request's freshness once it has verified (#26)#310iderex wants to merge 3 commits into
iderex wants to merge 3 commits into
Conversation
The freshness window and the nonce store have been in the tree since #152, and no route consulted them, so a request captured off the wire and sent again was served exactly like the first copy of it. The peer plane consults one now. What changed, in one sentence: `PeerPlane.Serve` judges freshness after the signature verifies and before the transition table answers, and a request that is stale, replayed, or arriving with no room left to remember its nonce is refused with the code the taxonomy in `docs/protocol.md` gives it. ## Where it sits, and why there Freshness is judged AFTER verification. That is the security property rather than an ordering convenience. `docs/threat-model.md` keeps one distinction on this plane deliberately - a refusal caused by clock skew says `clock` instead of reading as a signature failure - and the argument for keeping it is that the caller who sees it has already proved it holds the pairing's key, so the one bit it hands over is a bit the specification hands over anyway. Judging freshness first would hand that bit to a stranger. `replay` and `busy` sit behind the same door for the same reason. A request refused for freshness hands nothing on, though its body verified. Verification says the bytes are authentic; freshness says they are not this request. ## The one place a cause becomes a code `PeerPlane.Refuse` no longer names a code at all. It asks `RefusalCounters.CodeFor`, which is the same method the diagnostics payload sums by, so a site cannot answer one code while counting a cause that carries another. That is a stronger version of the property the old remark described, not a weaker one: the site does not choose a code. Three causes are added beside it, and each is reachable, which the theory walking every cause enforces: `timestamp-outside-the-window`, `nonce-already-seen` and `no-room-to-remember-the-nonce`. Every cause a caller without a verifying key can reach still collapses into `refused`. ## Registered once per server The window holds the nonces already seen, so a second instance remembers none of them. A per-caller window is not a weaker replay guard, it is no replay guard, and nothing about the plane's own behaviour would say so. The skew it runs on is the operator's, read through `ConfigurationReading.NewFreshnessWindow`, whose only caller until now was the test proving it worked. ## The proof Nine plants, each applied, built, run and reverted. Baseline 859 passed, 6 skipped, 0 failed. the plane stops judging freshness at all reds 13, leaves 844 a freshness refusal hands its verified body on reds 12, leaves 847 every refusal answers the undistinguished code again reds 9, leaves 848 the window is applied to the future only reds 6, leaves 853 a stale request is counted as one that did not verify reds 6, leaves 851 a replayed request is handed on instead of refused reds 5, leaves 854 freshness is judged before the signature is reds 4, leaves 853 a clock refusal is spelled as a replay to the peer reds 4, leaves 853 each caller gets a freshness window of its own reds 1, leaves 858 The second-to-last is the one worth knowing about: moving the judgement above the verification leaves the plane refusing exactly the same requests and reds only the cases about what a stranger is told, which is the whole of what the ordering buys. The last one red nothing until this change added the case that holds it. That is disclosed rather than repaired quietly: a per-caller window passes every case that drives one plane, because such a case holds one window either way. ## Cases the change moved rather than added `ADuplicatedMessageArrivesTwiceAndNothingRefusesTheSecondCopy` asserted, over the two-instance harness, that nothing refuses the second copy. It said so as the state of the protocol rather than as something it endorsed, and that state has moved; it is renamed and now asserts the replay is counted and the first copy is not. `TheAnswerIsTheSameRefusalWhateverTheCause` could not survive a taxonomy in which three causes answer their own code. It asserts the code the cause maps to instead, and the property it was really about - that a caller holding no key tells no two refusals apart - is asserted over exactly those callers in a new case rather than being dropped. Several cases sent one request repeatedly to reach the arrival limit. Under a nonce store every send after the first was a replay, so they now carry one nonce each, which is what the specification says two requests differing in nothing else must do. ## Documents Four pasted readings moved with the lines they name and are re-derived rather than adjusted by eye. Four passages said the plane consults no freshness window; each says what it said before and then says what is true now, in the register this repository uses for a claim that has stopped holding. ## Not claimed Nothing was run on a Jellyfin server. No key was written by an enrolment, no request was judged against a real clock on one, and no replay was ever presented to a running server - on a server today the key store is empty, so every request is refused before its freshness is reached and the window judges nothing. What is proved is proved against the types and against the in-process two-instance harness. Two servers with different skews are still not reconciled: each judges what arrives against its own setting, and no route here makes them agree. No case runs two threads, so the lock guarding the nonce store has still not been exercised under contention. The `net9.0` line of the suite did not run on the machine this was written on, which carries no .NET 9 runtime; only `net10.0` is measured above. The six skipped cases are the Unix-mode key store cases, skipped on Windows. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
`docs/threat-model.md` pastes the two `FreshnessOutcome` members that separate a
stale request from a replayed one, at lines 33 and 38. The change on this branch
added two lines to that file's header remark, so both moved to 35 and 40, and the
paste stopped reproducing.
git grep -n "AlreadySeen = \|OutsideTheWindow = " -- Jellyfin.Plugin.ServerPairing/Protocol/FreshnessOutcome.cs
Jellyfin.Plugin.ServerPairing/Protocol/FreshnessOutcome.cs:35: OutsideTheWindow = 2,
Jellyfin.Plugin.ServerPairing/Protocol/FreshnessOutcome.cs:40: AlreadySeen = 3,
How it was found is the part worth recording. I did grep the documents for pastes
naming the files this branch changes, and I read the result through `head -20`,
which cut the list one line above this block. So the survey was right and its
output was truncated, and `Reproduce every pasted reading` on the pull request is
what caught it - the same check could not be run to completion on this machine,
where it dies on the temporary directory it makes.
The paragraph carrying that block also said the sixth of six limits is a landed
type. It is a consulted one now, which is what this branch is about, so the
paragraph says that as well, with the same bound every other passage here carries:
no key reaches a key store, so nothing has been judged this way on a server.
sh .github/link-check.sh
link-check: 157 checked, 6 not in this mode, 0 bad.
Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
…#26) Five `cs/loss-of-precision` alerts arrived with the cases this branch added, on four sites in two files. Each is `AddSeconds` handed the result of an integer multiplication, and one of them is also an integer division whose fraction is dropped on purpose. gh api "repos/Flowfin/jellyfin-plugin-server-pairing/code-scanning/alerts?state=open&ref=refs/pull/310/merge&per_page=100" --jq '.[] | "\(.rule.id)\t\(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' cs/loss-of-precision Jellyfin.Plugin.ServerPairing.Tests/Api/RefusalCountersTests.cs:464 cs/loss-of-precision Jellyfin.Plugin.ServerPairing.Tests/Api/RefusalCountersTests.cs:464 cs/loss-of-precision Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs:622 cs/loss-of-precision Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs:614 cs/loss-of-precision Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs:541 None of the four could overflow: every factor is a compile-time constant of this specification or the 1 and -1 a theory hands in. That is an argument about these instances rather than about the shape, and the shape is what a later case copies, so the sites are written so the question cannot be asked instead of being answered once. The skew sites take their product in `double`, through one helper that says why. The fill site keeps its integer division and holds it in an `int` of its own, which is where the intent belongs: an allowance is spent every `MaximumArrivals` requests, so the window a request falls in is the floor of its position over that allowance, and written as one expression that floor reads as a fraction dropped by accident. dotnet build 0 warnings, 0 errors dotnet test -f net10.0 Bestanden! : Fehler: 0, erfolgreich: 859, übersprungen: 6, gesamt: 865 Not claimed: the alert set at the merge of this pull request is what the analysis says after the change, and it is read there rather than here. Nothing was run on a Jellyfin server. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #26.
The fourth done condition: a test asserts a skewed peer produces a refusal that
names the clock, distinct from a signature failure, and
docs/threat-model.mdsays why that distinction is not an oracle worth having. The document half has
been in place since the threat model landed. The test half had no subject, because
no route on the peer plane consulted a freshness window. One does now.
Why this issue and not #21
#21 owns the window and the nonce store, and it closed on 2026-08-31 saying in as
many words that the wiring is not one of its conditions and that all three sites
in the tree naming it for this were moved onto this issue:
Its closing note also states plainly that shutting it does not say a replay is
refused on a running server, and names this issue for that. So the wiring arrives
here rather than being taken from a closed issue.
Where the three other conditions stand
Read rather than recalled. The first three were met before this branch and are
untouched by it:
The third is the setting with its documented maximum, landed in #279. What this
branch adds is the only thing that made that setting reach anything: until now
ConfigurationReading.NewFreshnessWindowhad one caller and it was the testproving it worked.
What changed
PeerPlane.Servejudges freshness after the signature verifies and before thetransition table answers. A stale request is answered
clock, a replayed onereplay, and one arriving with no room left to remember its noncebusy.The ordering is the security property rather than a convenience. The threat model
keeps the clock distinction deliberately, and its argument is that the caller who
sees it has already proved it holds the pairing's key, so the one bit handed over
is a bit the specification hands over anyway. Judging freshness before verifying
would hand that bit to every stranger, which is why there is a case whose whole
subject is that a request which is both stale and unsigned is answered exactly as
one that is merely unsigned.
PeerPlane.Refuseno longer names a code. It asksRefusalCounters.CodeFor,which is the method the diagnostics payload already sums by, so a site cannot
answer one code while counting a cause carrying another. Three causes are added
beside it and every one is reachable, which the theory walking every cause
enforces.
The window is registered once per server. It holds the nonces already seen, so a
second instance remembers none of them: a per-caller window is not a weaker replay
guard, it is no replay guard.
The proof it bites
Nine plants, each applied, built, run and reverted. Baseline at the head of this
branch:
Two of them are worth reading rather than counting.
Moving the judgement above the verification reds 4. It refuses exactly the
same requests as the correct order does, and what goes red is only the cases about
what a stranger is told. That is the whole of what the ordering buys, and it is
the one-character mistake somebody will actually make.
A per-caller window red NOTHING before this branch added the case that holds
it, and that is disclosed rather than repaired quietly. Every case that drives one
plane holds one window either way, so the plane's own behaviour cannot see the
difference; only the composition root can, and now one case looks there.
Cases this moved rather than added
ADuplicatedMessageArrivesTwiceAndNothingRefusesTheSecondCopy, over thetwo-instance harness, asserted that nothing refuses the second copy. It said so as
the state of the protocol rather than as something it endorsed. It is renamed and
now asserts the second copy is counted as a replay and the first is not.
TheAnswerIsTheSameRefusalWhateverTheCausecannot survive a taxonomy in whichthree causes answer their own code. It asserts the code the cause maps to instead,
and the property it was actually about - that a caller holding no key tells no two
refusals apart - is asserted over exactly those five causes in a new case rather
than being dropped.
Several cases sent one request repeatedly to reach the arrival limit. Under a
nonce store every send after the first is a replay, so they carry one nonce each
now, which is what the specification requires of two requests differing in nothing
else.
Documents
Four pasted readings named lines that this change moved. They are re-derived
rather than adjusted by eye, and each was checked at the head of this branch.
Four passages said the plane consults no freshness window: in
docs/protocol.mdtwice, in
docs/configuration.md, and indocs/threat-model.mdunder A1. Eachkeeps what it said and then says what is true now. The threat model's A1 paste of
the constructor is replaced by a pointer, because a paste of a parameter list goes
stale every time an argument is added.
The means
No means question arises: this is C# in the assembly that already holds the plane
and the window, using a type this repository already built, and it adds no
language, runtime or dependency. The wiring could not be anything else without
moving the window out of the plugin.
What is not covered
Nothing was run on a Jellyfin server. No key was written by an enrolment, no
request was judged against a real clock on one, and no replay was ever presented
to a running server. On a server today the key store is empty, so every request is
refused before its freshness is reached and the window judges nothing: what this
changes is what a server will answer, not what one answers today. That is the same
bound every other line in the changelog carries and it is not softened here.
Two servers with different skews are still not reconciled. Each judges what
arrives against its own setting, so a request one accepts the other may refuse,
and no route here makes them agree.
No case anywhere runs two threads, so the lock guarding the nonce store has still
not been exercised under contention.
Nothing measures what a wider window costs. A wider skew remembers nonces for
twice as long against an unchanged per-pairing bound of 4096, and no run has said
what request rate that bound covers.
The
net9.0line of the suite did not run on the machine this was written on,which carries no .NET 9 runtime, so only
net10.0is measured above;Build and teston this pull request is what says the other line passes. The six skippedcases are the Unix-mode key store cases, skipped on Windows.
.github/reading-check.shcould not be run to completion on the machine this waswritten on: it dies partway on the temporary directory it makes. The four readings
this branch touches were each re-derived by hand at the head instead, and
Reproduce every pasted readingon this pull request is the run that judges all of them.There was no second reader on this board for this change. The plant table above
stands in place of one: every arm of the judgement was deleted in turn and watched
to redden.