code wiki / _hdl_build / nx_invite_token.nx

nx_invite_token.nx source

↩ module page · 346 lines · 15008 B

1// nx_invite_token.nx -- SECRET single-use, expiring, realm+level-scoped INVITE TOKEN store. 2// 3// Purpose: let a remote party (e.g. a law firm) self-register their OWN credential on an 4// admin.<domain> portal WITHOUT a guessable handle being the only gate -- they present a 5// secret single-use invite token that an owner minted out-of-band. The token is the bearer 6// secret; the server stores ONLY sha256(token) so a stolen store cannot reconstruct any 7// live token (rule: secrets never at rest in the clear). 8// 9// Storage doctrine (ADDITIVE-ONLY, per global rule 13), modeled on hub/nx_user_account_store.nx 10// (nx_uas_append): append-only text log, one row per line, a single sys_write of < 512 bytes => 11// atomic on the substrate (O_APPEND). LATEST row for a token-hash WINS. Single-use = consume 12// appends a superseding "consumed" row; history is never deleted. 13// 14// THE TOKEN: inv_issue mints 32 CSPRNG bytes -> 64 lowercase-hex chars (out_token_hex) = the 15// bearer secret returned to the caller. The STORED identity is sha256(out_token_hex) hex-encoded. 16// inv_check/inv_consume re-hash the presented hex token the SAME way (sha256 of the 64-hex bytes), 17// so issue and check agree by construction. (Hashing the printable hex token -- not re-decoding to 18// raw -- keeps the wire contract one opaque string and avoids a decode step on the hot path.) 19// 20// Row formats (space-delimited, '\n'-terminated; realm is a space/newline-free realm id): 21// issued: INV issued <hash:64hex> <realm> <level:dec> <expiry:hex16>\n 22// consumed: INV consumed <hash:64hex> <ts:hex16>\n 23// 24// COMPOSES: nx_syscalls (openat_append/read_file/fsync/write), nx_csprng (nx_csprng_fill), 25// nx_sha256 (sha256_digest -- the canonical substrate SHA-256 one-shot). 26// COMPOSED BY: _hdl_build/nx_docportal_admin_daemon (POST /admin/register invite gate). 27// license_tier: ORIGINAL 28import "nx_syscalls.nx" 29import "nx_csprng.nx" 30import "nx_sha256.nx" 31 32// ===== Sealed verdict surface (codes 1480-1499; disjoint from nx_uas 1460-1479) ================ 33const NX_INV_OK: i64 = 0 34const NX_INV_BAD_INPUT: i64 = 1480 35const NX_INV_IO_FAILED: i64 = 1481 36const NX_INV_RNG_FAILED: i64 = 1482 37 38// ===== Wire / row geometry (no magic numbers, rule 11) ========================================= 39const NX_INV_TOKEN_BYTES: i64 = 32 // CSPRNG entropy per token 40const NX_INV_TOKEN_HEX: i64 = 64 // = 2 * NX_INV_TOKEN_BYTES (the wire token width) 41const NX_INV_HASH_HEX: i64 = 64 // = 2 * 32 (hex of sha256(token)) 42const NX_INV_ISSUED_HASH_OFF: i64 = 11 // strlen("INV issued ") 43const NX_INV_CONSUMED_HASH_OFF: i64 = 13 // strlen("INV consumed ") 44const NX_INV_LINE_CAP: i64 = 512 // one row, comfortably < 512 (atomic append) 45const NX_INV_MAX_REALM_BYTES: i64 = 128 // realm-id upper bound (stays inside one row) 46const NX_INV_EXPIRY_HEX: i64 = 16 // 64-bit unix expiry, fixed-width hex 47const NX_INV_MAX_STORE_BYTES: i64 = 16777216 // 16 MiB scan cap 48 49// ===== small pure helpers ====================================================================== 50 51func _inv_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { d[o + i] = s[i]; i = i + 1 } return o + i } 52func _inv_catn(d: *u8, o: i64, s: *u8, n: i64) -> i64 { var i: i64 = 0; while i < n { d[o + i] = s[i]; i = i + 1 } return o + n } 53 54func _inv_hex_enc(src: *u8, n: i64, out: *u8) -> i64 { 55 let hx: *u8 = "0123456789abcdef" as *u8 56 var i: i64 = 0 57 while i < n { 58 out[i*2] = hx[((src[i] as i64) >> 4) & 15] 59 out[i*2+1] = hx[(src[i] as i64) & 15] 60 i = i + 1 61 } 62 return n * 2 63} 64 65func _inv_i64_hex16(v: i64, out: *u8) -> i64 { 66 let hx: *u8 = "0123456789abcdef" as *u8 67 var i: i64 = 0 68 while i < 16 { 69 out[i] = hx[(v >> ((15 - i) * 4)) & 15] 70 i = i + 1 71 } 72 return 16 73} 74 75// decimal encode a non-negative i64; returns bytes written. 76func _inv_i64_dec(v: i64, out: *u8) -> i64 { 77 let t: *u8 = sys_mmap(24) 78 var m: i64 = v 79 var k: i64 = 0 80 if m == 0 { t[0] = 48 as u8; k = 1 } 81 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 82 var i: i64 = 0 83 while i < k { out[i] = t[k - 1 - i]; i = i + 1 } 84 return k 85} 86 87func _inv_nib(c: i64) -> i64 { 88 if c >= 48 { if c <= 57 { return c - 48 } } 89 if c >= 97 { if c <= 102 { return c - 87 } } 90 if c >= 65 { if c <= 70 { return c - 55 } } 91 return 0 - 1 92} 93 94// parse exactly 16 hex chars at data[start..]; 0 on any malformed nibble (=> fail-closed expiry). 95func _inv_parse_hex16(data: *u8, start: i64) -> i64 { 96 var v: i64 = 0 97 var i: i64 = 0 98 while i < NX_INV_EXPIRY_HEX { 99 let nib: i64 = _inv_nib(data[start + i] as i64) 100 if nib < 0 { return 0 } 101 v = (v << 4) | nib 102 i = i + 1 103 } 104 return v 105} 106 107// parse a non-negative decimal from data[start..end). 108func _inv_parse_dec(data: *u8, start: i64, end: i64) -> i64 { 109 var v: i64 = 0 110 var i: i64 = start 111 var go: i64 = 1 112 while go == 1 { 113 if i >= end { go = 0 } 114 if go == 1 { 115 let c: i64 = data[i] as i64 116 if c < 48 { go = 0 } 117 if go == 1 { if c > 57 { go = 0 } } 118 if go == 1 { v = v * 10 + (c - 48); i = i + 1 } 119 } 120 } 121 return v 122} 123 124// 1 iff the 64 hex chars at data[off..off+64) equal want_hex[0..64). 125func _inv_match_hash(data: *u8, off: i64, want_hex: *u8) -> i64 { 126 var i: i64 = 0 127 while i < NX_INV_HASH_HEX { 128 if (data[off + i] as i64) != (want_hex[i] as i64) { return 0 } 129 i = i + 1 130 } 131 return 1 132} 133 134// Parse the realm / level / expiry of an `issued` row. after_hash points at the space that 135// follows the 64-hex hash; eol bounds the line. Out-boxes are zeroed first, so a malformed 136// (short) row leaves safe zeros => the caller's checks fail closed. Returns 1 on a full parse. 137func _inv_parse_issued(data: *u8, after_hash: i64, eol: i64, realm: *u8, realm_n: i64, 138 out_level: *i64, out_expiry: *i64, out_realm_ok: *i64) -> i64 { 139 out_level[0] = 0 140 out_expiry[0] = 0 141 out_realm_ok[0] = 0 142 var p: i64 = after_hash 143 if p >= eol { return 0 } 144 if (data[p] as i64) != 32 { return 0 } // expect the post-hash delimiter 145 p = p + 1 146 // realm token [rs, re) 147 let rs: i64 = p 148 var go: i64 = 1 149 while go == 1 { 150 if p >= eol { go = 0 } 151 if go == 1 { if (data[p] as i64) == 32 { go = 0 } else { p = p + 1 } } 152 } 153 let re: i64 = p 154 var rok: i64 = 0 155 if (re - rs) == realm_n { 156 rok = 1 157 var i: i64 = 0 158 while i < realm_n { 159 if (data[rs + i] as i64) != (realm[i] as i64) { rok = 0; i = realm_n } else { i = i + 1 } 160 } 161 } 162 out_realm_ok[0] = rok 163 if p >= eol { return 0 } 164 p = p + 1 // skip the space after realm 165 // level token [ls, le) 166 let ls: i64 = p 167 go = 1 168 while go == 1 { 169 if p >= eol { go = 0 } 170 if go == 1 { if (data[p] as i64) == 32 { go = 0 } else { p = p + 1 } } 171 } 172 let le: i64 = p 173 out_level[0] = _inv_parse_dec(data, ls, le) 174 if p >= eol { return 0 } 175 p = p + 1 // skip the space after level 176 if p + NX_INV_EXPIRY_HEX > eol { return 0 } 177 out_expiry[0] = _inv_parse_hex16(data, p) 178 return 1 179} 180 181// ===== inv_issue (mint + append `issued`) ====================================================== 182// 183// Mint 32 CSPRNG bytes -> 64-hex bearer token (written to out_token_hex, which the caller must 184// size for NX_INV_TOKEN_HEX bytes), append an `issued` row keyed by sha256(token), and bind it 185// to (realm, level, expiry=now_s+ttl_s). Returns NX_INV_OK or a negative sealed code. 186func inv_issue(store_path: *u8, realm: *u8, realm_n: i64, level: i64, ttl_s: i64, now_s: i64, 187 out_token_hex: *u8) -> i64 { 188 if (store_path as i64) == 0 { return 0 - NX_INV_BAD_INPUT } 189 if (realm as i64) == 0 { return 0 - NX_INV_BAD_INPUT } 190 if realm_n < 1 { return 0 - NX_INV_BAD_INPUT } 191 if realm_n > NX_INV_MAX_REALM_BYTES { return 0 - NX_INV_BAD_INPUT } 192 if level < 1 { return 0 - NX_INV_BAD_INPUT } 193 if ttl_s < 1 { return 0 - NX_INV_BAD_INPUT } 194 if now_s < 0 { return 0 - NX_INV_BAD_INPUT } 195 if (out_token_hex as i64) == 0 { return 0 - NX_INV_BAD_INPUT } 196 // realm must be space/newline-free so the row stays unambiguously parseable (boundary check). 197 var ri: i64 = 0 198 while ri < realm_n { 199 let rc: i64 = realm[ri] as i64 200 if rc == 32 { return 0 - NX_INV_BAD_INPUT } 201 if rc == 10 { return 0 - NX_INV_BAD_INPUT } 202 ri = ri + 1 203 } 204 205 // mint the bearer token: 32 CSPRNG bytes -> 64 hex chars. 206 let raw: *u8 = sys_mmap(NX_INV_TOKEN_BYTES) 207 if nx_csprng_fill(raw, NX_INV_TOKEN_BYTES) != 0 { return 0 - NX_INV_RNG_FAILED } 208 _inv_hex_enc(raw, NX_INV_TOKEN_BYTES, out_token_hex) 209 210 // stored identity = hex(sha256(token_hex)). 211 let h: *u8 = sys_mmap(32) 212 sha256_digest(out_token_hex, NX_INV_TOKEN_HEX, h) 213 let h_hex: *u8 = sys_mmap(NX_INV_HASH_HEX) 214 _inv_hex_enc(h, 32, h_hex) 215 216 let expiry: i64 = now_s + ttl_s 217 218 let line: *u8 = sys_mmap(NX_INV_LINE_CAP) 219 var pos: i64 = _inv_cat(line, 0, "INV issued " as *u8) 220 pos = _inv_catn(line, pos, h_hex, NX_INV_HASH_HEX) 221 line[pos] = 0x20 as u8; pos = pos + 1 222 pos = _inv_catn(line, pos, realm, realm_n) 223 line[pos] = 0x20 as u8; pos = pos + 1 224 pos = pos + _inv_i64_dec(level, (line as i64 + pos) as *u8) 225 line[pos] = 0x20 as u8; pos = pos + 1 226 pos = pos + _inv_i64_hex16(expiry, (line as i64 + pos) as *u8) 227 line[pos] = 10 as u8; pos = pos + 1 228 229 let fd: i64 = sys_openat_append(store_path, 0x180) // 0600 230 if fd < 0 { return 0 - NX_INV_IO_FAILED } 231 let wn: i64 = sys_write(fd, line, pos) 232 sys_fsync(fd) 233 sys_close(fd) 234 if wn != pos { return 0 - NX_INV_IO_FAILED } 235 return NX_INV_OK 236} 237 238// ===== inv_check (latest-row-wins scan; returns the live level or 0) ============================ 239// 240// Returns level (>0) IFF the LATEST row matching sha256(token_hex) is `issued` AND now_s < expiry 241// AND its realm equals (realm, realm_n). Returns 0 for every reject (consumed / expired / unknown 242// / wrong-realm / no store / bad input) -- fail-closed, no enumeration of WHY. 243func inv_check(store_path: *u8, token_hex: *u8, token_n: i64, realm: *u8, realm_n: i64, now_s: i64) -> i64 { 244 if (store_path as i64) == 0 { return 0 } 245 if (token_hex as i64) == 0 { return 0 } 246 if token_n < 1 { return 0 } 247 if (realm as i64) == 0 { return 0 } 248 if realm_n < 1 { return 0 } 249 if now_s < 0 { return 0 } 250 251 let h: *u8 = sys_mmap(32) 252 sha256_digest(token_hex, token_n, h) 253 let want_hex: *u8 = sys_mmap(NX_INV_HASH_HEX) 254 _inv_hex_enc(h, 32, want_hex) 255 256 let len_box: *i64 = sys_mmap(16) as *i64 257 len_box[0] = 0 258 let data: *u8 = sys_read_file(store_path, len_box) 259 if (data as i64) == 0 { return 0 } 260 var data_n: i64 = len_box[0] 261 if data_n > NX_INV_MAX_STORE_BYTES { data_n = NX_INV_MAX_STORE_BYTES } 262 263 var latest_type: i64 = 0 // 0 = none, 1 = issued, 2 = consumed 264 var latest_level: i64 = 0 265 var latest_expiry: i64 = 0 266 var latest_realm_ok: i64 = 0 267 268 var pos: i64 = 0 269 while pos < data_n { 270 // line bounds: advance eol to the newline (flag-controlled, matching the codebase idiom). 271 var eol: i64 = pos 272 var scan: i64 = 1 273 while scan == 1 { 274 if eol >= data_n { scan = 0 } 275 if scan == 1 { if (data[eol] as i64) == 10 { scan = 0 } } 276 if scan == 1 { eol = eol + 1 } 277 } 278 let line_n: i64 = eol - pos 279 // "INV " prefix? 280 if line_n >= 4 { 281 if (data[pos] as i64) == 0x49 { if (data[pos+1] as i64) == 0x4E { if (data[pos+2] as i64) == 0x56 { if (data[pos+3] as i64) == 0x20 { 282 let kind: i64 = data[pos+4] as i64 283 if kind == 0x69 { // 'i' => issued (hash at +11) 284 if line_n >= NX_INV_ISSUED_HASH_OFF + NX_INV_HASH_HEX { 285 if _inv_match_hash(data, pos + NX_INV_ISSUED_HASH_OFF, want_hex) == 1 { 286 let lvl_box: *i64 = sys_mmap(8) as *i64 287 let exp_box: *i64 = sys_mmap(8) as *i64 288 let rok_box: *i64 = sys_mmap(8) as *i64 289 _inv_parse_issued(data, pos + NX_INV_ISSUED_HASH_OFF + NX_INV_HASH_HEX, eol, 290 realm, realm_n, lvl_box, exp_box, rok_box) 291 latest_type = 1 292 latest_level = lvl_box[0] 293 latest_expiry = exp_box[0] 294 latest_realm_ok = rok_box[0] 295 } 296 } 297 } 298 if kind == 0x63 { // 'c' => consumed (hash at +13) 299 if line_n >= NX_INV_CONSUMED_HASH_OFF + NX_INV_HASH_HEX { 300 if _inv_match_hash(data, pos + NX_INV_CONSUMED_HASH_OFF, want_hex) == 1 { 301 latest_type = 2 302 } 303 } 304 } 305 } } } } 306 } 307 pos = eol + 1 308 } 309 310 if latest_type != 1 { return 0 } // none, or superseded by a consumed row 311 if latest_realm_ok != 1 { return 0 } // wrong realm 312 if now_s >= latest_expiry { return 0 } // expired 313 return latest_level 314} 315 316// ===== inv_consume (append `consumed` => single-use) =========================================== 317// 318// Append a `consumed` superseding row for sha256(token_hex). A subsequent inv_check returns 0 319// (latest-row-wins). Idempotent enough for replay: re-consuming an already-consumed token simply 320// appends another consumed row and still yields 0. Returns NX_INV_OK or a negative sealed code. 321func inv_consume(store_path: *u8, token_hex: *u8, token_n: i64, now_s: i64) -> i64 { 322 if (store_path as i64) == 0 { return 0 - NX_INV_BAD_INPUT } 323 if (token_hex as i64) == 0 { return 0 - NX_INV_BAD_INPUT } 324 if token_n < 1 { return 0 - NX_INV_BAD_INPUT } 325 if now_s < 0 { return 0 - NX_INV_BAD_INPUT } 326 327 let h: *u8 = sys_mmap(32) 328 sha256_digest(token_hex, token_n, h) 329 let h_hex: *u8 = sys_mmap(NX_INV_HASH_HEX) 330 _inv_hex_enc(h, 32, h_hex) 331 332 let line: *u8 = sys_mmap(NX_INV_LINE_CAP) 333 var pos: i64 = _inv_cat(line, 0, "INV consumed " as *u8) 334 pos = _inv_catn(line, pos, h_hex, NX_INV_HASH_HEX) 335 line[pos] = 0x20 as u8; pos = pos + 1 336 pos = pos + _inv_i64_hex16(now_s, (line as i64 + pos) as *u8) 337 line[pos] = 10 as u8; pos = pos + 1 338 339 let fd: i64 = sys_openat_append(store_path, 0x180) // 0600 340 if fd < 0 { return 0 - NX_INV_IO_FAILED } 341 let wn: i64 = sys_write(fd, line, pos) 342 sys_fsync(fd) 343 sys_close(fd) 344 if wn != pos { return 0 - NX_INV_IO_FAILED } 345 return NX_INV_OK 346}