code wiki / hub / nx_user_account_store.nx

nx_user_account_store.nx source

↩ module page · 276 lines · 11135 B

1// nx_user_account_store.nx -- V-MODAUTH-4: durable OPAQUE account store + server key bundle. 2// 3// What the server persists per user: the RegistrationRecord (129 bytes: client_public_key 4// || masking_key || envelope). By OPAQUE's design this is NOT offline-crackable without 5// ALSO stealing the oprf_seed (held in the SEPARATE server key bundle file), and even 6// then every guess pays a full OPRF + memory-hard argon2id evaluation. 7// 8// Storage doctrine (ADDITIVE-ONLY, per global rule 13): append-only text log, one 9// record per line, LATEST line for a user_id_hash wins. Re-registration appends a 10// superseding row; disable = append a row with is_current=0. History is never deleted. 11// A row is a single sys_write of < 400 bytes => atomic on the substrate (O_APPEND). 12// 13// Line formats: 14// account log: A1 <user_id_hash:64hex> <is_current:1> <created_unix:16hex> <record:258hex>\n 15// key bundle: K1 <oprf_seed:64hex> <ake_priv:64hex> <ake_pub:66hex> <ed_priv:64hex> <ed_pub:64hex>\n 16// 17// The key bundle file is the server's crown-jewel secret (oprf_seed + AKE private + 18// session-token signing key). It is created 0600 and MUST live OUTSIDE git-tracked 19// trees (caller passes the path; wiki wiring uses a data-dir path, never knowledge/). 20// 21// COMPOSES: nx_syscalls (openat_append/read_file/fsync), nx_csprng, 22// hub/nx_opaque_core (DeriveDiffieHellmanKeyPair for the AKE keypair), 23// nx_ed25519_signature (pub_from_priv) 24// COMPOSED BY: hub/nx_modern_auth_flow (register stores, login looks up) 25// license_tier: ORIGINAL 26 27import "nx_syscalls.nx" 28import "nx_csprng.nx" 29import "nx_ed25519_signature.nx" 30import "hub/nx_opaque_core.nx" 31 32// ===== Sealed verdict surface (codes 1460-1479) ================================================= 33const NX_UAS_OK: i64 = 0 34const NX_UAS_BAD_INPUT: i64 = 1460 35const NX_UAS_IO_FAILED: i64 = 1461 36const NX_UAS_NOT_FOUND: i64 = 1462 37const NX_UAS_DISABLED: i64 = 1463 // latest row for user has is_current=0 38const NX_UAS_MALFORMED: i64 = 1464 39const NX_UAS_KEYGEN_FAILED: i64 = 1465 40 41const NX_UAS_RECORD_BYTES: i64 = 129 // = NX_OPQ_RECORD_BYTES (sealed wire size) 42const NX_UAS_LINE_BYTES: i64 = 344 // 2+1+64+1+1+1+16+1+258 = 345 incl \n 43const NX_UAS_MAX_STORE_BYTES: i64 = 16777216 // 16 MiB scan cap (~48k rows) 44 45// ===== hex helpers ================================================= 46 47func _uas_hex_enc(src: *u8, n: i64, out: *u8) -> i64 { 48 let hx: *u8 = "0123456789abcdef" as *u8 49 var i: i64 = 0 50 while i < n { 51 out[i*2] = hx[((src[i] as i64) >> 4) & 15] 52 out[i*2+1] = hx[(src[i] as i64) & 15] 53 i = i + 1 54 } 55 return n * 2 56} 57 58func _uas_nib(c: i64) -> i64 { 59 if c >= 48 { if c <= 57 { return c - 48 } } 60 if c >= 97 { if c <= 102 { return c - 87 } } 61 if c >= 65 { if c <= 70 { return c - 55 } } 62 return 0 - 1 63} 64 65// decode exactly n bytes from 2n hex chars; returns NX_UAS_OK or MALFORMED. 66func _uas_hex_dec(hexs: *u8, n: i64, out: *u8) -> i64 { 67 var i: i64 = 0 68 while i < n { 69 let hi: i64 = _uas_nib(hexs[i*2] as i64) 70 let lo: i64 = _uas_nib(hexs[i*2+1] as i64) 71 if hi < 0 { return 0 - NX_UAS_MALFORMED } 72 if lo < 0 { return 0 - NX_UAS_MALFORMED } 73 out[i] = ((hi << 4) | lo) as u8 74 i = i + 1 75 } 76 return NX_UAS_OK 77} 78 79func _uas_i64_hex16(v: i64, out: *u8) -> i64 { 80 let hx: *u8 = "0123456789abcdef" as *u8 81 var i: i64 = 0 82 while i < 16 { 83 out[i] = hx[(v >> ((15 - i) * 4)) & 15] 84 i = i + 1 85 } 86 return 16 87} 88 89// ===== append (register / supersede / disable) ================================================= 90 91func nx_uas_append( 92 store_path: *u8, 93 user_id_hash_32: *u8, 94 record_129: *u8, 95 now_unix_s: i64, 96 is_current: i64 97) -> i64 { 98 if (store_path as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 99 if (user_id_hash_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 100 if (record_129 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 101 if now_unix_s < 0 { return 0 - NX_UAS_BAD_INPUT } 102 var cur: i64 = 0 103 if is_current == 1 { cur = 1 } 104 105 let line: *u8 = sys_mmap(400) 106 line[0] = 0x41 as u8 // 'A' 107 line[1] = 0x31 as u8 // '1' 108 line[2] = 0x20 as u8 109 var pos: i64 = 3 110 pos = pos + _uas_hex_enc(user_id_hash_32, 32, (line as i64 + pos) as *u8) 111 line[pos] = 0x20 as u8 112 pos = pos + 1 113 line[pos] = (48 + cur) as u8 114 pos = pos + 1 115 line[pos] = 0x20 as u8 116 pos = pos + 1 117 pos = pos + _uas_i64_hex16(now_unix_s, (line as i64 + pos) as *u8) 118 line[pos] = 0x20 as u8 119 pos = pos + 1 120 pos = pos + _uas_hex_enc(record_129, NX_UAS_RECORD_BYTES, (line as i64 + pos) as *u8) 121 line[pos] = 10 as u8 122 pos = pos + 1 123 124 let fd: i64 = sys_openat_append(store_path, 0x180) // 0600 125 if fd < 0 { return 0 - NX_UAS_IO_FAILED } 126 let wn: i64 = sys_write(fd, line, pos) 127 sys_fsync(fd) 128 sys_close(fd) 129 if wn != pos { return 0 - NX_UAS_IO_FAILED } 130 return NX_UAS_OK 131} 132 133// ===== lookup (latest row wins) ================================================= 134 135func nx_uas_lookup( 136 store_path: *u8, 137 user_id_hash_32: *u8, 138 out_record_129: *u8 139) -> i64 { 140 if (store_path as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 141 if (user_id_hash_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 142 if (out_record_129 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 143 144 let want_hex: *u8 = sys_mmap(64) 145 _uas_hex_enc(user_id_hash_32, 32, want_hex) 146 147 let len_box: *i64 = sys_mmap(16) as *i64 148 let data: *u8 = sys_read_file(store_path, len_box) 149 if (data as i64) == 0 { return 0 - NX_UAS_NOT_FOUND } 150 var data_n: i64 = len_box[0] 151 if data_n > NX_UAS_MAX_STORE_BYTES { data_n = NX_UAS_MAX_STORE_BYTES } 152 153 var found: i64 = 0 154 var found_cur: i64 = 0 155 var pos: i64 = 0 156 while pos < data_n { 157 // line bounds: advance eol to the newline (flag-controlled loop, no break idiom) 158 var eol: i64 = pos 159 var scan: i64 = 1 160 while scan == 1 { 161 if eol >= data_n { scan = 0 } 162 if scan == 1 { 163 if (data[eol] as i64) == 10 { scan = 0 } 164 } 165 if scan == 1 { eol = eol + 1 } 166 } 167 let line_n: i64 = eol - pos 168 // match: "A1 " + 64 hex of user_id + " " + cur + " " + 16 + " " + 258 169 if line_n >= 87 { 170 if (data[pos] as i64) == 0x41 { 171 var m: i64 = 1 172 var h: i64 = 0 173 while h < 64 { 174 if (data[pos + 3 + h] as i64) != (want_hex[h] as i64) { m = 0; h = 64 } 175 if h < 64 { h = h + 1 } 176 } 177 if m == 1 { 178 if line_n >= 86 + 258 { 179 let cur: i64 = (data[pos + 68] as i64) - 48 180 let rc_d: i64 = _uas_hex_dec((data as i64 + pos + 87) as *u8, 181 NX_UAS_RECORD_BYTES, out_record_129) 182 if rc_d == NX_UAS_OK { found = 1; found_cur = cur } 183 } 184 } 185 } 186 } 187 pos = eol + 1 188 } 189 if found == 0 { return 0 - NX_UAS_NOT_FOUND } 190 if found_cur != 1 { return 0 - NX_UAS_DISABLED } 191 return NX_UAS_OK 192} 193 194// ===== server key bundle ================================================= 195// 196// load-or-init: parse "K1 ..." line if the file exists; otherwise generate 197// (oprf_seed = CSPRNG; AKE keypair = DeriveDiffieHellmanKeyPair(CSPRNG seed); 198// ed25519 priv = CSPRNG, pub derived) and persist 0600. Idempotent: a second 199// call returns the SAME keys (global rule 10). 200 201func nx_uas_server_keys_load_or_init( 202 keys_path: *u8, 203 out_oprf_seed_32: *u8, 204 out_ake_priv_32: *u8, 205 out_ake_pub_33: *u8, 206 out_ed_priv_32: *u8, 207 out_ed_pub_32: *u8 208) -> i64 { 209 if (keys_path as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 210 if (out_oprf_seed_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 211 if (out_ake_priv_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 212 if (out_ake_pub_33 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 213 if (out_ed_priv_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 214 if (out_ed_pub_32 as i64) == 0 { return 0 - NX_UAS_BAD_INPUT } 215 216 // ---- try load ---- 217 // Empty/missing file => generate. NON-empty but unparseable => MALFORMED 218 // (never silently clobber a real bundle -- that would orphan every account). 219 let len_box: *i64 = sys_mmap(16) as *i64 220 let data: *u8 = sys_read_file(keys_path, len_box) 221 var have: i64 = 0 222 if (data as i64) != 0 { if len_box[0] > 0 { have = 1 } } 223 if have == 1 { 224 let n: i64 = len_box[0] 225 // K1 + sp + 64 + sp + 64 + sp + 66 + sp + 64 + sp + 64 = 327 minimum 226 if n >= 327 { 227 if (data[0] as i64) == 0x4B { 228 var ok: i64 = 1 229 if _uas_hex_dec((data as i64 + 3) as *u8, 32, out_oprf_seed_32) != NX_UAS_OK { ok = 0 } 230 if _uas_hex_dec((data as i64 + 68) as *u8, 32, out_ake_priv_32) != NX_UAS_OK { ok = 0 } 231 if _uas_hex_dec((data as i64 + 133) as *u8, 33, out_ake_pub_33) != NX_UAS_OK { ok = 0 } 232 if _uas_hex_dec((data as i64 + 200) as *u8, 32, out_ed_priv_32) != NX_UAS_OK { ok = 0 } 233 if _uas_hex_dec((data as i64 + 265) as *u8, 32, out_ed_pub_32) != NX_UAS_OK { ok = 0 } 234 if ok == 1 { return NX_UAS_OK } 235 return 0 - NX_UAS_MALFORMED 236 } 237 } 238 return 0 - NX_UAS_MALFORMED 239 } 240 241 // ---- generate ---- 242 if nx_csprng_fill(out_oprf_seed_32, 32) != 0 { return 0 - NX_UAS_KEYGEN_FAILED } 243 let ake_seed: *u8 = sys_mmap(32) 244 if nx_csprng_fill(ake_seed, 32) != 0 { return 0 - NX_UAS_KEYGEN_FAILED } 245 let ddh: *u8 = "OPAQUE-DeriveDiffieHellmanKeyPair" as *u8 246 if nx_opq_derive_keypair(ake_seed, ddh, 33, out_ake_priv_32, out_ake_pub_33) != NX_OPQ_OK { 247 return 0 - NX_UAS_KEYGEN_FAILED 248 } 249 if nx_csprng_fill(out_ed_priv_32, 32) != 0 { return 0 - NX_UAS_KEYGEN_FAILED } 250 if ed25519_pub_from_priv(out_ed_priv_32, out_ed_pub_32) != 0 { return 0 - NX_UAS_KEYGEN_FAILED } 251 252 // ---- persist (single write, 0600) ---- 253 let line: *u8 = sys_mmap(400) 254 line[0] = 0x4B as u8 // 'K' 255 line[1] = 0x31 as u8 // '1' 256 line[2] = 0x20 as u8 257 var pos: i64 = 3 258 pos = pos + _uas_hex_enc(out_oprf_seed_32, 32, (line as i64 + pos) as *u8) 259 line[pos] = 0x20 as u8; pos = pos + 1 260 pos = pos + _uas_hex_enc(out_ake_priv_32, 32, (line as i64 + pos) as *u8) 261 line[pos] = 0x20 as u8; pos = pos + 1 262 pos = pos + _uas_hex_enc(out_ake_pub_33, 33, (line as i64 + pos) as *u8) 263 line[pos] = 0x20 as u8; pos = pos + 1 264 pos = pos + _uas_hex_enc(out_ed_priv_32, 32, (line as i64 + pos) as *u8) 265 line[pos] = 0x20 as u8; pos = pos + 1 266 pos = pos + _uas_hex_enc(out_ed_pub_32, 32, (line as i64 + pos) as *u8) 267 line[pos] = 10 as u8; pos = pos + 1 268 269 let fd: i64 = sys_openat_wr(keys_path, 0x180) 270 if fd < 0 { return 0 - NX_UAS_IO_FAILED } 271 let wn: i64 = sys_write(fd, line, pos) 272 sys_fsync(fd) 273 sys_close(fd) 274 if wn != pos { return 0 - NX_UAS_IO_FAILED } 275 return NX_UAS_OK 276}