Skip to main content

hekate_crypto/
transcript.rs

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