cargo / base16ct / audit
cargo : base16ct @ 0.2.0
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

has-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignparser-impl-correctparser-impl-safeparser-impl-testedunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

base16ct 0.2.0 is the RustCrypto constant-time hex codec (RFC 4648 Base16): branchless sign-mask decode_nibble/encode_nibble over i16, no data-dependent branches. Four from_utf8_unchecked blocks on encoder output (ASCII-only by construction). Two low-severity quality findings: no SAFETY comments on the unsafe blocks, and thin test coverage for a constant-time crypto-adjacent parser.

Report

Subject

base16ct is a no_std-capable pure-Rust implementation of Base16 (hexadecimal, RFC 4648) encoding and decoding. It implements lower-case, upper-case, and mixed-case variants using branchless arithmetic that aims to provide portable "best effort" constant-time operation with respect to the data being encoded/decoded (not with respect to message length). The crate is part of the RustCrypto formats workspace and is intended for use in cryptographic contexts where data-dependent timing or cache side-channels matter.

The public API consists of:

  • lower::{encode, encode_str, encode_string, decode, decode_vec}
  • upper::{encode, encode_str, encode_string, decode, decode_vec}
  • mixed::{decode, decode_vec} (decode only, accepts both cases)
  • HexDisplay<'a>(&'a [u8]) implementing Display, UpperHex, LowerHex
  • Error (InvalidEncoding, InvalidLength) and decoded_len/encoded_len helpers

The crate has no runtime dependencies and two optional features: alloc (enables _vec/_string APIs) and std (gates std::error::Error impl).

Methodology

The published crate was unpacked and diffed against the upstream Git repository at the commit recorded in .cargo_vcs_info.json. The repository at https://github.com/RustCrypto/formats/tree/master/base16ct was cloned manually because openvet's default VCS resolution treated the deep-tree URL literally.

All six source files (lib.rs, lower.rs, upper.rs, mixed.rs, display.rs, error.rs; ~384 lines total) and the integration test file (tests/lib.rs, 163 lines) were read in full. The benchmark file was inspected. The Cargo.toml/Cargo.toml.orig were compared and the manifest metadata (categories, dependencies, features, build configuration) was reviewed.

The branchless decode_nibble/encode_nibble routines were manually traced for boundary inputs (0x2f, 0x30, 0x39, 0x3a, 0x40, 0x41, 0x46, 0x47, 0x60, 0x61, 0x66, 0x67) in both lower and upper variants to confirm correctness of the sign-mask arithmetic. The four unsafe sites were enumerated and their preconditions checked against the surrounding callers.

Results

The published crate matches the upstream VCS contents: the only differences from vcs/ are Cargo.toml normalisation by cargo (key reordering, quoting, comment header) and the addition of .cargo_vcs_info.json and Cargo.toml.orig. Cargo.toml.orig matches vcs/Cargo.toml byte-for-byte. No source files diverge.

The crate ships no binaries (justifying has-binaries = false), no build.rs, no proc-macro library, no [build-dependencies], and no install-time hooks (justifying has-build-exec = false and has-install-exec = false). It has no runtime dependencies.

The source-code review found that the crate performs no I/O of any kind: no filesystem access, no network access, no environment-variable access, no process execution, no JIT or interpreter, no concurrency primitives, no cryptographic primitives (it is a hex codec, not a crypto algorithm; the constant-time property is what makes it crypto-adjacent). This justifies uses-filesystem, uses-network, uses-environment, uses-exec, uses-jit, uses-interpreter, uses-concurrency, uses-crypto, impl-crypto, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, impl-algorithm, and impl-concurrency, all as false. The crate implements a parser/encoder for the hex format (justifying impl-parser = true).

Four unsafe blocks were found, all of the form from_utf8_unchecked(...) applied to the output of encode. The invariant (encoder produces only ASCII) holds: encode_nibble is called with nibbles in 0..16 and produces bytes only in 0x30-0x39 plus 0x61-0x66 (lower) or 0x30-0x39 plus 0x41-0x46 (upper). The integration tests in tests/lib.rs exercise all four sites via encode_str/encode_string/HexDisplay, justifying has-integration-tests. The crate ships no in-module #[cfg(test)] blocks, justifying has-unit-tests. This justifies uses-unsafe = true, unsafe-safe = true, unsafe-minimal = true, unsafe-tested = true. The blocks lack // SAFETY: comments, justifying unsafe-documented = false (finding FINDING-1).

The branchless decode_nibble was manually verified to reject all bytes outside the valid hex ranges at both boundaries (0x2f/0x3a for digits, 0x40/0x47 for upper, 0x60/0x67 for lower); errors propagate via 0xFFFF sentinel through decode_inner, which OR-accumulates into err and returns InvalidEncoding at the end without short-circuiting. The implementation matches RFC 4648 section 8 (Base 16 Encoding), justifying parser-impl-safe and parser-impl-correct.

The audit produced two low-severity quality findings:

  • FINDING-1: missing // SAFETY: comments on the four unsafe blocks (the invariant holds; only documentation is absent).
  • FINDING-2: limited test coverage for a constant-time crypto-adjacent parser: six hand-written test vectors and one fixed-byte length-sweep, with no fuzz target, no property tests, no exhaustive byte-rejection coverage, and no externally-sourced RFC 4648 vectors. Justifies has-fuzz-tests = false, has-property-tests = false, and parser-impl-tested = false.

Conclusion

The crate is small, focused, dependency-free, and contains no malicious or unexpected behaviour (justifying is-benign = true). The hex codec is correctly implemented with a sound branchless construction, and the four unsafe sites are minimal uses of from_utf8_unchecked whose invariants demonstrably hold. The findings are both low-severity quality issues: missing safety comments and a testing gap. Neither impacts the soundness or correctness of the current code. The crate is suitable for production use in the constant-time-encoding role for which RustCrypto consumes it.

Findings(2)

FINDING-1 quality low

Unsafe blocks lack SAFETY comments

All four unsafe blocks in the crate (src/lower.rs:35, src/lower.rs:49, src/upper.rs:35, src/upper.rs:49) call core::str::from_utf8_unchecked or String::from_utf8_unchecked on the byte output of encode without a // SAFETY: comment justifying that the input is valid UTF-8.

The invariant does hold: encode_nibble is reachable only from encode with input nibbles 0..16 (via byte >> 4 and byte & 0x0f), and produces only ASCII bytes in 0x30-0x39 plus 0x61-0x66 (lower) or 0x41-0x46 (upper), all of which are valid UTF-8. This justifies unsafe-safe and unsafe-minimal.

Missing SAFETY comments justify unsafe-documented = false.

FINDING-2 quality low

Limited test coverage for a constant-time crypto-adjacent parser

The integration test suite (tests/lib.rs, ~160 lines) exercises six fixed test vectors plus a single 0..64-length round-trip over a fixed byte (b'X'). It does not:

  • Enumerate all 256 possible input bytes to confirm that every non-hex byte is rejected by decode_nibble (the branchless arithmetic is non-obvious and benefits from exhaustive testing).
  • Use property-based testing (e.g. proptest) for round-trip encode/decode over arbitrary byte inputs.
  • Include a fuzz target.
  • Reference any external test vector source (e.g. RFC 4648 section 4 / section 8 examples).

The crate is in categories = ["cryptography", "encoding", ...] and exposes a constant-time API intended for crypto-adjacent use, where rejecting invalid inputs reliably matters. The branchless decode_nibble was manually verified to correctly reject boundary inputs (0x2f, 0x3a, 0x40, 0x47, 0x60, 0x67), but exhaustive coverage would catch any future modification regression.

Justifies has-fuzz-tests = false, has-property-tests = false, and parser-impl-tested = false.

Annotations(5)

Cargo.toml

Crate has no [dependencies] and no [build-dependencies]; [lib] is not declared as proc-macro. The published crate contains no build.rs. This justifies has-build-exec = false and has-install-exec = false.

src/lib.rs

src/lib.rs, line 104-124

fn decode_inner<'a>(
    src: &[u8],
    dst: &'a mut [u8],
    decode_nibble: impl Fn(u8) -> u16,
) -> Result<&'a [u8]> {
    let dst = dst
        .get_mut(..decoded_len(src)?)
        .ok_or(Error::InvalidLength)?;

    let mut err: u16 = 0;
    for (src, dst) in src.chunks_exact(2).zip(dst.iter_mut()) {
        let byte = (decode_nibble(src[0]) << 4) | decode_nibble(src[1]);
        err |= byte >> 8;
        *dst = byte as u8;
    }

    match err {
        0 => Ok(dst),
        _ => Err(Error::InvalidEncoding),
    }
}

decode_inner is the generic hex decoder. It returns Err(InvalidLength) for odd-length input or insufficient dst capacity, and accumulates per-nibble errors in err (set to 0xFF by decode_nibble returning 0xFFFF on invalid bytes, then OR'd into the top byte via (0xFFFF << 4) | other). The loop is straight-line and processes the entire input regardless of error state, leaking only a single bit (whole-input validity) — consistent with the documented "constant-time with respect to message data" guarantee. Justifies parser-impl-safe and parser-impl-correct.

src/lower.rs

src/lower.rs, line 53-78

#[inline(always)]
fn decode_nibble(src: u8) -> u16 {
    // 0-9  0x30-0x39
    // A-F  0x41-0x46 or a-f  0x61-0x66
    let byte = src as i16;
    let mut ret: i16 = -1;

    // 0-9  0x30-0x39
    // if (byte > 0x2f && byte < 0x3a) ret += byte - 0x30 + 1; // -47
    ret += (((0x2fi16 - byte) & (byte - 0x3a)) >> 8) & (byte - 47);
    // a-f  0x61-0x66
    // if (byte > 0x60 && byte < 0x67) ret += byte - 0x61 + 10 + 1; // -86
    ret += (((0x60i16 - byte) & (byte - 0x67)) >> 8) & (byte - 86);

    ret as u16
}

/// Encode a single nibble of hex
#[inline(always)]
fn encode_nibble(src: u8) -> u8 {
    let mut ret = src as i16 + 0x30;
    // 0-9  0x30-0x39
    // a-f  0x61-0x66
    ret += ((0x39i16 - ret) >> 8) & (0x61i16 - 0x3a);
    ret as u8
}

Branchless decode_nibble and encode_nibble derived from https://github.com/Sc00bz/ConstTimeEncoding/blob/master/hex.cpp. Manually traced boundary inputs:

  • decode '0'(0x30)..'9'(0x39): returns 0..9
  • decode 'a'(0x61)..'f'(0x66): returns 10..15
  • decode boundary rejects: '/' (0x2f), ':' (0x3a), '` (0x60), 'g' (0x67), and 'A'-'F' all return 0xFFFF, propagating to InvalidEncoding via decode_inner.
  • encode 0..9 -> '0'..'9'; encode 10..15 -> 'a'..'f'.

All branches use bitwise sign-mask arithmetic on i16; no data-dependent branches or table lookups. Justifies parser-impl-correct.

src/upper.rs

src/upper.rs, line 33-50

/// Encode input byte slice into a [`&str`] containing upper Base16 (hex).
pub fn encode_str<'a>(src: &[u8], dst: &'a mut [u8]) -> Result<&'a str, Error> {
    encode(src, dst).map(|r| unsafe { core::str::from_utf8_unchecked(r) })
}

/// Encode input byte slice into a [`String`] containing upper Base16 (hex).
///
/// # Panics
/// If `input` length is greater than `usize::MAX/2`.
#[cfg(feature = "alloc")]
pub fn encode_string(input: &[u8]) -> String {
    let elen = encoded_len(input);
    let mut dst = vec![0u8; elen];
    let res = encode(input, &mut dst).expect("dst length is correct");

    debug_assert_eq!(elen, res.len());
    unsafe { crate::String::from_utf8_unchecked(dst) }
}

encode_str and encode_string rely on from_utf8_unchecked because encode/encode_nibble produce only ASCII (0x30-0x39, 0x41-0x46). See FINDING-1 for the missing SAFETY comment; the invariant itself holds, justifying unsafe-safe.

tests/lib.rs

Integration tests cover encode/decode round-trips for both lower and upper, mixed-case decode, odd-size rejection, encode_string/decode_vec over a fixed byte for lengths 0..64, and HexDisplay upper/lower formatting. These exercise the four unsafe { from_utf8_unchecked } sites via encode_str/encode_string/HexDisplay, justifying unsafe-tested. Coverage gaps relative to a constant-time parser are documented in FINDING-2.