code wiki / hub / nx_voprf.nx

nx_voprf.nx source

↩ module page · 348 lines · 14069 B

1// nx_voprf.nx -- RFC 9497 OPRF(P-256, SHA-256) Mode Base. 2// 3// "Verifiable" in the file name is aspirational: this V1 ships ONLY 4// the Base mode of the OPRF protocol per RFC 9497 §3. The Verifiable 5// mode (VOPRF) and Partially-Oblivious mode (POPRF) add DLEQ proof 6// generation + verification (~300 more lines). OPAQUE PAKE 7// (V-MODAUTH-2) requires ONLY Base mode per IETF 8// draft-irtf-cfrg-opaque-13 §6.1, so V1 scope ends here. 9// 10// OPRF = Oblivious Pseudorandom Function. Two parties: 11// Server: holds a long-term scalar skS in [1, n-1]. 12// Client: has an input string x. 13// At protocol end, client learns y = F(skS, x) where F is a 14// pseudorandom function, and: 15// - Server NEVER sees x (only a blinded curve point). 16// - Client NEVER sees skS. 17// - Both confirm y matches without sending it on the wire. 18// 19// OPAQUE uses OPRF to derive a per-password key (from password + 20// server's OPRF key) that the client uses to encrypt their long-term 21// secret. The result: even if the server is compromised, an attacker 22// cannot mount offline crack on the password (they don't have the 23// OPRF key and the protocol requires interaction to evaluate). 24// 25// COMPOSES (per "avoid duplicate primitives" cardinal): 26// hub/nx_h2c_p256 hash_to_curve_p256 + hash_to_field 27// nx_p256_scalar_mul scalar * P256Point 28// nx_p256_modn scalar arithmetic mod n (group order) 29// nx_p256_point P256Point + load_g + to_affine + sgn0 30// nx_u256 8-limb LE u256 byte conversion 31// nx_csprng random scalar source 32// sha256 Finalize() hash + part of DST 33// 34// COMPOSED BY: 35// hub/nx_opaque_pake.nx V-MODAUTH-2 (uses Blind/Eval/Finalize) 36// bench/nx_voprf_smoke.sh RFC 9497 §A.1.1 test vectors (queued) 37// 38// SPEC REFERENCES: 39// RFC 9497 §3 Base mode protocol (Blind / BlindEvaluate / Finalize) 40// RFC 9497 §3.2 contextString construction 41// RFC 9497 §4.4 OPRF(P-256, SHA-256) ciphersuite parameters 42// RFC 9497 §4.7 RandomScalar 43// RFC 9497 §A.1.1 test vectors for OPRF(P-256, SHA-256) Mode Base 44// SEC1 v2.0 §2.3.3 elliptic curve point compression encoding 45// 46// WINNER-TIER: BASELINE-A provisional pending RFC 9497 §A.1.1 vector 47// verification by bench/nx_voprf_smoke.sh. 48// INCUMBENTS: voprf-rs (Rust; rustcrypto), pyvoprf (Python), 49// opaque-ke (Rust OPAQUE client embedding VOPRF) 50// NUMBERS: pending bench post-smoke; per-blind cost dominated by 51// hash_to_curve (~25ms estimated) + 1 scalar_mul (~5ms); 52// per-finalize cost: 1 modn_inv (Fermat ladder ~10ms) + 53// 1 scalar_mul + sha256. 54// GAP: no DLEQ proof (VOPRF / POPRF modes deferred V+1); 55// no batch optimization (one input per call; batching 56// would amortize hash_to_curve and is a V+1 concern). 57// EXEMPTION REASON: n/a; provisional pending vector verification. 58 59import "nx_syscalls.nx" 60import "nx_u256.nx" 61import "nx_p256_field.nx" 62import "nx_p256_field_inv.nx" 63import "nx_p256_modn.nx" 64import "nx_p256_point.nx" 65import "nx_p256_point_add.nx" 66import "nx_p256_scalar_mul.nx" 67import "nx_csprng.nx" 68import "sha256.nx" 69import "nx_h2c_p256.nx" 70 71// ===== Sealed verdict surface (codes 1320-1339) ================================================= 72const NX_VOPRF_OK: i64 = 0 73const NX_VOPRF_BAD_INPUT: i64 = 1320 74const NX_VOPRF_BUF_OVERFLOW: i64 = 1321 75const NX_VOPRF_CSPRNG_FAILED: i64 = 1322 76const NX_VOPRF_H2C_FAILED: i64 = 1323 77const NX_VOPRF_INVALID_ELEMENT: i64 = 1324 78const NX_VOPRF_INVALID_SCALAR: i64 = 1325 79const NX_VOPRF_DESERIALIZE_FAILED: i64 = 1326 80 81// ===== Named constants (per Cardinal M7) ================================================= 82const NX_VOPRF_SCALAR_BYTES: i64 = 32 // P-256 scalar = 32 bytes (256 bits) 83const NX_VOPRF_ELEMENT_BYTES: i64 = 33 // compressed P-256 point: 1-byte prefix + 32-byte X 84const NX_VOPRF_FINALIZE_OUT_BYTES: i64 = 32 // SHA-256 output 85const NX_VOPRF_MAX_INPUT_LEN: i64 = 4096 // sanity cap; OPAQUE passes passphrase (typ < 256B) 86const NX_VOPRF_RANDOM_RETRY_MAX: i64 = 16 // rejection sampling cap 87 88// SEC1 point-compression prefix bytes per §2.3.3: 89const NX_VOPRF_COMPRESS_EVEN: i64 = 0x02 // Y is even 90const NX_VOPRF_COMPRESS_ODD: i64 = 0x03 // Y is odd 91 92// ===== Context string per RFC 9497 §3.2 ================================================= 93// 94// contextString = "OPRFV1-" || I2OSP(mode, 1) || "-" || identifier 95// mode = 0x00 (Base mode) 96// identifier = "P256-SHA256" (per RFC 9497 §4.4) 97// 98// Result (19 bytes): 99// "OPRFV1-" (7) || 0x00 (1) || "-P256-SHA256" (12) - wait recount: 100// "OPRFV1-" = 7 bytes 101// 0x00 = 1 byte 102// "-" = 1 byte 103// "P256-SHA256" = 11 bytes 104// Total = 20 bytes 105// 106// Per RFC 9497 §3.2 EXACT format: contextString = "OPRFV1-" || I2OSP(mode, 1) || "-" || identifier 107// where identifier for P-256/SHA-256 is "P256-SHA256" (RFC 9497 §4.4 Table 1). 108 109const NX_VOPRF_CONTEXT_STR_LEN: i64 = 20 110 111func _voprf_load_context_string(out: *u8) -> i64 { 112 // "OPRFV1-" 7 bytes 113 out[0] = 0x4F as u8 // 'O' 114 out[1] = 0x50 as u8 // 'P' 115 out[2] = 0x52 as u8 // 'R' 116 out[3] = 0x46 as u8 // 'F' 117 out[4] = 0x56 as u8 // 'V' 118 out[5] = 0x31 as u8 // '1' 119 out[6] = 0x2D as u8 // '-' 120 // I2OSP(0, 1) = 0x00 (Base mode) 121 out[7] = 0x00 as u8 122 // "-P256-SHA256" 12 bytes 123 out[8] = 0x2D as u8 // '-' 124 out[9] = 0x50 as u8 // 'P' 125 out[10] = 0x32 as u8 // '2' 126 out[11] = 0x35 as u8 // '5' 127 out[12] = 0x36 as u8 // '6' 128 out[13] = 0x2D as u8 // '-' 129 out[14] = 0x53 as u8 // 'S' 130 out[15] = 0x48 as u8 // 'H' 131 out[16] = 0x41 as u8 // 'A' 132 out[17] = 0x32 as u8 // '2' 133 out[18] = 0x35 as u8 // '5' 134 out[19] = 0x36 as u8 // '6' 135 return NX_VOPRF_OK 136} 137 138// DST for HashToGroup per RFC 9497 §3.2: 139// DST = "HashToGroup-" || contextString 140// Total = 12 + 20 = 32 bytes 141 142const NX_VOPRF_DST_H2C_LEN: i64 = 32 143 144func _voprf_load_dst_h2c(out: *u8) -> i64 { 145 let prefix: *u8 = "HashToGroup-" as *u8 146 var i: i64 = 0 147 while i < 12 { out[i] = prefix[i]; i = i + 1 } 148 let ctx: *u8 = sys_mmap(NX_VOPRF_CONTEXT_STR_LEN) 149 _voprf_load_context_string(ctx) 150 var j: i64 = 0 151 while j < NX_VOPRF_CONTEXT_STR_LEN { out[12 + j] = ctx[j]; j = j + 1 } 152 return NX_VOPRF_OK 153} 154 155// ===== SerializeElement / DeserializeElement (SEC1 §2.3.3 compressed) ================================================= 156// 157// Compressed encoding: 158// byte 0 prefix: 0x02 if Y even, 0x03 if Y odd 159// bytes 1..33 X-coord big-endian (32 bytes) 160 161func nx_voprf_serialize_element(point: *P256Point, out_33: *u8) -> i64 { 162 if (point as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 163 if (out_33 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 164 // Normalize to affine if not already. 165 p256_point_to_affine(point) 166 // Y parity prefix. 167 let y_lsb: i64 = point.y[0] & 1 168 if y_lsb == 0 { out_33[0] = NX_VOPRF_COMPRESS_EVEN as u8 } 169 if y_lsb == 1 { out_33[0] = NX_VOPRF_COMPRESS_ODD as u8 } 170 // X big-endian into out[1..33]. 171 u256_store_be((out_33 as i64 + 1) as *u8, point.x) 172 return NX_VOPRF_OK 173} 174 175func nx_voprf_deserialize_element(bytes_33: *u8, out_point: *P256Point) -> i64 { 176 if (bytes_33 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 177 if (out_point as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 178 let prefix: i64 = bytes_33[0] as i64 179 var want_odd: i64 = 0 180 if prefix == NX_VOPRF_COMPRESS_EVEN { want_odd = 0 } 181 if prefix == NX_VOPRF_COMPRESS_ODD { want_odd = 1 } 182 if prefix != NX_VOPRF_COMPRESS_EVEN { 183 if prefix != NX_VOPRF_COMPRESS_ODD { 184 return 0 - NX_VOPRF_DESERIALIZE_FAILED 185 } 186 } 187 // Load X. 188 let x: *i64 = u256_alloc() 189 u256_load_be(x, (bytes_33 as i64 + 1) as *u8) 190 // Validate X < p (canonical). 191 let p: *i64 = u256_alloc() 192 p256_field_load_p(p) 193 if u256_cmp(x, p) >= 0 { return 0 - NX_VOPRF_DESERIALIZE_FAILED } 194 // Compute Y^2 = X^3 + A*X + B mod p, then Y = sqrt(Y^2). 195 let x_sq: *i64 = u256_alloc() 196 let x_cu: *i64 = u256_alloc() 197 let ax: *i64 = u256_alloc() 198 let y_sq: *i64 = u256_alloc() 199 let A: *i64 = u256_alloc() 200 let B: *i64 = u256_alloc() 201 _h2c_load_A_neg3(A) 202 _h2c_load_B(B) 203 p256_field_sq(x_sq, x) 204 p256_field_mul(x_cu, x_sq, x) 205 p256_field_mul(ax, A, x) 206 p256_field_add(y_sq, x_cu, ax) 207 p256_field_add(y_sq, y_sq, B) 208 // Y = sqrt(y_sq) via Fermat (p ≡ 3 mod 4). 209 let y: *i64 = u256_alloc() 210 _h2c_sqrt_3mod4(y, y_sq) 211 // Verify y^2 == y_sq (rejects non-QR inputs which would deserialize garbage). 212 let y_check: *i64 = u256_alloc() 213 p256_field_sq(y_check, y) 214 if p256_field_eq(y_check, y_sq) != 1 { 215 return 0 - NX_VOPRF_DESERIALIZE_FAILED 216 } 217 // Pick the Y matching the requested parity. 218 let y_lsb: i64 = y[0] & 1 219 if y_lsb != want_odd { 220 let neg_y: *i64 = u256_alloc() 221 p256_field_neg(neg_y, y) 222 u256_copy(y, neg_y) 223 } 224 p256_point_set_affine(out_point, x, y) 225 return NX_VOPRF_OK 226} 227 228// ===== RandomScalar per RFC 9497 §4.7 ================================================= 229// 230// Uniform sample from [1, n-1]. Algorithm: rejection-sampling 32 random 231// bytes; reject if 0 or >= n. Up to NX_VOPRF_RANDOM_RETRY_MAX attempts 232// (n is 99.99999% of 2^256 so rejection ratio is astronomically low). 233 234func nx_voprf_random_scalar(out_32: *u8) -> i64 { 235 if (out_32 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 236 let n: *i64 = u256_alloc() 237 p256_modn_load_n(n) 238 let candidate: *i64 = u256_alloc() 239 var retry: i64 = 0 240 while retry < NX_VOPRF_RANDOM_RETRY_MAX { 241 if nx_csprng_fill(out_32, NX_VOPRF_SCALAR_BYTES) != 0 { 242 return 0 - NX_VOPRF_CSPRNG_FAILED 243 } 244 u256_load_be(candidate, out_32) 245 // Reject 0 OR >= n. 246 if u256_is_zero(candidate) == 0 { 247 if u256_cmp(candidate, n) < 0 { 248 return NX_VOPRF_OK 249 } 250 } 251 retry = retry + 1 252 } 253 return 0 - NX_VOPRF_CSPRNG_FAILED 254} 255 256// ===== Blind per RFC 9497 §3.3.1 ================================================= 257// 258// Inputs: input bytes 259// Outputs: blind_32 (32-byte big-endian scalar) 260// blinded_element_33 (compressed P-256 point) 261// 262// Steps: 263// 1. blind = RandomScalar() 264// 2. inputElement = HashToGroup(input) // RFC 9380 hash_to_curve 265// 3. if inputElement is the identity: ABORT (negligible prob) 266// 4. blindedElement = blind * inputElement 267// 5. return (blind, SerializeElement(blindedElement)) 268 269func nx_voprf_blind( 270 input: *u8, input_n: i64, 271 out_blind_32: *u8, 272 out_blinded_element_33: *u8 273) -> i64 { 274 if (input as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 275 if input_n < 0 { return 0 - NX_VOPRF_BAD_INPUT } 276 if input_n > NX_VOPRF_MAX_INPUT_LEN { return 0 - NX_VOPRF_BAD_INPUT } 277 if (out_blind_32 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 278 if (out_blinded_element_33 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 279 280 // 1. blind = RandomScalar() 281 let rc_rand: i64 = nx_voprf_random_scalar(out_blind_32) 282 if rc_rand != NX_VOPRF_OK { return rc_rand } 283 284 // 2. inputElement = HashToGroup(input) with our DST. 285 let dst: *u8 = sys_mmap(NX_VOPRF_DST_H2C_LEN) 286 _voprf_load_dst_h2c(dst) 287 let input_pt: *P256Point = p256_point_alloc() 288 let rc_h2c: i64 = nx_h2c_hash_to_curve_p256(input, input_n, 289 dst, NX_VOPRF_DST_H2C_LEN, 290 input_pt) 291 if rc_h2c != NX_H2C_OK { return 0 - NX_VOPRF_H2C_FAILED } 292 293 // 3. Identity check: if inputElement is point-at-infinity, abort. 294 if p256_point_is_infinity(input_pt) == 1 { 295 return 0 - NX_VOPRF_INVALID_ELEMENT 296 } 297 298 // 4. blindedElement = blind * inputElement 299 // p256_scalar_mul expects scalar as 8-limb i64. Load blind bytes. 300 let blind_limbs: *i64 = u256_alloc() 301 u256_load_be(blind_limbs, out_blind_32) 302 let blinded_pt: *P256Point = p256_point_alloc() 303 p256_scalar_mul(blinded_pt, blind_limbs, input_pt) 304 305 // 5. Serialize blinded element to compressed wire format. 306 return nx_voprf_serialize_element(blinded_pt, out_blinded_element_33) 307} 308 309// ===== BlindEvaluate per RFC 9497 §3.3.1 (server side) ================================================= 310// 311// Inputs: server_skS_32 (server's long-term OPRF scalar, 32 bytes BE) 312// blinded_element_33 (compressed point from client's Blind) 313// Output: evaluated_element_33 (compressed point) 314// 315// Steps: 316// 1. parsedElement = DeserializeElement(blinded_element) 317// 2. if parsedElement is invalid: ABORT 318// 3. evaluatedElement = skS * parsedElement 319// 4. return SerializeElement(evaluatedElement) 320 321func nx_voprf_blind_evaluate( 322 server_skS_32: *u8, 323 blinded_element_33: *u8, 324 out_evaluated_element_33: *u8 325) -> i64 { 326 if (server_skS_32 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 327 if (blinded_element_33 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 328 if (out_evaluated_element_33 as i64) == 0 { return 0 - NX_VOPRF_BAD_INPUT } 329 330 let blinded_pt: *P256Point = p256_point_alloc() 331 let rc_des: i64 = nx_voprf_deserialize_element(blinded_element_33, blinded_pt) 332 if rc_des != NX_VOPRF_OK { return rc_des } 333 334 // Validate skS in [1, n-1]. 335 let skS: *i64 = u256_alloc() 336 u256_load_be(skS, server_skS_32) 337 if u256_is_zero(skS) == 1 { return 0 - NX_VOPRF_INVALID_SCALAR } 338 let n: *i64 = u256_alloc() 339 p256_modn_load_n(n) 340 if u256_cmp(skS, n) >= 0 { return 0 - NX_VOPRF_INVALID_SCALAR } 341 342 let evaluated_pt: *P256Point = p256_point_alloc() 343 p256_scalar_mul(evaluated_pt, skS, blinded_pt) 344 345 return nx_voprf_serialize_element(evaluated_pt, out_evaluated_element_33) 346} 347 348// nx_voprf_finalize RELOCATED to hub/nx_voprf_finalize.nx (2026-06-10 codegen-heisenbug fix; see that file).