nx_opaque_core.nx source
↩ module page · 377 lines · 15771 B
1// nx_opaque_core.nx -- V-MODAUTH-2a: OPAQUE (RFC 9807) shared primitives.
2//
3// OPAQUE = the asymmetric PAKE the modern-auth charter names as the auth
4// crown jewel: the server NEVER sees the password and stores NOTHING that
5// permits an offline dictionary attack without ALSO stealing the server's
6// long-term oprf_seed (and even then must pay the memory-hard KSF per guess).
7//
8// This module is the primitive floor the rest of V-MODAUTH-2 composes:
9// nx_opq_hash_to_scalar RFC 9497 §4.4 HashToScalar (P-256 group order n)
10// nx_opq_derive_keypair RFC 9497 §3.2 DeriveKeyPair (seed -> sk, pk)
11// nx_opq_dh RFC 9807 §6.4.1.2 DiffieHellman (P-256, compressed out)
12// nx_opq_expand_label RFC 9807 §6.4.2.1 Expand-Label (TLS 1.3 style, "OPAQUE-" prefix)
13// nx_opq_derive_secret RFC 9807 §6.4.2.1 Derive-Secret
14// nx_opq_stretch RFC 9807 KSF hook: mode 0 = Identity (RFC KAT), mode 1 = argon2id
15// nx_opq_randomized_password RFC 9807 §5.2.3 Extract("", oprf_output || stretched)
16// nx_opq_ct_eq constant-time tag compare
17//
18// COMPOSES (avoid duplicate primitives):
19// hub/nx_voprf + hub/nx_voprf_finalize RFC 9497 OPRF (Blind/BlindEvaluate/Finalize) -- VERIFIED CAPREG299
20// hub/nx_h2c_p256 expand_message_xmd (RFC 9380)
21// nx_p256_modn / nx_p256_point / nx_p256_scalar_mul group arithmetic
22// nx_hkdf / nx_hmac Extract / Expand / MAC
23// nx_argon2id memory-hard KSF (RFC 9106) -- vault KDF v2 lineage
24//
25// COMPOSED BY:
26// hub/nx_opaque_envelope.nx §4 Store/Recover
27// hub/nx_opaque_3dh.nx §6.4 AKE key schedule
28// hub/nx_opaque_pake.nx §5 + §6 top-level registration/login
29//
30// SPEC REFERENCES:
31// RFC 9807 (OPAQUE) §4, §5, §6; test vectors Appendix C.1.5/C.1.6 (P256-SHA256)
32// RFC 9497 (OPRF) §3.2 DeriveKeyPair, §4.4 HashToScalar for P256-SHA256
33// RFC 8446 §7.1 HKDF-Expand-Label encoding (repurposed with "OPAQUE-" prefix)
34// vectors staged: knowledge/specs/2026-06-10-rfc9807-opaque-p256-vectors.ref
35// license_tier: ORIGINAL
36
37import "nx_syscalls.nx"
38import "nx_u256.nx"
39import "nx_p256_field.nx"
40import "nx_p256_field_inv.nx"
41import "nx_p256_modn.nx"
42import "nx_p256_point.nx"
43import "nx_p256_point_add.nx"
44import "nx_p256_scalar_mul.nx"
45import "nx_csprng.nx"
46import "sha256.nx"
47import "nx_hmac.nx"
48import "nx_hkdf.nx"
49import "nx_argon2id.nx"
50import "hub/nx_h2c_p256.nx"
51import "hub/nx_voprf.nx"
52import "hub/nx_voprf_finalize.nx"
53
54// ===== Sealed verdict surface (codes 1440-1459) =================================================
55const NX_OPQ_OK: i64 = 0
56const NX_OPQ_BAD_INPUT: i64 = 1440
57const NX_OPQ_BUF_OVERFLOW: i64 = 1441
58const NX_OPQ_DERIVE_FAILED: i64 = 1442 // DeriveKeyPair exhausted 255 counters (negligible)
59const NX_OPQ_DH_FAILED: i64 = 1443 // invalid peer point / infinity result
60const NX_OPQ_ENVELOPE_AUTH_FAIL: i64 = 1444 // Recover: auth_tag mismatch (wrong password)
61const NX_OPQ_SERVER_AUTH_FAIL: i64 = 1445 // client: KE2 server_mac mismatch
62const NX_OPQ_CLIENT_AUTH_FAIL: i64 = 1446 // server: KE3 client_mac mismatch
63const NX_OPQ_OPRF_FAILED: i64 = 1447
64const NX_OPQ_KSF_FAILED: i64 = 1448
65const NX_OPQ_CSPRNG_FAILED: i64 = 1449
66
67// ===== Suite parameters: OPAQUE-3DH over OPRF(P-256, SHA-256) per RFC 9807 §7 ====================
68const NX_OPQ_NH: i64 = 32 // hash output
69const NX_OPQ_NPK: i64 = 33 // compressed P-256 public key
70const NX_OPQ_NSK: i64 = 32 // scalar private key
71const NX_OPQ_NM: i64 = 32 // MAC output
72const NX_OPQ_NX: i64 = 32 // AKE secret length
73const NX_OPQ_NOK: i64 = 32 // OPRF private key
74const NX_OPQ_NOE: i64 = 33 // serialized OPRF group element
75const NX_OPQ_NN: i64 = 32 // nonce length
76const NX_OPQ_NSEED: i64 = 32 // keypair derivation seed length
77
78const NX_OPQ_MAX_ID_LEN: i64 = 128 // client/server identity cap (charter realm cap)
79const NX_OPQ_MAX_PW_LEN: i64 = 256 // matches NX_MAUTH_MAX_PASSPHRASE_LEN
80const NX_OPQ_MAX_CONTEXT_LEN: i64 = 64
81
82// KSF modes for nx_opq_stretch:
83const NX_OPQ_KSF_IDENTITY: i64 = 0 // RFC test-vector configuration (KAT only; NEVER production)
84const NX_OPQ_KSF_ARGON2ID: i64 = 1 // production: memory-hard per charter + vault KDF v2 lineage
85
86// argon2id KSF salt: fixed all-zero 16 bytes. Deterministic by REQUIREMENT (the same
87// password must re-derive the same envelope keys at every login); per-user uniqueness
88// already comes from the per-credential OPRF key, so a fixed KSF salt adds no risk
89// (RFC 9807 §7 lists scrypt(S=zeroes(16)) the same way).
90const NX_OPQ_KSF_SALT_BYTES: i64 = 16
91
92// ===== constant-time compare =================================================
93
94func nx_opq_ct_eq(a: *u8, b: *u8, n: i64) -> i64 {
95 var diff: i64 = 0
96 var i: i64 = 0
97 while i < n {
98 diff = diff | ((a[i] as i64) ^ (b[i] as i64))
99 i = i + 1
100 }
101 if diff == 0 { return 1 }
102 return 0
103}
104
105// ===== HashToScalar per RFC 9497 §4.4 (P256-SHA256 suite) =================================================
106//
107// uniform = expand_message_xmd_sha256(msg, DST, 48); out = OS2IP(uniform) mod n.
108// Reduction mirrors _h2c_reduce_48be_mod_p but over the GROUP ORDER n:
109// value = high16 * 2^256 + low32; out = (high16 * Rn + low) mod n, Rn = 2^256 mod n.
110
111func _opq_load_Rn(out: *i64) -> i64 {
112 // Rn = 2^256 - n (n > 2^255 so 2^256 mod n = 2^256 - n)
113 // = 0x00000000 FFFFFFFF 00000000 00000000 43190552 58E8617B 0C46353D 039CDAAF (BE words)
114 out[0] = 0x039CDAAF as i64
115 out[1] = 0x0C46353D as i64
116 out[2] = 0x58E8617B as i64
117 out[3] = 0x43190552 as i64
118 out[4] = 0x00000000 as i64
119 out[5] = 0x00000000 as i64
120 out[6] = 0xFFFFFFFF as i64
121 out[7] = 0x00000000 as i64
122 return NX_OPQ_OK
123}
124
125func _opq_reduce_48be_mod_n(bytes_48: *u8, out_scalar: *i64) -> i64 {
126 let high_u256: *i64 = u256_alloc()
127 let low_u256: *i64 = u256_alloc()
128 let Rn: *i64 = u256_alloc()
129 let n: *i64 = u256_alloc()
130
131 let high_be_32: *u8 = sys_mmap(32)
132 var i: i64 = 0
133 while i < 16 { high_be_32[i] = 0 as u8; i = i + 1 }
134 var j: i64 = 0
135 while j < 16 { high_be_32[16 + j] = bytes_48[j]; j = j + 1 }
136 u256_load_be(high_u256, high_be_32)
137 u256_load_be(low_u256, (bytes_48 as i64 + 16) as *u8)
138
139 p256_modn_load_n(n)
140 if u256_cmp(low_u256, n) >= 0 {
141 let tmp: *i64 = u256_alloc()
142 u256_sub_with_borrow(tmp, low_u256, n)
143 u256_copy(low_u256, tmp)
144 }
145
146 _opq_load_Rn(Rn)
147 let prod: *i64 = u256_alloc()
148 p256_modn_mul(prod, high_u256, Rn)
149 p256_modn_add(out_scalar, prod, low_u256)
150 return NX_OPQ_OK
151}
152
153// msg -> scalar in [0, n-1] as 32-byte BE. dst is caller-built ("DeriveKeyPair" || contextString etc).
154func nx_opq_hash_to_scalar(msg: *u8, msg_n: i64, dst: *u8, dst_n: i64, out_32: *u8) -> i64 {
155 if (msg as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
156 if (dst as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
157 if (out_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
158 let uniform: *u8 = sys_mmap(48)
159 let rc: i64 = nx_h2c_expand_message_xmd_sha256(msg, msg_n, dst, dst_n, 48, uniform)
160 if rc != NX_H2C_OK { return 0 - NX_OPQ_DERIVE_FAILED }
161 let scalar: *i64 = u256_alloc()
162 _opq_reduce_48be_mod_n(uniform, scalar)
163 u256_store_be(out_32, scalar)
164 return NX_OPQ_OK
165}
166
167// ===== DeriveKeyPair per RFC 9497 §3.2 =================================================
168//
169// deriveInput = seed || I2OSP(len(info), 2) || info
170// loop counter 0..255: sk = HashToScalar(deriveInput || I2OSP(counter,1),
171// DST = "DeriveKeyPair" || contextString)
172// until sk != 0; pk = ScalarMultGen(sk) compressed.
173// contextString = the OPRF one (20 bytes, mode 0x00, P256-SHA256) -- reuses nx_voprf's loader.
174
175func nx_opq_derive_keypair(
176 seed_32: *u8,
177 info: *u8, info_n: i64,
178 out_sk_32: *u8,
179 out_pk_33: *u8
180) -> i64 {
181 if (seed_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
182 if (info as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
183 if info_n < 0 { return 0 - NX_OPQ_BAD_INPUT }
184 if info_n > 255 { return 0 - NX_OPQ_BAD_INPUT }
185 if (out_sk_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
186
187 // DST = "DeriveKeyPair" (13) || contextString (20) = 33 bytes
188 let dst: *u8 = sys_mmap(40)
189 let dkp: *u8 = "DeriveKeyPair" as *u8
190 var d: i64 = 0
191 while d < 13 { dst[d] = dkp[d]; d = d + 1 }
192 let ctx20: *u8 = sys_mmap(NX_VOPRF_CONTEXT_STR_LEN)
193 _voprf_load_context_string(ctx20)
194 var c: i64 = 0
195 while c < NX_VOPRF_CONTEXT_STR_LEN { dst[13 + c] = ctx20[c]; c = c + 1 }
196 let dst_n: i64 = 13 + NX_VOPRF_CONTEXT_STR_LEN
197
198 // deriveInput || counter byte
199 let din_n: i64 = NX_OPQ_NSEED + 2 + info_n
200 let buf: *u8 = sys_mmap(din_n + 1)
201 var i: i64 = 0
202 while i < NX_OPQ_NSEED { buf[i] = seed_32[i]; i = i + 1 }
203 buf[NX_OPQ_NSEED] = ((info_n >> 8) & 0xFF) as u8
204 buf[NX_OPQ_NSEED + 1] = (info_n & 0xFF) as u8
205 var k: i64 = 0
206 while k < info_n { buf[NX_OPQ_NSEED + 2 + k] = info[k]; k = k + 1 }
207
208 let sk_limbs: *i64 = u256_alloc()
209 var counter: i64 = 0
210 var found: i64 = 0
211 while counter < 256 {
212 if found == 0 {
213 buf[din_n] = (counter & 0xFF) as u8
214 let rc: i64 = nx_opq_hash_to_scalar(buf, din_n + 1, dst, dst_n, out_sk_32)
215 if rc != NX_OPQ_OK { return rc }
216 u256_load_be(sk_limbs, out_sk_32)
217 if u256_is_zero(sk_limbs) == 0 { found = 1 }
218 }
219 counter = counter + 1
220 }
221 if found == 0 { return 0 - NX_OPQ_DERIVE_FAILED }
222
223 if (out_pk_33 as i64) != 0 {
224 let g: *P256Point = p256_point_alloc()
225 p256_point_load_g(g)
226 let pk_pt: *P256Point = p256_point_alloc()
227 p256_scalar_mul(pk_pt, sk_limbs, g)
228 return nx_voprf_serialize_element(pk_pt, out_pk_33)
229 }
230 return NX_OPQ_OK
231}
232
233// ===== DiffieHellman per RFC 9807 §6.4.1.2 (P-256) =================================================
234//
235// out = SerializeElement(sk * Deserialize(pk_33)) -- 33-byte compressed shared point.
236
237func nx_opq_dh(sk_32: *u8, pk_33: *u8, out_33: *u8) -> i64 {
238 if (sk_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
239 if (pk_33 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
240 if (out_33 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
241 let peer: *P256Point = p256_point_alloc()
242 if nx_voprf_deserialize_element(pk_33, peer) != NX_VOPRF_OK {
243 return 0 - NX_OPQ_DH_FAILED
244 }
245 let sk_limbs: *i64 = u256_alloc()
246 u256_load_be(sk_limbs, sk_32)
247 if u256_is_zero(sk_limbs) == 1 { return 0 - NX_OPQ_DH_FAILED }
248 let shared: *P256Point = p256_point_alloc()
249 p256_scalar_mul(shared, sk_limbs, peer)
250 if p256_point_is_infinity(shared) == 1 { return 0 - NX_OPQ_DH_FAILED }
251 return nx_voprf_serialize_element(shared, out_33)
252}
253
254// ===== Expand-Label / Derive-Secret per RFC 9807 §6.4.2.1 =================================================
255//
256// CustomLabel = I2OSP(Length, 2) || I2OSP(7 + len(label), 1) || "OPAQUE-" || label
257// || I2OSP(len(context), 1) || context
258// Expand-Label(secret, label, context, L) = Expand(secret, CustomLabel, L)
259
260func nx_opq_expand_label(
261 secret_32: *u8,
262 label: *u8, label_n: i64,
263 context: *u8, context_n: i64,
264 out_len: i64, out: *u8
265) -> i64 {
266 if (secret_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
267 if (label as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
268 if label_n < 1 { return 0 - NX_OPQ_BAD_INPUT }
269 if label_n > 248 { return 0 - NX_OPQ_BAD_INPUT }
270 if context_n < 0 { return 0 - NX_OPQ_BAD_INPUT }
271 if context_n > 255 { return 0 - NX_OPQ_BAD_INPUT }
272 if (out as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
273
274 let cl_n: i64 = 2 + 1 + 7 + label_n + 1 + context_n
275 let cl: *u8 = sys_mmap(cl_n + 8)
276 var pos: i64 = 0
277 cl[0] = ((out_len >> 8) & 0xFF) as u8
278 cl[1] = (out_len & 0xFF) as u8
279 cl[2] = ((7 + label_n) & 0xFF) as u8
280 pos = 3
281 let prefix: *u8 = "OPAQUE-" as *u8
282 var i: i64 = 0
283 while i < 7 { cl[pos + i] = prefix[i]; i = i + 1 }
284 pos = pos + 7
285 var j: i64 = 0
286 while j < label_n { cl[pos + j] = label[j]; j = j + 1 }
287 pos = pos + label_n
288 cl[pos] = (context_n & 0xFF) as u8
289 pos = pos + 1
290 var k: i64 = 0
291 while k < context_n { cl[pos + k] = context[k]; k = k + 1 }
292 pos = pos + context_n
293
294 if hkdf_expand(secret_32, cl, pos, out_len, out) != 0 { return 0 - NX_OPQ_DERIVE_FAILED }
295 return NX_OPQ_OK
296}
297
298// Derive-Secret(secret, label, transcript_hash_32) -> Nx bytes.
299func nx_opq_derive_secret(
300 secret_32: *u8,
301 label: *u8, label_n: i64,
302 transcript_hash: *u8, transcript_hash_n: i64,
303 out_32: *u8
304) -> i64 {
305 return nx_opq_expand_label(secret_32, label, label_n,
306 transcript_hash, transcript_hash_n,
307 NX_OPQ_NX, out_32)
308}
309
310// ===== KSF Stretch + randomized_password per RFC 9807 §5.2.3 =================================================
311//
312// stretched = Stretch(oprf_output); randomized_password = Extract("", oprf_output || stretched).
313// ksf_mode IDENTITY exists ONLY so the RFC KAT (whose config is KSF=Identity) can gate the
314// surrounding protocol; production callers (nx_modern_auth_flow) pass ARGON2ID.
315
316func nx_opq_stretch(
317 oprf_output_32: *u8,
318 ksf_mode: i64, ksf_m_kib: i64, ksf_t: i64,
319 out_stretched_32: *u8
320) -> i64 {
321 if (oprf_output_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
322 if (out_stretched_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
323 if ksf_mode == NX_OPQ_KSF_IDENTITY {
324 var i: i64 = 0
325 while i < 32 { out_stretched_32[i] = oprf_output_32[i]; i = i + 1 }
326 return NX_OPQ_OK
327 }
328 if ksf_mode != NX_OPQ_KSF_ARGON2ID { return 0 - NX_OPQ_BAD_INPUT }
329
330 let ctx: *NxArgon2idCtx = sys_mmap(NX_ARGON2ID_CTX_BYTES) as *NxArgon2idCtx
331 ctx.memory_blocks = sys_mmap(ksf_m_kib * 1024)
332 ctx.h0_buf = sys_mmap(64)
333 ctx.prepend_buf = sys_mmap(1100)
334 ctx.prev_buf = sys_mmap(64)
335 ctx.curr_buf = sys_mmap(64)
336 ctx.zero_block = sys_mmap(1024)
337 ctx.z_buf = sys_mmap(1024)
338 ctx.tmp_block = sys_mmap(1024)
339 ctx.addr_block = sys_mmap(1024)
340 ctx.final_block = sys_mmap(1024)
341 ctx.h0_input = sys_mmap(1024)
342 ctx.b2b_ctx = sys_mmap(NX_BLAKE2B_CTX_BYTES) as *NxBlake2b
343 ctx.b2b_buf = sys_mmap(128)
344 ctx.b2b_sv = sys_mmap(128) as *i64
345 ctx.b2b_sm = sys_mmap(128) as *i64
346 ctx.g_r = sys_mmap(1024) as *i64
347 ctx.g_rs = sys_mmap(1024) as *i64
348 ctx.g_col = sys_mmap(128) as *i64
349
350 let salt: *u8 = sys_mmap(NX_OPQ_KSF_SALT_BYTES)
351 var s: i64 = 0
352 while s < NX_OPQ_KSF_SALT_BYTES { salt[s] = 0 as u8; s = s + 1 }
353
354 let rc: i64 = nx_argon2id_hash(ctx, oprf_output_32, 32,
355 salt, NX_OPQ_KSF_SALT_BYTES,
356 1, 32, ksf_m_kib, ksf_t,
357 out_stretched_32)
358 if rc != NX_AR2_OK { return 0 - NX_OPQ_KSF_FAILED }
359 return NX_OPQ_OK
360}
361
362func nx_opq_randomized_password(
363 oprf_output_32: *u8,
364 stretched_32: *u8,
365 out_rwd_32: *u8
366) -> i64 {
367 if (oprf_output_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
368 if (stretched_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
369 if (out_rwd_32 as i64) == 0 { return 0 - NX_OPQ_BAD_INPUT }
370 let ikm: *u8 = sys_mmap(64)
371 var i: i64 = 0
372 while i < 32 { ikm[i] = oprf_output_32[i]; i = i + 1 }
373 var j: i64 = 0
374 while j < 32 { ikm[32 + j] = stretched_32[j]; j = j + 1 }
375 hkdf_extract(0 as *u8, 0, ikm, 64, out_rwd_32)
376 return NX_OPQ_OK
377}