code wiki / (root) / nx_chacha20.nx

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.