Skip to content

Commit b822448

Browse files
committed
fix(net-websockets): the WebSocket nonce and masking key come from a CSPRNG (#2401)
Found by asking #2398's own question once more rather than by opening another module: #2228 put a real CSPRNG behind Guid::NewGuid, so WHAT ELSE IN THIS RUNTIME NEEDS UNPREDICTABLE BYTES, AND WHERE DOES IT GET THEM? ClientWebSocket drew BOTH its Sec-WebSocket-Key nonce and its PER-FRAME MASKING KEY from std::random_device. THAT IS A DEFECT RATHER THAN A STYLE POINT, AND THIS REPOSITORY HAD ALREADY MEASURED WHY. The standard explicitly permits a deterministic std::random_device, and Random.cpp:69-70 records in its own comment "on a platform whose random_device is deterministic (MinGW-w64's historically was)" -- MinGW-w64 being a supported compile target under CLAUDE.md's platform policy. On such a platform every connection sends the SAME Sec-WebSocket-Key and every frame's masking key is PREDICTABLE. RFC 6455 does not leave this to taste. Section 5.3: "The masking key needs to be unpredictable; thus, the masking key MUST be derived from a strong source of entropy, and the masking key for a given frame MUST NOT make it simple for a server/proxy to predict the masking key for a subsequent frame." Masking exists to stop cache-poisoning of intermediaries (section 10.3), so a predictable key defeats the one attack it was introduced for. .NET'S TWO ROUTES ARE DIFFERENT AND ARE TRANSCRIBED SEPARATELY RATHER THAN HARMONISED INTO ONE: the nonce is Guid.NewGuid().TryWriteBytes base64-encoded (WebSocketHandle.Managed.cs:490-494) and the mask is RandomNumberGenerator.Fill (ManagedWebSocket.cs:762-763). THE NONCE COST NO COMPONENT EDGE AT ALL, because #2228 already put the CSPRNG behind Guid::NewGuid and Core.Base was already a public dependency here. Module graph 41/93 -> 41/94: Security.Cryptography.Random becomes a PRIVATE dependency of Net.WebSockets, private because the only caller is a file-local helper so no public header names the type. Catalogue regenerated; scripts/check_selective_components.sh Net.WebSockets net_websockets.cpp passes with its 107 tests. CALLING getentropy() DIRECTLY FROM net-websockets WAS REJECTED ON A RECORDED PRECEDENT: it would be a third copy of the platform entropy call, the duplication #2354 spent a ticket removing. WHAT CANNOT BE OBSERVED HERE IS STATED RATHER THAN IMPLIED. On glibc, std::random_device reads /dev/urandom, so the SOURCE change is not behaviourally visible on this platform. The evidence is the reference, the RFC, this repository's own MinGW-w64 measurement, and SYMBOL INSPECTION, which is precisely discriminating (the #1983 idiom): after the repair random_device=0 NewGuid=1 Fill=1 nonce reverted random_device=16 NewGuid=0 Fill=1 mask reverted random_device=16 NewGuid=1 Fill=0 Five mutations. THE TWO SOURCE REVERSIONS ARE NOT CAUGHT BY TESTS AND ARE CAUGHT BY THE SYMBOL TABLE, reported as such rather than as passes. The three RFC-property mutations -- one mask cached per connection, a constant mask, and a nonce cached for the process lifetime -- are ALL CAUGHT, and each is the PLAUSIBLE OPTIMISATION rather than a contrived defect: a cached mask still unmasks correctly at the server, because both ends agree on whatever key was sent, so it passes every other assertion in the file. One mutation was INVALID AS FIRST WRITTEN and was reformulated rather than counted: zeroing the mask left randomMaskingKey() unreferenced and -Werror=unused-function rejected it. WHAT IS NEWLY PINNED ARE THE RFC PROPERTIES THEMSELVES, which nothing pinned before: a 16-byte nonce differing between connections (section 4.1) and a fresh masking key per frame (section 5.3), plus a separate assertion that no mask is all zeroes, which the freshness check alone would not catch for a single frame. Both are reachable from the existing mock-server harness. Landed under SA-5. No public signature, layout, vtable or noexcept change and no outlawed spelling -- both helpers are file-local to the .cpp -- so no negative fixture was owed; the set stays 49/248. Gate 17,629 / 38 executables: 17,629 run, 17,629 passed, 0 failed, 0 skipped, recounted from the per-executable logs with every executable run separately and continuing past failures. +2 on 17,627, in SharpRuntimeTests_Net_WebSockets (105 -> 107); no other executable moved. Zero build warnings at --parallel 2; module boundaries green at 41/94.
1 parent 17917fa commit b822448

9 files changed

Lines changed: 335 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

NEXT.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
# NEXT.md
55

6-
> **Test-count floor, 2026-08-19 — 17,627 / 38, AND THE GATE IS GREEN.** The complete
7-
> 38-executable gate reads **17,627 run: 17,627 passed, 0 failed, 0 skipped**, recounted from the
6+
> **Test-count floor, 2026-08-19 — 17,629 / 38, AND THE GATE IS GREEN.** The complete
7+
> 38-executable gate reads **17,629 run: 17,629 passed, 0 failed, 0 skipped**, recounted from the
88
> per-executable logs with every executable run separately and continuing past failures, zero build
99
> warnings at `--parallel 2`. Every checkpoint below this one ends with *"the gate is not green"*;
1010
> this one does not. Two of the three historical failure sources were environmental and are simply
@@ -28,15 +28,24 @@
2828
> could not have caught it** — two cases asserting `buffer.size()` after filling a buffer whose size
2929
> was fixed before the call, both of which pass against a generator that writes nothing.
3030
>
31-
> **That is the pattern worth carrying forward**: a module with no test sources is where an
32-
> undetected divergence survives, and a test that cannot fail is how it survives there. §4b lists
33-
> the two remaining candidates and says plainly which is and is not worth the measurement.
31+
> **#2401 — `ClientWebSocket`'s entropy.** Found by asking #2398's question **once more** rather
32+
> than by opening another module: *#2228 put a real CSPRNG behind `Guid::NewGuid` — what else needs
33+
> unpredictable bytes, and where does it get them?* `ClientWebSocket` drew **both** its
34+
> `Sec-WebSocket-Key` nonce and its **per-frame masking key** from `std::random_device`, which the
35+
> standard permits to be deterministic and which **`Random.cpp:69-70` already records as
36+
> deterministic on MinGW-w64**, a supported target. RFC 6455 §5.3 requires the mask to come from a
37+
> strong source of entropy by name.
3438
>
35-
> **BOTH WORK QUEUES ARE EMPTY AGAIN.** #2399 closed the same day it was filed (`RNGCryptoServiceProvider`
36-
> is now `final` and `[[deprecated]]`, with two of .NET's three missing constructors and the third
37-
> pinned as unreachable), and #2400 is its downstream record. `ticket` has **0 `todo`**; `task` has
38-
> **0** unclassified (14,979 ignored / 1,082 ported / 140 ignore). Ticket totals: **2,383 done, 9
39-
> blocked, 1 needs_user, 5 wontfix**. What remains *blocked* needs the user or an external event,
39+
> **Three patterns worth carrying forward, in order of yield.** (1) **Ask a repaired subsystem's
40+
> question of its neighbours** — that alone found #2398 and #2401. (2) **A module with no test
41+
> sources** is where an undetected divergence survives. (3) **A test that cannot fail** is how it
42+
> survives there: every defect these tickets found sat under one. §4b has the remaining candidates.
43+
>
44+
> **BOTH WORK QUEUES ARE EMPTY AGAIN.** Five tickets were filed and closed on 2026-08-19 by this
45+
> sweep — #2397, #2398, #2399, #2400 (downstream record) and #2401. `ticket` has **0 `todo`**;
46+
> `task` has **0** unclassified (14,979 ignored / 1,082 ported / 140 ignore). Ticket totals:
47+
> **2,384 done, 9 blocked, 1 needs_user, 5 wontfix**. Graph **41 / 94** (#2401 added one private
48+
> edge), negative fixtures **49 / 248** (#2399 added one). What remains *blocked* needs the user or an external event,
4049
> and each is itemised in §2 below. **§4b says where to look next, and the method that found all
4150
> three of today's tickets.**
4251

docs/ComponentCatalog.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file is generated from the CMake component registrations. Run
88
`python3 scripts/generate_component_catalog.py` after changing module
99
metadata, or use `--check` to verify that the committed catalogue is current.
1010

11-
The graph contains **41 physical modules** and **93 direct production dependency edges**.
11+
The graph contains **41 physical modules** and **94 direct production dependency edges**.
1212

1313
For each row, the component name and representative public header form a
1414
minimal consumer example using the template below:
@@ -63,7 +63,7 @@ for maintainers and are not part of the consumer include surface.
6363
| `Net.Mime` | `modules/net-mime` | static | `Collections.Core`, `Core.Base` |||| `System/Net/Mime/ContentType.hpp` |
6464
| `Net.NetworkInformation` | `modules/net-network-information` | static | `ComponentModel`, `Core.Base`, `Net`, `Threading.Tasks` |||| `System/Net/NetworkInformation/IPStatus.hpp` |
6565
| `Net.Security` | `modules/net-security` | interface | `Core.Base` |||| `System/Net/Security/AuthenticationLevel.hpp` |
66-
| `Net.WebSockets` | `modules/net-websockets` | static | `ComponentModel`, `Core.Base`, `Net`, `Net.Sockets`, `Threading`, `Threading.Tasks`, `Uri` | ||| `System/Net/WebSockets/ClientWebSocket.hpp` |
66+
| `Net.WebSockets` | `modules/net-websockets` | static | `ComponentModel`, `Core.Base`, `Net`, `Net.Sockets`, `Threading`, `Threading.Tasks`, `Uri` | `Security.Cryptography.Random` ||| `System/Net/WebSockets/ClientWebSocket.hpp` |
6767
| `Security` | `modules/security` | interface | `Core.Base` |||| `System/Security/Authentication/AuthenticationException.hpp` |
6868
| `Security.Cryptography` | `modules/security-cryptography` | static | `Core.Base` |||| `System/Security/Cryptography/AuthenticationTagMismatchException.hpp` |
6969
| `Security.Cryptography.Random` | `modules/security-cryptography-random` | static | `Core.Base` ||| `bcrypt` on Windows (private) | `System/Security/Cryptography/RNGCryptoServiceProvider.hpp` |
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — the WebSocket nonce and masking key come from a CSPRNG (ticket #2401)
5+
6+
*2026-08-19.* `ClientWebSocket` drew its `Sec-WebSocket-Key` nonce **and** its per-frame masking key
7+
from `std::random_device`. Both now come from the platform CSPRNG, by .NET's own two routes.
8+
9+
Landed under **SA-5**. **No public signature, layout, vtable or `noexcept` change** — both helpers
10+
are file-local to `ClientWebSocket.cpp`. **Nothing a caller can observe changed**; what changed is
11+
where the bytes come from, plus one declared component edge.
12+
13+
---
14+
15+
## 1. Why `std::random_device` was wrong here
16+
17+
`std::random_device` is **not required to be a CSPRNG**. The standard explicitly permits a
18+
deterministic implementation, and **this repository has already measured one**: `Random.cpp:69-70`
19+
records, in its own comment, *"on a platform whose random_device is deterministic (MinGW-w64's
20+
historically was)"* — and MinGW-w64 is a supported compile target under `CLAUDE.md`'s platform
21+
policy. On such a platform every connection would have sent the **same** `Sec-WebSocket-Key`, and
22+
every frame's masking key would have been **predictable**.
23+
24+
RFC 6455 does not leave this to taste. §5.3:
25+
26+
> The masking key needs to be unpredictable; thus, the masking key MUST be derived from a strong
27+
> source of entropy, and the masking key for a given frame MUST NOT make it simple for a
28+
> server/proxy to predict the masking key for a subsequent frame.
29+
30+
Masking exists to stop cache-poisoning of intermediaries (§10.3), so a predictable key defeats the
31+
one attack it was introduced for. §4.1 requires the nonce to be *"selected randomly for each
32+
connection"*.
33+
34+
## 2. The two routes are .NET's, and they are different
35+
36+
| | Source | Reference |
37+
|---|---|---|
38+
| `Sec-WebSocket-Key` nonce | `Guid::NewGuid().ToByteArray()` | `WebSocketHandle.Managed.cs:490-494``Guid.NewGuid().TryWriteBytes(bytes)`, base64-encoded |
39+
| per-frame masking key | `RandomNumberGenerator::Fill` | `ManagedWebSocket.cs:762-763``WriteRandomMask` is `RandomNumberGenerator.Fill(...)` |
40+
41+
They are transcribed **separately rather than harmonised into one**, because that is how .NET has
42+
them. Since **#2228** this port's `Guid::NewGuid()` draws from the platform CSPRNG, so the nonce
43+
route costs **no component edge at all**`Core.Base` was already a public dependency.
44+
45+
A v4 GUID fixes 6 of its 128 bits (version and variant), so the nonce carries **122 bits of entropy
46+
rather than 128**. That is .NET's own arithmetic here, not a shortcut this port took.
47+
48+
## 3. The one build-facing change: a new component edge
49+
50+
`Net.WebSockets` gains **`Security.Cryptography.Random` as a `PRIVATE` dependency**. The module
51+
graph goes **41 / 93 → 41 / 94** and `docs/ComponentCatalog.md` is regenerated.
52+
53+
It is `PRIVATE` because the only caller is a file-local helper in `ClientWebSocket.cpp`: no public
54+
header names the type, and no consumer inherits the include path. A consumer doing a **selective
55+
component build** of `Net.WebSockets` will now also configure and link
56+
`Security.Cryptography.Random`; verified by running
57+
`scripts/check_selective_components.sh Net.WebSockets net_websockets.cpp`, which passes with its
58+
107 tests.
59+
60+
**The alternative was rejected on a recorded precedent.** Calling `getentropy()` directly from
61+
`net-websockets` would avoid the edge and would be a **third** copy of the platform entropy call —
62+
the duplication #2354 spent a whole ticket removing.
63+
64+
## 4. What can and cannot be verified here
65+
66+
**Stated rather than implied.** On glibc, `std::random_device` reads `/dev/urandom`, so **the source
67+
change is not behaviourally observable on this platform**. Its evidence is the reference, RFC 6455,
68+
this repository's own MinGW-w64 measurement, and **symbol inspection**, which is precisely
69+
discriminating:
70+
71+
| Build | `random_device` | `Guid::NewGuid` | `RandomNumberGenerator::Fill` |
72+
|---|---|---|---|
73+
| after the repair | **0** | 1 | 1 |
74+
| nonce reverted | 16 | **0** | 1 |
75+
| mask reverted | 16 | 1 | **0** |
76+
77+
**What is newly pinned are the RFC properties themselves**, which nothing pinned before: a 16-byte
78+
nonce that differs between connections, and a **fresh** masking key per frame. Both are reachable
79+
from the existing mock-server harness, which already reads the key out of the handshake and the
80+
4-byte mask out of each frame.
81+
82+
Five mutations. The two source reversions are **not caught by tests and are caught by the symbol
83+
table** — reported as such rather than as passes. The three RFC-property mutations — caching one
84+
mask per connection, a constant mask, and caching the nonce for the process lifetime — are **all
85+
caught**, and each is the plausible optimisation rather than a contrived defect: a cached mask still
86+
unmasks correctly at the server, because both ends agree on whatever key was sent. One mutation was
87+
**invalid as first written and was reformulated rather than counted**: zeroing the mask left
88+
`randomMaskingKey()` unreferenced, so `-Werror=unused-function` rejected it at compile time.

modules/net-websockets/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,13 @@ sharp_runtime_register_module(
1919
Threading
2020
Threading.Tasks
2121
Uri
22+
# Security.Cryptography.Random became a PRIVATE dependency with ticket #2401: the per-frame
23+
# masking key must come from a CSPRNG (RFC 6455 section 5.3 -- "MUST be derived from a strong
24+
# source of entropy"), and .NET spells that RandomNumberGenerator.Fill
25+
# (ManagedWebSocket.cs:762-763). It is PRIVATE because the only caller is a file-local helper
26+
# in ClientWebSocket.cpp, so no public header names the type and no consumer inherits the
27+
# edge. The Sec-WebSocket-Key nonce needed no edge at all: .NET builds it from Guid.NewGuid()
28+
# (WebSocketHandle.Managed.cs:490-494), and Core.Base is already public here.
29+
PRIVATE_DEPENDENCIES
30+
Security.Cryptography.Random
2231
)

modules/net-websockets/src/System/Net/WebSockets/ClientWebSocket.cpp

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99
#include <array>
1010
#include <cstring>
1111
#include <map>
12-
#include <random>
1312
#include "System/ArgumentException.hpp"
1413
#include "System/ArgumentOutOfRangeException.hpp"
1514
#include "System/Convert.hpp"
15+
#include "System/Guid.hpp"
1616
#include "System/InvalidOperationException.hpp"
1717
#include "System/Net/IPEndPoint.hpp"
1818
#include "System/Net/detail/ProtocolFieldValidation.hpp"
1919
#include "System/Net/WebSockets/WebSocketException.hpp"
2020
#include "System/PlatformNotSupportedException.hpp"
21+
#include "System/Security/Cryptography/RandomNumberGenerator.hpp"
2122
#include "System/Threading/Tasks/TaskCanceledException.hpp"
2223
#include <chrono>
2324
#include <condition_variable>
@@ -101,16 +102,41 @@ namespace {
101102

102103
constexpr const char* kWebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
103104

104-
std::array<bytecs, 16> randomBytes16() {
105-
std::random_device rd;
106-
std::array<bytecs, 16> bytes{};
107-
for (auto& b : bytes) b = static_cast<bytecs>(rd() & 0xFF);
108-
return bytes;
109-
}
110-
105+
// BOTH OF THESE USED `std::random_device`, AND THAT IS A DEFECT RATHER THAN A STYLE POINT
106+
// (#2401). The standard explicitly permits a deterministic `std::random_device`, and THIS
107+
// REPOSITORY HAS ALREADY MEASURED ONE: `Random.cpp:69-70` records "on a platform whose
108+
// random_device is deterministic (MinGW-w64's historically was)", and MinGW-w64 is a
109+
// supported compile target of this project. On such a platform every connection would send
110+
// the SAME Sec-WebSocket-Key and every frame's masking key would be predictable.
111+
//
112+
// RFC 6455 does not leave that to taste. Section 5.3: "The masking key needs to be
113+
// unpredictable; thus, the masking key MUST be derived from a strong source of entropy, and
114+
// the masking key for a given frame MUST NOT make it simple for a server/proxy to predict the
115+
// masking key for a subsequent frame." Masking exists to stop cache-poisoning of
116+
// intermediaries (RFC 6455 section 10.3), so a predictable key defeats the one attack it was
117+
// introduced for.
118+
//
119+
// .NET USES A CSPRNG FOR BOTH, BY TWO DIFFERENT ROUTES, and each is transcribed as it stands
120+
// rather than harmonised into one.
121+
122+
/// The Sec-WebSocket-Key nonce. `WebSocketHandle.Managed.cs:490-494` --
123+
/// `Guid.NewGuid().TryWriteBytes(bytes)`, base64-encoded. Since #2228 this port's
124+
/// `Guid::NewGuid()` draws from the platform CSPRNG, so this route costs no component edge:
125+
/// `Core.Base` is already a public dependency. A v4 GUID fixes 6 of its 128 bits (version and
126+
/// variant), so the nonce carries 122 bits of entropy rather than 128 -- which is .NET's own
127+
/// arithmetic here, not a shortcut taken by this port.
128+
std::array<bytecs, 16> randomBytes16() { return System::Guid::NewGuid().ToByteArray(); }
129+
130+
/// The per-frame masking key. `ManagedWebSocket.cs:762-763` -- `WriteRandomMask` is
131+
/// `RandomNumberGenerator.Fill(buffer.AsSpan(offset, MaskLength))`. That is what the
132+
/// `Security.Cryptography.Random` PRIVATE dependency buys; calling `getentropy()` directly from
133+
/// here instead would be a third copy of the platform entropy call, which is the duplication
134+
/// #2354 spent a ticket removing.
111135
uint32_t randomMaskingKey() {
112-
std::random_device rd;
113-
return rd();
136+
std::vector<bytecs> key(4);
137+
System::Security::Cryptography::RandomNumberGenerator::Fill(key);
138+
return static_cast<uint32_t>(key[0]) | (static_cast<uint32_t>(key[1]) << 8) |
139+
(static_cast<uint32_t>(key[2]) << 16) | (static_cast<uint32_t>(key[3]) << 24);
114140
}
115141

116142
} // namespace

0 commit comments

Comments
 (0)