Skip to main content

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}