Dependency-free, no_std-first secret memory sanitization for Rust.
Redacted secret containers, volatile clearing, data-oblivious helpers, and optional native hardening.
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.
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:
- Essentials: ordinary owned containers and direct wiping.
- Protected operations: data-oblivious comparison and recommended locked storage.
- 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.
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.
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.
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:
SecretBoxByteshas one runtime-length allocation whose capacity cannot grow.SecretVecandSecretStringuse managed growth that clears replaced allocations before releasing them.- bounded variants reject public lengths above
MAX, including during serde ingestion. try_with_capacityreturnsSecretAllocationError; generated constructors returnSecretGenerateError<E>so allocation and generator failures remain distinct.try_from_slice_boundedandtry_from_secret_str_boundedenforce public byte limits before allocation.try_from_fn_boundeddoes the same for byte count;try_from_chars_boundedchecks worst-case UTF-8 byte capacity with checked arithmetic before allocation or callback execution.- infallible
with_capacity,from_fn, andfrom_charsare for trusted, already-bounded public sizes and retain ordinary allocation panic/abort behavior. - ownership-taking constructors such as
from_vecandfrom_stringavoid a second heap allocation but make the supplied allocation secret storage.
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.
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.
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.
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.
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.
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, andprofile-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.
| 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:
- Threat model
- Guarantees
- Non-guarantees
- Safety and unsafe boundaries
- Target tiers
- Evidence
- Error handling
- Deployment hardening
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.
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.
Run the local gate before release-sensitive changes:
scripts/checks.shIt 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-tagThe script publishes the five crates in dependency order and pauses while crates.io indexes dependencies.
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.