Skip to main content

Crate secure_gate

Crate secure_gate 

Source
Expand description

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

Secrets are automatically zeroized on drop (the inner type must implement Zeroize). While a secret is held in Fixed or Dynamic, there is no Deref and no AsRef: callers reach the inner secret only via RevealSecret / RevealSecretMut, and Debug on a wrapper always prints [REDACTED]. SlotWriter, which borrows a wrapper’s storage rather than holding it, prints its cursor instead — position, remaining, length — and never the bytes. Access has two shapes: borrow it, or take it. with_secret() and expose_secret() lend you a reference while the wrapper keeps ownership and keeps wiping. into_inner() transfers ownership: you get the plain value and the protection ends with the call.

Those two shapes are what the 3-tier access model in SECURITY.md counts in threes: with_secret (Tier 1) and expose_secret (Tier 2) are both the borrow shape, differing only in how tightly the lifetime is pinned, and into_inner (Tier 3) is the take. Two shapes, three volumes — the tiers say how loudly you have to announce which one you picked, not how many protections there are.

Extraction is a hand-off, not a third layer of protection. Encoding output is the one case that keeps a wrapper — EncodedSecret does implement Deref, because its job is to keep an encoded copy of the secret wiped until it drops. Ordinary copies you make of the derefed value (.to_string()) are not tracked. See Where accident-prevention ends.

§Which type should I use?

TypeAllocationUse caseFeature
Fixed<T>StackKeys, nonces, tokens — compile-time-known sizeAlways available
Dynamic<T>HeapPasswords, API keys, ciphertexts — variable lengthalloc (default)

Both types share the same RevealSecret / RevealSecretMut access API.

§Quick start

use secure_gate::{Fixed, RevealSecret};

// Wrap a 32-byte key
let key = Fixed::new([0x42u8; 32]);

// Tier 1 — scoped access (preferred): secret ref cannot escape the closure
let first = key.with_secret(|bytes| bytes[0]);
assert_eq!(first, 0x42);

// Tier 2 — direct reference (escape hatch for FFI / third-party APIs)
assert_eq!(key.expose_secret().len(), 32);

// Debug is always redacted
assert_eq!(format!("{:?}", key), "[REDACTED]");
// key is zeroized when dropped
use secure_gate::{Dynamic, RevealSecret};

let password: Dynamic<String> = Dynamic::new(String::from("hunter2"));
let len = password.with_secret(|s: &String| s.len());
assert_eq!(len, 7);
use secure_gate::{fixed_newtype, RevealSecret};

fixed_newtype!(pub Aes256Key, 32);

let key = Aes256Key::new([0xABu8; 32]);
key.with_secret(|b| assert_eq!(b.len(), 32));

§Module structure

secure_gate (lib.rs)
├── Fixed<T>              ← always available, stack-allocated
├── Dynamic<T>            ← requires `alloc`, heap-allocated
├── traits/
│   ├── RevealSecret      ← immutable access (always available)
│   ├── RevealSecretMut   ← mutable access (always available)
│   ├── revealed_secrets/
│   │   └── EncodedSecret     ← zeroizing encoded string wrapper (alloc)
│   ├── ConstantTimeEq    ← ct-eq feature
│   ├── CloneableSecret   ← cloneable feature
│   ├── SerializableSecret← serde-serialize feature
│   ├── encoding/         ← ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m
│   └── decoding/         ← FromHexStr, FromBase32Str, FromBase64UrlStr, FromBech32Str, FromBech32mStr
├── macros/               ← fixed_newtype!, dynamic_newtype!
└── error                 ← FromSliceError, HexError, Base32Error, Base64Error, Bech32Error

All public items are re-exported at the crate root. Use secure_gate::Fixed, not secure_gate::fixed::Fixed.

§Type taxonomy

CategoryTypesDeref to secret?Purpose
Secret wrappersFixed<T>, Dynamic<T>No — use RevealSecretHold live secrets; Debug → [REDACTED]
Output wrapperEncodedSecretYes — yields &str; copies you make are yours, the buffer stays the wrapper’sHolds encoded output, wiped on drop
Borrowed slot cursorSlotWriterNo — it writes, it does not read outAppends into a slot a sized new_with handed out; Debug → cursor state only, never bytes
Opt-in markersCloneableSecret, SerializableSecret— (no methods)Implement on inner type T to unlock gated impls

CloneableSecret and SerializableSecret are implemented on the inner type T, not on Fixed<T> or Dynamic<T> directly. EncodedSecret is not a secret wrapper and does not interact with these markers.

§Where accident-prevention ends

This crate carries two separate obligations, and they end in different places.

Accidents must not compile. This applies while the secret is held in Fixed/Dynamic, and it ends at the named extraction. into_inner and EncodedSecret::into_inner are the exits that transfer ownership: you typed a name, the call site is grep-able, and the wrapper is consumed. expose_secret is a named borrow — the wrapper still owns the secret and still wipes it — but it hands out a reference with no lifetime bound to a call site, so it is audited alongside them.

Documented behavior must be accurate. This never ends, and it is why the taxonomy table above says Deref: Yes for the output wrapper.

What EncodedSecret still guarantees: the String it owns is zeroized on drop, and its Debug prints [REDACTED]. What it does not guarantee: copies you make through Deref are ordinary values. str::to_string() and .to_owned() both produce untracked plaintext. That is what extraction is for — the crate is not trying to follow the bytes into your TLS stack. into_inner gives up even the first guarantee, by design: it is the named end of protection.

Two consequences worth knowing:

  • format!("{:?}", &*encoded) prints the encoded secret. Redaction lives on the wrapper, not on str; dereferencing first opts out of it.
  • EncodedSecret::into_zeroizing returns a Zeroizing<String>, whose Debug is not redacted. It is a deliberate hand-off to a foreign type, not an equivalent wrapper.

§Import paths

// ✅ Correct — always import from the crate root
use secure_gate::{Fixed, RevealSecret};

// ❌ Wrong — these internal paths compile but are not the public API
// use secure_gate::traits::reveal_secret::RevealSecret;
// use secure_gate::traits::encoding::hex::ToHex;

§Method resolution: wrapper methods vs trait methods

Encoding methods exist at two levels — both produce identical results:

Call styleExampleAppears in audit sweep?
Wrapper trait impl (ergonomic)key.to_hex() (needs use secure_gate::ToHex)No — grep for to_hex directly
Trait via scoped access (audit-friendly)key.with_secret(|b| b.to_hex())Yes — with_secret is grep-able

Both levels are impls of the same trait (ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m): a blanket impl covers the raw bytes inside with_secret, and per-wrapper impls on Fixed<[u8; N]> / Dynamic<Vec<u8>> delegate through with_secret internally. One trait also means one bound: fn fingerprint<S: ToHex>(s: &S) accepts wrappers and forwarding newtypes alike.

§Feature flags

FeatureDefaultDescription
allocyesHeap types (Dynamic<T>), Vec/String zeroization
stdnoFull std support (implies alloc)
Cryptographic
ct-eqnoConstantTimeEq via subtle — timing-safe comparison
randnofrom_random() / from_rng() — no_std for Fixed
sealednoSealedFixed / SealedDynamic: secrets encrypted in RAM while idle, under a locked Prekey (implies std, rand)
Serialization
serde-serializenoSerde Serialize (requires SerializableSecret marker)
serde-deserializenoSerde Deserialize with 1 MiB default limit
serdenoBoth directions
Encoding
encoding-hexnoToHex / FromHexStr via base16ct (constant-time)
encoding-base32noToBase32 / FromBase32Str via base32ct (constant-time)
encoding-base64noToBase64Url / FromBase64UrlStr via base64ct (constant-time)
encoding-bech32noToBech32 / FromBech32Str (BIP-173) and ToBech32m / FromBech32mStr (BIP-350); _sized::<N> for a caller-chosen code length
encodingnoAll encoding features
Meta
cloneablenoCloneableSecret opt-in cloning
fullnoEverything except std and sealed

§What’s available without alloc?

With default-features = false:

Not available without alloc: Dynamic<T>, EncodedSecret, encoding traits (ToHex, etc.), decoding traits (FromHexStr, etc.), dynamic_newtype!, serde support.

§no_std

no_std compatible (#![no_std] unless the std feature is enabled — verified in CI by cross-building for thumbv7em-none-eabihf). Fixed<T> works without alloc. Enable alloc (default) for Dynamic<T>. For pure stack / embedded builds, use default-features = false. MSRV: 1.85 (Rust edition 2024).

One caveat: the rand feature compiles without std or alloc, but Fixed::from_random relies on getrandom, which needs a platform entropy backend. On bare-metal targets you must configure one (see the getrandom crate’s “custom backend” documentation); without it, builds enabling rand fail at link time on such targets. Fixed::from_rng with a caller-supplied RNG has no such requirement.

§Security

This crate has not undergone an independent security audit. No unsafe code — enforced with #![forbid(unsafe_code)]. Prefer scoped access (RevealSecret::with_secret) over direct references. Encoders return EncodedSecret, which stays wiped until it drops; EncodedSecret::into_inner is the named call that ends that. See SECURITY.md for the full threat model.

§Inherent Rust Limitations

Three universal limits apply to any in-memory secret library in Rust (and in C, C++, and Go) — secure-gate documents them honestly rather than overclaiming:

  • Stack-move residue: moving a Fixed<T> by value leaves the prior stack slot uncleared until a later frame overwrites it. Prefer Fixed::new_with, pass by reference, or use Dynamic<T> for long-lived secrets.
  • Heap-reallocation residue: Vec / String realloc frees the old buffer unzeroed. Pre-size, use Dynamic<[u8; N]> (boxed array — no realloc surface), or install a zero-on-dealloc global allocator such as zeroizing-alloc at the binary level.
  • Swap / core dumps: process memory paged to disk or written on crash is outside any in-process library’s reach. Use OS facilities (mlock, encrypted swap, disabled core dumps).

Full discussion in SECURITY.md § Inherent Rust Limitations.

See the README and SECURITY.md for full details.

Re-exports§

pub use sealed::SealedFixed;sealed
pub use sealed::SealedDynamic;sealed
pub use sealed::Prekey;sealed
pub use sealed::LockError;sealed
pub use traits::CloneableSecret;cloneable
pub use traits::ConstantTimeEq;ct-eq
pub use traits::RevealSecret;
pub use traits::SecretLen;
pub use traits::RevealSecretMut;
pub use traits::SentinelValue;
pub use traits::FixedStorage;
pub use traits::EncodedSecret;alloc
pub use traits::SerializableSecret;serde-serialize
pub use traits::FromBase32Str;alloc and encoding-base32
pub use traits::FromBase64UrlStr;alloc and encoding-base64
pub use traits::FromBech32Str;alloc and encoding-bech32
pub use traits::FromBech32mStr;alloc and encoding-bech32
pub use traits::FromHexStr;alloc and encoding-hex
pub use traits::ToBase32;alloc and encoding-base32
pub use traits::ToBase64Url;alloc and encoding-base64
pub use traits::ToBech32;alloc and encoding-bech32
pub use traits::ToBech32m;alloc and encoding-bech32
pub use traits::Case;encoding-bech32
pub use traits::EncodableBytes;encoding-base32 or encoding-base64 or encoding-bech32 or encoding-hex
pub use traits::Bech32Sized;encoding-bech32
pub use traits::Bech32Standard;encoding-bech32
pub use traits::Bech32mSized;encoding-bech32
pub use traits::Bech32mStandard;encoding-bech32
pub use traits::BECH32_CODE_LENGTH;encoding-bech32
pub use traits::bech32_code_length;encoding-bech32
pub use traits::ToHex;alloc and encoding-hex

Modules§

scopedsealed
Scoped access shared by plain and sealed secrets. Requires the sealed feature.
sealedsealed
Secrets held encrypted in RAM while idle, opened for one closure call at a time. Requires the sealed feature.
traits
Core traits for wrapper polymorphism - always available. Traits for polymorphic secret handling.

Macros§

dynamic_newtypealloc
Creates a distinct nominal type wrapping Dynamic<T>.
fixed_newtype
Creates a distinct nominal type wrapping Fixed<[u8; N]>.
sealed_fixed_newtypesealed
Creates the sealed counterpart of a fixed_newtype! byte role: a distinct type around SealedFixed<N> that seals from, and unseals to, that role and no other. Requires the sealed feature.

Structs§

Dynamicalloc
Heap-allocated secret wrapper with explicit access and automatic zeroization on drop.
DynamicReaderalloc and std
Cursor-like reader over Dynamic<Vec<u8>> — see Dynamic::as_reader. Cursor-like reader over a Dynamic<Vec<u8>>.
Fixed
Stack-allocated secret wrapper with explicit access and automatic zeroization on drop.
SlotWriter
Append-shaped writing into the fixed slot handed to a sized new_with.

Enums§

Base32Errorencoding-base32
Errors from Base32 (RFC 4648 §6, uppercase, unpadded) decoding. Variant shapes are identical in debug and release builds; only numeric length metadata is carried. Errors produced when decoding Base32 (RFC 4648 §6, uppercase, unpadded) strings.
Base64Errorencoding-base64
Errors from Base64url decoding. Variant shapes are identical in debug and release builds; only numeric length metadata is carried. Errors produced when decoding base64url strings.
Bech32Errorencoding-bech32
Errors from Bech32 (BIP-173) and Bech32m (BIP-350) decoding. Variant shapes are identical in debug and release builds; no secret material (payload bytes, HRP strings) is ever carried in an error. Errors produced when decoding Bech32 (BIP-173) or Bech32m (BIP-350) strings.
FromSliceError
Error returned when a byte slice cannot be converted to Fixed<[u8; N]> due to length mismatch. Produced by Fixed::try_from(&[u8]). Error returned when a byte slice cannot be converted to a fixed-size array.
HexErrorencoding-hex
Errors from hex decoding. Variant shapes are identical in debug and release builds; only numeric length metadata is carried. Errors produced when decoding hexadecimal strings.

Constants§

MAX_DESERIALIZE_BYTESalloc and serde-deserialize
Default maximum byte length for Dynamic<Vec<u8>> / Dynamic<String> deserialization (1 MiB).