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}