otp_offline 0.2.0

Library for offline verification of YubiKey OTPs.
Documentation
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
//! `YubiKey` OTP decryption and validation module
//!
//! This module provides functionality for decrypting and validating `YubiKey` One-Time Passwords.
//! It implements AES decryption of the OTP private part and extracts all the embedded data.
//! The implementation follows the `YubiKey` OTP protocol specification.
//!
//! # Flow Overview: [`Otp`] -> [`DecryptedOtp`] -> Validation
//!
//! The OTP processing follows a three-step flow:
//!
//! 1. **Otp Creation**: Parse a modhex-encoded OTP string into an [`Otp`] struct
//! 2. **Decryption**: Decrypt the private portion using a secret key to create a [`DecryptedOtp`]
//! 3. **Validation**: Validate the decrypted data against previous OTP data to detect replay attacks and ensure proper sequence
//!
//! # Example Usage
//!
//! ```
//! use otp_offline::otp::{Otp, DecryptedOtp, DecryptedPrivateData};
//! use std::time::{SystemTime, Duration};
//!
//! // Step 1: Create OTP from modhex string
//! let otp_str = "cbcdcecfcgchkgnhckifdncgiflkcediddgrldhuubth";
//! let otp = Otp::from_modhex(otp_str).expect("Valid OTP format");
//!
//! // Step 2: Decrypt OTP with secret key
//! let secret_key = [0; 16]; // Replace with actual secret key
//! let decrypted = otp.decrypt(&secret_key).expect("Decryption successful");
//!
//! // Step 3: Validate against previous OTP
//! let now = SystemTime::now();
//! let previous_otp = DecryptedOtp {
//!     id: decrypted.id,
//!     private: DecryptedPrivateData {
//!         id: decrypted.private.id,
//!         usage_counter: 0,
//!         session_counter: 0,
//!         timestamp: 1000,
//!         random: [0; 2],
//!     }
//! };
//!
//! let result = decrypted.validate(&previous_otp);
//! assert!(result.is_ok());
//! ```
//!
//! # Detailed Flow Explanation
//!
//! ## 1. Otp Creation - [`Otp::from_modhex`]
//! - Takes a 44-character modhex-encoded OTP string
//! - Validates the format and length
//! - Parses the public ID (first 6 bytes) and private data (remaining 16 bytes)
//! - Returns an `Otp` struct containing the parsed data
//!
//! ## 2. Decryption - [`Otp::decrypt`]
//! - Takes a 16-byte AES key for decryption
//! - Performs AES-128-ECB decryption on the private data
//! - Validates the CRC16 checksum to ensure data integrity
//! - Extracts all embedded fields: private ID, usage counter, timestamp, session counter, and random value
//! - Returns a [`DecryptedOtp`] struct with the parsed private data
//!
//! ## 3. Validation - [`DecryptedOtp::validate`]
//! - Validates that the following rules:
//!   1. **Public ID** matches the previous OTP
//!   2. **Private ID** matches previous OTPs
//!   3. **Usage Counter**: Must not decrease (no wraparound at maximum value)
//!   4. **Session Counter**: Must increase when usage counter is unchanged
//!   5. **Timestamp**: Not checked in this implementation
//! - Returns appropriate error if validation fails
//!
//! For more information, see: <https://developers.yubico.com/OTP/OTPs_Explained.html>

use std::fmt::{self, Debug};

use crate::modhex::{self, ModHex};
use aes::{
    Aes128,
    cipher::{self, BlockDecrypt, KeyInit},
};

// Constants for validation
const MAX_USAGE_COUNTER: u16 = 0x7fff;

/// Error types for OTP operations
#[derive(Debug, PartialEq)]
pub enum Error {
    /// Invalid OTP format or length
    InvalidOtpFormat,

    /// Modhex conversion error
    Modhex(modhex::Error),

    /// Decryption failed or invalid padding
    Decryption,

    /// The key is not in the correct format
    InvalidKey,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::InvalidOtpFormat => write!(f, "Invalid OTP format"),
            Error::Modhex(err) => write!(f, "Modhex error: {err:?}"),
            Error::Decryption => write!(f, "Decryption failed"),
            Error::InvalidKey => write!(f, "Invalid decryption key"),
        }
    }
}

impl std::error::Error for Error {}

/// Public ID (first 6 bytes [12 modhex characters] of the OTP)
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct PublicId {
    pub raw_bytes: [u8; 6],
}

/// Private ID (6 bytes extracted from the decrypted OTP)
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct PrivateId {
    pub raw_bytes: [u8; 6],
}

impl fmt::Display for PublicId {
    /// Formats the `PublicId` for display as modhex string
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", ModHex::from(&self.raw_bytes[..]))
    }
}

impl Debug for PublicId {
    /// Formats the `PublicId` for debugging as modhex string
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "PublicId (")?;
        fmt::Display::fmt(&self, f)?;
        write!(f, ")")
    }
}

impl fmt::Display for PrivateId {
    /// Formats the `PrivateId` for display as hex string
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        format_hex(f, &self.raw_bytes)
    }
}

impl Debug for PrivateId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "PrivateId (")?;
        fmt::Display::fmt(&self, f)?;
        write!(f, ")")
    }
}

/// `YubiKey` OTP structure containing public and private data
#[derive(Clone, PartialEq, Eq)]
pub struct Otp {
    /// Public ID (first 12 characters of OTP)
    pub id: PublicId,

    /// Still encrypted private data
    private: [u8; 16],
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecryptedOtp {
    /// Public ID (first 12 characters of OTP)
    pub id: PublicId,

    /// Decrypted private data
    pub private: DecryptedPrivateData,
}

/// Decrypted OTP data structure containing all the embedded information
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecryptedPrivateData {
    /// Private ID
    pub id: PrivateId,

    /// Usage counter (persistent)
    ///
    /// Increments for each power up.
    /// Stays at maximum value 0x7fff (can be reset by reprogramming the OTP slot)
    pub usage_counter: u16,

    /// Session counter, resets to 0 at power up
    /// incremented for each generated OTP.
    ///
    /// If this field wraps from 0xff to 0, the usage counter field is
    /// automatically incremented.
    pub session_counter: u8,

    /// Timestamp
    ///
    /// 24 bits, starts at random value at power up and is updated at about 8Hz.
    /// Wraps around approximately every 24 days
    pub timestamp: u32,

    /// Random value
    pub random: [u8; 2],
}

/// Validation error types
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
    /// Public ID mismatch
    PublicIdMismatch,

    // Decryption failed
    Decryption,

    /// Private ID mismatch between current and previous OTP
    PrivateIdMismatch,

    /// Usage counter decreased (possible replay attack)
    UsageCounterDecreased,

    /// Usage counter is at maximum and hasn't changed
    UsageCounterAtMaximum,

    /// Session counter not valid
    InvalidSessionCounter,
}

impl fmt::Display for ValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ValidationError::PublicIdMismatch => write!(f, "Public ID does not match"),
            ValidationError::PrivateIdMismatch => {
                write!(f, "Private ID does not match previous OTP")
            }
            ValidationError::UsageCounterDecreased => {
                write!(f, "Usage counter decreased - possible replay attack")
            }
            ValidationError::UsageCounterAtMaximum => {
                write!(f, "Usage counter is at maximum value")
            }
            ValidationError::InvalidSessionCounter => write!(f, "Session counter is not valid"),
            ValidationError::Decryption => write!(f, "Decryption failed"),
        }
    }
}

impl DecryptedPrivateData {
    /// Validates new private data against previous private data
    ///
    /// This function checks that the new private data can be accepted based on
    /// the previous value, considering possible wraparounds in counters.
    ///
    /// # Arguments
    /// * `previous` - The previous private data to validate against
    ///
    /// # Returns
    /// * `Result<(), ValidationError>` - Ok if valid, error describing the validation failure
    ///
    /// # Validation rules:
    /// * Private ID must match
    /// * Usage counter must not decrease (no wraparound, stays at 0x7fff max)
    /// * If usage counter increased: power cycle occurred
    /// * If usage counter same: no power cycle, session counter must increase
    /// * Session counter must increase for same usage counter (8-bit with wraparound at 0xff)
    fn validate_with_previous(&self, previous: &Self) -> Result<(), ValidationError> {
        // 1. Check private ID and maximum usage_counter
        self.validate_basic(previous.id)?;

        // 2. Validate usage counter (must never decrease, no wraparound)
        if self.usage_counter < previous.usage_counter || self.usage_counter == 0 {
            return Err(ValidationError::UsageCounterDecreased);
        } else if self.usage_counter == previous.usage_counter {
            // Same usage counter: no power cycle
            // Validate session counter
            if self.session_counter <= previous.session_counter {
                return Err(ValidationError::InvalidSessionCounter);
            }

            return Ok(());
        } else {
            // After power cycle, timestamp starts at a RANDOM value
            // We do not validate the timestamp value
            // So for power cycle case, we accept any session_counter
        }

        // All validations passed
        Ok(())
    }

    /// Do basic validation of the OTP
    fn validate_basic(&self, private_id: PrivateId) -> Result<(), ValidationError> {
        if self.id != private_id {
            return Err(ValidationError::PrivateIdMismatch);
        }

        if self.usage_counter == MAX_USAGE_COUNTER {
            // YubiKey stays at max value until reprogrammed
            // Reject OTPs when at maximum to prevent replay attacks
            return Err(ValidationError::UsageCounterAtMaximum);
        }

        Ok(())
    }
}

impl Otp {
    /// Creates a new OTP from a modhex-encoded string and AES key
    ///
    /// # Arguments
    /// * `otp` - A 44-character modhex-encoded `YubiKey` OTP string
    ///
    /// # Returns
    /// * `Result<Otp, Error>` - OTP or error
    ///
    /// # Errors
    /// If the OTP string is not valid modhex or not the correct length
    pub fn from_modhex(otp: &str) -> Result<Self, Error> {
        // Validate OTP length (YubiKey OTP is 44 characters)
        if otp.len() != 44 {
            return Err(Error::InvalidOtpFormat);
        }

        let otp_raw = ModHex::try_from(otp).map_err(Error::Modhex)?;

        // Extract public id and private encrypted part
        // Due to the length check at the start unwrap is safe here
        unsafe {
            Ok(Otp {
                id: PublicId {
                    raw_bytes: otp_raw.raw_bytes()[..6].try_into().unwrap_unchecked(),
                },
                private: otp_raw.raw_bytes()[6..].try_into().unwrap_unchecked(),
            })
        }
    }

    /// Decrypts the OTP using the provided secret key
    ///
    /// # Arguments
    /// * `key` - 16-byte secret (AES) key for decryption
    ///
    /// # Errors
    /// If decryption fails or the key is invalid
    pub fn decrypt(&self, key: &[u8]) -> Result<DecryptedOtp, Error> {
        Ok(DecryptedOtp {
            id: self.id,
            private: DecryptedPrivateData::decrypt(
                &self.private,
                &key.try_into().map_err(|_| Error::InvalidKey)?,
            )
            .or(Err(Error::Decryption))?,
        })
    }

    /// Validates the OTP against previous OTP data
    ///
    /// # Arguments
    /// * `now` - Current time
    /// * `previous` - The previous OTP to validate against
    /// * `key` - 16-byte secret (AES) key for decryption
    ///
    /// # Errors
    /// If validation fails an appropriate error is returned
    pub fn validate(&self, previous: &Self, key: &[u8]) -> Result<(), ValidationError> {
        if self.id != previous.id {
            return Err(ValidationError::PublicIdMismatch);
        }

        let decrypted_self = self.decrypt(key).or(Err(ValidationError::Decryption))?;
        let decrypted_previous = previous.decrypt(key).or(Err(ValidationError::Decryption))?;
        decrypted_self.validate(&decrypted_previous)
    }
}

impl DecryptedOtp {
    /// Validates the OTP against previous OTP data
    ///
    /// # Arguments
    /// * `now` - Current time
    /// * `previous` - The previous OTP to validate against
    ///
    /// # Errors
    /// If validation fails an appropriate error is returned
    pub fn validate(&self, previous: &Self) -> Result<(), ValidationError> {
        if self.id != previous.id {
            return Err(ValidationError::PublicIdMismatch);
        }

        self.private.validate_with_previous(&previous.private)
    }
}

impl DecryptedPrivateData {
    /// Decrypts the private part of a `YubiKey` OTP
    ///
    /// # Arguments
    /// * `private_data` - 16-byte encrypted private data
    /// * `secret_key` - 16-byte AES key for decryption
    ///
    /// # Returns
    /// * `Result<Self, ()>` - Decrypted private data or empty error if decryption fails
    ///
    /// # Note
    /// This function performs AES-128-ECB decryption and validates the CRC16 checksum
    fn decrypt(private_data: &[u8; 16], secret_key: &[u8; 16]) -> Result<Self, ()> {
        let decrypted = {
            use cipher::generic_array::GenericArray;

            let cipher = Aes128::new(GenericArray::from_slice(secret_key));
            let mut block = GenericArray::clone_from_slice(private_data);

            // Decrypt using AES-128-ECB
            cipher.decrypt_block(&mut block);
            block.into_iter().collect::<Vec<u8>>()
        };

        // Calculate CRC16 checksum over all 16 bytes
        // For valid YubiKey OTP, this should equal the magic residue 0xf0b8
        if Self::calculate_crc16(&decrypted) != 0xf0b8 {
            return Err(());
        }

        // Extract fields from decrypted data
        // The data format is:
        // [0..6]   - Private ID [6 bytes]
        // [6..8]   - Usage counter (little-endian) [2 bytes]
        // [8..11]  - Timestamp (little-endian, 24-bit) [3 bytes]
        // [11]     - Session counter [1 bytes]
        // [12..14] - Random value [2 bytes]
        // [14..16] - CRC16 (ISO 13239) [2 bytes]
        Ok(DecryptedPrivateData {
            id: PrivateId {
                raw_bytes: unsafe { decrypted[0..6].try_into().unwrap_unchecked() },
            },
            usage_counter: u16::from_le_bytes([decrypted[6], decrypted[7]]),
            timestamp: u32::from_le_bytes([decrypted[8], decrypted[9], decrypted[10], 0]),
            session_counter: decrypted[11],
            random: [decrypted[12], decrypted[13]],
        })
    }

    /// Calculates CRC16 checksum using ISO 13239 algorithm
    ///
    /// # Arguments
    /// * `data` - Data to calculate CRC16 for
    ///
    /// # Returns
    /// * `u16` - CRC16 checksum
    ///
    /// # Note
    /// This implementation follows the `YubiKey` OTP specification and uses the
    /// same algorithm as used by `YubiKey` devices.
    fn calculate_crc16(data: &[u8]) -> u16 {
        let mut crc: u16 = 0xffff;

        for &byte in data {
            crc ^= u16::from(byte);
            for _ in 0..8 {
                if crc & 1 != 0 {
                    crc = (crc >> 1) ^ 0x8408;
                } else {
                    crc >>= 1;
                }
            }
        }

        crc
    }
}

/// Helper function to format byte arrays as hex string
pub(crate) fn format_hex(f: &mut std::fmt::Formatter, value: &[u8]) -> std::fmt::Result {
    for byte in value {
        write!(f, "{byte:02x}")?;
    }
    Ok(())
}