Skip to main content

ftts_artifacts/
sha256.rs

1//! SHA-256 (FIPS 180-4), for `.fttsq` section digests.
2//!
3//! # Why this is here rather than a dependency
4//!
5//! `sha2` is already resolved in `Cargo.lock` as a transitive dependency, and it is the better
6//! long-run answer. Adding a direct edge to it changes the dependency graph, which changes
7//! `Cargo.lock` — and `cargo check --locked` is a hard gate that every concurrent agent in this
8//! workspace runs. Landing a Cargo.toml edit without an in-lockstep lockfile regeneration breaks
9//! the gate for everyone, and regenerating the lock requires winning a contended build lock.
10//!
11//! So this is a deliberate, bounded trade, not a preference for hand-rolled crypto.
12//!
13//! **DELETION CONDITION:** replace this module with `sha2::Sha256` the next time the workspace
14//! lockfile is being updated for another reason. Tracked by `frankentts-p2-fttsq-format-wsa`.
15//!
16//! # Scope
17//!
18//! Digests here are **integrity** checks — they detect truncation, bit-flips, and mismatched
19//! sections. They are not a signature scheme and prove nothing about *who* produced an artifact.
20//! Verified against the FIPS 180-4 vectors in the tests below.
21
22/// Round constants: the first 32 bits of the fractional parts of the cube roots of the first 64
23/// primes (FIPS 180-4 §4.2.2).
24const K: [u32; 64] = [
25    0x428a_2f98,
26    0x7137_4491,
27    0xb5c0_fbcf,
28    0xe9b5_dba5,
29    0x3956_c25b,
30    0x59f1_11f1,
31    0x923f_82a4,
32    0xab1c_5ed5,
33    0xd807_aa98,
34    0x1283_5b01,
35    0x2431_85be,
36    0x550c_7dc3,
37    0x72be_5d74,
38    0x80de_b1fe,
39    0x9bdc_06a7,
40    0xc19b_f174,
41    0xe49b_69c1,
42    0xefbe_4786,
43    0x0fc1_9dc6,
44    0x240c_a1cc,
45    0x2de9_2c6f,
46    0x4a74_84aa,
47    0x5cb0_a9dc,
48    0x76f9_88da,
49    0x983e_5152,
50    0xa831_c66d,
51    0xb003_27c8,
52    0xbf59_7fc7,
53    0xc6e0_0bf3,
54    0xd5a7_9147,
55    0x06ca_6351,
56    0x1429_2967,
57    0x27b7_0a85,
58    0x2e1b_2138,
59    0x4d2c_6dfc,
60    0x5338_0d13,
61    0x650a_7354,
62    0x766a_0abb,
63    0x81c2_c92e,
64    0x9272_2c85,
65    0xa2bf_e8a1,
66    0xa81a_664b,
67    0xc24b_8b70,
68    0xc76c_51a3,
69    0xd192_e819,
70    0xd699_0624,
71    0xf40e_3585,
72    0x106a_a070,
73    0x19a4_c116,
74    0x1e37_6c08,
75    0x2748_774c,
76    0x34b0_bcb5,
77    0x391c_0cb3,
78    0x4ed8_aa4a,
79    0x5b9c_ca4f,
80    0x682e_6ff3,
81    0x748f_82ee,
82    0x78a5_636f,
83    0x84c8_7814,
84    0x8cc7_0208,
85    0x90be_fffa,
86    0xa450_6ceb,
87    0xbef9_a3f7,
88    0xc671_78f2,
89];
90
91/// Initial hash value: the first 32 bits of the fractional parts of the square roots of the first
92/// eight primes (FIPS 180-4 §5.3.3).
93const H0: [u32; 8] = [
94    0x6a09_e667,
95    0xbb67_ae85,
96    0x3c6e_f372,
97    0xa54f_f53a,
98    0x510e_527f,
99    0x9b05_688c,
100    0x1f83_d9ab,
101    0x5be0_cd19,
102];
103
104/// Streaming SHA-256 state.
105///
106/// Streaming rather than one-shot because a `.fttsq` section is hundreds of megabytes: the digest
107/// must be computable over a borrowed slice in blocks, without a second copy of the payload.
108#[derive(Clone, Debug)]
109pub struct Sha256 {
110    state: [u32; 8],
111    /// Partial block awaiting a full 64 bytes.
112    buffer: [u8; 64],
113    /// Bytes currently held in `buffer`.
114    buffered: usize,
115    /// Total message length in bytes, for the length suffix.
116    length: u64,
117}
118
119impl Default for Sha256 {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125impl Sha256 {
126    /// Starts a new digest.
127    #[must_use]
128    pub const fn new() -> Self {
129        Self {
130            state: H0,
131            buffer: [0; 64],
132            buffered: 0,
133            length: 0,
134        }
135    }
136
137    /// Absorbs more message bytes.
138    pub fn update(&mut self, mut input: &[u8]) {
139        self.length = self.length.wrapping_add(input.len() as u64);
140
141        if self.buffered > 0 {
142            let want = 64 - self.buffered;
143            let take = want.min(input.len());
144            self.buffer[self.buffered..self.buffered + take].copy_from_slice(&input[..take]);
145            self.buffered += take;
146            input = &input[take..];
147            if self.buffered == 64 {
148                let block = self.buffer;
149                self.compress(&block);
150                self.buffered = 0;
151            } else {
152                return;
153            }
154        }
155
156        let (blocks, tail) = input.as_chunks::<64>();
157        for block in blocks {
158            self.compress(block);
159        }
160
161        self.buffer[..tail.len()].copy_from_slice(tail);
162        self.buffered = tail.len();
163    }
164
165    /// Finishes the digest and returns the 32 raw bytes.
166    #[must_use]
167    pub fn finish(mut self) -> [u8; 32] {
168        // Padding: 0x80, then zeros, then the bit length as a big-endian u64.
169        let bit_length = self.length.wrapping_mul(8);
170        self.update_no_count(&[0x80]);
171        while self.buffered != 56 {
172            self.update_no_count(&[0x00]);
173        }
174        self.update_no_count(&bit_length.to_be_bytes());
175
176        let mut out = [0_u8; 32];
177        for (chunk, word) in out.as_chunks_mut::<4>().0.iter_mut().zip(self.state) {
178            *chunk = word.to_be_bytes();
179        }
180        out
181    }
182
183    /// Absorbs padding bytes without advancing the message-length counter.
184    fn update_no_count(&mut self, input: &[u8]) {
185        for &byte in input {
186            self.buffer[self.buffered] = byte;
187            self.buffered += 1;
188            if self.buffered == 64 {
189                let block = self.buffer;
190                self.compress(&block);
191                self.buffered = 0;
192            }
193        }
194    }
195
196    /// One 64-byte block through the compression function (FIPS 180-4 §6.2.2).
197    fn compress(&mut self, block: &[u8; 64]) {
198        let mut w = [0_u32; 64];
199        for (slot, chunk) in w.iter_mut().zip(block.as_chunks::<4>().0) {
200            *slot = u32::from_be_bytes(*chunk);
201        }
202        for index in 16..64 {
203            let s0 = w[index - 15].rotate_right(7)
204                ^ w[index - 15].rotate_right(18)
205                ^ (w[index - 15] >> 3);
206            let s1 = w[index - 2].rotate_right(17)
207                ^ w[index - 2].rotate_right(19)
208                ^ (w[index - 2] >> 10);
209            w[index] = w[index - 16]
210                .wrapping_add(s0)
211                .wrapping_add(w[index - 7])
212                .wrapping_add(s1);
213        }
214
215        let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state;
216
217        for index in 0..64 {
218            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
219            let ch = (e & f) ^ ((!e) & g);
220            let temp1 = h
221                .wrapping_add(s1)
222                .wrapping_add(ch)
223                .wrapping_add(K[index])
224                .wrapping_add(w[index]);
225            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
226            let maj = (a & b) ^ (a & c) ^ (b & c);
227            let temp2 = s0.wrapping_add(maj);
228
229            h = g;
230            g = f;
231            f = e;
232            e = d.wrapping_add(temp1);
233            d = c;
234            c = b;
235            b = a;
236            a = temp1.wrapping_add(temp2);
237        }
238
239        for (slot, value) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
240            *slot = slot.wrapping_add(value);
241        }
242    }
243}
244
245/// Digests a file by streaming fixed-size reads, returning lowercase hex.
246///
247/// Exists for the same reason [`Sha256`] streams: the files this verifies (downloaded model
248/// checkpoints) are hundreds of megabytes to gigabytes, and reading one into memory just to hash
249/// it would double the peak footprint of a verification pass.
250///
251/// # Errors
252///
253/// Propagates the underlying [`std::io::Error`] from opening or reading the file.
254pub fn hex_digest_file(path: &std::path::Path) -> std::io::Result<String> {
255    use std::io::Read as _;
256
257    let mut file = std::fs::File::open(path)?;
258    let mut hasher = Sha256::new();
259    // 1 MiB: large enough that syscall overhead is negligible, small enough to stay cache-polite.
260    let mut buffer = vec![0_u8; 1 << 20];
261    loop {
262        match file.read(&mut buffer) {
263            Ok(0) => break,
264            Ok(read) => hasher.update(&buffer[..read]),
265            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
266            Err(error) => return Err(error),
267        }
268    }
269    Ok(to_hex(&hasher.finish()))
270}
271
272/// Digests a byte slice, returning lowercase hex.
273#[must_use]
274pub fn hex_digest(bytes: &[u8]) -> String {
275    let mut hasher = Sha256::new();
276    hasher.update(bytes);
277    to_hex(&hasher.finish())
278}
279
280/// Renders raw digest bytes as lowercase hex.
281#[must_use]
282pub fn to_hex(digest: &[u8; 32]) -> String {
283    let mut out = String::with_capacity(64);
284    for byte in digest {
285        // Hand-rolled rather than `format!("{byte:02x}")` per byte: this runs over every section
286        // digest and the formatting machinery dominates otherwise.
287        const HEX: &[u8; 16] = b"0123456789abcdef";
288        out.push(HEX[usize::from(byte >> 4)] as char);
289        out.push(HEX[usize::from(byte & 0x0f)] as char);
290    }
291    out
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// FIPS 180-4 / NIST CAVP known-answer vectors.
299    ///
300    /// A hash implementation that has not been checked against published vectors is an assumption,
301    /// and this one gates artifact integrity — a wrong digest either rejects every good artifact or
302    /// accepts every corrupted one.
303    #[test]
304    fn matches_the_published_nist_vectors() {
305        assert_eq!(
306            hex_digest(b""),
307            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
308        );
309        assert_eq!(
310            hex_digest(b"abc"),
311            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
312        );
313        assert_eq!(
314            hex_digest(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
315            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
316        );
317        assert_eq!(
318            hex_digest(
319                b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu"
320            ),
321            "cf5b16a778af8380036ce59e7b0492370b249b11e8f07a51afac45037afee9d1"
322        );
323        // One million 'a' — exercises the length counter past a single block group.
324        let mut hasher = Sha256::new();
325        for _ in 0..1000 {
326            hasher.update(&[b'a'; 1000]);
327        }
328        assert_eq!(
329            to_hex(&hasher.finish()),
330            "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
331        );
332    }
333
334    /// The streaming path must agree with the one-shot path at every chunk boundary.
335    ///
336    /// Section digests are fed in whatever chunks the caller has; a boundary bug would produce
337    /// digests that depend on read size, which is the worst possible failure — intermittent.
338    #[test]
339    fn streaming_in_any_chunking_matches_one_shot() {
340        let message: Vec<u8> = (0..1000_u32).map(|i| (i % 251) as u8).collect();
341        let expected = hex_digest(&message);
342        for chunk_size in [1_usize, 2, 7, 31, 63, 64, 65, 127, 128, 999, 1000] {
343            let mut hasher = Sha256::new();
344            for chunk in message.chunks(chunk_size) {
345                hasher.update(chunk);
346            }
347            assert_eq!(
348                to_hex(&hasher.finish()),
349                expected,
350                "digest changed with chunk size {chunk_size}"
351            );
352        }
353    }
354
355    /// The file path must agree with the in-memory path — it is the same algorithm behind a
356    /// different read loop, and a divergence would verify downloads against the wrong contract.
357    #[test]
358    fn file_digest_matches_the_in_memory_digest() {
359        let dir = std::env::temp_dir().join(format!("ftts-sha256-file-{}", std::process::id()));
360        std::fs::create_dir_all(&dir).expect("temp dir");
361        let path = dir.join("digest-input.bin");
362        // Larger than one read buffer would be unnecessary; larger than one hash block matters.
363        let message: Vec<u8> = (0..70_000_u32).map(|i| (i % 251) as u8).collect();
364        std::fs::write(&path, &message).expect("write temp file");
365        assert_eq!(
366            hex_digest_file(&path).expect("file digest"),
367            hex_digest(&message)
368        );
369    }
370
371    /// A single flipped bit must change the digest — the property section verification relies on.
372    #[test]
373    fn a_single_bit_flip_changes_the_digest() {
374        let mut message = vec![0_u8; 256];
375        let clean = hex_digest(&message);
376        message[128] ^= 0x01;
377        assert_ne!(hex_digest(&message), clean);
378    }
379}