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?
| Type | Allocation | Use case | Feature |
|---|---|---|---|
Fixed<T> | Stack | Keys, nonces, tokens — compile-time-known size | Always available |
Dynamic<T> | Heap | Passwords, API keys, ciphertexts — variable length | alloc (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 droppeduse 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, Bech32ErrorAll public items are re-exported at the crate root. Use secure_gate::Fixed,
not secure_gate::fixed::Fixed.
§Type taxonomy
| Category | Types | Deref to secret? | Purpose |
|---|---|---|---|
| Secret wrappers | Fixed<T>, Dynamic<T> | No — use RevealSecret | Hold live secrets; Debug → [REDACTED] |
| Output wrapper | EncodedSecret | Yes — yields &str; copies you make are yours, the buffer stays the wrapper’s | Holds encoded output, wiped on drop |
| Borrowed slot cursor | SlotWriter | No — it writes, it does not read out | Appends into a slot a sized new_with handed out; Debug → cursor state only, never bytes |
| Opt-in markers | CloneableSecret, 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 onstr; dereferencing first opts out of it.EncodedSecret::into_zeroizingreturns aZeroizing<String>, whoseDebugis 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 style | Example | Appears 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
| Feature | Default | Description |
|---|---|---|
alloc | yes | Heap types (Dynamic<T>), Vec/String zeroization |
std | no | Full std support (implies alloc) |
| Cryptographic | ||
ct-eq | no | ConstantTimeEq via subtle — timing-safe comparison |
rand | no | from_random() / from_rng() — no_std for Fixed |
sealed | no | SealedFixed / SealedDynamic: secrets encrypted in RAM while idle, under a locked Prekey (implies std, rand) |
| Serialization | ||
serde-serialize | no | Serde Serialize (requires SerializableSecret marker) |
serde-deserialize | no | Serde Deserialize with 1 MiB default limit |
serde | no | Both directions |
| Encoding | ||
encoding-hex | no | ToHex / FromHexStr via base16ct (constant-time) |
encoding-base32 | no | ToBase32 / FromBase32Str via base32ct (constant-time) |
encoding-base64 | no | ToBase64Url / FromBase64UrlStr via base64ct (constant-time) |
encoding-bech32 | no | ToBech32 / FromBech32Str (BIP-173) and ToBech32m / FromBech32mStr (BIP-350); _sized::<N> for a caller-chosen code length |
encoding | no | All encoding features |
| Meta | ||
cloneable | no | CloneableSecret opt-in cloning |
full | no | Everything except std and sealed |
§What’s available without alloc?
With default-features = false:
Fixed<T>,RevealSecret,RevealSecretMutFixed::try_from_hex,Fixed::try_from_base32,Fixed::try_from_base64url,Fixed::try_from_bech32,Fixed::try_from_bech32m(no-alloc stack-based decoding)fixed_newtype!(distinct nominal types — two keys of the same size that the compiler keeps apart); a plaintypealias overFixed<T>needs nothing at allFromSliceError
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. PreferFixed::new_with, pass by reference, or useDynamic<T>for long-lived secrets. - Heap-reallocation residue:
Vec/Stringrealloc frees the old buffer unzeroed. Pre-size, useDynamic<[u8; N]>(boxed array — no realloc surface), or install a zero-on-dealloc global allocator such aszeroizing-allocat 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;sealedpub use sealed::SealedDynamic;sealedpub use sealed::Prekey;sealedpub use sealed::LockError;sealedpub use traits::CloneableSecret;cloneablepub use traits::ConstantTimeEq;ct-eqpub use traits::RevealSecret;pub use traits::SecretLen;pub use traits::RevealSecretMut;pub use traits::SentinelValue;pub use traits::FixedStorage;pub use traits::EncodedSecret;allocpub use traits::SerializableSecret;serde-serializepub use traits::FromBase32Str;allocandencoding-base32pub use traits::FromBase64UrlStr;allocandencoding-base64pub use traits::FromBech32Str;allocandencoding-bech32pub use traits::FromBech32mStr;allocandencoding-bech32pub use traits::FromHexStr;allocandencoding-hexpub use traits::ToBase32;allocandencoding-base32pub use traits::ToBase64Url;allocandencoding-base64pub use traits::ToBech32;allocandencoding-bech32pub use traits::ToBech32m;allocandencoding-bech32pub use traits::Case;encoding-bech32pub use traits::EncodableBytes;encoding-base32orencoding-base64orencoding-bech32orencoding-hexpub use traits::Bech32Sized;encoding-bech32pub use traits::Bech32Standard;encoding-bech32pub use traits::Bech32mSized;encoding-bech32pub use traits::Bech32mStandard;encoding-bech32pub use traits::BECH32_CODE_LENGTH;encoding-bech32pub use traits::bech32_code_length;encoding-bech32pub use traits::ToHex;allocandencoding-hex
Modules§
- scoped
sealed - Scoped access shared by plain and sealed secrets. Requires the
sealedfeature. - sealed
sealed - Secrets held encrypted in RAM while idle, opened for one closure call at a time.
Requires the
sealedfeature. - traits
- Core traits for wrapper polymorphism - always available. Traits for polymorphic secret handling.
Macros§
- dynamic_
newtype alloc - Creates a distinct nominal type wrapping
Dynamic<T>. - fixed_
newtype - Creates a distinct nominal type wrapping
Fixed<[u8; N]>. - sealed_
fixed_ newtype sealed - Creates the sealed counterpart of a
fixed_newtype!byte role: a distinct type aroundSealedFixed<N>that seals from, and unseals to, that role and no other. Requires thesealedfeature.
Structs§
- Dynamic
alloc - Heap-allocated secret wrapper with explicit access and automatic zeroization on drop.
- Dynamic
Reader allocandstd - Cursor-like reader over
Dynamic<Vec<u8>>— seeDynamic::as_reader. Cursor-like reader over aDynamic<Vec<u8>>. - Fixed
- Stack-allocated secret wrapper with explicit access and automatic zeroization on drop.
- Slot
Writer - Append-shaped writing into the fixed slot handed to a sized
new_with.
Enums§
- Base32
Error encoding-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.
- Base64
Error encoding-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.
- Bech32
Error encoding-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.
- From
Slice Error - Error returned when a byte slice cannot be converted to
Fixed<[u8; N]>due to length mismatch. Produced byFixed::try_from(&[u8]). Error returned when a byte slice cannot be converted to a fixed-size array. - HexError
encoding-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_ BYTES allocandserde-deserialize - Default maximum byte length for
Dynamic<Vec<u8>>/Dynamic<String>deserialization (1 MiB).