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 key: Aes256Key = new;
// Scoped access — preferred; the borrow cannot outlive the closure
pw.with_secret;
// Mutable scoped access
pw.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 typed newtype wrappers with full visibility control, optional doc strings, and compile-time zero-size guards:
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. - Type-safe wrappers — macros create newtype aliases that redact
Debugoutput automatically. - 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.0-rc.{x}"
No-heap / embedded (Fixed<T> only — pure stack / no_std):
= { = "0.8.0-rc.{x}", = false }
Batteries-included:
= { = "0.8.0-rc.{x}", = ["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)]
Read SECURITY.md for the full threat model and mitigations.
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. Fixed<T> with rand works heap-free. 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