code wiki / (root) / chacha20.nx

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}