envseal 0.3.13

Write-only secret vault with process-level access control — post-agent secret management
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
//! TOTP (Time-Based One-Time Password) — RFC 6238 implementation.
//!
//! Used for two-factor authentication when `totp_required` is enabled
//! in `SecurityConfig`. The TOTP secret is stored encrypted with the
//! master key in `security.toml`.
//!
//! # Pairing Flow
//!
//! 1. `envseal security totp-setup` generates a random 160-bit secret
//! 2. Displays a `otpauth://` URI (scannable by any authenticator app)
//! 3. Asks for a verification code to confirm pairing
//! 4. Stores the secret (encrypted) in `security.toml`
//!
//! # Verification Flow
//!
//! On vault unlock when `totp_required = true`:
//! 1. After passphrase entry, a GUI popup asks for the 6-digit code
//! 2. The code is verified against the stored secret
//! 3. Accepts current code ±1 time step (30s window each side)

use hmac::{Hmac, Mac};
use sha1::Sha1;

use crate::error::Error;

type HmacSha1 = Hmac<Sha1>;

/// TOTP time step in seconds (standard: 30s).
const TIME_STEP: u64 = 30;

/// Number of digits in the TOTP code (standard: 6).
const CODE_DIGITS: u32 = 6;

/// How many time steps to accept before/after current (1 = ±30s).
const SKEW: i64 = 1;

/// Generate a random 160-bit TOTP secret.
///
/// Returns the secret as a base32-encoded string (compatible with
/// Google Authenticator, Authy, etc.).
pub fn generate_secret() -> String {
    use rand::rngs::OsRng;
    use rand::RngCore;
    let mut buf = [0u8; 20]; // 160 bits
    OsRng.fill_bytes(&mut buf);
    base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &buf)
}

/// Build an `otpauth://` URI for QR code scanning.
///
/// Compatible with Google Authenticator, Authy, 1Password, Bitwarden,
/// and any RFC 6238 compliant app.
///
/// Audit L16: the account name is percent-encoded so a username
/// containing `:`, `?`, `&`, `#`, ` `, or any non-ASCII character
/// produces a parseable URI. The previous unencoded interpolation
/// happened to work for kebab-case ASCII names but produced a
/// silently-broken QR for usernames like `alice:work` or
/// `Müller@example.com`.
pub fn otpauth_uri(secret_base32: &str, account: &str) -> String {
    let encoded_account = percent_encode_path(account);
    format!(
        "otpauth://totp/envseal:{encoded_account}?secret={secret_base32}&issuer=envseal&digits={CODE_DIGITS}&period={TIME_STEP}"
    )
}

/// Percent-encode bytes that are NOT in the unreserved set per
/// RFC 3986 §2.3. Implemented inline (no extra dep) since this is
/// the only call site that needs URI encoding.
fn percent_encode_path(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for byte in s.bytes() {
        // Unreserved characters per RFC 3986 + ":" since this is
        // the path-segment after the colon (not the colon itself).
        let allowed = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
        if allowed {
            out.push(byte as char);
        } else {
            use std::fmt::Write as _;
            let _ = write!(out, "%{byte:02X}");
        }
    }
    out
}

/// Generate the current TOTP code for a given secret.
///
/// The `secret_base32` is the base32-encoded shared secret.
pub fn generate_code(secret_base32: &str) -> Result<String, Error> {
    let now = current_time_step()?;
    compute_totp(secret_base32, now)
}

/// Verify a user-provided TOTP code against the secret.
///
/// Accepts codes within ±1 time step (±30 seconds) to account for
/// clock skew between the server and the authenticator device.
///
/// Audit M17: process-wide rate limit (max 5 failed attempts per
/// 60 seconds). Without this, an attacker who can repeatedly invoke
/// envseal — e.g. a script that calls `envseal inject` in a tight
/// loop — has unbounded brute-force budget against the 6-digit
/// code space (10^6 ≈ 20 days at 1 attempt/sec). With the limit
/// they get 5 tries per minute, 7,200/day, which keeps the expected
/// time-to-crack above the rotation horizon.
///
/// The in-memory limiter is per-process. A forked-fresh envseal
/// sidesteps it — that hole is closed by [`verify_code_persistent`]
/// which writes a counter file under the vault root so two
/// processes share the budget. Production callers go through that
/// path; the bare [`verify_code`] is kept for back-compat (and for
/// tests that don't have a vault root) but is **not** safe against
/// fork-respawn brute force.
///
/// Returns `true` if the code is valid.
pub fn verify_code(secret_base32: &str, user_code: &str) -> Result<bool, Error> {
    if !rate_limit::permit_attempt() {
        return Err(Error::CryptoFailure(
            "TOTP verification rate-limit exceeded \
             (5 failed attempts per 60s); slow down and retry later"
                .to_string(),
        ));
    }
    let result = verify_code_no_limiter(secret_base32, user_code)?;
    if result {
        rate_limit::record_success();
    } else {
        rate_limit::record_failure();
    }
    Ok(result)
}

/// Pure crypto check — no rate-limiting state mutated. Used by both
/// `verify_code` (which then updates the in-memory limiter) and
/// `verify_code_persistent` (which then updates the on-disk counter).
fn verify_code_no_limiter(secret_base32: &str, user_code: &str) -> Result<bool, Error> {
    let now = current_time_step()?;
    let user_code = user_code.trim();
    for offset in -SKEW..=SKEW {
        let step = if offset < 0 {
            now.checked_sub(offset.unsigned_abs())
        } else {
            #[allow(clippy::cast_sign_loss)]
            now.checked_add(offset as u64)
        };
        if let Some(step) = step {
            let expected = compute_totp(secret_base32, step)?;
            if constant_time_eq(user_code.as_bytes(), expected.as_bytes()) {
                return Ok(true);
            }
        }
    }
    Ok(false)
}

/// Verify a TOTP code with a persistent on-disk rate limiter
/// rooted at `vault_root`.
///
/// The counter file `<vault_root>/.totp_failures` records the
/// timestamps of recent failed attempts in plain ASCII (one
/// unix-seconds value per line), so a forked-fresh envseal sees the
/// same budget the previous process did. Closes the fork-respawn
/// brute-force evasion that the in-memory limiter alone leaves
/// open.
///
/// Falls back to in-memory only if the counter file cannot be read
/// or written (best-effort: a permission error must not lock out
/// a legitimate user — the in-memory limiter still bounds attempts
/// within this process).
///
/// # Errors
/// `Error::CryptoFailure` when the rate limit is exceeded; any
/// error `verify_code` itself returns.
pub fn verify_code_persistent(
    vault_root: &std::path::Path,
    secret_base32: &str,
    user_code: &str,
) -> Result<bool, Error> {
    if !persistent_rate_limit::permit_attempt(vault_root) {
        return Err(Error::CryptoFailure(
            "TOTP verification rate-limit exceeded \
             (5 failed attempts per 60s, persistent across process \
             restarts); slow down and retry later"
                .to_string(),
        ));
    }
    let ok = verify_code_no_limiter(secret_base32, user_code)?;
    if ok {
        persistent_rate_limit::record_success(vault_root);
    } else {
        persistent_rate_limit::record_failure(vault_root);
    }
    Ok(ok)
}

/// Domain string for TOTP secret encryption via HKDF key derivation.
const TOTP_SEAL_DOMAIN: &[u8] = b"totp.v1";

/// Encrypt a TOTP secret with the vault master key.
///
/// Uses AES-256-GCM with a random nonce, keyed by an HKDF-derived
/// subkey (domain `totp.v1`) so the master key bytes are never
/// directly exposed to the AEAD primitive. Returns hex-encoded
/// ciphertext.
pub fn encrypt_secret(secret_base32: &str, master_key: &[u8; 32]) -> Result<String, Error> {
    use aes_gcm::{
        aead::{Aead, KeyInit},
        Aes256Gcm, Nonce,
    };
    use rand::RngCore;

    let derived_key = crate::vault::sealed_blob::derive_aead_key(master_key, TOTP_SEAL_DOMAIN)
        .map_err(|e| Error::CryptoFailure(format!("TOTP key derivation failed: {e}")))?;
    let cipher = Aes256Gcm::new((&*derived_key).into());

    let mut nonce_bytes = [0u8; 12];
    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);

    let ciphertext = cipher
        .encrypt(nonce, secret_base32.as_bytes())
        .map_err(|_| Error::CryptoFailure("failed to encrypt TOTP secret".to_string()))?;

    // Format: hex(nonce || ciphertext)
    let mut combined = Vec::with_capacity(12 + ciphertext.len());
    combined.extend_from_slice(&nonce_bytes);
    combined.extend_from_slice(&ciphertext);

    Ok(hex_encode(&combined))
}

/// Decrypt a TOTP secret from its encrypted hex form. The returned
/// secret is wrapped in [`zeroize::Zeroizing`] so the cleartext
/// base32 string is wiped from heap on drop — anyone holding the
/// TOTP secret can compute valid codes, so the same hygiene we
/// apply to vault secrets applies here.
pub fn decrypt_secret(
    encrypted_hex: &str,
    master_key: &[u8; 32],
) -> Result<zeroize::Zeroizing<String>, Error> {
    use aes_gcm::{
        aead::{Aead, KeyInit},
        Aes256Gcm, Nonce,
    };

    let combined = hex_decode(encrypted_hex)?;
    if combined.len() < 13 {
        return Err(Error::CryptoFailure(
            "encrypted TOTP secret too short".to_string(),
        ));
    }

    let (nonce_bytes, ciphertext) = combined.split_at(12);
    let derived_key = crate::vault::sealed_blob::derive_aead_key(master_key, TOTP_SEAL_DOMAIN)
        .map_err(|e| Error::CryptoFailure(format!("TOTP key derivation failed: {e}")))?;
    let cipher = Aes256Gcm::new((&*derived_key).into());
    let nonce = Nonce::from_slice(nonce_bytes);

    // Hold the AEAD output in Zeroizing so the heap allocation is
    // wiped if the UTF-8 validation fails or if any of the
    // intermediate slot reuse happens before from_utf8 succeeds.
    let plaintext = zeroize::Zeroizing::new(
        cipher
            .decrypt(nonce, ciphertext)
            .map_err(|_| Error::CryptoFailure("failed to decrypt TOTP secret".to_string()))?,
    );

    let s = std::str::from_utf8(&plaintext)
        .map_err(|_| Error::CryptoFailure("TOTP secret is not valid UTF-8".to_string()))?;
    Ok(zeroize::Zeroizing::new(s.to_string()))
}

/// Compute the TOTP value for a given time step using HMAC-SHA1 (RFC 4226/6238).
fn compute_totp(secret_base32: &str, time_step: u64) -> Result<String, Error> {
    let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret_base32)
        .ok_or_else(|| Error::CryptoFailure("invalid base32 TOTP secret".to_string()))?;

    let mut mac = HmacSha1::new_from_slice(&secret)
        .map_err(|_| Error::CryptoFailure("invalid HMAC key length".to_string()))?;

    mac.update(&time_step.to_be_bytes());
    let result = mac.finalize().into_bytes();

    // Dynamic truncation (RFC 4226 §5.4)
    let offset = (result[19] & 0x0f) as usize;
    let code = u32::from_be_bytes([
        result[offset] & 0x7f,
        result[offset + 1],
        result[offset + 2],
        result[offset + 3],
    ]);

    let modulus = 10u32.pow(CODE_DIGITS);
    Ok(format!(
        "{:0>width$}",
        code % modulus,
        width = CODE_DIGITS as usize
    ))
}

/// Get the current UNIX time step (seconds since epoch / 30).
fn current_time_step() -> Result<u64, Error> {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|_| Error::CryptoFailure("system clock before UNIX epoch".to_string()))?;
    Ok(now.as_secs() / TIME_STEP)
}

/// In-memory rate limiter for TOTP verification (audit M17).
///
/// Tracks the timestamps of recent *failed* attempts in a fixed-size
/// ring; refuses to verify when the window is full. A successful
/// verification clears the ring so the user is not penalized for
/// retrying after a typo.
///
/// Per-process by design: this is a defense against scripted brute-
/// forcing within a single envseal invocation pattern, not a
/// device-bound lockout. A persistent on-disk limiter is queued —
/// the in-memory cap is a strict improvement over no limit at all.
mod rate_limit {
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    /// Maximum failed attempts allowed within [`WINDOW`].
    const MAX_FAILURES: usize = 5;
    /// Sliding window size.
    const WINDOW: Duration = Duration::from_secs(60);

    static FAILURES: Mutex<Vec<Instant>> = Mutex::new(Vec::new());

    /// `true` if a fresh attempt is allowed now; `false` if too
    /// many failures already in the window.
    pub fn permit_attempt() -> bool {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let cutoff = Instant::now()
            .checked_sub(WINDOW)
            .unwrap_or_else(Instant::now);
        guard.retain(|t| *t > cutoff);
        guard.len() < MAX_FAILURES
    }

    pub fn record_failure() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.push(Instant::now());
        let cutoff = Instant::now()
            .checked_sub(WINDOW)
            .unwrap_or_else(Instant::now);
        guard.retain(|t| *t > cutoff);
    }

    pub fn record_success() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.clear();
    }

    #[cfg(test)]
    #[allow(dead_code)] // ready for tests that need to reset the limiter
    pub(super) fn reset_for_test() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.clear();
    }
}

/// Persistent-on-disk TOTP rate limiter, keyed on a vault root.
///
/// Counter file `<root>/.totp_failures` holds one unix-seconds
/// timestamp per line in plain ASCII. Read on every attempt; a
/// failed attempt appends; a successful attempt truncates. The
/// budget is the same 5-failures-per-60s as the in-memory limiter,
/// but a forked-fresh envseal sees the previous process's
/// timestamps and respects them.
///
/// All operations are best-effort: an I/O error degrades silently
/// to "no persistent state" (the in-memory limiter still bounds
/// attempts within the current process), so a permissions
/// problem can't lock out a legitimate user. A persistent
/// attacker driving brute-force has zero motive to corrupt their
/// own counter, so degrading-to-permissive is the right
/// trade-off.
mod persistent_rate_limit {
    use std::path::{Path, PathBuf};

    const MAX_FAILURES: usize = 5;
    const WINDOW_SECS: u64 = 60;

    fn counter_path(root: &Path) -> PathBuf {
        root.join(".totp_failures")
    }

    fn now_secs() -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0, |d| d.as_secs())
    }

    fn load_timestamps(path: &Path) -> Vec<u64> {
        let Ok(content) = std::fs::read_to_string(path) else {
            return Vec::new();
        };
        let cutoff = now_secs().saturating_sub(WINDOW_SECS);
        content
            .lines()
            .filter_map(|line| line.trim().parse::<u64>().ok())
            .filter(|ts| *ts > cutoff)
            .collect()
    }

    fn save_timestamps(path: &Path, ts: &[u64]) {
        use std::fmt::Write as _;
        use std::io::Write as _;
        // Tmp+rename so a planted symlink at the counter path
        // can't redirect our write outside the vault root. Same
        // pattern as `keychain::write_unlock_failures`.
        let pid = std::process::id();
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map_or(0, |d| d.as_nanos());
        let tmp_name = format!(".totp_failures.tmp.{pid}.{nanos}");
        let tmp = path.with_file_name(tmp_name);
        let mut body = String::with_capacity(ts.len() * 12);
        for t in ts {
            let _ = writeln!(body, "{t}");
        }
        if let Ok(mut f) = crate::file::atomic_open::create_new_no_traverse(&tmp) {
            if f.write_all(body.as_bytes()).is_ok() && f.sync_all().is_ok() {
                drop(f);
                if std::fs::rename(&tmp, path).is_err() {
                    let _ = std::fs::remove_file(&tmp);
                }
            } else {
                drop(f);
                let _ = std::fs::remove_file(&tmp);
            }
        }
    }

    pub fn permit_attempt(root: &Path) -> bool {
        let path = counter_path(root);
        let ts = load_timestamps(&path);
        ts.len() < MAX_FAILURES
    }

    pub fn record_failure(root: &Path) {
        let path = counter_path(root);
        let mut ts = load_timestamps(&path);
        ts.push(now_secs());
        let cutoff = now_secs().saturating_sub(WINDOW_SECS);
        ts.retain(|t| *t > cutoff);
        save_timestamps(&path, &ts);
    }

    pub fn record_success(root: &Path) {
        let path = counter_path(root);
        // Best-effort: a remove failure is fine — the counter is
        // either overwritten on next failure or already absent.
        let _ = std::fs::remove_file(&path);
    }
}

/// Constant-time byte comparison to prevent timing attacks.
///
/// Audit M17: wrap each XOR with `std::hint::black_box` so an
/// optimizing compiler cannot lift the loop body into a short-
/// circuit. Without the hint, LLVM is permitted (though has no
/// observed reason) to recognize the early-mismatch invariant and
/// turn the constant-time loop back into an early-return — a
/// silent regression of the timing-leak property. The hint is a
/// no-op at runtime cost.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= std::hint::black_box(x ^ y);
    }
    std::hint::black_box(diff) == 0
}

/// Hex encode bytes.
fn hex_encode(bytes: &[u8]) -> String {
    use std::fmt::Write;
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// Hex decode a string.
fn hex_decode(hex: &str) -> Result<Vec<u8>, Error> {
    if hex.len() % 2 != 0 {
        return Err(Error::CryptoFailure("odd-length hex string".to_string()));
    }
    let mut bytes = Vec::with_capacity(hex.len() / 2);
    for i in (0..hex.len()).step_by(2) {
        let byte = u8::from_str_radix(&hex[i..i + 2], 16)
            .map_err(|_| Error::CryptoFailure("invalid hex character".to_string()))?;
        bytes.push(byte);
    }
    Ok(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generate_and_verify() {
        let secret = generate_secret();
        assert!(!secret.is_empty());

        // Generate a code and verify it
        let code = generate_code(&secret).unwrap();
        assert_eq!(code.len(), 6);
        assert!(code.chars().all(|c| c.is_ascii_digit()));

        // Verify should pass for the current code
        assert!(verify_code(&secret, &code).unwrap());

        // Wrong code should fail
        assert!(!verify_code(&secret, "000000").unwrap_or(true));
    }

    #[test]
    fn encrypt_decrypt_roundtrip() {
        let secret = generate_secret();
        let master_key = [42u8; 32];

        let encrypted = encrypt_secret(&secret, &master_key).unwrap();
        let decrypted = decrypt_secret(&encrypted, &master_key).unwrap();

        assert_eq!(secret.as_str(), decrypted.as_str());
    }

    #[test]
    fn otpauth_uri_format() {
        let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "test@example.com");
        assert!(uri.starts_with("otpauth://totp/"));
        assert!(uri.contains("secret=JBSWY3DPEHPK3PXP"));
        assert!(uri.contains("issuer=envseal"));
        assert!(uri.contains("digits=6"));
        assert!(uri.contains("period=30"));
    }

    #[test]
    fn constant_time_eq_works() {
        assert!(constant_time_eq(b"123456", b"123456"));
        assert!(!constant_time_eq(b"123456", b"654321"));
        assert!(!constant_time_eq(b"123456", b"12345"));
    }

    /// RFC 6238 test vector: known secret + known time → known code.
    #[test]
    fn rfc6238_test_vector() {
        // The canonical test secret from RFC 4226 appendix D
        // "12345678901234567890" encoded to base32
        let secret_b32 = base32::encode(
            base32::Alphabet::Rfc4648 { padding: false },
            b"12345678901234567890",
        );

        // Time step 1 (t = 30..59 seconds after epoch)
        let code = compute_totp(&secret_b32, 1).unwrap();
        assert_eq!(code.len(), 6);
        assert!(code.chars().all(|c| c.is_ascii_digit()));
    }

    /// TOTP encryption must use a domain-separated HKDF key, not the raw master key.
    #[test]
    fn totp_uses_domain_separated_key() {
        let master_key = [42u8; 32];
        let totp_derived =
            crate::vault::sealed_blob::derive_aead_key(&master_key, TOTP_SEAL_DOMAIN).unwrap();
        let other_derived =
            crate::vault::sealed_blob::derive_aead_key(&master_key, b"other.domain").unwrap();
        assert_ne!(
            totp_derived.as_ref(),
            other_derived.as_ref(),
            "different HKDF domains must produce different keys"
        );
        assert_ne!(
            totp_derived.as_ref(),
            &master_key,
            "TOTP derived key must not equal the raw master key"
        );
    }

    /// Re-using the same code within the same time step should still pass
    /// (no strict replay window at the crypto layer; rate limiter is separate).
    #[test]
    fn same_code_same_window_reaccepted() {
        let secret = generate_secret();
        let code = generate_code(&secret).unwrap();
        // First verification
        assert!(verify_code(&secret, &code).unwrap());
        // Second verification in the same window should also succeed
        assert!(verify_code(&secret, &code).unwrap());
    }
}