code wiki / hub / nx_opaque_core.nx

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}