rhdl_dsig_scalable_core/lease.rs
1//! Durable namespace reservation before deriving or loading one-time keys.
2//!
3//! A lease is a permanent, exclusive-create record keyed by trusted signing
4//! identity, epoch and batch number. Both the file and containing directory are
5//! synchronized before a lease is returned. A crashed/abandoned reservation is
6//! burned, never recovered or recycled. No private key material is stored.
7//!
8//! # The problem
9//!
10//! DSig signs every message with a *one-time* key: a W-OTS+ key that is safe
11//! to use for exactly one message. Signing two different messages with the
12//! same one-time key reveals enough hash-chain nodes for a forger to sign
13//! other messages. So the most important rule of the whole project is: **a
14//! one-time key is never reused**, not after it was admitted, not after a
15//! timeout, not after a reset and not after a launch whose outcome is
16//! uncertain.
17//!
18//! The keys are not stored anywhere. They are *derived*: the hardware (and the
19//! software reference in [`crate::reference`]) computes every key of a batch
20//! from the master seed, the epoch and the batch number. So the triple
21//! `(signer identity, epoch, batch number)` is the real name of 128 one-time
22//! keys ([`crate::BATCH_KEYS`]). If two runs ever derive the same triple from
23//! the same seed, they derive the same keys, and reuse has happened, even if
24//! each run on its own was perfectly correct.
25//!
26//! # The decision
27//!
28//! Before any keys are derived or loaded, the host *leases* the batch numbers
29//! by creating a small text file on disk. The file is the proof that those
30//! names have been used, and it is never deleted:
31//!
32//! * The file is created with `create_new` (the operating system's exclusive
33//! create). If it already exists the call fails, so two processes racing for
34//! the same batch cannot both win.
35//! * The file *and* its directory are `fsync`ed (forced to stable storage)
36//! before the lease is handed out. A power cut after that point cannot make
37//! the reservation disappear.
38//! * There is no "release", "resume" or "reopen". A crashed or abandoned
39//! reservation is *burned*: its batch numbers are simply lost. Losing some
40//! numbers out of a 64-bit space costs nothing; reusing one could cost
41//! everything.
42//! * No private key material is ever written. The record holds only the
43//! public identity, the epoch and the batch numbers. The master seed is
44//! never stored.
45//!
46//! # Two kinds of lease
47//!
48//! | Type | Covers | Durable writes | Epoch half |
49//! |---|---|---|---|
50//! | [`BatchLease`] (via [`BatchLease::reserve`]) | one batch (128 keys) | one record per batch | low half: bit 63 clear, nonzero |
51//! | [`RangeLease`] | [`RANGE_BATCHES`] = 2^20 batches (2^27 keys) | one record per range | high half: bit 63 ([`RANGE_EPOCH_BIT`]) set |
52//!
53//! A record per batch is simple but means one synchronous disk write per 128
54//! keys, far too slow for a card that makes keys continuously. A range lease
55//! burns a whole fixed interval of batch numbers with one durable write, then
56//! hands the batches out one by one from memory. Because single leases and
57//! range leases live in different halves of the epoch space, a single lease
58//! can never overlap a range.
59//!
60//! # Lifecycle
61//!
62//! ```text
63//! single batch fixed range
64//! ──────────── ───────────
65//! BatchLease::reserve(dir, id, e, b) RangeLease::reserve(dir, id, E|bit63, r)
66//! │ create_new + write record │ create_new + write record
67//! │ fsync file, fsync directory │ fsync file, fsync directory
68//! ▼ ▼
69//! ┌───────────────┐ ┌──────────────────────────────┐
70//! │ BatchLease │ │ RangeLease next = r × 2^20 │
71//! │ (not Clone) │ │ end = next+2^20 │
72//! └──────┬────────┘ └──────┬───────────────────────┘
73//! │ │ take_next(): next += 1
74//! │◀───────────────────────────────────┘ (memory only, no fsync)
75//! │ prepare(master_seed, signing key)
76//! │ identity mismatch → error, lease consumed (burned)
77//! ▼
78//! PreparedBatch (keys derived; the local seed copy is zeroized)
79//!
80//! Every path ends in "burned": drop, crash, error, depletion or use.
81//! The record file stays on disk forever; nothing reads it back to resume.
82//! ```
83//!
84//! # Where it fits
85//!
86//! The host program `dsig_engine_host` (`src/bin/dsig_engine_host.rs`) calls
87//! [`RangeLease::reserve`] in its `prepare` step *before* it draws the master
88//! seed, so a crash at any later point burns the range instead of leaving it
89//! reusable. The card ([`crate::engine`]) then derives every key of that run
90//! from the loaded parameters. For software users, [`BatchLease::prepare`]
91//! wraps [`PreparedBatch::new`] so a batch is burned on disk before its keys
92//! exist in memory.
93//!
94//! # What it does not protect against
95//!
96//! This protects cooperating processes on a trusted local filesystem, not a
97//! malicious administrator, filesystem rollback, restored snapshots or cloned
98//! machines. Keep the directory persistent and backed by storage with correct
99//! `fsync` semantics. Reusing a master seed after losing this journal is unsafe;
100//! use new independent entropy in that case. Lease files must not be deleted.
101
102use std::{
103 fs::{self, OpenOptions},
104 io::{self, Write},
105 path::{Path, PathBuf},
106};
107
108use ed25519_dalek::SigningKey;
109use zeroize::Zeroize;
110
111use crate::reference::PreparedBatch;
112
113/// Epoch high bit reserved for fixed-range allocation; single leases reject it.
114///
115/// Bit 63 of the 64-bit epoch splits the epoch space into two halves: epochs
116/// with this bit clear belong to [`BatchLease::reserve`], epochs with it set
117/// belong to [`RangeLease::reserve`]. The split exists so a single-batch
118/// lease and a range lease can never name the same batch, even though their
119/// record files have different names and so would not collide on disk.
120pub const RANGE_EPOCH_BIT: u64 = 1 << 63;
121/// One permanent range contains this many 128-key batches.
122///
123/// 2^20 = 1,048,576 batches, so 2^27 one-time keys per range record. Range
124/// number `r` always covers batch IDs `r × 2^20` up to (not including)
125/// `(r + 1) × 2^20`. The mapping is fixed, so no "next free batch" counter
126/// has to be updated on disk, and there is no such counter to corrupt.
127pub const RANGE_BATCHES: u64 = 1 << 20;
128
129/// A non-cloneable allocation capability, returned only after durable creation.
130///
131/// Holding a `BatchLease` means "this batch number is burned on disk and is
132/// mine alone". It deliberately does not derive `Clone` or `Copy`: the only
133/// thing you can do with it is spend it once, in [`Self::prepare`]. Dropping
134/// it without preparing simply wastes the batch number, which is safe.
135pub struct BatchLease {
136 /// The signer's Ed25519 public (verifying) key, 32 bytes. It is part of
137 /// the lease's name, and [`Self::prepare`] checks the signing key against it.
138 signer: [u8; 32],
139 /// The epoch: a 64-bit namespace number chosen by the operator. Low half
140 /// (bit 63 clear) for single leases, high half for batches from a range.
141 epoch: u64,
142 /// The batch number inside the epoch; one batch is 128 one-time keys.
143 batch_id: u64,
144 /// Path of the on-disk record that burned this batch. For a batch taken
145 /// from a [`RangeLease`], this is the range's shared record.
146 record: PathBuf,
147}
148
149impl BatchLease {
150 /// Atomically reserve a fresh batch in an existing, trusted journal directory.
151 ///
152 /// # Errors
153 /// Duplicate IDs, a zero or range-mode epoch, missing/unwritable directories
154 /// or durability failures return an error. A record left behind by any
155 /// failure stays burned. Range allocation uses a disjoint epoch namespace,
156 /// preventing a single lease from overlapping any [`RangeLease`].
157 ///
158 /// Arguments: `directory` is the journal (it must already exist and be
159 /// persistent); `signer` is the 32-byte Ed25519 public key; `epoch` and
160 /// `batch_id` name the batch. On success the record
161 /// `dsig-v1-<signer hex>-<epoch hex>-<batch hex>.lease` exists on stable
162 /// storage and will stay there forever.
163 pub fn reserve(
164 directory: &Path,
165 signer: [u8; 32],
166 epoch: u64,
167 batch_id: u64,
168 ) -> io::Result<Self> {
169 // Epoch 0 is rejected, and so is the high half: that half belongs to
170 // range leases, and keeping the halves apart is what stops overlap.
171 if epoch == 0 || epoch & RANGE_EPOCH_BIT != 0 {
172 return Err(io::Error::new(
173 io::ErrorKind::InvalidInput,
174 "single leases require a nonzero low-half epoch",
175 ));
176 }
177 // The signer's public key as 64 lowercase hex characters, used in the
178 // file name so different signers never share a record.
179 let identity: String = signer.iter().map(|byte| format!("{byte:02x}")).collect();
180 // Fixed-width hex fields make the name unique per (signer, epoch, batch).
181 let record = directory.join(format!(
182 "dsig-v1-{identity}-{epoch:016x}-{batch_id:016x}.lease"
183 ));
184 // `create_new` is the exclusive create: it fails with AlreadyExists if
185 // any process (this one, an earlier crashed one, a racing one) has
186 // ever created this record. This one call is the whole race guard.
187 let mut file = OpenOptions::new()
188 .write(true)
189 .create_new(true)
190 .open(&record)?;
191 // Human-readable public contents only: profile, identity, epoch, batch.
192 // If this write fails, the empty record still exists and stays burned.
193 writeln!(
194 file,
195 "DSIG_FPGA_W4_HARAKA_BLAKE3_V1\nsigner={identity}\nepoch={epoch}\nbatch={batch_id}\nstate=permanently-reserved"
196 )?;
197 // Flush the file's contents to stable storage...
198 file.sync_all()?;
199 // ...and the directory, so the new directory entry (the name) survives
200 // a power cut too. Only after both does the caller get a lease.
201 fs::File::open(directory)?.sync_all()?;
202 Ok(Self {
203 signer,
204 epoch,
205 batch_id,
206 record,
207 })
208 }
209
210 /// Permanent public record path, possibly covering an entire fixed range.
211 /// Dropping this permit never removes or reopens its record.
212 #[must_use]
213 pub fn record(&self) -> &Path {
214 &self.record
215 }
216
217 /// Consume the lease before preparing keys. Identity mismatches burn it too.
218 ///
219 /// # Errors
220 /// Returns an error if the supplied signing key is not the reserved identity.
221 ///
222 /// Takes `self` by value, so the lease is gone after this call whether it
223 /// succeeds or fails: a batch can be prepared at most once. `master_seed`
224 /// is the 32-byte secret from which the batch's 128 one-time keys are
225 /// derived (see [`PreparedBatch::new`]); this function wipes its own copy
226 /// before returning and never writes it anywhere.
227 pub fn prepare(
228 self,
229 mut master_seed: [u8; 32],
230 signer: &SigningKey,
231 ) -> io::Result<PreparedBatch> {
232 // The lease names one public key; refuse to derive keys certified by
233 // any other key. The seed copy is wiped on this error path too.
234 if signer.verifying_key().to_bytes() != self.signer {
235 master_seed.zeroize();
236 return Err(io::Error::new(
237 io::ErrorKind::InvalidInput,
238 "lease signer mismatch",
239 ));
240 }
241 // Derive and certify the batch's keys for exactly the leased
242 // (epoch, batch) pair; the caller cannot pick different numbers here.
243 let prepared = PreparedBatch::new(master_seed, self.epoch, self.batch_id, signer);
244 // Overwrite this stack copy of the seed with zeros before it goes out
245 // of scope, so the secret does not linger in memory.
246 master_seed.zeroize();
247 Ok(prepared)
248 }
249}
250
251/// Persist-ahead allocation of a fixed, disjoint range of batch identifiers.
252///
253/// Each record burns 2^20 batches (2^27 one-time keys) before any permit is
254/// returned. At 20 million keys/s this amortizes durability to roughly one
255/// reservation every 6.7 seconds rather than one per 128 keys. This is capacity
256/// arithmetic, not a measured allocator or signing rate.
257///
258/// Range epochs have their high bit set; single-batch leases reject those
259/// epochs. Each range number maps to exactly one fixed-width interval, so
260/// exclusive file creation prevents races without an in-place journal or a
261/// crash-sensitive high-water-mark update. A partial record is still burned.
262/// There is deliberately no resume/reopen method: a crash or drop discards all
263/// unused batch IDs. Do not delete records or reuse them after snapshot rollback.
264///
265/// Like [`BatchLease`], a `RangeLease` is not `Clone`: only one live object
266/// can hand out batches from a range, so no two callers get the same ID.
267pub struct RangeLease {
268 /// The signer's 32-byte Ed25519 public key; copied into every
269 /// [`BatchLease`] this range hands out.
270 signer: [u8; 32],
271 /// The range epoch; always has [`RANGE_EPOCH_BIT`] set.
272 epoch: u64,
273 /// The next batch ID to hand out. Starts at `range_number × 2^20` and only
274 /// ever increases; it lives in memory only and is never saved.
275 next: u64,
276 /// One past the last batch ID of the range (`start + RANGE_BATCHES`).
277 end: u64,
278 /// Path of the single record that burned the whole interval.
279 record: PathBuf,
280}
281
282impl RangeLease {
283 /// Durably reserve the fixed interval belonging to `range_number`.
284 ///
285 /// # Errors
286 /// Rejects non-range epochs, identifier overflow, an existing record, and
287 /// filesystem/durability errors. Failures never authorize any batch permit.
288 ///
289 /// Arguments: `directory` is the persistent journal; `signer` the 32-byte
290 /// Ed25519 public key; `epoch` a high-half epoch; `range_number` picks the
291 /// interval `range_number × 2^20 .. (range_number + 1) × 2^20`.
292 pub fn reserve(
293 directory: &Path,
294 signer: [u8; 32],
295 epoch: u64,
296 range_number: u64,
297 ) -> io::Result<Self> {
298 // Ranges must use the high half of the epoch space (the mirror image
299 // of the check in BatchLease::reserve), so the two never overlap.
300 if epoch & RANGE_EPOCH_BIT == 0 {
301 return Err(io::Error::new(
302 io::ErrorKind::InvalidInput,
303 "range leases require a high-half epoch",
304 ));
305 }
306 // First batch ID of the range. Checked multiply: a huge range number
307 // must be an error, not a silent wrap-around onto low batch IDs.
308 let start = range_number
309 .checked_mul(RANGE_BATCHES)
310 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "range start overflow"))?;
311 // One past the last batch ID; also checked, for the topmost range.
312 let end = start
313 .checked_add(RANGE_BATCHES)
314 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "range end overflow"))?;
315 // Public key as 64 hex characters, for the record's file name.
316 let identity: String = signer.iter().map(|byte| format!("{byte:02x}")).collect();
317 // The word "range" in the name keeps range records apart from
318 // single-batch records in the same directory.
319 let record = directory.join(format!(
320 "dsig-v1-{identity}-{epoch:016x}-range-{range_number:016x}.lease"
321 ));
322 // Exclusive create: exactly one caller ever wins a given range.
323 let mut file = OpenOptions::new()
324 .write(true)
325 .create_new(true)
326 .open(&record)?;
327 // Public description of the burned interval; no secrets. A failure
328 // here leaves a partial record that still burns the range.
329 writeln!(
330 file,
331 "DSIG_FPGA_W4_HARAKA_BLAKE3_V1\nsigner={identity}\nepoch={epoch}\nrange={range_number}\nstart={start}\nend_exclusive={end}\nstate=permanently-reserved"
332 )?;
333 // Make the contents durable, then the directory entry, before any
334 // batch of the range can be handed out.
335 file.sync_all()?;
336 fs::File::open(directory)?.sync_all()?;
337 Ok(Self {
338 signer,
339 epoch,
340 next: start,
341 end,
342 record,
343 })
344 }
345
346 /// The durable reservation record that burned this whole interval.
347 #[must_use]
348 pub fn record(&self) -> &Path {
349 &self.record
350 }
351
352 /// Unallocated batch permits in this live capability, never on-disk recovery.
353 #[must_use]
354 pub const fn remaining(&self) -> u64 {
355 self.end - self.next
356 }
357
358 /// Burn the next batch ID before handing out its non-cloneable capability.
359 ///
360 /// No filesystem synchronization is needed here: the entire interval was
361 /// durably burned by [`Self::reserve`]. Dropping a returned permit also burns
362 /// that batch. A depleted range returns `None` and cannot be recycled.
363 pub fn take_next(&mut self) -> Option<BatchLease> {
364 // Depleted: the range is used up for good; reserve a new range number.
365 if self.next == self.end {
366 return None;
367 }
368 // Hand out the current ID and advance first, so the same ID can never
369 // be returned twice by this object.
370 let batch_id = self.next;
371 self.next += 1;
372 Some(BatchLease {
373 signer: self.signer,
374 epoch: self.epoch,
375 batch_id,
376 record: self.record.clone(),
377 })
378 }
379}
380
381/// Tests of the burn-forever rules: duplicates fail, drops do not free a
382/// batch, races have one winner and ranges never overlap single leases.
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::sync::atomic::{AtomicU64, Ordering};
387 /// Counter that makes each test's scratch directory name unique within
388 /// one test process (tests run in parallel threads).
389 static NEXT: AtomicU64 = AtomicU64::new(0);
390
391 /// A fresh, empty directory under the system temp dir, named by process
392 /// ID and [`NEXT`], used as a throwaway journal by one test.
393 fn temporary_directory() -> PathBuf {
394 let path = std::env::temp_dir().join(format!(
395 "dsig-lease-test-{}-{}",
396 std::process::id(),
397 NEXT.fetch_add(1, Ordering::Relaxed)
398 ));
399 fs::create_dir(&path).unwrap();
400 path
401 }
402
403 /// Dropping a lease (or restarting the process) must not free its batch:
404 /// a second reserve of the same triple fails with `AlreadyExists`, while
405 /// a different batch or epoch still succeeds and epoch 0 is rejected.
406 #[test]
407 fn dropping_or_restarting_cannot_reallocate_a_durable_lease() {
408 let directory = temporary_directory();
409 let signer = SigningKey::from_bytes(&[0x19; 32])
410 .verifying_key()
411 .to_bytes();
412 let lease = BatchLease::reserve(&directory, signer, 1, 4).unwrap();
413 assert!(lease.record().is_file());
414 drop(lease);
415 assert_eq!(
416 BatchLease::reserve(&directory, signer, 1, 4)
417 .err()
418 .unwrap()
419 .kind(),
420 io::ErrorKind::AlreadyExists
421 );
422 assert!(BatchLease::reserve(&directory, signer, 1, 5).is_ok());
423 assert!(BatchLease::reserve(&directory, signer, 2, 4).is_ok());
424 assert!(BatchLease::reserve(&directory, signer, 0, 4).is_err());
425 // Only this test's uniquely-created directory is removed. Real journals
426 // are permanent and expose no cleanup operation in the API.
427 fs::remove_dir_all(directory).unwrap();
428 }
429
430 /// Four threads race for the same single batch; exactly one may win.
431 #[test]
432 fn simultaneous_reservations_have_exactly_one_winner() {
433 let directory = temporary_directory();
434 let handles: Vec<_> = (0..4)
435 .map(|_| {
436 let path = directory.clone();
437 std::thread::spawn(move || BatchLease::reserve(&path, [0x42; 32], 10, 3).is_ok())
438 })
439 .collect();
440 assert_eq!(
441 handles
442 .into_iter()
443 .map(|h| usize::from(h.join().unwrap()))
444 .sum::<usize>(),
445 1
446 );
447 fs::remove_dir_all(directory).unwrap();
448 }
449
450 /// Range rules: wrong epoch halves are rejected both ways, overflowing
451 /// range numbers are rejected, ranges 0 and 1 hand out disjoint IDs, a
452 /// dropped range cannot be re-reserved, depletion returns `None`, and
453 /// even an empty (torn) record burns its range.
454 #[test]
455 fn ranges_are_disjoint_permanent_and_separate_from_single_leases() {
456 let directory = temporary_directory();
457 let signer = [0x29; 32];
458 let epoch = RANGE_EPOCH_BIT | 3;
459 assert!(BatchLease::reserve(&directory, signer, epoch, 0).is_err());
460 assert!(RangeLease::reserve(&directory, signer, 3, 0).is_err());
461 assert!(RangeLease::reserve(&directory, signer, epoch, u64::MAX).is_err());
462 assert!(RangeLease::reserve(&directory, signer, epoch, u64::MAX / RANGE_BATCHES).is_err());
463 let mut first = RangeLease::reserve(&directory, signer, epoch, 0).unwrap();
464 let mut second = RangeLease::reserve(&directory, signer, epoch, 1).unwrap();
465 assert_eq!(first.remaining(), RANGE_BATCHES);
466 let zero = first.take_next().unwrap();
467 let one = first.take_next().unwrap();
468 let next_range = second.take_next().unwrap();
469 assert_eq!(
470 (zero.batch_id, one.batch_id, next_range.batch_id),
471 (0, 1, RANGE_BATCHES)
472 );
473 assert_eq!(first.remaining(), RANGE_BATCHES - 2);
474 drop(first);
475 assert_eq!(
476 RangeLease::reserve(&directory, signer, epoch, 0)
477 .err()
478 .unwrap()
479 .kind(),
480 io::ErrorKind::AlreadyExists
481 );
482 assert_eq!(
483 RangeLease::reserve(&directory, signer, epoch, 1)
484 .err()
485 .unwrap()
486 .kind(),
487 io::ErrorKind::AlreadyExists
488 );
489 // Exercise the final allocation without looping over a million permits.
490 second.next = second.end - 1;
491 assert_eq!(second.take_next().unwrap().batch_id, 2 * RANGE_BATCHES - 1);
492 assert_eq!(second.remaining(), 0);
493 assert!(second.take_next().is_none());
494 // Even a torn/empty record from a failed reservation is never resumed.
495 let identity: String = signer.iter().map(|byte| format!("{byte:02x}")).collect();
496 let torn = directory.join(format!(
497 "dsig-v1-{identity}-{epoch:016x}-range-0000000000000002.lease"
498 ));
499 OpenOptions::new()
500 .write(true)
501 .create_new(true)
502 .open(torn)
503 .unwrap();
504 assert_eq!(
505 RangeLease::reserve(&directory, signer, epoch, 2)
506 .err()
507 .unwrap()
508 .kind(),
509 io::ErrorKind::AlreadyExists
510 );
511 fs::remove_dir_all(directory).unwrap();
512 }
513
514 /// Four threads race for the same range number; exactly one may win.
515 #[test]
516 fn concurrent_range_claims_have_one_winner() {
517 let directory = temporary_directory();
518 let handles: Vec<_> = (0..4)
519 .map(|_| {
520 let path = directory.clone();
521 std::thread::spawn(move || {
522 RangeLease::reserve(&path, [0x24; 32], RANGE_EPOCH_BIT, 2).is_ok()
523 })
524 })
525 .collect();
526 assert_eq!(
527 handles
528 .into_iter()
529 .map(|h| usize::from(h.join().unwrap()))
530 .sum::<usize>(),
531 1
532 );
533 fs::remove_dir_all(directory).unwrap();
534 }
535}