Skip to main content

wots_rhdl/
framing.rs

1//! Canonical 512-bit framing for a fused signature and public key.
2
3use core::{fmt, marker::PhantomData};
4use hashsigs_types::{
5    CHAIN_COUNT, HASH_BYTES, PUBLIC_KEY_BYTES, Profile, PublicKey, SIGNATURE_BYTES, Signature,
6};
7
8/// Number of bytes carried by one 512-bit output beat.
9pub const OUTPUT_BEAT_BYTES: usize = 64;
10
11/// Number of output beats in one fused 2,208-byte result.
12pub const FUSED_OUTPUT_BEATS: usize = 35;
13
14const FULL_KEEP: u64 = u64::MAX;
15const FINAL_KEEP: u64 = (1_u64 << 32) - 1;
16
17const _: () = {
18    assert!(SIGNATURE_BYTES == 2_144);
19    assert!(PUBLIC_KEY_BYTES == 64);
20    assert!(SIGNATURE_BYTES + PUBLIC_KEY_BYTES == 2_208);
21    assert!((SIGNATURE_BYTES + PUBLIC_KEY_BYTES).div_ceil(OUTPUT_BEAT_BYTES) == FUSED_OUTPUT_BEATS);
22};
23
24/// Host-side representation of one 512-bit output transfer.
25///
26/// Byte `data[n]` is valid exactly when bit `n` of `keep` is one. `last` is
27/// asserted only on beat 34 in a canonical frame. The public fields can also
28/// represent malformed, untrusted input; [`unpack_fused_output`] is the
29/// validation boundary. The hardware boundary maps the fields to `TDATA`,
30/// `TKEEP`, and `TLAST`.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct FusedBeat {
33    /// Sixty-four byte lanes in ascending stream order.
34    pub data: [u8; OUTPUT_BEAT_BYTES],
35    /// One validity bit per byte lane.
36    pub keep: u64,
37    /// End-of-result marker.
38    pub last: bool,
39}
40
41impl Default for FusedBeat {
42    fn default() -> Self {
43        Self {
44            data: [0; OUTPUT_BEAT_BYTES],
45            keep: 0,
46            last: false,
47        }
48    }
49}
50
51/// Raw output-frame storage carrying a caller-selected profile type `P`.
52///
53/// The stored beats are not guaranteed to be canonical or to have been
54/// produced by profile `P`. The wire encoding has no in-band profile tag, and
55/// the type parameter only records the profile selected by the caller. A
56/// hardware or network boundary must authenticate that selection separately.
57/// Treat the frame as untrusted until [`unpack_fused_output`] succeeds; only
58/// that successful result establishes canonical framing and yields protocol
59/// values carrying the caller-selected `P`. It does not authenticate the
60/// payload's origin or profile.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct FusedFrame<P: Profile> {
63    beats: [FusedBeat; FUSED_OUTPUT_BEATS],
64    profile: PhantomData<fn() -> P>,
65}
66
67impl<P: Profile> FusedFrame<P> {
68    /// Stores externally received beats under an explicitly selected profile.
69    ///
70    /// This constructor neither validates canonical framing nor proves that
71    /// the payload was generated with `P`. [`unpack_fused_output`] performs
72    /// strict framing validation, but does not authenticate `P`. The caller is
73    /// responsible for obtaining `P` from authenticated configuration rather
74    /// than guessing from byte length.
75    #[must_use]
76    pub const fn from_beats(beats: [FusedBeat; FUSED_OUTPUT_BEATS]) -> Self {
77        Self {
78            beats,
79            profile: PhantomData,
80        }
81    }
82
83    /// Borrows the 35 stored wire beats, which may still be noncanonical.
84    #[must_use]
85    pub const fn beats(&self) -> &[FusedBeat; FUSED_OUTPUT_BEATS] {
86        &self.beats
87    }
88
89    /// Releases the unvalidated, caller-profile-marked frame as raw wire beats.
90    #[must_use]
91    pub const fn into_beats(self) -> [FusedBeat; FUSED_OUTPUT_BEATS] {
92        self.beats
93    }
94}
95
96/// Error returned when an input beat sequence violates canonical framing.
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum OutputFrameError {
99    /// A non-final beat did not mark all 64 byte lanes valid.
100    NonFinalKeep {
101        /// Zero-based beat index.
102        beat: usize,
103        /// Observed keep mask.
104        actual: u64,
105    },
106    /// The final beat did not mark exactly its low 32 lanes valid.
107    FinalKeep {
108        /// Observed keep mask.
109        actual: u64,
110    },
111    /// `last` was asserted on the wrong beat or missing from the final beat.
112    Last {
113        /// Zero-based beat index where the mismatch was observed.
114        beat: usize,
115        /// Observed marker.
116        actual: bool,
117    },
118    /// An invalid byte lane contained nonzero data.
119    NonzeroPadding {
120        /// Byte lane in the final beat.
121        lane: usize,
122        /// Observed byte value.
123        actual: u8,
124    },
125}
126
127impl fmt::Display for OutputFrameError {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match *self {
130            Self::NonFinalKeep { beat, actual } => {
131                write!(
132                    formatter,
133                    "beat {beat} has noncanonical keep mask {actual:#018x}"
134                )
135            }
136            Self::FinalKeep { actual } => {
137                write!(
138                    formatter,
139                    "final beat has noncanonical keep mask {actual:#018x}"
140                )
141            }
142            Self::Last { beat, actual } => {
143                write!(formatter, "beat {beat} has noncanonical last={actual}")
144            }
145            Self::NonzeroPadding { lane, actual } => {
146                write!(
147                    formatter,
148                    "invalid final byte lane {lane} contains {actual:#04x}"
149                )
150            }
151        }
152    }
153}
154
155impl std::error::Error for OutputFrameError {}
156
157/// Packs `signature || public_key` into the canonical 35-beat output stream.
158///
159/// Beats 0 through 32 contain two 32-byte signature segments each. Beat 33
160/// contains signature segment 66 followed by the public seed (the first half of
161/// the public key). Beat 34 contains the public-key hash in its low 32 byte
162/// lanes; its high lanes are zero and invalid.
163///
164/// The shared profile parameter prevents combining a signature from one profile
165/// with a public key from the other:
166///
167/// ```compile_fail
168/// use hashsigs_types::{
169///     CHAIN_COUNT, HashSigsSha256GenericV1, LegacyKeccak, PublicKey, Signature,
170/// };
171/// use wots_rhdl::pack_fused_output;
172///
173/// let signature = Signature::<LegacyKeccak>::new([[0; 32]; CHAIN_COUNT]);
174/// let public_key = PublicKey::<HashSigsSha256GenericV1>::new([0; 32], [0; 32]);
175/// let _ = pack_fused_output(&signature, &public_key);
176/// ```
177#[must_use]
178pub fn pack_fused_output<P: Profile>(
179    signature: &Signature<P>,
180    public_key: &PublicKey<P>,
181) -> FusedFrame<P> {
182    let signature = signature.to_bytes();
183    let public_key = public_key.to_bytes();
184    let mut beats = [FusedBeat::default(); FUSED_OUTPUT_BEATS];
185    for (beat_index, beat) in beats.iter_mut().enumerate() {
186        let stream_offset = beat_index * OUTPUT_BEAT_BYTES;
187        for (lane, output) in beat.data.iter_mut().enumerate() {
188            let source = stream_offset + lane;
189            *output = if source < SIGNATURE_BYTES {
190                signature[source]
191            } else if source < SIGNATURE_BYTES + PUBLIC_KEY_BYTES {
192                public_key[source - SIGNATURE_BYTES]
193            } else {
194                0
195            };
196        }
197        beat.keep = if beat_index + 1 == FUSED_OUTPUT_BEATS {
198            FINAL_KEEP
199        } else {
200            FULL_KEEP
201        };
202        beat.last = beat_index + 1 == FUSED_OUTPUT_BEATS;
203    }
204    FusedFrame::from_beats(beats)
205}
206
207/// Validates and unpacks a canonical fused-output stream.
208///
209/// Strict validation catches misplaced `last`, malformed keep masks, and
210/// nonzero data in invalid final lanes. This makes byte-order mistakes visible
211/// in simulation instead of accepting an equivalent-length but ambiguous frame.
212///
213/// # Errors
214///
215/// Returns [`OutputFrameError`] when any keep mask, `last` marker, or invalid
216/// padding lane differs from the canonical 35-beat representation.
217pub fn unpack_fused_output<P: Profile>(
218    frame: &FusedFrame<P>,
219) -> Result<(Signature<P>, PublicKey<P>), OutputFrameError> {
220    let beats = frame.beats();
221    let mut signature = [0_u8; SIGNATURE_BYTES];
222    let mut public_key = [0_u8; PUBLIC_KEY_BYTES];
223
224    for (beat_index, beat) in beats.iter().enumerate() {
225        let final_beat = beat_index + 1 == FUSED_OUTPUT_BEATS;
226        if !final_beat && beat.keep != FULL_KEEP {
227            return Err(OutputFrameError::NonFinalKeep {
228                beat: beat_index,
229                actual: beat.keep,
230            });
231        }
232        if final_beat && beat.keep != FINAL_KEEP {
233            return Err(OutputFrameError::FinalKeep { actual: beat.keep });
234        }
235        if beat.last != final_beat {
236            return Err(OutputFrameError::Last {
237                beat: beat_index,
238                actual: beat.last,
239            });
240        }
241
242        let stream_offset = beat_index * OUTPUT_BEAT_BYTES;
243        for (lane, byte) in beat.data.iter().copied().enumerate() {
244            let destination = stream_offset + lane;
245            if destination < SIGNATURE_BYTES {
246                signature[destination] = byte;
247            } else if destination < SIGNATURE_BYTES + PUBLIC_KEY_BYTES {
248                public_key[destination - SIGNATURE_BYTES] = byte;
249            } else if byte != 0 {
250                return Err(OutputFrameError::NonzeroPadding { lane, actual: byte });
251            }
252        }
253    }
254    let mut segments = [[0_u8; HASH_BYTES]; CHAIN_COUNT];
255    for (segment, bytes) in segments.iter_mut().zip(signature.chunks_exact(HASH_BYTES)) {
256        segment.copy_from_slice(bytes);
257    }
258    let mut public_seed = [0_u8; HASH_BYTES];
259    let mut public_key_hash = [0_u8; HASH_BYTES];
260    public_seed.copy_from_slice(&public_key[..HASH_BYTES]);
261    public_key_hash.copy_from_slice(&public_key[HASH_BYTES..]);
262    Ok((
263        Signature::new(segments),
264        PublicKey::new(public_seed, public_key_hash),
265    ))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use hashsigs_types::LegacyKeccak;
272
273    fn signature_fixture() -> [u8; SIGNATURE_BYTES] {
274        core::array::from_fn(|index| {
275            let folded = (index.wrapping_mul(149) ^ (index >> 3)) & 0xff;
276            u8::try_from(folded).expect("fixture byte is masked to eight bits")
277        })
278    }
279
280    fn public_key_fixture() -> [u8; PUBLIC_KEY_BYTES] {
281        core::array::from_fn(|index| {
282            0xa5_u8.wrapping_add(u8::try_from(index).expect("public-key fixture has 64 bytes"))
283        })
284    }
285
286    fn typed_fixture() -> (Signature<LegacyKeccak>, PublicKey<LegacyKeccak>) {
287        let signature =
288            Signature::from_slice(&signature_fixture()).expect("fixture signature has exact size");
289        let public_key = PublicKey::from_slice(&public_key_fixture())
290            .expect("fixture public key has exact size");
291        (signature, public_key)
292    }
293
294    #[test]
295    fn canonical_layout_matches_segment_boundaries() {
296        let signature_bytes = signature_fixture();
297        let public_key_bytes = public_key_fixture();
298        let (signature, public_key) = typed_fixture();
299        let frame = pack_fused_output(&signature, &public_key);
300        let beats = frame.beats();
301
302        for beat in &beats[..34] {
303            assert_eq!(beat.keep, u64::MAX);
304            assert!(!beat.last);
305        }
306        assert_eq!(&beats[0].data, &signature_bytes[..64]);
307        assert_eq!(&beats[32].data, &signature_bytes[2_048..2_112]);
308        assert_eq!(&beats[33].data[..32], &signature_bytes[2_112..]);
309        assert_eq!(&beats[33].data[32..], &public_key_bytes[..32]);
310        assert_eq!(&beats[34].data[..32], &public_key_bytes[32..]);
311        assert_eq!(&beats[34].data[32..], &[0_u8; 32]);
312        assert_eq!(beats[34].keep, 0xffff_ffff);
313        assert!(beats[34].last);
314    }
315
316    #[test]
317    fn canonical_frame_round_trips() {
318        let (signature, public_key) = typed_fixture();
319        let unpacked = unpack_fused_output(&pack_fused_output(&signature, &public_key))
320            .expect("canonical output must unpack");
321        assert_eq!(unpacked, (signature, public_key));
322    }
323
324    #[test]
325    fn malformed_control_and_padding_are_rejected() {
326        let (signature, public_key) = typed_fixture();
327        let mut beats = pack_fused_output(&signature, &public_key).into_beats();
328        beats[12].keep ^= 1;
329        assert!(matches!(
330            unpack_fused_output(&FusedFrame::<LegacyKeccak>::from_beats(beats)),
331            Err(OutputFrameError::NonFinalKeep { beat: 12, .. })
332        ));
333
334        let mut beats = pack_fused_output(&signature, &public_key).into_beats();
335        beats[34].last = false;
336        assert!(matches!(
337            unpack_fused_output(&FusedFrame::<LegacyKeccak>::from_beats(beats)),
338            Err(OutputFrameError::Last { beat: 34, .. })
339        ));
340
341        let mut beats = pack_fused_output(&signature, &public_key).into_beats();
342        beats[34].data[63] = 1;
343        assert!(matches!(
344            unpack_fused_output(&FusedFrame::<LegacyKeccak>::from_beats(beats)),
345            Err(OutputFrameError::NonzeroPadding { lane: 63, .. })
346        ));
347    }
348}