secure-gate 0.8.0-rc.10

Secure wrappers for secrets with explicit access and mandatory zeroization — no_std-compatible, zero-overhead library with audit-friendly access patterns.
Documentation

secure-gate

Docs.rs CI MSRV: 1.70 License: MIT OR Apache-2.0

[!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 the main branch (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 secure_gate::{dynamic_alias, fixed_alias, RevealSecret, RevealSecretMut};

dynamic_alias!(pub Password, String);    // Dynamic<String>
fixed_alias!(pub Aes256Key, 32);         // Fixed<[u8; 32]>

let mut pw: Password = "hunter2".into();
let mut key: Aes256Key = Aes256Key::new([42u8; 32]);

// Scoped access — preferred; the borrow cannot outlive the closure
pw.with_secret(|s| println!("length: {}", s.len()));

// 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(|bytes| bytes[0] = 0);

// Direct reference — auditable escape hatch (e.g. FFI, third-party APIs)
assert_eq!(pw.expose_secret(), "hunter2");
pw.expose_secret_mut().clear();

#[cfg(all(feature = "encoding-hex", feature = "encoding-bech32"))]
{
    use secure_gate::{Fixed, RevealSecret, ToHex, ToBech32, FromHexStr};

    let key: Fixed<[u8; 32]> = Fixed::new([42u8; 32]);

    // Encode to hex (scoped borrow — no long-lived reference)
    let hex: String = key.with_secret(|bytes| bytes.to_hex());

    // Encode to Bech32 (BIP-173) with human-readable prefix "key"
    let bech32: String = key.with_secret(|bytes| {
        bytes.try_to_bech32("key").expect("valid bech32")
    });

    // Round-trip demonstration (decode hex back to bytes)
    let decoded: Vec<u8> = hex.try_from_hex().expect("valid hex");

    // Optional: assert round-trip (useful in real code / tests)
    key.with_secret(|original| assert_eq!(decoded, original));
}

Core Concepts

Fixed<T> (stack-allocated) and Dynamic<T> (heap, requires alloc) share the same access interface:

  • Debug output → [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> (wraps Zeroizing<T>, transfers zeroization to caller)
  • Streaming I/O via impl Write and .as_reader() for Dynamic<Vec<u8>> (requires std)

Preferred: scoped access

use secure_gate::{Fixed, RevealSecret, RevealSecretMut};

let mut key: Fixed<[u8; 32]> = Fixed::new([0xAB; 32]);

// Read — closure borrow cannot outlive the call
let sum: u32 = key.with_secret(|bytes| bytes.iter().map(|&b| b as u32).sum());

// Mutate
key.with_secret_mut(|bytes: &mut [u8; 32]| bytes[0] = 0);

Direct reference — auditable escape hatch

// Use only when a long-lived reference is unavoidable (FFI, third-party APIs)
use secure_gate::{Fixed, RevealSecret};
let key: Fixed<[u8; 32]> = Fixed::new([0xAB; 32]);
let raw: &[u8; 32] = key.expose_secret();

Owned consumption — transfer ownership

// When you need to move the secret value out (FFI hand-off, type migration)
use secure_gate::{Fixed, RevealSecret};
let key: Fixed<[u8; 32]> = Fixed::new([0xAB; 32]);
let owned: secure_gate::InnerSecret<[u8; 32]> = key.into_inner();
// Zeroizes its 32 bytes when it drops — same guarantee as Fixed<[u8; 32]>.
assert_eq!(format!("{:?}", owned), "[REDACTED]");

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 secure_gate::{fixed_alias, dynamic_alias};

fixed_alias!(pub Aes256Key, 32, "32-byte AES-256 key");

#[cfg(feature = "alloc")]
dynamic_alias!(pub Password, String, "variable-length password");

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 secure_gate::RevealSecret;

fn log_length<S: RevealSecret>(secret: &S) {
    println!("length = {}", secret.len());
}

What You Get

  • Zero-cost safety — mandatory zeroization on drop; no_std / no_alloc support.
  • Audit-first API — secrets cannot leak via Deref. Access requires explicit with_secret scopes or an auditable expose_secret escape hatch.
  • Named aliases — macros create type aliases over Fixed / Dynamic that inherit redacted Debug and zeroize-on-drop. These are plain type aliases, not newtypes: same-shape aliases (e.g. two Fixed<[u8; 32]> aliases) are interchangeable at the type level — wrap in a struct newtype 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):

[dependencies]

secure-gate = "0.8"

No-heap / embedded (Fixed<T> only — pure stack / no_std):

secure-gate = { version = "0.8", default-features = false }

Batteries-included:

secure-gate = { version = "0.8", features = ["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: Fixed<[u8; 32]> = ...;

// 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("bc")?;
let bech32m = key.try_to_bech32m("bc")?;

// 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("bc")?;
let bech32m_z = key.try_to_bech32m_zeroizing("bc")?;

// Scoped on the inner bytes (preferred when you want `with_secret` in audit sweeps)
let hex_scoped = key.with_secret(|s| s.to_hex());
let b64_scoped = key.with_secret(|s| s.to_base64url());
let bech32_scoped  = key.with_secret(|s| s.try_to_bech32("bc"))?;
let bech32m_scoped = key.with_secret(|s| s.try_to_bech32m("bc"))?;

// Trait-level zeroizing APIs are also available on byte-like values:
let hex_trait_z = key.with_secret(|s| s.to_hex_zeroizing());
let b64_trait_z = key.with_secret(|s| s.to_base64url_zeroizing());

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 _unchecked only 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 EncodedSecret and SECURITY.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 (and deserialize_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

#[cfg(feature = "rand")]
{
    use secure_gate::Fixed;
    // System RNG — panics if entropy is unavailable (fatal environment error).
    let key: Fixed<[u8; 32]> = Fixed::from_random();
}

#[cfg(all(feature = "rand", feature = "alloc"))]
{
    use rand::rngs::StdRng;
    use rand::SeedableRng;
    use secure_gate::{Dynamic, Fixed};

    let mut rng = StdRng::from_seed([0u8; 32]);
    let _fixed: Fixed<[u8; 16]> = Fixed::from_rng(&mut rng).expect("rng fill");
    let _buf: Dynamic<Vec<u8>> = Dynamic::from_rng(32, &mut rng).expect("rng fill");
}

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 .inner directly but require opt-in marker traits.
  • Zeroize on drop — always active; inner type must implement Zeroize
  • Timing-safe equality — ct-eq feature (.ct_eq()) routes through expose_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> requires T: Zeroize; Dynamic<T> requires T: ?Sized + Zeroize.
  • Removed the old optional zeroize feature and related toggles (insecure, secure, and std).
  • Real impl Drop now calls zeroize() 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 Vec and String, runtime heap verification via ProxyAllocator, and AddressSanitizer integration.
  • ExposeSecret → RevealSecret trait rename — ExposeSecret / ExposeSecretMut are now RevealSecret / RevealSecretMut. Method names (expose_secret, with_secret, etc.) are unchanged; only code that names the trait explicitly needs updating.
  • ct-eq-hash feature removed — ConstantTimeEqExt, ct_eq_hash, and ct_eq_auto are gone. Use the ct-eq feature and .ct_eq() instead.
  • Bech32 / Bech32m constructor API changed — Primary decode is now try_from_bech32(s, hrp) (HRP-validated); unchecked single-arg form is try_from_bech32_unchecked(s). _expect_hrp variants renamed to _with_hrp.
  • ToHex::to_hex_left removed — 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:

cargo +1.70 update

git add Cargo.lock

git commit -m "chore: regenerate Cargo.lock with MSRV 1.70"

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