nx_csrf_token.nx source
↩ module page · 179 lines · 5827 B
1// csrf_token.nx -- Cross-Site Request Forgery token generation.
2//
3// Standard defence against forged cross-origin form submits:
4// server issues a token tied to the user's session, embeds it in
5// forms, rejects submissions whose token doesn't HMAC-match the
6// session. Since cross-origin JS can't read the session cookie
7// or the issued token, attacker can't forge a valid request.
8//
9// OWASP recommended pattern: \"Signed Double-Submit Cookie\" --
10// token = random + HMAC(session_id, random). Verification
11// splits, re-derives HMAC, constant-time compares.
12//
13// Composes rand.nx + hmac.nx + base64.nx + ct.nx.
14//
15// Token format (URL-safe, no padding):
16// base64url(random_32_bytes) . base64url(hmac[0..16])
17//
18// 32 bytes random = 256 bits unpredictability; 128-bit truncated
19// HMAC = collision-resistant auth tag for this use. Total URI-
20// safe length ~65 chars.
21//
22// Invariants:
23// CT1 Token split at '.'; left = random, right = MAC.
24// CT2 Verification re-derives MAC from (session_id, random)
25// and ct_memcmp against provided MAC.
26// CT3 Session ID opaque to this module -- any byte-stable
27// session identifier works.
28
29// nx_safety_envelope:
30// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
31// sil_target: SIL1
32// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
33// verdict: NOT_YET_EVALUATED
34
35import "nx_syscalls.nx"
36import "nx_rand.nx"
37import "nx_hmac.nx"
38import "nx_base64.nx"
39import "nx_ct.nx"
40
41const CSRF_ERR_FORMAT: i64 = -1
42const CSRF_ERR_MAC: i64 = -2
43const CSRF_RAND_BYTES: i64 = 32
44const CSRF_MAC_BYTES: i64 = 16
45
46// Encode raw bytes as base64url without padding into out; returns
47// bytes written.
48func csrf_b64url_encode(data: *u8, n: i64, out: *u8) -> i64 {
49 let scratch: *u8 = sys_mmap(n * 2 + 16)
50 let b_len: i64 = b64_encode(data, n, scratch)
51 var stripped: i64 = b_len
52 while stripped > 0 {
53 if scratch[stripped - 1] != 0x3D { break }
54 stripped = stripped - 1
55 }
56 var i: i64 = 0
57 while i < stripped {
58 var c: i64 = scratch[i]
59 if c == 0x2B { c = 0x2D }
60 if c == 0x2F { c = 0x5F }
61 out[i] = c
62 i = i + 1
63 }
64 return stripped
65}
66
67// Decode base64url (padding-tolerant).
68func csrf_b64url_decode(chars: *u8, n: i64, out: *u8) -> i64 {
69 let scratch: *u8 = sys_mmap(n + 16)
70 var i: i64 = 0
71 while i < n {
72 var c: i64 = chars[i]
73 if c == 0x2D { c = 0x2B }
74 if c == 0x5F { c = 0x2F }
75 scratch[i] = c
76 i = i + 1
77 }
78 var padded: i64 = n
79 while padded % 4 != 0 {
80 scratch[padded] = 0x3D
81 padded = padded + 1
82 }
83 return b64_decode(scratch, padded, out)
84}
85
86// Generate a CSRF token bound to the given session ID. Writes
87// to out and returns bytes written.
88func csrf_token_new(session_id: *u8, session_id_len: i64,
89 out: *u8, cap: i64) -> i64 {
90 let randb: *u8 = sys_mmap(64)
91 rand_bytes(randb, CSRF_RAND_BYTES)
92
93 // MAC over the random bytes.
94 let mac: *u8 = sys_mmap(64)
95 hmac_sha256(session_id, session_id_len,
96 randb, CSRF_RAND_BYTES, mac)
97
98 // Encode both halves URL-safe.
99 let r_enc_len: i64 = csrf_b64url_encode(randb, CSRF_RAND_BYTES, out)
100 if r_enc_len <= 0 { return CSRF_ERR_FORMAT }
101 if r_enc_len + 1 >= cap { return CSRF_ERR_FORMAT }
102 out[r_enc_len] = 0x2E // '.'
103 let m_enc_len: i64 = csrf_b64url_encode(mac, CSRF_MAC_BYTES,
104 out + r_enc_len + 1)
105 if m_enc_len <= 0 { return CSRF_ERR_FORMAT }
106 return r_enc_len + 1 + m_enc_len
107}
108
109// Verify a token against a session ID. Returns 0 on success,
110// negative on format error or MAC mismatch.
111func csrf_token_verify(token: *u8, token_len: i64,
112 session_id: *u8, session_id_len: i64) -> i64 {
113 // Find separator '.'.
114 var dot: i64 = -1
115 var i: i64 = 0
116 while i < token_len {
117 if token[i] == 0x2E { dot = i; break }
118 i = i + 1
119 }
120 if dot < 0 { return CSRF_ERR_FORMAT }
121
122 let r_enc_len: i64 = dot
123 let m_enc_off: i64 = dot + 1
124 let m_enc_len: i64 = token_len - m_enc_off
125
126 // Decode random half.
127 let randb: *u8 = sys_mmap(64)
128 let r_bytes: i64 = csrf_b64url_decode(token, r_enc_len, randb)
129 if r_bytes != CSRF_RAND_BYTES { return CSRF_ERR_FORMAT }
130
131 // Decode provided MAC.
132 let prov_mac: *u8 = sys_mmap(64)
133 let p_bytes: i64 = csrf_b64url_decode(token + m_enc_off, m_enc_len,
134 prov_mac)
135 if p_bytes != CSRF_MAC_BYTES { return CSRF_ERR_FORMAT }
136
137 // Recompute expected MAC.
138 let exp_mac: *u8 = sys_mmap(64)
139 hmac_sha256(session_id, session_id_len,
140 randb, CSRF_RAND_BYTES, exp_mac)
141 // Only compare first CSRF_MAC_BYTES of the 32-byte HMAC (the
142 // token truncation matches).
143 if ct_memcmp(prov_mac, exp_mac, CSRF_MAC_BYTES) != 0 {
144 return CSRF_ERR_MAC
145 }
146 return 0
147}
148
149// Compile-only smoke.
150func main() -> i64 {
151 let session_id: *u8 = "session_for_elder_0123456789"
152 let tok: *u8 = sys_mmap(256)
153 let n: i64 = csrf_token_new(session_id, 28, tok, 256)
154 if n <= 0 { return 1 }
155
156 // Contains exactly one '.'
157 var dots: i64 = 0
158 var i: i64 = 0
159 while i < n {
160 if tok[i] == 0x2E { dots = dots + 1 }
161 i = i + 1
162 }
163 if dots != 1 { return 2 }
164
165 // Verify round-trip.
166 if csrf_token_verify(tok, n, session_id, 28) != 0 { return 3 }
167
168 // Wrong session -> MAC mismatch.
169 if csrf_token_verify(tok, n, "other_session_0000000000000", 27)
170 != CSRF_ERR_MAC {
171 return 4
172 }
173
174 // Tamper last byte -> fail.
175 tok[n - 1] = tok[n - 1] ^ 1
176 let rc: i64 = csrf_token_verify(tok, n, session_id, 28)
177 if rc == 0 { return 5 }
178 return 0
179}