Skip to main content

hekate_crypto/
transcript.rs

1// SPDX-FileCopyrightText: 2026 Andrei Kochergin <andrei@oumuamua.dev>
2// SPDX-FileCopyrightText: 2026 Oumuamua Labs <info@oumuamua.dev>
3// SPDX-License-Identifier: AGPL-3.0-only
4
5use crate::{DefaultHasher, Hasher};
6#[cfg(feature = "transcript-trace")]
7use alloc::vec::Vec;
8use core::fmt;
9use core::marker::PhantomData;
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    /// Field is wider than a single hash
17    /// output, so a raw squeeze cannot
18    /// deliver enough entropy for a challenge.
19    FieldTooLargeForChallenge {
20        field_bytes: usize,
21        max_entropy_bytes: usize,
22    },
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        }
36    }
37}
38
39/// Op-by-op trace of transcript activity,
40/// enabled under `transcript-trace`.
41/// Prover/verifier traces must match
42/// step-for-step or Fiat-Shamir diverges.
43#[cfg(feature = "transcript-trace")]
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum TranscriptOp {
46    AppendMessage {
47        label: &'static [u8],
48        digest: [u8; 32],
49    },
50    AppendU64 {
51        label: &'static [u8],
52        value: u64,
53    },
54    AppendField {
55        label: &'static [u8],
56        digest: [u8; 32],
57    },
58    AppendFieldList {
59        label: &'static [u8],
60        count: u64,
61        digest: [u8; 32],
62    },
63    ChallengeField {
64        label: &'static [u8],
65    },
66}
67
68/// Fiat-Shamir transcript as
69/// a continuous hash chain:
70/// inputs update the running state;
71/// challenges are produced by finalizing
72/// a clone and re-absorbing the digest
73/// so each squeeze depends on every prior op.
74///
75/// Generic over `H` so SHA3 / Blake3 / Poseidon
76/// backends are interchangeable.
77#[derive(Clone, Debug)]
78pub struct Transcript<H: Hasher = DefaultHasher> {
79    hasher: H,
80    #[cfg(feature = "transcript-trace")]
81    trace: Vec<TranscriptOp>,
82    _marker: PhantomData<H>,
83}
84
85impl<H: Hasher> Transcript<H> {
86    /// Create a new transcript with a domain separator.
87    pub fn new(label: &'static [u8]) -> Self {
88        let mut hasher = H::new();
89        hasher.update(b"hekate-transcript-v1");
90        hasher.update(label);
91
92        Self {
93            hasher,
94            #[cfg(feature = "transcript-trace")]
95            trace: Vec::new(),
96            _marker: PhantomData,
97        }
98    }
99
100    #[cfg(feature = "transcript-trace")]
101    pub fn take_trace(&mut self) -> Vec<TranscriptOp> {
102        core::mem::take(&mut self.trace)
103    }
104
105    #[cfg(feature = "transcript-trace")]
106    pub fn trace(&self) -> &[TranscriptOp] {
107        &self.trace
108    }
109
110    /// Append labelled bytes. The length prefix
111    /// is required to block length-extension
112    /// collisions between messages.
113    pub fn append_message(&mut self, label: &'static [u8], message: &[u8]) {
114        self.hasher.update(label);
115
116        self.hasher.update(&(message.len() as u64).to_le_bytes());
117        self.hasher.update(message);
118
119        #[cfg(feature = "transcript-trace")]
120        self.trace.push(TranscriptOp::AppendMessage {
121            label,
122            digest: payload_digest::<H>(message),
123        });
124    }
125
126    /// Append a `u64` for protocol context
127    /// (`num_rows`, `num_cols`, bus heights, …).
128    pub fn append_u64(&mut self, label: &'static [u8], value: u64) {
129        self.hasher.update(label);
130        self.hasher.update(&value.to_le_bytes());
131
132        #[cfg(feature = "transcript-trace")]
133        self.trace.push(TranscriptOp::AppendU64 { label, value });
134    }
135
136    pub fn append_field<F: TowerField>(&mut self, label: &'static [u8], element: F) {
137        self.hasher.update(label);
138
139        let bytes = element.to_bytes();
140        self.hasher.update(&bytes);
141
142        #[cfg(feature = "transcript-trace")]
143        self.trace.push(TranscriptOp::AppendField {
144            label,
145            digest: payload_digest::<H>(&bytes),
146        });
147    }
148
149    /// Append a list of field elements
150    /// (e.g. a polynomial's round coefficients).
151    /// Length-prefixed and serialized via
152    /// `TowerField::to_bytes()` for canonical,
153    /// padding-free, endian-agnostic hashing.
154    pub fn append_field_list<F: TowerField>(&mut self, label: &'static [u8], elements: &[F]) {
155        self.hasher.update(label);
156
157        self.hasher.update(&(elements.len() as u64).to_le_bytes());
158
159        #[cfg(feature = "transcript-trace")]
160        let mut digest_h = H::new();
161        for element in elements {
162            let bytes = element.to_bytes();
163            self.hasher.update(&bytes);
164
165            #[cfg(feature = "transcript-trace")]
166            digest_h.update(&bytes);
167        }
168
169        #[cfg(feature = "transcript-trace")]
170        self.trace.push(TranscriptOp::AppendFieldList {
171            label,
172            count: elements.len() as u64,
173            digest: digest_h.finalize(),
174        });
175    }
176
177    /// Draw a field challenge via the
178    /// wide-pipe Fiat-Shamir pattern:
179    /// finalize a clone of the running hasher
180    /// (preserving full internal entropy),
181    /// then re-absorb the digest so the
182    /// next challenge depends on this one.
183    pub fn challenge_field<F: TowerField>(&mut self, label: &'static [u8]) -> Result<F> {
184        self.hasher.update(label);
185
186        let challenge_hasher = self.hasher.clone();
187        let result = challenge_hasher.finalize();
188
189        self.hasher.update(&result);
190
191        #[cfg(feature = "transcript-trace")]
192        self.trace.push(TranscriptOp::ChallengeField { label });
193
194        Self::bytes_to_field(&result)
195    }
196
197    fn bytes_to_field<F: TowerField>(bytes: &[u8]) -> Result<F> {
198        let size = size_of::<F>();
199        let max_entropy_bytes = 32;
200
201        if size > max_entropy_bytes {
202            return Err(Error::FieldTooLargeForChallenge {
203                field_bytes: size,
204                max_entropy_bytes,
205            });
206        }
207
208        // SAFETY:
209        // every bit pattern is a valid
210        // binary-field element, so raw-copy
211        // from the digest is sound.
212        let mut elem = F::default();
213        unsafe {
214            let elem_ptr = &mut elem as *mut F as *mut u8;
215            core::ptr::copy_nonoverlapping(bytes.as_ptr(), elem_ptr, size);
216        }
217
218        Ok(elem)
219    }
220}
221
222#[cfg(feature = "transcript-trace")]
223fn payload_digest<H: Hasher>(bytes: &[u8]) -> [u8; 32] {
224    let mut h = H::new();
225    h.update(bytes);
226
227    h.finalize()
228}