secure-gate 0.8.0-rc.8

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 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
pw.with_secret_mut(|s: &mut String| s.push('!'));

// 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 typed newtype wrappers with full visibility control, optional doc strings, and compile-time zero-size guards:

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.
  • Type-safe wrappers — macros create newtype aliases that redact Debug output 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):

[dependencies]

secure-gate = "0.8.0-rc.{x}"

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

secure-gate = { version = "0.8.0-rc.{x}", default-features = false }

Batteries-included:

secure-gate = { version = "0.8.0-rc.{x}", 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 equalityct-eq feature (.ct_eq()) routes through expose_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> 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.
  • ExposeSecretRevealSecret trait renameExposeSecret / 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 removedConstantTimeEqExt, 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. 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:

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