nx_modern_auth_flow.nx source
↩ module page · 660 lines · 31636 B
1// nx_modern_auth_flow.nx -- V-MODAUTH-6: SEALED §8 cross-context API.
2//
3// REPLACES hub/nx_admin_login_flow (V1 cookie+session module). Per
4// NISHI_MODERN_AUTH_CHARTER §1 cardinals C1-C12 + §8 API contract:
5// one hub primitive serves every auth context (admin / staff / clients
6// / API tokens / inter-substrate / future Nishi-IDE) via NxAuthContext
7// config struct.
8//
9// SEALED 5-FUNCTION API (NEVER changes between contexts; per Cardinal
10// 19 API stability):
11// nx_modern_auth_register user creates account; receives
12// recovery mnemonic
13// nx_modern_auth_login user authenticates; receives
14// short-lived session token
15// nx_modern_auth_validate_session per-request session validation
16// (the hot path; called every
17// protected request)
18// nx_modern_auth_refresh_session bump TTL without passphrase
19// re-prompt
20// nx_modern_auth_recover forgot-passphrase via BIP39
21// mnemonic; sets new passphrase +
22// issues new mnemonic
23//
24// V2 IMPLEMENTATION STATUS (2026-06-10, OPAQUE shipped):
25// ✓ NxAuthContext + nx_auth_context_init IMPLEMENTED
26// ✓ nx_modern_auth_validate_session IMPLEMENTED (composes M5)
27// ✓ nx_modern_auth_refresh_session IMPLEMENTED (composes M5)
28// ✓ nx_modern_auth_register IMPLEMENTED (V-MODAUTH-2 OPAQUE
29// + V-MODAUTH-4 account store;
30// RFC 9807 KAT-gated). Issues a
31// 24-word BIP39 mnemonic when
32// ctx.allow_recovery=1 (M3).
33// ✓ nx_modern_auth_login IMPLEMENTED (OPAQUE-3DH full
34// flow in-process + M5 mint)
35// ✓ nx_modern_auth_recover IMPLEMENTED (V-MODAUTH-3: the
36// mnemonic entropy is a SECOND
37// OPAQUE credential under
38// handle||"|rec"; recover verifies
39// it, sets the new passphrase, and
40// ROTATES the mnemonic). The sealed
41// 5-function API is now COMPLETE.
42//
43// SEALED-STRUCT FIELD SEMANTICS (V2, no layout change per Cardinal 19):
44// ctx.opaque_skS_32 = the server's OPAQUE oprf_seed (32 bytes). The server's
45// P-256 AKE keypair is DERIVED from it (Expand ->
46// DeriveDiffieHellmanKeyPair) -- one crown-jewel secret,
47// one bundle file, sealed init signature unchanged.
48// ctx.account_store = *u8 pointer (as i64) to a NUL-terminated account-store
49// path consumed by hub/nx_user_account_store.
50// ctx.argon2id_p_cost: the substrate argon2id is p=1 (RFC 9106 single-lane);
51// values != 1 are clamped to 1 (honest: no parallel lanes).
52//
53// COMPOSES per "avoid duplicate primitives":
54// hub/nx_no_cookie_session M5 (validate + refresh)
55// nx_syscalls
56// (V+1 when M2/M3/M4 land:
57// hub/nx_opaque_pake V-MODAUTH-2
58// hub/nx_bip39_mnemonic V-MODAUTH-3
59// hub/nx_user_account_store V-MODAUTH-4)
60//
61// COMPOSED BY:
62// wiki/nx_wiki_admin_auth_wiring V-MODAUTH-7 per-site wiring
63// wiki/nx_wiki_https_daemon_mv replaces admin_login_flow dep
64//
65// SPEC REFERENCES:
66// NISHI_MODERN_AUTH_CHARTER §8 SEALED API contract
67// NISHI_MODERN_AUTH_CHARTER §1 C1-C12 sealed cardinals
68
69import "nx_syscalls.nx"
70import "hub/nx_no_cookie_session.nx"
71import "hub/nx_opaque_pake.nx"
72import "hub/nx_user_account_store.nx"
73import "hub/nx_bip39.nx"
74
75// ===== Sealed verdict surface (codes 1400-1419) =================================================
76const NX_MAUTH_OK: i64 = 0
77const NX_MAUTH_BAD_INPUT: i64 = 1400
78const NX_MAUTH_BAD_CONTEXT: i64 = 1401
79const NX_MAUTH_NO_SESSION: i64 = 1402 // no token header in request
80const NX_MAUTH_INVALID_SESSION: i64 = 1403 // signature failed
81const NX_MAUTH_EXPIRED: i64 = 1404
82const NX_MAUTH_REALM_MISMATCH: i64 = 1405
83const NX_MAUTH_BUF_OVERFLOW: i64 = 1406
84const NX_MAUTH_NOT_IMPLEMENTED: i64 = 1407 // M2/M3/M4 prereq missing
85const NX_MAUTH_RATE_LIMITED: i64 = 1408
86const NX_MAUTH_USER_NOT_FOUND: i64 = 1409
87const NX_MAUTH_WRONG_PASSPHRASE: i64 = 1410
88const NX_MAUTH_RECOVERY_MISMATCH: i64 = 1411
89
90// ===== Named constants (M7) =================================================
91const NX_MAUTH_MAX_REALM_LEN: i64 = 128
92const NX_MAUTH_MAX_USER_HANDLE_LEN: i64 = 64
93const NX_MAUTH_MAX_PASSPHRASE_LEN: i64 = 256
94const NX_MAUTH_MAX_MNEMONIC_LEN: i64 = 512 // 24 BIP39 words * ~10 chars
95const NX_MAUTH_DEFAULT_SESSION_TTL_S: i64 = 900 // 15 min per charter §7
96const NX_MAUTH_HARD_MAX_TTL_S: i64 = 86400 // 24h hard cap
97const NX_MAUTH_DEFAULT_ARGON2ID_M_COST: i64 = 65536 // 64 MiB per OWASP 2026
98const NX_MAUTH_DEFAULT_ARGON2ID_T_COST: i64 = 3
99const NX_MAUTH_DEFAULT_ARGON2ID_P_COST: i64 = 4
100const NX_MAUTH_DEFAULT_RATE_LIMIT_PER_MIN: i64 = 5
101
102const NX_MAUTH_ED25519_PRIV_BYTES: i64 = 32
103const NX_MAUTH_ED25519_PUB_BYTES: i64 = 32
104const NX_MAUTH_SESSION_TOKEN_BYTES: i64 = 152 // matches M5 NX_NCS_TOKEN_BYTES
105
106// ===== NxAuthContext (per charter §8 SEALED struct) =================================================
107//
108// One per realm. Daemon constructs N contexts at boot for N realms;
109// per-request dispatch picks the appropriate context based on route.
110
111struct NxAuthContext {
112 realm_id: *u8 // per-site/scope identifier (e.g., "nishifamily_admin")
113 realm_id_n: i64
114 realm_display_name: *u8 // shown in UI (e.g., "Nishi Family hub admin")
115 realm_display_name_n: i64
116
117 // V-MODAUTH-4 user account store (per realm or shared). V1: 0 ptr
118 // accepted (register/login NOT_IMPLEMENTED so store unused yet).
119 account_store: i64 // *NxUserAccountStore when M4 ships
120
121 // V-MODAUTH-2 OPAQUE state (server's long-term OPRF key). V1: 0
122 // accepted (register/login NOT_IMPLEMENTED so unused yet).
123 opaque_skS_32: *u8 // 32-byte Ed25519-like scalar; null in V1
124
125 // M5 session-token signing keys (REQUIRED; validate/refresh work
126 // now via these).
127 server_ed25519_priv_32: *u8
128 server_ed25519_pub_32: *u8
129
130 session_token_ttl_seconds: i64 // default 900 (15 min); per realm
131 argon2id_m_cost: i64 // V-MODAUTH-2 KSF parameter
132 argon2id_t_cost: i64
133 argon2id_p_cost: i64
134 rate_limit_max_per_min: i64
135 allow_recovery: i64 // 1 if recovery flow enabled
136
137 valid: i64
138}
139
140func nx_auth_context_init(
141 ctx: *NxAuthContext,
142 realm_id: *u8, realm_id_n: i64,
143 realm_display_name: *u8, realm_display_name_n: i64,
144 account_store: i64, // *NxUserAccountStore or 0
145 opaque_skS_32: *u8, // 32-byte or 0
146 server_ed25519_priv_32: *u8,
147 server_ed25519_pub_32: *u8,
148 session_token_ttl_seconds: i64,
149 argon2id_m_cost: i64,
150 argon2id_t_cost: i64,
151 argon2id_p_cost: i64,
152 rate_limit_max_per_min: i64,
153 allow_recovery: i64
154) -> i64 {
155 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
156 if (realm_id as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
157 if realm_id_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
158 if realm_id_n > NX_MAUTH_MAX_REALM_LEN { return 0 - NX_MAUTH_BAD_INPUT }
159 if (realm_display_name as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
160 if realm_display_name_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
161 if (server_ed25519_priv_32 as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
162 if (server_ed25519_pub_32 as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
163 if session_token_ttl_seconds < 1 { return 0 - NX_MAUTH_BAD_INPUT }
164 if session_token_ttl_seconds > NX_MAUTH_HARD_MAX_TTL_S { return 0 - NX_MAUTH_BAD_INPUT }
165 if rate_limit_max_per_min < 1 { return 0 - NX_MAUTH_BAD_INPUT }
166
167 ctx.realm_id = realm_id
168 ctx.realm_id_n = realm_id_n
169 ctx.realm_display_name = realm_display_name
170 ctx.realm_display_name_n = realm_display_name_n
171 ctx.account_store = account_store
172 ctx.opaque_skS_32 = opaque_skS_32
173 ctx.server_ed25519_priv_32 = server_ed25519_priv_32
174 ctx.server_ed25519_pub_32 = server_ed25519_pub_32
175 ctx.session_token_ttl_seconds = session_token_ttl_seconds
176 ctx.argon2id_m_cost = argon2id_m_cost
177 ctx.argon2id_t_cost = argon2id_t_cost
178 ctx.argon2id_p_cost = argon2id_p_cost
179 ctx.rate_limit_max_per_min = rate_limit_max_per_min
180 ctx.allow_recovery = allow_recovery
181 ctx.valid = 1
182 return NX_MAUTH_OK
183}
184
185// ===== OPAQUE plumbing shared by register + login =================================================
186
187// AKE context string bound into every OPAQUE-3DH transcript on this substrate.
188const NX_MAUTH_OPAQUE_CONTEXT_LEN: i64 = 11
189func _ma_load_context(out: *u8) -> i64 {
190 let s: *u8 = "NishiAuthV1" as *u8
191 var i: i64 = 0
192 while i < NX_MAUTH_OPAQUE_CONTEXT_LEN { out[i] = s[i]; i = i + 1 }
193 return NX_MAUTH_OK
194}
195
196// NUL-terminated store path length (ctx.account_store carries the pointer).
197func _ma_store_path(ctx: *NxAuthContext) -> *u8 {
198 return ctx.account_store as *u8
199}
200
201// Server AKE keypair derived from the oprf_seed: one bundle secret, sealed struct.
202func _ma_server_ake_keys(ctx: *NxAuthContext, out_priv_32: *u8, out_pub_33: *u8) -> i64 {
203 let info: *u8 = "NishiServerAkeKeyV1" as *u8
204 let seed: *u8 = sys_mmap(32)
205 if hkdf_expand(ctx.opaque_skS_32, info, 19, 32, seed) != 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
206 let ddh: *u8 = "OPAQUE-DeriveDiffieHellmanKeyPair" as *u8
207 if nx_opq_derive_keypair(seed, ddh, 33, out_priv_32, out_pub_33) != NX_OPQ_OK {
208 return 0 - NX_MAUTH_BAD_CONTEXT
209 }
210 return NX_MAUTH_OK
211}
212
213// credential_identifier = lowercase hex of user_id_hash (64 chars; stable, printable).
214func _ma_cred_id(user_id_hash_32: *u8, out_64: *u8) -> i64 {
215 let hx: *u8 = "0123456789abcdef" as *u8
216 var i: i64 = 0
217 while i < 32 {
218 out_64[i*2] = hx[((user_id_hash_32[i] as i64) >> 4) & 15]
219 out_64[i*2+1] = hx[(user_id_hash_32[i] as i64) & 15]
220 i = i + 1
221 }
222 return NX_MAUTH_OK
223}
224
225// Build the OPAQUE client/server configs from the auth context. Identities bind
226// realm_id (server) + user_handle (client) into envelope MAC + AKE transcript.
227// ksf_mode: ARGON2ID for human passphrases; IDENTITY for the recovery credential
228// (its secret is full-strength 256-bit CSPRNG entropy -- a KSF adds nothing
229// against dictionary attack because there is no dictionary).
230func _ma_mk_cfgs(
231 ctx: *NxAuthContext,
232 user_handle: *u8, user_handle_n: i64,
233 context_buf: *u8,
234 ccfg: *NxOpqClientCfg,
235 scfg: *NxOpqServerCfg,
236 ake_priv_32: *u8, ake_pub_33: *u8,
237 ksf_mode: i64
238) -> i64 {
239 _ma_load_context(context_buf)
240 var t: i64 = ctx.argon2id_t_cost
241 if t < 1 { t = 1 }
242 ccfg.server_id = ctx.realm_id
243 ccfg.server_id_n = ctx.realm_id_n
244 ccfg.client_id = user_handle
245 ccfg.client_id_n = user_handle_n
246 ccfg.context = context_buf
247 ccfg.context_n = NX_MAUTH_OPAQUE_CONTEXT_LEN
248 ccfg.ksf_mode = ksf_mode
249 ccfg.ksf_m_kib = ctx.argon2id_m_cost
250 ccfg.ksf_t = t
251 scfg.server_priv_32 = ake_priv_32
252 scfg.server_pub_33 = ake_pub_33
253 scfg.oprf_seed_32 = ctx.opaque_skS_32
254 scfg.server_id = ctx.realm_id
255 scfg.server_id_n = ctx.realm_id_n
256 scfg.context = context_buf
257 scfg.context_n = NX_MAUTH_OPAQUE_CONTEXT_LEN
258 return NX_MAUTH_OK
259}
260
261// Recovery credential handle = user_handle || "|rec" (a distinct row in the same
262// realm/store). Returns the suffixed length, or 0 when the handle is too long to
263// suffix (recovery then unavailable for that handle; register degrades honestly).
264const NX_MAUTH_REC_SUFFIX_LEN: i64 = 4
265func _ma_rec_handle(user_handle: *u8, user_handle_n: i64, out: *u8) -> i64 {
266 if user_handle_n + NX_MAUTH_REC_SUFFIX_LEN > NX_MAUTH_MAX_USER_HANDLE_LEN { return 0 }
267 var i: i64 = 0
268 while i < user_handle_n { out[i] = user_handle[i]; i = i + 1 }
269 let sfx: *u8 = "|rec" as *u8
270 var j: i64 = 0
271 while j < NX_MAUTH_REC_SUFFIX_LEN { out[user_handle_n + j] = sfx[j]; j = j + 1 }
272 return user_handle_n + NX_MAUTH_REC_SUFFIX_LEN
273}
274
275// ===== shared OPAQUE cores (register + login + recover compose these) =================================================
276
277// Register ONE credential (handle, secret) into the store (additive supersede).
278func _ma_register_credential(
279 ctx: *NxAuthContext,
280 cred_handle: *u8, cred_handle_n: i64,
281 secret: *u8, secret_n: i64,
282 ksf_mode: i64
283) -> i64 {
284 let user_id_hash: *u8 = sys_mmap(32)
285 if nx_ncs_derive_user_id_hash(ctx.realm_id, ctx.realm_id_n,
286 cred_handle, cred_handle_n, user_id_hash) != NX_NCS_OK {
287 return 0 - NX_MAUTH_BAD_INPUT
288 }
289 let cred_id: *u8 = sys_mmap(64)
290 _ma_cred_id(user_id_hash, cred_id)
291 let ake_priv: *u8 = sys_mmap(32)
292 let ake_pub: *u8 = sys_mmap(33)
293 let rc_ak: i64 = _ma_server_ake_keys(ctx, ake_priv, ake_pub)
294 if rc_ak != NX_MAUTH_OK { return rc_ak }
295 let context_buf: *u8 = sys_mmap(16)
296 let ccfg: *NxOpqClientCfg = sys_mmap(80) as *NxOpqClientCfg
297 let scfg: *NxOpqServerCfg = sys_mmap(64) as *NxOpqServerCfg
298 _ma_mk_cfgs(ctx, cred_handle, cred_handle_n, context_buf, ccfg, scfg, ake_priv, ake_pub, ksf_mode)
299
300 let blind: *u8 = sys_mmap(32)
301 let request: *u8 = sys_mmap(NX_OPQ_REG_REQUEST_BYTES)
302 if nx_opaque_create_registration_request(secret, secret_n, blind, request) != NX_OPQ_OK {
303 return 0 - NX_MAUTH_BAD_INPUT
304 }
305 let response: *u8 = sys_mmap(NX_OPQ_REG_RESPONSE_BYTES)
306 if nx_opaque_create_registration_response(request, ake_pub, cred_id, 64,
307 ctx.opaque_skS_32, response) != NX_OPQ_OK {
308 return 0 - NX_MAUTH_BAD_INPUT
309 }
310 let record: *u8 = sys_mmap(NX_OPQ_RECORD_BYTES)
311 let export_key: *u8 = sys_mmap(32)
312 if nx_opaque_finalize_registration(ccfg, secret, secret_n, blind,
313 response, record, export_key) != NX_OPQ_OK {
314 return 0 - NX_MAUTH_BAD_INPUT
315 }
316 let now_s: i64 = sys_now_realtime_sec()
317 if nx_uas_append(_ma_store_path(ctx), user_id_hash, record, now_s, 1) != NX_UAS_OK {
318 return 0 - NX_MAUTH_BAD_CONTEXT
319 }
320 return NX_MAUTH_OK
321}
322
323// Verify ONE credential via the full OPAQUE-3DH flow (KE1/KE2/KE3 + ServerFinish).
324// out_user_id_hash_32 receives the credential's user id hash on ANY return.
325func _ma_verify_credential(
326 ctx: *NxAuthContext,
327 cred_handle: *u8, cred_handle_n: i64,
328 secret: *u8, secret_n: i64,
329 ksf_mode: i64,
330 out_user_id_hash_32: *u8
331) -> i64 {
332 if nx_ncs_derive_user_id_hash(ctx.realm_id, ctx.realm_id_n,
333 cred_handle, cred_handle_n, out_user_id_hash_32) != NX_NCS_OK {
334 return 0 - NX_MAUTH_BAD_INPUT
335 }
336 let record: *u8 = sys_mmap(NX_OPQ_RECORD_BYTES)
337 if nx_uas_lookup(_ma_store_path(ctx), out_user_id_hash_32, record) != NX_UAS_OK {
338 return 0 - NX_MAUTH_USER_NOT_FOUND
339 }
340 let ake_priv: *u8 = sys_mmap(32)
341 let ake_pub: *u8 = sys_mmap(33)
342 let rc_ak: i64 = _ma_server_ake_keys(ctx, ake_priv, ake_pub)
343 if rc_ak != NX_MAUTH_OK { return rc_ak }
344 let context_buf: *u8 = sys_mmap(16)
345 let ccfg: *NxOpqClientCfg = sys_mmap(80) as *NxOpqClientCfg
346 let scfg: *NxOpqServerCfg = sys_mmap(64) as *NxOpqServerCfg
347 _ma_mk_cfgs(ctx, cred_handle, cred_handle_n, context_buf, ccfg, scfg, ake_priv, ake_pub, ksf_mode)
348 let cred_id: *u8 = sys_mmap(64)
349 _ma_cred_id(out_user_id_hash_32, cred_id)
350
351 let blind: *u8 = sys_mmap(32)
352 let ke1: *u8 = sys_mmap(NX_OPQ_KE1_BYTES)
353 let client_secret: *u8 = sys_mmap(32)
354 if nx_opaque_generate_ke1(secret, secret_n, blind, ke1, client_secret) != NX_OPQ_OK {
355 return 0 - NX_MAUTH_BAD_INPUT
356 }
357 let ke2: *u8 = sys_mmap(NX_OPQ_KE2_BYTES)
358 let expected_cmac: *u8 = sys_mmap(32)
359 let session_key_s: *u8 = sys_mmap(32)
360 if nx_opaque_generate_ke2(scfg, record, cred_id, 64,
361 cred_handle, cred_handle_n, ke1,
362 ke2, expected_cmac, session_key_s) != NX_OPQ_OK {
363 return 0 - NX_MAUTH_BAD_CONTEXT
364 }
365 let ke3: *u8 = sys_mmap(NX_OPQ_KE3_BYTES)
366 let session_key_c: *u8 = sys_mmap(32)
367 let export_key: *u8 = sys_mmap(32)
368 let rc_k3: i64 = nx_opaque_generate_ke3(ccfg, secret, secret_n, blind,
369 client_secret, ke1, ke2,
370 ke3, session_key_c, export_key)
371 if rc_k3 == (0 - NX_OPQ_ENVELOPE_AUTH_FAIL) { return 0 - NX_MAUTH_WRONG_PASSPHRASE }
372 if rc_k3 != NX_OPQ_OK { return 0 - NX_MAUTH_BAD_CONTEXT }
373 if nx_opaque_server_finish(ke3, expected_cmac) != NX_OPQ_OK {
374 return 0 - NX_MAUTH_WRONG_PASSPHRASE
375 }
376 return NX_MAUTH_OK
377}
378
379// Issue (or rotate) the recovery credential: fresh 24-word mnemonic, its entropy
380// registered as a second OPAQUE record under handle||"|rec". Writes the mnemonic
381// string to the out buffer. Returns OK with out_n=0 when the handle is too long
382// to suffix (recovery unavailable, honest degradation).
383func _ma_issue_recovery(
384 ctx: *NxAuthContext,
385 user_handle: *u8, user_handle_n: i64,
386 out_mnemonic: *u8, out_mnemonic_cap: i64,
387 out_mnemonic_n: *i64
388) -> i64 {
389 out_mnemonic_n[0] = 0
390 let rec_handle: *u8 = sys_mmap(NX_MAUTH_MAX_USER_HANDLE_LEN + 8)
391 let rec_n: i64 = _ma_rec_handle(user_handle, user_handle_n, rec_handle)
392 if rec_n == 0 { return NX_MAUTH_OK }
393 if out_mnemonic_cap < NX_B39_MAX_MNEMONIC_LEN { return 0 - NX_MAUTH_BUF_OVERFLOW }
394 let entropy: *u8 = sys_mmap(32)
395 let mn_n: *i64 = sys_mmap(8) as *i64
396 if nx_bip39_generate_24(entropy, out_mnemonic, out_mnemonic_cap, mn_n) != NX_B39_OK {
397 return 0 - NX_MAUTH_BAD_CONTEXT
398 }
399 let rc: i64 = _ma_register_credential(ctx, rec_handle, rec_n, entropy, 32,
400 NX_OPQ_KSF_IDENTITY)
401 if rc != NX_MAUTH_OK { return rc }
402 out_mnemonic_n[0] = mn_n[0]
403 return NX_MAUTH_OK
404}
405
406// ===== nx_modern_auth_register (IMPLEMENTED V2) =================================================
407//
408// Per charter §4: OPAQUE registration (RFC 9807 §5) run in-process on the
409// TLS-delivered passphrase, record persisted additive-only via V-MODAUTH-4.
410// The server stores ONLY the RegistrationRecord -- nothing offline-crackable
411// without the separate oprf_seed, and each guess pays memory-hard argon2id.
412// WIRE-PAKE HONESTY: the browser-side Blind/Finalize (password never leaves
413// the client) is the named V-MODAUTH-8 wasm rung; record format is already
414// the RFC wire format so that client lands with zero server change.
415// BIP39 (M3) pending: out_recovery_mnemonic_n=0.
416
417func nx_modern_auth_register(
418 ctx: *NxAuthContext,
419 user_handle: *u8, user_handle_n: i64,
420 passphrase: *u8, passphrase_n: i64,
421 out_recovery_mnemonic: *u8, out_recovery_mnemonic_cap: i64,
422 out_recovery_mnemonic_n: *i64
423) -> i64 {
424 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
425 if ctx.valid != 1 { return 0 - NX_MAUTH_BAD_CONTEXT }
426 if (user_handle as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
427 if user_handle_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
428 if user_handle_n > NX_MAUTH_MAX_USER_HANDLE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
429 if (passphrase as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
430 if passphrase_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
431 if passphrase_n > NX_MAUTH_MAX_PASSPHRASE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
432 if (out_recovery_mnemonic_n as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
433 if ctx.account_store == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
434 if (ctx.opaque_skS_32 as i64) == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
435 out_recovery_mnemonic_n[0] = 0
436
437 // --- passphrase credential (OPAQUE registration, argon2id KSF) ---
438 let rc_reg: i64 = _ma_register_credential(ctx, user_handle, user_handle_n,
439 passphrase, passphrase_n,
440 NX_OPQ_KSF_ARGON2ID)
441 if rc_reg != NX_MAUTH_OK { return rc_reg }
442
443 // --- recovery credential: 24-word BIP39 mnemonic (V-MODAUTH-3) ---
444 if ctx.allow_recovery == 1 {
445 return _ma_issue_recovery(ctx, user_handle, user_handle_n,
446 out_recovery_mnemonic, out_recovery_mnemonic_cap,
447 out_recovery_mnemonic_n)
448 }
449 return NX_MAUTH_OK
450}
451
452// ===== nx_modern_auth_login (IMPLEMENTED V2) =================================================
453//
454// Per charter §4: full OPAQUE-3DH login (RFC 9807 §6) -- KE1/KE2/KE3 + ServerFinish
455// run in-process -- then M5 session mint. Wrong passphrase fails INSIDE the
456// envelope auth (NX_OPQ_ENVELOPE_AUTH_FAIL -> WRONG_PASSPHRASE); unknown user
457// -> USER_NOT_FOUND (client-enumeration fake-record defense = named V+1 gap).
458
459func nx_modern_auth_login(
460 ctx: *NxAuthContext,
461 user_handle: *u8, user_handle_n: i64,
462 passphrase: *u8, passphrase_n: i64,
463 out_session_token: *u8, out_session_token_cap: i64,
464 out_session_token_n: *i64
465) -> i64 {
466 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
467 if ctx.valid != 1 { return 0 - NX_MAUTH_BAD_CONTEXT }
468 if (user_handle as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
469 if user_handle_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
470 if user_handle_n > NX_MAUTH_MAX_USER_HANDLE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
471 if (passphrase as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
472 if passphrase_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
473 if passphrase_n > NX_MAUTH_MAX_PASSPHRASE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
474 if (out_session_token as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
475 if (out_session_token_n as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
476 if out_session_token_cap < NX_MAUTH_SESSION_TOKEN_BYTES { return 0 - NX_MAUTH_BUF_OVERFLOW }
477 if ctx.account_store == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
478 if (ctx.opaque_skS_32 as i64) == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
479 out_session_token_n[0] = 0
480
481 // --- full OPAQUE-3DH verify (shared core) ---
482 let user_id_hash: *u8 = sys_mmap(32)
483 let rc_v: i64 = _ma_verify_credential(ctx, user_handle, user_handle_n,
484 passphrase, passphrase_n,
485 NX_OPQ_KSF_ARGON2ID, user_id_hash)
486 if rc_v != NX_MAUTH_OK { return rc_v }
487
488 // --- mint M5 session token ---
489 let realm_id_hash: *u8 = sys_mmap(32)
490 nx_ncs_derive_realm_id_hash(ctx.realm_id, ctx.realm_id_n, realm_id_hash)
491 let now_s: i64 = sys_now_realtime_sec()
492 if nx_ncs_mint_token(ctx.server_ed25519_priv_32, user_id_hash, realm_id_hash,
493 now_s, ctx.session_token_ttl_seconds,
494 out_session_token) != NX_NCS_OK {
495 return 0 - NX_MAUTH_BAD_CONTEXT
496 }
497 out_session_token_n[0] = NX_MAUTH_SESSION_TOKEN_BYTES
498 return NX_MAUTH_OK
499}
500
501// ===== nx_modern_auth_validate_session (IMPLEMENTED V1) =================================================
502//
503// Per-request session validation. The HOT PATH the daemon calls on
504// EVERY protected request. Composes M5 nx_no_cookie_session.
505//
506// Inputs:
507// ctx auth context (per-realm)
508// session_token raw 152-byte binary token (caller base64-decoded
509// from X-Nishi-Session header)
510// session_token_n must equal NX_MAUTH_SESSION_TOKEN_BYTES
511// now_unix_s current epoch seconds
512// out_user_handle optional output for caller (may be 0)
513// out_user_handle_cap
514// out_user_handle_n receives 32 (the user_id_hash bytes) if caller
515// provided a 32+ byte buffer
516//
517// Returns NX_MAUTH_OK if session valid + not expired + realm matches.
518
519func nx_modern_auth_validate_session(
520 ctx: *NxAuthContext,
521 session_token: *u8, session_token_n: i64,
522 now_unix_s: i64,
523 out_user_handle: *u8, out_user_handle_cap: i64,
524 out_user_handle_n: *i64
525) -> i64 {
526 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
527 if ctx.valid != 1 { return 0 - NX_MAUTH_BAD_CONTEXT }
528 if (session_token as i64) == 0 { return 0 - NX_MAUTH_NO_SESSION }
529 if session_token_n != NX_MAUTH_SESSION_TOKEN_BYTES { return 0 - NX_MAUTH_INVALID_SESSION }
530 if now_unix_s < 0 { return 0 - NX_MAUTH_BAD_INPUT }
531
532 // Derive expected realm_id_hash from ctx.realm_id.
533 let expected_realm_hash: *u8 = sys_mmap(NX_NCS_REALM_ID_HASH_BYTES)
534 nx_ncs_derive_realm_id_hash(ctx.realm_id, ctx.realm_id_n, expected_realm_hash)
535
536 // Compose M5 validation: signature + expiry + realm match.
537 let user_id_hash_buf: *u8 = sys_mmap(NX_NCS_USER_ID_HASH_BYTES)
538 let rc: i64 = nx_ncs_validate_token(
539 ctx.server_ed25519_pub_32,
540 session_token, now_unix_s,
541 expected_realm_hash,
542 user_id_hash_buf)
543
544 if rc == 0 - NX_NCS_EXPIRED { return 0 - NX_MAUTH_EXPIRED }
545 if rc == 0 - NX_NCS_VERIFY_FAILED { return 0 - NX_MAUTH_INVALID_SESSION }
546 if rc == 0 - NX_NCS_MALFORMED { return 0 - NX_MAUTH_REALM_MISMATCH }
547 if rc != NX_NCS_OK { return 0 - NX_MAUTH_INVALID_SESSION }
548
549 // Copy user_handle hash to caller buffer if requested.
550 if (out_user_handle as i64) != 0 {
551 if (out_user_handle_n as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
552 if out_user_handle_cap < NX_NCS_USER_ID_HASH_BYTES { return 0 - NX_MAUTH_BUF_OVERFLOW }
553 var i: i64 = 0
554 while i < NX_NCS_USER_ID_HASH_BYTES {
555 out_user_handle[i] = user_id_hash_buf[i]
556 i = i + 1
557 }
558 out_user_handle_n[0] = NX_NCS_USER_ID_HASH_BYTES
559 }
560 return NX_MAUTH_OK
561}
562
563// ===== nx_modern_auth_refresh_session (IMPLEMENTED V1) =================================================
564//
565// Called when client hits /wiki/admin/refresh-token. Composes M5
566// nx_no_cookie_session: validates old + mints new with bumped expiry.
567// No passphrase re-prompt; user-friendly for long-running browser
568// sessions.
569
570func nx_modern_auth_refresh_session(
571 ctx: *NxAuthContext,
572 session_token: *u8, session_token_n: i64,
573 now_unix_s: i64,
574 out_new_token: *u8, out_new_token_cap: i64,
575 out_new_token_n: *i64
576) -> i64 {
577 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
578 if ctx.valid != 1 { return 0 - NX_MAUTH_BAD_CONTEXT }
579 if (session_token as i64) == 0 { return 0 - NX_MAUTH_NO_SESSION }
580 if session_token_n != NX_MAUTH_SESSION_TOKEN_BYTES { return 0 - NX_MAUTH_INVALID_SESSION }
581 if (out_new_token as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
582 if (out_new_token_n as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
583 if out_new_token_cap < NX_MAUTH_SESSION_TOKEN_BYTES { return 0 - NX_MAUTH_BUF_OVERFLOW }
584
585 let rc: i64 = nx_ncs_refresh_token(
586 ctx.server_ed25519_priv_32,
587 ctx.server_ed25519_pub_32,
588 session_token, now_unix_s,
589 ctx.session_token_ttl_seconds,
590 out_new_token)
591 if rc == 0 - NX_NCS_EXPIRED { return 0 - NX_MAUTH_EXPIRED }
592 if rc == 0 - NX_NCS_VERIFY_FAILED { return 0 - NX_MAUTH_INVALID_SESSION }
593 if rc != NX_NCS_OK { return 0 - NX_MAUTH_INVALID_SESSION }
594 out_new_token_n[0] = NX_MAUTH_SESSION_TOKEN_BYTES
595 return NX_MAUTH_OK
596}
597
598// ===== nx_modern_auth_recover (IMPLEMENTED V2) =================================================
599//
600// Per charter §6, EXCEEDED: the mnemonic's 256-bit entropy is a SECOND OPAQUE
601// credential (handle||"|rec"), so the server stores nothing offline-crackable for
602// recovery either (charter sketched a recovery_hash -- this is strictly stronger).
603// Flow: decode mnemonic (typo => checksum fails) -> full OPAQUE verify against the
604// recovery record -> on success re-register the passphrase credential AND rotate
605// the recovery credential -> return the NEW mnemonic. Old passphrase + old
606// mnemonic both die (additive latest-wins supersede).
607
608func nx_modern_auth_recover(
609 ctx: *NxAuthContext,
610 user_handle: *u8, user_handle_n: i64,
611 recovery_mnemonic: *u8, recovery_mnemonic_n: i64,
612 new_passphrase: *u8, new_passphrase_n: i64,
613 out_new_recovery_mnemonic: *u8, out_new_recovery_mnemonic_cap: i64,
614 out_new_recovery_mnemonic_n: *i64
615) -> i64 {
616 if (ctx as i64) == 0 { return 0 - NX_MAUTH_BAD_CONTEXT }
617 if ctx.valid != 1 { return 0 - NX_MAUTH_BAD_CONTEXT }
618 if ctx.allow_recovery != 1 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
619 if (user_handle as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
620 if user_handle_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
621 if user_handle_n > NX_MAUTH_MAX_USER_HANDLE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
622 if (recovery_mnemonic as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
623 if recovery_mnemonic_n > NX_MAUTH_MAX_MNEMONIC_LEN { return 0 - NX_MAUTH_BAD_INPUT }
624 if (new_passphrase as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
625 if new_passphrase_n < 1 { return 0 - NX_MAUTH_BAD_INPUT }
626 if new_passphrase_n > NX_MAUTH_MAX_PASSPHRASE_LEN { return 0 - NX_MAUTH_BAD_INPUT }
627 if (out_new_recovery_mnemonic as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
628 if (out_new_recovery_mnemonic_n as i64) == 0 { return 0 - NX_MAUTH_BAD_INPUT }
629 if ctx.account_store == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
630 if (ctx.opaque_skS_32 as i64) == 0 { return 0 - NX_MAUTH_NOT_IMPLEMENTED }
631 out_new_recovery_mnemonic_n[0] = 0
632
633 // --- decode the mnemonic (typo => BAD_CHECKSUM => MISMATCH, no store touch) ---
634 let entropy: *u8 = sys_mmap(40)
635 let ent_n: *i64 = sys_mmap(8) as *i64
636 if nx_bip39_decode(recovery_mnemonic, recovery_mnemonic_n, entropy, 40, ent_n) != NX_B39_OK {
637 return 0 - NX_MAUTH_RECOVERY_MISMATCH
638 }
639 if ent_n[0] != 32 { return 0 - NX_MAUTH_RECOVERY_MISMATCH }
640
641 // --- verify against the recovery credential ---
642 let rec_handle: *u8 = sys_mmap(NX_MAUTH_MAX_USER_HANDLE_LEN + 8)
643 let rec_n: i64 = _ma_rec_handle(user_handle, user_handle_n, rec_handle)
644 if rec_n == 0 { return 0 - NX_MAUTH_USER_NOT_FOUND }
645 let rec_uid: *u8 = sys_mmap(32)
646 let rc_v: i64 = _ma_verify_credential(ctx, rec_handle, rec_n, entropy, 32,
647 NX_OPQ_KSF_IDENTITY, rec_uid)
648 if rc_v == (0 - NX_MAUTH_WRONG_PASSPHRASE) { return 0 - NX_MAUTH_RECOVERY_MISMATCH }
649 if rc_v != NX_MAUTH_OK { return rc_v }
650
651 // --- proven: set the new passphrase + rotate the mnemonic ---
652 let rc_reg: i64 = _ma_register_credential(ctx, user_handle, user_handle_n,
653 new_passphrase, new_passphrase_n,
654 NX_OPQ_KSF_ARGON2ID)
655 if rc_reg != NX_MAUTH_OK { return rc_reg }
656 return _ma_issue_recovery(ctx, user_handle, user_handle_n,
657 out_new_recovery_mnemonic,
658 out_new_recovery_mnemonic_cap,
659 out_new_recovery_mnemonic_n)
660}