Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <dwg@linuxprogrammer.org>"]
Expand Down Expand Up @@ -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"] }
Expand Down
49 changes: 49 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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<dyn DynDigest>` 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.
94 changes: 94 additions & 0 deletions src/mh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Loading