macro_rules! fixed_newtype {
($(#[$attr:meta])* $vis:vis $name:ident, generic [u8; $n:literal], $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, generic [u8; $n:literal], $doc:literal) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, generic [u8; $n:literal], derive: [$($opt:ident),* $(,)?]) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, generic [u8; $n:literal]) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, generic [$t:ty; $n:literal]
$(, $doc:literal)? $(, derive: [$($opt:ident),* $(,)?])?) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, generic $inner:ty
$(, $doc:literal)? $(, derive: [$($opt:ident),* $(,)?])?) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, $size:literal, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, $size:literal, $doc:literal) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, $size:literal) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, $size:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
($(#[$attr:meta])* $vis:vis $name:ident, $($rest:tt)+) => { ... };
}Expand description
Creates a distinct nominal type wrapping Fixed<[u8; N]>.
Generates a struct rather than a type alias: two fixed_newtype! types
of the same N are not interchangeable, where two type aliases of
that N are one and the same type. Use it when distinct cryptographic
roles share a shape — an encryption key and a MAC key are both
Fixed<[u8; 32]>, and under a plain alias the compiler cannot tell them
apart.
§Syntax
fixed_newtype!(pub Name, N); // public, auto-generated doc
fixed_newtype!(pub(crate) Name, N); // crate-visible
fixed_newtype!(Name, N); // private
fixed_newtype!(pub Name, N, "doc string"); // with custom doc
fixed_newtype!(pub Name, N, derive: [FromWrapper]); // with opt-in impls
fixed_newtype!(pub Name, N, "doc", derive: [FromWrapper]);
fixed_newtype!(pub Name, generic [T; N]); // array: adds SecretLen
fixed_newtype!(pub Name, generic T); // reduced API, opted into
fixed_newtype!(pub Name, generic T, "doc string");
fixed_newtype!(pub Name, generic T, derive: [WrapperAccess]);
fixed_newtype!(pub Name, generic T, "doc", derive: [WrapperAccess]);Supported derive: options are Deserialize, FromWrapper, IntoWrapper, and WrapperAccess (= both directions), plus ConstantTimeEq on the generic arms only. Deserialize and ConstantTimeEq constrain the inner type — it must implement them, which rules them out for most generic inner types ([i16; 256] has neither) — while the three wrapper-access tokens apply to any. See
Cloning and serialization below for the two that are deliberately absent.
ConstantTimeEq is not an option on the size-literal arm, and naming it is an
error. A [u8; N] payload gets ct_eq
automatically whenever the ct-eq feature is on, because the wrapper it forwards
to always has it and a plain type Name = Fixed<[u8; N]>; therefore always did.
Requiring the token here put the extra word on the spelling this crate recommends
as the safer of the two, for the one comparison whose alternative is a timing
leak. Since the arm now emits the impl itself, a second one from the derive:
list is E0119: conflicting implementations — delete the token. It remains a
real opt-in on the generic arms, where the inner type may or may not implement
the trait.
§Examples
All three visibility forms:
use secure_gate::{fixed_newtype, SecretLen};
fixed_newtype!(pub EncKey, 32); // public
fixed_newtype!(pub(crate) HmacKey, 32); // crate-visible
fixed_newtype!(local_nonce, 12); // private
let key = EncKey::new([42u8; 32]);
assert_eq!(key.len(), 32);Distinct roles do not mix — this is the point:
use secure_gate::fixed_newtype;
fixed_newtype!(pub EncKey, 32);
fixed_newtype!(pub MacKey, 32);
fn seal(_enc: &EncKey, _mac: &MacKey) {}
let enc = EncKey::new([1u8; 32]);
let mac = MacKey::new([2u8; 32]);
seal(&mac, &enc); // E0308: roles swappedZero-size is a compile error, raised where the type is declared rather than where one is first built:
use secure_gate::fixed_newtype;
fixed_newtype!(pub Bad, 0); // compile-time error: index out of bounds§Inner types other than bytes
The size-literal forms cover Fixed<[u8; N]>, the shape nearly every
secret takes. Fixed<T> holds more than that, and the
generic marker opts a newtype into it. The requirement is
Zeroize + SentinelValue+FixedStorage, checked at the
declaration — slightly narrower than Fixed<T> itself, which needs only
Zeroize, because the generated into_inner
has to leave an inert value behind. Arrays of a Default element, String
and Vec<T> already satisfy both; a custom inner type needs its own
impl SentinelValue, which is why that trait is public:
use secure_gate::{fixed_newtype, RevealSecret};
fixed_newtype!(pub Poly, generic [i16; 256]);
let poly = Poly::new([0i16; 256]);
assert_eq!(poly.with_secret(|coeffs| coeffs.len()), 256);The case this exists for is no_std. With no allocator there is no
Dynamic, so Fixed is the only wrapper, and a secret on
such a target is frequently not bytes: an ML-KEM secret polynomial
[i16; 256], Ed25519 scalar limbs [u64; 4], AES-256 expanded round keys
[u32; 60]. An inner type missing either bound is a compile error reported
from the expansion: Zeroize fails against Fixed<T>’s own bound, and
SentinelValue against the one the generated RevealSecret restates.
dynamic_newtype!’s generic arm carries the
same pair, for the same reason.
Every generic form emits RevealSecret,
RevealSecretMut, redacted Debug, Zeroize,
ZeroizeOnDrop, new, and new_with. What sits on top
of that depends on how much shape the macro can read off the tokens you wrote, and
there are two answers.
A literal array — generic [i16; 256] — takes the array arm. The element type
and the length are both still tokens there, so the expansion can see the shape and
emits two more things: SecretLen, and the declaration-site
N = 0 guard the size-literal arm has always carried. len() counts elements and
byte_len() multiplies by size_of::<T>(), because that is what
Fixed<[T; N]>’s own impl computes — 256 and 512 for a polynomial. The arm needs a
literal length: generic [i16; KEY_LEN] is a different token sequence and falls
through to the opaque arm, the same remainder a path-qualified spelling has.
A true opaque inner type — generic Poly, a struct, an alias — takes the reduced
arm. SecretLen is absent there, and so is the declaration-site guard; a
zero-sized inner value is refused by Fixed at construction
instead. That is a limit on the macro’s field of view rather than a verdict on the
type: the base wrapper still implements SecretLen for every Fixed<[T; N]>, so
the capability is one layer down and derive: [IntoWrapper] reaches it in one call.
What the arm withholds is the newtype’s own len(), not the answer.
From, TryFrom, the encoders and the RNG constructors are absent from both.
The encoders have no defined byte order over [i16], and byte order is not the
whole of it: the canonical encoding of these secrets is domain-specific — ML-KEM
serialises as bit-packed 12-bit coefficients, not a 16-bit dump — so an encoder here
would round-trip inside this crate and disagree with every implementation outside
it. RNG is a separate question with a separate answer: the byte-level fill is well
defined, but uniform bits are not a valid ML-KEM coefficient, a reduced scalar, or a
derived round key. It is tracked in #219 rather than settled here.
generic [u8; N] is a compile error, not a reduced byte array. It is the
same payload as fixed_newtype!(pub K, N) with strictly less API, and
dynamic_newtype! already refuses the analogous
spellings (generic Vec<u8>, generic String). Write the size literal.
The inner type must not be able to reallocate. This arm used to accept
generic Vec<u8> and generic String, which put a growable buffer inside the
wrapper the documentation calls free of any realloc surface; measured, each
abandoned an unwiped buffer holding the whole secret on any capacity change, and
with no io::Write escape of the sort Dynamic<Vec<u8>> has.
Fixed::new now requires
FixedStorage, so those forms are a compile error reported
at this declaration. Arrays, the primitives, tuples and Option are covered for you,
which is every shape this arm exists for; a custom inner type adds
impl FixedStorage for MyType {}, which asserts that it owns no heap
allocation. For a growable payload reach for
dynamic_newtype! instead, where the residue is
documented and one safe growth path exists.
new_with is forwarded. Fixed::new_with is an
inherent on every T: FixedStorage+SentinelValue,
not only [u8; N]: the slot starts as T::sentinel_value() and the closure
writes into the wrapper’s own storage. The generic arm emits it, so a
polynomial does not have to take the secret by value the way new does.
Prefer a size-literal newtype when the secret is a byte array — that arm
is the full API, and generic [u8; N] is refused rather than quietly
reduced.
§Cloning and serialization are not generated
There is no derive: [Clone] or derive: [Serialize], by design. Neither
can be forwarded from the wrapper — Fixed<[u8; N]>: Clone requires
[u8; N]: CloneableSecret, which the orphan
rule makes permanently unimplementable downstream (deliberately so). A
generated impl would therefore have to route through with_secret and
rebuild, opting the secret into cloning with no marker impl anywhere — a
second door around the opt-in system, spelled in one word inside a macro
expansion. Asking for either is a compile error that says so.
The generated type is local to your crate, so write the impl by hand when you mean it. The decision then lives in your code, where it is visible, greppable, and reviewable:
use secure_gate::{fixed_newtype, Fixed, RevealSecret};
fixed_newtype!(pub SessionKey, 32);
// Deliberate: each clone is an independent copy, zeroized on its own drop.
// Cloning widens the window for memory-extraction attacks — audit each use.
impl Clone for SessionKey {
fn clone(&self) -> Self {
Self::new(self.with_secret(|bytes| *bytes))
}
}
let key = SessionKey::new([7u8; 32]);
let copy = key.clone();
assert_eq!(copy.expose_secret()[0], 7);The same applies to Serialize, with a higher bar: serialization exposes
the full secret, so treat a hand-written impl as a security decision and
see SerializableSecret for the risk notes.
§Implementation Notes
The generated type is #[repr(transparent)] over the wrapper, so
size_of::<EncKey>() == N and delegation is #[inline] throughout — the
newtype costs nothing at runtime.
The size-literal and array expansions both emit
const _: () = { let _ = [(); N][0]; };, a declaration-site courtesy: N = 0
fails on the line that declares the type, which is the line worth pointing at.
Whether the length arrives as the size argument or inside a literal array, it is
a literal the macro can count at expansion.
It is not the only guard, and the two are not redundant.
Fixed::new and
Fixed::new_with carry a const assertion rejecting a
zero-sized inner value at construction, which is what covers a plain type alias
and the opaque generic arm; there the error falls at the first concrete
construction instead. The macro guard checks a length, the wrapper’s checks a
size — so generic [(); 4] passes the first and is caught by the second, which
is exactly the case tests/compile-fail/fixed_zero_size.rs pins.
Do not add your own Drop impl. None is needed: the wrapped
Fixed still runs its own, so zeroization is unaffected.
Adding one makes the inner field unmovable (E0509) and silently costs
you into_inner.
§Security
Generated types inherit every Fixed guarantee: zeroize on
drop, Debug that always prints [REDACTED], and access only through
RevealSecret /
RevealSecretMut. There is no Deref — a
generated newtype is not coercible to its wrapper, which is what keeps the
nominal separation total rather than by-value-only.
Nominal separation guards against mistakes, not intent — but nothing is
generated that would undo it by accident. There is no From<Wrapper> and
no Deref, so a base-typed value cannot flow into a newtype through
.into(), and &Newtype never coerces to &Wrapper at a call site. By
default the only way material enters or leaves is the 3-tier access API —
a with_secret round trip — which is explicit and shows up in the audit
sweep. Base-wrapper access is opt-in, per newtype, and split by
direction: derive: [FromWrapper] adds from_wrapper (a base value
enters this role), derive: [IntoWrapper] adds as_wrapper,
as_wrapper_mut, and into_wrapper (material leaves this role toward
the base type), and derive: [WrapperAccess] is shorthand for both.
These cross a different wall from into_inner. A newtype has two:
| Wall | Going in | Going out |
|---|---|---|
| Contents (the wrapper’s protection) | new, From<[u8; N]> | with_secret, expose_secret, into_inner |
| Role (the nominal label) | from_wrapper | as_wrapper, as_wrapper_mut, into_wrapper |
into_inner leaves the protection: it hands back the plain value, so the
contents are in the caller’s hands (tier 3 of the access model, audited).
into_wrapper is a label drop; into_inner is a protection drop.
Confusing those two names is the main way this model gets misread. into_wrapper only removes the label: the
result is still a Fixed, still unreadable without with_secret. The
role row exists so that dropping a label never forces opening the
contents — without it, reaching base-typed code costs an into_inner plus
a rebuild, a reveal the job never needed.
Which direction is safe depends on the pool. In a mixed tree the base
type is not raw material: every plain type alias sharing it is that
type, so a directional token connects this role to all of them at once.
FromWrapper lets anything in the pool become this role — the source never
opts in, because the source is just the base type — so a type that guards
a boundary must never take it. IntoWrapper lets this role become anything
in the pool, which is safe only when the role is no more sensitive than
the least-sensitive alias sharing its base; on a secret role it is an
explicit, greppable downgrade, not a neutral operation. The default,
neither token, is sufficient more often than it looks. IntoWrapper also
reopens whatever the base wrapper implements for that inner type, which on a
generic newtype includes the surface the arm withheld: as_wrapper().len()
and .byte_len() answer for any Fixed<[T; N]>. The reduced surface is a
property of the label, not a barrier. This is now worth granting less often than
it was — a literal array gets its own len() from the array arm, so reaching for
the outbound token merely to measure a secret is no longer the trade it used to
be. Where it is still the only route is the opaque arm. The exposure is
largest during a partial migration — the regime real consumers live in —
because while most aliases stay plain the base type is a universal donor.
Audit these methods the way you audit expose_secret().
§See also
dynamic_newtype!— heap-allocated counterpart- a plain
typealias —pub type Aes256Key = Fixed<[u8; 32]>;— when a readable name rather than role separation is the goal, and the name should stay interchangeable with its base. Two aliases over one shape are the same type, so the compiler will not keep two roles apart; that is the whole of the choice, and the crate’s README argues it at length