Skip to main content

fixed_newtype

Macro fixed_newtype 

Source
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 swapped

Zero-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:

WallGoing inGoing out
Contents (the wrapper’s protection)new, From<[u8; N]>with_secret, expose_secret, into_inner
Role (the nominal label)from_wrapperas_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 type alias — 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