The control channel stops reusing a nonce, and the media ports stop trusting whoever knocks first #417

Merged
enricobuehler merged 5 commits from worktree-gamestream-security into main 2026-08-27 16:44:46 +00:00
Owner

Picks up where #414 left off, working the security gaps in design/gamestream-security-and-gaps-handoff.md (planning repo) in the order that document gives them. Five commits, each gated on the Linux container and Windows .133 before the next was started.

Two of them close real defects. Three of them answer questions that had been sitting in the code as follow-up comments — and in two cases the answer was that the premise was wrong.

What changes for a Moonlight user

  • The control channel gets a per-direction nonce. Host messages and client input no longer share one (key, nonce) space. Negotiated automatically; nothing to configure.
  • A media port only adopts the client that can prove it is the client. Being first to send a datagram is no longer enough.
  • Nothing else moves. No new configuration is required, and the plaintext wire is still one env var away.

The commits

eb8117b5 The ping payload becomes a per-session secret — and, more to the point, becomes reachable only by the client it was minted for.
f4a26987 SS_ENC_CONTROL_V2, shipped dark: the advertisement, plus the sealed-RTSP framing it enables.
a7934378 Flips it on by default, after a real client negotiated it on glass.
d3616aae Four answers instead of four follow-up notes: TLS pinning, PIN brute-forcing, audio CBC, encoderCscMode.
b61566e6 MaxLumaPixelsHEVC follows the codec mask instead of contradicting it.

The nonce reuse, observed closed

control.rs has carried a note saying the legacy control scheme builds its GCM nonce from the sender's own sequence number and nothing else — no direction byte — so the host's messages and the client's input collide whenever their independent counters cross, and ending "the real fix is V2 control-encryption negotiation".

Most of that fix was already written. The scheme detector has understood NonceKind::V2 since it was built, and encrypt_control already flips the marker to b"HC" for host-originated messages. What was missing was the one thing that makes a client choose V2: the advertisement. The control plane needed no code change at all.

On .173 (Win11, RTX 4090) against Moonlight on macOS, 2560x1440@240 HEVC Main10 HDR:

Leg Result
control — offered, not requested the client turns it on by itself, video + audio flow, hardware decode
require — both bits requested negotiated and streamed identically; the lever is not broken

The line that settles it is the host reporting what it actually locked onto, rather than what was negotiated:

control: GCM scheme locked in
  scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] },
                  tag_first: true, aad: None }

[67, 67] is b"CC", the client→host marker. The host then sealed its HDR-mode cue under the flipped b"HC" and the client acted on it — both directions on distinct nonces, on hardware. That is why a7934378 makes it the default rather than leaving it behind a knob, which is the same bar #414 set for video encryption.

The deferral that turned out not to exist

Control-v2 was deferred in #414 because it also lets the client seal RTSP, and that appeared to need corever — the field deciding whether a session speaks sealed RTSP, whose threshold the sanctioned reference names but never gives a value for. Guessing it would break every session it guessed wrong about.

It never has to be consulted. The two framings are self-distinguishing: a sealed message opens with typeAndLength, whose MSB is ENCRYPTED_MESSAGE_TYPE_BIT, and a plaintext one opens with an ASCII method name, always below 0x80. So each connection is read in whichever framing it arrived in and answered in the same one — the same "detect it from the wire" posture the control stream already takes for its nonce scheme.

Both on-glass legs kept RTSP plaintext (the client asked for rtsp:// and stayed there), which also confirms the framing is chosen by the launch URL scheme rather than the DESCRIBE flag. The sealed reader is therefore unit-tested but has not been exercised on the wire. It is written to answer in kind the day a client opens a connection with the type bit set.

The ping payload, and the hole underneath it

The media planes learned where to send by taking the first datagram from the launch owner's address and never looking inside it. The payload the protocol carries for exactly this purpose was the fixed constant 0011223344556677 on every host, and nothing compared it to what arrived.

Minting a per-session secret would have been theatre on its own, because RTSP SETUP was unauthenticated while its siblings ANNOUNCE and PLAY were not — any peer that could reach 48010 could simply ask for the value the media planes were about to verify. So SETUP is gated on authorized_launch like the other two. That hole, not the constant, was the reason the old check would not have bought anything.

Both planes now go through one learn_client_endpoint; they had drifted into two byte-identical loops, and a rule about who owns a stream should exist once.

The check prefers a match rather than requiring one, which is deliberate. The reference says the client echoes the payload and that modern clients wrap it in an SS_PING carrying a sequence number, but gives neither that structure's layout nor whether the payload crosses as the header's ASCII or as its decoded bytes. Every shape those unknowns allow is accepted; an unverified datagram is held as a fallback, adopted only if two seconds pass with nothing better, and logged with the bytes that arrived. On glass both planes logged ping payload verified on the first datagram in both legs, so the fallback never fired and this can be tightened to a hard gate whenever we want it.

Answers, not code

Four items were carrying comments that read as unfinished work. Three of them should not be built:

  • TLS accepting any client certificate is the design, not an unfinished pin. A handshake completes before the request line is parsed, so per-route pinning is inexpressible on one port. Some HTTPS traffic must come from unpaired peers — /serverinfo answers PairStatus=0 precisely so a client can discover it needs to pair, and the management API admits certless browsers. And deferring costs nothing: the CertificateVerify signature is checked, so a peer reaching a handler has proved possession of its certificate's key, and peer_is_paired pins the SHA-256 of that same certificate before any state-changing work. Rejecting with an HTTP error rather than a TLS alert is a difference in when, not in what is proven.

  • The PIN's 4-digit space is not the exposure. Nothing can brute-force it, because submission is the bearer-authenticated management API and nowhere else — there is no oracle to hammer, and a per-attempt cap would bound the operator's typos rather than an attacker. The real residual is capture, not guessing: the PIN slot is bound to no particular handshake. That is already narrowed twice (an ambiguous submit is refused, an unconsumed PIN expires) and its full fix is keying the gate by uniqueid.

  • Audio's unauthenticated CBC cannot be negotiated away. The instinct is to reach for SS_ENC_AUDIO as the authenticated answer. Per the sanctioned reference it selects exactly the mode we already implement — "CBC, not GCM. No auth tag appended" — and is negotiated through featureFlags bit 0x20 rather than the encryptionSupported mask. GameStream has no authenticated audio mode to advertise. The malleability is real and protocol-inherent, in the same family as pairing over plain HTTP. This moves the item from "blocked on a layout we could not find" to answered: the layout was not missing, the premise was.

  • encoderCscMode was warning about something it could not act on. The note claimed Moonlight renders from the requested value rather than the bitstream VUI. The reference does not say that, and we emit an explicit VUI a VUI-driven renderer would follow. The common case is unactionable by construction: this PR's own on-glass run shows a stock client sending csc=3 (Rec709 full) while negotiating HDR, and HDR10 is BT.2020 PQ, so the request and the mode contradict each other and the mode wins. It now says so at debug, and warns only for SDR, labelled unverified.

Verification

Every commit gated before the next began. Final state: Linux 14/14 pacing + 96/96 gamestream (90 before), Windows .133 14/14 + 95/95 (89 before), full nvenc,amf-qsv,qsv clippy matrix, check-docs-drift.sh and check-docs-links.sh clean.

The new tests were confirmed non-vacuous by mutation, not by watching them pass: ping_matches forced to true fails the negative case, and the sealed-RTSP nonce's direction byte forced to a constant fails both directional assertions — which is the security property doing its job.

.173 was restored afterwards: original binary back byte-for-byte by size and timestamp, no test lines in host.env, service running, listeners up, build tree removed.

Worth a reviewer's attention

  • The host's RTSP sequence counter is process-global and never resets. GameStream RTSP is one message per TCP connection, so a per-connection counter would restart at zero for every one of a session's seven messages and reuse (key, nonce) six times over — reintroducing, inside the fix, the exact defect being fixed. Same rule WP7 established for the video counter.
  • The sealed frame's length field is bounded before it is believed. It is attacker-controlled and arrives before a single byte has authenticated.
  • PUNKTFUNK_GS_ENCRYPT gained a video value, not just a new default. It keeps video encryption and drops only the control offer — the granular way out, because 0 would throw away video encryption to fix a control-channel problem, and exactly one client build has been tested against the V2 offer.
  • moonlight.md no longer says the control channel "uses the older GameStream scheme." That stopped being true for a stock client with this PR.

Not in this PR

Honouring encoderCscMode is not "just plumbing", which is worth recording because it was scoped that way. videoFullRangeFlag is hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match, so an SDR client asking for full range needs a per-session colour request threaded from RTSP through the capture CSC into each backend's VUI — code the native plane shares and currently gets right. That is a real piece of work with a real regression surface.

Also untouched: the grants posture for newly paired Moonlight devices (GRANT_ALL by default — a product question rather than a bug, and changing it would silently downgrade every existing pairing), C3–C6 from the handoff, the trigger-rumble / motion / 4:4:4 items that remain capture-blocked, and the latency A/B measurement debt — still the largest thing nobody has measured.

Picks up where #414 left off, working the security gaps in `design/gamestream-security-and-gaps-handoff.md` (planning repo) in the order that document gives them. Five commits, each gated on the Linux container **and** Windows `.133` before the next was started. Two of them close real defects. Three of them answer questions that had been sitting in the code as follow-up comments — and in two cases the answer was that the premise was wrong. ## What changes for a Moonlight user * **The control channel gets a per-direction nonce.** Host messages and client input no longer share one (key, nonce) space. Negotiated automatically; nothing to configure. * **A media port only adopts the client that can prove it is the client.** Being first to send a datagram is no longer enough. * Nothing else moves. No new configuration is required, and the plaintext wire is still one env var away. ## The commits | | | |---|---| | `eb8117b5` | **The ping payload becomes a per-session secret** — and, more to the point, becomes reachable only by the client it was minted for. | | `f4a26987` | **`SS_ENC_CONTROL_V2`**, shipped dark: the advertisement, plus the sealed-RTSP framing it enables. | | `a7934378` | **Flips it on by default**, after a real client negotiated it on glass. | | `d3616aae` | **Four answers instead of four follow-up notes**: TLS pinning, PIN brute-forcing, audio CBC, `encoderCscMode`. | | `b61566e6` | `MaxLumaPixelsHEVC` follows the codec mask instead of contradicting it. | ## The nonce reuse, observed closed `control.rs` has carried a note saying the legacy control scheme builds its GCM nonce from the sender's own sequence number and nothing else — no direction byte — so the host's messages and the client's input collide whenever their independent counters cross, and ending "the real fix is V2 control-encryption negotiation". Most of that fix was already written. The scheme detector has understood `NonceKind::V2` since it was built, and `encrypt_control` already flips the marker to `b"HC"` for host-originated messages. What was missing was the one thing that makes a client *choose* V2: the advertisement. The control plane needed no code change at all. On `.173` (Win11, RTX 4090) against Moonlight on macOS, 2560x1440@240 HEVC Main10 HDR: | Leg | Result | |---|---| | `control` — offered, not requested | **the client turns it on by itself**, video + audio flow, hardware decode | | `require` — both bits requested | negotiated and streamed identically; the lever is not broken | The line that settles it is the host reporting what it actually locked onto, rather than what was negotiated: ``` control: GCM scheme locked in scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] }, tag_first: true, aad: None } ``` `[67, 67]` is `b"CC"`, the client→host marker. The host then sealed its HDR-mode cue under the flipped `b"HC"` and the client acted on it — both directions on distinct nonces, on hardware. That is why `a7934378` makes it the default rather than leaving it behind a knob, which is the same bar #414 set for video encryption. ## The deferral that turned out not to exist Control-v2 was deferred in #414 because it also lets the client seal RTSP, and that appeared to need `corever` — the field deciding whether a session speaks sealed RTSP, whose threshold the sanctioned reference names but never gives a value for. Guessing it would break every session it guessed wrong about. It never has to be consulted. The two framings are **self-distinguishing**: a sealed message opens with `typeAndLength`, whose MSB is `ENCRYPTED_MESSAGE_TYPE_BIT`, and a plaintext one opens with an ASCII method name, always below `0x80`. So each connection is read in whichever framing it arrived in and answered in the same one — the same "detect it from the wire" posture the control stream already takes for its nonce scheme. Both on-glass legs kept RTSP plaintext (the client asked for `rtsp://` and stayed there), which also confirms the framing is chosen by the launch URL scheme rather than the DESCRIBE flag. **The sealed reader is therefore unit-tested but has not been exercised on the wire.** It is written to answer in kind the day a client opens a connection with the type bit set. ## The ping payload, and the hole underneath it The media planes learned where to send by taking the first datagram from the launch owner's address and never looking inside it. The payload the protocol carries for exactly this purpose was the fixed constant `0011223344556677` on every host, and nothing compared it to what arrived. Minting a per-session secret would have been theatre on its own, because **RTSP SETUP was unauthenticated** while its siblings ANNOUNCE and PLAY were not — any peer that could reach 48010 could simply *ask* for the value the media planes were about to verify. So SETUP is gated on `authorized_launch` like the other two. That hole, not the constant, was the reason the old check would not have bought anything. Both planes now go through one `learn_client_endpoint`; they had drifted into two byte-identical loops, and a rule about who owns a stream should exist once. **The check prefers a match rather than requiring one**, which is deliberate. The reference says the client echoes the payload and that modern clients wrap it in an `SS_PING` carrying a sequence number, but gives neither that structure's layout nor whether the payload crosses as the header's ASCII or as its decoded bytes. Every shape those unknowns allow is accepted; an unverified datagram is held as a fallback, adopted only if two seconds pass with nothing better, and logged with the bytes that arrived. On glass both planes logged `ping payload verified` on the **first** datagram in both legs, so the fallback never fired and this can be tightened to a hard gate whenever we want it. ## Answers, not code Four items were carrying comments that read as unfinished work. Three of them should not be built: * **TLS accepting any client certificate is the design, not an unfinished pin.** A handshake completes before the request line is parsed, so per-route pinning is inexpressible on one port. Some HTTPS traffic *must* come from unpaired peers — `/serverinfo` answers `PairStatus=0` precisely so a client can discover it needs to pair, and the management API admits certless browsers. And deferring costs nothing: the `CertificateVerify` signature **is** checked, so a peer reaching a handler has proved possession of its certificate's key, and `peer_is_paired` pins the SHA-256 of that same certificate before any state-changing work. Rejecting with an HTTP error rather than a TLS alert is a difference in *when*, not in what is proven. * **The PIN's 4-digit space is not the exposure.** Nothing can brute-force it, because submission is the bearer-authenticated management API and nowhere else — there is no oracle to hammer, and a per-attempt cap would bound the operator's typos rather than an attacker. The real residual is **capture**, not guessing: the PIN slot is bound to no particular handshake. That is already narrowed twice (an ambiguous submit is refused, an unconsumed PIN expires) and its full fix is keying the gate by `uniqueid`. * **Audio's unauthenticated CBC cannot be negotiated away.** The instinct is to reach for `SS_ENC_AUDIO` as the authenticated answer. Per the sanctioned reference it *selects exactly the mode we already implement* — "CBC, not GCM. No auth tag appended" — and is negotiated through `featureFlags` bit 0x20 rather than the `encryptionSupported` mask. GameStream has no authenticated audio mode to advertise. The malleability is real and protocol-inherent, in the same family as pairing over plain HTTP. This moves the item from "blocked on a layout we could not find" to answered: the layout was not missing, the premise was. * **`encoderCscMode` was warning about something it could not act on.** The note claimed Moonlight renders from the requested value rather than the bitstream VUI. The reference does not say that, and we emit an explicit VUI a VUI-driven renderer would follow. The common case is unactionable by construction: this PR's own on-glass run shows a stock client sending `csc=3` (Rec709 **full**) while negotiating HDR, and HDR10 *is* BT.2020 PQ, so the request and the mode contradict each other and the mode wins. It now says so at debug, and warns only for SDR, labelled unverified. ## Verification Every commit gated before the next began. Final state: **Linux 14/14 pacing + 96/96 gamestream** (90 before), **Windows `.133` 14/14 + 95/95** (89 before), full `nvenc,amf-qsv,qsv` clippy matrix, `check-docs-drift.sh` and `check-docs-links.sh` clean. The new tests were confirmed non-vacuous by mutation, not by watching them pass: `ping_matches` forced to `true` fails the negative case, and the sealed-RTSP nonce's direction byte forced to a constant fails both directional assertions — which is the security property doing its job. `.173` was restored afterwards: original binary back byte-for-byte by size and timestamp, no test lines in `host.env`, service running, listeners up, build tree removed. ## Worth a reviewer's attention * **The host's RTSP sequence counter is process-global and never resets.** GameStream RTSP is one message per TCP connection, so a per-connection counter would restart at zero for every one of a session's seven messages and reuse (key, nonce) six times over — reintroducing, inside the fix, the exact defect being fixed. Same rule WP7 established for the video counter. * **The sealed frame's length field is bounded before it is believed.** It is attacker-controlled and arrives before a single byte has authenticated. * **`PUNKTFUNK_GS_ENCRYPT` gained a `video` value**, not just a new default. It keeps video encryption and drops only the control offer — the granular way out, because `0` would throw away video encryption to fix a control-channel problem, and exactly one client build has been tested against the V2 offer. * **`moonlight.md` no longer says the control channel "uses the older GameStream scheme."** That stopped being true for a stock client with this PR. ## Not in this PR **Honouring `encoderCscMode` is not "just plumbing"**, which is worth recording because it was scoped that way. `videoFullRangeFlag` is hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match, so an SDR client asking for full range needs a per-session colour request threaded from RTSP through the capture CSC into each backend's VUI — code the **native** plane shares and currently gets right. That is a real piece of work with a real regression surface. Also untouched: the grants posture for newly paired Moonlight devices (`GRANT_ALL` by default — a product question rather than a bug, and changing it would silently downgrade every existing pairing), C3–C6 from the handoff, the trigger-rumble / motion / 4:4:4 items that remain capture-blocked, and the latency A/B measurement debt — still the largest thing nobody has measured.
enricobuehler added 5 commits 2026-08-27 16:38:22 +00:00
Both media planes learned where to send by taking the first UDP datagram that
arrived from the launch owner's address and never looking inside it. The ping
payload the protocol exists to carry — handed to the client in the SETUP
response, echoed back as its first datagram — was the fixed constant
`0011223344556677` for every session on every host, and nothing ever compared
it against what arrived. Source-IP binding was the whole guard, so anything
that could send from (or spoof) that address won the endpoint simply by being
first, and was handed the stream.

So the payload becomes a per-session secret: eight random bytes minted at
`/launch`, re-minted at `/resume`, advertised hex-encoded in SETUP, and checked
before an endpoint is adopted. Both planes now go through one
`learn_client_endpoint`, because they had drifted into two byte-identical loops
and a rule about who owns a stream should exist once.

Minting alone would have proved nothing, though, and this is the part worth
pausing on: SETUP was **unauthenticated** while its siblings ANNOUNCE and PLAY
were not. Any peer that could reach 48010 could simply ask for the payload the
media planes were about to verify, and walk the check. So SETUP is gated on
`authorized_launch` like the other two — a secret handed to whoever asks is not
a secret, and that hole, not the constant, was the reason the old check would
have been theatre.

The check **prefers** a matching datagram rather than **requiring** one, and
that is deliberate rather than timid. The sanctioned wire reference says the
client echoes the payload and that modern clients wrap it in an `SS_PING`
carrying a sequence number, but it gives neither that structure's layout nor
whether the payload crosses as the header's ASCII or as its decoded bytes.
`ping_matches` accepts every shape those unknowns allow — either encoding, with
trailing bytes — yet a hard gate resting on a layout nobody has confirmed would
black-screen every session it guessed wrong about, and compatibility is this
plane's entire reason to exist. An unverified datagram is therefore held as a
fallback, adopted only if two seconds pass with nothing better, and logged with
the bytes that did arrive. One real session settles the encoding; then the
fallback can go and this becomes the hard gate it wants to be.

What this closes: an off-path attacker who cannot read the plaintext RTSP
exchange can no longer guess its way onto a media port. What it does not close:
an on-path observer, who can still read the payload out of SETUP — that one
needs `SS_ENC_CONTROL_V2`, and the two changes are worth more together than
either is alone.

Constant-time comparison throughout (`crypto::ct_eq`), so the expected value
can't be probed a byte at a time.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
93/93 gamestream (90 before). The three new tests were confirmed non-vacuous by
mutating `ping_matches` to return `true` and watching the negative case fail.
The legacy GameStream control scheme builds its AES-GCM nonce out of the
sender's own sequence number and nothing else. There is no direction byte, so
the host's messages — rumble, the HDR-mode cue — and the client's input share a
single (key, nonce) space and collide the moment their two independent counters
cross. That is the one catastrophic GCM failure, and `control.rs` has carried a
note saying so, ending "the real fix is V2 control-encryption negotiation".

This is that fix, and most of it turned out to be already written. The scheme
detector has understood `NonceKind::V2` — `seq` in `[0..4]`, `b"CC"` at
`[10..12]` — since it was built, and `encrypt_control` already flips the marker
to `b"HC"` for host-originated messages. What was missing was the one thing that
makes a client choose that scheme: the advertisement. So the control plane needs
no code change at all here; it needs `SS_ENC_CONTROL_V2` in
`encryptionSupported`, and it detects the rest from the first packet that
authenticates.

Enabling V2 also lets the client seal RTSP itself, which is the part that was
deferred, and the reason it was deferred dissolves on inspection. The worry was
needing to know `corever` — the field that decides whether a session speaks
sealed RTSP — whose threshold the sanctioned reference names but does not value,
and guessing it would break every session it guessed wrong about. But the two
framings are **self-distinguishing**: a sealed message opens with
`typeAndLength`, whose MSB is `ENCRYPTED_MESSAGE_TYPE_BIT`, and a plaintext one
opens with an ASCII method name, always below 0x80. So there is nothing to
negotiate and no threshold to guess — the connection is read in whichever
framing it arrived in and answered in the same one, the same "detect it from the
wire" posture the control stream already takes. `corever` never has to be
consulted, which also means it never has to be guessed.

Two details worth their own lines:

**The host's RTSP sequence counter is process-global and never resets.**
GameStream RTSP is one message per TCP connection, so a per-connection counter
would restart at zero for every one of a session's seven messages and reuse
(key, nonce) six times over — reintroducing, in the fix, exactly the defect
being fixed. Same rule WP7 established for the video counter, and same reasoning.

**The frame's length field is bounded before it is believed.** It is
attacker-controlled and arrives before a single byte has authenticated, so it is
checked against the same budget the plaintext path uses rather than trusted
enough to reserve against.

Shipped dark, deliberately: `PUNKTFUNK_GS_ENCRYPT=control` opts in, the default
advertisement is unchanged, and `require` (the on-glass lever) now covers both
bits. This is the posture WP7 shipped video encryption in — dark until glass
confirmed a real client negotiated it — and control-v2 has not yet met a real
client here. Flipping the default is a one-line change once it has.

The two comments claiming we "advertise no encryption" are corrected while I am
here; they have been wrong since WP7 advertised `SS_ENC_VIDEO`, right in their
conclusion and wrong in their reason.

Gate: Linux container fmt + clippy `--all-targets -D warnings` + 14/14 pacing +
96/96 gamestream (93 before); Windows `.133` clippy on the full
`nvenc,amf-qsv,qsv` matrix + 14/14 + 95/95 (92 before). The three new tests cover
the receive path as well as the send path, and were confirmed non-vacuous by
mutating the nonce's direction byte to a constant — both directional assertions
fail, which is the security property doing its job.
The previous commit shipped `SS_ENC_CONTROL_V2` dark, on the grounds that the
wire had not yet met a real client here. It has now, twice, on `.173` (Win11,
RTX 4090) against Moonlight on macOS at 2560x1440@240 HEVC Main10 HDR.

| Leg | Result |
|---|---|
| `control` (SUPPORTED, not requested) | **the client turns it on by itself** — video and audio flow, hardware decode |
| `require` (both bits REQUESTED) | negotiated and streamed the same way; the lever is not broken |

The line that settles it is the host's own scheme detector, which reports what
it actually locked onto rather than what was negotiated:

```
control: GCM scheme locked in
  scheme=Scheme { key_rev: false, nonce: V2 { seq_be: false, marker: [67, 67] },
                  tag_first: true, aad: None }
```

`[67, 67]` is `b"CC"` — the client→host V2 marker — after which the host sealed
its HDR-mode cue under the flipped `b"HC"` and the client acted on it. Both
directions on distinct nonces, on hardware. That is the defect from the previous
commit's note, observed closed rather than argued closed.

So `Supported` — the default — now carries both bits, exactly as WP7's video
encryption graduated after its own glass pass. What used to be the `control`
opt-in becomes `PUNKTFUNK_GS_ENCRYPT=video`, the way *out*: it keeps video
encryption and drops only the control offer. That granularity is worth its three
lines rather than being YAGNI — this plane serves a spread of client builds and
exactly one has been tested against the V2 offer, so the alternative rollback
(`=0`) would throw away video encryption to fix a control-channel problem.

**The deferral that turned out not to exist.** The worry was that control-v2
would drag encrypted RTSP in with it, forcing a `corever` threshold nobody has
the value for. Both legs kept RTSP plaintext — the client asked for
`rtsp://192.168.1.173:48010` and stayed there — which confirms the framing is
chosen by the launch URL scheme, not by the DESCRIBE flag. The sealed-RTSP
reader is in and unit-tested, but it has not been exercised on the wire; it will
be the day a client opens a connection with the type bit set, and it is written
to answer in kind when that happens.

**The other thing glass settled** is the previous commit's open question about
the ping payload's encoding. Both media planes logged `client endpoint learned
(ping payload verified)` on the first datagram, in both legs — so the payload
crosses in a form `ping_matches` accepts, the two-second fallback never fired,
and that check can be tightened from "prefer" to "require" whenever we want it.

Docs follow: `moonlight.md` no longer says the control channel "uses the older
GameStream scheme", which stopped being true for a stock client with this
commit, and `configuration.md` documents the new `video` value.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95. One `send_pacing` failure on the
first Linux run was a timing flake under load (0.36 s vs the usual 0.06 s) and
passed 14/14 on re-run — the suite is known for it. `.173` was restored
afterwards: original binary back byte-for-byte by size and timestamp, no test
lines in `host.env`, service running, listeners up, and the build tree this
session created removed.
None of these change behaviour. All four were carrying comments that read as
unfinished work, and the work turned out to be establishing what is true.

**TLS accepts any client certificate — and should.** The verifier's comment
called pinning "a hardening follow-up". It is not one. A TLS handshake completes
before the request line is parsed, so "pin the post-pair routes, accept-any on
the pairing routes" cannot be expressed there at all — it would take a second
listener on a second port, and the protocol fixes the ports. Some HTTPS traffic
must come from unpaired peers anyway: `/serverinfo` answers `PairStatus=0`
precisely so a client can discover it needs to pair, and the management API goes
further and admits certless browsers. And deferring costs nothing: the
`CertificateVerify` signature IS checked, so a peer reaching a handler has proved
possession of its certificate's private key, and `peer_is_paired` pins the
SHA-256 of that same certificate before any state-changing work. Every route but
`/serverinfo` goes through it. Rejecting with an HTTP error rather than a TLS
alert is a difference in *when*, not in what is proven. The comment now says
that, including the thing that WOULD be a hole — accepting the certificate
without checking the signature, which is why the legacy-Moonlight fallback
re-verifies rather than waving old certificates through.

**The PIN's 4-digit space is not the exposure.** 10⁴ invites the brute-force
question, and the answer is that nothing can brute-force it: submission is the
bearer-authenticated management API and nowhere else, so a network peer has no
oracle to hammer, and a per-attempt cap would bound the operator's typos rather
than an attacker. A wrong PIN costs a fresh client handshake *and* a fresh
operator submission — not a loop anyone automates from the network. The real
residual is **capture**, not guessing: the PIN slot is bound to no particular
handshake, so a peer parked at the right moment can take the PIN typed for
someone else. That is already narrowed twice (an ambiguous submit is refused, an
unconsumed PIN expires) and its full fix is to key the gate by `uniqueid` —
which also needs the management API to name *which* device is asking, so the
operator answers a named prompt. Written down so the next reader audits the race
rather than re-deriving the brute-force answer.

**Audio's unauthenticated CBC cannot be negotiated away.** The instinct is to
reach for `SS_ENC_AUDIO` (0x04) as the authenticated answer. Per the sanctioned
wire reference, `SS_ENC_AUDIO` *selects exactly the mode we already implement*:
"if SS_ENC_AUDIO: AES-128-CBC encrypt the PKCS7-padded Opus frame", noted there
as "CBC, not GCM. No auth tag appended (unlike video/control GCM)", negotiated
through `x-nv-general.featureFlags` bit 0x20 rather than the `encryptionSupported`
mask. So GameStream has no authenticated audio mode to advertise: offering the
flag would change nothing on the wire, and adding a tag would be a private
extension no client can decode. The malleability is real and it is
protocol-inherent, in the same family as pairing over plain HTTP. A session that
needs authenticated audio needs the native plane, whose audio is AES-GCM. This
moves the item from "blocked on a layout we could not find" to "answered" — the
layout was not missing, the premise was.

**`encoderCscMode` was warning about something it could not act on.** The note
claimed Moonlight renders from the requested value rather than the bitstream VUI,
so any mismatch shifts colours. The wire reference does not say that — it lists
the key among those a host parses, and nothing more — and we emit an explicit,
correct VUI that a VUI-driven renderer would follow. Worse, the common case is
unactionable by construction: this session's own on-glass run shows a stock
client sending `csc=3` (Rec709 **full**) while negotiating HDR, and HDR10 *is*
BT.2020 PQ, so the request and the mode contradict each other and the mode wins.
The old code warned on every HDR session about that. It now says so at debug, and
keeps a warning only for SDR, where the request could in principle be met —
honestly labelled unverified.

Also recorded: honouring it is not "just plumbing". `videoFullRangeFlag` is
hardcoded to 0 in every encoder backend with the capture-side CSC fixed to match,
so an SDR client asking for full range needs a per-session colour request
threaded from RTSP through the capture CSC into each backend's VUI — code the
native plane shares and currently gets right. That is a real piece of work with a
real regression surface, and it should be scoped as one rather than as a logging
follow-up.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
serverinfo stops advertising HEVC capacity it may not have
ci / web (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 48s
ci / docs-drift (pull_request) Successful in 30s
ci / rust-arm64 (pull_request) Successful in 2m13s
ci / docs-site (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m0s
ci / rust (pull_request) Successful in 9m9s
b61566e6c6
`MaxLumaPixelsHEVC` was the constant `1869449984` in every document, including
the ones whose `ServerCodecModeSupport` had just dropped HEVC — a GPU-less host
encodes H.264 and nothing else, and said so in the mask while still quoting a
4K60 HEVC ceiling two lines above it. The code's own comment flagged the
contradiction and left it, on the grounds that Moonlight gates on the mask.

That reasoning is right, which is exactly why this is worth closing rather than
keeping: the field is harmless only for as long as every client keeps ignoring
it, and nothing on our side guarantees that. It now follows the mask, with `0` —
the field's own way of saying no HEVC capacity — when HEVC is not offered.

Gate: Linux fmt + clippy + 14/14 pacing + 96/96 gamestream; Windows `.133`
clippy on the full matrix + 14/14 + 95/95.
enricobuehler merged commit 2d0a4270b4 into main 2026-08-27 16:44:46 +00:00
enricobuehler deleted branch worktree-gamestream-security 2026-08-27 16:44:54 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#417