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_field`](Self::append_field) on each element
150    /// in turn, the labelled bytes batched through one buffer.
151    pub fn append_field_each<F: TowerField>(&mut self, label: &'static [u8], elements: &[F]) {
152        self.hasher.update_fields(label, elements.iter().copied());
153
154        #[cfg(feature = "transcript-trace")]
155        for element in elements {
156            self.trace.push(TranscriptOp::AppendField {
157                label,
158                digest: payload_digest::<H>(&element.to_bytes()),
159            });
160        }
161    }
162
163    /// Append a list of field elements
164    /// (e.g. a polynomial's round coefficients).
165    /// Length-prefixed and serialized as
166    /// `TowerField::to_bytes()` for canonical,
167    /// padding-free, endian-agnostic hashing.
168    pub fn append_field_list<F: TowerField>(&mut self, label: &'static [u8], elements: &[F]) {
169        self.hasher.update(label);
170
171        self.hasher.update(&(elements.len() as u64).to_le_bytes());
172        self.hasher.update_fields(&[], elements.iter().copied());
173
174        #[cfg(feature = "transcript-trace")]
175        self.trace.push(TranscriptOp::AppendFieldList {
176            label,
177            count: elements.len() as u64,
178            digest: {
179                let mut digest_h = H::new();
180                digest_h.update_fields(&[], elements.iter().copied());
181
182                digest_h.finalize()
183            },
184        });
185    }
186
187    /// Draw a field challenge via the
188    /// wide-pipe Fiat-Shamir pattern:
189    /// finalize a clone of the running hasher
190    /// (preserving full internal entropy),
191    /// then re-absorb the digest so the
192    /// next challenge depends on this one.
193    pub fn challenge_field<F: TowerField>(&mut self, label: &'static [u8]) -> Result<F> {
194        self.hasher.update(label);
195
196        let challenge_hasher = self.hasher.clone();
197        let result = challenge_hasher.finalize();
198
199        self.hasher.update(&result);
200
201        #[cfg(feature = "transcript-trace")]
202        self.trace.push(TranscriptOp::ChallengeField { label });
203
204        Self::bytes_to_field(&result)
205    }
206
207    fn bytes_to_field<F: TowerField>(bytes: &[u8]) -> Result<F> {
208        let size = size_of::<F>();
209        let max_entropy_bytes = 32;
210
211        if size > max_entropy_bytes {
212            return Err(Error::FieldTooLargeForChallenge {
213                field_bytes: size,
214                max_entropy_bytes,
215            });
216        }
217
218        // SAFETY:
219        // every bit pattern is a valid
220        // binary-field element, so raw-copy
221        // from the digest is sound.
222        let mut elem = F::default();
223        unsafe {
224            let elem_ptr = &mut elem as *mut F as *mut u8;
225            core::ptr::copy_nonoverlapping(bytes.as_ptr(), elem_ptr, size);
226        }
227
228        Ok(elem)
229    }
230}
231
232#[cfg(feature = "transcript-trace")]
233fn payload_digest<H: Hasher>(bytes: &[u8]) -> [u8; 32] {
234    let mut h = H::new();
235    h.update(bytes);
236
237    h.finalize()
238}