Skip to content

Judge an arriving request's freshness once it has verified (#26) - #310

Open
iderex wants to merge 3 commits into
masterfrom
protocol/the-plane-judges-freshness
Open

Judge an arriving request's freshness once it has verified (#26)#310
iderex wants to merge 3 commits into
masterfrom
protocol/the-plane-judges-freshness

Conversation

@iderex

@iderex iderex commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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.md
says 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:

gh issue view 21 --repo Flowfin/jellyfin-plugin-server-pairing --json state,stateReason,closedAt --jq '[.state,.stateReason,.closedAt]|@tsv'
CLOSED  COMPLETED       2026-08-31T18:16:45Z

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:

git grep -n 'public void NoPluginSourceFileReadsTheWallClock\|public void NoTestSourceFileWaitsForRealTime' -- Jellyfin.Plugin.ServerPairing.Tests/ClockSourceTests.cs
Jellyfin.Plugin.ServerPairing.Tests/ClockSourceTests.cs:84:    public void NoPluginSourceFileReadsTheWallClock()
Jellyfin.Plugin.ServerPairing.Tests/ClockSourceTests.cs:97:    public void NoTestSourceFileWaitsForRealTime()

git grep -n '^| `TimestampWindowSeconds`' -- docs/configuration.md
docs/configuration.md:29:| `TimestampWindowSeconds` | `int` | `300` | 1 to 900 |

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.NewFreshnessWindow had one caller and it was the test
proving it worked.

What changed

PeerPlane.Serve judges freshness after the signature verifies and before the
transition table answers. A stale request is answered clock, a replayed one
replay, and one arriving with no room left to remember its nonce busy.

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.Refuse no longer names a code. It asks RefusalCounters.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:

dotnet test -f net10.0
Bestanden!   : Fehler:     0, erfolgreich:   859, übersprungen:     6, gesamt:   865

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

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 the
two-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.

TheAnswerIsTheSameRefusalWhateverTheCause cannot 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 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.md
twice, in docs/configuration.md, and in docs/threat-model.md under A1. Each
keeps 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.0 line of the suite did not run on the machine this was written on,
which carries no .NET 9 runtime, so only net10.0 is measured above; Build and test on this pull request is what says the other line passes. The six skipped
cases are the Unix-mode key store cases, skipped on Windows.

.github/reading-check.sh could not be run to completion on the machine this was
written 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 reading on 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.

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>
Comment thread Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs Fixed
Comment thread Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs Fixed
Comment thread Jellyfin.Plugin.ServerPairing.Tests/Api/PeerPlaneTests.cs Fixed
Comment thread Jellyfin.Plugin.ServerPairing.Tests/Api/RefusalCountersTests.cs Fixed
Comment thread Jellyfin.Plugin.ServerPairing.Tests/Api/RefusalCountersTests.cs Fixed
`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>
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.

Inject the clock everywhere and fix the skew policy

2 participants