Skip to content

Commit e6eb850

Browse files
committed
fix(security-cryptography-random): the CSPRNG works on Emscripten, and its two messages are .NET's (#2398)
RandomNumberGenerator threw PlatformNotSupportedException on Emscripten, above a comment reading "No secure random source wired up under Emscripten yet". THAT PREMISE HAD ALREADY BEEN MEASURED FALSE IN THIS REPOSITORY. Guid.cpp:377-388 records #2228's finding: Emscripten's libc declares getentropy() in <unistd.h> and implements it as __wasi_random_get(), backed by the host's crypto.getRandomValues. So Guid::NewGuid() has been drawing real entropy on Emscripten through exactly the call this file refused to make -- two answers to one question inside one runtime, and the type whose entire purpose is cryptographic randomness was the one refusing. .NET does not refuse either: RandomNumberGeneratorImplementation.Browser.cs forwards to Interop.GetCryptographicallySecureRandomBytes, whose __EMSCRIPTEN__ arm is SystemJS_RandomBytes (src/native/minipal/random.c:83-93). FOUR PLATFORM ARMS BECAME TWO, AND THAT IS WHAT MAKES THE REPAIR VERIFIABLE RATHER THAN MERELY TIDY. getrandom() is Linux-only -- undeclared on Apple/BSD, and Emscripten declares it but backs getentropy with __wasi_random_get -- so the old file had one arm per platform and the Linux gate compiled exactly one of them. With a single non-Windows getentropy() loop, the code Emscripten takes is the code Linux takes, so the gate executes it on every run. An arm no gate could compile became an arm every gate run exercises. Failure still throws, and that differs from Guid deliberately: NewGuid() retries because callers treat it as infallible and an escaping exception would reach std::terminate, while .NET throws here too (Interop.GetRandomBytes.cs:22-26). Neither ever falls back to a weaker source. THE LIMITATION WAS ALSO UNDECLARED: CLAUDE.md's platform table lists eight subsystems and never listed RandomNumberGenerator, so a caller reading it would have concluded the type worked everywhere. After this change that is correct, so no row is added. Two messages moved onto .NET's exact text -- GetInt32's empty range (RandomNumberGenerator.cs:105-106, SR.Argument_InvalidRandomRange) and verifyGetBytes's truncated SR.Argument_InvalidOffLen (:438-446), the latter also ending a disagreement INSIDE this port, since Console.hpp already spelled .NET's full sentence. THE EXCEPTION TYPES WERE ALREADY RIGHT AND DID NOT MOVE: .NET uses ArgumentException with NO parameter name for the empty range, because the fault is the relationship between two arguments rather than either one's range, and a case asserts that shape beside the ArgumentOutOfRangeException guards. THE SHIPPED COVERAGE COULD NOT HAVE CAUGHT ANY OF THIS: two cases asserting buffer.size() AFTER filling a buffer whose size was fixed before the call, both of which pass against a generator that writes nothing. Thirteen cases replace them, including #2228's fork() distinctness idiom (a userspace PRNG has state and fork() duplicates it) and the 256-byte chunk boundary, which the Linux gate could not reach before this change because getrandom() has no such limit. Eight mutations, six caught, TWO NOT CAUGHT WITH STATED REASONS. Restoring the Emscripten throw cannot be caught here -- that arm is not compiled and there is no Emscripten toolchain in this container; what is verified is nevertheless stronger than before, and the residual claim is #2228's, which shipped code has relied on since. Ignoring a getentropy() failure is unobservable in isolation, because with chunking intact the call does not fail. One mutation was INVALID AS FIRST WRITTEN and was reformulated rather than counted: removing the chunk cap left maxChunk unused, so -Werror=unused-variable rejected it and the verdict said nothing about the tests. Landed under SA-5. No layout, vtable, signature or noexcept change and NO OUTLAWED SPELLING, so no negative fixture was owed; the set stays 48/245 and the graph 41/93. Downstream, measured: zero sites in both consumers. RNGCryptoServiceProvider is SPLIT OUT AS #2399 rather than bundled -- it is public sealed and [Obsolete] in .NET and neither here, and three of .NET's four constructors are absent (one of which, (CspParameters?), cannot be transcribed because CspParameters does not exist in this port). Every part of that is a public source break or new public surface. Gate 17,625 / 38 executables: 17,625 run, 17,625 passed, 0 failed, 0 skipped, recounted from the per-executable logs with every executable run separately and continuing past failures. +13 on 17,612, all in SharpRuntimeIntegrationTests (931 -> 944) = exactly the thirteen new Rng2398* cases; no other executable moved. Zero build warnings at --parallel 2; module boundaries green.
1 parent a805603 commit e6eb850

8 files changed

Lines changed: 496 additions & 62 deletions

File tree

CLAUDE.md

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

NEXT.md

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,41 @@
33

44
# NEXT.md
55

6-
> **Test-count floor, 2026-08-19 — 17,612 / 38, AND THE GATE IS GREEN.** The complete
7-
> 38-executable gate reads **17,612 run: 17,612 passed, 0 failed, 0 skipped**, recounted from the
6+
> **Test-count floor, 2026-08-19 — 17,625 / 38, AND THE GATE IS GREEN.** The complete
7+
> 38-executable gate reads **17,625 run: 17,625 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
1111
> absent in this container; the third was a real repair (#2351).
1212
>
13-
> **The queues were empty and the work was found by measurement instead (#2397).** With `ticket`
14-
> at 0 `todo` and `task` fully classified, the next move was **not** to stop but to point the
15-
> restored reference at a module the audit had never reviewed. `System::Text::RegularExpressions`
16-
> has **no dedicated test executable** — its only coverage was a section of one integration file —
17-
> and it turned out to hold **four divergences from .NET, two of them silent data loss through a
18-
> public member**: `Regex::Split` discarded every matched capture group's value *and* dropped a
19-
> trailing empty segment, both because the body was a single `std::sregex_token_iterator(-1)`.
20-
> **A module with no test executable is where to look next**, and §5 below lists the others.
13+
> **The queues were empty and the work was found by measurement instead (#2397, then #2398).**
14+
> With `ticket` at 0 `todo` and `task` fully classified, the next move was **not** to stop but to
15+
> point the restored reference at the modules with **no test sources of their own**. Two have now
16+
> been measured and both held real defects.
2117
>
22-
> **BOTH WORK QUEUES ARE STILL EMPTY.** `ticket` has **0 `todo`**; `task` has **0** unclassified
23-
> (14,979 ignored / 1,082 ported / 140 ignore). Ticket totals: **2,380 done, 9 blocked, 1
24-
> needs_user, 5 wontfix**. What remains blocked needs the user or an external event, and each is
25-
> itemised in §2 below.
18+
> **#2397 — `System::Text::RegularExpressions`.** Four divergences, **two of them silent data loss
19+
> through a public member**: `Regex::Split` discarded every matched capture group's value *and*
20+
> dropped a trailing empty segment, both because the body was a single
21+
> `std::sregex_token_iterator(-1)`.
22+
>
23+
> **#2398 — `System::Security::Cryptography::RandomNumberGenerator`.** It **threw
24+
> `PlatformNotSupportedException` on Emscripten**, on a premise **this repository had already
25+
> measured false**: `Guid.cpp:377-388` records #2228's finding that Emscripten's `getentropy()` is
26+
> `__wasi_random_get()`, so `Guid::NewGuid()` has been getting real entropy there through the very
27+
> call the cryptographic RNG refused to make. .NET does not refuse either. **And its shipped tests
28+
> could not have caught it** — two cases asserting `buffer.size()` after filling a buffer whose size
29+
> was fixed before the call, both of which pass against a generator that writes nothing.
30+
>
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.
34+
>
35+
> **THE `todo` QUEUE IS NOT EMPTY: #2399 IS OPEN.** It was split out of #2398 rather than bundled,
36+
> because every part of it — making `RNGCryptoServiceProvider` `sealed`, marking it `[Obsolete]`,
37+
> and adding constructors — is a public source break or new public surface, where #2398 outlawed no
38+
> spelling at all. `task` has **0** unclassified (14,979 ignored / 1,082 ported / 140 ignore).
39+
> Ticket totals: **2,381 done, 1 todo, 9 blocked, 1 needs_user, 5 wontfix**. What remains *blocked*
40+
> needs the user or an external event, and each is itemised in §2 below.
2641

2742
## Handoff for a new context, 2026-08-19 (queue exhausted)
2843

@@ -130,17 +145,22 @@ coverage" and the difference matters:
130145
|---|---|---|
131146
| `text-regular-expressions` | `Text.RegularExpressions` | a section of `tests/integration/System/Text/TextRemainingTests.cpp` — **this is the one #2397 measured, and it held four divergences** |
132147
| `io-compression-zip` | `IO.Compression.Zip` | `tests/integration/System/IO/Compression/CompressionTests.cpp` (145 cases in the file, covering the whole namespace) |
133-
| `security-cryptography-random` | `Security.Cryptography.Random` | `tests/integration/System/Security/Cryptography/KeyDerivationTests.cpp` (13 cases in the file) |
148+
| `security-cryptography-random` | `Security.Cryptography.Random` | **measured by #2398 and it held the Emscripten defect**; its two shipped cases could not fail. Thirteen real cases now live in `RandomNumberGeneratorEntropyTests.cpp`; `RNGCryptoServiceProvider` remains, as **#2399** |
134149
| `storage` | `Storage` | `tests/integration/Task39RemainingTests.cpp` (78 cases in the file) — one header, `StoragePaths` |
135150

136151
**Do not read the table as four equal opportunities.** `io-compression-zip` is genuinely well
137-
covered by a large sibling file; `storage` is 48 lines and one header. The two worth measuring
138-
against `/rv` next are **`security-cryptography-random`** (`RandomNumberGenerator` /
139-
`RNGCryptoServiceProvider`, and note that #2228 has already put a real CSPRNG behind `Guid::NewGuid`,
140-
so the question is whether *these* types reach the same source) and a **second pass over
141-
`Text.RegularExpressions`**, whose remaining gaps #2397 listed rather than closed: `Regex::Unescape`
142-
is absent, the `count`/`startat` `Split` overloads are absent, and `Match::Empty().Groups().Count`
143-
is 0 here against .NET's 1.
152+
covered by a large sibling file; `storage` is 48 lines and one header.
153+
154+
**Two of the four have now been measured, and the question that found #2398 is the one to reuse.**
155+
It was: *#2228 put a real CSPRNG behind `Guid::NewGuid` — do the types whose whole purpose is
156+
cryptographic randomness reach the same source?* They did not. Asking a repaired subsystem's
157+
question of its neighbours is cheap and it found a defect on the first try.
158+
159+
What is left here: a **second pass over `Text.RegularExpressions`**, whose remaining gaps #2397
160+
listed rather than closed (`Regex::Unescape` absent, the `count`/`startat` `Split` overloads absent,
161+
`Match::Empty().Groups().Count` 0 here against .NET's 1), and **#2399** for
162+
`RNGCryptoServiceProvider`. `io-compression-zip` and `storage` were checked and are **not**
163+
recommended: the first is covered by 145 sibling cases, the second is one 48-line header.
144164

145165
**Adding a 39th executable was considered and not taken.** A dedicated
146166
`SharpRuntimeTests_Text_RegularExpressions` would be the architecturally tidier home, and the
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `RandomNumberGenerator`'s entropy source and two messages (ticket #2398)
5+
6+
*2026-08-19.* `System::Security::Cryptography::RandomNumberGenerator` **threw
7+
`PlatformNotSupportedException` on Emscripten**, on a premise this repository had already measured
8+
to be false. It no longer does. Two exception messages also moved onto .NET's exact text.
9+
10+
Landed under **SA-5**. No layout, vtable, signature or `noexcept` change, and **no outlawed
11+
spelling** — the repair is confined to one `.cpp` body and two string literals.
12+
13+
**Downstream, measured:** **zero** `RandomNumberGenerator` and **zero** `RNGCryptoServiceProvider`
14+
sites in `cna` and in `mobile-eggbert`.
15+
16+
---
17+
18+
## 1. Emscripten gets a real CSPRNG, because it always had one
19+
20+
The old body had four platform arms. The second was:
21+
22+
```cpp
23+
#elif defined(__EMSCRIPTEN__)
24+
// No secure random source wired up under Emscripten yet — throw a clear exception
25+
// rather than silently produce weak randomness (e.g. from an unseeded PRNG).
26+
throw System::PlatformNotSupportedException(
27+
"RandomNumberGenerator is not implemented on Emscripten in this runtime.");
28+
```
29+
30+
**That comment's premise was measured false by #2228, in this same repository.** `Guid.cpp:377-388`
31+
records it in terms: Emscripten's libc declares `getentropy()` in `<unistd.h>` and implements it as
32+
`__wasi_random_get()` (`system/lib/libc/musl/src/misc/getentropy.c`), which the runtime backs with
33+
the host's `crypto.getRandomValues`. **So `System::Guid::NewGuid()` has been drawing real entropy on
34+
Emscripten through exactly the call this file refused to make** — two answers to one question inside
35+
one runtime, and the type whose entire purpose is cryptographic randomness was the one refusing.
36+
37+
.NET does not refuse either: `RandomNumberGeneratorImplementation.Browser.cs` forwards to
38+
`Interop.GetCryptographicallySecureRandomBytes`, whose `__EMSCRIPTEN__` arm is `SystemJS_RandomBytes`
39+
(`src/native/minipal/random.c:83-93`).
40+
41+
### 1.1 Four arms became two, and that is what makes the repair verifiable
42+
43+
The Linux-only `getrandom()` arm went with the Emscripten one. The file now has `Guid.cpp`'s shape:
44+
Windows uses `BCryptGenRandom`, and **every other platform uses one chunked `getentropy()` loop**.
45+
46+
That is not tidying. `getrandom()` is Linux-only — undeclared on Apple/BSD, and Emscripten declares
47+
it but backs `getentropy` with `__wasi_random_get` — so the old file had **one arm per platform, and
48+
the Linux gate compiled exactly one of them**. With a single non-Windows arm, **the code Emscripten
49+
takes is the code Linux takes**, so the gate exercises it on every run. An arm no gate could compile
50+
became an arm every gate run executes.
51+
52+
### 1.2 What a caller has to change
53+
54+
**On Emscripten:** a `try`/`catch` around `RandomNumberGenerator::Create()`,
55+
`RandomNumberGenerator::Fill()`, `GetBytes()` or `GetInt32()` written to handle
56+
`PlatformNotSupportedException` will no longer see one. Nothing else moves; the call now succeeds.
57+
58+
**On Linux:** the syscall behind the call changed from `getrandom(buf, n, 0)` to a loop of
59+
`getentropy(buf, ≤256)`. Both are the same kernel CSPRNG — glibc implements `getentropy()` on top of
60+
`getrandom()` — and both block until the pool is initialised. No caller-visible behaviour changes.
61+
This is also the call `Guid::NewGuid()` has already been making on Linux since #2228.
62+
63+
**On Windows and Apple/BSD:** nothing changes.
64+
65+
### 1.3 Failure still throws, and that differs from `Guid` deliberately
66+
67+
`Guid::NewGuid()` retries on failure rather than throwing, because callers treat it as infallible and
68+
an escaping exception would reach `std::terminate`. This member has no such constraint, and **.NET
69+
throws too**: `Interop.GetRandomBytes.cs:22-26` is
70+
`if (Sys.GetCryptographicallySecureRandomBytes(...) != 0) throw new CryptographicException();`.
71+
Neither ever falls back to a weaker source, which is the property both exist to hold.
72+
73+
### 1.4 The limitation was also undeclared
74+
75+
`CLAUDE.md`'s platform-limitation table lists `Net::Sockets`, `IO::RandomAccess`, `AppDomain`,
76+
`TimeZoneInfo`, `Diagnostics::Process`, `PosixSignal`, `NetworkInterface` and `FileSystemWatcher`.
77+
**It never listed `RandomNumberGenerator.`** So this limitation was not only wrong, it was
78+
unrecorded — a caller reading the table would have concluded the type worked everywhere. After this
79+
change that conclusion is correct, so no row is added.
80+
81+
---
82+
83+
## 2. Two messages are now .NET's exact text
84+
85+
| Site | Before | After (= .NET) |
86+
|---|---|---|
87+
| `GetInt32(from, to)` with `from >= to` | `"fromInclusive must be less than toExclusive."` | `"Range of random number does not contain at least one possibility."` |
88+
| `verifyGetBytes` window past the end | `"Offset and length were out of bounds for the array."` | `"Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection."` |
89+
90+
`RandomNumberGenerator.cs:105-106` (`SR.Argument_InvalidRandomRange`, `Strings.resx:126-128`) and
91+
`RandomNumberGenerator.cs:438-446` (`SR.Argument_InvalidOffLen`).
92+
93+
**The exception types were already right and did not move.** .NET throws `ArgumentException` for the
94+
empty range rather than `ArgumentOutOfRangeException`, and names **no parameter**, because the fault
95+
is the *relationship* between the two arguments rather than either one's range. The negative-`offset`
96+
and negative-`count` guards remain `ArgumentOutOfRangeException` with their parameter names, and a
97+
test asserts the two shapes side by side so a later repair cannot conflate them.
98+
99+
**The second message also ended an inconsistency inside this port**: `Console.hpp:140` already spells
100+
.NET's full sentence verbatim. Only this file truncated it.
101+
102+
---
103+
104+
## 3. Testing, and the two things that could not be tested
105+
106+
The shipped coverage was two cases that asserted `buffer.size()` **after** filling a buffer whose
107+
size was fixed before the call — both would have passed against a generator that wrote nothing.
108+
Thirteen cases replace them, including the `fork()` distinctness case #2228 established for `Guid`
109+
(a userspace PRNG has state and `fork()` duplicates it; a CSPRNG does not) and the 256-byte chunk
110+
boundary, **which the Linux gate could not reach before this change at all** because `getrandom()`
111+
has no such limit.
112+
113+
Eight mutations, six caught. **Two are not caught and both are stated rather than papered over:**
114+
115+
- **Restoring the `__EMSCRIPTEN__` throw is not caught here, and cannot be.** That arm is not
116+
compiled on this platform, and there is no Emscripten toolchain in this container. What *is*
117+
verified is stronger than it was: the arm Emscripten now takes is the arm Linux runs on every
118+
gate. The residual unverified claim is that `getentropy()` exists on Emscripten — #2228's
119+
measurement, which shipped code has already relied on since that ticket.
120+
- **Ignoring a `getentropy()` failure is not observable in isolation.** With chunking intact the
121+
call does not fail on a working system, so no test can drive the branch. It becomes observable
122+
only in combination with a chunking mutation, which is separately caught.
123+
124+
One mutation was **invalid as first written and was reformulated rather than counted**: removing the
125+
chunk cap left `maxChunk` unused, so `-Werror=unused-variable` rejected it at compile time and the
126+
verdict said nothing about the tests.

modules/security-cryptography-random/include/System/Security/Cryptography/RandomNumberGenerator.hpp

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,13 @@ namespace System::Security::Cryptography {
7878
/** @return A random intcs in [fromInclusive, toExclusive). */
7979
[[nodiscard]] static intcs GetInt32(intcs fromInclusive, intcs toExclusive) {
8080
if (fromInclusive >= toExclusive) {
81-
throw System::ArgumentException("fromInclusive must be less than toExclusive.");
81+
// RandomNumberGenerator.cs:105-106 -- SR.Argument_InvalidRandomRange, whose text is
82+
// System.Security.Cryptography Strings.resx:126-128. The exception TYPE was already
83+
// right: .NET throws ArgumentException here rather than ArgumentOutOfRangeException,
84+
// and it names no parameter, because the fault is the RELATIONSHIP between the two
85+
// rather than either one being out of range.
86+
throw System::ArgumentException(
87+
"Range of random number does not contain at least one possibility.");
8288
}
8389
uint32_t range = static_cast<uint32_t>(toExclusive) - static_cast<uint32_t>(fromInclusive) - 1;
8490
if (range == 0) return fromInclusive;
@@ -113,7 +119,12 @@ namespace System::Security::Cryptography {
113119
System::ArgumentOutOfRangeException::ThrowIfNegative(offset, "offset");
114120
System::ArgumentOutOfRangeException::ThrowIfNegative(count, "count");
115121
if (count > static_cast<intcs>(data.size()) - offset) {
116-
throw System::ArgumentException("Offset and length were out of bounds for the array.");
122+
// RandomNumberGenerator.cs:438-446 -- SR.Argument_InvalidOffLen. The text was
123+
// TRUNCATED here at "for the array.", which also left this file inconsistent with
124+
// the rest of the port: Console.hpp already spells .NET's full sentence verbatim.
125+
throw System::ArgumentException(
126+
"Offset and length were out of bounds for the array or count is greater than "
127+
"the number of elements from index to the end of the source collection.");
117128
}
118129
}
119130
};

0 commit comments

Comments
 (0)