From 73365ac3c81aad8732929a90c2905fc2f0373ac4 Mon Sep 17 00:00:00 2001 From: Dave Grantham Date: Thu, 16 Jul 2026 21:30:38 -0600 Subject: [PATCH] hardening Signed-off-by: Dave Grantham --- Cargo.toml | 3 +- SECURITY.md | 49 ++++++++++++++++++++++++++++ src/mh.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 SECURITY.md diff --git a/Cargo.toml b/Cargo.toml index aac79c9..887d198 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-hash" -version = "1.0.4" +version = "1.0.5" edition = "2024" rust-version = "1.85" authors = ["Dave Grantham "] @@ -29,6 +29,7 @@ serde = { version = "1.0", default-features = false, features = ["alloc", "deriv sha1 = "0.10" sha2 = "0.10" sha3 = "0.10" +subtle = "2" thiserror = { version = "2.0" } typenum = "1.17" unsigned-varint = { version = "0.8", features = ["std"] } diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dc7475d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Overview + +The `multi-hash` crate provides self-describing cryptographic hash digests +following the [Multihash](https://github.com/multiformats/multihash) +specification. This document outlines the security properties, threat model, +and guarantees of this crate. + +## std-only Status + +This crate is **std-only**. It depends on the `digest` crate's `DynDigest` +trait (which requires `Box` and thus `std::alloc` + `std`'s +box support), and `unsigned-varint` with the `std` feature. A `no_std` +conversion is not planned for this crate. + +## Security Properties + +### Memory Safety + +- **No unsafe code**: `#![deny(unsafe_code)]` is enforced at compile time. +- **Input validation**: All decode paths validate lengths and codec + identifiers before allocation. +- **DoS protection**: `Varbytes` decode (used for the hash digest length) + enforces `MAX_DECODED_SIZE` (16 MiB) and buffer-length checks, mitigating + CWE-400 (Uncontrolled Resource Consumption) and CWE-125 (Out-of-bounds + Read). + +### Constant-Time Comparison + +`Multihash` derives `PartialEq`, which uses a short-circuiting byte +comparison. This is **not** constant-time and is unsuitable for +timing-sensitive comparisons (e.g. verifying a hash received from an +untrusted party). + +The crate provides `impl subtle::ConstantTimeEq for Multihash`, which +compares the `codec`, hash length, and hash bytes in constant time. Use +`mh.ct_eq(&other)` in any context where timing leaks could be exploited. + +### Supported Algorithms + +See `SAFE_HASH_CODECS` for cryptographically recommended algorithms. +Legacy algorithms (SHA1, MD5, RIPEMD) are provided for compatibility only +and should not be used in new cryptographic constructions. + +## Reporting Vulnerabilities + +Report security issues via the project's GitHub issue tracker or privately +to the maintainers. \ No newline at end of file diff --git a/src/mh.rs b/src/mh.rs index 8447976..16e6aeb 100644 --- a/src/mh.rs +++ b/src/mh.rs @@ -12,6 +12,7 @@ use multi_base::Base; use multi_codec::Codec; use multi_trait::{EncodeInto, Null, TryDecodeFrom}; use multi_util::{BaseEncoded, CodecInfo, DetectedEncoder, EncodingInfo, Varbytes}; +use subtle::ConstantTimeEq; use typenum::consts::{U28, U32, U48, U64}; /// the hash codecs currently supported @@ -104,6 +105,14 @@ impl DynDigest for Blake3DynDigest { } /// inner implementation of the multihash +/// +/// # Constant-Time Comparison +/// +/// `Multihash` derives [`PartialEq`], which uses a short-circuiting byte +/// comparison and is **not** suitable for timing-sensitive contexts (e.g. +/// comparing MACs or hashes received from an untrusted party). Use +/// [`ct_eq`](ConstantTimeEq::ct_eq) in those contexts — it compares the +/// `codec`, the hash length, and the hash bytes in constant time. #[derive(Clone, Default, Eq, Ord, PartialEq, PartialOrd, Hash)] pub struct Multihash { /// hash codec @@ -180,6 +189,28 @@ impl AsRef<[u8]> for Multihash { } } +/// Constant-time equality comparison for [`Multihash`]. +/// +/// Compares `codec`, `hash.len()`, and the hash bytes without +/// short-circuiting. Returns `1u8` if both multihashes are equal, `0u8` +/// otherwise. Use this instead of `PartialEq` in timing-sensitive contexts +/// (e.g. verifying a hash received from an untrusted party). +impl ConstantTimeEq for Multihash { + fn ct_eq(&self, other: &Self) -> subtle::Choice { + // Compare codec (Codec is a Copy enum backed by u64) + let codec_eq = u64::from(self.codec).ct_eq(&u64::from(other.codec)); + + // Compare hash lengths in constant time + let len_eq = self.hash.len().ct_eq(&other.hash.len()); + + // Compare hash bytes; ConstantTimeEq on [u8] handles unequal lengths + // by returning 0 (it first compares lengths, then bytes). + let bytes_eq = self.hash.as_slice().ct_eq(other.hash.as_slice()); + + codec_eq & len_eq & bytes_eq + } +} + /// Multihashes can have a null value impl Null for Multihash { fn null() -> Self { @@ -500,4 +531,67 @@ mod tests { assert_eq!(map.len(), 2); } + + #[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(); + + 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(); + + 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(); + // same hash bytes, different codec + let mh3 = Multihash { + codec: Codec::Sha2512, + hash: mh1.hash.clone(), + }; + + assert_eq!(mh1.ct_eq(&mh2).unwrap_u8(), 1); + assert_eq!(mh1.ct_eq(&mh3).unwrap_u8(), 0); + } + + #[test] + fn test_ct_eq_unequal_length() { + let mh1 = Builder::new_from_bytes(Codec::Sha2256, b"hello") + .unwrap() + .try_build() + .unwrap(); + // same codec, different length hash + let mh2 = Multihash { + codec: mh1.codec, + hash: vec![0u8; 16], + }; + + assert_eq!(mh1.ct_eq(&mh2).unwrap_u8(), 0); + } }