entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
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
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
//! TOTP (Time-based One-Time Password) per RFC 6238.
//!
//! Provides TOTP code generation and verification for multi-factor
//! authentication (MFA). Supports SHA-1, SHA-256, and SHA-512 HMAC
//! algorithms as specified in RFC 6238. The default configuration
//! (6-digit codes, 30-second period, SHA-1) is compatible with Google
//! Authenticator and similar apps.
//!
//! # Protocol
//!
//! TOTP builds on HOTP (RFC 4226) by using the current time divided by
//! a period (default 30 seconds) as the counter value. Verification
//! checks a configurable window around the current time step to
//! accommodate clock skew.
//!
//! # Examples
//!
//! ```
//! use entropy_auth::mfa::totp::{TotpSecret, TotpConfig, totp_generate, totp_verify};
//!
//! let secret = TotpSecret::from_bytes(b"12345678901234567890".to_vec());
//! let config = TotpConfig::default();
//! let code = totp_generate(&secret, 59, &config).unwrap();
//! assert!(totp_verify(&secret, &code, 59, &config).unwrap());
//! ```

use core::fmt;

use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{HmacSha1, HmacSha256, HmacSha512, RandomError, fill_random};
use crate::encoding::{Base32DecodeError, base32_decode, base32_encode, hex_encode};

// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------

/// HMAC algorithm used for TOTP code generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TotpAlgorithm {
    /// HMAC-SHA-1 (default, compatible with most authenticator apps).
    Sha1,
    /// HMAC-SHA-256.
    Sha256,
    /// HMAC-SHA-512.
    Sha512,
}

impl fmt::Display for TotpAlgorithm {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Sha1 => f.write_str("SHA1"),
            Self::Sha256 => f.write_str("SHA256"),
            Self::Sha512 => f.write_str("SHA512"),
        }
    }
}

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

/// Configuration parameters for TOTP generation and verification.
///
/// The defaults (30-second period, 6 digits, SHA-1, skew of 1) are
/// compatible with Google Authenticator, Authy, and most authenticator
/// apps.
///
/// Use [`TotpConfig::new()`] for defaults, then chain builder methods
/// to customise:
///
/// ```
/// use entropy_auth::mfa::totp::TotpConfig;
///
/// let config = TotpConfig::new()
///     .with_digits(8).unwrap()
///     .with_skew(2).unwrap();
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TotpConfig {
    /// Time step in seconds. Default: 30.
    period: u64,
    /// Number of digits in the generated code. Default: 6.
    digits: u32,
    /// HMAC algorithm. Default: SHA-1.
    algorithm: TotpAlgorithm,
    /// Number of time steps to check before and after the current step
    /// during verification. Default: 1.
    skew: u64,
}

impl TotpConfig {
    /// Creates a new `TotpConfig` with sensible defaults:
    /// period = 30, digits = 6, algorithm = SHA-1, skew = 1.
    #[must_use]
    pub fn new() -> Self {
        Self {
            period: 30,
            digits: 6,
            algorithm: TotpAlgorithm::Sha1,
            skew: 1,
        }
    }

    /// Sets the time step period in seconds. Must be > 0.
    ///
    /// # Errors
    ///
    /// Returns [`TotpError::InvalidPeriod`] if `period` is 0.
    pub fn with_period(mut self, period: u64) -> Result<Self, TotpError> {
        if period == 0 {
            return Err(TotpError::InvalidPeriod);
        }
        self.period = period;
        Ok(self)
    }

    /// Sets the number of digits in the generated code. Must be 1–9.
    ///
    /// # Errors
    ///
    /// Returns [`TotpError::InvalidDigits`] if `digits` is not in the range 1–9.
    pub fn with_digits(mut self, digits: u32) -> Result<Self, TotpError> {
        if digits == 0 || digits > 9 {
            return Err(TotpError::InvalidDigits);
        }
        self.digits = digits;
        Ok(self)
    }

    /// Sets the HMAC algorithm.
    #[must_use]
    pub fn with_algorithm(mut self, algorithm: TotpAlgorithm) -> Self {
        self.algorithm = algorithm;
        self
    }

    /// Validates the `period` and `digits` invariants.
    ///
    /// The builder setters enforce these too, but a `TotpConfig` can also be
    /// constructed by struct literal (in-crate), so the generation and
    /// verification entry points re-check before use.
    fn validate(&self) -> Result<(), TotpError> {
        if self.period == 0 {
            return Err(TotpError::InvalidPeriod);
        }
        if self.digits == 0 || self.digits > 9 {
            return Err(TotpError::InvalidDigits);
        }
        Ok(())
    }

    /// Sets the skew (number of time steps to check before/after). Must be <= 2.
    ///
    /// Each unit of skew widens the acceptance window by one period on each
    /// side, so a code stays valid for `period * (2*skew + 1)` seconds. With
    /// the default 30-second period, `skew = 2` already accepts a ±60s
    /// window (150s total); larger values trade too much security for clock
    /// tolerance, so they are rejected. RFC 6238 §5.2 suggests at most one
    /// step.
    ///
    /// # Errors
    ///
    /// Returns [`TotpError::InvalidSkew`] if `skew` is greater than 2.
    pub fn with_skew(mut self, skew: u64) -> Result<Self, TotpError> {
        if skew > 2 {
            return Err(TotpError::InvalidSkew);
        }
        self.skew = skew;
        Ok(self)
    }

    /// Returns the time step period in seconds.
    #[must_use]
    #[inline]
    pub fn period(&self) -> u64 {
        self.period
    }

    /// Returns the number of digits in the generated code.
    #[must_use]
    #[inline]
    pub fn digits(&self) -> u32 {
        self.digits
    }

    /// Returns the HMAC algorithm.
    #[must_use]
    #[inline]
    pub fn algorithm(&self) -> TotpAlgorithm {
        self.algorithm
    }

    /// Returns the skew (time steps checked before/after current).
    #[must_use]
    #[inline]
    pub fn skew(&self) -> u64 {
        self.skew
    }
}

impl Default for TotpConfig {
    fn default() -> Self {
        Self::new()
    }
}

// ---------------------------------------------------------------------------
// URI percent-encoding
// ---------------------------------------------------------------------------

/// Percent-encodes a string per RFC 3986, encoding all characters that are
/// not in the "unreserved" set: `ALPHA / DIGIT / "-" / "." / "_" / "~"`.
fn uri_encode(input: &str) -> String {
    // Upper-case hex encoding per RFC 3986 §2.1.
    static HEX: &[u8; 16] = b"0123456789ABCDEF";
    let mut out = String::with_capacity(input.len());
    for &b in input.as_bytes() {
        if b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_' || b == b'~' {
            out.push(b as char);
        } else {
            out.push('%');
            out.push(HEX[(b >> 4) as usize] as char);
            out.push(HEX[(b & 0x0F) as usize] as char);
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Secret
// ---------------------------------------------------------------------------

/// A TOTP shared secret key, stored in a zeroizing wrapper.
///
/// The secret can be generated randomly, imported from Base32, or
/// constructed from raw bytes. It is cleared from memory on drop.
pub struct TotpSecret {
    key: Zeroizing<Vec<u8>>,
}

impl TotpSecret {
    /// Generates a new random TOTP secret of the given length in bytes.
    ///
    /// A length of 20 bytes is recommended for SHA-1 (the default TOTP
    /// algorithm).
    ///
    /// # Errors
    ///
    /// Returns [`TotpError`] if the platform CSPRNG is unavailable.
    pub fn generate(len: usize) -> Result<Self, TotpError> {
        // RFC 4226 §4 requires a shared secret of at least 128 bits, and
        // RFC 6238 recommends 160 bits for the default SHA-1 HMAC. Enforce a
        // 128-bit floor so a caller cannot accidentally provision a
        // low-entropy second factor.
        const MIN_SECRET_BYTES: usize = 16;
        if len < MIN_SECRET_BYTES {
            return Err(TotpError::SecretTooShort);
        }
        let mut buf = vec![0u8; len];
        fill_random(&mut buf).map_err(TotpError::Random)?;
        Ok(Self {
            key: Zeroizing::new(buf),
        })
    }

    /// Creates a secret from a Base32-encoded string.
    ///
    /// This is the format used in `otpauth://` URIs and by most
    /// authenticator app setup flows.
    ///
    /// # Security
    ///
    /// Unlike [`generate`](Self::generate), this import path does **not**
    /// enforce the 128-bit entropy floor: it must interoperate with
    /// existing secrets (including legacy 80-bit secrets) provisioned by
    /// other systems. Callers minting fresh secrets should use `generate`.
    ///
    /// # Errors
    ///
    /// Returns [`TotpError`] if the Base32 input is invalid.
    pub fn from_base32(encoded: &str) -> Result<Self, TotpError> {
        let bytes = base32_decode(encoded).map_err(TotpError::Base32)?;
        Ok(Self {
            key: Zeroizing::new(bytes),
        })
    }

    /// Returns the secret encoded as Base32 without trailing `=` padding.
    ///
    /// The padding is stripped for compatibility with `otpauth://` URIs,
    /// which conventionally omit it.
    #[must_use]
    pub fn to_base32(&self) -> String {
        base32_encode(&self.key).trim_end_matches('=').to_owned()
    }

    /// Returns a reference to the raw secret bytes.
    #[must_use]
    pub fn as_bytes(&self) -> &[u8] {
        &self.key
    }

    /// Creates a secret from raw bytes.
    ///
    /// # Security
    ///
    /// Like [`from_base32`](Self::from_base32), this import path trusts the
    /// caller and does not enforce the 128-bit entropy floor that
    /// [`generate`](Self::generate) applies. Use `generate` to mint fresh
    /// secrets.
    #[must_use]
    pub fn from_bytes(bytes: Vec<u8>) -> Self {
        Self {
            key: Zeroizing::new(bytes),
        }
    }

    /// Generates an `otpauth://totp/` URI for provisioning authenticator apps.
    ///
    /// The returned URI follows the
    /// [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format):
    ///
    /// ```text
    /// otpauth://totp/{issuer}:{account}?secret={base32}&issuer={issuer}&algorithm={alg}&digits={d}&period={p}
    /// ```
    ///
    /// The `issuer` and `account` values are percent-encoded per RFC 3986.
    #[must_use]
    pub fn generate_uri(&self, issuer: &str, account: &str, config: &TotpConfig) -> String {
        let secret_b32 = self.to_base32();
        let enc_issuer = uri_encode(issuer);
        let enc_account = uri_encode(account);
        format!(
            "otpauth://totp/{enc_issuer}:{enc_account}?secret={secret_b32}&issuer={enc_issuer}&algorithm={alg}&digits={digits}&period={period}",
            enc_issuer = enc_issuer,
            enc_account = enc_account,
            secret_b32 = secret_b32,
            alg = config.algorithm,
            digits = config.digits,
            period = config.period,
        )
    }
}

impl fmt::Debug for TotpSecret {
    /// SECURITY: Redact secret key material from debug output.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TotpSecret").finish_non_exhaustive()
    }
}

impl Clone for TotpSecret {
    fn clone(&self) -> Self {
        Self {
            key: Zeroizing::new(self.key.to_vec()),
        }
    }
}

// ---------------------------------------------------------------------------
// HOTP (RFC 4226)
// ---------------------------------------------------------------------------

/// Computes an HOTP code per RFC 4226 §5.3.
///
/// 1. Compute HMAC of the counter (8-byte big-endian) using the given algorithm.
/// 2. Dynamic truncation: extract a 4-byte code from the HMAC output.
/// 3. Reduce modulo 10^digits and left-pad with zeros.
fn hotp(key: &[u8], counter: u64, algorithm: TotpAlgorithm, digits: u32) -> String {
    let counter_bytes = counter.to_be_bytes();

    // Compute the HMAC using the selected algorithm.
    let hs: Vec<u8> = match algorithm {
        TotpAlgorithm::Sha1 => HmacSha1::mac(key, &counter_bytes).to_vec(),
        TotpAlgorithm::Sha256 => HmacSha256::mac(key, &counter_bytes).to_vec(),
        TotpAlgorithm::Sha512 => HmacSha512::mac(key, &counter_bytes).to_vec(),
    };

    // Dynamic truncation (RFC 4226 §5.3).
    let offset = (hs[hs.len() - 1] & 0x0F) as usize;
    let bin_code = (u32::from(hs[offset]) & 0x7F) << 24
        | u32::from(hs[offset + 1]) << 16
        | u32::from(hs[offset + 2]) << 8
        | u32::from(hs[offset + 3]);

    // Reduce to the desired number of digits.
    let modulus = 10u32.pow(digits);
    let otp = bin_code % modulus;

    // Left-pad with zeros to exactly `digits` characters.
    format!("{otp:0>width$}", width = digits as usize)
}

// ---------------------------------------------------------------------------
// TOTP generation
// ---------------------------------------------------------------------------

/// Generates a TOTP code for the given secret and time.
///
/// The `time_secs` parameter is the current Unix timestamp in seconds.
/// The time step `T` is computed as `time_secs / config.period`.
///
/// # Errors
///
/// Returns [`TotpError`] if the configuration has invalid digits or period.
pub fn totp_generate(
    secret: &TotpSecret,
    time_secs: u64,
    config: &TotpConfig,
) -> Result<String, TotpError> {
    config.validate()?;

    let t = time_secs / config.period;
    Ok(hotp(secret.as_bytes(), t, config.algorithm, config.digits))
}

// ---------------------------------------------------------------------------
// TOTP verification
// ---------------------------------------------------------------------------

/// Verifies a TOTP code against the given secret and time.
///
/// Checks time steps from `T - skew` through `T + skew` inclusive.
/// The comparison is constant-time with respect to the number of steps
/// checked: all steps are always evaluated (no early return).
///
/// # Security
///
/// This function provides **no replay protection**: the same valid code is
/// accepted repeatedly within its skew window. For a second-factor login
/// flow, use [`totp_verify_step`] instead and persist the matched step so a
/// code cannot be reused (RFC 6238 §5.2).
///
/// # Errors
///
/// Returns [`TotpError`] if the configuration has invalid digits or period.
pub fn totp_verify(
    secret: &TotpSecret,
    code: &str,
    time_secs: u64,
    config: &TotpConfig,
) -> Result<bool, TotpError> {
    Ok(totp_verify_step(secret, code, time_secs, config)?.is_some())
}

/// Verifies a TOTP code and, on success, returns the **time step** that
/// matched (within the skew window).
///
/// This is the replay-aware variant of [`totp_verify`]. RFC 6238 §5.2
/// requires a verifier to reject a code that was already accepted within its
/// validity window. Because this crate is storage-free, the caller must
/// persist the last accepted step per user and reject any code whose matched
/// step is not strictly greater than it:
///
/// ```ignore
/// if let Some(step) = totp_verify_step(&secret, code, now, &cfg)? {
///     if Some(step) <= last_used_step { return Err(/* replay */); }
///     // accept, then persist `step` as the new last_used_step
/// }
/// ```
///
/// Returns `Some(step)` for the matched step or `None` if no step matched.
/// Like [`totp_verify`], every step in the window is evaluated (no early
/// return) so the timing does not leak which step matched.
///
/// # Errors
///
/// Returns [`TotpError`] if the configuration has invalid digits or period.
pub fn totp_verify_step(
    secret: &TotpSecret,
    code: &str,
    time_secs: u64,
    config: &TotpConfig,
) -> Result<Option<u64>, TotpError> {
    config.validate()?;

    let t = time_secs / config.period;
    let mut matched: Option<u64> = None;

    // SECURITY: Check all time steps regardless of match to avoid
    // timing side-channel leakage of which step matched.
    let start = t.saturating_sub(config.skew);
    let end = t.saturating_add(config.skew);

    for step in start..=end {
        let expected = hotp(secret.as_bytes(), step, config.algorithm, config.digits);
        if crate::crypto::constant_time::constant_time_eq(expected.as_bytes(), code.as_bytes()) {
            // SECURITY: on the (astronomically rare) chance the same code
            // matches more than one step in the window, keep the *highest*
            // step. A replay guard persists "last accepted step" and rejects
            // `<=`, so advancing the watermark maximally is the safe choice.
            matched = Some(step);
        }
    }

    Ok(matched)
}

// ---------------------------------------------------------------------------
// Recovery codes
// ---------------------------------------------------------------------------

/// Minimum bytes of entropy per recovery code (64 bits). Recovery codes
/// are a full authentication-bypass factor, so a low-entropy code would be
/// brute-forceable.
const MIN_RECOVERY_CODE_BYTES: usize = 8;

/// Generates a set of single-use recovery codes.
///
/// Each code is a hex string of `length` random bytes with a hyphen
/// inserted at the midpoint, e.g. `"a1b2c3-d4e5f6"` for `length = 6`.
///
/// # Security
///
/// Recovery codes are a full authentication-bypass factor, so each is
/// returned wrapped in [`Zeroizing`] — its plaintext is cleared from
/// memory when dropped, matching the handling of other secret material in
/// this crate. The caller should hash the codes for storage and drop the
/// returned values promptly.
///
/// # Errors
///
/// - [`TotpError::RecoveryCodeTooShort`] if `length` is below the 64-bit
///   (8-byte) floor — a shorter code would not be a meaningful bypass
///   factor.
/// - [`TotpError::Random`] if the platform CSPRNG is unavailable.
pub fn generate_recovery_codes(
    count: usize,
    length: usize,
) -> Result<Vec<Zeroizing<String>>, TotpError> {
    if length < MIN_RECOVERY_CODE_BYTES {
        return Err(TotpError::RecoveryCodeTooShort);
    }
    let mut codes = Vec::with_capacity(count);
    for _ in 0..count {
        let mut buf = vec![0u8; length];
        fill_random(&mut buf).map_err(TotpError::Random)?;
        // Wrap the intermediate hex (a copy of the secret bytes) so it is
        // scrubbed on drop alongside the returned code.
        let hex = Zeroizing::new(hex_encode(&buf));
        crate::crypto::zeroize::zeroize(&mut buf);
        // Insert hyphen at the midpoint.
        let mid = hex.len() / 2;
        let code = Zeroizing::new(format!("{}-{}", &hex[..mid], &hex[mid..]));
        codes.push(code);
    }
    Ok(codes)
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// Error returned by TOTP operations.
#[derive(Debug)]
pub enum TotpError {
    /// The platform CSPRNG is unavailable.
    Random(RandomError),
    /// Base32 decoding of the secret failed.
    Base32(Base32DecodeError),
    /// The configured digit count is invalid (must be 1–9).
    InvalidDigits,
    /// The configured period is invalid (must be non-zero).
    InvalidPeriod,
    /// The configured skew is invalid (must be <= 2).
    InvalidSkew,
    /// The requested secret length is below the 128-bit (16-byte) floor.
    SecretTooShort,
    /// The requested recovery-code length is below the 64-bit (8-byte) floor.
    RecoveryCodeTooShort,
}

impl fmt::Display for TotpError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Random(e) => write!(f, "totp: {e}"),
            Self::Base32(e) => write!(f, "totp: {e}"),
            Self::InvalidDigits => f.write_str("totp: invalid digit count (must be 1-9)"),
            Self::InvalidPeriod => f.write_str("totp: invalid period (must be non-zero)"),
            Self::InvalidSkew => f.write_str("totp: invalid skew (must be <= 2)"),
            Self::SecretTooShort => {
                f.write_str("totp: secret too short (must be at least 16 bytes / 128 bits)")
            }
            Self::RecoveryCodeTooShort => {
                f.write_str("totp: recovery code too short (must be at least 8 bytes / 64 bits)")
            }
        }
    }
}

impl std::error::Error for TotpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Random(e) => Some(e),
            Self::Base32(e) => Some(e),
            Self::InvalidDigits
            | Self::InvalidPeriod
            | Self::InvalidSkew
            | Self::SecretTooShort
            | Self::RecoveryCodeTooShort => None,
        }
    }
}

impl Clone for TotpError {
    fn clone(&self) -> Self {
        match self {
            Self::Random(e) => Self::Random(e.clone()),
            Self::Base32(e) => Self::Base32(e.clone()),
            Self::InvalidDigits => Self::InvalidDigits,
            Self::InvalidPeriod => Self::InvalidPeriod,
            Self::InvalidSkew => Self::InvalidSkew,
            Self::SecretTooShort => Self::SecretTooShort,
            Self::RecoveryCodeTooShort => Self::RecoveryCodeTooShort,
        }
    }
}

impl PartialEq for TotpError {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (Self::Random(a), Self::Random(b)) => a == b,
            (Self::Base32(a), Self::Base32(b)) => a == b,
            (Self::InvalidDigits, Self::InvalidDigits)
            | (Self::InvalidPeriod, Self::InvalidPeriod)
            | (Self::InvalidSkew, Self::InvalidSkew)
            | (Self::SecretTooShort, Self::SecretTooShort)
            | (Self::RecoveryCodeTooShort, Self::RecoveryCodeTooShort) => true,
            _ => false,
        }
    }
}

impl Eq for TotpError {}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // -----------------------------------------------------------------------
    // RFC 6238 Appendix B test vectors (SHA-1, 8 digits, period 30)
    // -----------------------------------------------------------------------

    fn rfc6238_sha1_config() -> TotpConfig {
        TotpConfig {
            period: 30,
            digits: 8,
            algorithm: TotpAlgorithm::Sha1,
            skew: 0,
        }
    }

    fn rfc6238_sha1_secret() -> TotpSecret {
        TotpSecret::from_bytes(b"12345678901234567890".to_vec())
    }

    #[test]
    fn rfc6238_sha1_time_59() {
        let secret = rfc6238_sha1_secret();
        let config = rfc6238_sha1_config();
        let code = totp_generate(&secret, 59, &config).unwrap();
        assert_eq!(code, "94287082");
    }

    #[test]
    fn rfc6238_sha1_time_1111111109() {
        let secret = rfc6238_sha1_secret();
        let config = rfc6238_sha1_config();
        let code = totp_generate(&secret, 1_111_111_109, &config).unwrap();
        assert_eq!(code, "07081804");
    }

    #[test]
    fn rfc6238_sha1_time_1234567890() {
        let secret = rfc6238_sha1_secret();
        let config = rfc6238_sha1_config();
        let code = totp_generate(&secret, 1_234_567_890, &config).unwrap();
        assert_eq!(code, "89005924");
    }

    // RFC 6238 Appendix B vectors for SHA-256 / SHA-512. The seeds are the
    // 20-byte ASCII seed extended to the algorithm's block-derived length
    // (32 bytes for SHA-256, 64 for SHA-512), as the RFC specifies.

    #[test]
    fn rfc6238_sha256_vectors() {
        let secret = TotpSecret::from_bytes(b"12345678901234567890123456789012".to_vec());
        let config = TotpConfig {
            period: 30,
            digits: 8,
            algorithm: TotpAlgorithm::Sha256,
            skew: 0,
        };
        assert_eq!(totp_generate(&secret, 59, &config).unwrap(), "46119246");
        assert_eq!(
            totp_generate(&secret, 1_111_111_109, &config).unwrap(),
            "68084774"
        );
        assert_eq!(
            totp_generate(&secret, 1_234_567_890, &config).unwrap(),
            "91819424"
        );
    }

    #[test]
    fn rfc6238_sha512_vectors() {
        let secret = TotpSecret::from_bytes(
            b"1234567890123456789012345678901234567890123456789012345678901234".to_vec(),
        );
        let config = TotpConfig {
            period: 30,
            digits: 8,
            algorithm: TotpAlgorithm::Sha512,
            skew: 0,
        };
        assert_eq!(totp_generate(&secret, 59, &config).unwrap(), "90693936");
        assert_eq!(
            totp_generate(&secret, 1_111_111_109, &config).unwrap(),
            "25091201"
        );
        assert_eq!(
            totp_generate(&secret, 1_234_567_890, &config).unwrap(),
            "93441116"
        );
    }

    // -----------------------------------------------------------------------
    // Skew verification
    // -----------------------------------------------------------------------

    #[test]
    fn verify_step_returns_matched_step_for_replay_guard() {
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig {
            period: 30,
            digits: 8,
            algorithm: TotpAlgorithm::Sha1,
            skew: 1,
        };
        // T=59 is step 1. The matched step must be returned so a caller can
        // persist it and reject re-presentation of the same code.
        let code = totp_generate(&secret, 59, &config).unwrap();
        assert_eq!(
            totp_verify_step(&secret, &code, 59, &config).unwrap(),
            Some(1)
        );
        // A wrong code matches no step.
        assert_eq!(
            totp_verify_step(&secret, "00000000", 59, &config).unwrap(),
            None
        );
    }

    #[test]
    fn verify_with_skew() {
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig {
            period: 30,
            digits: 8,
            algorithm: TotpAlgorithm::Sha1,
            skew: 1,
        };

        // Generate code at T=59 (time step 1).
        let code = totp_generate(&secret, 59, &config).unwrap();

        // Verify at T=59 itself — must pass.
        assert!(totp_verify(&secret, &code, 59, &config).unwrap());

        // Verify at T±30 (one step away) — must pass with skew=1.
        assert!(totp_verify(&secret, &code, 59 + 30, &config).unwrap());
        // T-30=29 is step 0, code was step 1, so step 0 is 1 step away.
        assert!(totp_verify(&secret, &code, 29, &config).unwrap());

        // Verify at T±60 (two steps away) — must fail with skew=1.
        assert!(!totp_verify(&secret, &code, 59 + 60, &config).unwrap());
    }

    #[test]
    fn verify_rejects_wrong_length_code() {
        // A candidate whose length differs from the configured digit count
        // is the boundary where `constant_time_eq`'s length check engages.
        // It must return a clean "no match", never panic or error.
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig::default(); // 6 digits
        assert_eq!(
            totp_verify_step(&secret, "1234", 59, &config).unwrap(),
            None,
        );
        assert_eq!(
            totp_verify_step(&secret, "12345678", 59, &config).unwrap(),
            None,
        );
        assert!(!totp_verify(&secret, "", 59, &config).unwrap());
    }

    // -----------------------------------------------------------------------
    // Base32 round-trip
    // -----------------------------------------------------------------------

    #[test]
    fn secret_base32_round_trip() {
        let secret = TotpSecret::from_bytes(b"12345678901234567890".to_vec());
        let b32 = secret.to_base32();
        let decoded = TotpSecret::from_base32(&b32).unwrap();
        assert_eq!(decoded.as_bytes(), secret.as_bytes());
    }

    // -----------------------------------------------------------------------
    // URI format
    // -----------------------------------------------------------------------

    #[test]
    fn generate_uri_format() {
        let secret = TotpSecret::from_bytes(b"12345678901234567890".to_vec());
        let config = TotpConfig::default();
        let uri = secret.generate_uri("Example", "user@example.com", &config);
        // '@' in the account is percent-encoded as %40.
        assert!(uri.starts_with("otpauth://totp/Example:user%40example.com?secret="));
        assert!(uri.contains("&issuer=Example"));
        assert!(uri.contains("&algorithm=SHA1"));
        assert!(uri.contains("&digits=6"));
        assert!(uri.contains("&period=30"));
        // Secret should not contain trailing '='
        let secret_part = uri
            .split("secret=")
            .nth(1)
            .unwrap()
            .split('&')
            .next()
            .unwrap();
        assert!(!secret_part.contains('='));
    }

    // -----------------------------------------------------------------------
    // Recovery codes
    // -----------------------------------------------------------------------

    #[test]
    fn recovery_codes_format() {
        let codes = generate_recovery_codes(8, 8).unwrap();
        assert_eq!(codes.len(), 8);
        for code in &codes {
            // `Zeroizing<String>` derefs to `str`. Each code is
            // hex_len/2 + '-' + hex_len/2 chars: 8 bytes = 16 hex chars ->
            // "xxxxxxxx-xxxxxxxx" = 17 chars.
            let code: &str = code;
            assert_eq!(code.len(), 17);
            assert_eq!(&code[8..9], "-");
            assert!(code[..8].chars().all(|c| c.is_ascii_hexdigit()));
            assert!(code[9..].chars().all(|c| c.is_ascii_hexdigit()));
        }
    }

    #[test]
    fn recovery_codes_reject_below_entropy_floor() {
        // A zero-length request would otherwise produce a "-" code with no
        // entropy; anything below the 8-byte floor is rejected. (`Zeroizing`
        // has no `PartialEq`, so match on the error rather than `assert_eq!`.)
        assert!(matches!(
            generate_recovery_codes(5, 0),
            Err(TotpError::RecoveryCodeTooShort)
        ));
        assert!(matches!(
            generate_recovery_codes(5, 7),
            Err(TotpError::RecoveryCodeTooShort)
        ));
        assert!(generate_recovery_codes(1, 8).is_ok());
    }

    #[test]
    fn recovery_codes_unique() {
        let codes = generate_recovery_codes(10, 8).unwrap();
        for (i, a) in codes.iter().enumerate() {
            for (j, b) in codes.iter().enumerate() {
                if i != j {
                    assert_ne!(a.as_str(), b.as_str(), "recovery codes should be unique");
                }
            }
        }
    }

    // -----------------------------------------------------------------------
    // Error cases
    // -----------------------------------------------------------------------

    #[test]
    fn invalid_period_zero() {
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig {
            period: 0,
            ..TotpConfig::default()
        };
        assert!(matches!(
            totp_generate(&secret, 59, &config),
            Err(TotpError::InvalidPeriod)
        ));
    }

    #[test]
    fn invalid_digits_zero() {
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig {
            digits: 0,
            ..TotpConfig::default()
        };
        assert!(matches!(
            totp_generate(&secret, 59, &config),
            Err(TotpError::InvalidDigits)
        ));
    }

    #[test]
    fn invalid_digits_ten() {
        let secret = rfc6238_sha1_secret();
        // digits=10 would overflow 10u32.pow(10), so it must be rejected.
        let config = TotpConfig {
            digits: 10,
            ..Default::default()
        };
        assert!(matches!(
            totp_generate(&secret, 59, &config),
            Err(TotpError::InvalidDigits)
        ));
    }

    #[test]
    fn invalid_base32_secret() {
        assert!(matches!(
            TotpSecret::from_base32("!!!invalid!!!"),
            Err(TotpError::Base32(_))
        ));
    }

    // -----------------------------------------------------------------------
    // Debug redaction
    // -----------------------------------------------------------------------

    #[test]
    fn secret_debug_does_not_leak_key() {
        let secret = TotpSecret::from_bytes(b"super-secret-key".to_vec());
        let debug = format!("{secret:?}");
        assert!(debug.contains("TotpSecret"));
        assert!(!debug.contains("super"));
        assert!(!debug.contains("secret-key"));
    }

    // -----------------------------------------------------------------------
    // Error display
    // -----------------------------------------------------------------------

    #[test]
    fn error_display_messages() {
        let err = TotpError::InvalidDigits;
        assert_eq!(err.to_string(), "totp: invalid digit count (must be 1-9)");

        let err = TotpError::InvalidPeriod;
        assert_eq!(err.to_string(), "totp: invalid period (must be non-zero)");
    }

    #[test]
    fn error_implements_std_error() {
        let err: Box<dyn std::error::Error> = Box::new(TotpError::InvalidDigits);
        assert!(err.source().is_none());
        let _ = err.to_string();
    }

    // -----------------------------------------------------------------------
    // Default 6-digit verification
    // -----------------------------------------------------------------------

    #[test]
    fn default_config_6_digit() {
        let secret = rfc6238_sha1_secret();
        let config = TotpConfig::default();
        let code = totp_generate(&secret, 59, &config).unwrap();
        assert_eq!(code.len(), 6);
        assert!(totp_verify(&secret, &code, 59, &config).unwrap());
    }
}