Skip to main content

toolkit_zero/encryption/timelock/
mod.rs

1//! Time-locked key derivation.
2//!
3//! Derives a deterministic 32-byte key from a **time value** through a
4//! three-pass heterogeneous KDF chain:
5//!
6//! | Pass | Algorithm | Role |
7//! |------|-----------|------|
8//! | 1 | **Argon2id** | PHC winner; sequential- and random-access memory-hard; GPU/ASIC-resistant |
9//! | 2 | **scrypt**   | Independently designed memory-hard function (ROMix); orthogonal to Argon2id |
10//! | 3 | **Argon2id** | Extends the chain depth with fresh parameters and a distinct salt |
11//!
12//! Using two *independently designed* memory-hard functions ensures the chain
13//! remains strong even if a weakness is discovered in either algorithm.
14//! Every intermediate KDF output is zeroized from memory before the subsequent
15//! pass begins.
16//!
17//! # Two entry points
18//!
19//! | `params` argument                   | Path                | Intended use                                                         |
20//! |-------------------------------------|---------------------|----------------------------------------------------------------------|
21//! | `params: None` (+ all other `Some`) | `_at` — encryption  | Caller supplies cadence, time, precision, format, salts, and KDF parameters |
22//! | `params: Some(p)` (rest `None`)     | `_now` — decryption | All settings are read from [`TimeLockParams`]; no additional input required |
23//!
24//! Async counterparts ([`timelock_async`]) are provided under the
25//! `enc-timelock-async-keygen-now` and `enc-timelock-async-keygen-input` features;
26//! they offload blocking KDF work to a dedicated thread, ensuring the calling
27//! executor is never stalled.
28//!
29//! # Time input
30//!
31//! The KDF input is a short ASCII string derived from the time value at one
32//! of three selectable precision levels.
33//!
34//! | [`TimePrecision`] | [`TimeFormat`] | Example string | Window   | Candidates/day |
35//! |-------------------|----------------|----------------|----------|----------------|
36//! | `Hour`    | `Hour24` | `"14"`        | 60 min   | 24             |
37//! | `Hour`    | `Hour12` | `"02PM"`      | 60 min   | 12 unique × 2  |
38//! | `Quarter` | `Hour24` | `"14:30"`     | 15 min   | 96             |
39//! | `Quarter` | `Hour12` | `"02:30PM"`   | 15 min   | 48 unique × 2  |
40//! | `Minute`  | `Hour24` | `"14:37"`     | 1 min    | 1440           |
41//! | `Minute`  | `Hour12` | `"02:37PM"`   | 1 min    | 720 unique × 2 |
42//!
43//! > **`Hour12` note**: the same time slot recurs twice daily (AM + PM),
44//! > making the derived key valid twice per day.  Use `Hour24` for a key
45//! > that is uniquely valid once per day.
46//!
47//! > **Clock skew (`Minute` precision):** if both parties' clocks may diverge
48//! > by up to one minute, derive keys for `now() − 1 min`, `now()`, and
49//! > `now() + 1 min` and try each in turn. The additional cost is negligible
50//! > relative to a single full KDF pass.
51//!
52//! # Salts
53//!
54//! [`TimeLockSalts`] holds three independent 32-byte random values — one per
55//! KDF pass — generated at encryption time via [`TimeLockSalts::generate`].
56//! Salts are **not secret**; they prevent precomputation attacks and must be
57//! stored in plaintext alongside the ciphertext header. The identical salts
58//! must be provided to the decryption call.
59//!
60//! # Memory safety
61//!
62//! All intermediate KDF outputs are wrapped in [`Zeroizing`] and overwritten
63//! upon being dropped. [`TimeLockKey`] implements [`ZeroizeOnDrop`]; the final
64//! 32-byte key material is scrubbed from memory the moment it goes out of scope.
65//!
66//! # Quick start
67//!
68//! ```no_run
69//! use toolkit_zero::encryption::timelock::*;
70//!
71//! // ── Encryption side ───────────────────────────────────────────────────
72//! let salts     = TimeLockSalts::generate();
73//! let kdf       = KdfPreset::Balanced.params();
74//! let lock_time = TimeLockTime::new(14, 37).unwrap();
75//!
76//! // Derive the encryption key (params = None → _at path).
77//! let enc_key = timelock(
78//!     Some(TimeLockCadence::None),
79//!     Some(lock_time),
80//!     Some(TimePrecision::Minute),
81//!     Some(TimeFormat::Hour24),
82//!     Some(salts.clone()),
83//!     Some(kdf),
84//!     None,
85//! ).unwrap();
86//!
87//! // Pack all settings into a header and store alongside the ciphertext.
88//! let header = pack(TimePrecision::Minute, TimeFormat::Hour24,
89//!                   &TimeLockCadence::None, salts, kdf);
90//!
91//! // ── Decryption side ───────────────────────────────────────────────────
92//! // Load header from ciphertext (params = Some → _now path).
93//! // Call at 14:37 local time:
94//! let dec_key = timelock(
95//!     None, None, None, None, None, None,
96//!     Some(header),
97//! ).unwrap();
98//! // enc_key.as_bytes() == dec_key.as_bytes() when called at 14:37 local time
99//! ```
100
101#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input"))]
102mod helper;
103
104#[cfg(feature = "backend-deps")]
105pub mod backend_deps;
106
107#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
108pub mod utility;
109#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
110pub use utility::{TimeLockParams, pack, unpack};
111
112#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
113use zeroize::{Zeroize, ZeroizeOnDrop};
114
115// ─── time precision / format ──────────────────────────────────────────────────
116
117/// The granularity at which the time value is quantised when constructing the
118/// KDF input string.
119///
120/// Coarser precision yields a longer validity window, making it easier for a
121/// legitimate user to produce the correct key; finer precision increases the
122/// cost of time-sweeping attacks.
123#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum TimePrecision {
126    /// Quantise to the current **hour**.
127    ///
128    /// Input example: `"14"` (24-hour) | `"02PM"` (12-hour).  
129    /// The derived key is valid for the entire 60-minute block.
130    Hour,
131
132    /// Quantise to the current **15-minute block** (minute snapped to
133    /// 00, 15, 30, or 45).
134    ///
135    /// Input example: `"14:30"` (24-hour) | `"02:30PM"` (12-hour).  
136    /// The derived key is valid for the 15-minute interval enclosing the
137    /// chosen minute.
138    Quarter,
139
140    /// Quantise to the current **minute** (1-minute validity window).
141    ///
142    /// Input example: `"14:37"` (24-hour) | `"02:37PM"` (12-hour).  
143    /// The strongest temporal constraint available — both parties' clocks must
144    /// be NTP-synchronised to within ±30 seconds.
145    Minute,
146}
147
148/// Clock representation used when formatting the time input string.
149#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum TimeFormat {
152    /// 24-hour clock (`00`–`23`). Every time slot is unique within a day.
153    Hour24,
154
155    /// 12-hour clock (`01`–`12`) with an `AM`/`PM` suffix.
156    /// Each time slot recurs **twice daily**.
157    Hour12,
158}
159
160// ─── schedule cadence ────────────────────────────────────────────────────────
161
162/// Day of the week, Monday-indexed (Mon = 0 … Sun = 6).
163///
164/// Used as a cadence component in [`TimeLockCadence`] to constrain key
165/// derivation to a specific weekday.
166#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum Weekday {
169    Monday,
170    Tuesday,
171    Wednesday,
172    Thursday,
173    Friday,
174    Saturday,
175    Sunday,
176}
177
178#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
179impl Weekday {
180    /// The full English name of this weekday (e.g. `"Tuesday"`).
181    pub fn name(self) -> &'static str {
182        match self {
183            Self::Monday    => "Monday",
184            Self::Tuesday   => "Tuesday",
185            Self::Wednesday => "Wednesday",
186            Self::Thursday  => "Thursday",
187            Self::Friday    => "Friday",
188            Self::Saturday  => "Saturday",
189            Self::Sunday    => "Sunday",
190        }
191    }
192
193    /// Zero-based weekday number (Monday = 0, …, Sunday = 6).
194    pub fn number(self) -> u8 {
195        match self {
196            Self::Monday    => 0,
197            Self::Tuesday   => 1,
198            Self::Wednesday => 2,
199            Self::Thursday  => 3,
200            Self::Friday    => 4,
201            Self::Saturday  => 5,
202            Self::Sunday    => 6,
203        }
204    }
205
206    /// Convert from a `chrono::Weekday` value (used by the `_now` derivation path).
207    #[cfg(feature = "enc-timelock-keygen-now")]
208    pub(crate) fn from_chrono(w: chrono::Weekday) -> Self {
209        match w {
210            chrono::Weekday::Mon => Self::Monday,
211            chrono::Weekday::Tue => Self::Tuesday,
212            chrono::Weekday::Wed => Self::Wednesday,
213            chrono::Weekday::Thu => Self::Thursday,
214            chrono::Weekday::Fri => Self::Friday,
215            chrono::Weekday::Sat => Self::Saturday,
216            chrono::Weekday::Sun => Self::Sunday,
217        }
218    }
219}
220
221/// Calendar month (January = 1 … December = 12).
222///
223/// Used as a cadence component in [`TimeLockCadence`] to constrain key
224/// derivation to a specific month of the year.
225#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum Month {
228    January,
229    February,
230    March,
231    April,
232    May,
233    June,
234    July,
235    August,
236    September,
237    October,
238    November,
239    December,
240}
241
242#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
243impl Month {
244    /// The full English name of this month (e.g. `"February"`).
245    pub fn name(self) -> &'static str {
246        match self {
247            Self::January   => "January",
248            Self::February  => "February",
249            Self::March     => "March",
250            Self::April     => "April",
251            Self::May       => "May",
252            Self::June      => "June",
253            Self::July      => "July",
254            Self::August    => "August",
255            Self::September => "September",
256            Self::October   => "October",
257            Self::November  => "November",
258            Self::December  => "December",
259        }
260    }
261
262    /// 1-based month number (January = 1, …, December = 12).
263    pub fn number(self) -> u8 {
264        match self {
265            Self::January   => 1,
266            Self::February  => 2,
267            Self::March     => 3,
268            Self::April     => 4,
269            Self::May       => 5,
270            Self::June      => 6,
271            Self::July      => 7,
272            Self::August    => 8,
273            Self::September => 9,
274            Self::October   => 10,
275            Self::November  => 11,
276            Self::December  => 12,
277        }
278    }
279
280    /// Maximum day count for this month.
281    ///
282    /// February is defined as 28 days; leap years are intentionally not
283    /// accounted for to keep the cadence policy stable across years.
284    pub fn max_days(self) -> u8 {
285        match self {
286            Self::February => 28,
287            Self::April | Self::June | Self::September | Self::November => 30,
288            _ => 31,
289        }
290    }
291
292    /// Construct from a 1-based month number (1 = January … 12 = December).
293    ///
294    /// # Panics
295    ///
296    /// Panics if `n` is outside 1–12.
297    #[cfg(feature = "enc-timelock-keygen-now")]
298    pub(crate) fn from_number(n: u8) -> Self {
299        match n {
300            1  => Self::January,
301            2  => Self::February,
302            3  => Self::March,
303            4  => Self::April,
304            5  => Self::May,
305            6  => Self::June,
306            7  => Self::July,
307            8  => Self::August,
308            9  => Self::September,
309            10 => Self::October,
310            11 => Self::November,
311            12 => Self::December,
312            _  => panic!("Month::from_number: invalid month number {}", n),
313        }
314    }
315}
316
317/// Calendar cadence for a scheduled time-lock — constrains key derivation to
318/// a recurring calendar pattern **in addition to** the time-of-day window.
319///
320/// Combine with a [`TimeLockTime`] on the encryption path to express policies
321/// such as:
322///
323/// - *"valid only on Tuesdays at 18:00"* — `DayOfWeek(Weekday::Tuesday)` + 18 h
324/// - *"valid only on the 1st of each month at 00:00"* — `DayOfMonth(1)` + 0 h
325/// - *"valid only on Tuesdays in February at 06:00"* — `DayOfWeekInMonth(Weekday::Tuesday, Month::February)` + 6 h
326///
327/// On the decryption side, pass the cadence to [`pack`] (along with precision
328/// and format) to obtain a [`TimeLockParams`] for storage in the ciphertext
329/// header.
330///
331/// `TimeLockCadence::None` is equivalent to a call without any calendar
332/// constraint — no calendar dimension is incorporated into the KDF input.
333///
334/// # Panics
335///
336/// Constructing [`DayOfMonthInMonth`](TimeLockCadence::DayOfMonthInMonth) is
337/// always valid, but **key derivation panics** if the stored day exceeds the
338/// month's maximum (for example, day 29 for February or day 31 for April).
339#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum TimeLockCadence {
342    /// No calendar constraint — behaves like a plain time-lock.
343    ///
344    /// Compact discriminant: `0`.
345    None,
346
347    /// Valid only on the specified weekday.
348    ///
349    /// Compact discriminant: `1`.
350    DayOfWeek(Weekday),
351
352    /// Valid only on the specified day of any month (1–31).
353    ///
354    /// Days 29–31 simply never match in shorter months.
355    ///
356    /// Compact discriminant: `2`.
357    DayOfMonth(u8),
358
359    /// Valid only during the specified month of any year.
360    ///
361    /// Compact discriminant: `3`.
362    MonthOfYear(Month),
363
364    /// Valid only on the specified weekday **and** during the specified month.
365    ///
366    /// Compact discriminant: `4`.
367    DayOfWeekInMonth(Weekday, Month),
368
369    /// Valid only on the specified day of the specified month.
370    ///
371    /// Key derivation panics if the day exceeds the month's maximum.
372    ///
373    /// Compact discriminant: `5`.
374    DayOfMonthInMonth(u8, Month),
375
376    /// Valid only on the specified weekday **and** the specified day of month.
377    ///
378    /// Days 29–31 do not match in shorter months.
379    ///
380    /// Compact discriminant: `6`.
381    DayOfWeekAndDayOfMonth(Weekday, u8),
382}
383
384#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
385impl TimeLockCadence {
386    /// Returns the compact variant discriminant stored in
387    /// [`TimeLockParams::cadence_variant`].
388    pub fn variant_id(self) -> u8 {
389        match self {
390            Self::None                         => 0,
391            Self::DayOfWeek(_)                 => 1,
392            Self::DayOfMonth(_)                => 2,
393            Self::MonthOfYear(_)               => 3,
394            Self::DayOfWeekInMonth(_, _)       => 4,
395            Self::DayOfMonthInMonth(_, _)      => 5,
396            Self::DayOfWeekAndDayOfMonth(_, _) => 6,
397        }
398    }
399
400    /// Produces the cadence prefix baked into the KDF input during the
401    /// encryption (`_at`) path.
402    ///
403    /// The prefix is empty for `None`; otherwise it is `"<component>|"` or
404    /// `"<a>+<b>|"` for composite variants.
405    ///
406    /// # Panics
407    ///
408    /// Panics if `DayOfMonthInMonth(day, month)` has `day > month.max_days()`.
409    pub(crate) fn bake_string(self) -> String {
410        match self {
411            Self::None                          => String::new(),
412            Self::DayOfWeek(w)                  => format!("{}|", w.name()),
413            Self::DayOfMonth(d)                 => format!("{}|", d),
414            Self::MonthOfYear(m)                => format!("{}|", m.name()),
415            Self::DayOfWeekInMonth(w, m)        => format!("{}+{}|", w.name(), m.name()),
416            Self::DayOfMonthInMonth(d, m)       => {
417                let max = m.max_days();
418                if d < 1 || d > max {
419                    panic!(
420                        "TimeLockCadence::DayOfMonthInMonth: day {} is out of range \
421                         1–{} for {}",
422                        d, max, m.name()
423                    );
424                }
425                format!("{}+{}|", d, m.name())
426            }
427            Self::DayOfWeekAndDayOfMonth(w, d)  => format!("{}+{}|", w.name(), d),
428        }
429    }
430}
431
432// ─── explicit time input ──────────────────────────────────────────────────────
433
434/// An explicit time value supplied by the caller for encryption-time key
435/// derivation.
436///
437/// `hour` is always expressed in **24-hour notation** (0–23) regardless of
438/// the [`TimeFormat`] chosen for the KDF string — the format flag only
439/// controls how the string is rendered, not how you supply the input.
440///
441/// # Example
442///
443/// ```
444/// use toolkit_zero::encryption::timelock::TimeLockTime;
445///
446/// let t = TimeLockTime::new(14, 37).unwrap(); // 14:37 local (2:37 PM)
447/// ```
448#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub struct TimeLockTime {
451    hour:   u32,  // 0–23
452    minute: u32,  // 0–59
453}
454
455#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
456impl TimeLockTime {
457    /// Construct a `TimeLockTime` from a 24-hour `hour` (0–23) and `minute`
458    /// (0–59).
459    ///
460    /// Returns `None` if either value is out of range.
461    pub fn new(hour: u32, minute: u32) -> Option<Self> {
462        if hour > 23 || minute > 59 {
463            return None;
464        }
465        Some(Self { hour, minute })
466    }
467
468    /// The hour component (0–23).
469    #[inline]
470    pub fn hour(self) -> u32 { self.hour }
471
472    /// The minute component (0–59).
473    #[inline]
474    pub fn minute(self) -> u32 { self.minute }
475}
476
477// ─── KDF parameters ───────────────────────────────────────────────────────────
478
479/// Argon2id parameters for one pass of the KDF chain.
480#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub struct Argon2PassParams {
483    /// Memory usage in **KiB** (e.g. `131_072` = 128 MiB).
484    pub m_cost: u32,
485    /// Number of passes over memory (time cost).
486    pub t_cost: u32,
487    /// Degree of parallelism (lanes). Keep at `1` for single-threaded use.
488    pub p_cost: u32,
489}
490
491/// scrypt parameters for the second pass of the KDF chain.
492#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub struct ScryptPassParams {
495    /// CPU/memory cost exponent: `N = 2^log_n`. Each increment doubles memory.
496    pub log_n: u8,
497    /// Block size (`r`). Standard value is `8`.
498    pub r: u32,
499    /// Parallelization factor (`p`). Keep at `1` for sequential derivation.
500    pub p: u32,
501}
502
503/// Combined parameters for the full three-pass
504/// Argon2id → scrypt → Argon2id KDF chain.
505///
506/// Prefer constructing via [`KdfPreset::params`] unless you have specific
507/// tuning requirements.  All fields implement `Copy`, so this struct can be
508/// stored inline in [`KdfPreset::Custom`].
509#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub struct KdfParams {
512    /// First  pass: Argon2id.
513    pub pass1: Argon2PassParams,
514    /// Second pass: scrypt.
515    pub pass2: ScryptPassParams,
516    /// Third  pass: Argon2id (different parameters and a distinct salt).
517    pub pass3: Argon2PassParams,
518}
519
520// ─── presets ──────────────────────────────────────────────────────────────────
521
522/// Pre-tuned [`KdfParams`] sets.
523///
524/// Pick the variant that matches your **target platform** and security goal.
525/// Use [`Custom`](KdfPreset::Custom) to supply entirely your own parameters.
526///
527/// > **Why device-specific presets?**  Apple Silicon has exceptional memory
528/// > bandwidth (unified memory, ~400 GB/s on M2).  The same parameters that
529/// > take 2 seconds on an M2 may take 15+ seconds on a typical x86-64 server.
530/// > Device-specific variants let you choose a cost that is _consistent_ across
531/// > the hardware you actually deploy on.
532///
533/// ## Generic (cross-platform)
534///
535/// Suitable for any platform.  Use these when you don't know or don't control
536/// the target hardware.
537///
538/// | Preset     | Peak RAM  | Est. Mac M2 | Est. x86-64  |
539/// |------------|-----------|-------------|--------------|
540/// | `Fast`     | ~128 MiB  | ~500 ms     | ~1.5 s       |
541/// | `Balanced` | ~512 MiB  | ~2 s        | ~8–15 s      |
542/// | `Paranoid` | ~768 MiB  | ~4–6 s      | ~20–30 s     |
543///
544/// ## Apple Silicon (`*Mac`)
545///
546/// Harder parameters calibrated for Apple Silicon's superior memory bandwidth.
547/// All three tiers assume at least 8 GiB unified memory (all M-series chips).
548///
549/// | Preset        | Peak RAM | Est. Mac M2  | Est. Mac M3/M4 |
550/// |---------------|----------|--------------|----------------|
551/// | `FastMac`     | ~512 MiB | ~2 s         | faster         |
552/// | `BalancedMac` | ~1 GiB   | ~5–12 s      | faster         |
553/// | `ParanoidMac` | ~3 GiB   | ~30–60 s     | faster         |
554///
555/// ## x86-64 (`*X86`)
556///
557/// Equivalent to Generic; provided as explicit named variants so code
558/// documents intent clearly.
559///
560/// | Preset        | Peak RAM  | Est. x86-64  |
561/// |---------------|-----------|------------------|
562/// | `FastX86`     | ~128 MiB  | ~1.5 s           |
563/// | `BalancedX86` | ~512 MiB  | ~8–15 s          |
564/// | `ParanoidX86` | ~768 MiB  | ~20–30 s         |
565///
566/// ## Linux ARM64 (`*Arm`)
567///
568/// Tuned for AWS Graviton3 / similar high-end ARM servers.  Raspberry Pi and
569/// lower-end ARM boards will be slower.
570///
571/// | Preset        | Peak RAM  | Est. Graviton3 |
572/// |---------------|-----------|----------------|
573/// | `FastArm`     | ~256 MiB  | ~3 s           |
574/// | `BalancedArm` | ~512 MiB  | ~10–20 s       |
575/// | `ParanoidArm` | ~768 MiB  | ~30–50 s       |
576///
577/// ## Custom
578///
579/// `Custom(KdfParams)` lets you supply exactly the parameters you measured
580/// and tuned for your own hardware.
581#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum KdfPreset {
584    // ── generic (cross-platform) ─────────────────────────────────────────────
585
586    /// ~128 MiB · scrypt 2¹⁶ · ~64 MiB, 3 iters each.
587    Fast,
588    /// ~512 MiB · scrypt 2¹⁷ · ~256 MiB, 4 iters each.
589    Balanced,
590    /// ~768 MiB · scrypt 2¹⁸ · ~512 MiB, 5 iters each.
591    Paranoid,
592
593    // ── Apple Silicon ─────────────────────────────────────────────────────────
594
595    /// Dev / CI on macOS.  ~512 MiB · scrypt 2¹⁷ · ~256 MiB, 4 iters each.
596    FastMac,
597    /// Production on macOS (Apple Silicon).  ~1 GiB · scrypt 2¹⁸ · ~512 MiB, 4 iters each.
598    BalancedMac,
599    /// Maximum security on macOS.  ~3 GiB · scrypt 2²⁰ · ~1 GiB, 4 iters each.
600    /// Assumes 8+ GiB unified memory (all M-series chips).
601    ParanoidMac,
602
603    // ── x86-64 ───────────────────────────────────────────────────────────────
604
605    /// Dev / CI on x86-64.  Same params as `Fast`.
606    FastX86,
607    /// Production on x86-64.  Same params as `Balanced`.
608    BalancedX86,
609    /// Maximum security on x86-64.  Same params as `Paranoid`.
610    ParanoidX86,
611
612    // ── Linux ARM64 ──────────────────────────────────────────────────────────
613
614    /// Dev / CI on Linux ARM64.  ~256 MiB · scrypt 2¹⁶ · ~128 MiB, 3 iters each.
615    FastArm,
616    /// Production on Linux ARM64.  ~512 MiB · scrypt 2¹⁷ · ~256 MiB, 5 iters each.
617    BalancedArm,
618    /// Maximum security on Linux ARM64.  ~768 MiB · scrypt 2¹⁸ · ~512 MiB, 5 iters each.
619    ParanoidArm,
620
621    // ── custom ────────────────────────────────────────────────────────────────
622
623    /// Fully user-defined parameters.  Use when you have measured and tuned
624    /// KDF cost on your own hardware.
625    ///
626    /// # Example
627    ///
628    /// ```no_run
629    /// # #[cfg(feature = "enc-timelock-keygen-input")]
630    /// # {
631    /// use toolkit_zero::encryption::timelock::*;
632    /// let p = KdfPreset::Custom(KdfParams {
633    ///     pass1: Argon2PassParams { m_cost: 262_144, t_cost: 3, p_cost: 1 },
634    ///     pass2: ScryptPassParams { log_n: 16, r: 8, p: 1 },
635    ///     pass3: Argon2PassParams { m_cost: 131_072, t_cost: 3, p_cost: 1 },
636    /// });
637    /// # }
638    /// ```
639    Custom(KdfParams),
640}
641
642#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
643impl KdfPreset {
644    /// Return the [`KdfParams`] for this preset.
645    pub fn params(self) -> KdfParams {
646        // Fast = ~128 MiB · scrypt 2¹⁶ · ~64 MiB
647        let fast = KdfParams {
648            pass1: Argon2PassParams { m_cost:  131_072, t_cost: 3, p_cost: 1 },
649            pass2: ScryptPassParams { log_n: 16, r: 8, p: 1 },
650            pass3: Argon2PassParams { m_cost:   65_536, t_cost: 3, p_cost: 1 },
651        };
652        // Balanced = ~512 MiB · scrypt 2¹⁷ · ~256 MiB
653        let balanced = KdfParams {
654            pass1: Argon2PassParams { m_cost:  524_288, t_cost: 4, p_cost: 1 },
655            pass2: ScryptPassParams { log_n: 17, r: 8, p: 1 },
656            pass3: Argon2PassParams { m_cost:  262_144, t_cost: 4, p_cost: 1 },
657        };
658        // Paranoid = ~768 MiB · scrypt 2¹⁸ · ~512 MiB
659        let paranoid = KdfParams {
660            pass1: Argon2PassParams { m_cost:  786_432, t_cost: 5, p_cost: 1 },
661            pass2: ScryptPassParams { log_n: 18, r: 8, p: 1 },
662            pass3: Argon2PassParams { m_cost:  524_288, t_cost: 5, p_cost: 1 },
663        };
664
665        match self {
666            // Generic / x86-64 (identical params, named for code clarity)
667            KdfPreset::Fast    | KdfPreset::FastX86    => fast,
668            KdfPreset::Balanced | KdfPreset::BalancedX86 => balanced,
669            KdfPreset::Paranoid | KdfPreset::ParanoidX86 => paranoid,
670            // Apple Silicon — calibrated for M-series memory bandwidth
671            KdfPreset::FastMac    => balanced, // ~512 MiB
672            KdfPreset::BalancedMac => KdfParams {
673                pass1: Argon2PassParams { m_cost: 1_048_576, t_cost: 4, p_cost: 1 }, // 1 GiB
674                pass2: ScryptPassParams { log_n: 18, r: 8, p: 1 },
675                pass3: Argon2PassParams { m_cost:   524_288, t_cost: 4, p_cost: 1 },
676            },
677            KdfPreset::ParanoidMac => KdfParams {
678                pass1: Argon2PassParams { m_cost: 3_145_728, t_cost: 4, p_cost: 1 }, // 3 GiB
679                pass2: ScryptPassParams { log_n: 20, r: 8, p: 1 },
680                pass3: Argon2PassParams { m_cost: 1_048_576, t_cost: 4, p_cost: 1 }, // 1 GiB
681            },
682            // Linux ARM64
683            KdfPreset::FastArm => KdfParams {
684                pass1: Argon2PassParams { m_cost:  262_144, t_cost: 3, p_cost: 1 }, // 256 MiB
685                pass2: ScryptPassParams { log_n: 16, r: 8, p: 1 },
686                pass3: Argon2PassParams { m_cost:  131_072, t_cost: 3, p_cost: 1 },
687            },
688            KdfPreset::BalancedArm => KdfParams {
689                pass1: Argon2PassParams { m_cost:  524_288, t_cost: 5, p_cost: 1 }, // 512 MiB
690                pass2: ScryptPassParams { log_n: 17, r: 8, p: 1 },
691                pass3: Argon2PassParams { m_cost:  262_144, t_cost: 5, p_cost: 1 },
692            },
693            KdfPreset::ParanoidArm => KdfParams {
694                pass1: Argon2PassParams { m_cost:  786_432, t_cost: 5, p_cost: 1 }, // 768 MiB
695                pass2: ScryptPassParams { log_n: 18, r: 8, p: 1 },
696                pass3: Argon2PassParams { m_cost:  524_288, t_cost: 5, p_cost: 1 },
697            },
698            // Custom
699            KdfPreset::Custom(p) => p,
700        }
701    }
702}
703
704// ─── salts ────────────────────────────────────────────────────────────────────
705
706/// Three independent 32-byte random salts — one per KDF pass.
707///
708/// Generate once at **encryption time** via [`TimeLockSalts::generate`] and
709/// store 96 bytes in the ciphertext header.  The same `TimeLockSalts` **must**
710/// be supplied to [`derive_key_now`] / [`derive_key_at`] at decryption time.
711///
712/// Salts are **not secret** — they only prevent precomputation attacks.
713/// All three fields are zeroized when this value is dropped.
714#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
715#[derive(Debug, Clone)]
716pub struct TimeLockSalts {
717    /// Salt for the first Argon2id pass.
718    pub s1: [u8; 32],
719    /// Salt for the scrypt pass.
720    pub s2: [u8; 32],
721    /// Salt for the final Argon2id pass.
722    pub s3: [u8; 32],
723}
724
725#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
726impl TimeLockSalts {
727    /// Generate three independent 32-byte salts from the OS CSPRNG.
728    pub fn generate() -> Self {
729        use rand::RngCore as _;
730        let mut rng = rand::rng();
731        let mut s = Self { s1: [0u8; 32], s2: [0u8; 32], s3: [0u8; 32] };
732        rng.fill_bytes(&mut s.s1);
733        rng.fill_bytes(&mut s.s2);
734        rng.fill_bytes(&mut s.s3);
735        s
736    }
737
738    /// Construct from raw bytes (e.g. when loading from a ciphertext header).
739    pub fn from_bytes(s1: [u8; 32], s2: [u8; 32], s3: [u8; 32]) -> Self {
740        Self { s1, s2, s3 }
741    }
742
743    /// Serialize to 96 contiguous bytes (`s1 ∥ s2 ∥ s3`) for header storage.
744    pub fn to_bytes(&self) -> [u8; 96] {
745        let mut out = [0u8; 96];
746        out[..32].copy_from_slice(&self.s1);
747        out[32..64].copy_from_slice(&self.s2);
748        out[64..].copy_from_slice(&self.s3);
749        out
750    }
751
752    /// Deserialize from 96 contiguous bytes produced by [`to_bytes`].
753    pub fn from_slice(b: &[u8; 96]) -> Self {
754        let mut s1 = [0u8; 32]; s1.copy_from_slice(&b[..32]);
755        let mut s2 = [0u8; 32]; s2.copy_from_slice(&b[32..64]);
756        let mut s3 = [0u8; 32]; s3.copy_from_slice(&b[64..]);
757        Self { s1, s2, s3 }
758    }
759}
760
761#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
762impl Zeroize for TimeLockSalts {
763    fn zeroize(&mut self) {
764        self.s1.zeroize();
765        self.s2.zeroize();
766        self.s3.zeroize();
767    }
768}
769
770#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
771impl Drop for TimeLockSalts {
772    fn drop(&mut self) { self.zeroize(); }
773}
774
775// ─── output ───────────────────────────────────────────────────────────────────
776
777/// A derived 32-byte time-locked key.
778///
779/// The inner bytes are **automatically overwritten** (`ZeroizeOnDrop`) the
780/// moment this value is dropped.  Access the key via [`as_bytes`](Self::as_bytes).
781#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
782pub struct TimeLockKey([u8; 32]);
783
784#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
785impl TimeLockKey {
786    /// Borrow the raw 32-byte key.
787    ///
788    /// The reference is valid only while this `TimeLockKey` is alive.  If you
789    /// must copy the bytes into another buffer, protect it with [`Zeroize`] too.
790    #[inline]
791    pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
792}
793
794#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
795impl Zeroize for TimeLockKey {
796    #[inline]
797    fn zeroize(&mut self) { self.0.zeroize(); }
798}
799
800#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
801impl ZeroizeOnDrop for TimeLockKey {}
802
803#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
804impl Drop for TimeLockKey {
805    fn drop(&mut self) { self.zeroize(); }
806}
807
808// ─── error ────────────────────────────────────────────────────────────────────
809
810/// Errors returned by the `derive_key_*` functions.
811#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
812#[derive(Debug)]
813pub enum TimeLockError {
814    /// An Argon2id pass failed (invalid parameters or internal error).
815    Argon2(String),
816    /// The scrypt pass failed (invalid parameters or output length).
817    Scrypt(String),
818    /// The OS clock returned an unusable value.
819    #[cfg(feature = "enc-timelock-keygen-now")]
820    ClockUnavailable,
821    /// A [`TimeLockTime`] field was out of range.
822    #[cfg(feature = "enc-timelock-keygen-input")]
823    InvalidTime(String),
824    /// The async task panicked inside `spawn_blocking`.
825    #[cfg(any(feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
826    TaskPanic(String),
827    /// The caller passed `Some(time)` but `enc-timelock-keygen-input` is not
828    /// active, or passed `None` but `enc-timelock-keygen-now` is not active.
829    ForbiddenAction(&'static str),
830}
831
832#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
833impl std::fmt::Display for TimeLockError {
834    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
835        match self {
836            Self::Argon2(s)         => write!(f, "Argon2id error: {s}"),
837            Self::Scrypt(s)         => write!(f, "scrypt error: {s}"),
838            #[cfg(feature = "enc-timelock-keygen-now")]
839            Self::ClockUnavailable  => write!(f, "system clock unavailable"),
840            #[cfg(feature = "enc-timelock-keygen-input")]
841            Self::InvalidTime(s)    => write!(f, "invalid time input: {s}"),
842            #[cfg(any(feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
843            Self::TaskPanic(s)      => write!(f, "KDF task panicked: {s}"),
844            Self::ForbiddenAction(s) => write!(f, "action not permitted: {s}"),
845        }
846    }
847}
848
849#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input", feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
850impl std::error::Error for TimeLockError {}
851
852// ─── internal sync API ───────────────────────────────────────────────────────
853
854/// Derive a 32-byte key from the **current system time** (decryption path).
855///
856/// The OS wall clock is read inside this call — the caller supplies no time
857/// value.  Use this on the **decryption side** so the user never needs to
858/// re-enter the unlock time.
859///
860/// The `precision`, `format`, and `salts` must match exactly what was used
861/// during [`derive_key_at`] at encryption time (store them in the header).
862///
863/// # Errors
864///
865/// Returns [`TimeLockError`] if the system clock is unavailable or any KDF
866/// pass fails.
867///
868/// # Example
869///
870/// ```ignore
871/// # use toolkit_zero::encryption::timelock::*;
872/// let salts = TimeLockSalts::generate();
873/// let key = timelock(
874///     TimeLockCadence::None,
875///     None,
876///     TimePrecision::Minute,
877///     TimeFormat::Hour24,
878///     &salts,
879///     &KdfPreset::Balanced.params(),
880/// ).unwrap();
881/// ```
882#[allow(dead_code)]
883#[cfg(feature = "enc-timelock-keygen-now")]
884fn derive_key_now(
885    precision: TimePrecision,
886    format:    TimeFormat,
887    salts:     &TimeLockSalts,
888    params:    &KdfParams,
889) -> Result<TimeLockKey, TimeLockError> {
890    let time_str = helper::format_time_now(precision, format)?;
891    helper::run_kdf_chain(time_str.into_bytes(), salts, params)
892}
893
894/// Derive a 32-byte key from an **explicit [`TimeLockTime`]** (encryption path).
895///
896/// The caller supplies the time at which decryption should be permitted.
897/// Use this on the **encryption side** — the user chooses `(hour, minute)` and
898/// the result is the key that will only be reproducible by [`derive_key_now`]
899/// called within the matching time window.
900///
901/// # Errors
902///
903/// Returns [`TimeLockError`] if the time value is invalid or any KDF pass
904/// fails.
905///
906/// # Example
907///
908/// ```ignore
909/// # use toolkit_zero::encryption::timelock::*;
910/// let salts = TimeLockSalts::generate();
911/// let at = TimeLockTime::new(14, 37).unwrap();
912/// let key = timelock(
913///     TimeLockCadence::None,
914///     Some(at),
915///     TimePrecision::Minute,
916///     TimeFormat::Hour24,
917///     &salts,
918///     &KdfPreset::Balanced.params(),
919/// ).unwrap();
920/// ```
921#[allow(dead_code)]
922#[cfg(feature = "enc-timelock-keygen-input")]
923fn derive_key_at(
924    time:      TimeLockTime,
925    precision: TimePrecision,
926    format:    TimeFormat,
927    salts:     &TimeLockSalts,
928    params:    &KdfParams,
929) -> Result<TimeLockKey, TimeLockError> {
930    let time_str = helper::format_time_at(time, precision, format)?;
931    helper::run_kdf_chain(time_str.into_bytes(), salts, params)
932}
933
934// ─── internal async API ──────────────────────────────────────────────────────
935
936/// Async variant of [`derive_key_now`].
937///
938/// Offloads the blocking Argon2id + scrypt work to a Tokio blocking thread
939/// so the calling future's executor is never stalled during derivation.
940///
941/// Takes `salts` and `params` by **value** (required for `'static` move into
942/// `spawn_blocking`); both are zeroized before the async task exits.
943///
944/// Requires the `enc-timelock-async` feature.
945///
946/// # Errors
947///
948/// Returns [`TimeLockError`] if the system clock is unavailable, any KDF
949/// pass fails, or the spawned task panics.
950#[allow(dead_code)]
951#[cfg(feature = "enc-timelock-async-keygen-now")]
952async fn derive_key_now_async(
953    precision: TimePrecision,
954    format:    TimeFormat,
955    salts:     TimeLockSalts,
956    params:    KdfParams,
957) -> Result<TimeLockKey, TimeLockError> {
958    tokio::task::spawn_blocking(move || derive_key_now(precision, format, &salts, &params))
959        .await
960        .map_err(|e| TimeLockError::TaskPanic(e.to_string()))?
961}
962
963/// Async variant of [`derive_key_at`].
964///
965/// Offloads the blocking Argon2id + scrypt work to a Tokio blocking thread.
966/// Takes `salts` and `params` by **value**; both are zeroized on drop.
967///
968/// Requires the `enc-timelock-async-keygen-input` feature.
969#[allow(dead_code)]
970#[cfg(feature = "enc-timelock-async-keygen-input")]
971async fn derive_key_at_async(
972    time:      TimeLockTime,
973    precision: TimePrecision,
974    format:    TimeFormat,
975    salts:     TimeLockSalts,
976    params:    KdfParams,
977) -> Result<TimeLockKey, TimeLockError> {
978    tokio::task::spawn_blocking(move || derive_key_at(time, precision, format, &salts, &params))
979        .await
980        .map_err(|e| TimeLockError::TaskPanic(e.to_string()))?
981}
982
983// ─── internal scheduled sync API ─────────────────────────────────────────────
984
985/// Derive a 32-byte key from a [`TimeLockCadence`] anchor plus an **explicit
986/// [`TimeLockTime`]** (encryption path).
987///
988/// Extends [`derive_key_at`] with a calendar constraint.  The KDF input
989/// is `"<cadence_prefix><time_string>"`.  For [`TimeLockCadence::None`] the
990/// prefix is empty, producing a result identical to [`derive_key_at`].
991///
992/// Store [`pack`]ed settings alongside the salts in the ciphertext header so
993/// the decryption side can reconstruct the correct KDF input via
994/// [`derive_key_scheduled_now`].
995///
996/// # Panics
997///
998/// Panics if `cadence` is [`TimeLockCadence::DayOfMonthInMonth`] with a day
999/// that exceeds the month's maximum (e.g. day 29 for February).
1000///
1001/// # Errors
1002///
1003/// Returns [`TimeLockError`] if the time value is out of range or any KDF
1004/// pass fails.
1005///
1006/// # Example
1007///
1008/// ```ignore
1009/// # use toolkit_zero::encryption::timelock::*;
1010/// let salts = TimeLockSalts::generate();
1011/// let kdf   = KdfPreset::Balanced.params();
1012/// let t     = TimeLockTime::new(18, 0).unwrap();
1013/// // Use the public timelock() entry point (params = None → _at path):
1014/// let key = timelock(
1015///     Some(TimeLockCadence::DayOfWeek(Weekday::Tuesday)),
1016///     Some(t),
1017///     Some(TimePrecision::Hour),
1018///     Some(TimeFormat::Hour24),
1019///     Some(salts),
1020///     Some(kdf),
1021///     None,
1022/// ).unwrap();
1023/// // key is valid only at 18:xx on any Tuesday
1024/// ```
1025#[cfg(feature = "enc-timelock-keygen-input")]
1026fn derive_key_scheduled_at(
1027    cadence:   TimeLockCadence,
1028    time:      TimeLockTime,
1029    precision: TimePrecision,
1030    format:    TimeFormat,
1031    salts:     &TimeLockSalts,
1032    params:    &KdfParams,
1033) -> Result<TimeLockKey, TimeLockError> {
1034    let cadence_part = cadence.bake_string();
1035    let time_part    = helper::format_time_at(time, precision, format)?;
1036    let full         = format!("{}{}", cadence_part, time_part);
1037    helper::run_kdf_chain(full.into_bytes(), salts, params)
1038}
1039
1040/// Derive a 32-byte key from the **current system time and calendar state**
1041/// using the settings stored in a [`TimeLockParams`] (decryption path).
1042///
1043/// Extends [`derive_key_now`] with calendar awareness.  The `cadence_variant`
1044/// field in `timelock_params` determines which calendar dimension(s) are read
1045/// from the live clock, making the KDF input identical to what
1046/// [`derive_key_scheduled_at`] produced on the matching slot.
1047///
1048/// # Errors
1049///
1050/// Returns [`TimeLockError`] if the system clock is unavailable or any KDF
1051/// pass fails.
1052///
1053/// # Example
1054///
1055/// ```ignore
1056/// # use toolkit_zero::encryption::timelock::*;
1057/// // Load header from ciphertext then call with params = Some(header):
1058/// let dec_key = timelock(
1059///     None, None, None, None, None, None,
1060///     Some(header),  // header: TimeLockParams loaded from ciphertext
1061/// ).unwrap();
1062/// ```
1063#[cfg(feature = "enc-timelock-keygen-now")]
1064fn derive_key_scheduled_now(
1065    timelock_params: &TimeLockParams,
1066) -> Result<TimeLockKey, TimeLockError> {
1067    let (precision, format, cadence_variant) = utility::unpack(timelock_params);
1068    let cadence_part = helper::bake_cadence_now(cadence_variant)?;
1069    let time_part    = helper::format_time_now(precision, format)?;
1070    let full         = format!("{}{}", cadence_part, time_part);
1071    helper::run_kdf_chain(full.into_bytes(), &timelock_params.salts, &timelock_params.kdf_params)
1072}
1073
1074// ─── internal scheduled async API ───────────────────────────────────────────
1075
1076/// Async variant of [`derive_key_scheduled_at`].
1077///
1078/// Offloads the blocking KDF work to a Tokio blocking thread.  Takes `salts`
1079/// and `params` by **value** (required for `'static` move into
1080/// `spawn_blocking`); both are zeroized on drop.  `cadence` and `time` are
1081/// `Copy`.
1082///
1083/// Requires the `enc-timelock-async-keygen-input` feature.
1084#[cfg(feature = "enc-timelock-async-keygen-input")]
1085async fn derive_key_scheduled_at_async(
1086    cadence:   TimeLockCadence,
1087    time:      TimeLockTime,
1088    precision: TimePrecision,
1089    format:    TimeFormat,
1090    salts:     TimeLockSalts,
1091    params:    KdfParams,
1092) -> Result<TimeLockKey, TimeLockError> {
1093    tokio::task::spawn_blocking(move || {
1094        derive_key_scheduled_at(cadence, time, precision, format, &salts, &params)
1095    })
1096    .await
1097    .map_err(|e| TimeLockError::TaskPanic(e.to_string()))?
1098}
1099
1100/// Async variant of [`derive_key_scheduled_now`].
1101///
1102/// Offloads the blocking KDF work to a Tokio blocking thread.  Takes
1103/// `timelock_params` by **value**; the [`TimeLockSalts`] inside are
1104/// zeroized on drop.
1105///
1106/// Requires the `enc-timelock-async-keygen-now` feature.
1107#[cfg(feature = "enc-timelock-async-keygen-now")]
1108async fn derive_key_scheduled_now_async(
1109    timelock_params: TimeLockParams,
1110) -> Result<TimeLockKey, TimeLockError> {
1111    tokio::task::spawn_blocking(move || {
1112        derive_key_scheduled_now(&timelock_params)
1113    })
1114    .await
1115    .map_err(|e| TimeLockError::TaskPanic(e.to_string()))?
1116}
1117
1118// ─── public API ───────────────────────────────────────────────────────────────
1119
1120/// Derive a 32-byte time-locked key — unified sync entry point.
1121///
1122/// ## Encryption path (`params = None`)
1123///
1124/// Set `params` to `None` and supply all of `cadence`, `time`, `precision`,
1125/// `format`, `salts`, and `kdf` as `Some(...)`.  Requires the
1126/// `enc-timelock-keygen-input` feature.  After calling, use [`pack`] with the
1127/// same arguments to produce a [`TimeLockParams`] header for the ciphertext.
1128///
1129/// ## Decryption path (`params = Some(p)`)
1130///
1131/// Set `params` to `Some(header)` where `header` is the [`TimeLockParams`]
1132/// read from the ciphertext.  All other arguments are ignored and may be
1133/// `None`.  Requires the `enc-timelock-keygen-now` feature.
1134///
1135/// # Errors
1136///
1137/// - [`TimeLockError::ForbiddenAction`] if the required feature is not active,
1138///   or if the `_at` path is taken but any required `Option` argument is `None`.
1139/// - [`TimeLockError::Argon2`] / [`TimeLockError::Scrypt`] on KDF failure.
1140/// - [`TimeLockError::ClockUnavailable`] if the OS clock is unusable (`_now` path).
1141///
1142/// # Example
1143///
1144/// ```no_run
1145/// # use toolkit_zero::encryption::timelock::*;
1146/// let salts = TimeLockSalts::generate();
1147/// let kdf   = KdfPreset::BalancedMac.params();
1148///
1149/// // Encryption side — lock to every Tuesday at 18:00
1150/// let enc_key = timelock(
1151///     Some(TimeLockCadence::DayOfWeek(Weekday::Tuesday)),
1152///     Some(TimeLockTime::new(18, 0).unwrap()),
1153///     Some(TimePrecision::Hour),
1154///     Some(TimeFormat::Hour24),
1155///     Some(salts.clone()),
1156///     Some(kdf),
1157///     None,
1158/// ).unwrap();
1159///
1160/// // Pack settings + salts + kdf into header; store in ciphertext.
1161/// let header = pack(TimePrecision::Hour, TimeFormat::Hour24,
1162///                   &TimeLockCadence::DayOfWeek(Weekday::Tuesday), salts, kdf);
1163///
1164/// // Decryption side — call on a Tuesday at 18:xx:
1165/// let dec_key = timelock(
1166///     None, None, None, None, None, None,
1167///     Some(header),
1168/// ).unwrap();
1169/// // enc_key.as_bytes() == dec_key.as_bytes() when called at the right time
1170/// ```
1171#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input"))]
1172pub fn timelock(
1173    cadence:   Option<TimeLockCadence>,
1174    time:      Option<TimeLockTime>,
1175    precision: Option<TimePrecision>,
1176    format:    Option<TimeFormat>,
1177    salts:     Option<TimeLockSalts>,
1178    kdf:       Option<KdfParams>,
1179    params:    Option<TimeLockParams>,
1180) -> Result<TimeLockKey, TimeLockError> {
1181    if let Some(p) = params {
1182        // _now (decryption) path: all settings come from TimeLockParams.
1183        let _ = (cadence, time, precision, format, salts, kdf);  // unused on this path
1184        #[cfg(not(feature = "enc-timelock-keygen-now"))]
1185        return Err(TimeLockError::ForbiddenAction(
1186            "enc-timelock-keygen-now feature is required for the _now (decryption) path"
1187        ));
1188        #[cfg(feature = "enc-timelock-keygen-now")]
1189        return derive_key_scheduled_now(&p);
1190    } else {
1191        // _at (encryption) path: caller must supply all other arguments.
1192        #[cfg(not(feature = "enc-timelock-keygen-input"))]
1193        return Err(TimeLockError::ForbiddenAction(
1194            "enc-timelock-keygen-input feature is required for the _at (encryption) path; \
1195             pass Some(TimeLockParams) for the decryption path (requires enc-timelock-keygen-now)"
1196        ));
1197        #[cfg(feature = "enc-timelock-keygen-input")]
1198        {
1199            let c  = cadence.ok_or(TimeLockError::ForbiddenAction("_at path: cadence must be Some"))?;
1200            let t  = time.ok_or(TimeLockError::ForbiddenAction("_at path: time must be Some"))?;
1201            let pr = precision.ok_or(TimeLockError::ForbiddenAction("_at path: precision must be Some"))?;
1202            let fm = format.ok_or(TimeLockError::ForbiddenAction("_at path: format must be Some"))?;
1203            let sl = salts.ok_or(TimeLockError::ForbiddenAction("_at path: salts must be Some"))?;
1204            let kd = kdf.ok_or(TimeLockError::ForbiddenAction("_at path: kdf must be Some"))?;
1205            return derive_key_scheduled_at(c, t, pr, fm, &sl, &kd);
1206        }
1207    }
1208}
1209
1210/// Derive a 32-byte time-locked key — unified async entry point.
1211///
1212/// Async counterpart of [`timelock`].  Same `params`-based routing: set
1213/// `params = Some(header)` for the **decryption** path, or `params = None`
1214/// with all other arguments as `Some(...)` for the **encryption** path.
1215/// All arguments are taken by value; the blocking KDF work is offloaded to a
1216/// Tokio blocking thread.
1217///
1218/// # Errors
1219///
1220/// Same as [`timelock`], plus [`TimeLockError::TaskPanic`] if the spawned
1221/// task panics.
1222#[cfg(any(feature = "enc-timelock-async-keygen-now", feature = "enc-timelock-async-keygen-input"))]
1223pub async fn timelock_async(
1224    cadence:   Option<TimeLockCadence>,
1225    time:      Option<TimeLockTime>,
1226    precision: Option<TimePrecision>,
1227    format:    Option<TimeFormat>,
1228    salts:     Option<TimeLockSalts>,
1229    kdf:       Option<KdfParams>,
1230    params:    Option<TimeLockParams>,
1231) -> Result<TimeLockKey, TimeLockError> {
1232    if let Some(p) = params {
1233        let _ = (cadence, time, precision, format, salts, kdf);
1234        #[cfg(not(feature = "enc-timelock-async-keygen-now"))]
1235        return Err(TimeLockError::ForbiddenAction(
1236            "enc-timelock-async-keygen-now feature is required for the async _now (decryption) path"
1237        ));
1238        #[cfg(feature = "enc-timelock-async-keygen-now")]
1239        return derive_key_scheduled_now_async(p).await;
1240    } else {
1241        #[cfg(not(feature = "enc-timelock-async-keygen-input"))]
1242        return Err(TimeLockError::ForbiddenAction(
1243            "enc-timelock-async-keygen-input feature is required for the async _at (encryption) path"
1244        ));
1245        #[cfg(feature = "enc-timelock-async-keygen-input")]
1246        {
1247            let c  = cadence.ok_or(TimeLockError::ForbiddenAction("_at path: cadence must be Some"))?;
1248            let t  = time.ok_or(TimeLockError::ForbiddenAction("_at path: time must be Some"))?;
1249            let pr = precision.ok_or(TimeLockError::ForbiddenAction("_at path: precision must be Some"))?;
1250            let fm = format.ok_or(TimeLockError::ForbiddenAction("_at path: format must be Some"))?;
1251            let sl = salts.ok_or(TimeLockError::ForbiddenAction("_at path: salts must be Some"))?;
1252            let kd = kdf.ok_or(TimeLockError::ForbiddenAction("_at path: kdf must be Some"))?;
1253            return derive_key_scheduled_at_async(c, t, pr, fm, sl, kd).await;
1254        }
1255    }
1256}
1257
1258// ─── tests ────────────────────────────────────────────────────────────────────
1259
1260#[cfg(any(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input"))]
1261#[cfg(test)]
1262mod tests {
1263    use super::*;
1264    #[cfg(all(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input"))]
1265    use chrono::Timelike as _;
1266
1267    fn fast() -> KdfParams {
1268        // Minimal params for fast test execution — not a real security preset.
1269        KdfParams {
1270            pass1: Argon2PassParams { m_cost: 32_768, t_cost: 1, p_cost: 1 },
1271            pass2: ScryptPassParams { log_n: 13, r: 8, p: 1 },
1272            pass3: Argon2PassParams { m_cost: 16_384, t_cost: 1, p_cost: 1 },
1273        }
1274    }
1275    fn salts() -> TimeLockSalts   { TimeLockSalts::generate() }
1276
1277    // ── TimeLockTime construction ─────────────────────────────────────────
1278
1279    #[cfg(feature = "enc-timelock-keygen-input")]
1280    #[test]
1281    fn timelocktime_valid_range() {
1282        assert!(TimeLockTime::new(0,  0).is_some());
1283        assert!(TimeLockTime::new(23, 59).is_some());
1284        assert!(TimeLockTime::new(14, 37).is_some());
1285    }
1286
1287    #[cfg(feature = "enc-timelock-keygen-input")]
1288    #[test]
1289    fn timelocktime_invalid_range() {
1290        assert!(TimeLockTime::new(24,  0).is_none(), "hour=24 should fail");
1291        assert!(TimeLockTime::new( 0, 60).is_none(), "minute=60 should fail");
1292        assert!(TimeLockTime::new(99, 99).is_none());
1293    }
1294
1295    // ── format_components ────────────────────────────────────────────────
1296
1297    #[test]
1298    fn format_hour_24h() {
1299        let s = helper::format_components(14, 37, TimePrecision::Hour, TimeFormat::Hour24);
1300        assert_eq!(s, "14");
1301    }
1302
1303    #[test]
1304    fn format_hour_12h() {
1305        let s_pm = helper::format_components(14,  0, TimePrecision::Hour, TimeFormat::Hour12);
1306        let s_am = helper::format_components( 2,  0, TimePrecision::Hour, TimeFormat::Hour12);
1307        assert_eq!(s_pm, "02PM");
1308        assert_eq!(s_am, "02AM");
1309    }
1310
1311    #[test]
1312    fn format_quarter_snaps_correctly() {
1313        // 37 should snap to 30; 15 → 15; 0 → 0; 59 → 45
1314        assert_eq!(helper::format_components(14, 37, TimePrecision::Quarter, TimeFormat::Hour24), "14:30");
1315        assert_eq!(helper::format_components(14, 15, TimePrecision::Quarter, TimeFormat::Hour24), "14:15");
1316        assert_eq!(helper::format_components(14,  0, TimePrecision::Quarter, TimeFormat::Hour24), "14:00");
1317        assert_eq!(helper::format_components(14, 59, TimePrecision::Quarter, TimeFormat::Hour24), "14:45");
1318    }
1319
1320    #[test]
1321    fn format_minute_exact() {
1322        let s = helper::format_components(9, 5, TimePrecision::Minute, TimeFormat::Hour24);
1323        assert_eq!(s, "09:05");
1324    }
1325
1326    // ── derive_key_at: determinism ────────────────────────────────────────
1327
1328    #[cfg(feature = "enc-timelock-keygen-input")]
1329    #[test]
1330    fn at_same_inputs_same_key() {
1331        let s = salts();
1332        let t = TimeLockTime::new(14, 37).unwrap();
1333        let k1 = derive_key_at(t, TimePrecision::Minute, TimeFormat::Hour24, &s, &fast()).unwrap();
1334        // Regenerate salts from their raw bytes to prove serialization round-trip too.
1335        let s2 = TimeLockSalts::from_slice(&s.to_bytes());
1336        let k2 = derive_key_at(t, TimePrecision::Minute, TimeFormat::Hour24, &s2, &fast()).unwrap();
1337        assert_eq!(k1.as_bytes(), k2.as_bytes());
1338    }
1339
1340    #[cfg(feature = "enc-timelock-keygen-input")]
1341    #[test]
1342    fn at_different_salts_different_key() {
1343        let t = TimeLockTime::new(14, 37).unwrap();
1344        let k1 = derive_key_at(t, TimePrecision::Minute, TimeFormat::Hour24, &salts(), &fast()).unwrap();
1345        let k2 = derive_key_at(t, TimePrecision::Minute, TimeFormat::Hour24, &salts(), &fast()).unwrap();
1346        assert_ne!(k1.as_bytes(), k2.as_bytes());
1347    }
1348
1349    #[cfg(feature = "enc-timelock-keygen-input")]
1350    #[test]
1351    fn at_different_time_different_key() {
1352        let s = salts();
1353        let t1 = TimeLockTime::new(14, 37).unwrap();
1354        let t2 = TimeLockTime::new(14, 38).unwrap();
1355        let k1 = derive_key_at(t1, TimePrecision::Minute, TimeFormat::Hour24, &s, &fast()).unwrap();
1356        let k2 = derive_key_at(t2, TimePrecision::Minute, TimeFormat::Hour24, &s, &fast()).unwrap();
1357        assert_ne!(k1.as_bytes(), k2.as_bytes());
1358    }
1359
1360    // ── derive_key_now: liveness ──────────────────────────────────────────
1361
1362    #[cfg(feature = "enc-timelock-keygen-now")]
1363    #[test]
1364    fn now_returns_nonzero_key() {
1365        let k = derive_key_now(TimePrecision::Hour, TimeFormat::Hour24, &salts(), &fast()).unwrap();
1366        assert_ne!(k.as_bytes(), &[0u8; 32]);
1367    }
1368
1369    #[cfg(all(feature = "enc-timelock-keygen-now", feature = "enc-timelock-keygen-input"))]
1370    #[test]
1371    fn now_and_at_same_minute_match() {
1372        // Build a TimeLockTime from the current clock and confirm it produces
1373        // the same key as derive_key_now with Minute precision.
1374        let now = chrono::Local::now();
1375        let t = TimeLockTime::new(now.hour(), now.minute()).unwrap();
1376        let s = salts();
1377        let kn = derive_key_now(TimePrecision::Minute, TimeFormat::Hour24, &s, &fast()).unwrap();
1378        let ka = derive_key_at(t, TimePrecision::Minute, TimeFormat::Hour24, &s, &fast()).unwrap();
1379        assert_eq!(
1380            kn.as_bytes(), ka.as_bytes(),
1381            "now and explicit current time must produce the same key"
1382        );
1383    }
1384
1385    // ── salt serialization round-trip ─────────────────────────────────────
1386
1387    #[test]
1388    fn salt_round_trip() {
1389        let s = salts();
1390        let b = s.to_bytes();
1391        let s2 = TimeLockSalts::from_slice(&b);
1392        assert_eq!(s.s1, s2.s1);
1393        assert_eq!(s.s2, s2.s2);
1394        assert_eq!(s.s3, s2.s3);
1395    }
1396
1397    // ── Custom variant ───────────────────────────────────────────────────
1398
1399    #[cfg(feature = "enc-timelock-keygen-input")]
1400    #[test]
1401    fn custom_params_works() {
1402        // Verify Custom(KdfParams) goes through the code path without failing.
1403        let preset = KdfPreset::Custom(KdfPreset::Fast.params());
1404        let t = TimeLockTime::new(10, 0).unwrap();
1405        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &preset.params())
1406            .expect("Custom params should succeed");
1407    }
1408
1409    #[cfg(feature = "enc-timelock-keygen-input")]
1410    #[test]
1411    fn custom_params_roundtrip_eq() {
1412        let p = KdfPreset::Fast.params();
1413        assert_eq!(KdfPreset::Custom(p).params(), p);
1414    }
1415
1416    // ── Generic preset smoke tests (slow) ────────────────────────────────
1417
1418    #[cfg(feature = "enc-timelock-keygen-input")]
1419    #[test]
1420    #[ignore = "slow (~400–600 ms) — run with `cargo test -- --ignored`"]
1421    fn balanced_preset_completes() {
1422        let t = TimeLockTime::new(10, 0).unwrap();
1423        let start = std::time::Instant::now();
1424        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::Balanced.params())
1425            .expect("Balanced should succeed");
1426        println!("Balanced (generic): {:?}", start.elapsed());
1427    }
1428
1429    #[cfg(feature = "enc-timelock-keygen-input")]
1430    #[test]
1431    #[ignore = "slow (~2 s on Mac, ~8–15 s on x86) — run with `cargo test -- --ignored`"]
1432    fn paranoid_preset_completes() {
1433        let t = TimeLockTime::new(10, 0).unwrap();
1434        let start = std::time::Instant::now();
1435        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::Paranoid.params())
1436            .expect("Paranoid should succeed");
1437        println!("Paranoid (generic): {:?}", start.elapsed());
1438    }
1439
1440    // ── Mac preset smoke tests ───────────────────────────────────────────
1441
1442    #[cfg(all(feature = "enc-timelock-keygen-input", target_os = "macos"))]
1443    #[test]
1444    #[ignore = "slow (~2 s on M2) — run with `cargo test -- --ignored`"]
1445    fn balanced_mac_completes() {
1446        let t = TimeLockTime::new(10, 0).unwrap();
1447        let start = std::time::Instant::now();
1448        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::BalancedMac.params())
1449            .expect("BalancedMac should succeed");
1450        println!("BalancedMac: {:?}", start.elapsed());
1451    }
1452
1453    #[cfg(all(feature = "enc-timelock-keygen-input", target_os = "macos"))]
1454    #[test]
1455    #[ignore = "slow (~5–12 s on M2, faster on M3/M4) — run with `cargo test -- --ignored`"]
1456    fn paranoid_mac_completes() {
1457        let t = TimeLockTime::new(10, 0).unwrap();
1458        let start = std::time::Instant::now();
1459        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::ParanoidMac.params())
1460            .expect("ParanoidMac should succeed");
1461        println!("ParanoidMac: {:?}", start.elapsed());
1462    }
1463
1464    // ── x86-64 preset smoke tests ────────────────────────────────────────
1465
1466    #[cfg(all(feature = "enc-timelock-keygen-input", target_arch = "x86_64"))]
1467    #[test]
1468    #[ignore = "slow (~1.5 s on typical x86-64) — run with `cargo test -- --ignored`"]
1469    fn balanced_x86_completes() {
1470        let t = TimeLockTime::new(10, 0).unwrap();
1471        let start = std::time::Instant::now();
1472        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::BalancedX86.params())
1473            .expect("BalancedX86 should succeed");
1474        println!("BalancedX86: {:?}", start.elapsed());
1475    }
1476
1477    #[cfg(all(feature = "enc-timelock-keygen-input", target_arch = "x86_64"))]
1478    #[test]
1479    #[ignore = "slow (~8–15 s on typical x86-64) — run with `cargo test -- --ignored`"]
1480    fn paranoid_x86_completes() {
1481        let t = TimeLockTime::new(10, 0).unwrap();
1482        let start = std::time::Instant::now();
1483        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::ParanoidX86.params())
1484            .expect("ParanoidX86 should succeed");
1485        println!("ParanoidX86: {:?}", start.elapsed());
1486    }
1487
1488    // ── Linux ARM64 preset smoke tests ───────────────────────────────────
1489
1490    #[cfg(all(feature = "enc-timelock-keygen-input", target_arch = "aarch64", not(target_os = "macos")))]
1491    #[test]
1492    #[ignore = "slow (~3 s on Graviton3) — run with `cargo test -- --ignored`"]
1493    fn balanced_arm_completes() {
1494        let t = TimeLockTime::new(10, 0).unwrap();
1495        let start = std::time::Instant::now();
1496        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::BalancedArm.params())
1497            .expect("BalancedArm should succeed");
1498        println!("BalancedArm: {:?}", start.elapsed());
1499    }
1500
1501    #[cfg(all(feature = "enc-timelock-keygen-input", target_arch = "aarch64", not(target_os = "macos")))]
1502    #[test]
1503    #[ignore = "slow (~10–20 s on Graviton3) — run with `cargo test -- --ignored`"]
1504    fn paranoid_arm_completes() {
1505        let t = TimeLockTime::new(10, 0).unwrap();
1506        let start = std::time::Instant::now();
1507        derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &salts(), &KdfPreset::ParanoidArm.params())
1508            .expect("ParanoidArm should succeed");
1509        println!("ParanoidArm: {:?}", start.elapsed());
1510    }
1511
1512    // ── Scheduled key derivation ─────────────────────────────────────────────
1513
1514    #[cfg(feature = "enc-timelock-keygen-input")]
1515    #[test]
1516    fn scheduled_none_same_as_regular_at() {
1517        // cadence=None adds no prefix — result must equal derive_key_at
1518        let s = salts();
1519        let t = TimeLockTime::new(14, 0).unwrap();
1520        let regular   = derive_key_at(t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast()).unwrap();
1521        let scheduled = derive_key_scheduled_at(
1522            TimeLockCadence::None, t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1523        ).unwrap();
1524        assert_eq!(regular.as_bytes(), scheduled.as_bytes());
1525    }
1526
1527    #[cfg(feature = "enc-timelock-keygen-input")]
1528    #[test]
1529    fn scheduled_different_weekdays_different_keys() {
1530        let s = salts();
1531        let t = TimeLockTime::new(18, 0).unwrap();
1532        let k_mon = derive_key_scheduled_at(
1533            TimeLockCadence::DayOfWeek(Weekday::Monday),
1534            t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1535        ).unwrap();
1536        let k_tue = derive_key_scheduled_at(
1537            TimeLockCadence::DayOfWeek(Weekday::Tuesday),
1538            t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1539        ).unwrap();
1540        assert_ne!(k_mon.as_bytes(), k_tue.as_bytes());
1541    }
1542
1543    #[cfg(feature = "enc-timelock-keygen-input")]
1544    #[test]
1545    fn scheduled_different_months_different_keys() {
1546        let s = salts();
1547        let t = TimeLockTime::new(0, 0).unwrap();
1548        let k_jan = derive_key_scheduled_at(
1549            TimeLockCadence::MonthOfYear(Month::January),
1550            t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1551        ).unwrap();
1552        let k_feb = derive_key_scheduled_at(
1553            TimeLockCadence::MonthOfYear(Month::February),
1554            t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1555        ).unwrap();
1556        assert_ne!(k_jan.as_bytes(), k_feb.as_bytes());
1557    }
1558
1559    #[cfg(feature = "enc-timelock-keygen-input")]
1560    #[test]
1561    fn scheduled_at_deterministic() {
1562        // Same inputs must always produce the same key
1563        let s  = salts();
1564        let t  = TimeLockTime::new(6, 0).unwrap();
1565        let c  = TimeLockCadence::DayOfWeekInMonth(Weekday::Friday, Month::March);
1566        let k1 = derive_key_scheduled_at(c, t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast()).unwrap();
1567        let s2 = TimeLockSalts::from_slice(&s.to_bytes());
1568        let k2 = derive_key_scheduled_at(c, t, TimePrecision::Hour, TimeFormat::Hour24, &s2, &fast()).unwrap();
1569        assert_eq!(k1.as_bytes(), k2.as_bytes());
1570    }
1571
1572    #[cfg(feature = "enc-timelock-keygen-now")]
1573    #[test]
1574    fn scheduled_now_none_matches_derive_now() {
1575        // cadence_variant=0 (None) + Hour + Hour24 must match derive_key_now exactly.
1576        // TimeLockParams now carries salts+kdf; build via pack() and clone salts.
1577        let s = salts();
1578        let f = fast();
1579        let stored = pack(
1580            TimePrecision::Hour, TimeFormat::Hour24,
1581            &TimeLockCadence::None,
1582            s.clone(),
1583            f,
1584        );
1585        let k1 = derive_key_now(TimePrecision::Hour, TimeFormat::Hour24, &s, &f).unwrap();
1586        let k2 = derive_key_scheduled_now(&stored).unwrap();
1587        assert_eq!(k1.as_bytes(), k2.as_bytes());
1588    }
1589
1590    #[cfg(any(feature = "enc-timelock-keygen-input", feature = "enc-timelock-keygen-now"))]
1591    #[test]
1592    fn pack_unpack_roundtrip() {
1593        let params = pack(
1594            TimePrecision::Minute,
1595            TimeFormat::Hour24,
1596            &TimeLockCadence::DayOfWeekInMonth(Weekday::Tuesday, Month::February),
1597            salts(),
1598            fast(),
1599        );
1600        assert_eq!(params.time_precision, 2);  // Minute
1601        assert_eq!(params.time_format, 1);      // Hour24
1602        assert_eq!(params.cadence_variant, 4);  // DayOfWeekInMonth
1603        let (p, f, v) = unpack(&params);
1604        assert!(matches!(p, TimePrecision::Minute));
1605        assert!(matches!(f, TimeFormat::Hour24));
1606        assert_eq!(v, 4);
1607    }
1608
1609    #[cfg(feature = "enc-timelock-keygen-input")]
1610    #[test]
1611    #[should_panic(expected = "DayOfMonthInMonth")]
1612    fn day_of_month_in_month_panics_on_invalid_day() {
1613        // February can have at most 28 days; day 29 must panic
1614        let s = salts();
1615        let t = TimeLockTime::new(0, 0).unwrap();
1616        let _ = derive_key_scheduled_at(
1617            TimeLockCadence::DayOfMonthInMonth(29, Month::February),
1618            t, TimePrecision::Hour, TimeFormat::Hour24, &s, &fast(),
1619        );
1620    }
1621}