nx_chacha20.nx source
↩ module page · 392 lines · 19455 B
1// chacha20.nx -- RFC 8439 ChaCha20 stream cipher.
2//
3// license_tier: INDEPENDENT_REDERIVE
4// genealogy_id: international-research-sources/ietf/rfc_8439
5//
6// 256-bit key, 96-bit nonce, 32-bit counter -> 64-byte keystream
7// blocks that XOR with plaintext. Specified in RFC 8439 (May 2018),
8// identical to IETF ChaCha20 (Bernstein 2008, IETF variant by Nir +
9// Langley 2014).
10//
11// Why ChaCha20:
12// - Naturally constant-time: only ADD, ROTATE, XOR operations on
13// 32-bit words. No S-box tables, no branches on secret data.
14// Immune to cache side-channels that plague table-based AES.
15// - Quantum stance: under Grover's algorithm the effective security
16// of a 256-bit-keyed stream cipher is 128 bits -- secure for the
17// forseeable future. Pairs with ML-KEM-768 for TLS 1.3 PQ hybrid.
18// - Simpler than AES (no key schedule distinct from encryption;
19// no bitsliced constant-time variant required).
20//
21// Invariants:
22// CC1 No branches on key, nonce, counter, or state values. Every
23// word of the 16-word state is processed identically.
24// CC2 Rotate-left operations use constant shift amounts (7, 8, 9,
25// 12, 13, 16, 18, 25) -- never driven by secret data.
26// CC3 Memory access pattern depends only on block length, not on
27// key/nonce/counter values.
28// CC4 The 32-bit counter is caller-owned; wrap-around behaviour
29// at 2^32 blocks (= 256 GiB with a given key+nonce) is
30// undefined by the spec. Callers must rekey before wrap.
31//
32// API:
33// chacha20_block(key, counter, nonce, out) -- one 64-byte block
34// chacha20_encrypt(key, counter, nonce, in, n, out)
35// -- full message
36//
37// References:
38// RFC 8439, section 2.3 "The ChaCha20 Block Function"
39// RFC 8439, section 2.4 "The ChaCha20 Encryption Algorithm"
40// Test vectors: RFC 8439 Appendix A.1 / A.2
41//
42// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
43// intended_use: "ChaCha20 stream cipher -- TLS 1.3 ChaCha20-
44// Poly1305 / fallback when AES-NI unavailable /
45// constant-time crypto for shared-cache CPUs"
46// sil_target: SIL3 (cipher; same stake as AES)
47// asil_target: QM
48// dal_target: DAL B
49// iec_62304_class: B
50// evidence: [no_floating_point, sealed_enum_complete,
51// bit_equal_reproducible,
52// RFC_8439_Appendix_A1_A2_KAT_VERIFIED,
53// constant_time_by_construction_no_table_lookup,
54// license_tier_INDEPENDENT_REDERIVE]
55// hazard_register: [bug-tape-nonce-reuse-disclosure,
56// bug-tape-counter-rollover-keystream-collision,
57// bug-tape-32-bit-counter-2GB-message-limit]
58// residual_risk: "32-bit block counter wraps after 2^32 blocks
59// (256 GiB per nonce). Long-stream callers
60// MUST chunk + rotate nonce. Nonce uniqueness
61// is CALLER RESPONSIBILITY (RFC 8439 §4)."
62// verdict: NOT_YET_EVALUATED (timing-side-channel axis
63// PRE-PROVEN PASS -- ChaCha20 has no S-box,
64// no secret-indexed table lookups; the cache-
65// timing hazard that AES has does NOT apply.
66// This makes ChaCha20 the substrate's
67// recommended cipher on shared-cache CPUs.)
68
69import "nx_syscalls.nx"
70import "nx_bits.nx"
71import "nx_safety.nx"
72
73// ---- Native SafetyEnvelope (machine-readable companion) -----------
74
75func _nx_chacha20_evidence_ids() -> *i64 {
76 let p_raw: *u8 = sys_mmap(64)
77 let p: *i64 = p_raw as *i64
78 p[0] = EVID_NO_FP
79 p[1] = EVID_SEALED_ENUM_COMPLETE
80 p[2] = EVID_BIT_EQUAL_REPRODUCIBLE
81 p[3] = EVID_CONSTANT_TIME_BY_DESIGN
82 p[4] = EVID_RFC_TEST_VECTORS
83 p[5] = EVID_LICENSE_TIER_INDEPENDENT_REDERIVE
84 return p
85}
86
87func _nx_chacha20_hazard_ids() -> *i64 {
88 let p_raw: *u8 = sys_mmap(16)
89 let p: *i64 = p_raw as *i64
90 p[0] = HAZ_NONCE_REUSE_DISCLOSURE
91 return p
92}
93
94func nx_chacha20_safety_envelope() -> *SafetyEnvelope {
95 let e_raw: *u8 = sys_mmap(128)
96 let e: *SafetyEnvelope = e_raw as *SafetyEnvelope
97 e.sil_target = SIL_3
98 e.asil_target = ASIL_QM
99 e.dal_target = DAL_B
100 e.iec_62304_class = IEC_62304_B
101 e.n_evidence_ids = 6
102 e.evidence_ids = _nx_chacha20_evidence_ids()
103 e.n_hazard_ids = 1
104 e.hazard_ids = _nx_chacha20_hazard_ids()
105 let msg: *u8 = "32-bit block counter wraps at 256 GiB per nonce; chunk + rotate for long streams. Caller MUST guarantee 96-bit nonce uniqueness per key per RFC 8439 sect 4." as *u8
106 e.residual_risk = msg
107 e.residual_risk_len = 165
108 e.verdict = NX_VERDICT_NOT_YET_EVALUATED
109 return e
110}
111
112// Mask a 32-bit quantity stored in i64. Used after every arithmetic
113// op because NishiLang only has i64 and we simulate u32 semantics.
114func u32_mask(x: i64) -> i64 {
115 return x & 0xFFFFFFFF
116}
117
118// Delegated to nx_bits_rotl32 (canonical pre-mask phrasing). Each
119// ChaCha20 quarter-round does 4 rotates; full block = 80 rotates.
120func rotl32(x: i64, r: i64) -> i64 {
121 return nx_bits_rotl32(x, r)
122}
123
124// One ChaCha20 quarter-round: 4 state words shuffle via 4 ARX steps.
125// Takes a 16-word state and four lane indices (a, b, c, d).
126// Per RFC 8439 section 2.1.
127//
128// HOT-PATH NOTE (X-ROOM-005 rung A, 2026-06-14): the ARX steps are inlined
129// here -- the rotates are constant-amount (16/12/8/7) so the rotl32 ->
130// nx_bits_rotl32 call chain and the u32_mask calls are folded to straight
131// shift/or/and. The old phrasing made ~960 out-of-line calls per 64-byte
132// block (~273M for 16 MiB) -- that was the scalar inner-loop wall named in
133// 2026-06-10-room-perf-arc.md. State words are read into locals once and
134// written back once, so the array is indexed 4r+4w instead of ~16/quarter.
135// Math is BYTE-IDENTICAL to the helper phrasing (RFC 8439 KAT gates it):
136// rotl32(x,r) == ((x << r) | (x >> (32-r))) & 0xFFFFFFFF for x in u32.
137func qr(state: *i64, a: i64, b: i64, c: i64, d: i64) -> i64 {
138 var sa: i64 = state[a]
139 var sb: i64 = state[b]
140 var sc: i64 = state[c]
141 var sd: i64 = state[d]
142
143 sa = (sa + sb) & 0xFFFFFFFF
144 sd = sd ^ sa; sd = ((sd << 16) | (sd >> 16)) & 0xFFFFFFFF
145 sc = (sc + sd) & 0xFFFFFFFF
146 sb = sb ^ sc; sb = ((sb << 12) | (sb >> 20)) & 0xFFFFFFFF
147 sa = (sa + sb) & 0xFFFFFFFF
148 sd = sd ^ sa; sd = ((sd << 8) | (sd >> 24)) & 0xFFFFFFFF
149 sc = (sc + sd) & 0xFFFFFFFF
150 sb = sb ^ sc; sb = ((sb << 7) | (sb >> 25)) & 0xFFFFFFFF
151
152 state[a] = sa
153 state[b] = sb
154 state[c] = sc
155 state[d] = sd
156 return 0
157}
158
159// Read a little-endian u32 from buf[off..off+4].
160func load_u32_le(buf: *u8, off: i64) -> i64 {
161 let b0: i64 = buf[off + 0]
162 let b1: i64 = buf[off + 1]
163 let b2: i64 = buf[off + 2]
164 let b3: i64 = buf[off + 3]
165 return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
166}
167
168// Store a little-endian u32 to buf[off..off+4].
169func store_u32_le(buf: *u8, off: i64, v: i64) -> i64 {
170 buf[off + 0] = v & 0xFF
171 buf[off + 1] = (v >> 8) & 0xFF
172 buf[off + 2] = (v >> 16) & 0xFF
173 buf[off + 3] = (v >> 24) & 0xFF
174 return 0
175}
176
177// Generate one 64-byte keystream block into `out`.
178//
179// Initial state layout (RFC 8439 section 2.3):
180// words 0..3 : constants "expand 32-byte k" = 0x61707865 0x3320646e
181// 0x79622d32 0x6b206574 (little-endian of the string)
182// words 4..11 : 256-bit key (8 u32 LE)
183// words 12 : 32-bit counter
184// words 13..15 : 96-bit nonce (3 u32 LE)
185//
186// Then run 20 rounds = 10 double-rounds (column + diagonal), add the
187// initial state back in, and serialize.
188// The ChaCha20 permutation: words 0..15 already set by the caller; runs 20
189// rounds, adds the initial state back, serializes 64 bytes LE. Shared core
190// of every block variant (IETF + original/DJB) so there is one round impl.
191// perm CORE: caller supplies `init` scratch (>=16 i64) so the hot path allocates ZERO
192// per block. The old per-block sys_mmap here was the ~8 MB/s wall (~500k syscalls/16MiB,
193// G-PART-NET diagnosis 2026-06-13). Math is byte-identical (KAT-gated).
194// X-ROOM-005 rung B (2026-06-14): register-resident permutation. The 16
195// state words live in locals s0..s15 across all 10 double-rounds -- the
196// column round's writes feed the diagonal round directly (no array round-
197// trip, no qr() call/arg-pass per quarter-round = -80 calls/block on top of
198// rung A's inlined ARX). init copy i0..i15 holds the original words for the
199// final add-back. The `init` array param is now unused (kept for the stable
200// signature; callers still pass it). Output is BYTE-IDENTICAL to the qr()
201// reference above -- RFC 8439 KAT (nx_chacha20_test) gates every word.
202func chacha20_perm_core(state: *i64, out: *u8, init: *i64) -> i64 {
203 var s0: i64 = state[0]; var s1: i64 = state[1]; var s2: i64 = state[2]; var s3: i64 = state[3]
204 var s4: i64 = state[4]; var s5: i64 = state[5]; var s6: i64 = state[6]; var s7: i64 = state[7]
205 var s8: i64 = state[8]; var s9: i64 = state[9]; var s10: i64 = state[10]; var s11: i64 = state[11]
206 var s12: i64 = state[12]; var s13: i64 = state[13]; var s14: i64 = state[14]; var s15: i64 = state[15]
207 let i0: i64 = s0; let i1: i64 = s1; let i2: i64 = s2; let i3: i64 = s3
208 let i4: i64 = s4; let i5: i64 = s5; let i6: i64 = s6; let i7: i64 = s7
209 let i8: i64 = s8; let i9: i64 = s9; let i10: i64 = s10; let i11: i64 = s11
210 let i12: i64 = s12; let i13: i64 = s13; let i14: i64 = s14; let i15: i64 = s15
211
212 var r: i64 = 0
213 while r < 10 {
214 // --- column rounds ---
215 // QR(0,4,8,12)
216 s0=(s0+s4)&0xFFFFFFFF; s12=s12^s0; s12=((s12<<16)|(s12>>16))&0xFFFFFFFF
217 s8=(s8+s12)&0xFFFFFFFF; s4=s4^s8; s4=((s4<<12)|(s4>>20))&0xFFFFFFFF
218 s0=(s0+s4)&0xFFFFFFFF; s12=s12^s0; s12=((s12<<8)|(s12>>24))&0xFFFFFFFF
219 s8=(s8+s12)&0xFFFFFFFF; s4=s4^s8; s4=((s4<<7)|(s4>>25))&0xFFFFFFFF
220 // QR(1,5,9,13)
221 s1=(s1+s5)&0xFFFFFFFF; s13=s13^s1; s13=((s13<<16)|(s13>>16))&0xFFFFFFFF
222 s9=(s9+s13)&0xFFFFFFFF; s5=s5^s9; s5=((s5<<12)|(s5>>20))&0xFFFFFFFF
223 s1=(s1+s5)&0xFFFFFFFF; s13=s13^s1; s13=((s13<<8)|(s13>>24))&0xFFFFFFFF
224 s9=(s9+s13)&0xFFFFFFFF; s5=s5^s9; s5=((s5<<7)|(s5>>25))&0xFFFFFFFF
225 // QR(2,6,10,14)
226 s2=(s2+s6)&0xFFFFFFFF; s14=s14^s2; s14=((s14<<16)|(s14>>16))&0xFFFFFFFF
227 s10=(s10+s14)&0xFFFFFFFF;s6=s6^s10; s6=((s6<<12)|(s6>>20))&0xFFFFFFFF
228 s2=(s2+s6)&0xFFFFFFFF; s14=s14^s2; s14=((s14<<8)|(s14>>24))&0xFFFFFFFF
229 s10=(s10+s14)&0xFFFFFFFF;s6=s6^s10; s6=((s6<<7)|(s6>>25))&0xFFFFFFFF
230 // QR(3,7,11,15)
231 s3=(s3+s7)&0xFFFFFFFF; s15=s15^s3; s15=((s15<<16)|(s15>>16))&0xFFFFFFFF
232 s11=(s11+s15)&0xFFFFFFFF;s7=s7^s11; s7=((s7<<12)|(s7>>20))&0xFFFFFFFF
233 s3=(s3+s7)&0xFFFFFFFF; s15=s15^s3; s15=((s15<<8)|(s15>>24))&0xFFFFFFFF
234 s11=(s11+s15)&0xFFFFFFFF;s7=s7^s11; s7=((s7<<7)|(s7>>25))&0xFFFFFFFF
235 // --- diagonal rounds ---
236 // QR(0,5,10,15)
237 s0=(s0+s5)&0xFFFFFFFF; s15=s15^s0; s15=((s15<<16)|(s15>>16))&0xFFFFFFFF
238 s10=(s10+s15)&0xFFFFFFFF;s5=s5^s10; s5=((s5<<12)|(s5>>20))&0xFFFFFFFF
239 s0=(s0+s5)&0xFFFFFFFF; s15=s15^s0; s15=((s15<<8)|(s15>>24))&0xFFFFFFFF
240 s10=(s10+s15)&0xFFFFFFFF;s5=s5^s10; s5=((s5<<7)|(s5>>25))&0xFFFFFFFF
241 // QR(1,6,11,12)
242 s1=(s1+s6)&0xFFFFFFFF; s12=s12^s1; s12=((s12<<16)|(s12>>16))&0xFFFFFFFF
243 s11=(s11+s12)&0xFFFFFFFF;s6=s6^s11; s6=((s6<<12)|(s6>>20))&0xFFFFFFFF
244 s1=(s1+s6)&0xFFFFFFFF; s12=s12^s1; s12=((s12<<8)|(s12>>24))&0xFFFFFFFF
245 s11=(s11+s12)&0xFFFFFFFF;s6=s6^s11; s6=((s6<<7)|(s6>>25))&0xFFFFFFFF
246 // QR(2,7,8,13)
247 s2=(s2+s7)&0xFFFFFFFF; s13=s13^s2; s13=((s13<<16)|(s13>>16))&0xFFFFFFFF
248 s8=(s8+s13)&0xFFFFFFFF; s7=s7^s8; s7=((s7<<12)|(s7>>20))&0xFFFFFFFF
249 s2=(s2+s7)&0xFFFFFFFF; s13=s13^s2; s13=((s13<<8)|(s13>>24))&0xFFFFFFFF
250 s8=(s8+s13)&0xFFFFFFFF; s7=s7^s8; s7=((s7<<7)|(s7>>25))&0xFFFFFFFF
251 // QR(3,4,9,14)
252 s3=(s3+s4)&0xFFFFFFFF; s14=s14^s3; s14=((s14<<16)|(s14>>16))&0xFFFFFFFF
253 s9=(s9+s14)&0xFFFFFFFF; s4=s4^s9; s4=((s4<<12)|(s4>>20))&0xFFFFFFFF
254 s3=(s3+s4)&0xFFFFFFFF; s14=s14^s3; s14=((s14<<8)|(s14>>24))&0xFFFFFFFF
255 s9=(s9+s14)&0xFFFFFFFF; s4=s4^s9; s4=((s4<<7)|(s4>>25))&0xFFFFFFFF
256 r = r + 1
257 }
258
259 s0=(s0+i0)&0xFFFFFFFF; s1=(s1+i1)&0xFFFFFFFF; s2=(s2+i2)&0xFFFFFFFF; s3=(s3+i3)&0xFFFFFFFF
260 s4=(s4+i4)&0xFFFFFFFF; s5=(s5+i5)&0xFFFFFFFF; s6=(s6+i6)&0xFFFFFFFF; s7=(s7+i7)&0xFFFFFFFF
261 s8=(s8+i8)&0xFFFFFFFF; s9=(s9+i9)&0xFFFFFFFF; s10=(s10+i10)&0xFFFFFFFF; s11=(s11+i11)&0xFFFFFFFF
262 s12=(s12+i12)&0xFFFFFFFF;s13=(s13+i13)&0xFFFFFFFF;s14=(s14+i14)&0xFFFFFFFF; s15=(s15+i15)&0xFFFFFFFF
263
264 store_u32_le(out, 0, s0); store_u32_le(out, 4, s1); store_u32_le(out, 8, s2); store_u32_le(out, 12, s3)
265 store_u32_le(out, 16, s4); store_u32_le(out, 20, s5); store_u32_le(out, 24, s6); store_u32_le(out, 28, s7)
266 store_u32_le(out, 32, s8); store_u32_le(out, 36, s9); store_u32_le(out, 40, s10); store_u32_le(out, 44, s11)
267 store_u32_le(out, 48, s12); store_u32_le(out, 52, s13); store_u32_le(out, 56, s14); store_u32_le(out, 60, s15)
268 return 0
269}
270// back-compat single-block wrapper (allocates init once per call)
271func chacha20_perm(state: *i64, out: *u8) -> i64 {
272 let init: *i64 = sys_mmap(128) as *i64
273 return chacha20_perm_core(state, out, init)
274}
275
276func _chacha20_set_consts_key(state: *i64, key: *u8) -> i64 {
277 state[0] = 0x61707865; state[1] = 0x3320646e; state[2] = 0x79622d32; state[3] = 0x6b206574
278 state[4] = load_u32_le(key, 0); state[5] = load_u32_le(key, 4)
279 state[6] = load_u32_le(key, 8); state[7] = load_u32_le(key, 12)
280 state[8] = load_u32_le(key, 16); state[9] = load_u32_le(key, 20)
281 state[10] = load_u32_le(key, 24); state[11] = load_u32_le(key, 28)
282 return 0
283}
284
285// IETF ChaCha20 block (RFC 8439): 32-bit counter (word 12), 96-bit nonce.
286func chacha20_block(key: *u8, counter: i64, nonce: *u8, out: *u8) -> i64 {
287 let state: *i64 = sys_mmap(128) as *i64
288 _chacha20_set_consts_key(state, key)
289 state[12] = counter & 0xFFFFFFFF
290 state[13] = load_u32_le(nonce, 0)
291 state[14] = load_u32_le(nonce, 4)
292 state[15] = load_u32_le(nonce, 8)
293 return chacha20_perm(state, out)
294}
295
296// Original (DJB) ChaCha20 block: 64-bit counter (words 12-13) + 64-bit nonce
297// (words 14-15). This is the layout the original spec AND
298// chacha20-poly1305@openssh.com use. A proper first-class primitive -- the
299// SSH transport calls this directly with the 8-byte sequence-number nonce,
300// no IETF nonce-packing indirection.
301func chacha20_block_djb(key: *u8, counter: i64, nonce8: *u8, out: *u8) -> i64 {
302 let state: *i64 = sys_mmap(128) as *i64
303 _chacha20_set_consts_key(state, key)
304 state[12] = counter & 0xFFFFFFFF
305 state[13] = (counter >> 32) & 0xFFFFFFFF
306 state[14] = load_u32_le(nonce8, 0)
307 state[15] = load_u32_le(nonce8, 4)
308 return chacha20_perm(state, out)
309}
310// DJB-layout stream cipher: keystream from `counter` upward, 8-byte nonce.
311func chacha20_djb(key: *u8, counter: i64, nonce8: *u8, in_bytes: *u8, n: i64, out: *u8) -> i64 {
312 let ks: *u8 = sys_mmap(64)
313 var bi: i64 = 0
314 var pos: i64 = 0
315 while pos < n {
316 chacha20_block_djb(key, counter + bi, nonce8, ks)
317 let remain: i64 = n - pos
318 var take: i64 = 64
319 if remain < 64 { take = remain }
320 var b: i64 = 0
321 while b + 8 <= take { // word-wise XOR bulk (2026-07-03)
322 let pp: *i64 = ((in_bytes as i64) + pos + b) as *i64
323 let kp: *i64 = ((ks as i64) + b) as *i64
324 let op: *i64 = ((out as i64) + pos + b) as *i64
325 op[0] = pp[0] ^ kp[0]
326 b = b + 8
327 }
328 while b < take { out[pos + b] = in_bytes[pos + b] ^ ks[b]; b = b + 1 }
329 pos = pos + take; bi = bi + 1
330 }
331 return 0
332}
333
334// Encrypt/decrypt `in_bytes` of length `n` into `out` with key, nonce,
335// and starting block counter. ChaCha20 is its own inverse: calling
336// this twice with identical (key, counter, nonce) returns the original
337// plaintext. CC1-CC4 preserved because the keystream generation
338// depends only on (key, counter, nonce) and the XOR loop reads every
339// byte regardless of plaintext value.
340func chacha20_encrypt(key: *u8, counter: i64, nonce: *u8,
341 in_bytes: *u8, n: i64, out: *u8) -> i64 {
342 // scratch allocated ONCE (was 2x sys_mmap PER 64B block = the G-PART-NET bottleneck);
343 // per-block work is now pure register/memory math. Byte-identical to chacha20_block.
344 let ks: *u8 = sys_mmap(64)
345 let state: *i64 = sys_mmap(128) as *i64
346 let init: *i64 = sys_mmap(128) as *i64
347 // X-ROOM-005 rung C (2026-06-14): the rung-B perm core reads `state`
348 // READ-ONLY (it permutes locals, never writes back), so the constants,
349 // key, and nonce words are INVARIANT across every block -- set them ONCE
350 // here instead of re-deriving the key from bytes (_chacha20_set_consts_key
351 // = 8 load_u32_le) on every 64-byte block. Only the counter word changes.
352 _chacha20_set_consts_key(state, key)
353 state[13] = load_u32_le(nonce, 0)
354 state[14] = load_u32_le(nonce, 4)
355 state[15] = load_u32_le(nonce, 8)
356 var block_idx: i64 = 0
357 var pos: i64 = 0
358 while pos < n {
359 state[12] = (counter + block_idx) & 0xFFFFFFFF
360 chacha20_perm_core(state, ks, init)
361 let remain: i64 = n - pos
362 var take: i64 = 64
363 if remain < 64 { take = remain }
364 var b: i64 = 0
365 // WORD-WISE XOR (8 bytes/iter) for the bulk (2026-07-03): 8 i64 ops/block instead of 64
366 // byte-ops -- XOR is per-byte so word-wise is bit-identical (KAT-gated). Byte tail for <8.
367 // MEASURED 38->49 MB/s = 1.29x (the byte-indexing overhead was ~30% of block time).
368 while b + 8 <= take {
369 let pp: *i64 = ((in_bytes as i64) + pos + b) as *i64
370 let kp: *i64 = ((ks as i64) + b) as *i64
371 let op: *i64 = ((out as i64) + pos + b) as *i64
372 op[0] = pp[0] ^ kp[0]
373 b = b + 8
374 }
375 while b < take {
376 out[pos + b] = in_bytes[pos + b] ^ ks[b]
377 b = b + 1
378 }
379 pos = pos + take
380 block_idx = block_idx + 1
381 }
382 return 0
383}
384
385// ---- self-test (compile-only) ----
386//
387// Real known-answer validation against RFC 8439 Appendix A.1 test
388// Real KAT execution lives in runtime/nx_chacha20_test.nx, which
389// imports this module and runs RFC 8439 §2.4.2 (Sunscreen vector)
390// against the expected ciphertext. Removed the stub `main` so
391// importing this module from a test or AEAD file doesn't double-
392// define main.