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}