chacha20.nx source
↩ module page · 215 lines · 7611 B
1// chacha20.nx -- RFC 8439 ChaCha20 stream cipher.
2//
3// 256-bit key, 96-bit nonce, 32-bit counter -> 64-byte keystream
4// blocks that XOR with plaintext. Specified in RFC 8439 (May 2018),
5// identical to IETF ChaCha20 (Bernstein 2008, IETF variant by Nir +
6// Langley 2014).
7//
8// Why ChaCha20:
9// - Naturally constant-time: only ADD, ROTATE, XOR operations on
10// 32-bit words. No S-box tables, no branches on secret data.
11// Immune to cache side-channels that plague table-based AES.
12// - Quantum stance: under Grover's algorithm the effective security
13// of a 256-bit-keyed stream cipher is 128 bits -- secure for the
14// forseeable future. Pairs with ML-KEM-768 for TLS 1.3 PQ hybrid.
15// - Simpler than AES (no key schedule distinct from encryption;
16// no bitsliced constant-time variant required).
17//
18// Invariants:
19// CC1 No branches on key, nonce, counter, or state values. Every
20// word of the 16-word state is processed identically.
21// CC2 Rotate-left operations use constant shift amounts (7, 8, 9,
22// 12, 13, 16, 18, 25) -- never driven by secret data.
23// CC3 Memory access pattern depends only on block length, not on
24// key/nonce/counter values.
25// CC4 The 32-bit counter is caller-owned; wrap-around behaviour
26// at 2^32 blocks (= 256 GiB with a given key+nonce) is
27// undefined by the spec. Callers must rekey before wrap.
28//
29// API:
30// chacha20_block(key, counter, nonce, out) -- one 64-byte block
31// chacha20_encrypt(key, counter, nonce, in, n, out)
32// -- full message
33//
34// References:
35// RFC 8439, section 2.3 "The ChaCha20 Block Function"
36// RFC 8439, section 2.4 "The ChaCha20 Encryption Algorithm"
37// Test vectors: RFC 8439 Appendix A.1 / A.2
38
39import "syscalls.nx"
40import "nx_bits.nx"
41
42// Mask a 32-bit quantity stored in i64. Used after every arithmetic
43// op because NishiLang only has i64 and we simulate u32 semantics.
44func u32_mask(x: i64) -> i64 {
45 return x & 0xFFFFFFFF
46}
47
48// Delegated to nx_bits_rotl32.
49func rotl32(x: i64, r: i64) -> i64 {
50 return nx_bits_rotl32(x, r)
51}
52
53// One ChaCha20 quarter-round: 4 state words shuffle via 4 ARX steps.
54// Takes a 16-word state and four lane indices (a, b, c, d).
55// Per RFC 8439 section 2.1.
56func qr(state: *i64, a: i64, b: i64, c: i64, d: i64) -> i64 {
57 let va1: i64 = u32_mask(state[a] + state[b])
58 state[a] = va1
59 state[d] = rotl32(state[d] ^ va1, 16)
60
61 let vc1: i64 = u32_mask(state[c] + state[d])
62 state[c] = vc1
63 state[b] = rotl32(state[b] ^ vc1, 12)
64
65 let va2: i64 = u32_mask(state[a] + state[b])
66 state[a] = va2
67 state[d] = rotl32(state[d] ^ va2, 8)
68
69 let vc2: i64 = u32_mask(state[c] + state[d])
70 state[c] = vc2
71 state[b] = rotl32(state[b] ^ vc2, 7)
72 return 0
73}
74
75// Read a little-endian u32 from buf[off..off+4].
76func load_u32_le(buf: *u8, off: i64) -> i64 {
77 let b0: i64 = buf[off + 0]
78 let b1: i64 = buf[off + 1]
79 let b2: i64 = buf[off + 2]
80 let b3: i64 = buf[off + 3]
81 return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
82}
83
84// Store a little-endian u32 to buf[off..off+4].
85func store_u32_le(buf: *u8, off: i64, v: i64) -> i64 {
86 buf[off + 0] = v & 0xFF
87 buf[off + 1] = (v >> 8) & 0xFF
88 buf[off + 2] = (v >> 16) & 0xFF
89 buf[off + 3] = (v >> 24) & 0xFF
90 return 0
91}
92
93// Generate one 64-byte keystream block into `out`.
94//
95// Initial state layout (RFC 8439 section 2.3):
96// words 0..3 : constants "expand 32-byte k" = 0x61707865 0x3320646e
97// 0x79622d32 0x6b206574 (little-endian of the string)
98// words 4..11 : 256-bit key (8 u32 LE)
99// words 12 : 32-bit counter
100// words 13..15 : 96-bit nonce (3 u32 LE)
101//
102// Then run 20 rounds = 10 double-rounds (column + diagonal), add the
103// initial state back in, and serialize.
104func chacha20_block(key: *u8, counter: i64, nonce: *u8, out: *u8) -> i64 {
105 let state_raw: *u8 = sys_mmap(128) // 16 u32 = 64 bytes; allocate 128 for alignment headroom
106 let state: *i64 = state_raw as *i64
107
108 // Constants "expand 32-byte k"
109 state[0] = 0x61707865
110 state[1] = 0x3320646e
111 state[2] = 0x79622d32
112 state[3] = 0x6b206574
113
114 // Key: 8 x u32 LE
115 state[4] = load_u32_le(key, 0)
116 state[5] = load_u32_le(key, 4)
117 state[6] = load_u32_le(key, 8)
118 state[7] = load_u32_le(key, 12)
119 state[8] = load_u32_le(key, 16)
120 state[9] = load_u32_le(key, 20)
121 state[10] = load_u32_le(key, 24)
122 state[11] = load_u32_le(key, 28)
123
124 // Counter (32-bit)
125 state[12] = counter & 0xFFFFFFFF
126
127 // Nonce: 3 x u32 LE = 96 bits
128 state[13] = load_u32_le(nonce, 0)
129 state[14] = load_u32_le(nonce, 4)
130 state[15] = load_u32_le(nonce, 8)
131
132 // Snapshot initial state so we can add it back after the rounds.
133 let init_raw: *u8 = sys_mmap(128)
134 let init: *i64 = init_raw as *i64
135 var i: i64 = 0
136 while i < 16 { init[i] = state[i]; i = i + 1 }
137
138 // 10 double-rounds (20 total rounds).
139 var r: i64 = 0
140 while r < 10 {
141 // Column rounds.
142 qr(state, 0, 4, 8, 12)
143 qr(state, 1, 5, 9, 13)
144 qr(state, 2, 6, 10, 14)
145 qr(state, 3, 7, 11, 15)
146 // Diagonal rounds.
147 qr(state, 0, 5, 10, 15)
148 qr(state, 1, 6, 11, 12)
149 qr(state, 2, 7, 8, 13)
150 qr(state, 3, 4, 9, 14)
151 r = r + 1
152 }
153
154 // Add initial state back in (mod 2^32 per word).
155 var j: i64 = 0
156 while j < 16 {
157 state[j] = u32_mask(state[j] + init[j])
158 j = j + 1
159 }
160
161 // Serialize 64 bytes little-endian.
162 var k: i64 = 0
163 while k < 16 {
164 store_u32_le(out, k * 4, state[k])
165 k = k + 1
166 }
167 return 0
168}
169
170// Encrypt/decrypt `in_bytes` of length `n` into `out` with key, nonce,
171// and starting block counter. ChaCha20 is its own inverse: calling
172// this twice with identical (key, counter, nonce) returns the original
173// plaintext. CC1-CC4 preserved because the keystream generation
174// depends only on (key, counter, nonce) and the XOR loop reads every
175// byte regardless of plaintext value.
176func chacha20_encrypt(key: *u8, counter: i64, nonce: *u8,
177 in_bytes: *u8, n: i64, out: *u8) -> i64 {
178 let ks_raw: *u8 = sys_mmap(64)
179 var block_idx: i64 = 0
180 var pos: i64 = 0
181 while pos < n {
182 chacha20_block(key, counter + block_idx, nonce, ks_raw)
183 let remain: i64 = n - pos
184 var take: i64 = 64
185 if remain < 64 { take = remain }
186 var b: i64 = 0
187 while b < take {
188 out[pos + b] = in_bytes[pos + b] ^ ks_raw[b]
189 b = b + 1
190 }
191 pos = pos + take
192 block_idx = block_idx + 1
193 }
194 return 0
195}
196
197// ---- self-test (compile-only) ----
198//
199// Real known-answer validation against RFC 8439 Appendix A.1 test
200// vector 1 (all-zero key/nonce, counter=0) requires an execution
201// harness. Structure check: compile produces a valid SSA IR with
202// all branches balanced and the arithmetic paths closed.
203func main() -> i64 {
204 let key: *u8 = sys_mmap(32)
205 let nonce: *u8 = sys_mmap(12)
206 let out: *u8 = sys_mmap(64)
207 // All-zero key + nonce + counter=0 is RFC 8439 A.1 test 1.
208 // Expected first 4 bytes: 0x76 0xb8 0xe0 0xad
209 var i: i64 = 0
210 while i < 32 { key[i] = 0; i = i + 1 }
211 i = 0
212 while i < 12 { nonce[i] = 0; i = i + 1 }
213 chacha20_block(key, 0, nonce, out)
214 return out[0] as i64
215}