Skip to content

Commit 68444ab

Browse files
committed
[sha256] sha256_fixed function for fixed-length inputs (#909)
This is useful for Bitcoin applications, like proving knowledge of an address private key. This involves a SHA-256 hash of a fixed-size payload. I chose to make it the callers responsibility to split the wires into words, we'll need to add another function to help with that. Part of the rationale is to decouple concerns. More practically, the output digest will be the input to RIPEMD160 hash, which also operates on 32-bit words, so having interfaces for both that take inputs and outputs split into words is natural and efficient for chaining them.
1 parent 43c8daf commit 68444ab

File tree

1 file changed

+224
-1
lines changed
  • crates/frontend/src/circuits/sha256

1 file changed

+224
-1
lines changed

crates/frontend/src/circuits/sha256/mod.rs

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,12 +485,134 @@ impl Sha256 {
485485
}
486486
}
487487

488+
/// Computes SHA-256 hash of a fixed-length message.
489+
///
490+
/// This function creates a subcircuit that computes the SHA-256 hash of a message
491+
/// with a compile-time known length. Unlike the `Sha256` struct which handles
492+
/// variable-length inputs, this function is optimized for fixed-length inputs where
493+
/// the length is known at circuit construction time.
494+
///
495+
/// # Arguments
496+
/// * `builder` - Circuit builder for constructing constraints
497+
/// * `message` - Input message as 32-bit words (4 bytes per wire) in big-endian format. Each wire
498+
/// must have the high 32 bits set to zero (enforced as a precondition).
499+
/// * `len_bytes` - The fixed length of the message in bytes (known at compile time)
500+
///
501+
/// # Returns
502+
/// * `[Wire; 8]` - The SHA-256 digest as 8 wires, each containing a 32-bit word in the low 32 bits
503+
/// (high 32 bits are zero) in big-endian order
504+
///
505+
/// # Panics
506+
/// * If `message.len()` does not equal exactly `len_bytes.div_ceil(4)`
507+
/// * If the message length in bits cannot fit in 32 bits
508+
///
509+
/// # Example
510+
/// ```rust,ignore
511+
/// use binius_frontend::circuits::sha256::sha256_fixed;
512+
/// use binius_frontend::compiler::CircuitBuilder;
513+
///
514+
/// let mut builder = CircuitBuilder::new();
515+
///
516+
/// // Create input wires for a 32-byte message (8 32-bit words)
517+
/// let message: Vec<_> = (0..8).map(|_| builder.add_witness()).collect();
518+
///
519+
/// // Compute SHA-256 of the 32-byte message
520+
/// let digest = sha256_fixed(&builder, &message, 32);
521+
/// ```
522+
pub fn sha256_fixed(builder: &CircuitBuilder, message: &[Wire], len_bytes: usize) -> [Wire; 8] {
523+
// Validate that message.len() equals exactly len_bytes.div_ceil(4)
524+
assert_eq!(
525+
message.len(),
526+
len_bytes.div_ceil(4),
527+
"message.len() ({}) must equal len_bytes.div_ceil(4) ({})",
528+
message.len(),
529+
len_bytes.div_ceil(4)
530+
);
531+
532+
// Ensure message length in bits fits in 32 bits
533+
assert!(
534+
(len_bytes as u64)
535+
.checked_mul(8)
536+
.is_some_and(|bits| bits <= u32::MAX as u64),
537+
"Message length in bits must fit in 32 bits"
538+
);
539+
540+
// Calculate padding requirements
541+
// SHA-256 requires: message || 0x80 || zeros || 64-bit length field
542+
// The 64-bit length field goes in the last 8 bytes of a block
543+
// We need at least 9 bytes for padding (1 for 0x80 + 8 for length)
544+
let n_blocks = (len_bytes + 9).div_ceil(64);
545+
let n_padded_words = n_blocks * 16; // 16 32-bit words per block
546+
547+
// Create padded message
548+
let mut padded_message = Vec::with_capacity(n_padded_words);
549+
550+
// Add message words
551+
let n_message_words = len_bytes / 4;
552+
let boundary_bytes = len_bytes % 4;
553+
554+
// Add complete message words
555+
padded_message.extend_from_slice(&message[0..n_message_words]);
556+
557+
// Handle partial word at boundary
558+
if boundary_bytes > 0 {
559+
// The last message word contains partial data
560+
let last_word = message[n_message_words];
561+
562+
// Mask out the unused bytes and add delimiter
563+
let shift_amount = (4 - boundary_bytes) * 8;
564+
let mask = builder.add_constant(Word((0xFFFFFFFFu64 >> shift_amount) << shift_amount));
565+
let masked = builder.band(last_word, mask);
566+
567+
// Add 0x80 delimiter at the right position
568+
let delimiter_shift = (3 - boundary_bytes) * 8;
569+
let delimiter = builder.add_constant(Word(0x80u64 << delimiter_shift));
570+
let boundary_word = builder.bxor(masked, delimiter);
571+
572+
padded_message.push(boundary_word);
573+
} else {
574+
// Message ends at word boundary - delimiter goes in new word
575+
padded_message.push(builder.add_constant(Word(0x80000000)));
576+
}
577+
578+
// Fill with zeros until we reach the length field position
579+
let zero = builder.add_constant(Word::ZERO);
580+
padded_message.resize(n_padded_words - 2, zero);
581+
582+
// Add the length field (64 bits total)
583+
padded_message.push(zero); // High 32 bits of length (always 0 for us)
584+
let bitlen = (len_bytes as u64) * 8;
585+
padded_message.push(builder.add_constant(Word(bitlen)));
586+
587+
// Process compression blocks
588+
let state_out = padded_message.chunks_exact(16).enumerate().fold(
589+
State::iv(builder),
590+
|state, (block_idx, block)| {
591+
let block_message: [Wire; 16] = block.try_into().unwrap();
592+
593+
let compress = Compress::new(
594+
&builder.subcircuit(format!("sha256_fixed_compress[{}]", block_idx)),
595+
state.clone(),
596+
block_message,
597+
);
598+
599+
compress.state_out
600+
},
601+
);
602+
603+
// Return the final state as 8 32-bit words
604+
state_out.0
605+
}
606+
488607
#[cfg(test)]
489608
mod tests {
609+
use std::array;
610+
490611
use binius_core::Word;
491612
use hex_literal::hex;
613+
use sha2::Digest;
492614

493-
use super::Sha256;
615+
use super::*;
494616
use crate::{
495617
compiler::{self, Wire},
496618
constraint_verifier::verify_constraints,
@@ -906,4 +1028,105 @@ mod tests {
9061028
}
9071029
assert_eq!(&extracted_bytes, message);
9081030
}
1031+
1032+
// Helper function for sha256_fixed tests
1033+
fn test_sha256_fixed_with_input(message: &[u8], expected_bytes: [u8; 32]) {
1034+
let b = CircuitBuilder::new();
1035+
1036+
// Pack message into 32-bit words
1037+
let n_words = message.len().div_ceil(4);
1038+
let mut message_wires = Vec::new();
1039+
1040+
for word_idx in 0..n_words {
1041+
let mut packed = 0u32;
1042+
for i in 0..4 {
1043+
let byte_idx = word_idx * 4 + i;
1044+
if byte_idx < message.len() {
1045+
packed |= (message[byte_idx] as u32) << (24 - i * 8);
1046+
}
1047+
}
1048+
message_wires.push(b.add_constant(Word(packed as u64)));
1049+
}
1050+
1051+
// Create expected digest wires (8 32-bit words)
1052+
let expected_digest_wires = array::from_fn::<_, 8, _>(|_| b.add_inout());
1053+
1054+
// Compute the digest
1055+
let computed_digest = sha256_fixed(&b, &message_wires, message.len());
1056+
1057+
// Assert that computed digest equals expected digest
1058+
for i in 0..8 {
1059+
b.assert_eq(format!("digest[{}]", i), computed_digest[i], expected_digest_wires[i]);
1060+
}
1061+
1062+
let circuit = b.build();
1063+
let cs = circuit.constraint_system();
1064+
let mut w = circuit.new_witness_filler();
1065+
1066+
// Populate the expected digest wires
1067+
for i in 0..8 {
1068+
let mut word = 0u32;
1069+
for j in 0..4 {
1070+
word |= (expected_bytes[i * 4 + j] as u32) << (24 - j * 8);
1071+
}
1072+
w[expected_digest_wires[i]] = Word(word as u64);
1073+
}
1074+
1075+
circuit.populate_wire_witness(&mut w).unwrap();
1076+
verify_constraints(cs, &w.into_value_vec()).unwrap();
1077+
}
1078+
1079+
#[test]
1080+
#[should_panic(expected = "message.len() (1) must equal len_bytes.div_ceil(4) (2)")]
1081+
fn test_sha256_fixed_with_insufficient_wires() {
1082+
use super::sha256_fixed;
1083+
let builder = compiler::CircuitBuilder::new();
1084+
1085+
// Create only 1 wire but claim message is 5 bytes (which needs 2 wires)
1086+
let message_wires: Vec<Wire> = vec![builder.add_witness()];
1087+
1088+
// This should panic because message.len() (1) != len_bytes.div_ceil(4) (2)
1089+
sha256_fixed(&builder, &message_wires, 5);
1090+
}
1091+
1092+
#[test]
1093+
fn test_sha256_fixed_various_sizes() {
1094+
use rand::{Rng, SeedableRng, rngs::StdRng};
1095+
1096+
// Test various message sizes to ensure padding works correctly
1097+
let sizes = vec![
1098+
0, // empty
1099+
1, // single byte
1100+
3, // "abc" test vector
1101+
4, // exactly one word
1102+
5, // just over word boundary
1103+
31, // just under half block
1104+
32, // exactly half block
1105+
33, // just over half block
1106+
55, // max single block
1107+
56, // forces two blocks
1108+
63, // one byte from block boundary
1109+
64, // exactly one block
1110+
65, // just over one block
1111+
119, // max two blocks
1112+
120, // forces three blocks
1113+
128, // exactly two blocks
1114+
256, // exactly four blocks
1115+
];
1116+
1117+
let mut rng = StdRng::seed_from_u64(0);
1118+
1119+
for size in sizes {
1120+
// Generate random payload
1121+
let mut message = vec![0u8; size];
1122+
rng.fill(&mut message[..]);
1123+
1124+
// Compute expected hash using sha2 crate
1125+
let expected = sha2::Sha256::digest(&message);
1126+
let expected_bytes: [u8; 32] = expected.into();
1127+
1128+
// Test with our circuit
1129+
test_sha256_fixed_with_input(&message, expected_bytes);
1130+
}
1131+
}
9091132
}

0 commit comments

Comments
 (0)