Skip to main content

binius_circuits/
base64.rs

1// Copyright 2025 Irreducible Inc.
2// Copyright 2026 The Binius Developers
3use binius_core::word::Word;
4use binius_frontend::{CircuitBuilder, Wire, WitnessFiller};
5
6/// Base64 encoding (URL-safe, without trailing padding characters) encoding verification.
7///
8/// Verifies that encoded data is a valid base64 URL-safe encoding (without
9/// trailing padding characters) of decoded data.
10///
11/// This encoding is defined in the JSON Web Signature (JWS) spec:
12/// <https://datatracker.ietf.org/doc/html/rfc7515#section-2> (Base64url Encoding)
13///
14/// # Base64 URL-Safe Alphabet (RFC 4648 §5)
15///
16/// - Characters 0-61: Same as standard base64 (A-Z, a-z, 0-9)
17/// - Character 62: '-' (minus) instead of '+'
18/// - Character 63: '_' (underscore) instead of '/'
19///
20/// # Circuit Behavior
21///
22/// The circuit performs the following validations:
23/// - encoded is valid base64 URL-safe encoding of decoded
24/// - len_decoded is the actual length of data in decoded (in bytes)
25/// - len_decoded ≤ max_len_decoded (compile-time maximum)
26///
27/// # Input Packing
28///
29/// - decoded: Pack 8 bytes per 64-bit word in little-endian format
30/// - encoded: Pack 8 base64 characters per 64-bit word in little-endian format
31/// - len_decoded: Single 64-bit word containing byte count
32pub struct Base64UrlSafe {
33	/// Decoded data array (packed 8 bytes per word).
34	pub decoded: Vec<Wire>,
35	/// Encoded base64 array (packed 8 chars per word).
36	pub encoded: Vec<Wire>,
37	/// Actual length of decoded data in bytes.
38	pub len_bytes: Wire,
39}
40
41impl Base64UrlSafe {
42	/// Creates a new Base64UrlSafe verifier.
43	///
44	/// # Arguments
45	///
46	/// * `builder` - Circuit builder for constructing constraints
47	/// * `decoded` - raw byte array wires
48	/// * `encoded` - Base64 encoded array wires
49	/// * `len_bytes` - Wire containing actual length of raw data in bytes
50	///
51	/// # Panics
52	///
53	/// * If `decoded.len()` is not a multiple of 3
54	///
55	/// # Implementation Notes
56	///
57	/// The requirement that `decoded.len()` be a multiple of 3 ensures:
58	/// - Word alignment: divisible by 8 for packing bytes into 64-bit words
59	/// - Base64 group alignment: divisible by 3 for processing complete groups
60	/// - Exact array sizing with no rounding needed
61	pub fn new(
62		builder: &CircuitBuilder,
63		decoded: Vec<Wire>,
64		encoded: Vec<Wire>,
65		len_bytes: Wire,
66	) -> Self {
67		// Verify length bounds
68		verify_length_bounds(builder, len_bytes, decoded.len() << 3);
69
70		// Process groups of 3 bytes -> 4 base64 chars
71		let groups = (decoded.len() << 3).div_ceil(3); // how many 3-byte chunks are there?
72
73		for group_idx in 0..groups {
74			let b = builder.subcircuit(format!("group[{group_idx}]"));
75			verify_base64_group(&b, &decoded, &encoded, len_bytes, group_idx);
76		}
77
78		Self {
79			decoded,
80			encoded,
81			len_bytes,
82		}
83	}
84
85	/// Populates the length wire with the actual decoded data length.
86	///
87	/// # Arguments
88	///
89	/// * `w` - Witness filler to populate
90	/// * `length` - Actual length of decoded data in bytes
91	pub fn populate_len_bytes(&self, w: &mut WitnessFiller<'_>, len_bytes: usize) {
92		w[self.len_bytes] = Word(len_bytes as u64);
93	}
94
95	/// Populates the decoded data array from a byte slice.
96	///
97	/// # Arguments
98	///
99	/// * `w` - Witness filler to populate
100	/// * `data` - Decoded bytes
101	///
102	/// # Panics
103	///
104	/// Panics if `data.len()` exceeds the maximum size specified during construction.
105	pub fn populate_decoded(&self, w: &mut WitnessFiller<'_>, data: &[u8]) {
106		w.pack_bytes_le(&self.decoded, data);
107	}
108
109	/// Populates the encoded base64 array from a byte slice.
110	///
111	/// # Arguments
112	///
113	/// * `w` - Witness filler to populate
114	/// * `data` - Base64-encoded bytes
115	///
116	/// # Panics
117	///
118	/// Panics if `data.len()` exceeds the maximum size specified during construction.
119	pub fn populate_encoded(&self, w: &mut WitnessFiller<'_>, data: &[u8]) {
120		w.pack_bytes_le(&self.encoded, data);
121	}
122}
123
124/// Verifies that the length is within bounds (0 < len_decoded <= max_len_decoded).
125fn verify_length_bounds(builder: &CircuitBuilder, len_bytes: Wire, max_len_bytes: usize) {
126	// Check if len_decoded > max_len_decoded (which should be false)
127	let too_long = builder.icmp_ugt(len_bytes, builder.add_constant_64(max_len_bytes as u64));
128	builder.assert_false("length_check", too_long);
129}
130
131/// Verifies a single base64 group (3 decoded bytes -> 4 base64 chars).
132///
133/// # Base64 Encoding Rules
134///
135/// Three bytes: AAAAAAAA BBBBBBBB CCCCCCCC
136/// Become four 6-bit values:
137/// - val0 = AAAAAA (top 6 bits of byte0)
138/// - val1 = AABBBB (bottom 2 bits of byte0 + top 4 bits of byte1)
139/// - val2 = BBBBCC (bottom 4 bits of byte1 + top 2 bits of byte2)
140/// - val3 = CCCCCC (bottom 6 bits of byte2)
141fn verify_base64_group(
142	builder: &CircuitBuilder,
143	decoded: &[Wire],
144	encoded: &[Wire],
145	len_bytes: Wire,
146	group_idx: usize,
147) {
148	let base_byte_idx = group_idx * 3;
149	let base_char_idx = group_idx * 4;
150
151	// Extract 3 decoded bytes
152	let byte0 = extract_byte(builder, decoded, base_byte_idx);
153	let byte1 = extract_byte(builder, decoded, base_byte_idx + 1);
154	let byte2 = extract_byte(builder, decoded, base_byte_idx + 2);
155
156	let has_1 = builder.icmp_ult(builder.add_constant_64(base_byte_idx as u64), len_bytes);
157	let has_2 = builder.icmp_ult(builder.add_constant_64((base_byte_idx + 1) as u64), len_bytes);
158	let has_3 = builder.icmp_ult(builder.add_constant_64((base_byte_idx + 2) as u64), len_bytes);
159
160	let zero = builder.add_constant(Word::ZERO);
161	builder.assert_eq_cond("past boundary should be empty", byte0, zero, builder.bnot(has_1));
162	builder.assert_eq_cond("past boundary should be empty", byte1, zero, builder.bnot(has_2));
163	builder.assert_eq_cond("past boundary should be empty", byte2, zero, builder.bnot(has_3));
164
165	// Convert 3 bytes to 4 6-bit values
166	let val0 = extract_6bit_value_0(builder, byte0);
167	let val1 = extract_6bit_value_1(builder, byte0, byte1);
168	let val2 = extract_6bit_value_2(builder, byte1, byte2);
169	let val3 = extract_6bit_value_3(builder, byte2);
170
171	// Convert 6-bit values to base64 encoded chars
172	let expected_char0 = compute_expected_base64_char(builder, val0);
173	let expected_char1 = compute_expected_base64_char(builder, val1);
174	let expected_char2 = compute_expected_base64_char(builder, val2);
175	let expected_char3 = compute_expected_base64_char(builder, val3);
176
177	// Extract 4 base64 characters
178	let actual_char0 = extract_byte(builder, encoded, base_char_idx);
179	let actual_char1 = extract_byte(builder, encoded, base_char_idx + 1);
180	let actual_char2 = extract_byte(builder, encoded, base_char_idx + 2);
181	let actual_char3 = extract_byte(builder, encoded, base_char_idx + 3);
182
183	verify_base64_char(builder, expected_char0, actual_char0, has_1);
184	verify_base64_char(builder, expected_char1, actual_char1, has_1);
185	verify_base64_char(builder, expected_char2, actual_char2, has_2);
186	verify_base64_char(builder, expected_char3, actual_char3, has_3);
187}
188
189/// Extracts a byte from a word array at the given byte index.
190///
191/// # Arguments
192///
193/// * `builder` - Circuit builder
194/// * `words` - Array of 64-bit words, each containing 8 packed bytes in little-endian format
195/// * `byte_idx` - Global byte index to extract
196///
197/// # Returns
198///
199/// Wire containing the extracted byte value (0-255), or 0 if out of bounds.
200fn extract_byte(builder: &CircuitBuilder, words: &[Wire], byte_idx: usize) -> Wire {
201	let word_idx = byte_idx / 8;
202	let byte_offset = byte_idx % 8;
203
204	let zero = builder.add_constant(Word::ZERO);
205	let word = words.get(word_idx).copied().unwrap_or(zero);
206	builder.extract_byte(word, byte_offset as u32)
207}
208
209/// Extracts the first 6-bit value (top 6 bits of byte0).
210fn extract_6bit_value_0(builder: &CircuitBuilder, byte0: Wire) -> Wire {
211	builder.shr(byte0, 2)
212}
213
214/// Extracts the second 6-bit value (bottom 2 bits of byte0 + top 4 bits of byte1).
215fn extract_6bit_value_1(builder: &CircuitBuilder, byte0: Wire, byte1: Wire) -> Wire {
216	let byte0_low = builder.band(byte0, builder.add_constant_64(0x03));
217	builder.bxor(builder.shl(byte0_low, 4), builder.shr(byte1, 4))
218}
219
220/// Extracts the third 6-bit value (bottom 4 bits of byte1 + top 2 bits of byte2).
221fn extract_6bit_value_2(builder: &CircuitBuilder, byte1: Wire, byte2: Wire) -> Wire {
222	let byte1_low = builder.band(byte1, builder.add_constant_64(0x0F));
223	builder.bxor(builder.shl(byte1_low, 2), builder.shr(byte2, 6))
224}
225
226/// Extracts the fourth 6-bit value (bottom 6 bits of byte2).
227fn extract_6bit_value_3(builder: &CircuitBuilder, byte2: Wire) -> Wire {
228	builder.band(byte2, builder.add_constant_64(0x3F))
229}
230
231/// Verifies that a base64 character matches the expected encoding.
232///
233/// # Arguments
234///
235/// * `builder` - Circuit builder
236/// * `expected_encoded_char` - The expected encoding of the character
237/// * `char_val` - The actual character value found
238/// * `is_active` - Whether this check should be enforced
239fn verify_base64_char(
240	builder: &CircuitBuilder,
241	expected_encoded_char: Wire,
242	actual_encoded_char: Wire,
243	is_active: Wire,
244) {
245	builder.assert_eq(
246		"base64_char",
247		actual_encoded_char,
248		builder.select(is_active, expected_encoded_char, builder.add_constant(Word::ZERO)),
249	);
250}
251
252/// Computes the expected base64 URL-safe character for a 6-bit value.
253///
254/// # Base64 URL-Safe Mapping
255///
256/// - 0-25: 'A'-'Z' (65-90)
257/// - 26-51: 'a'-'z' (97-122)
258/// - 52-61: '0'-'9' (48-57)
259/// - 62: '-' (45) [URL-safe variant]
260/// - 63: '_' (95) [URL-safe variant]
261///
262/// # Arguments
263///
264/// * `builder` - Circuit builder
265/// * `six_bit_val` - The 6-bit value to encode (0-63)
266///
267/// # Preconditions
268///
269/// `six_bit_val` must be at most 63. Every caller derives it by masking or shifting a byte, so
270/// the range holds by construction; a wider value would run off the end of the alphabet.
271///
272/// # Implementation Details
273///
274/// The alphabet is affine on each of its ranges, so the character follows from three range
275/// checks and one addition, costing 11 AND constraints. Reading the mapping out of a 64-entry
276/// constant table with a multiplexer instead costs 70.
277fn compute_expected_base64_char(builder: &CircuitBuilder, six_bit_val: Wire) -> Wire {
278	let is_lowercase = builder.icmp_uge(six_bit_val, builder.add_constant_64(26));
279	let is_digit = builder.icmp_uge(six_bit_val, builder.add_constant_64(52));
280	let is_symbol = builder.icmp_uge(six_bit_val, builder.add_constant_64(62));
281
282	// Each range adds a fixed offset to the value. The digit offset is the byte-sized
283	// representation of -4, so its sum carries out of the low byte and is truncated below.
284	let offset = builder.select(
285		is_lowercase,
286		builder.add_constant_64(b'a' as u64 - 26),
287		builder.add_constant_64(b'A' as u64),
288	);
289	let offset = builder.select(is_digit, builder.add_constant_64(0xfc), offset);
290
291	// The digit offset carries out of the low byte, so the sum is masked back down to one.
292	let sum = builder.iadd_32(six_bit_val, offset);
293	let alphanumeric = builder.band(sum, builder.add_constant_64(0xff));
294
295	// The two symbols are told apart by the low bit of the value, moved into the MSB that
296	// `select` reads as its condition.
297	let symbol = builder.select(
298		builder.shl(six_bit_val, 63),
299		builder.add_constant_64(b'_' as u64),
300		builder.add_constant_64(b'-' as u64),
301	);
302
303	builder.select(is_symbol, symbol, alphanumeric)
304}
305
306#[cfg(test)]
307mod tests {
308	use binius_frontend::CircuitBuilder;
309
310	use super::{Base64UrlSafe, Wire};
311
312	/// Encodes bytes to base64 using URL-safe alphabet without trailing padding
313	/// '=" chars.
314	fn encode_base64(input: &[u8]) -> Vec<u8> {
315		const BASE64_CHARS: &[u8] =
316			b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
317
318		let mut output = Vec::new();
319
320		for chunk in input.chunks(3) {
321			let b1 = chunk[0];
322			let b2 = chunk.get(1).copied().unwrap_or(0);
323			let b3 = chunk.get(2).copied().unwrap_or(0);
324
325			let n = ((b1 as u32) << 16) | ((b2 as u32) << 8) | (b3 as u32);
326
327			output.push(BASE64_CHARS[((n >> 18) & 63) as usize]);
328			output.push(BASE64_CHARS[((n >> 12) & 63) as usize]);
329
330			if chunk.len() > 1 {
331				output.push(BASE64_CHARS[((n >> 6) & 63) as usize]);
332			};
333
334			if chunk.len() > 2 {
335				output.push(BASE64_CHARS[(n & 63) as usize]);
336			};
337		}
338
339		output
340	}
341
342	/// Helper to create base64 circuit with given max size.
343	fn create_base64_circuit(builder: &CircuitBuilder, max_len_decoded: usize) -> Base64UrlSafe {
344		// Create input wires
345		assert!(
346			max_len_decoded.is_multiple_of(3),
347			"max_len_decoded must be a multiple of 3, got {max_len_decoded}"
348		);
349		let decoded: Vec<Wire> = (0..max_len_decoded).map(|_| builder.add_inout()).collect();
350		let max_len_encoded = (max_len_decoded / 3) * 4;
351		let encoded: Vec<Wire> = (0..max_len_encoded).map(|_| builder.add_inout()).collect();
352
353		let len_bytes = builder.add_inout();
354
355		Base64UrlSafe::new(builder, decoded, encoded, len_bytes)
356	}
357
358	/// Core helper that tests base64 encoding verification and returns a Result.
359	fn check_base64_encoding(
360		input_bytes: &[u8],
361		encoded: &[u8],
362		max_len_decoded: usize,
363	) -> Result<(), Box<dyn std::error::Error>> {
364		let builder = CircuitBuilder::new();
365		let circuit = create_base64_circuit(&builder, max_len_decoded);
366		let compiled = builder.build();
367
368		// Create witness
369		let mut witness = compiled.new_witness_filler();
370
371		circuit.populate_len_bytes(&mut witness, input_bytes.len());
372		circuit.populate_decoded(&mut witness, input_bytes);
373		circuit.populate_encoded(&mut witness, encoded);
374
375		// Verify circuit
376		compiled.populate_wire_witness(&mut witness)?;
377
378		// Verify constraints
379		let cs = compiled.constraint_system();
380		cs.verify(&witness.into_value_vec())?;
381
382		Ok(())
383	}
384
385	/// Helper to test base64 encoding verification with specified padding mode.
386	fn test_base64_encoding(input: &[u8], max_len_decoded: usize) {
387		let expected_base64 = encode_base64(input);
388		check_base64_encoding(input, &expected_base64, max_len_decoded).unwrap();
389	}
390
391	/// Assert that the base64 circuit fails to verify the specified inputs
392	fn assert_base64_failure(input: &[u8], encoded: &[u8], max_len_decoded: usize) {
393		check_base64_encoding(input, encoded, max_len_decoded).unwrap_err();
394	}
395
396	#[test]
397	fn test_base64_hello_world() {
398		test_base64_encoding(b"Hello World!", 189);
399	}
400
401	#[test]
402	fn test_base64_empty() {
403		test_base64_encoding(b"", 189);
404	}
405
406	#[test]
407	fn test_base64_long_input() {
408		let input =
409			b"The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog.";
410		test_base64_encoding(input, 189);
411	}
412
413	#[test]
414	fn test_invalid_base64() {
415		let input = b"ABC";
416		let invalid_base64 = b"XXXX"; // Invalid base64 for "ABC"
417		assert_base64_failure(input, invalid_base64, 15);
418	}
419
420	/// Packs `0..64` as consecutive 6-bit groups, most significant bit first, so that the base64
421	/// encoding of the result is the alphabet in order.
422	fn all_six_bit_values() -> Vec<u8> {
423		let mut bytes = vec![0u8; 48];
424		for value in 0..64usize {
425			for k in 0..6 {
426				if (value >> (5 - k)) & 1 == 1 {
427					let bit_index = value * 6 + k;
428					bytes[bit_index / 8] |= 1 << (7 - bit_index % 8);
429				}
430			}
431		}
432		bytes
433	}
434
435	#[test]
436	fn test_every_alphabet_index() {
437		const BASE64_CHARS: &[u8] =
438			b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
439
440		let input = all_six_bit_values();
441		let encoded = encode_base64(&input);
442		assert_eq!(encoded, BASE64_CHARS, "fixture must cover every alphabet index");
443
444		// The circuit derives each character from the decoded bytes and asserts it against the
445		// encoded ones, so this accepts only if all 64 indices map to the right character.
446		check_base64_encoding(&input, &encoded, 48).unwrap();
447	}
448
449	#[test]
450	fn test_every_alphabet_index_rejects_wrong_character() {
451		let input = all_six_bit_values();
452		let encoded = encode_base64(&input);
453
454		// Corrupting one character at a time proves no index is left unchecked.
455		for position in 0..encoded.len() {
456			let mut corrupted = encoded.clone();
457			// Flipping the low bit always lands on a different character.
458			corrupted[position] ^= 1;
459			assert_base64_failure(&input, &corrupted, 48);
460		}
461	}
462
463	#[test]
464	fn test_url_safe_characters() {
465		// Test that URL-safe characters - and _ are used instead of + and /
466		// Create input that will result in characters 62 and 63 in base64
467
468		// For 111110 (62): we need top 6 bits = 111110
469		let input1 = &[0b11111000]; // Top 6 bits = 111110 = 62
470		let expected1 = encode_base64(input1);
471		assert_eq!(expected1[0], b'-', "Index 62 should map to '-' not '+'");
472
473		// For 111111 (63): we need top 6 bits = 111111
474		let input2 = &[0b11111100]; // Top 6 bits = 111111 = 63
475		let expected2 = encode_base64(input2);
476		assert_eq!(expected2[0], b'_', "Index 63 should map to '_' not '/'");
477
478		// Now test with the circuit
479		test_base64_encoding(input1, 15);
480		test_base64_encoding(input2, 15);
481	}
482
483	#[test]
484	#[should_panic(expected = "max_len_decoded must be a multiple of 3")]
485	fn test_panic_when_max_len_not_multiple_of_3() {
486		// This test verifies that max_len_decoded must be a multiple of 3
487		// Testing with max_len_decoded = 13 which is not a multiple of 3
488		test_base64_encoding(b"test", 13);
489	}
490
491	#[test]
492	fn test_encoding_with_padding_rejected() {
493		let input = b"A";
494		let encoding_with_padding = b"QQ==";
495		// encoding with padding should be rejected
496		assert_base64_failure(input, encoding_with_padding, 15);
497	}
498}