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).