Skip to main content

binius_circuits/
jwt_claims.rs

1// Copyright 2026 The Binius Developers
2// Copyright 2025 Irreducible Inc.
3use binius_core::word::Word;
4use binius_frontend::{CircuitBuilder, Wire, WitnessFiller};
5
6use crate::slice;
7
8/// Represents a single JWT attribute to verify
9pub struct Attribute {
10	pub name: &'static str,
11	/// The actual length of the expected value in bytes.
12	pub len_bytes: Wire,
13	pub value: Vec<Wire>,
14}
15
16impl Attribute {
17	/// Populate the actual value length
18	pub fn populate_len_bytes(&self, w: &mut WitnessFiller<'_>, len_bytes: usize) {
19		w[self.len_bytes] = Word(len_bytes as u64);
20	}
21
22	/// Populate the expected value from bytes
23	///
24	/// # Panics
25	/// Panics if value.len() > max_value_size (determined by self.value.len() * 8)
26	pub fn populate_value(&self, w: &mut WitnessFiller<'_>, value: &[u8]) {
27		w.pack_bytes_le(&self.value, value);
28	}
29}
30
31/// Verifies that a JSON string contains specific attribute values.
32///
33/// This circuit validates that the JSON contains each specified attribute
34/// with exactly the expected string value.
35///
36/// This circuit makes some strong assumptions, in particular:
37///
38/// 1. The input is a valid JSON object. No effort is put into checking that or any other properties
39///    such as duplicate keys.
40/// 2. No whitespace handling.
41/// 3. The attributes of interest are strings only. Matching arrays or objects as values is not
42///    supported.
43///
44/// ❗️ At this point, the circuit does not check that it did not get multiple attributes. For
45/// example, imagine there are two attributes "sub" and "nonce" where one is right next to the
46/// other. A prover can get away with providing the attribute "sub" but the end quote of the nonce.
47pub struct JwtClaims {
48	/// The actual length of the JSON string in bytes.
49	pub len_bytes: Wire,
50	pub json: Vec<Wire>,
51	pub attributes: Vec<Attribute>,
52}
53
54impl JwtClaims {
55	/// Creates a new JWT claims verifier circuit. See the struct documentation for more details.
56	///
57	/// # Arguments
58	/// * `b` - Circuit builder
59	/// * `len_bytes` - Wire for actual JSON size in bytes
60	/// * `json` - JSON input array packed as words (8 bytes per word)
61	/// * `attributes` - List of attributes to verify with their value wires
62	pub fn new(
63		b: &CircuitBuilder,
64		len_bytes: Wire,
65		json: Vec<Wire>,
66		attributes: Vec<Attribute>,
67	) -> Self {
68		// For each attribute, we need to:
69		// 1. Find the pattern "name":" in the JSON
70		// 2. Extract the string value between the quotes
71		// 3. Verify it matches the expected value
72		let max_len_bytes = json.len() << 3;
73		let too_long = b.icmp_ult(b.add_constant_64(max_len_bytes as u64), len_bytes);
74		b.assert_false("length check", too_long);
75
76		for (attr_idx, attr) in attributes.iter().enumerate() {
77			let b = b.subcircuit(format!("attr[ix={}, name={}]", attr_idx, attr.name));
78
79			// Build the search pattern: "name":"
80			let pattern = format!("\"{}\":\"", attr.name);
81			let pattern_bytes = pattern.as_bytes();
82			let pattern_len = pattern_bytes.len();
83
84			// ---- Pattern matching algorithm
85			//
86			// We search for the pattern "name":" in the JSON by checking every possible
87			// starting position. Since we can't break out of loops in circuits, we check
88			// all positions and use masking to track where we found matches.
89			//
90			// Variables:
91			// - found_position: the position where we found the pattern (0 if not found yet)
92			// - any_found: becomes msb-true when we find the pattern anywhere
93			let zero = b.add_constant(Word::ZERO);
94			let mut value_start = zero;
95			let mut found_start = zero;
96
97			// Check each possible starting position
98			for start_pos in 0..max_len_bytes.saturating_sub(pattern_len) {
99				let b = b.subcircuit(format!("start_pos[{start_pos}]"));
100
101				// Check if this position could contain the full pattern
102				let end_wire = b.add_constant(Word((start_pos + pattern_len) as u64));
103
104				// Verify position is within JSON bounds
105				let within_bounds = b.icmp_ult(end_wire, len_bytes);
106				// strict inequality is safe, because there will also be a closing "
107				let mut matches_here = within_bounds;
108
109				// Check each byte of the pattern
110				for (i, &expected_byte) in pattern_bytes.iter().enumerate() {
111					let byte_pos = start_pos + i;
112					let word_idx = byte_pos / 8;
113					let byte_offset = byte_pos % 8;
114
115					let actual_byte = b.extract_byte(json[word_idx], byte_offset as u32);
116					let expected = b.add_constant(Word(expected_byte as u64));
117					let byte_matches = b.icmp_eq(actual_byte, expected);
118					matches_here = b.band(matches_here, byte_matches);
119				}
120
121				// If we found a match here, remember this position
122				value_start = b.select(matches_here, end_wire, value_start);
123				found_start = b.bor(found_start, matches_here);
124			}
125
126			// Assert that we found the pattern (found_start should be msb-true)
127			b.assert_true("attr_found", found_start);
128
129			// ---- Find value terminator
130			//
131			// Search for the terminator that marks the end of the attribute value.
132			// Valid terminators are: " (closing quote), , (comma), or } (closing brace)
133			// We scan all positions starting from value_start and use masking to
134			// remember where we found a terminator.
135			let mut value_end = zero;
136			let mut found_end = zero;
137			let quote = b.add_constant_zx_8(b'"');
138			let comma = b.add_constant_zx_8(b',');
139			let close_brace = b.add_constant_zx_8(b'}');
140			// i actually don't grok why these latter two are valid terminators.
141
142			for pos in 0..max_len_bytes {
143				let b = b.subcircuit(format!("find_terminator[{pos}]"));
144
145				let pos_wire = b.add_constant(Word(pos as u64));
146				let within_bounds = b.icmp_ult(pos_wire, len_bytes);
147
148				// Check if this position is after value_start
149				// For empty strings, the closing quote is at value_start
150				let at_or_after_start = b.bnot(b.icmp_ult(pos_wire, value_start));
151				let not_found_yet = b.bnot(found_end);
152				let should_check = b.band(b.band(at_or_after_start, within_bounds), not_found_yet);
153
154				// Extract byte at this position
155				let word_idx = pos / 8;
156				let byte_offset = pos % 8;
157
158				let byte_at_pos = b.extract_byte(json[word_idx], byte_offset as u32);
159
160				// Check if this byte is any of the valid terminators
161				let is_quote = b.icmp_eq(byte_at_pos, quote);
162				let is_comma = b.icmp_eq(byte_at_pos, comma);
163				let is_close_brace = b.icmp_eq(byte_at_pos, close_brace);
164
165				// It's a terminator if it's any of the three. The three equality flags test
166				// the same byte against distinct constants, so at most one is set and OR
167				// agrees with the linear XOR on the only bit (the MSB) they define.
168				let is_terminator = b.bxor(b.bxor(is_quote, is_comma), is_close_brace);
169
170				let found_here = b.band(should_check, is_terminator);
171
172				// If we found a terminator here, remember this position
173				// When found_here is all-1s, include pos_wire in value_end
174				// When found_here is all-0s, masked_pos is 0 and OR leaves value_end unchanged
175				value_end = b.select(found_here, pos_wire, value_end);
176				// `should_check` gates `found_here` on `not_found_yet = bnot(found_end)`, so once
177				// `found_end` is set no later position contributes again. The accumulator and the
178				// new term never share the MSB, so XOR matches OR there and is linear.
179				found_end = b.bxor(found_end, found_here);
180			}
181
182			// Assert that we found a terminator (found_end should be all-1s)
183			b.assert_true("attr_terminator_found", found_end);
184
185			// Calculate value length: value_end - value_start
186			// Since circuits don't have a subtraction operation, we use two's complement:
187			// a - b = a + (~b + 1), where ~b is bitwise NOT of b
188			let (value_length, _borrow) = b.isub_bin_bout(value_end, value_start, zero);
189
190			// Verify the length matches expected
191			b.assert_eq("attr_length", value_length, attr.len_bytes);
192
193			// Extract the value from the JSON and assert it matches the caller-supplied value.
194			let extracted =
195				slice::slice(&b, len_bytes, value_length, &json, value_start, attr.value.len());
196			slice::assert_slice_eq(&b, "attr_value", value_length, &extracted, &attr.value);
197		}
198
199		JwtClaims {
200			len_bytes,
201			json,
202			attributes,
203		}
204	}
205
206	/// Populate the len_bytes wire with the actual JSON size in bytes
207	pub fn populate_len_bytes(&self, w: &mut WitnessFiller<'_>, len_bytes: usize) {
208		w[self.len_bytes] = Word(len_bytes as u64);
209	}
210
211	/// Populate the JSON array from a byte slice
212	///
213	/// # Panics
214	/// Panics if json.len() > max_len_json (the maximum size specified during construction)
215	pub fn populate_json(&self, w: &mut WitnessFiller<'_>, json: &[u8]) {
216		w.pack_bytes_le(&self.json, json);
217	}
218}
219
220#[cfg(test)]
221mod tests {
222	use binius_frontend::CircuitBuilder;
223
224	use super::{Attribute, JwtClaims, Wire};
225
226	#[test]
227	fn test_single_attribute() {
228		let b = CircuitBuilder::new();
229
230		let len_json = b.add_witness();
231		let json: Vec<Wire> = (0..32).map(|_| b.add_witness()).collect();
232
233		let attributes = vec![Attribute {
234			name: "sub",
235			len_bytes: b.add_inout(),
236			value: (0..2).map(|_| b.add_inout()).collect(),
237		}];
238
239		let jwt_claims = JwtClaims::new(&b, len_json, json, attributes);
240
241		let circuit = b.build();
242		let mut filler = circuit.new_witness_filler();
243
244		let json_str = r#"{"sub":"1234567890","iss":"google.com"}"#;
245
246		// Populate inputs
247		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
248		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
249
250		// Populate expected value
251		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 10);
252		jwt_claims.attributes[0].populate_value(&mut filler, b"1234567890");
253
254		circuit.populate_wire_witness(&mut filler).unwrap();
255
256		// Verify constraints
257		let cs = circuit.constraint_system();
258		cs.verify(&filler.into_value_vec()).unwrap();
259	}
260
261	#[test]
262	fn test_multiple_attributes() {
263		let b = CircuitBuilder::new();
264
265		let len_bytes = b.add_witness();
266		let json: Vec<Wire> = (0..32).map(|_| b.add_witness()).collect();
267
268		let attributes = vec![
269			Attribute {
270				name: "sub",
271				len_bytes: b.add_inout(),
272				value: (0..2).map(|_| b.add_inout()).collect(),
273			},
274			Attribute {
275				name: "iss",
276				len_bytes: b.add_inout(),
277				value: (0..4).map(|_| b.add_inout()).collect(),
278			},
279			Attribute {
280				name: "aud",
281				len_bytes: b.add_inout(),
282				value: (0..2).map(|_| b.add_inout()).collect(),
283			},
284		];
285
286		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
287
288		let circuit = b.build();
289		let mut filler = circuit.new_witness_filler();
290
291		// Test JSON with all attributes
292		let json_str =
293			r#"{"sub":"1234567890","iss":"google.com","aud":"4074087","iat":1676415809}"#;
294
295		// Populate inputs
296		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
297		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
298
299		// Populate expected values
300		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 10);
301		jwt_claims.attributes[0].populate_value(&mut filler, b"1234567890");
302
303		jwt_claims.attributes[1].populate_len_bytes(&mut filler, 10);
304		jwt_claims.attributes[1].populate_value(&mut filler, b"google.com");
305
306		jwt_claims.attributes[2].populate_len_bytes(&mut filler, 7);
307		jwt_claims.attributes[2].populate_value(&mut filler, b"4074087");
308
309		circuit.populate_wire_witness(&mut filler).unwrap();
310
311		// Verify constraints
312		let cs = circuit.constraint_system();
313		cs.verify(&filler.into_value_vec()).unwrap();
314	}
315
316	#[test]
317	fn test_attribute_not_found() {
318		let b = CircuitBuilder::new();
319
320		let len_bytes = b.add_witness();
321		let json: Vec<Wire> = (0..16).map(|_| b.add_witness()).collect();
322
323		let attributes = vec![Attribute {
324			name: "missing",
325			len_bytes: b.add_inout(),
326			value: (0..2).map(|_| b.add_inout()).collect(),
327		}];
328
329		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
330
331		let circuit = b.build();
332		let mut filler = circuit.new_witness_filler();
333
334		// JSON without the required attribute
335		let json_str = r#"{"sub":"1234567890","iss":"google.com"}"#;
336
337		// Populate inputs
338		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
339		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
340
341		// Populate expected value (won't be found)
342		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 5);
343		jwt_claims.attributes[0].populate_value(&mut filler, b"value");
344
345		// This should fail because "missing" attribute is not in the JSON
346		let result = circuit.populate_wire_witness(&mut filler);
347		assert!(result.is_err());
348	}
349
350	#[test]
351	fn test_wrong_value() {
352		let b = CircuitBuilder::new();
353
354		let len_bytes = b.add_witness();
355		let json: Vec<Wire> = (0..16).map(|_| b.add_witness()).collect();
356
357		let attributes = vec![Attribute {
358			name: "sub",
359			len_bytes: b.add_inout(),
360			value: (0..2).map(|_| b.add_inout()).collect(),
361		}];
362
363		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
364
365		let circuit = b.build();
366		let mut filler = circuit.new_witness_filler();
367
368		// Test JSON
369		let json_str = r#"{"sub":"1234567890","iss":"google.com"}"#;
370
371		// Populate inputs
372		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
373		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
374
375		// Populate wrong expected value
376		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 10);
377		jwt_claims.attributes[0].populate_value(&mut filler, b"9876543210");
378
379		// This should fail because the value doesn't match
380		let result = circuit.populate_wire_witness(&mut filler);
381		assert!(result.is_err());
382	}
383
384	#[test]
385	fn test_attributes_in_different_order() {
386		let b = CircuitBuilder::new();
387
388		let len_bytes = b.add_witness();
389		let json: Vec<Wire> = (0..32).map(|_| b.add_witness()).collect();
390
391		let attributes = vec![
392			Attribute {
393				name: "aud",
394				len_bytes: b.add_inout(),
395				value: (0..16 / 8).map(|_| b.add_inout()).collect(),
396			},
397			Attribute {
398				name: "sub",
399				len_bytes: b.add_inout(),
400				value: (0..16 / 8).map(|_| b.add_inout()).collect(),
401			},
402		];
403
404		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
405
406		let circuit = b.build();
407		let mut filler = circuit.new_witness_filler();
408
409		// JSON with attributes in different order
410		let json_str =
411			r#"{"iss":"google.com","sub":"1234567890","email":"test@example.com","aud":"4074087"}"#;
412
413		// Populate inputs
414		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
415		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
416
417		// Populate expected values
418		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 7);
419		jwt_claims.attributes[0].populate_value(&mut filler, b"4074087");
420
421		jwt_claims.attributes[1].populate_len_bytes(&mut filler, 10);
422		jwt_claims.attributes[1].populate_value(&mut filler, b"1234567890");
423
424		circuit.populate_wire_witness(&mut filler).unwrap();
425
426		// Verify constraints
427		let cs = circuit.constraint_system();
428		cs.verify(&filler.into_value_vec()).unwrap();
429	}
430
431	#[test]
432	fn test_empty_string_value() {
433		let b = CircuitBuilder::new();
434
435		let len_bytes = b.add_witness();
436		let json: Vec<Wire> = (0..16).map(|_| b.add_witness()).collect();
437
438		let attributes = vec![Attribute {
439			name: "empty",
440			len_bytes: b.add_inout(),
441			value: (0..1).map(|_| b.add_inout()).collect(),
442		}];
443
444		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
445
446		let circuit = b.build();
447		let mut filler = circuit.new_witness_filler();
448
449		// JSON with empty string value
450		let json_str = r#"{"empty":"","sub":"123"}"#;
451
452		// Populate inputs
453		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
454		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
455
456		// Populate expected empty value
457		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 0);
458		jwt_claims.attributes[0].populate_value(&mut filler, b"");
459
460		circuit.populate_wire_witness(&mut filler).unwrap();
461
462		// Verify constraints
463		let cs = circuit.constraint_system();
464		cs.verify(&filler.into_value_vec()).unwrap();
465	}
466
467	#[test]
468	fn test_special_characters() {
469		let b = CircuitBuilder::new();
470
471		let len_bytes = b.add_witness();
472		let json: Vec<Wire> = (0..32).map(|_| b.add_witness()).collect();
473
474		let attributes = vec![
475			Attribute {
476				name: "email",
477				len_bytes: b.add_inout(),
478				value: (0..4).map(|_| b.add_inout()).collect(),
479			},
480			Attribute {
481				name: "nonce",
482				len_bytes: b.add_inout(),
483				value: (0..4).map(|_| b.add_inout()).collect(),
484			},
485		];
486
487		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
488
489		let circuit = b.build();
490		let mut filler = circuit.new_witness_filler();
491
492		// JSON with special characters
493		let json_str = r#"{"email":"john.doe@gmail.com","nonce":"7-VU9fuWeWtgDLHmVJ2UtRrine8"}"#;
494
495		// Populate inputs
496		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
497		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
498
499		// Populate expected values
500		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 18);
501		jwt_claims.attributes[0].populate_value(&mut filler, b"john.doe@gmail.com");
502
503		jwt_claims.attributes[1].populate_len_bytes(&mut filler, 27);
504		jwt_claims.attributes[1].populate_value(&mut filler, b"7-VU9fuWeWtgDLHmVJ2UtRrine8");
505
506		circuit.populate_wire_witness(&mut filler).unwrap();
507
508		// Verify constraints
509		let cs = circuit.constraint_system();
510		cs.verify(&filler.into_value_vec()).unwrap();
511	}
512
513	#[test]
514	fn test_last_attribute_no_comma() {
515		let b = CircuitBuilder::new();
516
517		let len_bytes = b.add_witness();
518		let json: Vec<Wire> = (0..16).map(|_| b.add_witness()).collect();
519
520		let attributes = vec![
521			Attribute {
522				name: "iss",
523				len_bytes: b.add_inout(),
524				value: (0..16 / 8).map(|_| b.add_inout()).collect(),
525			},
526			Attribute {
527				name: "last",
528				len_bytes: b.add_inout(),
529				value: (0..16 / 8).map(|_| b.add_inout()).collect(),
530			},
531		];
532
533		let jwt_claims = JwtClaims::new(&b, len_bytes, json, attributes);
534
535		let circuit = b.build();
536		let mut filler = circuit.new_witness_filler();
537
538		// JSON where the last attribute has no comma after it (terminated by })
539		let json_str = r#"{"iss":"example.com","last":"value123"}"#;
540
541		// Populate inputs
542		jwt_claims.populate_len_bytes(&mut filler, json_str.len());
543		jwt_claims.populate_json(&mut filler, json_str.as_bytes());
544
545		// Populate expected values
546		jwt_claims.attributes[0].populate_len_bytes(&mut filler, 11);
547		jwt_claims.attributes[0].populate_value(&mut filler, b"example.com");
548
549		jwt_claims.attributes[1].populate_len_bytes(&mut filler, 8);
550		jwt_claims.attributes[1].populate_value(&mut filler, b"value123");
551
552		circuit.populate_wire_witness(&mut filler).unwrap();
553
554		// Verify constraints
555		let cs = circuit.constraint_system();
556		cs.verify(&filler.into_value_vec()).unwrap();
557	}
558}