nx_no_cookie_session.nx source
↩ module page · 340 lines · 13682 B
1// nx_no_cookie_session.nx -- Ed25519-signed short-lived session token.
2//
3// V-MODAUTH-5 per NISHI_MODERN_AUTH_CHARTER §7. Replaces cookie-
4// session sessions with a self-contained signed token. Per the
5// NO-COOKIE / NO-TRACKING CARDINAL (C1 §1): NEVER a cookie.
6//
7// Token lifecycle:
8// 1. After OPAQUE login succeeds, server calls nx_ncs_mint_token()
9// with (user_id, realm_id) + short TTL.
10// 2. Server returns base64(token) in JSON body. Client stores in
11// sessionStorage (NOT localStorage — clears on tab close) OR holds
12// in CLI process memory.
13// 3. Client sends per-request: `X-Nishi-Session: <base64-token>`.
14// 4. Server calls nx_ncs_validate_token() per protected request.
15// 5. On expiry (~15min), client calls /wiki/admin/refresh-token to
16// get a new token signed over the same user_id but fresh nonce +
17// bumped expiry (no passphrase re-prompt).
18// 6. Logout = client discards token. Server stateless; no
19// invalidation list (per C10 full-privacy: no per-session
20// server-side correlation). Token bound to short TTL minimizes
21// leak-window risk.
22//
23// Token wire format (152 bytes BINARY; ~204 chars base64):
24// offset 0..32 user_id_hash SHA-256("realm|||user_handle")
25// offset 32..64 realm_id_hash SHA-256(NxAuthContext.realm_id)
26// offset 64..72 expires_at_unix big-endian i64 seconds since epoch
27// offset 72..88 nonce 16 random bytes (CSPRNG)
28// offset 88..152 ed25519_sig signature over bytes[0..88]
29// with server's long-term Ed25519 key
30//
31// SIGNATURE BINDING: server's Ed25519 pubkey is the SOLE authority
32// here. Token holder = anyone with the right signed bytes within TTL.
33// We do NOT bind to client cert (deferred V-MODAUTH-2 mTLS path) or
34// client_pubkey. Short TTL is the leak-window control.
35//
36// COMPOSES per "avoid duplicate primitives" cardinal:
37// nx_ed25519_signature ed25519_sign_full + ed25519_verify_full
38// nx_csprng nonce randomness
39// sha256 user_id + realm_id hash derivation helpers
40//
41// COMPOSED BY:
42// hub/nx_modern_auth_flow.nx V-MODAUTH-6 mint after OPAQUE login
43// wiki/nx_wiki_admin_auth_wiring V-MODAUTH-7 per-site validate per-req
44//
45// SPEC REFERENCES (token construction is custom per charter §7):
46// RFC 8032 Ed25519 signature scheme (substrate-shipped)
47// NIST SP 800-90A CSPRNG / DRBG conventions
48// NISHI_MODERN_AUTH_CHARTER §7 no-cookie session token format
49//
50// WINNER-TIER: BASELINE-A provisional pending bench.
51// INCUMBENTS: JWT (RFC 7519; usually in cookies = forbidden per C1),
52// PASETO (Paragon Initiative; Ed25519-signed; similar
53// shape but cookie-coupled by default tooling)
54// NUMBERS: mint: 1 sha256_digest + 1 csprng_fill(16) + 1
55// ed25519_sign_full (~3ms estimated)
56// validate: 1 ed25519_verify_full (~10ms estimated)
57// GAP: no rotation list (post-logout tokens valid until TTL
58// expires; mitigated by short default TTL = 15min)
59// no key rotation (single Ed25519 key per server; V+1
60// adds key_id field for graceful rotation)
61// no client-binding (deferred V-MODAUTH-2 mTLS)
62
63import "nx_syscalls.nx"
64import "nx_csprng.nx"
65import "nx_ed25519_signature.nx"
66import "sha256.nx"
67
68// ===== Sealed verdict surface (codes 1340-1359) =================================================
69const NX_NCS_OK: i64 = 0
70const NX_NCS_BAD_INPUT: i64 = 1340
71const NX_NCS_BUF_OVERFLOW: i64 = 1341
72const NX_NCS_CSPRNG_FAILED: i64 = 1342
73const NX_NCS_SIGN_FAILED: i64 = 1343
74const NX_NCS_VERIFY_FAILED: i64 = 1344
75const NX_NCS_EXPIRED: i64 = 1345
76const NX_NCS_MALFORMED: i64 = 1346
77
78// ===== Named constants (per Cardinal M7) =================================================
79const NX_NCS_USER_ID_HASH_BYTES: i64 = 32
80const NX_NCS_REALM_ID_HASH_BYTES: i64 = 32
81const NX_NCS_EXPIRES_BYTES: i64 = 8
82const NX_NCS_NONCE_BYTES: i64 = 16
83const NX_NCS_SIG_BYTES: i64 = 64
84const NX_NCS_TOKEN_BYTES: i64 = 152 // 32 + 32 + 8 + 16 + 64
85
86const NX_NCS_OFF_USER_ID: i64 = 0
87const NX_NCS_OFF_REALM_ID: i64 = 32
88const NX_NCS_OFF_EXPIRES: i64 = 64
89const NX_NCS_OFF_NONCE: i64 = 72
90const NX_NCS_OFF_SIG: i64 = 88
91
92const NX_NCS_SIGNED_PREFIX_BYTES: i64 = 88 // bytes 0..88 are what's signed
93
94const NX_NCS_DEFAULT_TTL_S: i64 = 900 // 15 minutes per charter §7
95
96const NX_NCS_MAX_REALM_LEN: i64 = 128
97const NX_NCS_MAX_USER_HANDLE_LEN: i64 = 64
98
99// ===== Big-endian i64 helpers =================================================
100
101func _ncs_put_i64_be(out: *u8, v: i64) -> i64 {
102 out[0] = ((v >> 56) & 0xFF) as u8
103 out[1] = ((v >> 48) & 0xFF) as u8
104 out[2] = ((v >> 40) & 0xFF) as u8
105 out[3] = ((v >> 32) & 0xFF) as u8
106 out[4] = ((v >> 24) & 0xFF) as u8
107 out[5] = ((v >> 16) & 0xFF) as u8
108 out[6] = ((v >> 8) & 0xFF) as u8
109 out[7] = (v & 0xFF) as u8
110 return NX_NCS_OK
111}
112
113func _ncs_get_i64_be(b: *u8) -> i64 {
114 return ((b[0] as i64) << 56)
115 | ((b[1] as i64) << 48)
116 | ((b[2] as i64) << 40)
117 | ((b[3] as i64) << 32)
118 | ((b[4] as i64) << 24)
119 | ((b[5] as i64) << 16)
120 | ((b[6] as i64) << 8)
121 | (b[7] as i64)
122}
123
124// ===== Identity-hash derivation helpers =================================================
125//
126// user_id_hash = SHA-256(realm_id || "|||" || user_handle)
127// realm_id_hash = SHA-256(realm_id)
128//
129// The "|||" separator prevents canonicalization ambiguity between
130// (realm="ab", user="c") and (realm="a", user="bc") — they'd hash
131// to the same value without a separator.
132
133func nx_ncs_derive_user_id_hash(
134 realm_id: *u8, realm_id_n: i64,
135 user_handle: *u8, user_handle_n: i64,
136 out_32: *u8
137) -> i64 {
138 if (realm_id as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
139 if realm_id_n < 1 { return 0 - NX_NCS_BAD_INPUT }
140 if realm_id_n > NX_NCS_MAX_REALM_LEN { return 0 - NX_NCS_BAD_INPUT }
141 if (user_handle as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
142 if user_handle_n < 1 { return 0 - NX_NCS_BAD_INPUT }
143 if user_handle_n > NX_NCS_MAX_USER_HANDLE_LEN { return 0 - NX_NCS_BAD_INPUT }
144 if (out_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
145 let sep_n: i64 = 3
146 let total_n: i64 = realm_id_n + sep_n + user_handle_n
147 let buf: *u8 = sys_mmap(total_n)
148 var i: i64 = 0
149 while i < realm_id_n { buf[i] = realm_id[i]; i = i + 1 }
150 buf[realm_id_n + 0] = 0x7C as u8 // '|'
151 buf[realm_id_n + 1] = 0x7C as u8
152 buf[realm_id_n + 2] = 0x7C as u8
153 var j: i64 = 0
154 while j < user_handle_n {
155 buf[realm_id_n + sep_n + j] = user_handle[j]
156 j = j + 1
157 }
158 sha256_digest(buf, total_n, out_32)
159 return NX_NCS_OK
160}
161
162func nx_ncs_derive_realm_id_hash(
163 realm_id: *u8, realm_id_n: i64,
164 out_32: *u8
165) -> i64 {
166 if (realm_id as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
167 if realm_id_n < 1 { return 0 - NX_NCS_BAD_INPUT }
168 if realm_id_n > NX_NCS_MAX_REALM_LEN { return 0 - NX_NCS_BAD_INPUT }
169 if (out_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
170 sha256_digest(realm_id, realm_id_n, out_32)
171 return NX_NCS_OK
172}
173
174// ===== Token mint =================================================
175//
176// Inputs:
177// server_ed25519_priv_32 server's long-term Ed25519 private key
178// user_id_hash_32 derived via nx_ncs_derive_user_id_hash
179// realm_id_hash_32 derived via nx_ncs_derive_realm_id_hash
180// now_unix_s current epoch seconds (caller supplies;
181// keeps the module substrate-pure / no sys_now)
182// ttl_s token lifetime (e.g., NX_NCS_DEFAULT_TTL_S)
183// Output:
184// token_152 binary token (caller base64-encodes for wire)
185
186func nx_ncs_mint_token(
187 server_ed25519_priv_32: *u8,
188 user_id_hash_32: *u8,
189 realm_id_hash_32: *u8,
190 now_unix_s: i64,
191 ttl_s: i64,
192 token_152: *u8
193) -> i64 {
194 if (server_ed25519_priv_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
195 if (user_id_hash_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
196 if (realm_id_hash_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
197 if (token_152 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
198 if ttl_s < 1 { return 0 - NX_NCS_BAD_INPUT }
199 if ttl_s > 86400 { return 0 - NX_NCS_BAD_INPUT } // 24h hard cap
200 if now_unix_s < 0 { return 0 - NX_NCS_BAD_INPUT }
201
202 // Lay out user_id, realm_id, expires_at, nonce in token[0..88].
203 var i: i64 = 0
204 while i < NX_NCS_USER_ID_HASH_BYTES {
205 token_152[NX_NCS_OFF_USER_ID + i] = user_id_hash_32[i]
206 i = i + 1
207 }
208 var j: i64 = 0
209 while j < NX_NCS_REALM_ID_HASH_BYTES {
210 token_152[NX_NCS_OFF_REALM_ID + j] = realm_id_hash_32[j]
211 j = j + 1
212 }
213 let expires_at: i64 = now_unix_s + ttl_s
214 _ncs_put_i64_be((token_152 as i64 + NX_NCS_OFF_EXPIRES) as *u8, expires_at)
215 if nx_csprng_fill((token_152 as i64 + NX_NCS_OFF_NONCE) as *u8,
216 NX_NCS_NONCE_BYTES) != 0 {
217 return 0 - NX_NCS_CSPRNG_FAILED
218 }
219
220 // Sign bytes[0..88] -> bytes[88..152].
221 let sig_rc: i64 = ed25519_sign_full(
222 server_ed25519_priv_32,
223 token_152, NX_NCS_SIGNED_PREFIX_BYTES,
224 (token_152 as i64 + NX_NCS_OFF_SIG) as *u8)
225 if sig_rc != 0 { return 0 - NX_NCS_SIGN_FAILED }
226
227 return NX_NCS_OK
228}
229
230// ===== Token validate =================================================
231//
232// Inputs:
233// server_ed25519_pub_32 matching pubkey for the priv used to mint
234// token_152 the binary token (caller base64-decoded)
235// now_unix_s current epoch seconds
236// expected_realm_hash_32 (optional; 0 to skip check)
237// Outputs:
238// out_user_id_hash_32 on success: the user_id_hash from the token
239// (out_user_id_hash_32 may be NULL if caller doesn't need it)
240//
241// Returns NX_NCS_OK if signature valid AND not expired
242// (AND optional realm matches if expected_realm_hash_32 != NULL).
243// Returns NX_NCS_VERIFY_FAILED on bad sig.
244// Returns NX_NCS_EXPIRED if past TTL.
245// Returns NX_NCS_MALFORMED if realm mismatch.
246
247func nx_ncs_validate_token(
248 server_ed25519_pub_32: *u8,
249 token_152: *u8,
250 now_unix_s: i64,
251 expected_realm_hash_32: *u8,
252 out_user_id_hash_32: *u8
253) -> i64 {
254 if (server_ed25519_pub_32 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
255 if (token_152 as i64) == 0 { return 0 - NX_NCS_BAD_INPUT }
256 if now_unix_s < 0 { return 0 - NX_NCS_BAD_INPUT }
257
258 // 1. Verify Ed25519 signature over bytes[0..88].
259 let ver_rc: i64 = ed25519_verify_full(
260 server_ed25519_pub_32,
261 token_152, NX_NCS_SIGNED_PREFIX_BYTES,
262 (token_152 as i64 + NX_NCS_OFF_SIG) as *u8)
263 // ed25519_verify_full returns NX_ED25519_SIG_OK (1) on a VALID signature,
264 // 0 on invalid, negative on malformed -- so success must be tested as
265 // == NX_ED25519_SIG_OK. The old `!= 0` rejected every valid token (rc=1)
266 // AND would have accepted an invalid one (rc=0): the real end-to-end
267 // auth blocker, surfaced by a 1:1 mint->validate runtime test.
268 if ver_rc != NX_ED25519_SIG_OK { return 0 - NX_NCS_VERIFY_FAILED }
269
270 // 2. Check expiry.
271 let expires_at: i64 = _ncs_get_i64_be((token_152 as i64 + NX_NCS_OFF_EXPIRES) as *u8)
272 if expires_at <= now_unix_s { return 0 - NX_NCS_EXPIRED }
273
274 // 3. (Optional) realm match.
275 if (expected_realm_hash_32 as i64) != 0 {
276 var i: i64 = 0
277 while i < NX_NCS_REALM_ID_HASH_BYTES {
278 if token_152[NX_NCS_OFF_REALM_ID + i] != expected_realm_hash_32[i] {
279 return 0 - NX_NCS_MALFORMED
280 }
281 i = i + 1
282 }
283 }
284
285 // 4. Extract user_id_hash for caller (optional output).
286 if (out_user_id_hash_32 as i64) != 0 {
287 var j: i64 = 0
288 while j < NX_NCS_USER_ID_HASH_BYTES {
289 out_user_id_hash_32[j] = token_152[NX_NCS_OFF_USER_ID + j]
290 j = j + 1
291 }
292 }
293
294 return NX_NCS_OK
295}
296
297// ===== Token refresh =================================================
298//
299// Inputs a valid (not yet expired) token; mints a fresh one with same
300// user_id + realm_id but new nonce + bumped expiry. Server should call
301// this when client hits /wiki/admin/refresh-token endpoint.
302//
303// Caller passes both the old token + the server priv/pub keys; this
304// function validates old then mints new in one call.
305
306func nx_ncs_refresh_token(
307 server_ed25519_priv_32: *u8,
308 server_ed25519_pub_32: *u8,
309 old_token_152: *u8,
310 now_unix_s: i64,
311 ttl_s: i64,
312 new_token_152: *u8
313) -> i64 {
314 // Validate old (signature + expiry).
315 let val_rc: i64 = nx_ncs_validate_token(
316 server_ed25519_pub_32, old_token_152, now_unix_s,
317 (0 as i64) as *u8, // skip realm check (refresh keeps same realm)
318 (0 as i64) as *u8)
319 if val_rc != NX_NCS_OK { return val_rc }
320
321 // Extract user_id + realm_id from old token.
322 let user_id_hash: *u8 = sys_mmap(NX_NCS_USER_ID_HASH_BYTES)
323 let realm_id_hash: *u8 = sys_mmap(NX_NCS_REALM_ID_HASH_BYTES)
324 var i: i64 = 0
325 while i < NX_NCS_USER_ID_HASH_BYTES {
326 user_id_hash[i] = old_token_152[NX_NCS_OFF_USER_ID + i]
327 i = i + 1
328 }
329 var j: i64 = 0
330 while j < NX_NCS_REALM_ID_HASH_BYTES {
331 realm_id_hash[j] = old_token_152[NX_NCS_OFF_REALM_ID + j]
332 j = j + 1
333 }
334
335 // Mint new.
336 return nx_ncs_mint_token(server_ed25519_priv_32,
337 user_id_hash, realm_id_hash,
338 now_unix_s, ttl_s,
339 new_token_152)
340}