Skip to content

valkyoth/sanitization

Repository files navigation

Dependency-free, no_std-first secret memory sanitization for Rust.
Redacted secret containers, volatile clearing, data-oblivious helpers, and optional native hardening.


sanitization Rust crate overview

sanitization

sanitization provides redacted, non-Copy, clear-on-drop secret containers for Rust. The default crate is dependency-free and no_std; heap storage, derive macros, ecosystem interop, memory locking, guard pages, and other hardening are explicit opt-ins.

Every crate-owned clearing path reaches one audited internal volatile-write backend. The crate reduces accidental retention and exposure. It does not make an entire process, operating system, compiler, or hardware platform secret.

The 2.0 line has intentional breaking changes. Existing 1.x users should read the 2.0 migration guide.

Start Here

Choose the narrowest type that matches the storage requirement:

Need Start with
Fixed-size key, nonce, or token SecretBytes<N>
Runtime-length bytes with fixed capacity SecretBoxBytes with alloc
Growable secret bytes SecretVec with alloc
Secret UTF-8 text SecretString with alloc
Untrusted input with a public maximum BoundedSecretVec<MAX> or BoundedSecretString<MAX>
Fallible generated dynamic input try_from_fn_bounded or try_from_chars_bounded
Custom value needing current-value clearing only Secret<T> where T: SecureSanitize
Generic shared exposure with reviewed stable storage Secret<T> where T: StableSharedSecretStorage
Generic mutable exposure with reviewed stable storage Secret<T> where T: StableMutableSecretStorage
Closed production storage allow-list AllowlistedSecret<T, PrivatePolicy>
Fixed key that should avoid swap/pagefiles LockedSecretBytes<N> with memory-lock
Dynamic locked bytes or text LockedSecretVec or LockedSecretString
Many same-size locked keys SecretPool<N, SLOTS>
Guarded dynamic mapping GuardedSecretVec or GuardedSecretString
Fixed mapping sealed between scoped accesses SealedSecretBytes<N> with page-seal
Secret-derived control flow ct::Choice, fixed CT helpers, and explicit declassification
One successful scoped access ConsumeOnceSecret<T>
Existing ordinary byte storage sanitization::wipe
Existing ecosystem trait bounds zeroize-interop, subtle-interop, or a companion crate

Use the API in three levels:

  1. Essentials: ordinary owned containers and direct wiping.
  2. Protected operations: data-oblivious comparison and recommended locked storage.
  3. Advanced hardening: custom protection policy, guard pages, sealing, classified CT ownership, cache/register controls, and specialized backends.

Most applications should begin at level 1 and add only the level 2 controls required by their threat model.

Install

Fixed-size no_std secrets need no feature flags:

[dependencies]
sanitization = "2.0.0"

Heap-backed byte and text containers:

[dependencies]
sanitization = { version = "2.0.0", features = ["alloc"] }

Recommended native hardening profile:

[dependencies]
sanitization = { version = "2.0.0", features = ["profile-hardened-native"] }

This profile includes OS-random canaries and strict-canary-check. Enabling canary-check alone uses deterministic address-derived words intended to catch accidental corruption; it is not an attacker-resistant integrity control.

Optional derives:

[dependencies]
sanitization = { version = "2.0.0", features = ["derive"] }

See the complete feature reference before combining platform features.

Level 1: Essential Secret Ownership

Fixed-Size Secrets

Use SecretBytes<N> when the size is known at compile time. Generate directly into the container where possible instead of first creating an ordinary secret buffer.

use sanitization::SecretBytes;

let mut key = SecretBytes::<32>::from_fn(|index| index as u8);

let first = key.expose_secret(|bytes| bytes[0]);
assert_eq!(first, 0);
assert!(key.constant_time_eq(&[
    0, 1, 2, 3, 4, 5, 6, 7,
    8, 9, 10, 11, 12, 13, 14, 15,
    16, 17, 18, 19, 20, 21, 22, 23,
    24, 25, 26, 27, 28, 29, 30, 31,
]));

key.replace_from_fn(|index| 31 - index as u8);
key.secure_clear();
assert!(key.constant_time_eq(&[0; 32]));

SecretBytes<N> does not implement Clone, Copy, Deref, AsRef<[u8]>, or secret-printing Debug. expose_secret directly borrows owned storage. Use the reason-bearing export_secret_copy only when an external API requires an independent temporary copy.

use sanitization::SecretBytes;

let key = SecretBytes::<32>::from_array([7; 32]);
let public_identifier = key.export_secret_copy(
    "protocol callback returns public key identifier byte",
    |bytes| bytes[0],
);
assert_eq!(public_identifier, 7);

The crate clears that temporary on normal return and unwinding, but cannot clear copies made by the callback or copies surviving process abort.

Heap Bytes And Text

Enable alloc for runtime-length secrets:

use sanitization::{SecretBoxBytes, SecretString, SecretVec};

let fixed = SecretBoxBytes::from_slice(b"fixed-token");
assert!(fixed.constant_time_eq(b"fixed-token"));

let mut bytes = SecretVec::from_slice(b"session-key");
bytes.extend_from_slice(b"-v2");
assert!(bytes.constant_time_eq(b"session-key-v2"));

let mut text = SecretString::from_secret_str("bearer-token");
text.push_str("-v2");
assert!(text.constant_time_eq("bearer-token-v2"));

let generated = SecretVec::try_from_fn_bounded(32, 4096, |index| {
    Ok::<u8, &'static str>(index as u8)
})?;
assert_eq!(generated.len(), 32);
# Ok::<(), sanitization::SecretGenerateError<&'static str>>(())

Choose deliberately:

  • SecretBoxBytes has one runtime-length allocation whose capacity cannot grow.
  • SecretVec and SecretString use managed growth that clears replaced allocations before releasing them.
  • bounded variants reject public lengths above MAX, including during serde ingestion.
  • try_with_capacity returns SecretAllocationError; generated constructors return SecretGenerateError<E> so allocation and generator failures remain distinct.
  • try_from_slice_bounded and try_from_secret_str_bounded enforce public byte limits before allocation. try_from_fn_bounded does the same for byte count; try_from_chars_bounded checks worst-case UTF-8 byte capacity with checked arithmetic before allocation or callback execution.
  • infallible with_capacity, from_fn, and from_chars are for trusted, already-bounded public sizes and retain ordinary allocation panic/abort behavior.
  • ownership-taking constructors such as from_vec and from_string avoid a second heap allocation but make the supplied allocation secret storage.

Direct Wiping

Use the sealed wipe module only when a secret already exists in ordinary supported storage:

use sanitization::wipe;

let mut bytes = [0xA5; 32];
wipe::bytes(&mut bytes);
assert_eq!(bytes, [0; 32]);

Use wipe::maybe_uninit for non-live MaybeUninit<T> storage such as an allocator or fixed-capacity container's spare slots. It performs volatile writes without constructing references to uninitialized byte values.

With alloc, wipe::vec and wipe::string clear the reachable allocation. WipeOnDrop<T> is available only for audited built-in byte/text types. Custom structured types should implement SecureSanitize and use Secret<T> or the optional derives.

Custom Structs

The derive feature generates field-wise struct sanitization:

use sanitization::{SecretBytes, SecureSanitize, SecureSanitizeOnDrop};

#[derive(SecureSanitize, SecureSanitizeOnDrop)]
struct Credentials {
    key: SecretBytes<32>,
    nonce: SecretBytes<12>,
}

SecureSanitizeOnDrop requires DropSafeSanitize + Unpin and invokes the complete sanitizer, preserving aggregate cleanup such as external storage and ordering. #[derive(SecureSanitize)] supplies the drop-safe contract for its generated field-wise sanitizer. A reviewed manual aggregate sanitizer must implement DropSafeSanitize explicitly. For generic drop structs, declare T: SecureSanitize + Unpin on the struct itself. Pinned secret owners require a reviewed pin-aware manual design.

Enum derives are rejected. Safe generated code cannot reach inactive enum representation bytes after variant transitions. Model secret storage with a stable struct layout and keep public state in a separate tag.

Level 2: Protected Operations

Data-Oblivious Final Decisions

The native ct module aims to avoid secret-dependent control flow and memory access under documented target and compiler conditions. It does not promise identical wall-clock timing on every machine.

For a final fixed-size authentication decision, use the reason-bearing helper:

use sanitization::ct::declassified_eq_fixed;

let expected = [7u8; 32];
let received = [7u8; 32];

let accepted = declassified_eq_fixed(
    &expected,
    &received,
    "authentication comparison result is public",
);
assert!(accepted);

Use eq_fixed, cmp_fixed, and Choice when the result must remain inside the data-oblivious domain for further composition. eq_public_len and declassified_eq_public_len treat length as public and may return immediately on mismatch.

Declassification reasons are review labels, not authorization. This repository runs scripts/lint-declassification-reasons.py to reject dynamic, placeholder, and generic reasons. High-assurance downstream projects can run the same lint.

Locked Native Storage

memory-lock provides private native mappings backed by mlock/VirtualLock or platform equivalents. Generate or decode directly into locked storage when possible:

# #[cfg(feature = "memory-lock")]
# {
use sanitization::LockedSecretBytes;

let mut key = LockedSecretBytes::<32>::try_from_fill(|output| {
    output.fill(7);
    Ok::<(), std::io::Error>(())
})?;

assert_eq!(key.try_constant_time_eq(&[7; 32]), Ok(true));
key.try_replace_from_fallible_fill(|output| {
    output.fill(9);
    Ok::<(), std::io::Error>(())
})?;
# Ok::<(), Box<dyn std::error::Error>>(())
# }

When a custom protection request is needed, initialize the resulting mapping without an intermediate array:

# #[cfg(feature = "memory-lock")]
# {
use sanitization::{LockedSecretBytes, ProtectionRequest};

let request = ProtectionRequest::profile_hardened_native();
let key = LockedSecretBytes::<32>::zeroed_with_protection(request)?
    .try_init_with(|output| {
        // Decode, derive, or ask an RNG to write directly into `output`.
        output.fill(7);
        Ok::<(), std::io::Error>(())
    })?;

assert_eq!(key.try_constant_time_eq(&[7; 32]), Ok(true));
# Ok::<(), Box<dyn std::error::Error>>(())
# }

Prefer direct final-storage generation through try_from_fn, try_from_fill, or try_init_with. Avoid Clone, to_vec, formatting, and temporary arrays for secret material. Keep unavoidable cryptographic scratch buffers in clear-on-drop owners, and keep mutable exposure closures as short as possible. These practices reduce avoidable copies; they cannot prove that compiler moves or register spills never create historical copies.

For the reviewed native bundle, enable profile-hardened-native and use its type-associated constructor:

# #[cfg(feature = "profile-hardened-native")]
# {
use sanitization::LockedSecretBytes;

let key = LockedSecretBytes::<32>::zeroed_hardened_native()?;
let request = key.protection_request();

if !key.protection_report().satisfies(request) {
    return Err("preferred runtime protections were unavailable".into());
}
# Ok::<(), Box<dyn std::error::Error>>(())
# }

Cargo features compile capabilities; they do not prove that runtime controls succeeded. Required failures return ProtectionError. Preferred failures are visible in ProtectionReport. Validate the report once at startup according to deployment policy.

Locked memory does not by itself control hibernation images, every crash-dump path, privileged reads, DMA, firmware, or external copies. Platform coverage and fork/dump behavior are documented in TARGETS.md and PROTECTION_REPORT.md.

Level 3: Advanced Hardening

Advanced facilities are intentionally separate from the normal path:

Requirement Facility Read first
Custom required/preferred runtime controls ProtectionRequest and ProtectionReport Protection reports
Guard pages around dynamic storage GuardedSecretVec, GuardedSecretString Advanced usage
Inaccessible pages between accesses SealedSecretBytes<N> Safety
Secret-bearing optional/result CT state SecretValue, SecretCtOption, SecretCtResult Guarantees
Cache-line eviction after clearing cache-flush Barrier strategy
Best-effort vector-register clearing register-scrub Barrier strategy
Many fixed secrets under one lock quota SecretPool<N, SLOTS> Advanced usage
N-of-N fixed split storage SplitSecretBytes<N, SHARES> Threat model
HSM, TEE, enclave, or keystore adapters hardware-secrets traits Advanced usage

Do not enable advanced features merely because they sound stronger. Each one has separate target assumptions, failure modes, and residual risks. The advanced usage guide provides short recipes and links to the normative documents.

Page-sealed callers that must observe final mapping cleanup should call SealedSecretBytes::try_close() before drop. It reports page normalization, unlock, and unmap failures without exposing bytes, addresses, or canary values; Drop remains the final best-effort fallback.

High-assurance applications should use AllowlistedSecret<T, P> as their internal production alias, keep P private or pub(crate), and run scripts/lint-storage-policies.py over sensitive modules. The lint rejects direct Secret<T>, unapproved storage-marker implementations, and public policy types. See STORAGE_CONTRACTS.md and the compile-checked high_assurance_policy example.

Feature And Platform Reference

The default feature set is empty. The major opt-in groups are:

  • allocation and integration: alloc, std, derive, serde, zeroize-interop, subtle-interop;
  • mapped storage: memory-lock, guard-pages, page-seal, canary-check, random-canary;
  • CT and post-use controls: asm-compare, strict-compare, cache-flush, register-scrub;
  • named profiles: profile-hardened-native, profile-guarded-native, and profile-hardened-linux.

See FEATURES.md for every feature, implication, companion crate, and profile policy. Native profiles fail to compile on WASM rather than silently degrading.

WASM compatibility is explicit and reduced-guarantee. Pair memory-lock with wasm-compat only when API compatibility is required; WASM linear memory has no host mlock, mprotect, fork policy, or native volatile guarantee across a JIT boundary. See FEATURE_PROFILES.md.

Trust Dashboard

Area Status
License MIT OR Apache-2.0
MSRV Rust 1.90.0
Pinned toolchain Rust 1.97.1
Default target no_std
Default external runtime dependencies zero
Unsafe policy denied at crate root and isolated in documented modules
Clear primitive volatile writes by default
Proc macros optional derive feature
Formal evidence bounded Kani harnesses for selected properties
Main guarantee narrow ownership, redaction, and clear-on-drop hygiene

High-assurance users should read these documents in order:

  1. Threat model
  2. Guarantees
  3. Non-guarantees
  4. Safety and unsafe boundaries
  5. Target tiers
  6. Evidence
  7. Error handling
  8. Deployment hardening

Rust Version Support

The MSRV is Rust 1.90.0. Release development is pinned to Rust 1.97.1, and the release gate checks compatibility from 1.90.0 through the pinned stable toolchain. The online release preflight also verifies that the pin is the current stable patch release without changing the MSRV.

Ecosystem Integration

The core crate remains dependency-free by default. Opt-in integration is split by ownership boundary:

Integration Use
sanitization-derive Struct derives for sanitization and conservative CT traits
zeroize-interop Existing APIs requiring zeroize trait bounds
subtle-interop Existing APIs requiring subtle::ConstantTimeEq
sanitization-arrayvec ArrayVec storage
sanitization-bytes Fixed-capacity BytesMut storage
sanitization-crypto-interop SHA-2/BLAKE3 cleanup wrappers and HMAC-SHA2 helpers

The optional derive dependency is exact-pinned to the runtime's version because generated code may reference runtime traits introduced by that same release. The release script publishes and waits for the derive crate before the core.

zeroize remains broader for retrofitting existing ecosystem types. sanitization focuses on ownership, lifecycle, explicit exposure, and optional platform protection. Interop features bridge trait bounds without replacing the core clearing backend.

Verification And Release Checks

Run the local gate before release-sensitive changes:

scripts/checks.sh

It covers formatting, feature matrices, docs, examples, linting, negative fixtures, downstream migration, codegen inspection, leakage smoke checks, Loom, lifecycle probes, API snapshots, package archives, and optional Kani/Miri when installed. Native target evidence and timing runs are documented in EVIDENCE.md and LEAKAGE_TESTS.md.

Release publication is staged through:

scripts/release_crates.py --version 2.0.0 --prepare-only
scripts/release_crates.py --require-tag

The script publishes the five crates in dependency order and pauses while crates.io indexes dependencies.

Limits

Important limits include:

  • safe Rust cannot reliably scrub historical stack frames or compiler-created copies;
  • destructors do not run after process abort;
  • exposure closures and external libraries can copy, log, export, or retain secret data;
  • memory locking does not solve every swap, hibernation, dump, debugger, privileged-read, DMA, firmware, or VM snapshot threat;
  • data-oblivious source structure is not a universal hardware timing guarantee;
  • cache flush and register scrub helpers cover only their documented target subsets;
  • Miri and Kani provide targeted evidence, not native OS or concurrency proof.

See NON_GUARANTEES.md, THREAT_MODEL.md, and SECURITY.md before using this crate for high-assurance secret handling.

About

A dependency-free, no_std Rust crate for secure in-memory secret handling, constant-time checks, and automated clear-on-drop zeroization.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors