secure-gate
[!NOTE] Note: This is the LTS (Long-Term Support) branch for secure-gate 0.8.x (
release/0.8). It targets Rust Edition 2021 and MSRV 1.70, making it the right choice for projects that cannot yet move to Rust 1.85+. For the latest features see themainbranch (v0.9.x).
| Aspect | 0.8.x | 0.9.x |
|---|---|---|
| Edition | 2021 | 2024 |
| MSRV | 1.70 | 1.85 |
| Status | LTS / stable patches | Active development |
| Branch | release/0.8 |
main |
Secure wrappers for secrets with explicit access and mandatory zeroization — a no_std-compatible, zero-overhead library with audit-friendly access patterns.
Security Notice: This crate has not undergone independent audit. Review the code and SECURITY.md before production use. No unsafe code — enforced with
#![forbid(unsafe_code)].
Quick Start
use ;
dynamic_alias!; // Dynamic<String>
fixed_alias!; // Fixed<[u8; 32]>
let mut pw: Password = "hunter2".into;
let mut key: Aes256Key = new;
// Scoped access — preferred; the borrow cannot outlive the closure
pw.with_secret;
// Mutable scoped access. For Dynamic<String> / Dynamic<Vec<u8>>, prefer
// capacity-stable mutations or pre-allocate before wrapping; see SECURITY.md.
key.with_secret_mut;
// Direct reference — auditable escape hatch (e.g. FFI, third-party APIs)
assert_eq!;
pw.expose_secret_mut.clear;
Core Concepts
Fixed<T> (stack-allocated) and Dynamic<T> (heap, requires alloc) share the same access interface:
Debugoutput →[REDACTED].len()/.is_empty()without exposure- Zeroize on drop (always)
- Access via
.with_secret(|s| ...)(preferred) or.expose_secret()(auditable escape hatch) - Owned extraction via
.into_inner()→InnerSecret<T>(wrapsZeroizing<T>, transfers zeroization to caller) - Streaming I/O via
impl Writeand.as_reader()forDynamic<Vec<u8>>(requiresstd)
Preferred: scoped access
use ;
let mut key: = new;
// Read — closure borrow cannot outlive the call
let sum: u32 = key.with_secret;
// Mutate
key.with_secret_mut;
Direct reference — auditable escape hatch
// Use only when a long-lived reference is unavoidable (FFI, third-party APIs)
use ;
let key: = new;
let raw: & = key.expose_secret;
Owned consumption — transfer ownership
// When you need to move the secret value out (FFI hand-off, type migration)
use ;
let key: = new;
let owned: InnerSecret = key.into_inner;
// Zeroizes its 32 bytes when it drops — same guarantee as Fixed<[u8; 32]>.
assert_eq!;
Macros for typed aliases
fixed_alias!, dynamic_alias!, fixed_generic_alias!, and dynamic_generic_alias! create named type aliases over Fixed<T> / Dynamic<T> with full visibility control, optional doc strings, and (for fixed_alias!) a compile-time zero-size guard. They expand to plain pub type aliases, not newtypes — two aliases over the same underlying type are the same nominal type and assignable to each other. Use them for readability and audit grep targets, not for nominal separation between cryptographic roles:
use ;
fixed_alias!;
dynamic_alias!;
See [fixed_alias!], [dynamic_alias!], [fixed_generic_alias!], and [dynamic_generic_alias!] in the API docs.
Zero-size behavior note
fixed_alias!(Name, N) rejects N = 0 at compile time (via a const-eval index-out-of-bounds guard).
However, fixed_generic_alias!, dynamic_alias!, and dynamic_generic_alias! allow zero-sized types (SecretBuffer<0>, Dynamic<[u8; 0]>, Dynamic<()> etc.). These compile successfully but have no cryptographic value and should never be used in production. Always validate that the effective size is > 0 in your unit tests when using the generic or dynamic alias macros.
See also the Best Practices section in SECURITY.md for the equivalent guidance.
Polymorphic / generic code
use RevealSecret;
What You Get
- Zero-cost safety — mandatory zeroization on drop;
no_std/no_allocsupport. - Audit-first API — secrets cannot leak via
Deref. Access requires explicitwith_secretscopes or an auditableexpose_secretescape hatch. - Named aliases — macros create
typealiases overFixed/Dynamicthat inherit redactedDebugand zeroize-on-drop. These are plain type aliases, not newtypes: same-shape aliases (e.g. twoFixed<[u8; 32]>aliases) are interchangeable at the type level — wrap in astructnewtype yourself if you need nominal separation. - Batteries included — optional, zero-overhead support for serde, constant-time comparison (
subtle), and secure encoding (hex, base64url, bech32/m). - No unsafe code — enforced with
#![forbid(unsafe_code)].
Installation
Default (alloc enabled — Fixed<T> + Dynamic<T> + full zeroization):
[]
= "0.8"
No-heap / embedded (Fixed<T> only — pure stack / no_std):
= { = "0.8", = false }
Batteries-included:
= { = "0.8", = ["full"] }
Encoding & Decoding
secure-gate provides symmetric, zero-overhead encoding and decoding for four formats: hex, base64url, bech32 (BIP-173), and bech32m (BIP-350). All operations are explicit and return Result on failure.
Available traits
| Format | Encode | Decode | Feature |
|---|---|---|---|
| Hex | ToHex |
FromHexStr |
encoding-hex |
| Base64URL | ToBase64Url |
FromBase64UrlStr |
encoding-base64 |
| Bech32 (BIP-173) | ToBech32 |
FromBech32Str |
encoding-bech32 |
| Bech32m (BIP-350) | ToBech32m |
FromBech32mStr |
encoding-bech32m |
Encoding (to string)
Use trait methods or inherent convenience methods on the wrappers. Plain methods return String (for public values). Use the zeroizing variants (returning [EncodedSecret]) when the encoded form should remain sensitive.
let key: = ...;
// Plain — returns String (suitable for public encodings)
let hex = key.to_hex;
let hex_u = key.to_hex_upper;
let b64 = key.to_base64url;
let bech32 = key.try_to_bech32?;
let bech32m = key.try_to_bech32m?;
// Zeroizing — returns EncodedSecret (preserves zeroization for sensitive encodings)
let hex_z = key.to_hex_zeroizing;
let hex_u_z = key.to_hex_upper_zeroizing;
let b64_z = key.to_base64url_zeroizing;
let bech32_z = key.try_to_bech32_zeroizing?;
let bech32m_z = key.try_to_bech32m_zeroizing?;
// Scoped on the inner bytes (preferred when you want `with_secret` in audit sweeps)
let hex_scoped = key.with_secret;
let b64_scoped = key.with_secret;
let bech32_scoped = key.with_secret?;
let bech32m_scoped = key.with_secret?;
// Trait-level zeroizing APIs are also available on byte-like values:
let hex_trait_z = key.with_secret;
let b64_trait_z = key.with_secret;
Zeroizing variants (*_zeroizing) return [EncodedSecret] (wrapping Zeroizing<String> with redacted Debug) to maintain the zeroization guarantee for sensitive encoded output. These APIs are available both on wrapper conveniences (Fixed / Dynamic) and on encoding traits (ToHex, ToBase64Url, ToBech32, ToBech32m).
Direct Constructors (Recommended)
Both Fixed<[u8; N]> and Dynamic<Vec<u8>> offer one-shot constructors from strings. Both use panic-safe Zeroizing-wrapped decode buffers internally. Fixed also supports a no-alloc path that decodes directly into stack storage when alloc is disabled.
| Format | Method | Notes |
|---|---|---|
| Hex | try_from_hex(s) |
HexError |
| Base64URL | try_from_base64url(s) |
Base64Error (unpadded, URL-safe) |
| Bech32 (BIP-173) | try_from_bech32(s, hrp) |
HRP validated; Bech32Error::UnexpectedHrp |
| Bech32 (unchecked) | try_from_bech32_unchecked(s) |
No HRP; Bech32Error |
| Bech32m (BIP-350) | try_from_bech32m(s, hrp) |
HRP validated; Bech32Error::UnexpectedHrp |
| Bech32m (unchecked) | try_from_bech32m_unchecked(s) |
No HRP; Bech32Error |
Security notes:
- Prefer HRP-validated constructors to prevent cross-protocol confusion attacks.
- Use
_uncheckedonly when HRP is validated upstream. - All constructors guarantee zeroization even on OOM panic via
Zeroizing. - For encoding output, prefer zeroizing methods when the encoded string itself is sensitive (see
EncodedSecretandSECURITY.md).
Serde
serde-deserialize decodes directly to the inner type. After deserialization completes, temporary buffers for Dynamic<Vec<u8>> and Dynamic<String> are Zeroizing-wrapped — oversized buffers are zeroized even on rejection. The default limit is MAX_DESERIALIZE_BYTES (1 MiB); call Dynamic::deserialize_with_limit to set a custom ceiling. Serialization requires the SerializableSecret marker trait.
Note:
MAX_DESERIALIZE_BYTES(anddeserialize_with_limit) is enforced after the upstream deserializer has fully materialized the payload. It is a result-length acceptance bound, not a pre-allocation DoS guard. For untrusted input, enforce size limits at the transport or parser layer upstream.
See [SerializableSecret] in the API docs for the full example.
Random Generation
from_random() uses the system RNG (OsRng), panics on failure, and is heap-free for Fixed<T> (no_std / no_alloc). from_rng fills from any TryCryptoRng + TryRngCore and returns Result (e.g. seeded StdRng in tests). Dynamic::from_random / from_rng require alloc (implicit — Dynamic<T> itself already requires it). See [Fixed::from_random], [Fixed::from_rng], [Dynamic::from_random], and [Dynamic::from_rng] in the API docs.
Security Model
- Explicit access only — all caller-facing access requires
.with_secret()/.expose_secret(); no silent leaks. Internal impls (Clone,Serialize) access.innerdirectly but require opt-in marker traits. - Zeroize on drop — always active; inner type must implement
Zeroize - Timing-safe equality —
ct-eqfeature (.ct_eq()) routes throughexpose_secret(), honoring the explicit-access model - No unsafe code — enforced with
#![forbid(unsafe_code)]
For Dynamic<Vec<_>> and Dynamic<String>, avoid capacity-changing mutations
after wrapping unless your deployment handles allocator-level residue. For
known-size heap-only key material, prefer Dynamic<[u8; N]> (boxed array — no
realloc surface). See SECURITY.md for the realloc threat-model note and
operational mitigations.
Inherent Rust limitations
Three universal in-memory-secret limits apply (same across C, C++, Go, and
Rust): stack-move residue (mitigated by Fixed::new_with, pass-by-reference,
or switching to Dynamic<T>), heap-reallocation residue (mitigated by
pre-sizing, Dynamic<[u8; N]>, or the
zeroizing-alloc global allocator),
and swap / core dumps (OS-level — mlock, encrypted swap, disabled core
dumps).
Read SECURITY.md for the full threat model and mitigations, including the dedicated § Inherent Rust Limitations section.
Audit Surface (Secret Materialization)
Encoding and decoding methods are convenience wrappers that internally use scoped with_secret access — they do not bypass the security model, but return the fully materialized encoded value.
They exist because users who call them have already decided to reveal the secret — the wrapper reduces boilerplate and avoids long-lived raw references.
Zeroizing variants (*_zeroizing) return [EncodedSecret] (wrapping Zeroizing<String> with redacted Debug) to maintain the zeroization guarantee for sensitive encoded output.
Audit every exposure point by searching your codebase for:
- Access:
expose_secret,expose_secret_mut,with_secret,with_secret_mut - Encode:
to_hex,to_hex_upper,to_base64url,try_to_bech32,try_to_bech32m,to_*_zeroizing,try_to_bech32*_zeroizing - Decode:
try_from_hex,try_from_base64url,try_from_bech32*(including_unchecked)
Best practice: Prefer scoped methods (with_secret / with_secret_mut) when possible — they keep exposure minimal.
What changed in 0.8.0
- Zeroize is now mandatory — memory wiping on drop is always enabled with no feature gate.
Fixed<T>requiresT: Zeroize;Dynamic<T>requiresT: ?Sized + Zeroize.- Removed the old optional
zeroizefeature and related toggles (insecure,secure, andstd). - Real
impl Dropnow callszeroize()on the inner value — the documented zeroization guarantee is fully enforced. - All previous versions (0.1.0–0.7.0-rc.14) were yanked from crates.io.
- Greatly expanded zeroization test suite with multi-size coverage, spare-capacity checks for both
VecandString, runtime heap verification viaProxyAllocator, and AddressSanitizer integration. ExposeSecret→RevealSecrettrait rename —ExposeSecret/ExposeSecretMutare nowRevealSecret/RevealSecretMut. Method names (expose_secret,with_secret, etc.) are unchanged; only code that names the trait explicitly needs updating.ct-eq-hashfeature removed —ConstantTimeEqExt,ct_eq_hash, andct_eq_autoare gone. Use thect-eqfeature and.ct_eq()instead.- Bech32 / Bech32m constructor API changed — Primary decode is now
try_from_bech32(s, hrp)(HRP-validated); unchecked single-arg form istry_from_bech32_unchecked(s)._expect_hrpvariants renamed to_with_hrp. ToHex::to_hex_leftremoved — The partial-reveal logging helper was removed; construct redacted output manually if needed.
Branch support
Version 0.9.x (main) targets Rust Edition 2024 and MSRV 1.85.
For Rust < 1.85, pin secure-gate = "0.8" — the release/0.8 branch (Edition 2021, MSRV 1.70) receives security patches and important backports.
Migrating from secrecy
See the secure-gate-compat crate for drop-in replacements for secrecy 0.8.x and 0.10.x.
See MIGRATING_FROM_SECRECY.md for the full guide.
Features
Common stacks: default (alloc), features = ["full"], or default-features = false for heap-free Fixed only.
| Feature | Description |
|---|---|
alloc (default) |
Heap-allocated Dynamic<T> + full zeroization of Vec/String spare capacity |
std |
Full std support (implies alloc). Enables std::io::Read/Write for Dynamic<Vec<u8>> via as_reader() and direct Write impl. Use default-features = false for no-heap builds. |
rand |
from_random() (system OsRng) and fallible from_rng() for any CryptoRng + RngCore; no_std compatible for Fixed<T> (no heap required). Dynamic::from_random() / from_rng() require alloc (implicit — Dynamic<T> itself requires it). |
ct-eq |
ConstantTimeEq — timing-safe comparison via expose_secret() (subtle) |
encoding |
Meta: all encoding sub-features (hex, base64url, bech32, bech32m). Encoding traits require alloc; Fixed::try_from_* decoding is no-alloc. |
encoding-hex |
ToHex / FromHexStr — constant-time via base16ct |
encoding-base64 |
ToBase64Url / FromBase64UrlStr — constant-time via base64ct |
encoding-bech32 |
ToBech32 / FromBech32Str — BIP-173 |
encoding-bech32m |
ToBech32m / FromBech32mStr — BIP-350 |
serde |
Meta: serde-deserialize + serde-serialize |
serde-deserialize |
Direct deserialization; Zeroizing-wrapped buffers; 1 MiB default limit (MAX_DESERIALIZE_BYTES); use deserialize_with_limit for custom ceilings |
serde-serialize |
Serialize secrets (requires SerializableSecret marker on inner type) |
cloneable |
CloneableSecret opt-in cloning |
full |
All features combined |
no_std compatible — the crate is #![no_std] unless the std feature is enabled, verified in CI by cross-building for thumbv7em-none-eabihf. Fixed<T> with rand works heap-free (on bare-metal targets, getrandom additionally requires a user-configured platform backend for from_random; from_rng with a caller-supplied RNG has no such requirement). Dynamic<T>, encoding traits, and serde require alloc. Fixed::try_from_* decoding works without alloc using constant-time stack-based decoders. Disabled features have zero overhead.
Contributing
MSRV & Lockfile
This crate (release/0.8, 0.8.x) enforces MSRV 1.70 (rust-version = "1.70" in Cargo.toml).
Important: Always use the MSRV toolchain to update Cargo.lock:
Do not use a newer toolchain (1.80+, nightly) to update the lockfile — it generates version 4 format, which Cargo 1.70 cannot read, breaking the MSRV CI job with:
lock file version `4` was found, but this version of Cargo does not understand this lock file
License
MIT OR Apache-2.0