1use crate::{DefaultHasher, Hasher};
6use alloc::vec::Vec;
7use core::fmt;
8use core::marker::PhantomData;
9use core::num::NonZeroU64;
10use hekate_math::TowerField;
11
12pub type Result<T> = core::result::Result<T, Error>;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum Error {
16 FieldTooLargeForChallenge {
19 field_bytes: usize,
20 max_entropy_bytes: usize,
21 },
22 EmptyIndexRange,
23}
24
25impl fmt::Display for Error {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::FieldTooLargeForChallenge {
29 field_bytes,
30 max_entropy_bytes,
31 } => write!(
32 f,
33 "field too large for transcript entropy: field_bytes={field_bytes}, max_entropy_bytes={max_entropy_bytes}",
34 ),
35 Self::EmptyIndexRange => write!(f, "index challenge over an empty range"),
36 }
37 }
38}
39
40#[cfg(feature = "transcript-trace")]
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum TranscriptOp {
45 AppendMessage {
46 label: &'static [u8],
47 digest: [u8; 32],
48 },
49 AppendU64 {
50 label: &'static [u8],
51 value: u64,
52 },
53 AppendField {
54 label: &'static [u8],
55 digest: [u8; 32],
56 },
57 AppendFieldList {
58 label: &'static [u8],
59 count: u64,
60 digest: [u8; 32],
61 },
62 ChallengeField {
63 label: &'static [u8],
64 },
65 ChallengeIndices {
66 label: &'static [u8],
67 count: u64,
68 },
69}
70
71#[derive(Clone, Debug)]
79pub struct Transcript<H: Hasher = DefaultHasher> {
80 hasher: H,
81 #[cfg(feature = "transcript-trace")]
82 trace: Vec<TranscriptOp>,
83 _marker: PhantomData<H>,
84}
85
86impl<H: Hasher> Transcript<H> {
87 pub fn new(label: &'static [u8]) -> Self {
89 let mut transcript = Self {
90 hasher: H::new(),
91 #[cfg(feature = "transcript-trace")]
92 trace: Vec::new(),
93 _marker: PhantomData,
94 };
95
96 transcript.absorb_label(b"hekate-transcript-v2");
97 transcript
98 .hasher
99 .update(&(label.len() as u64).to_le_bytes());
100 transcript.absorb_label(label);
101
102 transcript
103 }
104
105 #[cfg(feature = "transcript-trace")]
106 pub fn take_trace(&mut self) -> Vec<TranscriptOp> {
107 core::mem::take(&mut self.trace)
108 }
109
110 #[cfg(feature = "transcript-trace")]
111 pub fn trace(&self) -> &[TranscriptOp] {
112 &self.trace
113 }
114
115 pub fn append_message(&mut self, label: &'static [u8], message: &[u8]) {
118 self.absorb_label(label);
119
120 self.hasher.update(&(message.len() as u64).to_le_bytes());
121 self.hasher.update(message);
122
123 self.pad(message.len());
124
125 #[cfg(feature = "transcript-trace")]
126 self.trace.push(TranscriptOp::AppendMessage {
127 label,
128 digest: payload_digest::<H>(message),
129 });
130 }
131
132 pub fn append_u64(&mut self, label: &'static [u8], value: u64) {
135 self.absorb_label(label);
136 self.hasher.update(&value.to_le_bytes());
137
138 #[cfg(feature = "transcript-trace")]
139 self.trace.push(TranscriptOp::AppendU64 { label, value });
140 }
141
142 pub fn append_field<F: TowerField>(&mut self, label: &'static [u8], element: F) {
143 self.absorb_label(label);
144
145 let bytes = element.to_bytes();
146 self.hasher.update(&bytes);
147
148 self.pad(bytes.len());
149
150 #[cfg(feature = "transcript-trace")]
151 self.trace.push(TranscriptOp::AppendField {
152 label,
153 digest: payload_digest::<H>(&bytes),
154 });
155 }
156
157 pub fn append_field_list<F: TowerField>(&mut self, label: &'static [u8], elements: &[F]) {
162 self.absorb_label(label);
163
164 self.hasher.update(&(elements.len() as u64).to_le_bytes());
165 self.hasher.update_fields(elements.iter().copied());
166
167 self.pad(elements.iter().map(|e| e.serialized_size()).sum());
168
169 #[cfg(feature = "transcript-trace")]
170 self.trace.push(TranscriptOp::AppendFieldList {
171 label,
172 count: elements.len() as u64,
173 digest: {
174 let mut digest_h = H::new();
175 digest_h.update_fields(elements.iter().copied());
176
177 digest_h.finalize()
178 },
179 });
180 }
181
182 pub fn challenge_field<F: TowerField>(&mut self, label: &'static [u8]) -> Result<F> {
187 let digest = self.squeeze(label);
188
189 #[cfg(feature = "transcript-trace")]
190 self.trace.push(TranscriptOp::ChallengeField { label });
191
192 Self::bytes_to_field(&digest)
193 }
194
195 pub fn challenge_indices(
199 &mut self,
200 label: &'static [u8],
201 count: usize,
202 bound: usize,
203 ) -> Result<Vec<usize>> {
204 let Some(bound) = NonZeroU64::new(bound as u64) else {
205 return Err(Error::EmptyIndexRange);
206 };
207
208 let mut indices = Vec::with_capacity(count);
209 while indices.len() < count {
210 let digest = self.squeeze(label);
211 let missing = count - indices.len();
212
213 indices.extend(
214 digest
215 .as_chunks::<8>()
216 .0
217 .iter()
218 .take(missing)
219 .map(|word| (u64::from_le_bytes(*word) % bound) as usize),
220 );
221 }
222
223 #[cfg(feature = "transcript-trace")]
224 self.trace.push(TranscriptOp::ChallengeIndices {
225 label,
226 count: count as u64,
227 });
228
229 Ok(indices)
230 }
231
232 fn bytes_to_field<F: TowerField>(bytes: &[u8]) -> Result<F> {
233 let size = size_of::<F>();
234 let max_entropy_bytes = 32;
235
236 if size > max_entropy_bytes {
237 return Err(Error::FieldTooLargeForChallenge {
238 field_bytes: size,
239 max_entropy_bytes,
240 });
241 }
242
243 let mut elem = F::default();
248 unsafe {
249 let elem_ptr = &mut elem as *mut F as *mut u8;
250 core::ptr::copy_nonoverlapping(bytes.as_ptr(), elem_ptr, size);
251 }
252
253 Ok(elem)
254 }
255
256 fn squeeze(&mut self, label: &'static [u8]) -> [u8; 32] {
257 self.absorb_label(label);
258
259 let digest = self.hasher.clone().finalize();
260
261 self.hasher.update(&digest);
262
263 digest
264 }
265
266 fn absorb_label(&mut self, label: &'static [u8]) {
267 self.hasher.update(label);
268 self.pad(label.len());
269 }
270
271 fn pad(&mut self, len: usize) {
272 self.hasher.update(&[0u8; 8][..(8 - len % 8) % 8]);
273 }
274}
275
276#[cfg(feature = "transcript-trace")]
277fn payload_digest<H: Hasher>(bytes: &[u8]) -> [u8; 32] {
278 let mut h = H::new();
279 h.update(bytes);
280
281 h.finalize()
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use hekate_math::{Block128, CanonicalSerialize};
288
289 #[derive(Clone, Debug)]
290 struct Recorder {
291 bytes: Vec<u8>,
292 }
293
294 impl Hasher for Recorder {
295 const OUTPUT_SIZE: usize = 32;
296
297 fn new() -> Self {
298 Self { bytes: Vec::new() }
299 }
300
301 fn update(&mut self, data: &[u8]) {
302 self.bytes.extend_from_slice(data);
303 }
304
305 fn finalize(self) -> [u8; 32] {
306 DefaultHasher::digest(&self.bytes)
307 }
308
309 fn finalize_reset(&mut self) -> [u8; 32] {
310 DefaultHasher::digest(&core::mem::take(&mut self.bytes))
311 }
312 }
313
314 #[test]
315 fn stream_layout_matches_hand_built_bytes() {
316 let mut transcript = Transcript::<Recorder>::new(b"t");
317
318 transcript.append_message(b"root", &[0xAA; 32]);
319 transcript.append_message(b"odd", &[1, 2, 3]);
320 transcript.append_u64(b"num_rows", 7);
321 transcript.append_field(b"claim", Block128(5));
322 transcript.append_field_list(b"q", &[Block128(1), Block128(2)]);
323
324 let _: Block128 = transcript.challenge_field(b"alpha").unwrap();
325 let indices = transcript.challenge_indices(b"idx", 5, 1000).unwrap();
326
327 let mut expected = Vec::new();
328 expected.extend_from_slice(b"hekate-transcript-v2\0\0\0\0");
329 expected.extend_from_slice(&1u64.to_le_bytes());
330 expected.extend_from_slice(b"t\0\0\0\0\0\0\0");
331 expected.extend_from_slice(b"root\0\0\0\0");
332 expected.extend_from_slice(&32u64.to_le_bytes());
333 expected.extend_from_slice(&[0xAA; 32]);
334 expected.extend_from_slice(b"odd\0\0\0\0\0");
335 expected.extend_from_slice(&3u64.to_le_bytes());
336 expected.extend_from_slice(&[1, 2, 3, 0, 0, 0, 0, 0]);
337 expected.extend_from_slice(b"num_rows");
338 expected.extend_from_slice(&7u64.to_le_bytes());
339 expected.extend_from_slice(b"claim\0\0\0");
340 expected.extend_from_slice(&Block128(5).to_bytes());
341 expected.extend_from_slice(b"q\0\0\0\0\0\0\0");
342 expected.extend_from_slice(&2u64.to_le_bytes());
343 expected.extend_from_slice(&Block128(1).to_bytes());
344 expected.extend_from_slice(&Block128(2).to_bytes());
345 expected.extend_from_slice(b"alpha\0\0\0");
346
347 let digest = DefaultHasher::digest(&expected);
348 expected.extend_from_slice(&digest);
349
350 let mut words = Vec::new();
351 for _ in 0..2 {
352 expected.extend_from_slice(b"idx\0\0\0\0\0");
353
354 let digest = DefaultHasher::digest(&expected);
355 expected.extend_from_slice(&digest);
356
357 words.extend(
358 digest
359 .as_chunks::<8>()
360 .0
361 .iter()
362 .map(|w| u64::from_le_bytes(*w)),
363 );
364 }
365
366 assert_eq!(transcript.hasher.bytes, expected);
367 assert_eq!(
368 indices,
369 words[..5]
370 .iter()
371 .map(|w| (w % 1000) as usize)
372 .collect::<Vec<_>>()
373 );
374 }
375
376 #[test]
377 fn challenge_indices_rejects_empty_range() {
378 let mut transcript = Transcript::<DefaultHasher>::new(b"t");
379
380 assert_eq!(
381 transcript.challenge_indices(b"idx", 3, 0),
382 Err(Error::EmptyIndexRange)
383 );
384 }
385
386 #[test]
387 fn domain_labels_differing_in_trailing_zeros_diverge() {
388 let short = Transcript::<Recorder>::new(b"app");
389 let long = Transcript::<Recorder>::new(b"app\0");
390
391 assert_ne!(short.hasher.bytes, long.hasher.bytes);
392 }
393}