diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 335e9c7..4d1b38c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,14 +34,14 @@ jobs: run: cargo test --verbose --all-features msrv: - name: Verify MSRV (1.85) + name: Verify MSRV (1.99) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Rust Toolchain (1.85) - uses: dtolnay/rust-toolchain@1.85.0 + - name: Install Rust Toolchain (1.99) + uses: dtolnay/rust-toolchain@1.99.0 - name: Check run: cargo check --all-features diff --git a/.gitignore b/.gitignore index 196e176..c62d9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ Cargo.lock # Added by cargo /target + +plan.md +session.json diff --git a/CHANGELOG.md b/CHANGELOG.md index cf55c3e..78256e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-10-07 + +### Added + +- SHAKE128 and SHAKE256 extendable-output function (XOF) support. `Codec::Shake128` and `Codec::Shake256` now end the `HASH_CODECS` list. +- Streaming `Builder` support. `Builder::update` feeds data in chunks, and `Builder::output_len` sets the XOF output length in bytes. +- The `MAX_HASH_LENGTH` constant. The builder rejects XOF output lengths above this 16 MiB value before any allocation or squeeze. +- The `fips` feature. It exports the `FIPS_CODECS` and `SAFE_FIPS_CODECS` constants, which list the NIST FIPS approved hash algorithms. +- New error variants `Error::OutputLenRequired { codec }` and `Error::OutputLenInvalid { codec, output_len, max }`. They report a missing or out-of-range XOF output length. + +### Changed + +- `Builder::new(codec)` now returns a `Result`. It returns `Error::UnsupportedHash` for a codec outside `HASH_CODECS`. +- Removed `Builder::new_from_bytes`. Use `Builder::new` and `Builder::update` instead. +- `Builder::try_build()` and `Builder::try_build_encoded()` now consume the builder. Clone the builder when you need a second build. +- `Builder::try_build()` now checks the digest length against the codec's output policy. Fixed-output codecs accept only their exact policy length, and the XOF codecs accept 1 to `MAX_HASH_LENGTH` bytes. +- Removed the derived `Default` implementation for `Builder`. Create a builder with `Builder::new(codec)`. +- Fixed-output codecs ignore a `Builder::output_len` setting. They always produce their exact policy digest length. +- `HASH_CODECS` grew to 25 entries, and `SAFE_HASH_CODECS` grew to 10 entries. +- Streamed `Blake3` builds now require an `output_len` setting. A `Blake3` digest set with `with_hash` accepts 1 to `MAX_HASH_LENGTH` bytes. +- Raised the MSRV from 1.85 to 1.99. The CI MSRV job now checks 1.99.0. +- Made the `HashDigest::len` and `HashDigest::is_empty` methods `const`. + +### Dependencies + +- Added the `shake = "0.1"` dependency. It provides the SHAKE128 and SHAKE256 extendable-output functions. + ## [1.1.2] - 2026-08-17 ### Fixed @@ -138,6 +165,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Added a test suite for edge cases, integration, proptests, and security. - Initial published release on crates.io as `multi-hash`. +[2.0.0]: https://github.com/cryptidtech/multi-hash/compare/v1.1.2...v2.0.0 [1.1.2]: https://github.com/cryptidtech/multi-hash/compare/v1.1.1...v1.1.2 [1.1.1]: https://github.com/cryptidtech/multi-hash/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/cryptidtech/multi-hash/compare/v1.0.7...v1.1.0 diff --git a/Cargo.toml b/Cargo.toml index 8985f79..1a8d6ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "multi-hash" -version = "1.1.2" +version = "2.0.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.99" authors = ["Dave Grantham "] description = "Multihash self-describing cryptographic hash data" repository = "https://github.com/cryptidtech/multi-hash.git" @@ -14,6 +14,9 @@ categories = ["cryptography", "encoding"] [features] default = ["serde"] +# expose the FIPS approved codec constants, FIPS_CODECS and SAFE_FIPS_CODECS +fips = [] + [dependencies] blake2 = "0.11.0-rc.6" blake3 = { version = "1.8", features = ["traits-preview", "zeroize"] } @@ -21,7 +24,7 @@ multi-base = "1.0" multi-codec = "1.2" multi-trait = "1.0" multi-util = "1.1" -digest = "0.11" +digest = { version = "0.11", features = ["alloc"] } hex = "0.4" md-5 = "0.11" ripemd = "0.2" @@ -29,6 +32,7 @@ serde = { version = "1.0", default-features = false, features = ["alloc", "deriv sha1 = "0.11" sha2 = "0.11" sha3 = "0.12" +shake = "0.1" subtle = "2" thiserror = { version = "2.0" } typenum = "1.20" diff --git a/README.md b/README.md index d83ef52..60a0ee5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Rust implementation of the [Multihash](https://github.com/multiformats/multihash) specification for self-describing cryptographic hash digests. -Multihash is a self-describing format. It pairs a hash algorithm identifier (a multicodec tag) with the raw digest bytes. This lets systems switch hash algorithms without a break in compatibility. The crate gives 23 supported hash algorithms, type-safe wrappers, serde integration, and multibase encoding via the `multi-util` crate stack. +Multihash is a self-describing format. It pairs a hash algorithm identifier (a multicodec tag) with the raw digest bytes. This lets systems switch hash algorithms without a break in compatibility. The crate gives 25 supported hash algorithms, type-safe wrappers, serde integration, and multibase encoding via the `multi-util` crate stack. The codec set includes three extendable-output functions (XOF): `Blake3`, `Shake128`, and `Shake256`. You choose the digest length of an XOF with the builder, from 1 to `MAX_HASH_LENGTH` bytes (16 MiB). ## Table of Contents @@ -21,6 +21,7 @@ Multihash is a self-describing format. It pairs a hash algorithm identifier (a m - [Usage](#usage) - [Computing a Hash](#computing-a-hash) - [Building from an Existing Digest](#building-from-an-existing-digest) + - [Extendable-Output Functions](#extendable-output-functions) - [Encoding and Decoding](#encoding-and-decoding) - [Base Encoding](#base-encoding) - [Converting to EncodedMultihash](#converting-to-encodedmultihash) @@ -36,7 +37,7 @@ Multihash is a self-describing format. It pairs a hash algorithm identifier (a m ## Features -- 23 hash algorithms. SHA1, SHA2 family, SHA3 family, Blake2, Blake3, MD5, RIPEMD. +- 25 hash algorithms. SHA1, SHA2 family, SHA3 family, Blake2, Blake3, MD5, RIPEMD. `Blake3`, `Shake128`, and `Shake256` are extendable-output functions (XOF) with a caller-chosen digest length. - Builder pattern. A fluent API to create multihashes from raw data or existing digests. - Multibase encoding. The `EncodedMultihash` smart pointer gives a base-encoded string representation via `BaseEncoded` from `multi-util`. - Serde support. JSON gives the codec name string. Binary gives the varint bytes. The `serde` feature gates it. @@ -51,17 +52,17 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -multi-hash = "1.0" +multi-hash = "2.0" ``` To disable serde support: ```toml [dependencies] -multi-hash = { version = "1.0", default-features = false } +multi-hash = { version = "2.0", default-features = false } ``` -MSRV: Rust 1.85 (Edition 2024). +MSRV: Rust 1.99 (Edition 2024). ## Supported Algorithms @@ -73,10 +74,12 @@ MSRV: Rust 1.85 (Edition 2024). | Blake2b-384 | `Blake2B384` | 48 bytes | | Blake2b-512 | `Blake2B512` | 64 bytes | | Blake2s-256 | `Blake2S256` | 32 bytes | -| Blake3 | `Blake3` | 32 bytes | +| Blake3 | `Blake3` | Caller-chosen, 1 to `MAX_HASH_LENGTH` bytes (recommended 32) | | SHA3-256 | `Sha3256` | 32 bytes | | SHA3-384 | `Sha3384` | 48 bytes | | SHA3-512 | `Sha3512` | 64 bytes | +| Shake128 | `Shake128` | Caller-chosen, 1 to `MAX_HASH_LENGTH` bytes (recommended 32) | +| Shake256 | `Shake256` | Caller-chosen, 1 to `MAX_HASH_LENGTH` bytes (recommended 64) | See [`SAFE_HASH_CODECS`](https://docs.rs/multi-hash/latest/multi_hash/constant.SAFE_HASH_CODECS.html) for the constant array. @@ -100,7 +103,9 @@ See [`SAFE_HASH_CODECS`](https://docs.rs/multi-hash/latest/multi_hash/constant.S | RIPEMD-320 | `Ripemd320` | 40 bytes | | SHA3-224 | `Sha3224` | 28 bytes | -See [`HASH_CODECS`](https://docs.rs/multi-hash/latest/multi_hash/constant.HASH_CODECS.html) for the constant array of all 23 supported codecs. +See [`HASH_CODECS`](https://docs.rs/multi-hash/latest/multi_hash/constant.HASH_CODECS.html) for the constant array of all 25 supported codecs. + +The extendable-output functions (`Blake3`, `Shake128`, and `Shake256`) accept a caller-chosen digest length from 1 to `MAX_HASH_LENGTH` bytes (16 MiB), which you set with `Builder::output_len()`. ## Usage @@ -110,11 +115,10 @@ See [`HASH_CODECS`](https://docs.rs/multi-hash/latest/multi_hash/constant.HASH_C use multi_hash::Builder; use multi_codec::Codec; -// Compute a SHA2-256 hash -let multihash = Builder::new_from_bytes(Codec::Sha2256, b"hello world") - .unwrap() - .try_build() - .unwrap(); +// Compute a SHA2-256 hash over data fed in chunks +let mut builder = Builder::new(Codec::Sha2256).unwrap(); +builder.update(b"hello world"); +let multihash = builder.try_build().unwrap(); assert_eq!(multihash.codec(), Codec::Sha2256); assert_eq!(multihash.as_ref().len(), 32); // SHA2-256 outputs 32 bytes @@ -130,11 +134,53 @@ use multi_codec::Codec; let digest = vec![0u8; 32]; // pre-computed SHA2-256 digest let multihash = Builder::new(Codec::Sha2256) + .unwrap() .with_hash(digest) .try_build() .unwrap(); ``` +### Extendable-Output Functions + +`Blake3`, `Shake128`, and `Shake256` are extendable-output functions (XOF). They produce a digest of any length from 1 to `MAX_HASH_LENGTH` bytes. Set the length with `output_len()`. A streamed build of an XOF codec fails with `Error::OutputLenRequired` without a length: + +```rust +use multi_hash::{Builder, Error}; +use multi_codec::Codec; + +// Stream data through SHAKE128 and squeeze 64 digest bytes +let mut shake = Builder::new(Codec::Shake128).unwrap(); +shake.update(b"hello world"); +shake.output_len(64); +let shake_digest = shake.try_build().unwrap(); +assert_eq!(shake_digest.as_ref().len(), 64); + +// BLAKE3 uses the same length policy. A 32-byte digest equals the classic BLAKE3 digest. +let mut blake = Builder::new(Codec::Blake3).unwrap(); +blake.update(b"hello world"); +blake.output_len(32); +let blake_digest = blake.try_build().unwrap(); +assert_eq!(blake_digest.as_ref().len(), 32); + +// A streamed build of an XOF codec fails without an output length +let mut builder = Builder::new(Codec::Shake256).unwrap(); +builder.update(b"hello world"); +match builder.try_build() { + Err(Error::OutputLenRequired { codec }) => { + eprintln!("Set output_len() for {:?} with a length from 1 to MAX_HASH_LENGTH", codec); + } + Err(e) => eprintln!("Other error: {}", e), + Ok(_) => unreachable!(), +} +``` + +You can also wrap an existing XOF digest. `with_hash()` accepts any digest length from 1 to `MAX_HASH_LENGTH` bytes for an XOF codec. Notes on XOF digests: + +- Prefix consistency. For the same input, the first N bytes of a long digest equal the shorter digest. Two different lengths still encode as distinct multihashes, because the encoded length prefix differs. +- No extension of a built digest. A built `Multihash` digest cannot extend to a longer digest. A different length needs a rehash through a fresh builder. +- Different algorithms. A 32-byte `Shake256` digest is not a `Sha3256` digest. A 32-byte `Blake3` digest is not a BLAKE2b-256 digest. The codecs name different algorithms. +- Length and security. The sponge capacity fixes the security strength of the XOF. The chosen output length bounds collision resistance. Use at least 32 bytes for `Blake3` and `Shake128`, and at least 64 bytes for `Shake256`. Longer BLAKE3 outputs give no additional security. + ### Encoding and Decoding Multihashes encode as `codec || length || hash` (varint-prefixed): @@ -143,10 +189,9 @@ Multihashes encode as `codec || length || hash` (varint-prefixed): use multi_hash::{Builder, Multihash}; use multi_codec::Codec; -let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"data") - .unwrap() - .try_build() - .unwrap(); +let mut builder = Builder::new(Codec::Sha2256).unwrap(); +builder.update(b"data"); +let mh1 = builder.try_build().unwrap(); // Encode to binary (varint wire format) let bytes: Vec = mh1.clone().into(); @@ -165,8 +210,9 @@ use multi_hash::Builder; use multi_codec::Codec; use multi_base::Base; -let encoded = Builder::new_from_bytes(Codec::Sha2256, b"data") - .unwrap() +let mut builder = Builder::new(Codec::Sha2256).unwrap(); +builder.update(b"data"); +let encoded = builder .with_base_encoding(Base::Base58Btc) .try_build_encoded() .unwrap(); @@ -190,10 +236,9 @@ use multi_hash::{Builder, EncodedMultihash}; use multi_base::Base; use multi_codec::Codec; -let mh = Builder::new_from_bytes(Codec::Sha3384, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); +let mut builder = Builder::new(Codec::Sha3384).unwrap(); +builder.update(b"for great justice, move every zig!"); +let mh = builder.try_build().unwrap(); // Uses the preferred encoding for multihash objects: Base16Lower let encoded_mh1: EncodedMultihash = mh.clone().into(); @@ -217,11 +262,10 @@ struct DocumentHash { timestamp: u64, } +let mut builder = Builder::new(Codec::Sha2256).unwrap(); +builder.update(b"document content"); let doc = DocumentHash { - hash: Builder::new_from_bytes(Codec::Sha2256, b"document content") - .unwrap() - .try_build() - .unwrap(), + hash: builder.try_build().unwrap(), timestamp: 1234567890, }; @@ -236,14 +280,14 @@ assert_eq!(doc, deserialized); ### Error Handling -All conversion and builder errors return `Result` with a structured `Error` enum: +All conversion and builder errors return `Result` with a structured `Error` enum. `Builder::new()` is fallible and returns `Error::UnsupportedHash` for an unknown codec: ```rust use multi_hash::{Builder, Error}; use multi_codec::Codec; // Handle unsupported algorithms -match Builder::new_from_bytes(Codec::Identity, b"data") { +match Builder::new(Codec::Identity) { Err(Error::UnsupportedHash { codec }) => { eprintln!("Algorithm {:?} not supported", codec); } @@ -252,15 +296,50 @@ match Builder::new_from_bytes(Codec::Identity, b"data") { } // Handle missing hash data -match Builder::new(Codec::Sha2256).try_build() { +match Builder::new(Codec::Sha2256).unwrap().try_build() { Err(Error::MissingHash) => { - eprintln!("Must call with_hash() before build()"); + eprintln!("Must call with_hash() or update() before try_build()"); + } + Err(e) => eprintln!("Other error: {}", e), + Ok(_) => unreachable!(), +} +``` + +A streamed build of an XOF codec needs an output length: + +```rust +use multi_hash::{Builder, Error}; +use multi_codec::Codec; + +// Handle a streamed build of an XOF codec without an output length +let mut builder = Builder::new(Codec::Shake128).unwrap(); +builder.update(b"some data"); +match builder.try_build() { + Err(Error::OutputLenRequired { codec }) => { + eprintln!("Set output_len() from 1 to MAX_HASH_LENGTH for {:?}", codec); + } + Err(e) => eprintln!("Other error: {}", e), + Ok(_) => unreachable!(), +} + +// Handle an XOF output length outside the 1..=MAX_HASH_LENGTH policy +let mut builder = Builder::new(Codec::Shake256).unwrap(); +builder.update(b"some data"); +builder.output_len(0); +match builder.try_build() { + Err(Error::OutputLenInvalid { codec, output_len, max }) => { + eprintln!( + "Algorithm {:?} rejects a length of {}. Use a length from 1 to {}", + codec, output_len, max + ); } Err(e) => eprintln!("Other error: {}", e), Ok(_) => unreachable!(), } ``` +The length check runs before any allocation or squeeze. + ### Type-Safe Newtypes For more type safety, use the newtype wrappers: @@ -283,7 +362,7 @@ assert_eq!(algo.code(), 0x12); ## Testing -The crate has 110 tests across unit, integration, property-based, security, and doc-test suites: +The crate has 171 tests: 130 test functions and 41 doc examples. The suites cover unit, integration, property-based, security, XOF, FIPS, and doc-test paths: ```bash # Run all tests @@ -294,6 +373,10 @@ cargo test --test edge_case_tests cargo test --test integration_tests cargo test --test proptest_tests cargo test --test security_tests +cargo test --test xof_tests + +# Run the FIPS tests, which need the `fips` feature +cargo test --test fips_tests --features fips # Run benchmarks cargo bench @@ -311,12 +394,20 @@ CI collects coverage with `cargo-llvm-cov` and uploads the result to Codecov. ## Feature Flags - `serde` (default). Enables serde serialization and deserialization. When on, `Multihash` implements `Serialize` and `Deserialize`. Human-readable formats give the codec name and the hex digest. Binary formats give the varint bytes. +- `fips` (additive, off by default). Exports `FIPS_CODECS` (13 codecs) and `SAFE_FIPS_CODECS` (8 codecs). They list the NIST FIPS approved hash algorithms. `SAFE_FIPS_CODECS` omits SHA-1 and the 224-bit algorithms that SP 800-131A restricts. `Blake2`, `Blake3`, `Md5`, and the RIPEMD codecs are not FIPS approved. SHA-1 is verification-only under NIST SP 800-131A Rev. 2. The SP 800-131A Rev. 3 draft deprecates SHA-1 and the 224-bit hashes through 2030 and disallows them after 2030. + +The `fips` feature composes with the default `serde` feature: + +```toml +[dependencies] +multi-hash = { version = "2.0", features = ["fips"] } +``` ### Disabling Default Features ```toml [dependencies] -multi-hash = { version = "1.0", default-features = false } +multi-hash = { version = "2.0", default-features = false } ``` ## Security @@ -327,6 +418,7 @@ multi-hash = { version = "1.0", default-features = false } - Hash computation uses vetted cryptographic libraries from the RustCrypto ecosystem. - `impl subtle::ConstantTimeEq for Multihash` is available for timing-sensitive comparisons. - The `Varbytes` decode path enforces a decoded-size cap (16 MiB) and buffer-length checks. This mitigates CWE-400 and CWE-125. +- The builder enforces the same 16 MiB cap through `MAX_HASH_LENGTH`. It checks an XOF output length against `1..=MAX_HASH_LENGTH` before it allocates or squeezes. The builder validates the digest length at `try_build()` time. See [SECURITY.md](SECURITY.md) for the full security policy. diff --git a/SECURITY.md b/SECURITY.md index 015ae12..441fff0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ The `multi-hash` crate gives self-describing cryptographic hash digests. It foll ## std-only Status -The crate is std-only. It depends on the `DynDigest` trait from the `digest` crate. `DynDigest` needs `Box`, which needs `std::alloc` and `std` box support. The crate also uses `unsigned-varint` with the `std` feature. A `no_std` conversion is not planned for this crate. +The crate is std-only. The hash path holds each hasher as a concrete type in a private `Hasher` enum. The enum dispatches between the hashers without a trait object. The BLAKE3 arm holds a boxed `blake3::Hasher`. The boxed hasher and the digest buffers need the allocator in `std`. A `no_std` conversion is not planned for this crate. ## Security Properties @@ -14,6 +14,8 @@ The crate is std-only. It depends on the `DynDigest` trait from the `digest` cra - No unsafe code. `#![deny(unsafe_code)]` is set at the crate root. - Input validation. All decode paths check lengths and codec identifiers before they allocate. +- Builder validation. The builder checks an XOF output length against `1..=MAX_HASH_LENGTH` (16 MiB) before it allocates or squeezes. A missing length for streamed data returns `Error::OutputLenRequired`. A length of 0 or above `MAX_HASH_LENGTH` returns `Error::OutputLenInvalid`. +- Digest validation. `try_build` validates the digest against the codec output policy at build time. A fixed-output codec requires its exact policy length. - DoS protection. The `Varbytes` decode path sets the hash digest length. It enforces a decoded-size cap (16 MiB) and buffer-length checks. This mitigates CWE-400 and CWE-125. ### Constant-Time Comparison @@ -24,7 +26,13 @@ The crate gives `impl subtle::ConstantTimeEq for Multihash`. It compares the `co ### Supported Algorithms -See `SAFE_HASH_CODECS` for the recommended algorithms. The legacy algorithms (SHA1, MD5, RIPEMD) are for compatibility only. Do not use them in new cryptographic constructions. +`SAFE_HASH_CODECS` lists 10 recommended codecs. They include the BLAKE2 and BLAKE3 algorithms, the SHA-3 family, and the `Shake128` and `Shake256` extendable-output functions (XOF). An XOF accepts a caller-chosen digest length from 1 to `MAX_HASH_LENGTH` bytes. `MAX_HASH_LENGTH` is 16 MiB. The check runs in `try_build` before any allocation or squeeze. + +The sponge capacity fixes the security strength of an XOF. The chosen output length bounds collision resistance. Use at least 32 bytes for `Blake3` and `Shake128`, and at least 64 bytes for `Shake256`. Longer BLAKE3 outputs give no additional security. A different digest length encodes as a distinct multihash. A built `Multihash` digest cannot extend. A different length needs a rehash through a fresh builder. + +The legacy algorithms (SHA1, MD5, RIPEMD) are for compatibility only. Do not use them in new cryptographic constructions. + +With the `fips` feature, the crate exports `FIPS_CODECS` (13 codecs) and `SAFE_FIPS_CODECS` (8 codecs). They list the NIST FIPS approved hash algorithms. BLAKE2, BLAKE3, MD5, and RIPEMD are not FIPS approved. SHA-1 is verification-only under NIST SP 800-131A Rev. 2. The SP 800-131A Rev. 3 draft deprecates SHA-1 and the 224-bit hashes through 2030 and disallows them after 2030. `SAFE_FIPS_CODECS` omits SHA-1 and the restricted 224-bit algorithms, and it also omits SHA-512/256, for the same reason as `SAFE_HASH_CODECS`. ## Reporting Vulnerabilities @@ -32,4 +40,4 @@ Report security issues through the GitHub issue tracker. You can also report the ## CI -The CI workflow runs these jobs. `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test`, an MSRV job (1.85), `cargo audit`, and a `coverage` job. The coverage job uses `cargo-llvm-cov` and uploads the result to Codecov. There is no `no_std` job. The crate is std-only (see [std-only Status](#std-only-status)). \ No newline at end of file +The CI workflow runs these jobs. `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test`, an MSRV job (1.99), `cargo audit`, and a `coverage` job. The coverage job uses `cargo-llvm-cov` and uploads the result to Codecov. There is no `no_std` job. The crate is std-only (see [std-only Status](#std-only-status)). \ No newline at end of file diff --git a/benches/multihash_bench.rs b/benches/multihash_bench.rs index 0ed922c..3a858c6 100644 --- a/benches/multihash_bench.rs +++ b/benches/multihash_bench.rs @@ -7,6 +7,28 @@ use multi_hash::{Builder, Multihash}; use multi_trait::TryDecodeFrom; use std::hint::black_box; +/// build a multihash of `data` with `codec`, streaming the data +fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + +/// the output length the computation bench requests for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the check applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + /// Benchmark hash computation for various algorithms fn bench_hash_computation(c: &mut Criterion) { let mut group = c.benchmark_group("hash_computation"); @@ -18,11 +40,19 @@ fn bench_hash_computation(c: &mut Criterion) { ("SHA2-512", Codec::Sha2512), ("SHA3-256", Codec::Sha3256), ("SHA3-512", Codec::Sha3512), + ("Shake256", Codec::Shake256), ]; for (name, codec) in algorithms { group.bench_with_input(BenchmarkId::new("compute", name), &codec, |b, &codec| { - b.iter(|| Builder::new_from_bytes(codec, data).unwrap().try_build()); + b.iter(|| { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build() + }); }); } @@ -31,10 +61,7 @@ fn bench_hash_computation(c: &mut Criterion) { /// Benchmark encoding multihash to bytes fn bench_encoding(c: &mut Criterion) { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"test data") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, b"test data"); c.bench_function("multihash_to_bytes", |b| { b.iter(|| { @@ -45,10 +72,7 @@ fn bench_encoding(c: &mut Criterion) { /// Benchmark decoding multihash from bytes fn bench_decoding(c: &mut Criterion) { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"test data") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, b"test data"); let bytes: Vec = mh.into(); c.bench_function("multihash_from_bytes", |b| { @@ -66,10 +90,7 @@ fn bench_roundtrip(c: &mut Criterion) { let name = format!("{codec:?}"); group.bench_with_input(BenchmarkId::new("full", &name), &codec, |b, &codec| { b.iter(|| { - let mh1 = Builder::new_from_bytes(codec, data) - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(codec, data); let bytes: Vec = mh1.into(); let _mh2 = Multihash::try_from(bytes.as_ref()).unwrap(); }); @@ -89,9 +110,9 @@ fn bench_data_sizes(c: &mut Criterion) { let data = vec![0u8; size]; group.bench_with_input(BenchmarkId::new("sha2-256", size), &data, |b, data| { b.iter(|| { - Builder::new_from_bytes(Codec::Sha2256, black_box(data)) - .unwrap() - .try_build() + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + builder.update(black_box(data)); + builder.try_build() }); }); } @@ -101,14 +122,19 @@ fn bench_data_sizes(c: &mut Criterion) { /// Benchmark builder pattern operations fn bench_builder(c: &mut Criterion) { - c.bench_function("builder_from_bytes", |b| { - b.iter(|| Builder::new_from_bytes(black_box(Codec::Sha2256), black_box(b"data"))); + c.bench_function("builder_streaming", |b| { + b.iter(|| { + let mut builder = Builder::new(black_box(Codec::Sha2256)).unwrap(); + builder.update(black_box(b"data")); + builder.try_build() + }); }); let hash = vec![0u8; 32]; c.bench_function("builder_with_hash", |b| { b.iter(|| { Builder::new(black_box(Codec::Sha2256)) + .unwrap() .with_hash(black_box(hash.clone())) .try_build() }); @@ -126,10 +152,7 @@ fn bench_decode_from(c: &mut Criterion) { ]; for (name, codec) in test_cases { - let mh = Builder::new_from_bytes(codec, b"test") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(codec, b"test"); let bytes: Vec = mh.into(); group.bench_with_input(BenchmarkId::new("decode", name), &bytes, |b, bytes| { diff --git a/src/error.rs b/src/error.rs index 6d2781d..18c7e96 100644 --- a/src/error.rs +++ b/src/error.rs @@ -43,7 +43,9 @@ pub enum Error { /// /// # Resolution /// - /// Call `Builder::with_hash()` to set the hash digest before calling `build()`. + /// Call `Builder::with_hash()` to set a digest computed elsewhere. Call + /// `Builder::update()` to stream data. One of them must run before + /// `try_build()`. /// /// # Examples /// @@ -56,7 +58,7 @@ pub enum Error { #[error( "Missing hash data\n\ The multihash builder requires hash digest data.\n\ - Call Builder::with_hash() before build()." + Call with_hash() or update() before try_build()." )] MissingHash, @@ -73,6 +75,8 @@ pub enum Error { /// /// Use one of the supported hash algorithms. See the `HASH_CODECS` or /// `SAFE_HASH_CODECS` constants for the list of supported algorithms. + /// With the `fips` feature enabled, the `FIPS_CODECS` and + /// `SAFE_FIPS_CODECS` constants list the NIST FIPS approved algorithms. /// /// # Examples /// @@ -95,7 +99,7 @@ pub enum Error { /// Invalid hash digest length /// - /// The provided hash digest length doesn't match the expected output + /// The provided hash digest length does not match the expected output /// size for the specified hash algorithm. /// /// # Context @@ -127,6 +131,80 @@ pub enum Error { actual: usize, }, + /// XOF output length required + /// + /// The builder tried to finalize an extendable-output codec + /// (`Blake3`, `Shake128`, or `Shake256`) without an output length set + /// through `Builder::output_len()`. + /// + /// # Context + /// + /// - `codec`: The XOF codec that requested an output length + /// + /// # Resolution + /// + /// Call `Builder::output_len()` with a value from 1 to `MAX_HASH_LENGTH` + /// bytes before `try_build()` or `try_build_encoded()`. + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Error; + /// use multi_codec::Codec; + /// + /// let err = Error::output_len_required(Codec::Shake128); + /// assert!(matches!(err, Error::OutputLenRequired { .. })); + /// ``` + #[error( + "XOF output length required for {codec:?}\n\ + The {codec:?} XOF needs an output length set through Builder::output_len().\n\ + Set a value from 1 to MAX_HASH_LENGTH bytes before try_build()." + )] + OutputLenRequired { + /// the XOF codec that requested an output length + codec: multi_codec::Codec, + }, + + /// Invalid XOF output length + /// + /// The requested XOF output length is outside the allowed range of 1 to + /// `MAX_HASH_LENGTH` bytes. The length is checked before any allocation + /// or squeeze. + /// + /// # Context + /// + /// - `codec`: The XOF codec with the invalid output length + /// - `output_len`: The requested output length in bytes + /// - `max`: The maximum allowed output length in bytes + /// + /// # Resolution + /// + /// Set a value from 1 to `MAX_HASH_LENGTH` bytes through + /// `Builder::output_len()`. + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Error; + /// use multi_codec::Codec; + /// + /// let err = Error::output_len_invalid(Codec::Shake256, 0, 16 * 1024 * 1024); + /// assert!(matches!(err, Error::OutputLenInvalid { .. })); + /// ``` + #[error( + "Invalid XOF output length for {codec:?}\n\ + The requested output length {output_len} is outside the allowed\n\ + range of 1 to {max} bytes for {codec:?}." + )] + OutputLenInvalid { + /// the XOF codec with the invalid output length + codec: multi_codec::Codec, + /// the requested output length in bytes + output_len: usize, + /// the maximum allowed output length in bytes + max: usize, + }, + /// Hash computation failed /// /// An error occurred while computing the cryptographic hash. @@ -195,6 +273,46 @@ impl Error { } } + /// Create an `OutputLenRequired` error + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Error; + /// use multi_codec::Codec; + /// + /// let err = Error::output_len_required(Codec::Shake128); + /// assert!(matches!(err, Error::OutputLenRequired { .. })); + /// ``` + #[must_use] + pub const fn output_len_required(codec: multi_codec::Codec) -> Self { + Self::OutputLenRequired { codec } + } + + /// Create an `OutputLenInvalid` error + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Error; + /// use multi_codec::Codec; + /// + /// let err = Error::output_len_invalid(Codec::Shake256, 0, 16 * 1024 * 1024); + /// assert!(matches!(err, Error::OutputLenInvalid { .. })); + /// ``` + #[must_use] + pub const fn output_len_invalid( + codec: multi_codec::Codec, + output_len: usize, + max: usize, + ) -> Self { + Self::OutputLenInvalid { + codec, + output_len, + max, + } + } + /// Create a `HashComputeFailed` error /// /// # Examples @@ -238,6 +356,8 @@ impl Error { Self::MissingHash => "MissingHash", Self::UnsupportedHash { .. } => "UnsupportedHash", Self::InvalidDigestLength { .. } => "InvalidDigestLength", + Self::OutputLenRequired { .. } => "OutputLenRequired", + Self::OutputLenInvalid { .. } => "OutputLenInvalid", Self::HashComputeFailed { .. } => "HashComputeFailed", } } @@ -271,6 +391,16 @@ impl Error { } => format!( "Invalid digest length for {algorithm:?}: expected {expected}, got {actual}" ), + Self::OutputLenRequired { codec } => { + format!("XOF output length required for {codec:?}") + } + Self::OutputLenInvalid { + codec, + output_len, + max, + } => format!( + "Invalid XOF output length for {codec:?}: {output_len} is outside the allowed range of 1 to {max}" + ), Self::HashComputeFailed { algorithm, message } => { format!("Hash computation failed for {algorithm:?}: {message}") } @@ -296,11 +426,11 @@ mod tests { assert_eq!(err.kind(), "UnsupportedHash"); let msg = err.to_string(); // Check error message is present and non-empty - assert!(!msg.is_empty()); + assert_ne!(msg, ""); assert!(msg.len() > 10); // Check context contains codec info let context = err.context(); - assert!(!context.is_empty()); + assert_ne!(context, ""); } #[test] @@ -319,17 +449,41 @@ mod tests { assert!(err.to_string().contains("test failure")); } + #[test] + fn test_output_len_required_error() { + let err = Error::output_len_required(Codec::Shake128); + assert_eq!(err.kind(), "OutputLenRequired"); + assert!(err.to_string().contains("output length")); + let context = err.context(); + assert_ne!(context, ""); + assert!(context.contains("Shake128") || context.contains("shake-128")); + } + + #[test] + fn test_output_len_invalid_error() { + let err = Error::output_len_invalid(Codec::Shake256, 0, 16 * 1024 * 1024); + assert_eq!(err.kind(), "OutputLenInvalid"); + let msg = err.to_string(); + assert!(msg.contains('0')); + assert!(msg.contains("16777216")); + let context = err.context(); + assert_ne!(context, ""); + assert!(context.contains("Shake256") || context.contains("shake-256")); + } + #[test] fn test_error_kind_uniqueness() { let errors = [ Error::MissingHash, Error::unsupported_hash(Codec::Identity), Error::invalid_digest_length(Codec::Sha2256, 32, 16), + Error::output_len_required(Codec::Shake128), + Error::output_len_invalid(Codec::Shake256, 0, 16 * 1024 * 1024), Error::hash_compute_failed(Codec::Sha2256, "test"), ]; let kinds: Vec<_> = errors.iter().map(Error::kind).collect(); - assert_eq!(kinds.len(), 4); + assert_eq!(kinds.len(), 6); // All kinds should be unique for (i, k1) in kinds.iter().enumerate() { @@ -345,7 +499,7 @@ mod tests { fn test_error_context_informative() { let err = Error::unsupported_hash(Codec::Sha2256); let context = err.context(); - assert!(!context.is_empty()); + assert_ne!(context, ""); assert!(context.contains("Sha2256") || context.contains("sha2-256")); let err = Error::invalid_digest_length(Codec::Sha2512, 64, 32); diff --git a/src/lib.rs b/src/lib.rs index 8bcfc8b..b5a3c37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,8 @@ //! their use of hashes. //! //! This crate provides: -//! - Support for 23 cryptographic hash algorithms +//! - Support for 25 cryptographic hash algorithms, including the +//! extendable-output functions BLAKE3, SHAKE128, and SHAKE256 //! - Type-safe hash digest and algorithm wrappers //! - Encoding/decoding with multibase support //! - Serde serialization (optional) @@ -21,16 +22,21 @@ //! ## Supported Algorithms //! //! **Secure algorithms** (recommended for cryptographic use): -//! - Blake2b (224, 256, 384, 512 bits) -//! - Blake2s (224, 256 bits) -//! - Blake3 -//! - SHA3 (224, 256, 384, 512 bits) +//! - Blake2b (256, 384, 512 bits) +//! - Blake2s (256 bits) +//! - Blake3 (extendable-output function) +//! - SHA3 (256, 384, 512 bits) +//! - SHAKE128 and SHAKE256 extendable-output functions //! //! **Legacy algorithms** (for compatibility): //! - SHA1, SHA2 (224, 256, 384, 512, 512/224, 512/256 bits) //! - MD5, RIPEMD (128, 160, 256, 320 bits) +//! - Blake2b-224, Blake2s-224, SHA3-224 //! -//! See [`HASH_CODECS`] for the complete list and [`SAFE_HASH_CODECS`] for recommended algorithms. +//! See [`HASH_CODECS`] for the complete list and [`SAFE_HASH_CODECS`] for the +//! recommended algorithms. With the `fips` feature enabled, the crate also +//! exports `FIPS_CODECS` and `SAFE_FIPS_CODECS`, which list the hash +//! algorithms on the NIST FIPS approved lists. //! //! ## Quick Start //! @@ -42,15 +48,34 @@ //! use multi_util::CodecInfo; //! //! // Compute a SHA2-256 hash -//! let multihash = Builder::new_from_bytes(Codec::Sha2256, b"hello world") -//! .unwrap() -//! .try_build() -//! .unwrap(); +//! let mut builder = Builder::new(Codec::Sha2256).unwrap(); +//! builder.update(b"hello world"); +//! let multihash = builder.try_build().unwrap(); //! //! assert_eq!(multihash.codec(), Codec::Sha2256); //! assert_eq!(multihash.as_ref().len(), 32); // SHA2-256 outputs 32 bytes //! ``` //! +//! ### Computing a SHAKE256 Digest of a Chosen Length +//! +//! The `Shake256` extendable-output function produces a digest of any +//! length from 1 to `MAX_HASH_LENGTH` bytes. Set the length with +//! [`Builder::output_len`] before `try_build`: +//! +//! ```rust +//! use multi_hash::Builder; +//! use multi_codec::Codec; +//! use multi_util::CodecInfo; +//! +//! let mut builder = Builder::new(Codec::Shake256).unwrap(); +//! builder.update(b"hello world"); +//! builder.output_len(64); +//! let multihash = builder.try_build().unwrap(); +//! +//! assert_eq!(multihash.codec(), Codec::Shake256); +//! assert_eq!(multihash.as_ref().len(), 64); +//! ``` +//! //! ### Creating from Existing Hash //! //! ```rust @@ -60,6 +85,7 @@ //! // If you already have a hash digest //! let digest = vec![0u8; 32]; // SHA2-256 digest //! let multihash = Builder::new(Codec::Sha2256) +//! .unwrap() //! .with_hash(digest) //! .try_build() //! .unwrap(); @@ -71,10 +97,9 @@ //! use multi_hash::{Builder, Multihash}; //! use multi_codec::Codec; //! -//! let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"data") -//! .unwrap() -//! .try_build() -//! .unwrap(); +//! let mut builder = Builder::new(Codec::Sha2256).unwrap(); +//! builder.update(b"data"); +//! let mh1 = builder.try_build().unwrap(); //! //! // Encode to bytes //! let bytes: Vec = mh1.clone().into(); @@ -92,8 +117,9 @@ //! use multi_base::Base; //! //! // Create with specific base encoding -//! let encoded = Builder::new_from_bytes(Codec::Sha2256, b"data") -//! .unwrap() +//! let mut builder = Builder::new(Codec::Sha2256).unwrap(); +//! builder.update(b"data"); +//! let encoded = builder //! .with_base_encoding(Base::Base58Btc) //! .try_build_encoded() //! .unwrap(); @@ -127,7 +153,7 @@ //! use multi_codec::Codec; //! //! // Handle unsupported algorithms -//! match Builder::new_from_bytes(Codec::Identity, b"data") { +//! match Builder::new(Codec::Identity) { //! Ok(_) => println!("Success"), //! Err(Error::UnsupportedHash { codec }) => { //! eprintln!("Algorithm {:?} not supported", codec); @@ -136,10 +162,21 @@ //! } //! //! // Handle missing hash data -//! match Builder::new(Codec::Sha2256).try_build() { +//! match Builder::new(Codec::Sha2256).unwrap().try_build() { //! Ok(_) => println!("Success"), //! Err(Error::MissingHash) => { -//! eprintln!("Must call with_hash() before build()"); +//! eprintln!("Must call with_hash() or update() before try_build()"); +//! } +//! Err(e) => eprintln!("Other error: {}", e), +//! } +//! +//! // A streamed extendable-output build needs an output length +//! let mut builder = Builder::new(Codec::Shake256).unwrap(); +//! builder.update(b"hello world"); +//! match builder.try_build() { +//! Ok(_) => println!("Success"), +//! Err(Error::OutputLenRequired { codec }) => { +//! eprintln!("Set an output length for {:?} with output_len()", codec); //! } //! Err(e) => eprintln!("Other error: {}", e), //! } @@ -155,12 +192,9 @@ //! use multi_hash::Builder; //! use multi_codec::Codec; //! -//! let multihash = Arc::new( -//! Builder::new_from_bytes(Codec::Sha2256, b"shared data") -//! .unwrap() -//! .try_build() -//! .unwrap() -//! ); +//! let mut builder = Builder::new(Codec::Sha2256).unwrap(); +//! builder.update(b"shared data"); +//! let multihash = Arc::new(builder.try_build().unwrap()); //! //! let handle = thread::spawn(move || { //! println!("Hash: {}", hex::encode(multihash.as_ref())); @@ -176,14 +210,43 @@ //! - Builder pattern enables fluent, zero-cost construction //! - Benchmarks available: `cargo bench -p multi-hash` //! +//! ## Extendable-Output Functions +//! +//! BLAKE3, SHAKE128, and SHAKE256 are extendable-output functions (XOFs). +//! They produce a digest of any length from 1 to `MAX_HASH_LENGTH` bytes. +//! The builder applies these properties: +//! +//! - **Prefix consistency.** For the same input, the first N bytes of a +//! longer output equal the output at the shorter length. A 32-byte +//! SHAKE256 digest is the start of the 64-byte digest of the same +//! input. +//! - **No extension of a built digest.** A `Multihash` is a completed value. +//! A digest cannot grow from 32 to 64 bytes without a rehash of the +//! message through a fresh builder. +//! - **Distinct encodings at every length.** Two outputs of different +//! lengths encode as distinct multihashes. The encoded length prefix +//! differs for the two values. +//! - **Capacity versus collision resistance.** The sponge capacity fixes +//! the security strength of the XOF. The chosen output length bounds the +//! collision resistance. Recommended minimum outputs are 32 bytes for +//! BLAKE3 and SHAKE128, and 64 bytes for SHAKE256. A BLAKE3 output +//! longer than 32 bytes gives no additional collision resistance. +//! - **SHA3 is not truncated SHAKE.** SHA3-256 and 32 bytes of SHAKE256 are +//! different algorithms with different sponge rates. The codecs +//! `Sha3256` and `Shake256` name different algorithms. +//! //! ## Features //! //! - **`serde`** (default): Enables serde serialization support +//! - **`fips`**: Exports the `FIPS_CODECS` and `SAFE_FIPS_CODECS` +//! constants. They list the hash algorithms on the NIST FIPS approved +//! lists. `SAFE_FIPS_CODECS` omits SHA-1 and the 224-bit algorithms that +//! SP 800-131A restricts. //! //! To disable serde: //! ```toml //! [dependencies] -//! multi-hash = { version = "1.0", default-features = false } +//! multi-hash = { version = "2.0", default-features = false } //! ``` #![warn(missing_docs)] @@ -201,7 +264,12 @@ pub use error::Error; /// Multihash type and functions pub mod mh; -pub use mh::{Builder, EncodedMultihash, HASH_CODECS, Multihash, SAFE_HASH_CODECS}; +pub use mh::{ + Builder, EncodedMultihash, HASH_CODECS, MAX_HASH_LENGTH, Multihash, SAFE_HASH_CODECS, +}; +// the FIPS approved codec constants export only with the `fips` feature +#[cfg(feature = "fips")] +pub use mh::{FIPS_CODECS, SAFE_FIPS_CODECS}; /// Type-safe wrappers for multihash components pub mod types; @@ -216,10 +284,9 @@ pub mod serde; /// ``` /// use multi_hash::prelude::*; /// -/// let mh = Builder::new_from_bytes(Codec::Sha2256, b"test") -/// .unwrap() -/// .try_build() -/// .unwrap(); +/// let mut builder = Builder::new(Codec::Sha2256).unwrap(); +/// builder.update(b"test"); +/// let mh = builder.try_build().unwrap(); /// // CodecInfo trait is in prelude /// assert_eq!(mh.codec(), Codec::Sha2256); /// ``` diff --git a/src/mh.rs b/src/mh.rs index d5a79a5..df37939 100644 --- a/src/mh.rs +++ b/src/mh.rs @@ -2,12 +2,14 @@ //! Multihash implementation with support for multiple cryptographic hash algorithms //! //! This module provides the core [`Multihash`] type and [`Builder`] for creating -//! self-describing hash digests. +//! self-describing hash digests. The builder supports the fixed-output +//! algorithms and the extendable-output algorithms `Blake3`, `Shake128`, +//! and `Shake256`. use crate::Error; use core::fmt; use core::hash::Hash; -use digest::{Digest, DynDigest, InvalidBufferSize}; +use digest::{Digest, ExtendableOutput, Update}; use multi_base::Base; use multi_codec::Codec; use multi_trait::{EncodeInto, Null, TryDecodeFrom}; @@ -15,8 +17,27 @@ use multi_util::{BaseEncoded, CodecInfo, DetectedEncoder, EncodingInfo, Varbytes use subtle::ConstantTimeEq; use typenum::consts::{U28, U32, U48, U64}; +/// the maximum multihash digest output length in bytes +/// +/// The builder rejects XOF output lengths above this value before any +/// allocation or squeeze. The value is 16 MiB, aligned with the 16 MiB +/// decode cap that `Varbytes` in `multi-util` enforces. +/// +/// # Examples +/// +/// ``` +/// use multi_hash::MAX_HASH_LENGTH; +/// +/// assert_eq!(MAX_HASH_LENGTH, 16 * 1024 * 1024); +/// ``` +pub const MAX_HASH_LENGTH: usize = 16 * 1024 * 1024; + /// the hash codecs currently supported -pub const HASH_CODECS: [Codec; 23] = [ +/// +/// `Shake128` and `Shake256` end the list. The XOF codecs `Blake3`, +/// `Shake128`, and `Shake256` require an explicit digest output length +/// through [`Builder::output_len`]. +pub const HASH_CODECS: [Codec; 25] = [ Codec::Blake2B224, Codec::Blake2B256, Codec::Blake2B384, @@ -40,10 +61,12 @@ pub const HASH_CODECS: [Codec; 23] = [ Codec::Sha3256, Codec::Sha3384, Codec::Sha3512, + Codec::Shake128, + Codec::Shake256, ]; -/// the safe hash codecs current supported -pub const SAFE_HASH_CODECS: [Codec; 8] = [ +/// the safe hash codecs currently supported +pub const SAFE_HASH_CODECS: [Codec; 10] = [ Codec::Blake2B256, Codec::Blake2B384, Codec::Blake2B512, @@ -52,6 +75,80 @@ pub const SAFE_HASH_CODECS: [Codec; 8] = [ Codec::Sha3256, Codec::Sha3384, Codec::Sha3512, + Codec::Shake128, + Codec::Shake256, +]; + +/// the FIPS approved hash codecs +/// +/// The list holds the hash algorithms on the NIST FIPS approved list. +/// FIPS 180-4 approves SHA-1 and the SHA-2 family. FIPS 202 approves the +/// SHA-3 family and the SHAKE128 and SHAKE256 extendable-output functions. +/// The pre-standard Keccak codecs outside SHA-3, BLAKE2, BLAKE3, MD5, and +/// RIPEMD are not FIPS approved. +/// +/// NIST SP 800-131A Rev. 2 permits SHA-1 for verification only, and the +/// SP 800-131A Rev. 3 draft deprecates SHA-1 and the 224-bit hash functions +/// through 2030 and disallows them after 2030. Applications that must avoid +/// the deprecated functions can use [`SAFE_FIPS_CODECS`]. +/// +/// # Examples +/// +/// ``` +/// use multi_hash::FIPS_CODECS; +/// use multi_codec::Codec; +/// +/// assert!(FIPS_CODECS.contains(&Codec::Sha2256)); +/// assert!(FIPS_CODECS.contains(&Codec::Shake256)); +/// assert!(!FIPS_CODECS.contains(&Codec::Blake3)); +/// ``` +#[cfg(feature = "fips")] +pub const FIPS_CODECS: [Codec; 13] = [ + Codec::Sha1, + Codec::Sha2224, + Codec::Sha2256, + Codec::Sha2384, + Codec::Sha2512, + Codec::Sha2512224, + Codec::Sha2512256, + Codec::Sha3224, + Codec::Sha3256, + Codec::Sha3384, + Codec::Sha3512, + Codec::Shake128, + Codec::Shake256, +]; + +/// the safe FIPS approved hash codecs +/// +/// The list holds the FIPS approved codecs that avoid the restricted and +/// reduced-security selections. SHA-1 is verification-only under SP 800-131A +/// Rev. 2, and the 224-bit hashes SHA-224, SHA-512/224, and SHA3-224 fall +/// under the SP 800-131A Rev. 3 draft restrictions through 2030. SHA-512/256 +/// does not fall under the 224-bit restrictions, but it is excluded here to +/// apply the same strongest-selection principle as [`SAFE_HASH_CODECS`], +/// which omits the reduced-security variants of each family. +/// +/// # Examples +/// +/// ``` +/// use multi_hash::SAFE_FIPS_CODECS; +/// use multi_codec::Codec; +/// +/// assert!(SAFE_FIPS_CODECS.contains(&Codec::Sha3256)); +/// assert!(SAFE_FIPS_CODECS.contains(&Codec::Shake256)); +/// assert!(!SAFE_FIPS_CODECS.contains(&Codec::Sha1)); +/// ``` +#[cfg(feature = "fips")] +pub const SAFE_FIPS_CODECS: [Codec; 8] = [ + Codec::Sha2256, + Codec::Sha2384, + Codec::Sha2512, + Codec::Sha3256, + Codec::Sha3384, + Codec::Sha3512, + Codec::Shake128, + Codec::Shake256, ]; /// the multicodec sigil for multihash @@ -60,49 +157,331 @@ pub const SIGIL: Codec = Codec::Multihash; /// a base encoded multihash pub type EncodedMultihash = BaseEncoded; -#[derive(Clone)] -struct Blake3DynDigest(blake3::Hasher); +/// the digest output policy of a hash codec in [`HASH_CODECS`] +/// +/// Fixed-output codecs produce one exact digest length. The XOF codecs +/// `Blake3`, `Shake128`, and `Shake256` produce a length the caller +/// chooses within `1..=MAX_HASH_LENGTH` bytes. +#[derive(Clone, Copy)] +enum OutputPolicy { + /// exact digest length in bytes + Fixed(usize), + + /// extendable-output codec. The caller sets the digest length + /// through [`Builder::output_len`] + Xof, +} -impl Blake3DynDigest { - fn new() -> Self { - Self(blake3::Hasher::new()) - } +/// the digest output policy for each codec in [`HASH_CODECS`] order +const OUTPUT_POLICIES: [OutputPolicy; 25] = [ + OutputPolicy::Fixed(28), // Blake2B224 + OutputPolicy::Fixed(32), // Blake2B256 + OutputPolicy::Fixed(48), // Blake2B384 + OutputPolicy::Fixed(64), // Blake2B512 + OutputPolicy::Fixed(28), // Blake2S224 + OutputPolicy::Fixed(32), // Blake2S256 + OutputPolicy::Xof, // Blake3 + OutputPolicy::Fixed(16), // Md5 + OutputPolicy::Fixed(16), // Ripemd128 + OutputPolicy::Fixed(20), // Ripemd160 + OutputPolicy::Fixed(32), // Ripemd256 + OutputPolicy::Fixed(40), // Ripemd320 + OutputPolicy::Fixed(20), // Sha1 + OutputPolicy::Fixed(28), // Sha2224 + OutputPolicy::Fixed(32), // Sha2256 + OutputPolicy::Fixed(48), // Sha2384 + OutputPolicy::Fixed(64), // Sha2512 + OutputPolicy::Fixed(28), // Sha2512224 + OutputPolicy::Fixed(32), // Sha2512256 + OutputPolicy::Fixed(28), // Sha3224 + OutputPolicy::Fixed(32), // Sha3256 + OutputPolicy::Fixed(48), // Sha3384 + OutputPolicy::Fixed(64), // Sha3512 + OutputPolicy::Xof, // Shake128 + OutputPolicy::Xof, // Shake256 +]; + +// the digest policy table must stay parallel to `HASH_CODECS` +const _: () = assert!(OUTPUT_POLICIES.len() == HASH_CODECS.len()); + +/// the digest output policy of a supported hash codec +/// +/// Returns `None` for codecs outside [`HASH_CODECS`]. +fn output_policy(codec: Codec) -> Option { + let index = HASH_CODECS + .iter() + .position(|&candidate| candidate == codec)?; + OUTPUT_POLICIES.get(index).copied() } -impl DynDigest for Blake3DynDigest { - fn update(&mut self, data: &[u8]) { - self.0.update(data); +/// check a digest against the codec's output policy +/// +/// Fixed-output codecs require the exact digest length. XOF codecs accept +/// `1..=MAX_HASH_LENGTH` bytes. +fn validate_digest_length(codec: Codec, hash: &[u8]) -> Result<(), Error> { + match output_policy(codec) { + Some(OutputPolicy::Fixed(expected)) => { + let actual = hash.len(); + if actual == expected { + Ok(()) + } else { + Err(Error::invalid_digest_length(codec, expected, actual)) + } + } + Some(OutputPolicy::Xof) => { + let actual = hash.len(); + if actual == 0 || actual > MAX_HASH_LENGTH { + Err(Error::output_len_invalid(codec, actual, MAX_HASH_LENGTH)) + } else { + Ok(()) + } + } + None => Ok(()), } +} - fn finalize_into(self, buf: &mut [u8]) -> Result<(), InvalidBufferSize> { - if buf.len() != self.output_size() { - return Err(InvalidBufferSize); +/// validate the XOF output length a builder requests at build time +/// +/// The length runs through the `1..=MAX_HASH_LENGTH` policy before any +/// allocation or squeeze. +const fn validate_output_len(codec: Codec, output_len: Option) -> Result { + match output_len { + None => Err(Error::output_len_required(codec)), + Some(len) if len == 0 || len > MAX_HASH_LENGTH => { + Err(Error::output_len_invalid(codec, len, MAX_HASH_LENGTH)) } - let hash = blake3::Hasher::finalize(&self.0); - buf.copy_from_slice(hash.as_bytes()); - Ok(()) + Some(len) => Ok(len), } +} - fn finalize_into_reset(&mut self, buf: &mut [u8]) -> Result<(), InvalidBufferSize> { - if buf.len() != self.output_size() { - return Err(InvalidBufferSize); - } - let hash = blake3::Hasher::finalize(&self.0); - buf.copy_from_slice(hash.as_bytes()); - self.reset(); - Ok(()) +/// the streaming hasher state for one supported codec +/// +/// Enum dispatch keeps the hashing state concrete: every arm is `Send`, +/// `Sync`, `Clone`, and `Debug`, and hashing avoids a trait-object +/// allocation per hash. +#[derive(Clone)] +enum Hasher { + /// blake2b with a 224-bit digest + Blake2B224(blake2::Blake2b), + /// blake2b with a 256-bit digest + Blake2B256(blake2::Blake2b), + /// blake2b with a 384-bit digest + Blake2B384(blake2::Blake2b), + /// blake2b with a 512-bit digest + Blake2B512(blake2::Blake2b), + /// blake2s with a 224-bit digest + Blake2S224(blake2::Blake2s), + /// blake2s with a 256-bit digest + Blake2S256(blake2::Blake2s), + /// blake3 extendable-output function + /// + /// `blake3::Hasher` is roughly 2 KB, so its arm is boxed to keep the + /// other arms, and every [`Builder`] value, small + Blake3(Box), + /// md5 with a 128-bit digest + Md5(md5::Md5), + /// ripemd128 with a 128-bit digest + Ripemd128(ripemd::Ripemd128), + /// ripemd160 with a 160-bit digest + Ripemd160(ripemd::Ripemd160), + /// ripemd256 with a 256-bit digest + Ripemd256(ripemd::Ripemd256), + /// ripemd320 with a 320-bit digest + Ripemd320(ripemd::Ripemd320), + /// sha1 with a 160-bit digest + Sha1(sha1::Sha1), + /// sha2-224 with a 224-bit digest + Sha2224(sha2::Sha224), + /// sha2-256 with a 256-bit digest + Sha2256(sha2::Sha256), + /// sha2-384 with a 384-bit digest + Sha2384(sha2::Sha384), + /// sha2-512 with a 512-bit digest + Sha2512(sha2::Sha512), + /// sha2-512/224 with a 224-bit digest + Sha2512224(sha2::Sha512_224), + /// sha2-512/256 with a 256-bit digest + Sha2512256(sha2::Sha512_256), + /// sha3-224 with a 224-bit digest + Sha3224(sha3::Sha3_224), + /// sha3-256 with a 256-bit digest + Sha3256(sha3::Sha3_256), + /// sha3-384 with a 384-bit digest + Sha3384(sha3::Sha3_384), + /// sha3-512 with a 512-bit digest + Sha3512(sha3::Sha3_512), + /// shake128 extendable-output function + Shake128(shake::Shake128), + /// shake256 extendable-output function + Shake256(shake::Shake256), +} + +impl Hasher { + /// create the streaming hasher for a codec + /// + /// Returns `None` only for codecs outside [`HASH_CODECS`], which + /// [`Builder::new`] rejects up front. + fn new(codec: Codec) -> Option { + Some(match codec { + Codec::Blake2B224 => Self::Blake2B224(blake2::Blake2b::::new()), + Codec::Blake2B256 => Self::Blake2B256(blake2::Blake2b::::new()), + Codec::Blake2B384 => Self::Blake2B384(blake2::Blake2b::::new()), + Codec::Blake2B512 => Self::Blake2B512(blake2::Blake2b::::new()), + Codec::Blake2S224 => Self::Blake2S224(blake2::Blake2s::::new()), + Codec::Blake2S256 => Self::Blake2S256(blake2::Blake2s::::new()), + Codec::Blake3 => Self::Blake3(Box::new(blake3::Hasher::new())), + Codec::Md5 => Self::Md5(md5::Md5::new()), + Codec::Ripemd128 => Self::Ripemd128(ripemd::Ripemd128::new()), + Codec::Ripemd160 => Self::Ripemd160(ripemd::Ripemd160::new()), + Codec::Ripemd256 => Self::Ripemd256(ripemd::Ripemd256::new()), + Codec::Ripemd320 => Self::Ripemd320(ripemd::Ripemd320::new()), + Codec::Sha1 => Self::Sha1(sha1::Sha1::new()), + Codec::Sha2224 => Self::Sha2224(sha2::Sha224::new()), + Codec::Sha2256 => Self::Sha2256(sha2::Sha256::new()), + Codec::Sha2384 => Self::Sha2384(sha2::Sha384::new()), + Codec::Sha2512 => Self::Sha2512(sha2::Sha512::new()), + Codec::Sha2512224 => Self::Sha2512224(sha2::Sha512_224::new()), + Codec::Sha2512256 => Self::Sha2512256(sha2::Sha512_256::new()), + Codec::Sha3224 => Self::Sha3224(sha3::Sha3_224::new()), + Codec::Sha3256 => Self::Sha3256(sha3::Sha3_256::new()), + Codec::Sha3384 => Self::Sha3384(sha3::Sha3_384::new()), + Codec::Sha3512 => Self::Sha3512(sha3::Sha3_512::new()), + Codec::Shake128 => Self::Shake128(shake::Shake128::default()), + Codec::Shake256 => Self::Shake256(shake::Shake256::default()), + _ => return None, + }) } - fn reset(&mut self) { - self.0 = blake3::Hasher::new(); + /// feed data to the streaming hasher + fn update(&mut self, data: &[u8]) { + match self { + Self::Blake2B224(h) => Digest::update(h, data), + Self::Blake2B256(h) => Digest::update(h, data), + Self::Blake2B384(h) => Digest::update(h, data), + Self::Blake2B512(h) => Digest::update(h, data), + Self::Blake2S224(h) => Digest::update(h, data), + Self::Blake2S256(h) => Digest::update(h, data), + // the streaming calls go through the fully qualified inherent + // path: blake3 also supplies trait methods of the same names + Self::Blake3(h) => { + blake3::Hasher::update(h, data); + } + Self::Md5(h) => Digest::update(h, data), + Self::Ripemd128(h) => Digest::update(h, data), + Self::Ripemd160(h) => Digest::update(h, data), + Self::Ripemd256(h) => Digest::update(h, data), + Self::Ripemd320(h) => Digest::update(h, data), + Self::Sha1(h) => Digest::update(h, data), + Self::Sha2224(h) => Digest::update(h, data), + Self::Sha2256(h) => Digest::update(h, data), + Self::Sha2384(h) => Digest::update(h, data), + Self::Sha2512(h) => Digest::update(h, data), + Self::Sha2512224(h) => Digest::update(h, data), + Self::Sha2512256(h) => Digest::update(h, data), + Self::Sha3224(h) => Digest::update(h, data), + Self::Sha3256(h) => Digest::update(h, data), + Self::Sha3384(h) => Digest::update(h, data), + Self::Sha3512(h) => Digest::update(h, data), + // the XOF arms take `Update::update`; they do not implement + // the fixed-output `Digest` trait + Self::Shake128(h) => Update::update(h, data), + Self::Shake256(h) => Update::update(h, data), + } } - fn output_size(&self) -> usize { - blake3::OUT_LEN + /// finish the streamed hash and return the digest bytes + /// + /// Fixed-output arms ignore `output_len` and produce their policy + /// length. The XOF arms require an output length of `1..=MAX_HASH_LENGTH` + /// bytes, checked before any allocation or squeeze. + fn finalize(self, codec: Codec, output_len: Option) -> Result, Error> { + match self { + Self::Blake2B224(h) => Ok(Digest::finalize(h).to_vec()), + Self::Blake2B256(h) => Ok(Digest::finalize(h).to_vec()), + Self::Blake2B384(h) => Ok(Digest::finalize(h).to_vec()), + Self::Blake2B512(h) => Ok(Digest::finalize(h).to_vec()), + Self::Blake2S224(h) => Ok(Digest::finalize(h).to_vec()), + Self::Blake2S256(h) => Ok(Digest::finalize(h).to_vec()), + // see `update`: fully qualified inherent path for blake3 + // + // `finalize_xof` gives a fresh reader at stream offset zero, so + // the digest is always the first `out_len` bytes of the BLAKE3 + // output stream. A 32-byte request matches the classic fixed + // `blake3` digest + Self::Blake3(h) => { + let out_len = validate_output_len(codec, output_len)?; + let mut out = vec![0u8; out_len]; + blake3::Hasher::finalize_xof(&h).fill(&mut out); + Ok(out) + } + Self::Md5(h) => Ok(Digest::finalize(h).to_vec()), + Self::Ripemd128(h) => Ok(Digest::finalize(h).to_vec()), + Self::Ripemd160(h) => Ok(Digest::finalize(h).to_vec()), + Self::Ripemd256(h) => Ok(Digest::finalize(h).to_vec()), + Self::Ripemd320(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha1(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2224(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2256(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2384(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2512(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2512224(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha2512256(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha3224(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha3256(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha3384(h) => Ok(Digest::finalize(h).to_vec()), + Self::Sha3512(h) => Ok(Digest::finalize(h).to_vec()), + Self::Shake128(h) => { + let out_len = validate_output_len(codec, output_len)?; + Ok(xof_finalize(h, out_len)) + } + Self::Shake256(h) => { + let out_len = validate_output_len(codec, output_len)?; + Ok(xof_finalize(h, out_len)) + } + } } +} + +/// squeeze `out_len` XOF bytes into one pre-sized buffer +/// +/// `ExtendableOutput::finalize_xof_into` reads the first `out_len` bytes of +/// the XOF stream, so the read always starts at offset zero. +fn xof_finalize(hasher: T, out_len: usize) -> Vec { + let mut out = vec![0u8; out_len]; + ExtendableOutput::finalize_xof_into(hasher, &mut out); + out +} - fn box_clone(&self) -> Box { - Box::new(self.clone()) +impl fmt::Debug for Hasher { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let name = match self { + Self::Blake2B224(_) => "Blake2B224(..)", + Self::Blake2B256(_) => "Blake2B256(..)", + Self::Blake2B384(_) => "Blake2B384(..)", + Self::Blake2B512(_) => "Blake2B512(..)", + Self::Blake2S224(_) => "Blake2S224(..)", + Self::Blake2S256(_) => "Blake2S256(..)", + Self::Blake3(_) => "Blake3(..)", + Self::Md5(_) => "Md5(..)", + Self::Ripemd128(_) => "Ripemd128(..)", + Self::Ripemd160(_) => "Ripemd160(..)", + Self::Ripemd256(_) => "Ripemd256(..)", + Self::Ripemd320(_) => "Ripemd320(..)", + Self::Sha1(_) => "Sha1(..)", + Self::Sha2224(_) => "Sha2224(..)", + Self::Sha2256(_) => "Sha2256(..)", + Self::Sha2384(_) => "Sha2384(..)", + Self::Sha2512(_) => "Sha2512(..)", + Self::Sha2512224(_) => "Sha2512224(..)", + Self::Sha2512256(_) => "Sha2512256(..)", + Self::Sha3224(_) => "Sha3224(..)", + Self::Sha3256(_) => "Sha3256(..)", + Self::Sha3384(_) => "Sha3384(..)", + Self::Sha3512(_) => "Sha3512(..)", + Self::Shake128(_) => "Shake128(..)", + Self::Shake256(_) => "Shake256(..)", + }; + f.write_str(name) } } @@ -236,69 +615,168 @@ impl fmt::Debug for Multihash { } } -/// Hash builder that takes the codec and the data and produces a Multihash -#[derive(Clone, Debug, Default)] +/// Hash builder that takes the codec and produces a Multihash +/// +/// The builder owns streaming hash state. Feed data with +/// [`update`](Self::update) to hash it, or set a digest computed elsewhere +/// with [`with_hash`](Self::with_hash). [`try_build`](Self::try_build) +/// finalizes the streaming state, or validates the explicit digest, and +/// produces the [`Multihash`]. +/// +/// `try_build` validates the digest against the codec's output policy and +/// returns `Error::InvalidDigestLength` on a mismatch. Fixed-output codecs +/// require their exact policy length. The XOF codecs `Blake3`, `Shake128`, +/// and `Shake256` require an output length set through +/// [`output_len`](Self::output_len) and accept `1..=MAX_HASH_LENGTH` bytes. +/// A digest set with `with_hash` takes precedence over streamed data. +/// +/// # Examples +/// +/// ``` +/// use multi_hash::Builder; +/// use multi_codec::Codec; +/// +/// let mut builder = Builder::new(Codec::Sha2256).unwrap(); +/// builder.update(b"hello "); +/// builder.update(b"world"); +/// let multihash = builder.try_build().unwrap(); +/// +/// assert_eq!(multihash.as_ref().len(), 32); +/// ``` +#[derive(Clone, Debug)] pub struct Builder { + /// hash codec codec: Codec, + + /// streaming hash state, created lazily by [`update`](Self::update) + hasher: Option, + + /// explicit hash value set by [`with_hash`](Self::with_hash) hash: Option>, + + /// XOF digest output length set by [`output_len`](Self::output_len) + output_len: Option, + + /// base encoding requested through [`with_base_encoding`](Self::with_base_encoding) base_encoding: Option, } impl Builder { - /// create a hash with the given codec - #[must_use] - pub fn new(codec: Codec) -> Self { - Self { - codec, - ..Default::default() - } - } - - /// create a new builder from a hash + /// create a builder for the given codec /// /// # Errors /// /// Returns `Error::UnsupportedHash` if `codec` is not a recognized hash /// algorithm in [`HASH_CODECS`]. - pub fn new_from_bytes(codec: Codec, bytes: impl AsRef<[u8]>) -> Result { - let mut hasher: Box = match codec { - Codec::Blake2B224 => Box::new(blake2::Blake2b::::new()), - Codec::Blake2B256 => Box::new(blake2::Blake2b::::new()), - Codec::Blake2B384 => Box::new(blake2::Blake2b::::new()), - Codec::Blake2B512 => Box::new(blake2::Blake2b::::new()), - Codec::Blake2S224 => Box::new(blake2::Blake2s::::new()), - Codec::Blake2S256 => Box::new(blake2::Blake2s::::new()), - Codec::Blake3 => Box::new(Blake3DynDigest::new()), - Codec::Md5 => Box::new(md5::Md5::new()), - Codec::Ripemd128 => Box::new(ripemd::Ripemd128::new()), - Codec::Ripemd160 => Box::new(ripemd::Ripemd160::new()), - Codec::Ripemd256 => Box::new(ripemd::Ripemd256::new()), - Codec::Ripemd320 => Box::new(ripemd::Ripemd320::new()), - Codec::Sha1 => Box::new(sha1::Sha1::new()), - Codec::Sha2224 => Box::new(sha2::Sha224::new()), - Codec::Sha2256 => Box::new(sha2::Sha256::new()), - Codec::Sha2384 => Box::new(sha2::Sha384::new()), - Codec::Sha2512 => Box::new(sha2::Sha512::new()), - Codec::Sha2512224 => Box::new(sha2::Sha512_224::new()), - Codec::Sha2512256 => Box::new(sha2::Sha512_256::new()), - Codec::Sha3224 => Box::new(sha3::Sha3_224::new()), - Codec::Sha3256 => Box::new(sha3::Sha3_256::new()), - Codec::Sha3384 => Box::new(sha3::Sha3_384::new()), - Codec::Sha3512 => Box::new(sha3::Sha3_512::new()), - _ => return Err(Error::unsupported_hash(codec)), - }; - - // hash the data - hasher.update(bytes.as_ref()); - let hash = hasher.finalize().to_vec(); + /// + /// # Examples + /// + /// ``` + /// use multi_hash::{Builder, Error}; + /// use multi_codec::Codec; + /// + /// let result = Builder::new(Codec::Identity); + /// assert!(matches!(result, Err(Error::UnsupportedHash { .. }))); + /// ``` + pub fn new(codec: Codec) -> Result { + if !HASH_CODECS.contains(&codec) { + return Err(Error::unsupported_hash(codec)); + } Ok(Self { codec, - hash: Some(hash), + hasher: None, + hash: None, + output_len: None, base_encoding: None, }) } + /// feed data to the streaming hasher + /// + /// The internal hasher is created lazily on the first call. Call `update` + /// repeatedly to hash data in chunks. The streamed digest is finalized by + /// [`try_build`](Self::try_build). + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Builder; + /// use multi_codec::Codec; + /// + /// let mut builder = Builder::new(Codec::Sha2256).unwrap(); + /// builder.update(b"data chunk one"); + /// builder.update(b"data chunk two"); + /// let multihash = builder.try_build().unwrap(); + /// + /// assert_eq!(multihash.as_ref().len(), 32); + /// ``` + pub fn update(&mut self, data: impl AsRef<[u8]>) { + // `Builder::new` accepts only codecs in `HASH_CODECS`, for which + // `Hasher::new` always produces a hasher + if self.hasher.is_none() { + self.hasher = Hasher::new(self.codec); + } + debug_assert!( + self.hasher.is_some(), + "Hasher::new must cover every codec in HASH_CODECS" + ); + if let Some(hasher) = &mut self.hasher { + hasher.update(data.as_ref()); + } + } + + /// set the XOF digest output length in bytes + /// + /// The extendable-output codecs `Blake3`, `Shake128`, and `Shake256` + /// require an explicit output length. [`try_build`](Self::try_build) and + /// [`try_build_encoded`](Self::try_build_encoded) return + /// `Error::OutputLenRequired` without one. Lengths outside + /// `1..=MAX_HASH_LENGTH` return `Error::OutputLenInvalid` before any + /// allocation or squeeze. Fixed-output codecs ignore this setting and + /// always produce their exact policy length. + /// + /// # Examples + /// + /// ``` + /// use multi_hash::Builder; + /// use multi_codec::Codec; + /// + /// let mut builder = Builder::new(Codec::Blake3).unwrap(); + /// builder.update(b"hello world"); + /// builder.output_len(32); + /// let multihash = builder.try_build().unwrap(); + /// + /// assert_eq!(multihash.as_ref().len(), 32); + /// ``` + /// + /// # XOF notes + /// + /// - A 32-byte `Shake256` digest is `shake-256` at length 32, not + /// `sha3-256`: the two codecs name different algorithms with + /// different sponge rates. + /// - A completed multihash digest cannot extend. Producing a different + /// length for the same message requires a rehash through a fresh + /// builder. + /// - XOF prefix consistency: the same input produces digest bytes in + /// which a short output prefixes the long output. Two digest lengths + /// still encode as distinct multihashes, because the encoded length + /// prefix differs. + /// - Recommended minimum outputs are 32 bytes for `Blake3` and + /// `Shake128`, and 64 bytes for `Shake256`. The sponge capacity fixes + /// the security strength of the XOF, while collision resistance stays + /// bounded by the chosen output length. A `Blake3` output longer than + /// 32 bytes gives no additional collision resistance. + pub const fn output_len(&mut self, output_len: usize) { + self.output_len = Some(output_len); + } + /// set the hash data + /// + /// The digest must match the output policy of the codec: the exact + /// output size for fixed-output codecs, or `1..=MAX_HASH_LENGTH` bytes + /// for the XOF codecs. [`try_build`](Self::try_build) validates it. A + /// digest set with `with_hash` takes precedence over data streamed with + /// `update`, and over an `output_len` setting. #[must_use] pub fn with_hash(mut self, hash: impl Into>) -> Self { self.hash = Some(hash.into()); @@ -306,6 +784,8 @@ impl Builder { } /// set the base encoding codec + /// + /// The encoding applies to [`try_build_encoded`](Self::try_build_encoded). #[must_use] pub const fn with_base_encoding(mut self, base: Base) -> Self { self.base_encoding = Some(base); @@ -316,32 +796,110 @@ impl Builder { /// /// # Errors /// - /// Returns `Error::MissingHash` if no hash data was set on the builder. - pub fn try_build_encoded(&self) -> Result { + /// Returns the errors of [`try_build`](Self::try_build). + pub fn try_build_encoded(self) -> Result { + let Self { + codec, + hasher, + hash, + output_len, + base_encoding, + } = self; + let mh = build_multihash(codec, hasher, hash, output_len)?; Ok(BaseEncoded::new( - self.base_encoding - .unwrap_or_else(Multihash::preferred_encoding), - self.try_build()?, + base_encoding.unwrap_or_else(Multihash::preferred_encoding), + mh, )) } - /// build the multihash by hashing the provided data + /// build the multihash + /// + /// A digest set with [`with_hash`](Self::with_hash) takes precedence over + /// data streamed with [`update`](Self::update). The digest runs through + /// the codec's output policy: fixed-output codecs require their exact + /// policy length, and the XOF codecs accept `1..=MAX_HASH_LENGTH` bytes. /// /// # Errors /// - /// Returns `Error::MissingHash` if no hash data was set on the builder. - pub fn try_build(&self) -> Result { - Ok(Multihash { - codec: self.codec, - hash: self.hash.clone().ok_or(Error::MissingHash)?, - }) + /// Returns `Error::MissingHash` if no hash was set and no data was + /// streamed. Returns `Error::InvalidDigestLength` if a fixed-output + /// digest length does not match the codec's policy. For the XOF codecs, + /// returns `Error::OutputLenRequired` when no output length was set for + /// streamed data, and `Error::OutputLenInvalid` for an output length + /// outside `1..=MAX_HASH_LENGTH`. + pub fn try_build(self) -> Result { + let Self { + codec, + hasher, + hash, + output_len, + .. + } = self; + build_multihash(codec, hasher, hash, output_len) } } +/// finish hash state into a validated multihash +/// +/// An explicit digest takes precedence over the streaming hasher. A missing +/// digest fails with `Error::MissingHash`. The digest then runs through the +/// codec's output policy. +fn build_multihash( + codec: Codec, + hasher: Option, + hash: Option>, + output_len: Option, +) -> Result { + let hash = match hash { + Some(hash) => hash, + None => match hasher { + Some(hasher) => hasher.finalize(codec, output_len)?, + None => return Err(Error::MissingHash), + }, + }; + validate_digest_length(codec, &hash)?; + Ok(Multihash { codec, hash }) +} + #[cfg(test)] mod tests { use super::*; + /// the output length these tests request for an XOF codec + /// + /// Fixed-output codecs ignore an output length, so the helper applies + /// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. + fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } + } + + /// build a multihash of `data` with `codec`, streaming the data + fn streamed_multihash(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() + } + + /// build a base encoded multihash of `data` with `codec` + fn streamed_encoded(codec: Codec, base: Base, data: &[u8]) -> EncodedMultihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder + .with_base_encoding(base) + .try_build_encoded() + .unwrap() + } + #[test] fn test_matrix() { let hashers = vec![ @@ -368,6 +926,8 @@ mod tests { Codec::Sha3256, Codec::Sha3384, Codec::Sha3512, + Codec::Shake128, + Codec::Shake256, ]; let bases = vec![ @@ -397,12 +957,7 @@ mod tests { for h in &hashers { for b in &bases { - let mh1 = Builder::new_from_bytes(*h, b"for great justice, move every zig!") - .unwrap() - .with_base_encoding(*b) - .try_build_encoded() - .unwrap(); - //println!("{:?}", mh1); + let mh1 = streamed_encoded(*h, *b, b"for great justice, move every zig!"); let s = mh1.to_string(); assert_eq!(mh1, EncodedMultihash::try_from(s.as_str()).unwrap()); } @@ -411,10 +966,7 @@ mod tests { #[test] fn test_binary_roundtrip() { - let mh1 = Builder::new_from_bytes(Codec::Sha3384, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha3384, b"for great justice, move every zig!"); let v: Vec = mh1.clone().into(); let mh2 = Multihash::try_from(v.as_ref()).unwrap(); assert_eq!(mh1, mh2); @@ -422,11 +974,11 @@ mod tests { #[test] fn test_encoded() { - let mh = Builder::new_from_bytes(Codec::Sha3256, b"for great justice, move every zig!") - .unwrap() - .with_base_encoding(Base::Base58Btc) - .try_build_encoded() - .unwrap(); + let mh = streamed_encoded( + Codec::Sha3256, + Base::Base58Btc, + b"for great justice, move every zig!", + ); let s = mh.to_string(); println!("{mh:?}"); println!("{s}"); @@ -435,10 +987,7 @@ mod tests { #[test] fn test_matching() { - let mh1 = Builder::new_from_bytes(Codec::Sha3256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha3256, b"for great justice, move every zig!"); let mh2 = Multihash::try_from( hex::decode("16206b761d3b2e7675e088e337a82207b55711d3957efdb877a3d261b0ca2c38e201") .unwrap() @@ -471,11 +1020,7 @@ mod tests { ]; for (b, h) in bases { - let mh = Builder::new_from_bytes(Codec::Sha1, b"multihash") - .unwrap() - .with_base_encoding(b) - .try_build_encoded() - .unwrap(); + let mh = streamed_encoded(Codec::Sha1, b, b"multihash"); let s = mh.to_string(); assert_eq!(h, s.as_str()); } @@ -504,11 +1049,7 @@ mod tests { ]; for (b, h) in bases { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"multihash") - .unwrap() - .with_base_encoding(b) - .try_build_encoded() - .unwrap(); + let mh = streamed_encoded(Codec::Sha2256, b, b"multihash"); let s = mh.to_string(); assert_eq!(h, s.as_str()); } @@ -518,15 +1059,9 @@ mod tests { fn test_multihash_in_indexmap() { let mut map = std::collections::HashMap::new(); - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha2256, b"for great justice, move every zig!"); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"for great justice, move every zag!") - .unwrap() - .try_build() - .unwrap(); + let mh2 = streamed_multihash(Codec::Sha2256, b"for great justice, move every zag!"); map.insert(mh1, "zig"); map.insert(mh2, "zag"); @@ -536,42 +1071,24 @@ mod tests { #[test] fn test_ct_eq_equal() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha2256, b"hello"); + let mh2 = streamed_multihash(Codec::Sha2256, b"hello"); assert_eq!(mh1.ct_eq(&mh2).unwrap_u8(), 1); } #[test] fn test_ct_eq_unequal_hash() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"world") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha2256, b"hello"); + let mh2 = streamed_multihash(Codec::Sha2256, b"world"); assert_eq!(mh1.ct_eq(&mh2).unwrap_u8(), 0); } #[test] fn test_ct_eq_unequal_codec() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha2256, b"hello"); + let mh2 = streamed_multihash(Codec::Sha2256, b"hello"); // same hash bytes, different codec let mh3 = Multihash { codec: Codec::Sha2512, @@ -584,10 +1101,7 @@ mod tests { #[test] fn test_ct_eq_unequal_length() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"hello") - .unwrap() - .try_build() - .unwrap(); + let mh1 = streamed_multihash(Codec::Sha2256, b"hello"); // same codec, different length hash let mh2 = Multihash { codec: mh1.codec, @@ -596,4 +1110,423 @@ mod tests { assert_eq!(mh1.ct_eq(&mh2).unwrap_u8(), 0); } + + /// builder rejects codecs outside `HASH_CODECS` + #[test] + fn test_builder_new_unsupported_codec() { + for &codec in &[Codec::Identity, Codec::DagCbor, Codec::Multihash] { + let result = Builder::new(codec); + assert!( + matches!(result, Err(Error::UnsupportedHash { .. })), + "codec {codec:?} was accepted" + ); + } + } + + /// a builder without streamed data and without an explicit digest fails + /// at build time + #[test] + fn test_builder_missing_hash_state() { + let result = Builder::new(Codec::Sha2256).unwrap().try_build(); + assert!(matches!(result, Err(Error::MissingHash))); + } + + /// streaming feeds data through the lazy hasher + #[test] + fn test_builder_update_streaming() { + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + builder.update(b"multi"); + builder.update(b"hash"); + let mh = builder.try_build().unwrap(); + // sha2-256 of "multihash": digest bytes from the multiformats + // example vector in `test_multihash_sha2_256` (after the multibase + // prefix `f`, codec `12`, and length `20`) + assert_eq!( + hex::encode(mh.as_ref()), + "9cbc07c3f991725836a3aa2a581ca2029198aa420b9d99bc0e131d9f3e2cbe47" + ); + } + + /// streaming in chunks equals hashing the whole input at once + #[test] + fn test_builder_update_chunks_match_whole() { + let data = b"for great justice, move every zig!"; + + let mut chunked = Builder::new(Codec::Sha2256).unwrap(); + chunked.update(&data[..7]); + chunked.update(&data[7..20]); + chunked.update(&data[20..]); + let chunked = chunked.try_build().unwrap(); + + let whole = streamed_multihash(Codec::Sha2256, data); + + assert_eq!(chunked, whole); + } + + /// an exact-length digest is accepted and used as-is + #[test] + fn test_builder_with_hash_exact_length() { + let hash = vec![7u8; 32]; + let mh = Builder::new(Codec::Sha2256) + .unwrap() + .with_hash(hash.clone()) + .try_build() + .unwrap(); + assert_eq!(mh.codec(), Codec::Sha2256); + assert_eq!(mh.as_ref(), hash.as_slice()); + } + + /// a wrong-length digest is rejected for a fixed-output codec, and an + /// out-of-policy digest is rejected for an XOF codec + #[test] + fn test_builder_with_hash_wrong_length() { + for &codec in &HASH_CODECS { + match output_policy(codec) { + Some(OutputPolicy::Fixed(expected)) => { + let result = Builder::new(codec) + .unwrap() + .with_hash(vec![0u8; expected + 1]) + .try_build(); + assert!( + matches!( + result, + Err(Error::InvalidDigestLength { + expected: e, + actual: a, + .. + }) if e == expected && a == expected + 1 + ), + "codec {codec:?} accepted a wrong-length digest" + ); + } + Some(OutputPolicy::Xof) => { + let result = Builder::new(codec) + .unwrap() + .with_hash(Vec::new()) + .try_build(); + assert!( + matches!(result, Err(Error::OutputLenInvalid { output_len: 0, .. })), + "codec {codec:?} accepted an empty XOF digest" + ); + } + None => panic!("codec {codec:?} is in HASH_CODECS but has no policy"), + } + } + } + + /// a digest set with `with_hash` takes precedence over streamed data + #[test] + fn test_with_hash_precedence() { + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + builder.update(b"streamed data"); + let explicit = vec![9u8; 32]; + let mh = builder.with_hash(explicit.clone()).try_build().unwrap(); + assert_eq!(mh.as_ref(), explicit.as_slice()); + } + + /// a wrong-length explicit digest fails even with valid streamed data + #[test] + fn test_with_hash_precedence_validates_length() { + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + builder.update(b"streamed data"); + let result = builder.with_hash(vec![0u8; 31]).try_build(); + assert!(matches!(result, Err(Error::InvalidDigestLength { .. }))); + } + + /// every codec streams to its output policy length, and an explicit + /// digest of that length is accepted + #[test] + fn test_builder_stream_policy_lengths() { + for &codec in &HASH_CODECS { + match output_policy(codec) { + Some(OutputPolicy::Xof) => { + let output_len = xof_output_len(codec).unwrap(); + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"digest policy lengths"); + builder.output_len(output_len); + let mh = builder.try_build().unwrap(); + assert_eq!(mh.as_ref().len(), output_len, "codec {codec:?}"); + + let mh = Builder::new(codec) + .unwrap() + .with_hash(vec![0u8; output_len]) + .try_build() + .unwrap(); + assert_eq!(mh.as_ref().len(), output_len, "codec {codec:?}"); + } + Some(OutputPolicy::Fixed(expected)) => { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"digest policy lengths"); + let mh = builder.try_build().unwrap(); + assert_eq!(mh.as_ref().len(), expected, "codec {codec:?}"); + + let mh = Builder::new(codec) + .unwrap() + .with_hash(vec![0u8; expected]) + .try_build() + .unwrap(); + assert_eq!(mh.as_ref().len(), expected, "codec {codec:?}"); + } + None => panic!("codec {codec:?} is in HASH_CODECS but has no policy"), + } + } + } + + /// `try_build` consumes the builder; a clone keeps building independently + #[test] + fn test_try_build_consumes_builder() { + for &codec in &[Codec::Sha2256, Codec::Blake3, Codec::Sha3384] { + let mut original = Builder::new(codec).unwrap(); + original.update(b"consumed"); + if let Some(output_len) = xof_output_len(codec) { + original.output_len(output_len); + } + let snapshot = original.clone(); + let mh1 = original.try_build().unwrap(); + let mh2 = snapshot.try_build().unwrap(); + assert_eq!(mh1, mh2, "codec {codec:?}"); + } + } + + /// `try_build_encoded` consumes the builder too + #[test] + fn test_try_build_encoded_consumes_builder() { + let mut builder = Builder::new(Codec::Sha3256).unwrap(); + builder.update(b"encoded consume"); + let mh = builder + .with_base_encoding(Base::Base58Btc) + .try_build_encoded() + .unwrap(); + let s = mh.to_string(); + assert_eq!(mh, EncodedMultihash::try_from(s.as_str()).unwrap()); + } + + /// a builder with live hashing state stays `Send`, `Sync`, `Clone`, and + /// `Debug`, and its cloned state builds the same digest + #[test] + fn test_builder_send_sync_streaming() { + fn assert_send() {} + fn assert_sync() {} + fn assert_clone() {} + fn assert_debug() {} + + assert_send::(); + assert_sync::(); + assert_clone::(); + assert_debug::(); + + let mut builder = Builder::new(Codec::Sha3256).unwrap(); + builder.update(b"sent across threads"); + let handle = std::thread::spawn(move || { + builder.update(b" and more"); + builder.try_build().unwrap() + }); + let mh = handle.join().unwrap(); + assert_eq!(mh.codec(), Codec::Sha3256); + assert_eq!(mh.as_ref().len(), 32); + } + + /// builder debug output names the codec and the active hasher without + /// exposing hash state + #[test] + fn test_builder_debug() { + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + let codec_debug = format!("{:?}", Codec::Sha2256); + let debug_idle = format!("{builder:?}"); + assert!(debug_idle.contains(&codec_debug), "idle: {debug_idle}"); + assert!(debug_idle.contains("None"), "idle: {debug_idle}"); + + builder.update(b"debug"); + let debug_live = format!("{builder:?}"); + assert!(debug_live.contains(&codec_debug), "live: {debug_live}"); + assert!( + debug_live.contains("Some(Sha2256(..))"), + "live: {debug_live}" + ); + } + + /// decode stays format-validity only: a digest of the wrong length still + /// decodes outside the builder + #[test] + fn test_decode_ignores_digest_policy() { + let mut bytes = vec![0x12u8, 0x10]; + bytes.extend_from_slice(&[0u8; 16]); + let mh = Multihash::try_from(bytes.as_ref()).unwrap(); + assert_eq!(mh.codec(), Codec::Sha2256); + assert_eq!(mh.as_ref().len(), 16); + } + + /// the codec constants list 25 supported and 10 safe codecs, and the + /// XOF output cap is the 16 MiB value that matches the `Varbytes` + /// decode cap in `multi-util` + #[test] + fn test_codec_constant_lengths() { + assert_eq!(HASH_CODECS.len(), 25); + assert_eq!(SAFE_HASH_CODECS.len(), 10); + assert_eq!(MAX_HASH_LENGTH, 16 * 1024 * 1024); + assert_eq!(HASH_CODECS[23], Codec::Shake128); + assert_eq!(HASH_CODECS[24], Codec::Shake256); + assert_eq!(SAFE_HASH_CODECS[8], Codec::Shake128); + assert_eq!(SAFE_HASH_CODECS[9], Codec::Shake256); + } + + /// a streamed XOF build without an output length fails with + /// `OutputLenRequired` + #[test] + fn test_xof_output_len_required() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"missing output length"); + let result = builder.try_build(); + assert!( + matches!(result, Err(Error::OutputLenRequired { .. })), + "codec {codec:?} built without an output length" + ); + + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"encoded without an output length"); + let result = builder.try_build_encoded(); + assert!( + matches!(result, Err(Error::OutputLenRequired { .. })), + "codec {codec:?} encoded without an output length" + ); + } + } + + /// a zero or over-limit XOF output length fails with `OutputLenInvalid` + /// before any allocation or squeeze + #[test] + fn test_xof_output_len_invalid() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + for &output_len in &[0, MAX_HASH_LENGTH + 1] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"bad output length"); + builder.output_len(output_len); + let result = builder.try_build(); + assert!( + matches!( + result, + Err(Error::OutputLenInvalid { + output_len: requested, + max, + .. + }) if requested == output_len && max == MAX_HASH_LENGTH + ), + "codec {codec:?} accepted output length {output_len}" + ); + } + } + } + + /// the minimum XOF output length of one byte builds + #[test] + fn test_xof_output_len_minimum() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"one byte output"); + builder.output_len(1); + let mh = builder.try_build().unwrap(); + assert_eq!(mh.codec(), codec); + assert_eq!(mh.as_ref().len(), 1); + } + } + + /// fixed-output codecs ignore an `output_len` setting + #[test] + fn test_fixed_output_ignores_output_len() { + for &output_len in &[0, 33, MAX_HASH_LENGTH + 1] { + let mut builder = Builder::new(Codec::Sha2256).unwrap(); + builder.update(b"ignored output length"); + builder.output_len(output_len); + let mh = builder.try_build().unwrap(); + assert_eq!(mh.codec(), Codec::Sha2256); + assert_eq!(mh.as_ref().len(), 32); + } + + let result = Builder::new(Codec::Sha2256) + .unwrap() + .with_hash(vec![0u8; 32]) + .try_build(); + assert!(result.is_ok()); + } + + /// XOF prefix consistency: a short streamed digest prefixes the long + /// digest of the same input, while the two multihashes still encode + /// differently + #[test] + fn test_xof_prefix_consistency() { + for (codec, short_len) in [ + (Codec::Blake3, 32usize), + (Codec::Shake128, 32usize), + (Codec::Shake256, 64usize), + ] { + let data = b"prefix consistency"; + + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + builder.output_len(short_len); + let short = builder.try_build().unwrap(); + + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + builder.output_len(short_len * 2); + let long = builder.try_build().unwrap(); + + assert_eq!(short.codec(), long.codec()); + assert_eq!(short.as_ref().len(), short_len); + assert_eq!(long.as_ref().len(), short_len * 2); + assert!( + long.as_ref().starts_with(short.as_ref()), + "codec {codec:?}: short digest is not a prefix of long digest" + ); + + let short_bytes: Vec = short.clone().into(); + let long_bytes: Vec = long.clone().into(); + assert_ne!( + short_bytes, long_bytes, + "codec {codec:?}: digest encodings matched across lengths" + ); + } + } + + /// a builder streaming an XOF stays `Send` and `Sync`, and the digest + /// built across a thread matches the one-shot digest + #[test] + fn test_xof_send_sync_streaming() { + fn assert_send() {} + fn assert_sync() {} + + assert_send::(); + assert_sync::(); + + let mut builder = Builder::new(Codec::Shake128).unwrap(); + builder.update(b"sent across threads"); + builder.output_len(32); + let handle = std::thread::spawn(move || { + builder.update(b" and more"); + builder.try_build().unwrap() + }); + let streamed = handle.join().unwrap(); + + let mut one_shot = Builder::new(Codec::Shake128).unwrap(); + one_shot.update(b"sent across threads and more"); + one_shot.output_len(32); + let one_shot = one_shot.try_build().unwrap(); + + assert_eq!(streamed, one_shot); + } + + /// an explicit XOF digest inside the `1..=MAX_HASH_LENGTH` policy + /// builds, and the digest takes precedence over an `output_len` + /// setting + #[test] + fn test_xof_with_hash_policy() { + let hash = vec![3u8; 8]; + let mut builder = Builder::new(Codec::Shake256).unwrap(); + builder.update(b"streamed but ignored"); + builder.output_len(64); + let mh = builder.with_hash(hash.clone()).try_build().unwrap(); + assert_eq!(mh.codec(), Codec::Shake256); + assert_eq!(mh.as_ref(), hash.as_slice()); + } } diff --git a/src/serde/mod.rs b/src/serde/mod.rs index cda00fb..16bebd7 100644 --- a/src/serde/mod.rs +++ b/src/serde/mod.rs @@ -9,6 +9,13 @@ mod tests { use multi_trait::Null; use serde_test::{Configure, Token, assert_tokens}; + /// build a multihash of `data` with `codec`, streaming the data + fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + builder.try_build().unwrap() + } + /// Serialize a value to CBOR bytes using `ciborium` (replaces the /// unmaintained `serde_cbor` dev-dependency). fn cbor_to_vec(value: &T) -> Vec { @@ -19,10 +26,7 @@ mod tests { #[test] fn test_serde_compact() { - let mh = Builder::new_from_bytes(Codec::Blake2S256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Blake2S256, b"for great justice, move every zig!"); assert_tokens( &mh.compact(), // convert to Tagged @@ -37,8 +41,9 @@ mod tests { #[test] fn test_serde_encoded_string() { - let mh = Builder::new_from_bytes(Codec::Blake2S256, b"for great justice, move every zig!") - .unwrap() + let mut builder = Builder::new(Codec::Blake2S256).unwrap(); + builder.update(b"for great justice, move every zig!"); + let mh = builder .with_base_encoding(Base::Base58Btc) .try_build_encoded() .unwrap(); @@ -53,10 +58,7 @@ mod tests { #[test] fn test_serde_string() { - let mh = Builder::new_from_bytes(Codec::Blake2S256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Blake2S256, b"for great justice, move every zig!"); assert_tokens( &mh.readable(), &[ @@ -77,10 +79,7 @@ mod tests { #[test] fn test_serde_json() { - let mh1 = Builder::new_from_bytes(Codec::Blake2S256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Blake2S256, b"for great justice, move every zig!"); let s = serde_json::to_string(&mh1).unwrap(); assert_eq!(s, "{\"codec\":\"blake2s-256\",\"hash\":\"f20642203125d59e8b93edb676fc78de9c587cf52ccc6f219032da1f377082332b0\"}".to_string()); let mh2: Multihash = serde_json::from_str(&s).unwrap(); @@ -89,10 +88,7 @@ mod tests { #[test] fn test_serde_cbor() { - let mh1 = Builder::new_from_bytes(Codec::Blake2S256, b"for great justice, move every zig!") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Blake2S256, b"for great justice, move every zig!"); let v = cbor_to_vec(&mh1); // Note: ciborium may encode differently than serde_cbor, so we verify // round-trip instead of exact byte output. diff --git a/src/types.rs b/src/types.rs index 756956e..03665e8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -66,7 +66,7 @@ impl HashDigest { /// assert_eq!(digest.len(), 32); /// ``` #[must_use] - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.0.len() } @@ -84,7 +84,7 @@ impl HashDigest { /// assert!(!digest.is_empty()); /// ``` #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.0.is_empty() } diff --git a/tests/edge_case_tests.rs b/tests/edge_case_tests.rs index 41f2d9e..761b26f 100644 --- a/tests/edge_case_tests.rs +++ b/tests/edge_case_tests.rs @@ -6,14 +6,41 @@ use multi_hash::{Builder, Error, HASH_CODECS, Multihash, SAFE_HASH_CODECS}; use multi_trait::{Null, TryDecodeFrom}; use multi_util::CodecInfo; +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// build a multihash of `data` with `codec`, streaming the data +fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + /// Test all supported hash algorithms with empty input #[test] fn test_all_algorithms_empty_input() { for &codec in &HASH_CODECS { - let result = Builder::new_from_bytes(codec, []); + let result = Builder::new(codec); assert!(result.is_ok(), "Failed for {codec:?}"); - let mh = result.unwrap().try_build().unwrap(); + let mut builder = result.unwrap(); + builder.update([]); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + let mh = builder.try_build().unwrap(); assert_eq!(mh.codec(), codec); } } @@ -22,12 +49,9 @@ fn test_all_algorithms_empty_input() { #[test] fn test_all_algorithms_single_byte() { for &codec in &HASH_CODECS { - let mh = Builder::new_from_bytes(codec, [0x42]) - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(codec, &[0x42]); assert_eq!(mh.codec(), codec); - assert!(!mh.as_ref().is_empty()); + assert_ne!(mh.as_ref(), []); } } @@ -46,7 +70,7 @@ fn test_null_multihash() { /// Test builder without hash data fails #[test] fn test_builder_missing_hash() { - let result = Builder::new(Codec::Sha2256).try_build(); + let result = Builder::new(Codec::Sha2256).unwrap().try_build(); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::MissingHash)); } @@ -54,7 +78,7 @@ fn test_builder_missing_hash() { /// Test unsupported hash algorithm #[test] fn test_unsupported_algorithm() { - let result = Builder::new_from_bytes(Codec::Identity, b"data"); + let result = Builder::new(Codec::Identity); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::UnsupportedHash { .. })); } @@ -63,10 +87,7 @@ fn test_unsupported_algorithm() { #[test] fn test_large_data() { let large_data = vec![0u8; 1024 * 1024]; // 1MB - let mh = Builder::new_from_bytes(Codec::Sha2256, &large_data) - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, &large_data); assert_eq!(mh.codec(), Codec::Sha2256); assert_eq!(mh.as_ref().len(), 32); // SHA2-256 always outputs 32 bytes } @@ -77,10 +98,7 @@ fn test_binary_roundtrip_all_algorithms() { let data = b"test data"; for &codec in &HASH_CODECS { - let mh1 = Builder::new_from_bytes(codec, data) - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(codec, data); let bytes: Vec = mh1.clone().into(); let mh2 = Multihash::try_from(bytes.as_ref()).unwrap(); @@ -104,10 +122,7 @@ fn test_safe_codecs_subset() { /// Test multihash equality #[test] fn test_multihash_equality() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"data") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, b"data"); let mh2 = mh1.clone(); assert_eq!(mh1, mh2); @@ -117,14 +132,8 @@ fn test_multihash_equality() { /// Test multihash ordering #[test] fn test_multihash_ordering() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"aaa") - .unwrap() - .try_build() - .unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"bbb") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, b"aaa"); + let mh2 = hash_bytes(Codec::Sha2256, b"bbb"); // Different data produces different hashes, so ordering is meaningful assert_ne!(mh1, mh2); @@ -133,10 +142,7 @@ fn test_multihash_ordering() { /// Test `AsRef` implementation #[test] fn test_as_ref() { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"test") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, b"test"); let bytes: &[u8] = mh.as_ref(); assert_eq!(bytes.len(), 32); // SHA2-256 output size @@ -145,36 +151,31 @@ fn test_as_ref() { /// Test Debug formatting #[test] fn test_debug_format() { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"test") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, b"test"); let debug_str = format!("{mh:?}"); - assert!(!debug_str.is_empty()); + assert_ne!(debug_str, ""); assert!(debug_str.len() > 10); } /// Test builder with manual hash setting #[test] fn test_builder_with_hash() { - let hash_bytes = vec![0u8; 32]; + let hash = vec![0u8; 32]; let mh = Builder::new(Codec::Sha2256) - .with_hash(hash_bytes.clone()) + .unwrap() + .with_hash(hash.clone()) .try_build() .unwrap(); assert_eq!(mh.codec(), Codec::Sha2256); - assert_eq!(mh.as_ref(), hash_bytes.as_slice()); + assert_eq!(mh.as_ref(), hash.as_slice()); } /// Test that Clone works correctly #[test] fn test_clone() { - let mh1 = Builder::new_from_bytes(Codec::Sha3256, b"clone test") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha3256, b"clone test"); let mh2 = mh1.clone(); assert_eq!(mh1, mh2); @@ -185,10 +186,7 @@ fn test_clone() { /// Test `TryDecodeFrom` with trailing data #[test] fn test_decode_with_trailing_data() { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"test") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, b"test"); let mut bytes: Vec = mh1.clone().into(); bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); @@ -201,10 +199,7 @@ fn test_decode_with_trailing_data() { /// Test that truncated data fails gracefully #[test] fn test_truncated_data() { - let mh = Builder::new_from_bytes(Codec::Sha2256, b"test") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, b"test"); let bytes: Vec = mh.into(); diff --git a/tests/fips_tests.rs b/tests/fips_tests.rs new file mode 100644 index 0000000..35402c1 --- /dev/null +++ b/tests/fips_tests.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +//! FIPS approved codec tests for multi-hash +//! +//! With the `fips` feature enabled, the crate exposes the `FIPS_CODECS` +//! and `SAFE_FIPS_CODECS` constants covering the hash algorithms on the +//! NIST FIPS approved lists. These tests pin the shape of both lists, +//! keep them contained in the general codec lists, and build a multihash +//! for every approved codec through the builder, checking the digests +//! of a sample against independently generated known-answer values. +#![cfg(feature = "fips")] + +use multi_codec::Codec; +use multi_hash::{Builder, FIPS_CODECS, HASH_CODECS, Multihash, SAFE_FIPS_CODECS}; +use multi_util::CodecInfo; + +/// the FIPS approved codec entries the safe FIPS list omits +/// +/// SHA-1 is restricted to verification-only use under SP 800-131A Rev. 2, +/// and the 224-bit hashes SHA-224, SHA-512/224, and SHA3-224 fall under the +/// SP 800-131A Rev. 3 draft restrictions through 2030. SHA-512/256 does not +/// fall under those restrictions, but the safe list omits it under the same +/// reduced-selection principle that `SAFE_HASH_CODECS` applies. +/// `SAFE_FIPS_CODECS` must omit exactly these entries. +const RESTRICTED_FIPS_CODECS: [Codec; 5] = [ + Codec::Sha1, + Codec::Sha2224, + Codec::Sha2512224, + Codec::Sha2512256, + Codec::Sha3224, +]; + +/// the message the known-answer vectors hash +const KAT_MESSAGE: &[u8] = b"multihash"; + +/// sha1 digest of `KAT_MESSAGE`, per `coreutils sha1sum` and +/// `openssl dgst -sha1` +const SHA1_KAT: [u8; 20] = [ + 0x88, 0xc2, 0xf1, 0x1f, 0xb2, 0xce, 0x39, 0x2a, 0xcb, 0x5b, 0x29, 0x86, 0xe6, 0x40, 0x21, 0x1c, + 0x46, 0x90, 0x07, 0x3e, +]; + +/// sha2-256 digest of `KAT_MESSAGE`, the multiformats example vector, also +/// matching `coreutils sha256sum` and `openssl dgst -sha256` +const SHA2_256_KAT: [u8; 32] = [ + 0x9c, 0xbc, 0x07, 0xc3, 0xf9, 0x91, 0x72, 0x58, 0x36, 0xa3, 0xaa, 0x2a, 0x58, 0x1c, 0xa2, 0x02, + 0x91, 0x98, 0xaa, 0x42, 0x0b, 0x9d, 0x99, 0xbc, 0x0e, 0x13, 0x1d, 0x9f, 0x3e, 0x2c, 0xbe, 0x47, +]; + +/// sha3-256 digest of `KAT_MESSAGE`, per `openssl dgst -sha3-256` and GNU +/// coreutils `sha3sum` +const SHA3_256_KAT: [u8; 32] = [ + 0x08, 0xc3, 0x79, 0x2b, 0x2a, 0x4d, 0xee, 0xd1, 0xbd, 0x7e, 0xa2, 0x32, 0x8f, 0xb5, 0xde, 0x55, + 0x31, 0xec, 0xcf, 0x0f, 0xbf, 0xa0, 0x4a, 0x7d, 0x80, 0x0c, 0xdc, 0x26, 0x71, 0x37, 0xc6, 0x35, +]; + +/// shake128 of `KAT_MESSAGE` in 32 bytes, per `openssl dgst -shake128 +/// -xoflen 32` +const SHAKE128_KAT: [u8; 32] = [ + 0xd3, 0x70, 0x45, 0x66, 0x3a, 0x07, 0xfb, 0x35, 0xec, 0x57, 0x1d, 0x8f, 0x6e, 0xf9, 0x83, 0x00, + 0xa2, 0xda, 0xa5, 0xa8, 0x2d, 0x9d, 0x05, 0x5e, 0x68, 0x4b, 0xc2, 0x92, 0xe9, 0x8a, 0x02, 0xa3, +]; + +/// one known-answer vector: the codec and the digest it must produce +struct KatVector { + /// the FIPS codec the vector applies to + codec: Codec, + + /// the expected digest, digest bytes only, with no multihash coding + expected: &'static [u8], +} + +/// known-answer vectors for one codec from each FIPS family +/// +/// Each vector hashes [`KAT_MESSAGE`]: SHA-1 for the SHA-1 family, SHA-256 +/// for the SHA-2 family, SHA3-256 for the SHA-3 family, and SHAKE128 for +/// the SHAKE extendable-output functions. +const KAT_VECTORS: [KatVector; 4] = [ + KatVector { + codec: Codec::Sha1, + expected: &SHA1_KAT, + }, + KatVector { + codec: Codec::Sha2256, + expected: &SHA2_256_KAT, + }, + KatVector { + codec: Codec::Sha3256, + expected: &SHA3_256_KAT, + }, + KatVector { + codec: Codec::Shake128, + expected: &SHAKE128_KAT, + }, +]; + +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the Shake arms. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// the digest length each FIPS codec must produce +/// +/// The fixed-output entries are the algorithms' exact output lengths, and +/// the Shake entries are the recommended minimum outputs, 32 bytes for +/// `Shake128` and 64 bytes for `Shake256`. Returns `None` for codecs +/// outside `FIPS_CODECS`. +const fn expected_digest_len(codec: Codec) -> Option { + match codec { + Codec::Sha1 => Some(20), + Codec::Sha2224 | Codec::Sha2512224 | Codec::Sha3224 => Some(28), + Codec::Sha2256 | Codec::Sha2512256 | Codec::Sha3256 | Codec::Shake128 => Some(32), + Codec::Sha2384 | Codec::Sha3384 => Some(48), + Codec::Sha2512 | Codec::Sha3512 | Codec::Shake256 => Some(64), + _ => None, + } +} + +/// stream `message` through the builder in chunks of at most three bytes, +/// request the recommended output length for an XOF codec, and build the +/// multihash +fn streamed_multihash(codec: Codec, message: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + for chunk in message.chunks(3) { + builder.update(chunk); + } + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + +/// the full FIPS list holds 13 codecs and the safe list holds 8, with no +/// duplicate entries in either constant +#[test] +fn test_fips_constant_lengths() { + assert_eq!(FIPS_CODECS.len(), 13); + assert_eq!(SAFE_FIPS_CODECS.len(), 8); + + for (index, codec) in FIPS_CODECS.iter().enumerate() { + assert!( + !FIPS_CODECS[..index].contains(codec), + "{codec:?} appears more than once in FIPS_CODECS" + ); + } + for (index, codec) in SAFE_FIPS_CODECS.iter().enumerate() { + assert!( + !SAFE_FIPS_CODECS[..index].contains(codec), + "{codec:?} appears more than once in SAFE_FIPS_CODECS" + ); + } +} + +/// every entry of the full FIPS list is a supported hash codec +#[test] +fn test_fips_codecs_are_supported_hash_codecs() { + for &codec in &FIPS_CODECS { + assert!( + HASH_CODECS.contains(&codec), + "{codec:?} is missing from HASH_CODECS" + ); + } +} + +/// every entry of the safe FIPS list is a full-list FIPS codec +#[test] +fn test_safe_fips_codecs_are_fips_codecs() { + for &codec in &SAFE_FIPS_CODECS { + assert!( + FIPS_CODECS.contains(&codec), + "{codec:?} is missing from FIPS_CODECS" + ); + } +} + +/// the safe FIPS list omits exactly the restricted and deprecated entries +#[test] +fn test_safe_list_excludes_restricted_and_deprecated_codecs() { + for &codec in &RESTRICTED_FIPS_CODECS { + assert!( + FIPS_CODECS.contains(&codec), + "{codec:?} is missing from FIPS_CODECS" + ); + assert!( + !SAFE_FIPS_CODECS.contains(&codec), + "{codec:?} must not appear in SAFE_FIPS_CODECS" + ); + } + + // with the length test pinning 13 and 8 entries, this complement + // check makes `SAFE_FIPS_CODECS` the exact remainder of + // `FIPS_CODECS` once the restricted and deprecated entries are + // removed + for &codec in &FIPS_CODECS { + assert_eq!( + SAFE_FIPS_CODECS.contains(&codec), + !RESTRICTED_FIPS_CODECS.contains(&codec), + "{codec:?} membership disagrees between the two FIPS lists" + ); + } +} + +/// every FIPS codec builds a streamed multihash with its expected digest +/// length +#[test] +fn test_each_fips_codec_builds_with_expected_digest_length() { + let message: &[u8] = b"every FIPS approved codec hashes through the streaming builder"; + + for &codec in &FIPS_CODECS { + let mh = streamed_multihash(codec, message); + let expected_len = + expected_digest_len(codec).expect("FIPS codec has an expected digest length"); + + assert_eq!(mh.codec(), codec, "codec of the built {codec:?} multihash"); + assert_eq!(mh.as_ref().len(), expected_len, "{codec:?} digest length"); + } +} + +/// each known-answer vector hashes the message to its expected digest +#[test] +fn test_known_answer_digests() { + for vector in &KAT_VECTORS { + let mh = streamed_multihash(vector.codec, KAT_MESSAGE); + assert_eq!( + mh.as_ref(), + vector.expected, + "{:?} digest of the known-answer message", + vector.codec + ); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 456bc42..b36afe4 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4,15 +4,43 @@ use multi_base::Base; use multi_codec::Codec; -use multi_hash::{Builder, Multihash}; +use multi_hash::{Builder, EncodedMultihash, Multihash}; use multi_trait::TryDecodeFrom; use multi_util::{CodecInfo, EncodingInfo}; -/// Serialize a value to CBOR bytes using `ciborium`. -fn cbor_to_vec(value: &T) -> Vec { - let mut buf = Vec::new(); - ciborium::into_writer(value, &mut buf).expect("CBOR serialize"); - buf +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// build a multihash of `data` with `codec`, streaming the data +fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + +/// build a base encoded multihash of `data` with `codec` +fn hash_encoded(codec: Codec, base: Base, data: &[u8]) -> EncodedMultihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder + .with_base_encoding(base) + .try_build_encoded() + .unwrap() } /// Test integration with multi-codec @@ -27,7 +55,7 @@ fn test_multicodec_integration() { let name = codec.as_str(); assert!(code > 0); - assert!(!name.is_empty()); + assert_ne!(name, ""); } } @@ -43,15 +71,11 @@ fn test_multibase_integration() { ]; for base in bases { - let encoded = Builder::new_from_bytes(Codec::Sha2256, b"test data") - .unwrap() - .with_base_encoding(base) - .try_build_encoded() - .unwrap(); + let encoded = hash_encoded(Codec::Sha2256, base, b"test data"); // Should be able to convert to string and back let s = encoded.to_string(); - assert!(!s.is_empty()); + assert_ne!(s, ""); // Encoding should preserve the base assert_eq!(encoded.encoding(), base); @@ -61,10 +85,7 @@ fn test_multibase_integration() { /// Test integration with multi-trait #[test] fn test_multitrait_integration() { - let mh1 = Builder::new_from_bytes(Codec::Sha3256, b"multitrait test") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha3256, b"multitrait test"); // Convert to bytes using Into (from multitrait patterns) let bytes: Vec = mh1.clone().into(); @@ -73,16 +94,13 @@ fn test_multitrait_integration() { let (mh2, remaining) = Multihash::try_decode_from(&bytes).unwrap(); assert_eq!(mh1, mh2); - assert!(remaining.is_empty()); + assert_eq!(remaining, b""); } /// Test integration with multi-util `BaseEncoded` #[test] fn test_multiutil_integration() { - let mh = Builder::new_from_bytes(Codec::Blake3, b"multiutil test") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Blake3, b"multiutil test"); // CodecInfo trait from multiutil assert_eq!(mh.codec(), Codec::Blake3); @@ -96,13 +114,7 @@ fn test_multiutil_integration() { /// Test `EncodedMultihash` uses `BaseEncoded` from multiutil #[test] fn test_encoded_multihash_type() { - use multi_hash::EncodedMultihash; - - let encoded = Builder::new_from_bytes(Codec::Sha2512, b"encoded test") - .unwrap() - .with_base_encoding(Base::Base64) - .try_build_encoded() - .unwrap(); + let encoded = hash_encoded(Codec::Sha2512, Base::Base64, b"encoded test"); // EncodedMultihash is a type alias to BaseEncoded let _: &EncodedMultihash = &encoded; @@ -116,14 +128,8 @@ fn test_encoded_multihash_type() { fn test_in_collections() { use std::collections::BTreeMap; - let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"key1") - .unwrap() - .try_build() - .unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, b"key2") - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, b"key1"); + let mh2 = hash_bytes(Codec::Sha2256, b"key2"); // BTreeMap (requires Ord) let mut btree = BTreeMap::new(); @@ -140,6 +146,7 @@ fn test_in_collections() { #[test] fn test_builder_fluent_api() { let result = Builder::new(Codec::Sha3384) + .unwrap() .with_hash(vec![0u8; 48]) .with_base_encoding(Base::Base58Btc) .try_build_encoded(); @@ -155,27 +162,20 @@ fn test_full_workspace_integration() { let base = Base::Base32Lower; // multi-base // Create multihash (multi-hash) - let mh = Builder::new_from_bytes(codec, b"integration") - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(codec, b"integration"); // Verify traits from multi-util work assert_eq!(mh.codec(), codec); assert_eq!(mh.encoding(), Base::Base16Lower); // Verify we can create encoded version (uses BaseEncoded from multiutil) - let encoded = Builder::new_from_bytes(codec, b"integration") - .unwrap() - .with_base_encoding(base) - .try_build_encoded() - .unwrap(); + let encoded = hash_encoded(codec, base, b"integration"); assert_eq!(encoded.encoding(), base); // Verify encoding works let s = encoded.to_string(); - assert!(!s.is_empty()); + assert_ne!(s, ""); } #[cfg(feature = "serde")] @@ -183,6 +183,13 @@ mod serde_integration { use super::*; use serde::{Deserialize, Serialize}; + /// Serialize a value to CBOR bytes using `ciborium`. + fn cbor_to_vec(value: &T) -> Vec { + let mut buf = Vec::new(); + ciborium::into_writer(value, &mut buf).expect("CBOR serialize"); + buf + } + /// Test multihash in serde structs #[test] fn test_multihash_in_struct() { @@ -193,10 +200,7 @@ mod serde_integration { } let doc = Document { - hash: Builder::new_from_bytes(Codec::Sha2256, b"document") - .unwrap() - .try_build() - .unwrap(), + hash: hash_bytes(Codec::Sha2256, b"document"), timestamp: 1234567890, }; diff --git a/tests/proptest_tests.rs b/tests/proptest_tests.rs index def8c89..aeae821 100644 --- a/tests/proptest_tests.rs +++ b/tests/proptest_tests.rs @@ -7,12 +7,34 @@ use multi_trait::TryDecodeFrom; use multi_util::CodecInfo; use proptest::prelude::*; +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// build a multihash of `data` with `codec`, streaming the data +fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + /// Property: Multihash encoding and decoding should roundtrip #[test] fn test_multihash_roundtrip() { proptest!(|(data in prop::collection::vec(any::(), 0..1024))| { for &codec in &HASH_CODECS { - let mh1 = Builder::new_from_bytes(codec, &data).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(codec, &data); let bytes: Vec = mh1.clone().into(); let (mh2, remaining) = Multihash::try_decode_from(&bytes).unwrap(); @@ -27,8 +49,8 @@ fn test_multihash_roundtrip() { fn test_hash_deterministic() { proptest!(|(data in prop::collection::vec(any::(), 0..256))| { for &codec in HASH_CODECS.iter().take(5) { - let mh1 = Builder::new_from_bytes(codec, &data).unwrap().try_build().unwrap(); - let mh2 = Builder::new_from_bytes(codec, &data).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(codec, &data); + let mh2 = hash_bytes(codec, &data); prop_assert_eq!(&mh1, &mh2); @@ -45,8 +67,8 @@ fn test_different_data_different_hash() { proptest!(|(data1 in prop::collection::vec(any::(), 1..256), data2 in prop::collection::vec(any::(), 1..256))| { if data1 != data2 { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, &data1).unwrap().try_build().unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, &data2).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, &data1); + let mh2 = hash_bytes(Codec::Sha2256, &data2); prop_assert_ne!(mh1, mh2); } @@ -58,7 +80,7 @@ fn test_different_data_different_hash() { fn test_codec_preserved() { proptest!(|(data in prop::collection::vec(any::(), 0..256))| { for &codec in &HASH_CODECS { - let mh1 = Builder::new_from_bytes(codec, &data).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(codec, &data); let bytes: Vec = mh1.clone().into(); let mh2 = Multihash::try_from(bytes.as_ref()).unwrap(); @@ -74,8 +96,15 @@ fn test_codec_preserved() { fn test_empty_data_valid() { proptest!(|(_unit in 0..1u8)| { for &codec in &HASH_CODECS { - let result = Builder::new_from_bytes(codec, []); + let result = Builder::new(codec); prop_assert!(result.is_ok()); + + let mut builder = result.unwrap(); + builder.update([]); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + prop_assert!(builder.try_build().is_ok()); } }); } @@ -84,7 +113,7 @@ fn test_empty_data_valid() { #[test] fn test_equality_reflexive() { proptest!(|(data in prop::collection::vec(any::(), 0..256))| { - let mh = Builder::new_from_bytes(Codec::Sha2256, &data).unwrap().try_build().unwrap(); + let mh = hash_bytes(Codec::Sha2256, &data); prop_assert_eq!(&mh, &mh); }); } @@ -93,7 +122,7 @@ fn test_equality_reflexive() { #[test] fn test_equality_symmetric() { proptest!(|(data in prop::collection::vec(any::(), 0..256))| { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, &data).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, &data); let mh2 = mh1.clone(); prop_assert_eq!(&mh1, &mh2); @@ -106,8 +135,8 @@ fn test_equality_symmetric() { fn test_ordering_consistent() { proptest!(|(data1 in prop::collection::vec(any::(), 0..128), data2 in prop::collection::vec(any::(), 0..128))| { - let mh1 = Builder::new_from_bytes(Codec::Sha2256, &data1).unwrap().try_build().unwrap(); - let mh2 = Builder::new_from_bytes(Codec::Sha2256, &data2).unwrap().try_build().unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, &data1); + let mh2 = hash_bytes(Codec::Sha2256, &data2); if mh1 < mh2 { prop_assert!((mh2 >= mh1)); diff --git a/tests/security_tests.rs b/tests/security_tests.rs index 165692b..ab6091a 100644 --- a/tests/security_tests.rs +++ b/tests/security_tests.rs @@ -5,10 +5,32 @@ use multi_codec::Codec; use multi_hash::{Builder, Error, Multihash, SAFE_HASH_CODECS}; use multi_util::CodecInfo; +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// build a multihash of `data` with `codec`, streaming the data +fn hash_bytes(codec: Codec, data: &[u8]) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + builder.update(data); + if let Some(output_len) = xof_output_len(codec) { + builder.output_len(output_len); + } + builder.try_build().unwrap() +} + /// Test that invalid codec is rejected #[test] fn test_invalid_codec_rejected() { - let result = Builder::new_from_bytes(Codec::DagCbor, b"data"); + let result = Builder::new(Codec::DagCbor); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::UnsupportedHash { .. })); } @@ -16,7 +38,7 @@ fn test_invalid_codec_rejected() { /// Test that empty hash data is detected #[test] fn test_missing_hash_detected() { - let result = Builder::new(Codec::Sha2256).try_build(); + let result = Builder::new(Codec::Sha2256).unwrap().try_build(); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), Error::MissingHash)); } @@ -59,10 +81,7 @@ fn test_concurrent_hashing() { let data_clone = Arc::clone(&data); let handle = thread::spawn(move || { for _ in 0..10 { - let mh = Builder::new_from_bytes(Codec::Sha2256, data_clone.as_ref()) - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(Codec::Sha2256, data_clone.as_ref()); assert_eq!(mh.codec(), Codec::Sha2256); } }); @@ -81,17 +100,9 @@ fn test_deterministic_across_threads() { let data = b"deterministic test"; - let mh1 = Builder::new_from_bytes(Codec::Sha2256, data) - .unwrap() - .try_build() - .unwrap(); + let mh1 = hash_bytes(Codec::Sha2256, data); - let handle = thread::spawn(move || { - Builder::new_from_bytes(Codec::Sha2256, data) - .unwrap() - .try_build() - .unwrap() - }); + let handle = thread::spawn(move || hash_bytes(Codec::Sha2256, data)); let mh2 = handle.join().unwrap(); assert_eq!(mh1, mh2); @@ -127,10 +138,7 @@ fn test_safe_hash_codecs_functional() { let data = b"safety test data"; for &codec in &SAFE_HASH_CODECS { - let mh = Builder::new_from_bytes(codec, data) - .unwrap() - .try_build() - .unwrap(); + let mh = hash_bytes(codec, data); assert_eq!(mh.codec(), codec); diff --git a/tests/xof_tests.rs b/tests/xof_tests.rs new file mode 100644 index 0000000..316b594 --- /dev/null +++ b/tests/xof_tests.rs @@ -0,0 +1,501 @@ +// SPDX-License-Identifier: Apache-2.0 +//! XOF tests for the extendable-output codecs `Blake3`, `Shake128`, and +//! `Shake256` +//! +//! The SHAKE known-answer vectors below come from the Keccak team FIPS 202 +//! KAT files `ShortMsgKAT_SHAKE128.txt` and `ShortMsgKAT_SHAKE256.txt` in +//! the XKCP repository at . Each file's +//! `Squeezed` field supplies a 512-byte output stream; the constants here +//! are the first 128 bytes of that field. The BLAKE3 known-answer vectors +//! are 32-byte digests; the empty-message and `abc` digests are the +//! official BLAKE3 known-answer values, and the `blake3` message has no +//! published vector, so the test asserts its digest against the one-shot +//! `blake3::Hasher::finalize` digest of the same message. All three +//! codecs have prefix-consistent output: the same input squeezed to a +//! short length is a prefix of the same input squeezed to a longer +//! length, so any prefix up to the full stored bytes is a valid expected +//! value for the same message. A 32-byte BLAKE3 digest stays a BLAKE3 +//! digest: it is no BLAKE2b-256 value, because the codecs are distinct +//! algorithms. +#![allow(clippy::unreadable_literal)] + +use multi_base::Base; +use multi_codec::Codec; +use multi_hash::{Builder, EncodedMultihash, Error, MAX_HASH_LENGTH, Multihash}; +use multi_util::{CodecInfo, EncodingInfo}; + +/// SHAKE128 of the empty message, first 128 bytes of the 512-byte +/// `Squeezed` field in `ShortMsgKAT_SHAKE128.txt` at +/// +const SHAKE128_EMPTY: &str = "7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef263cb1eea988004b93103cfb0aeefd2a686e01fa4a58e8a3639ca8a1e3f9ae57e235b8cc873c23dc62b8d260169afa2f75ab916a58d974918835d25e6a435085b2badfd6dfaac359a5efbb7bcc4b59d538df9a04302e10c8bc1cbf1a0b3a5120ea"; + +/// SHAKE128 of the single byte 0xCC, first 128 bytes of the 512-byte +/// `Squeezed` field in `ShortMsgKAT_SHAKE128.txt` at +/// +const SHAKE128_ONE_CC: &str = "4dd4b0004a7d9e613a0f488b4846f804015f0f8ccdba5f7c16810bbc5a1c6fb254efc81969c5eb49e682babae02238a31fd2708e418d7b754e21e4b75b65e7d39b5b42d739066e7c63595daf26c3a6a2f7001ee636c7cb2a6c69b1ec7314a21ff24833eab61258327517b684928c7444380a6eacd60a6e9400da37a61050e4cd"; + +/// SHAKE256 of the empty message, first 128 bytes of the 512-byte +/// `Squeezed` field in `ShortMsgKAT_SHAKE256.txt` at +/// +const SHAKE256_EMPTY: &str = "46b9dd2b0ba88d13233b3feb743eeb243fcd52ea62b81b82b50c27646ed5762fd75dc4ddd8c0f200cb05019d67b592f6fc821c49479ab48640292eacb3b7c4be141e96616fb13957692cc7edd0b45ae3dc07223c8e92937bef84bc0eab862853349ec75546f58fb7c2775c38462c5010d846c185c15111e595522a6bcd16cf86"; + +/// SHAKE256 of the single byte 0xCC, first 128 bytes of the 512-byte +/// `Squeezed` field in `ShortMsgKAT_SHAKE256.txt` at +/// +const SHAKE256_ONE_CC: &str = "ddbf55dbf65977e3e2a3674d33e479f78163d592666bc576feb5e4c404ea5e5329c3a416be758687de1a55e23d9e48a7d3f3ce6d8f0b2006a935800eca9c9fc903d86f065367221067658b4d7473ed54800d196fbe1089811dd9b47f21e3698b1573653adad231c39f145b586d6c0133378416138e4423f7af7dacffe965706a"; + +/// one known-answer vector: the codec, the message it hashes, and the +/// expected digest +struct KatVector { + /// the XOF codec the vector applies to + codec: Codec, + + /// the message the vector hashes + message: &'static [u8], + + /// the expected digest, lowercase hex, as a prefix of the 512-byte + /// `Squeezed` stream from the XKCP KAT file + expected: &'static str, +} + +/// the four SHAKE known-answer vectors: `{Shake128, Shake256}` each over +/// the empty message and the single byte 0xCC +const KAT_VECTORS: [KatVector; 4] = [ + KatVector { + codec: Codec::Shake128, + message: &[], + expected: SHAKE128_EMPTY, + }, + KatVector { + codec: Codec::Shake128, + message: &[0xCC], + expected: SHAKE128_ONE_CC, + }, + KatVector { + codec: Codec::Shake256, + message: &[], + expected: SHAKE256_EMPTY, + }, + KatVector { + codec: Codec::Shake256, + message: &[0xCC], + expected: SHAKE256_ONE_CC, + }, +]; + +/// the BLAKE3 known-answer vectors: the message and its 32-byte digest, as +/// lowercase hex +/// +/// The empty-message and `abc` digests are official BLAKE3 known-answer +/// values. The `blake3` message has no published vector; the test asserts +/// each BLAKE3 digest against the one-shot `blake3::Hasher::finalize` +/// digest of the same message. +const BLAKE3_KAT_VECTORS: [(&[u8], &str); 3] = [ + ( + b"", + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", + ), + ( + b"abc", + "6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85", + ), + ( + b"blake3", + "aca4f0133c931ea1d8420a3a9b96d7a15ef245b1755224d80db917cee17c86c7", + ), +]; + +/// the output length these tests request for an XOF codec +/// +/// Fixed-output codecs ignore an output length, so the helper applies +/// only to the XOF arms, `Blake3`, `Shake128`, and `Shake256`. +const fn xof_output_len(codec: Codec) -> Option { + match codec { + Codec::Blake3 | Codec::Shake128 => Some(32), + Codec::Shake256 => Some(64), + _ => None, + } +} + +/// stream `message` through the builder in chunks of at most three bytes, +/// request `output_len` digest bytes, and build the multihash +/// +/// The empty message still gets one empty update call, so the builder owns +/// hashing state when it builds. +fn streamed_multihash(codec: Codec, message: &[u8], output_len: usize) -> Multihash { + let mut builder = Builder::new(codec).unwrap(); + if message.is_empty() { + builder.update([]); + } + for chunk in message.chunks(3) { + builder.update(chunk); + } + builder.output_len(output_len); + builder.try_build().unwrap() +} + +/// each SHAKE KAT vector matches at short, medium, and full digest +/// lengths, with the message streamed through the builder in chunks +#[test] +fn test_kat_vectors_at_multiple_lengths() { + for vector in &KAT_VECTORS { + let expected = hex::decode(vector.expected).unwrap(); + assert_eq!( + expected.len(), + 128, + "vector {:?}: not 128 digest bytes", + vector.codec + ); + + for &output_len in &[1usize, 8, 32, 64, 100, 128] { + let mh = streamed_multihash(vector.codec, vector.message, output_len); + assert_eq!( + mh.codec(), + vector.codec, + "codec of the {output_len}-byte digest" + ); + assert_eq!( + mh.as_ref().len(), + output_len, + "{:?} digest length", + vector.codec + ); + assert_eq!( + mh.as_ref(), + &expected[..output_len], + "{:?} of message {:02x?} squeezed to {output_len} bytes", + vector.codec, + vector.message + ); + } + } +} + +/// each BLAKE3 known-answer vector matches the streamed builder at 1, 8, +/// and 32 digest bytes, and the 32-byte digests match the one-shot +/// `blake3::Hasher::finalize` digests of the same messages +#[test] +fn test_blake3_kat_vectors() { + for (message, expected) in &BLAKE3_KAT_VECTORS { + let expected = hex::decode(expected).unwrap(); + + for &output_len in &[1usize, 8, 32] { + let mh = streamed_multihash(Codec::Blake3, message, output_len); + assert_eq!( + mh.codec(), + Codec::Blake3, + "codec of the {output_len}-byte digest" + ); + assert_eq!( + mh.as_ref(), + &expected[..output_len], + "BLAKE3 of {message:02x?} squeezed to {output_len} bytes" + ); + } + + let finalized = blake3::Hasher::new().update(message).finalize(); + assert_eq!( + expected.as_slice(), + finalized.as_bytes(), + "BLAKE3 of {message:02x?} against the one-shot digest" + ); + } +} + +/// streaming the same input in chunks produces the same multihash as one +/// full update, at several digest lengths +#[test] +fn test_streaming_matches_one_shot() { + let message: &[u8] = b"the same input must hash to the same digest however it is chunked"; + + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + for &output_len in &[32usize, 64, 100] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(&message[..12]); + builder.update(&message[12..33]); + builder.update(&message[33..]); + builder.output_len(output_len); + let chunked = builder.try_build().unwrap(); + + let one_shot = streamed_multihash(codec, message, output_len); + + assert_eq!(chunked, one_shot, "{codec:?} at {output_len} bytes"); + } + } +} + +/// the digest of a shorter squeeze is a prefix of the digest of a longer +/// squeeze of the same input, for each codec +#[test] +fn test_digest_prefix_consistency() { + let message: &[u8] = b"prefix consistency across squeeze lengths"; + + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let lengths = [1usize, 16, 32, 64, 128]; + let digests: Vec = lengths + .iter() + .map(|&output_len| streamed_multihash(codec, message, output_len)) + .collect(); + + for window in digests.windows(2) { + let (short, long) = (&window[0], &window[1]); + assert!( + long.as_ref().starts_with(short.as_ref()), + "{codec:?}: short digest is not a prefix of the long digest" + ); + } + } +} + +/// two digest lengths of the same input encode as distinct multihashes: +/// the raw encodings differ and the encoded digest-length field differs, +/// even though the short digest prefixes the long one +#[test] +fn test_distinct_lengths_distinct_encodings() { + let message: &[u8] = b"the digest length is part of the identity of a multihash"; + + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let short = streamed_multihash(codec, message, 32); + let long = streamed_multihash(codec, message, 64); + assert!( + long.as_ref().starts_with(short.as_ref()), + "{codec:?}: short digest is not a prefix of the long digest" + ); + + let short_bytes: Vec = short.clone().into(); + let long_bytes: Vec = long.into(); + assert_ne!(short_bytes, long_bytes, "{codec:?}: encodings matched"); + + // one codec varint byte and one digest-length varint byte: 32 and + // 64 fit in one unsigned varint byte each, so the second encoded + // byte carries the digest length + assert_ne!( + short_bytes[1], long_bytes[1], + "{codec:?}: length prefix matched" + ); + + let short_decoded = Multihash::try_from(short_bytes.as_ref()).unwrap(); + let long_decoded = Multihash::try_from(long_bytes.as_ref()).unwrap(); + assert_eq!(short_decoded.as_ref().len(), 32, "{codec:?}"); + assert_eq!(long_decoded.as_ref().len(), 64, "{codec:?}"); + } +} + +/// a built XOF multihash roundtrips through its binary encoding +#[test] +fn test_binary_roundtrip() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + for &output_len in &[32usize, 64, 128] { + let mh = streamed_multihash(codec, b"binary roundtrip", output_len); + + let bytes: Vec = mh.clone().into(); + let decoded = Multihash::try_from(bytes.as_ref()).unwrap(); + + assert_eq!(mh, decoded, "{codec:?} at {output_len} bytes"); + } + } +} + +/// an encoded XOF multihash roundtrips through its multibase string form +#[test] +fn test_multibase_roundtrip() { + let bases = [ + Base::Base16Lower, + Base::Base32Lower, + Base::Base58Btc, + Base::Base64, + ]; + + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let output_len = xof_output_len(codec).unwrap(); + + for &base in &bases { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"multibase roundtrip"); + builder.output_len(output_len); + let encoded = builder + .with_base_encoding(base) + .try_build_encoded() + .unwrap(); + + let s = encoded.to_string(); + assert_eq!( + encoded, + EncodedMultihash::try_from(s.as_str()).unwrap(), + "{codec:?} in {base:?}" + ); + assert_eq!(encoded.encoding(), base, "{codec:?} in {base:?}"); + } + } +} + +/// a zero output length is rejected with the requested length and the +/// maximum named in the error +#[test] +fn test_zero_output_len_rejected() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"zero output length"); + builder.output_len(0); + + let Err(Error::OutputLenInvalid { + codec: error_codec, + output_len, + max, + }) = builder.try_build() + else { + panic!("{codec:?} accepted a zero output length"); + }; + + assert_eq!(error_codec, codec); + assert_eq!(output_len, 0); + assert_eq!(max, MAX_HASH_LENGTH); + } +} + +/// an output length one byte over `MAX_HASH_LENGTH` is rejected with the +/// requested length and the maximum named in the error +#[test] +fn test_over_max_output_len_rejected() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"over-limit output length"); + builder.output_len(MAX_HASH_LENGTH + 1); + + let Err(Error::OutputLenInvalid { + codec: error_codec, + output_len, + max, + }) = builder.try_build() + else { + panic!("{codec:?} accepted an over-limit output length"); + }; + + assert_eq!(error_codec, codec); + assert_eq!(output_len, MAX_HASH_LENGTH + 1); + assert_eq!(max, MAX_HASH_LENGTH); + } +} + +/// streaming an XOF without an output length is rejected by both +/// `try_build` and `try_build_encoded`, naming the codec +#[test] +fn test_output_len_required_without_setting() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"no output length"); + let Err(Error::OutputLenRequired { codec: error_codec }) = builder.try_build() else { + panic!("{codec:?} built without an output length"); + }; + assert_eq!(error_codec, codec); + + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"no output length"); + let Err(Error::OutputLenRequired { codec: error_codec }) = builder.try_build_encoded() + else { + panic!("{codec:?} encoded without an output length"); + }; + assert_eq!(error_codec, codec); + } +} + +/// a digest of one byte, and a digest of sixty-four bytes, are accepted as +/// explicit hashes under an XOF codec +#[test] +fn test_with_hash_accepts_valid_lengths() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + for &hash_len in &[1usize, 64] { + let hash = vec![7u8; hash_len]; + let mh = Builder::new(codec) + .unwrap() + .with_hash(hash.clone()) + .try_build() + .unwrap(); + + assert_eq!(mh.codec(), codec, "{codec:?} with {hash_len}-byte digest"); + assert_eq!( + mh.as_ref(), + hash.as_slice(), + "{codec:?} with {hash_len}-byte digest" + ); + } + } +} + +/// an empty explicit digest, and a digest one byte over the maximum, are +/// rejected with `OutputLenInvalid` naming the digest length +#[test] +fn test_with_hash_rejects_out_of_policy_lengths() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let empty = Builder::new(codec) + .unwrap() + .with_hash(Vec::new()) + .try_build(); + assert!( + matches!( + empty, + Err(Error::OutputLenInvalid { + output_len: 0, + max, + .. + }) if max == MAX_HASH_LENGTH + ), + "{codec:?} accepted an empty digest" + ); + } + + // the over-limit digest is checked against the output policy without + // being squeezed + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let over = vec![0u8; MAX_HASH_LENGTH + 1]; + let result = Builder::new(codec).unwrap().with_hash(over).try_build(); + assert!( + matches!( + result, + Err(Error::OutputLenInvalid { + output_len, + max, + .. + }) if output_len == MAX_HASH_LENGTH + 1 && max == MAX_HASH_LENGTH + ), + "{codec:?} accepted a digest one byte over the maximum" + ); + } +} + +/// a valid explicit digest takes precedence over streamed data and an +/// output length setting +#[test] +fn test_with_hash_precedes_streamed_data_and_output_len() { + for &codec in &[Codec::Blake3, Codec::Shake128, Codec::Shake256] { + let hash = vec![0x5au8; 32]; + + let mut builder = Builder::new(codec).unwrap(); + builder.update(b"streamed data that must be ignored"); + builder.output_len(64); + let mh = builder.with_hash(hash.clone()).try_build().unwrap(); + + assert_eq!(mh.codec(), codec); + assert_eq!(mh.as_ref(), hash.as_slice(), "{codec:?}"); + } +} + +/// a multihash whose digest is shorter than the codec's output policy +/// still decodes, in binary and through a multibase string: decode stays +/// format-validity only +#[test] +fn test_truncated_sha2_256_multihash_decodes() { + // sha2-256 (codec 0x12) with a declared digest length of 16 bytes + let mut bytes = vec![0x12u8, 0x10]; + bytes.extend_from_slice(&[0u8; 16]); + + let mh = Multihash::try_from(bytes.as_ref()).unwrap(); + assert_eq!(mh.codec(), Codec::Sha2256); + assert_eq!(mh.as_ref().len(), 16); + + // `f` is the multibase prefix for base 16 lower + let s = format!("f{}", hex::encode(&bytes)); + let decoded = EncodedMultihash::try_from(s.as_str()).unwrap(); + assert_eq!(decoded.encoding(), Base::Base16Lower); + assert_eq!(decoded.to_inner(), mh); +}