Expand description
Marker trait asserting that a secret’s storage owns no heap allocation.
Fixed<T> is documented as having no reallocation surface, which is
what lets SECURITY.md exempt it from the heap-residue weakness that
Dynamic<T> carries. That exemption was a claim about the shape
people were expected to use, not something the type system checked: Fixed<T> is
bounded only by Zeroize, so Fixed<Vec<u8>> and Fixed<String> compiled, and
fixed_newtype!(pub Name, generic Vec<u8>) was a documented path straight to them.
Measured, they leak exactly as much as the Dynamic equivalent — and worse, because
Dynamic<Vec<u8>> has a safe-growth std::io::Write impl and Fixed<Vec<u8>> has
nothing.
FixedStorage is what makes the exemption true. Fixed::new
requires it, so the compiler now rejects an inner type that owns a heap allocation.
§The contract
Implementing FixedStorage for a type asserts that the type owns no heap
allocation. Nothing else, and note that this is stricter than “cannot be resized”.
The first version of this trait asked only for a fixed capacity, and blessed Box<[T]>
on the reasoning that a boxed slice has its length fixed at construction. That was
wrong, and measurably so: the residue never needed a capacity change. Whole-value
replacement abandons the allocation just as well, and the wrapper wipes whatever it
holds at drop, not what it used to hold. On a Fixed<Box<[u8]>> carrying a 1024-byte
secret, with_secret_mut(|slot| *slot = other_boxed_slice) released the original block
with 1024 of 1024 bytes intact, and so did the same assignment through
expose_secret_mut. The inline contrast, Fixed<[u8; 1024]> assigned the same way,
freed nothing at all — there is no allocation to abandon. So Box<[T]> is no longer
implemented, and the predicate is heap ownership rather than resizability.
The test to apply to your own type: does Self own a heap allocation? If yes, do not
implement this — reach for Dynamic, which documents the residue.
§This is an assertion, not an enforcement
Like CloneableSecret, the compiler checks that you wrote
the impl, not that the claim is true. A type with a Vec field that implements
FixedStorage anyway will compile and will leak, which is measured and deliberate:
the alternative is a closed set of blessed types, and that would break the one thing
the generic arm of fixed_newtype! exists for — holding a
custom secret on a target with no allocator.
What the marker buys is that the claim is now written down at a greppable line in the crate making it, rather than being an unstated assumption. That is the same trade the other opt-in markers in this crate make.
§What is implemented for you
- the integer, floating-point,
bool,charand()primitives; - the twelve
NonZerointegers; [T; N]for anyN, whenT: FixedStorage— which covers[u8; 32]and the non-byte arrays thegenericarm exists for, such as[i16; 256];- tuples up to ten elements of
FixedStoragetypes, matchingzeroize’s own ceiling; Option<T>whenT: FixedStorage— a discriminant adds no heap storage;Wrapping<T>andMaybeUninit<T>whenT: FixedStorage— both areT’s own storage;zeroize::Zeroizing<T>whenT: FixedStorage;Fixed<T>itself whenT: FixedStorage, so the wrapper nests.
§Why that list has to be this long
The bound is an allow-list, which means it fails closed: a type is refused both when it
owns a heap allocation and when nothing has asserted that it does not. For a type you
define, the second case costs one line. For a type you do not own it costs nothing,
because you cannot pay it — impl FixedStorage for NonZeroU32 in your crate is E0117,
the orphan rule, and no amount of effort downstream changes that.
So every heap-free type reachable through zeroize’s own Zeroize impls has to be
blessed here or it is simply unavailable as a Fixed inner type, with the caller’s only
escape being to change their own type. That is the reason for the NonZero, Wrapping,
MaybeUninit and Zeroizing impls above, and for tuples reaching ten rather than four:
each was a shape that compiled before this bound existed and that no downstream crate
could have restored. Ten is not arbitrary — zeroize implements Zeroize for tuples up
to ten elements and no further, so an eleventh element fails the Zeroize bound before
this one is ever consulted.
The deny-list alternative — accept everything except a known set of heap owners — was
rejected deliberately. It has no false rejections, but it fails open: a custom type
wrapping a Vec would be accepted in silence, which is the exact weakness this trait
exists to close.
Nothing heap-owning is implemented, deliberately. Box<[T]> was and is not: see the
contract above for the measurement that removed it. Box<[u8; N]> never arises anyway,
because zeroize does not implement Zeroize for a Box of a sized type. For a
heap-only secret of fixed size, the supported shape is Dynamic<[u8; N]>.
A custom inner type needs one line:
use secure_gate::{FixedStorage, Fixed, RevealSecret, SentinelValue};
use zeroize::Zeroize;
struct Poly([i16; 256]);
impl Zeroize for Poly {
fn zeroize(&mut self) {
self.0.zeroize();
}
}
impl SentinelValue for Poly {
fn sentinel_value() -> Self {
Poly([0i16; 256])
}
}
// Asserted here: `Poly` owns no heap allocation.
impl FixedStorage for Poly {}
let p = Fixed::new(Poly([7i16; 256]));
assert_eq!(p.with_secret(|poly| poly.0[0]), 7);§Naming the type still compiles
The bound is on Fixed::new, not on the struct, which keeps the
property the zero-size guard already documents: type Bad = Fixed<Vec<u8>>; is a
legal type expression and no guard in the type could make it otherwise. What the bound
removes is every value of it.
Traits§
- Fixed
Storage - Marker trait asserting that a type owns no heap allocation.