code wiki / (root) / nx_http_session.nx

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}