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