diff --git a/Cargo.lock b/Cargo.lock index 86e8082..02664ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,12 @@ dependencies = [ "proto", ] +[[package]] +name = "emsha" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998c85fef9015859e22d1a9afa657dc134835a43e95a28fe1413e916ed8b7cb3" + [[package]] name = "equivalent" version = "1.0.2" @@ -90,6 +96,7 @@ dependencies = [ name = "proto" version = "0.1.0" dependencies = [ + "emsha", "humantime", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 47dfb17..73969a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" humantime = "2.4.0" toml = "1.1.6" +emsha = "1.0.4" diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 3776b51..7b1681f 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -12,6 +12,7 @@ workspace = true serde.workspace = true serde_json.workspace = true humantime.workspace = true +emsha.workspace = true [dev-dependencies] toml.workspace = true diff --git a/crates/proto/src/hash.rs b/crates/proto/src/hash.rs new file mode 100644 index 0000000..75b1ce7 --- /dev/null +++ b/crates/proto/src/hash.rs @@ -0,0 +1,43 @@ +use crate::Hash32; +use emsha::Hash as _; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashError; + +impl std::fmt::Display for HashError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "sha256 error") + } +} + +impl std::error::Error for HashError {} + +pub struct Sha256(emsha::sha256::SHA256); + +impl Sha256 { + pub fn new() -> Self { + Sha256(emsha::sha256::SHA256::new()) + } + + pub fn update(&mut self, data: &[u8]) -> Result<(), HashError> { + self.0.update(data).map_err(|_| HashError) + } + + pub fn finish(mut self) -> Result { + let mut out = [0u8; emsha::sha256::SIZE]; + self.0.finalize(&mut out).map_err(|_| HashError)?; + Ok(Hash32::from_bytes(out)) + } +} + +impl Default for Sha256 { + fn default() -> Self { + Self::new() + } +} + +pub fn sha256(data: &[u8]) -> Result { + let mut h = Sha256::new(); + h.update(data)?; + h.finish() +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index a2d7d70..28b7fc4 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -4,6 +4,7 @@ pub mod audit; pub mod class; pub mod frame; pub mod grant; +pub mod hash; pub mod ids; pub mod log; pub mod wire; @@ -12,6 +13,7 @@ pub use audit::{AuditRecord, DecisionRecord}; pub use class::DataClass; pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame}; pub use grant::{Constraints, Grant, Mode}; +pub use hash::{HashError, Sha256, sha256}; pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError}; pub use log::{LogRecord, ToolCall}; pub use wire::{ diff --git a/crates/proto/tests/hash.rs b/crates/proto/tests/hash.rs new file mode 100644 index 0000000..e1cfa58 --- /dev/null +++ b/crates/proto/tests/hash.rs @@ -0,0 +1,100 @@ +//! SHA-256 vectors. Do not edit: these define the required behaviour. +//! +//! The lengths 55, 56, 63, 64 and 65 sit on either side of the padding boundaries of SHA-256, +//! which is where implementations go wrong. Expected values come from `sha256sum`. + +use proto::{Sha256, sha256}; + +const VECTORS: &[(usize, &str)] = &[ + ( + 0, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ( + 1, + "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + ), + ( + 55, + "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318", + ), + ( + 56, + "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a", + ), + ( + 63, + "7d3e74a05d7db15bce4ad9ec0658ea98e3f06eeecf16b4c6fff2da457ddc2f34", + ), + ( + 64, + "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb", + ), + ( + 65, + "635361c48bb9eab14198e76ea8ab7f1a41685d6ad62aa9146d301d4f17eb0ae0", + ), + ( + 119, + "31eba51c313a5c08226adf18d4a359cfdfd8d2e816b13f4af952f7ea6584dcfb", + ), + ( + 127, + "c57e9278af78fa3cab38667bef4ce29d783787a2f731d4e12200270f0c32320a", + ), + ( + 128, + "6836cf13bac400e9105071cd6af47084dfacad4e5e302c94bfed24e013afb73e", + ), + ( + 1000, + "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3", + ), +]; + +/// `n` bytes of the letter `a`. +fn letters(n: usize) -> Vec { + vec![b'a'; n] +} + +#[test] +fn abc() { + let want = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert_eq!(sha256(b"abc").unwrap().to_hex(), want); +} + +#[test] +fn lengths_around_the_padding_boundaries() { + for (n, want) in VECTORS { + assert_eq!(sha256(&letters(*n)).unwrap().to_hex(), *want, "{n} bytes"); + } +} + +#[test] +fn input_in_two_pieces_gives_the_same_hash() { + for (n, want) in VECTORS { + let data = letters(*n); + for split in [0, 1, 55, 56, 63, 64, 65, *n / 2, *n] { + let split = split.min(*n); + let mut h = Sha256::new(); + h.update(&data[..split]).unwrap(); + h.update(&data[split..]).unwrap(); + assert_eq!( + h.finish().unwrap().to_hex(), + *want, + "{n} bytes split at {split}" + ); + } + } +} + +#[test] +fn one_million_letters() { + let want = "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"; + assert_eq!(sha256(&letters(1_000_000)).unwrap().to_hex(), want); + let mut h = Sha256::default(); + for _ in 0..1000 { + h.update(&letters(1000)).unwrap(); + } + assert_eq!(h.finish().unwrap().to_hex(), want); +} diff --git a/docs/dependencies.md b/docs/dependencies.md index 183f098..cd0f4e1 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -8,3 +8,4 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it. | `serde_json` | 1.0.151 | `proto` | JSON for frames and log files. MIT OR Apache-2.0. | | `humantime` | 2.4.0 | `proto` | RFC 3339 timestamps. No dependencies of its own. MIT OR Apache-2.0. | | `toml` | 1.1.6 | `proto` tests; `brokerd` and `bxctl` from M3 | Parses grant files. MIT OR Apache-2.0. | +| `emsha` | 1.0.4 | `proto` | SHA-256. Written by the owner; no dependencies, no `unsafe`. Checked against `sha256sum` on 3,204 inputs. | diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 677f891..ad0efe9 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -14,6 +14,7 @@ reviewer adds findings under "Reviews" once per milestone. | M1/07-brokerd-decision | 2026-09-17 | done | 1 | pass | none | Added Decision (Debug only, private fields), decide (Err(NoGrant) until M3) and the run stub (ToolResponse::Failed) in crates/brokerd; Decision::new carries expect(dead_code). 2 unit + 3 doctests (2 compile_fail) pass; verified the compile_fail guards by temporarily making new pub. `make gate` prints `gate: ok`. | | M1/08-proto-strictness | 2026-09-17 | done | 1 | pass | none | Added deny_unknown_fields to AuditRecord and ToolCall in crates/proto; bounded Timestamp (MAX const, from_unix_millis -> Result, parse bounds via from_unix_millis, now clamps to MAX) in ids.rs. 45 proto tests pass; `cargo fmt --all` and `make gate` print `gate: ok`. | | M1/09-gate-scripts-table-form | 2026-09-17 | done | 1 | pass | none | Rewrote check-lines, check-crate-deps and check-dep-docs to parse table-form (`[dependencies.x]`) and dotted (`x.path`) dependencies and to fail closed (exit 1 when crates/Cargo.toml/docs/dependencies.md is missing); check-lines now prints `file has N lines (limit 500)`. Self-test passes with 0 failures, all three scripts pass on the real tree, and `make gate` prints `gate: ok`. | +| M2a/01-proto-sha256 | 2026-09-17 | done | 1 | pass | none | Added crates/proto/src/hash.rs wrapping emsha 1.0.4 (HashError, Sha256 with new/update/finish, sha256, Default); re-exported from lib.rs, added emsha workspace dep and dependencies.md row. One compile fix: finish needed `mut self` to call finalize. 4 hash tests pass, `make gate` prints `gate: ok`. | ## Reviews