secure_gate/slot_writer.rs
1//! Append-shaped writing into a fixed slot.
2//!
3//! > **Import path:** `use secure_gate::SlotWriter;`
4//!
5//! The sized constructors — [`Fixed::new_with`](crate::Fixed::new_with) and
6//! [`Dynamic::new_with`](crate::Dynamic::new_with) — hand the closure a slot whose
7//! length is already decided, which is what makes growth, and therefore the
8//! reallocation hazard, inexpressible. The cost is that concatenation becomes offset
9//! arithmetic:
10//!
11//! ```text
12//! slot[..7].copy_from_slice(LABEL);
13//! slot[7..7 + id.len()].copy_from_slice(suite_id); // and so on, by hand
14//! ```
15//!
16//! That trades a reallocation hazard for an **offset** hazard, and in wire-format code
17//! it is the worse of the two: a wrong bound derives the wrong key and still runs. This
18//! type keeps the slot as the primitive and restores the append shape on top of it.
19
20/// A write cursor over a fixed-length slot.
21///
22/// Writes advance a position and **panic on overrun** rather than truncating or wrapping
23/// — running past the end of a secret's buffer is a bug, not a condition to recover
24/// from, and the panic names the shortfall.
25///
26/// The slot is not owned here. Zeroization remains the wrapper's, so a `SlotWriter`
27/// borrowed from inside `new_with` inherits the same panic-safety: unwinding drops the
28/// wrapper's `Zeroizing`, which wipes whatever was written.
29///
30/// # Examples
31///
32/// RFC 9180-style labeled concatenation, where the length is the sum of the parts. Here
33/// every part has a length the compiler knows, so the total is a constant and the
34/// wrapper is a [`Fixed`](crate::Fixed):
35///
36/// ```rust
37/// use secure_gate::{Fixed, RevealSecret, SlotWriter};
38///
39/// const LABEL: &[u8] = b"HPKE-v1";
40/// let suite_id = [0xAAu8; 3];
41/// let ikm = [0xBBu8; 6];
42///
43/// let secret = Fixed::<[u8; 16]>::new_with(|slot| {
44/// let mut w = SlotWriter::new(slot);
45/// w.push_slice(LABEL);
46/// w.push_slice(&suite_id);
47/// w.push_slice(&ikm);
48/// assert!(w.is_full());
49/// });
50///
51/// assert_eq!(&secret.expose_secret()[..7], LABEL);
52/// ```
53///
54/// The same concatenation when the parts are sized at runtime. This is the commoner
55/// shape for a real wire format — a suite identifier and an IKM that arrived over the
56/// network have no length the compiler can see — and the only thing that changes is
57/// which constructor takes the sum: [`Dynamic::new_with`](crate::Dynamic::new_with)
58/// accepts the total as a value, so `SlotWriter` serves it exactly as it serves the
59/// constant case. There is no reason to reach for an oversized `Fixed` plus a separate
60/// logical length:
61///
62/// ```rust
63/// # #[cfg(feature = "alloc")] {
64/// use secure_gate::{Dynamic, RevealSecret, SlotWriter};
65///
66/// const LABEL: &[u8] = b"HPKE-v1";
67/// let suite_id: Vec<u8> = vec![0xAA; 3]; // lengths known only at run time
68/// let ikm: Vec<u8> = vec![0xBB; 6];
69///
70/// let total = LABEL.len() + suite_id.len() + ikm.len();
71///
72/// let secret = Dynamic::<Vec<u8>>::new_with(total, |slot| {
73/// let mut w = SlotWriter::new(slot);
74/// w.push_slice(LABEL);
75/// w.push_slice(&suite_id);
76/// w.push_slice(&ikm);
77/// assert!(w.is_full()); // the sum and the writes agreed
78/// });
79///
80/// assert_eq!(secret.expose_secret().len(), total);
81/// assert_eq!(&secret.expose_secret()[..7], LABEL);
82/// # }
83/// ```
84///
85/// # The zero tail is inherited, not imposed
86///
87/// A `SlotWriter` never clears anything. It copies the bytes it is handed and leaves
88/// every other byte exactly as it found it, so what an unwritten tail holds is a fact
89/// about the slot you passed to [`new`](Self::new), not a guarantee this type supplies.
90///
91/// Over a slot from a sized constructor that fact is zeros, by documented contract
92/// rather than by luck: [`Fixed::new_with`](crate::Fixed::new_with) starts from
93/// [`SentinelValue::sentinel_value`](crate::SentinelValue::sentinel_value), which is
94/// `[0u8; N]` for a byte array, and [`Dynamic::new_with`](crate::Dynamic::new_with)
95/// allocates `len` zero bytes and documents the zero fill as input callers may rely on.
96/// For those two slots — and for a stack buffer you zeroed yourself — a short write is
97/// not undefined; it is a zero tail, which is frequently what a format wants:
98///
99/// ```rust
100/// use secure_gate::{Fixed, RevealSecret, SlotWriter};
101///
102/// let key = Fixed::<[u8; 16]>::new_with(|slot| {
103/// let mut w = SlotWriter::new(slot);
104/// w.push_slice(&[1, 2, 3, 4, 5]); // 40-bit key, zero-padded to 16
105/// });
106/// assert_eq!(&key.expose_secret()[5..], &[0u8; 11]);
107/// ```
108///
109/// Over any other slot the tail is whatever was there before, and two of those are
110/// traps. One is a scratch buffer reused for a second key. The other is rewriting a
111/// wrapper in place — `SlotWriter::new(&mut secret.expose_secret_mut()[..])` — where the
112/// bytes already in the slot are the *previous secret*. Write fewer bytes than the
113/// previous pass did and the untouched tail is stale key material that the next
114/// derivation consumes as input: a wrong key that still runs, which is the failure mode
115/// this type exists to remove rather than to introduce by a different route. Build a new
116/// wrapper with a sized `new_with` instead of rewriting one in place; failing that, zero
117/// the slot before wrapping it, or write it to [`is_full`](Self::is_full) so that no byte
118/// of the old contents survives.
119///
120/// # `Debug` shows the cursor, never the slot
121///
122/// `{:?}` prints `position`, `remaining` and `len`, and no byte of the slot — see the
123/// `Debug` impl below for the exact rendering and for why it is hand-written.
124pub struct SlotWriter<'a> {
125 slot: &'a mut [u8],
126 pos: usize,
127}
128
129impl<'a> SlotWriter<'a> {
130 /// Wraps a slot, positioned at its start.
131 ///
132 /// # Precondition: a zeroed slot, unless every byte will be written
133 ///
134 /// Any `&mut [u8]` is accepted and nothing here zeroes it, so the slot's current
135 /// contents survive as the tail of whatever you write. Either write the slot to
136 /// [`is_full`](Self::is_full), in which case nothing of the old contents is left, or
137 /// pass a slot that is already zeroed — the ones
138 /// [`Fixed::new_with`](crate::Fixed::new_with) and
139 /// [`Dynamic::new_with`](crate::Dynamic::new_with) hand their closures both are, by
140 /// documented contract. Neither, and an unwritten tail holds whatever the buffer held
141 /// before, which for a reused scratch buffer or a wrapper being rewritten in place is
142 /// the previous secret. The type-level section
143 /// [*The zero tail is inherited, not imposed*](#the-zero-tail-is-inherited-not-imposed)
144 /// spells out what that costs.
145 #[inline]
146 pub fn new(slot: &'a mut [u8]) -> Self {
147 Self { slot, pos: 0 }
148 }
149
150 /// Appends `bytes` and advances past them.
151 ///
152 /// # Panics
153 ///
154 /// If `bytes` does not fit in the remaining space. The message names how much was
155 /// wanted and how much was left, because the useful question when this fires is
156 /// which length calculation was wrong.
157 #[inline]
158 pub fn push_slice(&mut self, bytes: &[u8]) {
159 let end = self
160 .pos
161 .checked_add(bytes.len())
162 .expect("slot position overflow");
163 assert!(
164 end <= self.slot.len(),
165 "SlotWriter overrun: {} more byte(s) than the slot holds ({} wanted, {} remaining)",
166 end - self.slot.len(),
167 bytes.len(),
168 self.remaining(),
169 );
170 self.slot[self.pos..end].copy_from_slice(bytes);
171 self.pos = end;
172 }
173
174 /// Appends one byte and advances past it.
175 ///
176 /// # Panics
177 ///
178 /// If the slot is already full.
179 #[inline]
180 pub fn push_byte(&mut self, byte: u8) {
181 self.push_slice(&[byte]);
182 }
183
184 /// Bytes written so far.
185 #[inline]
186 #[must_use]
187 pub fn position(&self) -> usize {
188 self.pos
189 }
190
191 /// Bytes still unwritten.
192 ///
193 /// These keep whatever the slot already held — zeros when the slot came from
194 /// [`Fixed::new_with`](crate::Fixed::new_with) or
195 /// [`Dynamic::new_with`](crate::Dynamic::new_with), which is the case the zero-tail
196 /// contract covers, and the buffer's previous contents otherwise. See
197 /// [*The zero tail is inherited, not imposed*](#the-zero-tail-is-inherited-not-imposed).
198 #[inline]
199 #[must_use]
200 pub fn remaining(&self) -> usize {
201 self.slot.len() - self.pos
202 }
203
204 /// Whether every byte of the slot has been written.
205 ///
206 /// Useful as an assertion at the end of a wire-format build, where a slot that is
207 /// not full usually means a length calculation disagreed with the writes.
208 #[inline]
209 #[must_use]
210 pub fn is_full(&self) -> bool {
211 self.pos == self.slot.len()
212 }
213}
214
215/// Prints cursor state — `position`, `remaining`, `len` — and never a byte of the slot.
216///
217/// Hand-written rather than derived, and the difference is not cosmetic. The one data
218/// field here is `slot: &'a mut [u8]`, so a derived `Debug` printed the borrowed bytes
219/// verbatim: a writer six bytes into an eight-byte slot rendered as
220/// `SlotWriter { slot: [222, 173, 190, 239, 202, 254, 0, 0], pos: 6 }` — live secret
221/// material, in decimal, in whatever log line the `{:?}` went to. A `SlotWriter` is alive
222/// exactly while the slot holds that material, and the case this type is built for, a
223/// wire format whose offsets are hard to get right, is precisely the case where someone
224/// reaches for `dbg!`. Every other secret-bearing type in the crate hand-writes `Debug`
225/// for the same reason; [`Fixed`](crate::Fixed), [`Dynamic`](crate::Dynamic) and
226/// [`EncodedSecret`](crate::EncodedSecret) print a flat `[REDACTED]`.
227///
228/// This one prints the cursor rather than `[REDACTED]` because the cursor is the thing
229/// worth seeing when an offset is wrong, and it is not secret material: `len` is the slot
230/// length the caller chose and passed to the constructor, and `position` counts the bytes
231/// that caller wrote. None of the three numbers depends on the *value* of any byte,
232/// written or unwritten — which is what makes the output safe to put in a log and safe
233/// to rely on.
234///
235/// ```rust
236/// use secure_gate::{Fixed, SlotWriter};
237///
238/// let secret = Fixed::<[u8; 8]>::new_with(|slot| {
239/// let mut w = SlotWriter::new(slot);
240/// w.push_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]);
241///
242/// let shown = format!("{:?}", w);
243/// assert_eq!(shown, "SlotWriter { position: 6, remaining: 2, len: 8 }");
244/// assert!(!shown.contains("222"), "0xDE is 222, which the derived impl printed");
245/// });
246/// assert_eq!(format!("{:?}", secret), "[REDACTED]");
247/// ```
248impl core::fmt::Debug for SlotWriter<'_> {
249 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
250 f.debug_struct("SlotWriter")
251 .field("position", &self.pos)
252 .field("remaining", &self.remaining())
253 .field("len", &self.slot.len())
254 .finish()
255 }
256}
257
258/// Writes into the slot, failing rather than panicking when it is full.
259///
260/// Present so a slot can be handed to code that writes through [`std::io::Write`] — a
261/// serializer, an encoder, a hasher's output sink. Overrun is reported as
262/// [`ErrorKind::WriteZero`](std::io::ErrorKind::WriteZero) rather than as a panic,
263/// because a `Write` implementation returning an error is how that interface expects to
264/// refuse.
265///
266/// Note the difference from [`push_slice`](SlotWriter::push_slice), which panics: this
267/// path exists for foreign code that cannot be asked to respect a length, so an error is
268/// the only way to tell it to stop.
269///
270/// # Which call reports the overrun, and what the slot holds when it does
271///
272/// Panic-versus-error is not the whole of the difference. The other half is *when* the
273/// refusal arrives and what is in the slot by then. [`push_slice`](SlotWriter::push_slice)
274/// checks the whole length before it copies anything, so an overrun there leaves the slot
275/// byte-for-byte as it was. `write` cannot do that and stay inside its contract:
276/// [`Write::write`](std::io::Write::write) is defined to accept a prefix and report how
277/// much it took, so an oversized `buf` is a **short write** — `min(buf.len(),
278/// remaining())` bytes are copied, `Ok(n)` comes back, and `WriteZero` appears only on a
279/// *later* call, once the slot has no room left at all.
280///
281/// | Call | Oversized payload | Slot afterwards |
282/// |------|-------------------|-----------------|
283/// | [`push_slice`](SlotWriter::push_slice) | panics, naming the shortfall | untouched — the check runs before any copy |
284/// | [`write_all`](std::io::Write::write_all) | `Err` of kind `WriteZero` | holds the bytes that fit |
285/// | [`write`](std::io::Write::write) | `Ok(n)` with `n < buf.len()`, no error | holds the bytes that fit |
286///
287/// So `write_all` is the call that surfaces overrun as an error, and it is the one to
288/// prefer. A bare `write` whose return value is ignored surfaces nothing at all: the
289/// weaker signal in the likelier case, since dropping a `write` count on the floor is the
290/// mistake `write_all` exists to prevent.
291///
292/// Either way, and unlike `push_slice`, a fill driven through this impl can leave a
293/// **partial value** in the slot: the bytes that fit, with whatever the slot already held
294/// behind them. That is indistinguishable, by inspection of the slot, from a deliberate
295/// short write. Assert the length you meant to reach instead —
296/// [`is_full`](SlotWriter::is_full) when the writes were supposed to fill the slot,
297/// [`position`](SlotWriter::position) or [`remaining`](SlotWriter::remaining) when they
298/// were not — which is the same post-condition the `push_slice` example asserts, applied
299/// to a fill you did not write yourself:
300///
301/// ```rust
302/// use std::io::{ErrorKind, Write};
303/// use secure_gate::{Fixed, RevealSecret, SlotWriter};
304///
305/// let truncated = Fixed::<[u8; 4]>::new_with(|slot| {
306/// let mut w = SlotWriter::new(slot);
307///
308/// let err = w.write_all(&[1, 2, 3, 4, 5, 6]).unwrap_err();
309/// assert_eq!(err.kind(), ErrorKind::WriteZero);
310///
311/// // The slot is NOT untouched: four bytes were taken before the refusal.
312/// assert!(w.is_full());
313/// assert_eq!(w.remaining(), 0);
314/// });
315/// assert_eq!(truncated.expose_secret(), &[1, 2, 3, 4]);
316/// ```
317#[cfg(feature = "std")]
318impl std::io::Write for SlotWriter<'_> {
319 /// Copies `min(buf.len(), remaining())` bytes and returns that count.
320 ///
321 /// An oversized `buf` is a short write, not an error: the return value is the only
322 /// place the shortfall is reported, so a caller that ignores it gets a silently
323 /// truncated slot. [`WriteZero`](std::io::ErrorKind::WriteZero) is returned only when
324 /// space is wanted and none remains; an empty `buf` wants none and always succeeds
325 /// with `Ok(0)`.
326 #[inline]
327 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
328 if self.remaining() == 0 && !buf.is_empty() {
329 return Err(std::io::Error::new(
330 std::io::ErrorKind::WriteZero,
331 "SlotWriter is full",
332 ));
333 }
334 let n = core::cmp::min(buf.len(), self.remaining());
335 let end = self.pos + n;
336 self.slot[self.pos..end].copy_from_slice(&buf[..n]);
337 self.pos = end;
338 Ok(n)
339 }
340
341 #[inline]
342 fn flush(&mut self) -> std::io::Result<()> {
343 Ok(())
344 }
345}