code wiki / hub / nx_modern_auth_flow.nx

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}