nx_http_session.nx source
↩ module page · 335 lines · 11889 B
1// nx_http_session.nx -- session-token format + Set-Cookie/Cookie I/O.
2//
3// Per cardinal feedback-privacy-by-default-no-tracking:
4// - Session tokens are OPAQUE. Server-side state (caller's
5// responsibility, via future nx_kv_store) maps token -> data.
6// The cookie carries ONLY the random ID; no encoded user info.
7// - Sessions are EPHEMERAL. Default Max-Age = 30 min (1800s).
8// Caller picks longer/shorter; the substrate's default tilts
9// short.
10// - Default flags: HttpOnly + Secure + SameSite=Strict. Together
11// they prevent: JS cookie theft (XSS escalation), HTTP-only
12// leakage, CSRF token replay.
13//
14// Per cardinal feedback-no-third-party-trust-native-or-nothing:
15// substrate's own session layer; no JWT library, no
16// express-session, no PHP $_SESSION. Substrate produces 32 hex
17// chars; future nx_kv_store maps them to server-side records.
18//
19// Token format: 32 chars lowercase hex = 16 random bytes from
20// caller's CSPRNG. 128 bits of entropy. RFC 7616 ยง3.4.1
21// guidance: 64+ bits adequate; we use 128 for headroom.
22//
23// nx_capability_claims:
24// needs: [sealed_enum, byte_ops, caller_supplied_random]
25// provides: [session_token_format, set_cookie_emit,
26// cookie_header_parse, secure_default_flags]
27// safety: [no_unchecked_deref, no_floating_point, no_syscall,
28// opaque_token_no_pii, ephemeral_default]
29// verdict: [sealed_enum_5_state]
30// license: ORIGINAL
31// kind: racing_crew_specialist
32// layer: L3 (algorithm: session token + cookie I/O)
33
34// ---- Sealed enum: session verdict --------------------------------
35
36const NXSS_OK: i64 = 0
37const NXSS_OOM_BUFFER: i64 = 1
38const NXSS_BAD_TOKEN: i64 = 2
39const NXSS_NOT_FOUND: i64 = 3
40const NXSS_BAD_ARG: i64 = 4
41const NXSS_VERDICT_N: i64 = 5
42
43func nxss_verdict_is_valid(v: i64) -> i64 {
44 if v < 0 { return 0 }
45 if v >= NXSS_VERDICT_N { return 0 }
46 return 1
47}
48
49func nxss_verdict_name(v: i64) -> *u8 {
50 if v == NXSS_OK { return "OK" as *u8 }
51 if v == NXSS_OOM_BUFFER { return "OOM_BUFFER" as *u8 }
52 if v == NXSS_BAD_TOKEN { return "BAD_TOKEN" as *u8 }
53 if v == NXSS_NOT_FOUND { return "NOT_FOUND" as *u8 }
54 if v == NXSS_BAD_ARG { return "BAD_ARG" as *u8 }
55 return "INVALID" as *u8
56}
57
58// ---- Token format constants --------------------------------------
59
60const NXSS_TOKEN_BYTES: i64 = 16 // 128-bit entropy
61const NXSS_TOKEN_HEX_LEN: i64 = 32 // 32 hex chars
62
63// Default session lifetime (30 minutes). Short by design per
64// cardinal feedback-privacy-by-default; longer requires caller
65// explicit choice.
66const NXSS_DEFAULT_MAX_AGE_S: i64 = 1800
67
68// ---- Hex emit / validate ----------------------------------------
69
70// Emit `n` bytes as 2n lowercase hex chars to out[off..].
71// Bounded by cap. Returns NXSS_OK or NXSS_OOM_BUFFER.
72func nxss_emit_hex(src: *u8, src_n: i64,
73 out: *u8, off: *i64, cap: i64) -> i64 {
74 var i: i64 = 0
75 while i < src_n {
76 if *off + 2 > cap { return NXSS_OOM_BUFFER }
77 let b: i64 = src[i] as i64
78 let hi: i64 = (b >> 4) & 0xf
79 let lo: i64 = b & 0xf
80 var hh: i64 = 0x30 + hi
81 if hi >= 10 { hh = 0x61 + (hi - 10) }
82 var ll: i64 = 0x30 + lo
83 if lo >= 10 { ll = 0x61 + (lo - 10) }
84 out[*off] = hh as u8
85 out[*off + 1] = ll as u8
86 *off = *off + 2
87 i = i + 1
88 }
89 return NXSS_OK
90}
91
92// Predicate: byte is valid lowercase hex (0-9 or a-f).
93func nxss_is_hex_lower(b: i64) -> i64 {
94 if b >= 0x30 && b <= 0x39 { return 1 }
95 if b >= 0x61 && b <= 0x66 { return 1 }
96 return 0
97}
98
99// Validate that buf[0..n] is exactly n lowercase hex chars.
100func nxss_validate_token_hex(buf: *u8, n: i64) -> i64 {
101 if buf == (0 as *u8) { return 0 }
102 if n != NXSS_TOKEN_HEX_LEN { return 0 }
103 var i: i64 = 0
104 while i < n {
105 if nxss_is_hex_lower(buf[i] as i64) == 0 { return 0 }
106 i = i + 1
107 }
108 return 1
109}
110
111// ---- Build a session token from caller-supplied random bytes ----
112//
113// Caller passes 16 random bytes (from nx_csprng_fill or equivalent
114// CSPRNG). Substrate emits 32 hex chars. No internal random source
115// per substrate boundary discipline.
116
117func nx_http_session_token_from_bytes(
118 random_16: *u8,
119 out_token: *u8, out_off: *i64, out_cap: i64) -> i64 {
120 if random_16 == (0 as *u8) { return NXSS_BAD_ARG }
121 if out_token == (0 as *u8) { return NXSS_BAD_ARG }
122 if out_off == (0 as *i64) { return NXSS_BAD_ARG }
123 if out_cap < NXSS_TOKEN_HEX_LEN { return NXSS_OOM_BUFFER }
124 return nxss_emit_hex(random_16, NXSS_TOKEN_BYTES,
125 out_token, out_off, out_cap)
126}
127
128// ---- Set-Cookie emitter ------------------------------------------
129//
130// Emits a full Set-Cookie header line with secure defaults:
131// Set-Cookie: nishi_sess=<token>; Path=/; Max-Age=<n>;
132// HttpOnly; Secure; SameSite=Strict\r\n
133//
134// Notes:
135// - HttpOnly: JS cannot read (mitigates XSS cookie-steal)
136// - Secure: transmitted over HTTPS only. HTTP browsers
137// accept and store but won't send to HTTP.
138// Required by SameSite=Strict in many browsers.
139// - SameSite=Strict: not sent on cross-origin requests
140// (structural CSRF prevention for state-changing
141// forms; complements CSP frame-ancestors 'none').
142// - Path=/: cookie applies to whole origin (default behavior).
143//
144// Caller picks the cookie NAME so multiple apps in one origin can
145// have distinct sessions. Common default: "nishi_sess".
146
147func nx_http_session_emit_set_cookie(
148 out: *u8, off: *i64, cap: i64,
149 cookie_name: *u8, cookie_name_n: i64,
150 token: *u8, token_n: i64,
151 max_age_s: i64) -> i64 {
152 if out == (0 as *u8) { return NXSS_BAD_ARG }
153 if off == (0 as *i64) { return NXSS_BAD_ARG }
154 if cookie_name == (0 as *u8) { return NXSS_BAD_ARG }
155 if token == (0 as *u8) { return NXSS_BAD_ARG }
156 if cap <= 0 { return NXSS_BAD_ARG }
157 if cookie_name_n <= 0 { return NXSS_BAD_ARG }
158 if token_n != NXSS_TOKEN_HEX_LEN { return NXSS_BAD_TOKEN }
159 if max_age_s < 0 { return NXSS_BAD_ARG }
160 if nxss_validate_token_hex(token, token_n) != 1 { return NXSS_BAD_TOKEN }
161
162 // "Set-Cookie: "
163 let p1: *u8 = "Set-Cookie: " as *u8
164 var i: i64 = 0
165 while p1[i] != 0 {
166 if *off >= cap { return NXSS_OOM_BUFFER }
167 out[*off] = p1[i]
168 *off = *off + 1
169 i = i + 1
170 }
171 // cookie_name=
172 var j: i64 = 0
173 while j < cookie_name_n {
174 if *off >= cap { return NXSS_OOM_BUFFER }
175 out[*off] = cookie_name[j]
176 *off = *off + 1
177 j = j + 1
178 }
179 if *off >= cap { return NXSS_OOM_BUFFER }
180 out[*off] = 0x3d as u8 // =
181 *off = *off + 1
182 // token
183 var k: i64 = 0
184 while k < token_n {
185 if *off >= cap { return NXSS_OOM_BUFFER }
186 out[*off] = token[k]
187 *off = *off + 1
188 k = k + 1
189 }
190 // "; Path=/; Max-Age="
191 let p2: *u8 = "; Path=/; Max-Age=" as *u8
192 var m: i64 = 0
193 while p2[m] != 0 {
194 if *off >= cap { return NXSS_OOM_BUFFER }
195 out[*off] = p2[m]
196 *off = *off + 1
197 m = m + 1
198 }
199 // max_age in decimal
200 if max_age_s == 0 {
201 if *off >= cap { return NXSS_OOM_BUFFER }
202 out[*off] = 0x30 as u8
203 *off = *off + 1
204 } else {
205 var n: i64 = max_age_s
206 let dig_start: i64 = *off
207 while n > 0 {
208 if *off >= cap { return NXSS_OOM_BUFFER }
209 out[*off] = (0x30 + (n - (n / 10) * 10)) as u8
210 *off = *off + 1
211 n = n / 10
212 }
213 var lo: i64 = dig_start
214 var hi: i64 = *off - 1
215 while lo < hi {
216 let tmp: i64 = out[lo] as i64
217 out[lo] = out[hi]
218 out[hi] = tmp as u8
219 lo = lo + 1
220 hi = hi - 1
221 }
222 }
223 // "; HttpOnly; Secure; SameSite=Strict\r\n"
224 let p3: *u8 = "; HttpOnly; Secure; SameSite=Strict\r\n" as *u8
225 var s: i64 = 0
226 while p3[s] != 0 {
227 if *off >= cap { return NXSS_OOM_BUFFER }
228 out[*off] = p3[s]
229 *off = *off + 1
230 s = s + 1
231 }
232 return NXSS_OK
233}
234
235// Convenience: emit Set-Cookie with substrate defaults
236// (name="nishi_sess", max_age=1800).
237func nx_http_session_emit_set_cookie_default(
238 out: *u8, off: *i64, cap: i64, token: *u8, token_n: i64) -> i64 {
239 return nx_http_session_emit_set_cookie(
240 out, off, cap,
241 "nishi_sess" as *u8, 10,
242 token, token_n,
243 NXSS_DEFAULT_MAX_AGE_S)
244}
245
246// ---- Cookie header parser ----------------------------------------
247//
248// Parses an RFC 6265 Cookie header value (e.g., from a request
249// header parsed via nx_http_header_find) to extract the named
250// cookie's value.
251//
252// Cookie header format: `name1=value1; name2=value2; ...`
253//
254// Returns NXSS_OK with out_val_off + out_val_len pointing into the
255// header value buffer on found. NXSS_NOT_FOUND if absent.
256
257// Helper: scan forward from `start` while predicate true; return offset.
258func nxss_scan_until(buf: *u8, end: i64, start: i64,
259 stop_byte_a: i64, stop_byte_b: i64) -> i64 {
260 var p: i64 = start
261 while p < end {
262 let b: i64 = buf[p] as i64
263 if b == stop_byte_a { return p }
264 if b == stop_byte_b { return p }
265 p = p + 1
266 }
267 return end
268}
269
270func nxss_skip_ws(buf: *u8, end: i64, start: i64) -> i64 {
271 var p: i64 = start
272 while p < end {
273 let b: i64 = buf[p] as i64
274 if b != 0x20 && b != 0x09 { return p }
275 p = p + 1
276 }
277 return end
278}
279
280func nxss_bytes_eq(a: *u8, b: *u8, n: i64) -> i64 {
281 var i: i64 = 0
282 while i < n {
283 if a[i] != b[i] { return 0 }
284 i = i + 1
285 }
286 return 1
287}
288
289func nx_http_session_parse_cookie(
290 header_val: *u8, header_val_n: i64,
291 cookie_name: *u8, cookie_name_n: i64,
292 out_val_off: *i64, out_val_len: *i64) -> i64 {
293 if header_val == (0 as *u8) { return NXSS_BAD_ARG }
294 if cookie_name == (0 as *u8) { return NXSS_BAD_ARG }
295 if out_val_off == (0 as *i64) { return NXSS_BAD_ARG }
296 if out_val_len == (0 as *i64) { return NXSS_BAD_ARG }
297 if header_val_n <= 0 { return NXSS_BAD_ARG }
298 if cookie_name_n <= 0 { return NXSS_BAD_ARG }
299
300 var p: i64 = 0
301 while p < header_val_n {
302 // Skip leading whitespace before name.
303 p = nxss_skip_ws(header_val, header_val_n, p)
304 if p >= header_val_n { return NXSS_NOT_FOUND }
305 let name_start: i64 = p
306 // Scan to `=` or `;`.
307 let name_end: i64 = nxss_scan_until(header_val, header_val_n,
308 p, 0x3d, 0x3b)
309 if name_end >= header_val_n { return NXSS_NOT_FOUND }
310 // Match name?
311 let nm_len: i64 = name_end - name_start
312 if header_val[name_end] == 0x3d as u8 {
313 if nm_len == cookie_name_n {
314 let nm_ptr: *u8 = ((header_val as i64) + name_start) as *u8
315 if nxss_bytes_eq(nm_ptr, cookie_name, nm_len) == 1 {
316 // Match. Value runs from name_end+1 to next `;` or end.
317 let val_start: i64 = name_end + 1
318 let val_end: i64 = nxss_scan_until(header_val, header_val_n,
319 val_start, 0x3b, 0x3b)
320 *out_val_off = val_start
321 *out_val_len = val_end - val_start
322 return NXSS_OK
323 }
324 }
325 // No match; advance past value + `;`.
326 p = nxss_scan_until(header_val, header_val_n, name_end + 1, 0x3b, 0x3b)
327 if p < header_val_n { p = p + 1 } // skip the `;`
328 }
329 if header_val[name_end] == 0x3b as u8 {
330 // No `=` in this pair; advance past `;`.
331 p = name_end + 1
332 }
333 }
334 return NXSS_NOT_FOUND
335}