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;
// Generate key material from the system RNG rather than a literal (needs `rand`).
let mut key: Aes256Key = from_random;
// Scoped access — preferred; the borrow cannot outlive the closure
let long_enough = pw.with_secret; // validate in-closure; never log a secret's length
// Mutable scoped access. For Dynamic<String> / Dynamic<Vec<u8>>, prefer
// capacity-stable mutations or pre-allocate before wrapping; see SECURITY.md.
let next: Aes256Key = from_random;
next.with_secret;
// 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()viaSecretLen— without exposing contents (length itself can still be sensitive)- 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 looks_degenerate = key.with_secret;
assert!; // every byte is 0xAB
// Mutate
key.with_secret_mut;
RustCrypto in-place cipher ops
BlockEncrypt/BlockDecrypt take &mut GenericArray<u8, U16>, and
GenericArray::from_mut_slice borrows rather than copies — so the cipher can run
inside the wrapper and the plaintext never leaves it:
use ;
use Aes128;
use ;
let cipher = new;
let mut block: = new;
block.with_secret_mut;
Copying out instead — block.with_secret(|b| aes::Block::from(*b)) — is the footgun:
the resulting Block is an ordinary stack value that nothing wipes on drop. See the
Fixed rustdoc
for both shapes side by side.
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 named secret types
Two families, differing in exactly one respect — whether the compiler can tell two same-shaped secrets apart.
Aliases (fixed_alias!, dynamic_alias!, fixed_generic_alias!, dynamic_generic_alias!) expand to plain pub type aliases with full visibility control, optional doc strings, and (for fixed_alias!) a compile-time zero-size guard. 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:
use ;
fixed_alias!;
dynamic_alias!;
Newtypes (fixed_newtype!, dynamic_newtype!) expand to structs instead, so two of the same shape are distinct types. Reach for these when distinct cryptographic roles share a shape — an encryption key and a MAC key are both Fixed<[u8; 32]>, and under an alias the compiler cannot tell them apart:
use fixed_newtype;
fixed_newtype!;
fixed_newtype!;
// seal(&mac, &enc) does not compile — the roles cannot be swapped by accident.
Generated newtypes carry the same guarantees as the wrapper (zeroize on drop, redacted Debug, access only via RevealSecret), are #[repr(transparent)] so they cost nothing at runtime, and have no Deref — the separation is total, not by-value-only. No From<Wrapper> or Deref is generated, so an alias-typed value cannot become a newtype through .into() and a newtype never coerces back to its base; base-wrapper access is opt-in per newtype and split by direction (derive: [FromWrapper] to construct from the base, derive: [IntoWrapper] to reach it; WrapperAccess is both) and should be audited like expose_secret(). In a mixed tree the base type is the pool every plain alias lives in: FromWrapper on a boundary type accepts all of them, and IntoWrapper on a secret role downgrades it to the least-sensitive alias sharing its base — neither token is the sufficient default more often than it looks.
See [fixed_alias!], [dynamic_alias!], [fixed_generic_alias!], [dynamic_generic_alias!], [fixed_newtype!], and [dynamic_newtype!] 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 SecretLen;
// Length is metadata, not contents — but for variable-length secrets it can
// still be sensitive. Validate against it; don't log it.
What You Get
- Zero-cost safety — mandatory zeroization on drop;
no_std/no_allocsupport. - Audit-first API — a held secret cannot leak via
Deref:Fixed/Dynamicimplement none. Access requires explicitwith_secretscopes or an auditableexpose_secretescape hatch. Extraction (into_inner,to_*_zeroizing) hands ownership to the caller and returns output wrappers that do deref — see Where accident-prevention ends. - Named secret types —
*_alias!macros createtypealiases overFixed/Dynamicthat inherit redactedDebugand zeroize-on-drop; same-shape aliases (e.g. twoFixed<[u8; 32]>aliases) are interchangeable at the type level. When distinct cryptographic roles share a shape,fixed_newtype!/dynamic_newtype!generatestructs instead, so the compiler rejects a swapped key role at the call site. - Batteries included — optional, zero-overhead support for serde, constant-time comparison (
subtle), and secure encoding (hex, base32, base64url, bech32/m). - No unsafe code — enforced with
#![forbid(unsafe_code)].
Installation
Default (alloc enabled — Fixed<T> + Dynamic<T> + full zeroization):
[]
= "0.8.0-rc.11"
No-heap / embedded (Fixed<T> only — pure stack / no_std):
= { = "0.8.0-rc.11", = false }
Batteries-included:
= { = "0.8.0-rc.11", = ["full"] }
Encoding & Decoding
secure-gate provides symmetric, zero-overhead encoding and decoding for five formats: hex, base32 (RFC 4648 §6), 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 |
| Base32 (RFC 4648 §6) | ToBase32 |
FromBase32Str |
encoding-base32 |
| Base64URL | ToBase64Url |
FromBase64UrlStr |
encoding-base64 |
| Bech32 (BIP-173) | ToBech32 |
FromBech32Str |
encoding-bech32 |
| Bech32m (BIP-350) | ToBech32m |
FromBech32mStr |
encoding-bech32m |
Base32 is here for TOTP/HOTP interop: otpauth:// key URIs (RFC 6238 / RFC 4226) carry the shared secret as uppercase, unpadded Base32, and Base32 is the densest encoding that fits QR alphanumeric mode.
Encoding (to string)
The wrapper encoding methods are trait impls, so the trait must be in scope — use secure_gate::{ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m}; — before key.to_base32() resolves. Plain methods return String (for public values). Use the zeroizing variants (returning [EncodedSecret]) when the encoded form should remain sensitive.
use ;
#
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, ToBase32, 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 |
| Base32 | try_from_base32(s) |
Base32Error (RFC 4648 §6, uppercase, unpadded) |
| 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 - Extract:
into_inner(hands the secret to the caller asInnerSecret<T>),as_reader(yields a reader over the secret bytes) - Encode:
to_hex,to_hex_upper,to_base32,to_base64url,try_to_bech32,try_to_bech32m,to_*_zeroizing,try_to_bech32*_zeroizing - Decode:
try_from_hex,try_from_base32,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, base32, base64url, bech32, bech32m). Encoding traits require alloc; Fixed::try_from_* decoding is no-alloc. |
encoding-hex |
ToHex / FromHexStr — constant-time via base16ct |
encoding-base32 |
ToBase32 / FromBase32Str — constant-time via base32ct; RFC 4648 §6, uppercase and unpadded |
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