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, ¶ms))
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, ¶ms))
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, ¶ms)
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(¶ms);
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}
1622
1623// ─── attribute macro re-export ────────────────────────────────────────────────
1624
1625/// Concise attribute macro for deriving a time-locked key inline.
1626///
1627/// Replaces the decorated `fn` with a call to [`timelock`] (sync) or
1628/// [`timelock_async`] (async, add the `async` flag). See
1629/// [`toolkit_zero_macros::timelock`] for the full argument reference.
1630///
1631/// # Examples
1632///
1633/// ```rust,ignore
1634/// use toolkit_zero::encryption::timelock::*;
1635///
1636/// // Encryption — derive for 14:37 with Minute precision.
1637/// fn encrypt_key() -> Result<TimeLockKey, TimeLockError> {
1638/// let salts = TimeLockSalts::generate();
1639/// let kdf = KdfPreset::Balanced.params();
1640/// #[timelock(precision = Minute, format = Hour24, time(14, 37), salts = salts, kdf = kdf)]
1641/// fn key() {}
1642/// Ok(key)
1643/// }
1644///
1645/// // Decryption — re-derive from a stored header.
1646/// fn decrypt_key(header: TimeLockParams) -> Result<TimeLockKey, TimeLockError> {
1647/// #[timelock(params = header)]
1648/// fn key() {}
1649/// Ok(key)
1650/// }
1651/// ```
1652#[cfg(any(
1653 feature = "enc-timelock-keygen-now",
1654 feature = "enc-timelock-keygen-input",
1655 feature = "enc-timelock-async-keygen-now",
1656 feature = "enc-timelock-async-keygen-input",
1657))]
1658pub use toolkit_zero_macros::timelock;