secure-gate 0.9.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

Crates.io Docs.rs CI MSRV: 1.85 License: MIT OR Apache-2.0

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

[!WARNING] Security Notice: This crate has not undergone independent audit. Review the code and SECURITY.md before production use.

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();

// Generate key material from the system RNG rather than a literal (needs `rand`).
let mut key: Aes256Key = Aes256Key::from_random();

// Scoped access — preferred; the borrow cannot outlive the closure
let long_enough = pw.with_secret(|s| s.len() >= 8); // validate in-closure; never log a secret's length

// Mutable scoped access — rotate in fresh material. The old key is overwritten in
// place and never leaves the wrapper. For Dynamic<String> / Dynamic<Vec<u8>>, prefer
// capacity-stable mutations or pre-allocate before wrapping; see SECURITY.md.
let next: Aes256Key = Aes256Key::from_random();
next.with_secret(|fresh| key.with_secret_mut(|old| old.copy_from_slice(fresh)));

// 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() via SecretLen — 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> (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 — the closure borrow cannot outlive the call, so validate inside it and
// return only the verdict, never the bytes.
let looks_degenerate = key.with_secret(|bytes| bytes.iter().all(|&b| b == bytes[0]));
assert!(looks_degenerate); // every byte is 0xAB

// Mutate in place — the secret is never copied out to be modified.
key.with_secret_mut(|bytes: &mut [u8; 32]| bytes.copy_from_slice(&[0xCD; 32]));

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 aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit};
use aes::Aes128;
use secure_gate::{Fixed, RevealSecretMut};

let cipher = Aes128::new(GenericArray::from_slice(&[0x42u8; 16]));
let mut block: Fixed<[u8; 16]> = Fixed::new([0u8; 16]);

block.with_secret_mut(|b| cipher.decrypt_block(GenericArray::from_mut_slice(b)));

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 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 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 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");

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

fixed_newtype!(pub EncKey, 32, "AES-256 key. Never used for authentication.");
fixed_newtype!(pub MacKey, 32, "HMAC-SHA256 key. Never used for encryption.");

fn seal(enc: &EncKey, mac: &MacKey) { /* … */ }

// 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 secure_gate::SecretLen;

// Length is metadata, not contents — but for variable-length secrets it can
// still be sensitive. Validate against it; don't log it.
fn require_min_len<S: SecretLen>(secret: &S, min: usize) -> bool {
    secret.len() >= min
}

What You Get

  • Zero-cost safety — mandatory zeroization on drop; no_std / no_alloc support.
  • Audit-first API — a held secret cannot leak via Deref: Fixed/Dynamic implement none. Access requires explicit with_secret scopes or an auditable expose_secret escape 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 create type aliases over Fixed / Dynamic that inherit redacted Debug and zeroize-on-drop; same-shape aliases (e.g. two Fixed<[u8; 32]> aliases) are interchangeable at the type level. When distinct cryptographic roles share a shape, fixed_newtype! / dynamic_newtype! generate structs 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):

[dependencies]

secure-gate = "0.9.0-rc.8"

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

secure-gate = { version = "0.9.0-rc.8", default-features = false }

Batteries-included:

secure-gate = { version = "0.9.0-rc.8", features = ["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 secure_gate::{Fixed, RevealSecret, ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m};
# fn main() -> Result<(), secure_gate::Bech32Error> {
let key: Fixed<[u8; 32]> = Fixed::new([0x42u8; 32]);

// Plain — returns String (suitable for public encodings)
let hex     = key.to_hex();
let hex_u   = key.to_hex_upper();
let b32     = key.to_base32();
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 b32_z     = key.to_base32_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 b32_scoped     = key.with_secret(|s| s.to_base32());
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 b32_trait_z = key.with_secret(|s| s.to_base32_zeroizing());
let b64_trait_z = key.with_secret(|s| s.to_base64url_zeroizing());
# Ok(())
# }

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 _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 (SysRng), panics on failure, and is heap-free for Fixed<T> (no_std / no_alloc). from_rng fills from any TryCryptoRng + TryRng 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
  • Extract: into_inner (hands the secret to the caller as InnerSecret<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.9.0

Edition 2024, MSRV 1.85, rand 0.10 (OsRng → SysRng), dep bumps.
Across the release candidates: SecretLen split out of RevealSecret (which now covers every inner type); Base32 (RFC 4648 §6) added behind encoding-base32; wrapper encoders are ToHex / ToBase32 / ToBase64Url / ToBech32 / ToBech32m trait impls; fixed_newtype! / dynamic_newtype! for nominal secret roles; no Display on EncodedSecret.
Full details in CHANGELOG.md. Users on Rust < 1.85: pin secure-gate = "0.8".

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.

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 SysRng) and fallible from_rng() for any TryRng + TryCryptoRng; 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 (main, 0.9.x) enforces MSRV 1.85 (rust-version = "1.85" in Cargo.toml). Rust 1.85 is the minimum that supports Rust edition 2024.

Always use the MSRV toolchain to update Cargo.lock:

cargo +1.85 update

git add Cargo.lock

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

License

MIT OR Apache-2.0