code wiki / hub / nx_no_cookie_session.nx

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}