otp_offline 0.3.0

Library for offline verification of YubiKey OTPs.
Documentation
//! Modhex encoding and decoding functions for `YubiKey` OTP
//!
//! The `YubiKey` OTP output is provided in the Modhex character set. The Modhex character set
//! uses characters common across the majority of latin alphabet QWERTY keyboard layouts,
//! allowing for functionality regardless of the language set.
//!
//! # Examples
//!
//! ## Converting from bytes to modhex
//!
//! ```
//! use otp_offline::modhex::ModHex;
//!
//! let bytes = [0x12, 0x34, 0x56];
//! let modhex = ModHex::from(&bytes[..]);
//! println!("{modhex}"); // prints "bdefgh"
//! ```
//!
//! ## Converting from modhex characters to bytes
//!
//! ```
//! use otp_offline::modhex::ModHex;
//!
//! let modhex_str = "cbdefghijklnrtuv";
//! let modhex = ModHex::try_from(modhex_str).unwrap();
//! println!("Bytes: {:?}", modhex.raw_bytes()); // prints raw bytes
//! ```
//!
//! ## Validating modhex strings
//!
//! ```
//! use otp_offline::modhex::ModHex;
//!
//! assert!(ModHex::is_valid("cbdefghijklnrtuv").is_ok());
//! ```

use std::{borrow::Cow, fmt::Display, io::Read};

/// Represents a `ModHex` encoded byte array
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Debug))]
pub struct ModHex<'a> {
    bytes: Cow<'a, [u8]>,
}

/// Error types for modhex operations
#[derive(Debug, PartialEq)]
pub enum Error {
    /// Invalid character found in modhex string
    InvalidCharacter(char),

    /// Invalid length of the modhex string
    InvalidLength,
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::InvalidCharacter(invalid_char) => {
                f.write_fmt(format_args!("Invalid character '{invalid_char}'"))
            }
            Error::InvalidLength => f.write_str("Invalid modhex length"),
        }
    }
}

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

/// Modhex to Hex character mapping
const MODHEX_TO_HEX: [char; 16] = [
    'c', 'b', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'r', 't', 'u', 'v',
];

impl ModHex<'_> {
    /// Check if a string is a valid modhex string
    ///
    /// # Errors
    /// If the length is not a multiple of 2 or any character is not a valid modhex character.
    pub fn is_valid(modhex_str: &str) -> Result<(), Error> {
        if !modhex_str.len().is_multiple_of(2) || modhex_str.is_empty() {
            return Err(Error::InvalidLength);
        }

        // modhex chars are in the range of c-v except m,o,p,q,s
        for modhex_char in modhex_str.chars() {
            if !('b'..='v').contains(&modhex_char)
                || modhex_char == 'm'
                || modhex_char == 'o'
                || modhex_char == 'p'
                || modhex_char == 'q'
                || modhex_char == 's'
            {
                return Err(Error::InvalidCharacter(modhex_char));
            }
        }

        Ok(())
    }

    /// Convert to an owned `ModHex` instance
    ///
    /// This is useful when the original data is borrowed and you want to ensure
    /// that the `ModHex` instance owns its data.
    #[inline]
    #[must_use]
    pub fn to_owned(&self) -> ModHex<'static> {
        ModHex {
            bytes: Cow::Owned(self.bytes.to_vec()),
        }
    }

    /// Get the length of the byte array representation
    #[inline]
    #[must_use]
    pub fn bytes_len(&self) -> usize {
        self.bytes.len()
    }

    /// Get the length of the modhex string representation
    #[inline]
    #[must_use]
    pub fn modhex_len(&self) -> usize {
        self.bytes.len() * 2
    }

    /// Get the raw byte slice
    #[inline]
    #[must_use]
    pub fn raw_bytes(&self) -> &[u8] {
        &self.bytes
    }
}

impl Display for ModHex<'_> {
    /// Format the `ModHex` instance as a modhex string
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for byte in self.bytes.iter() {
            let high_nibble = (byte >> 4) & 0x0f;
            let low_nibble = byte & 0x0f;

            write!(
                f,
                "{}{}",
                MODHEX_TO_HEX[high_nibble as usize], MODHEX_TO_HEX[low_nibble as usize]
            )?;
        }
        Ok(())
    }
}

impl<'a> From<&'a [u8]> for ModHex<'a> {
    /// Convert the given byte slice to a `ModHex` instance by borrowing the data
    fn from(value: &'a [u8]) -> Self {
        Self {
            bytes: Cow::Borrowed(value),
        }
    }
}

impl TryFrom<&str> for ModHex<'_> {
    type Error = Error;

    /// Convert the given `modhex` string to the raw bytes
    ///
    /// # Errors
    /// The given bytes buffer must be the exact length.
    /// In case the modhex string is invalid the corresponding error is returned.
    fn try_from(value: &str) -> Result<Self, Self::Error> {
        ModHex::is_valid(value)?;

        let mut bytes = Box::new_uninit_slice(value.len() / 2);

        for (modhex_offset, modhex_char) in value.chars().enumerate() {
            #[expect(
                clippy::cast_possible_truncation,
                reason = "MODHEX_TO_HEX has only 16 values so cast to u8 is safe"
            )]
            for (hex_value, &mapping_char) in MODHEX_TO_HEX.iter().enumerate() {
                if mapping_char == modhex_char {
                    if modhex_offset % 2 == 0 {
                        // high nibble
                        bytes[modhex_offset / 2].write((hex_value as u8) << 4);
                    } else {
                        // low nibble
                        // SAFETY: High nibble is always written first so assume_init is safe
                        unsafe { *bytes[modhex_offset / 2].assume_init_mut() |= hex_value as u8 }
                    }
                }
            }
        }

        // Safety: Due to the length check at the start all bytes are written in the loop
        Ok(Self {
            bytes: unsafe { Cow::Owned(bytes.assume_init().into_vec()) },
        })
    }
}

impl Read for ModHex<'_> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let bytes_to_copy = self.bytes_len().min(buf.len());
        buf.copy_from_slice(&self.bytes[0..bytes_to_copy]);
        Ok(bytes_to_copy)
    }
}

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

    #[test]
    fn test_from_slice() {
        let bytes = [0x12, 0x34, 0x56];
        let modhex = ModHex::from(&bytes[..]);
        assert_eq!(*modhex.raw_bytes(), bytes);
    }

    #[test]
    fn test_try_from_valid_string() {
        let modhex_str = "tett";
        let modhex = ModHex::try_from(modhex_str).unwrap();

        assert_eq!(modhex.bytes_len(), 2);
        assert_eq!(modhex.modhex_len(), 4);
        assert_eq!(*modhex.raw_bytes(), [0xd3, 0xdd]);
    }

    #[test]
    fn test_try_from_invalid_character() {
        matches!(
            ModHex::try_from("testtab").unwrap_err(),
            Error::InvalidCharacter('a')
        );
    }

    #[test]
    fn test_try_from_invalid_length() {
        assert!(matches!(
            ModHex::try_from("cde").unwrap_err(),
            Error::InvalidLength
        ));
        assert!(matches!(
            ModHex::try_from("").unwrap_err(),
            Error::InvalidLength
        ));
    }

    #[test]
    fn test_is_valid() {
        assert!(ModHex::is_valid("cbdefghijklnrtuv").is_ok());
        assert!(ModHex::is_valid("c").is_err()); // Odd length
        assert!(ModHex::is_valid("invalid").is_err()); // Invalid characters
    }

    #[test]
    fn test_display() {
        let bytes = [0x12, 0x34, 0x56];
        let modhex = ModHex::from(&bytes[..]);
        assert_eq!(format!("{}", modhex), "bdefgh");
    }

    #[test]
    fn test_round_trip() {
        // Test with a known modhex string
        let original_modhex = "cbdefghijklnrtuv";
        let modhex = ModHex::try_from(original_modhex).unwrap();

        let display_string = format!("{}", modhex);

        // The display string should match the original
        assert_eq!(display_string, original_modhex);

        // Test that we can convert back
        let modhex_from_bytes = ModHex::from(&modhex.bytes[..]);
        assert_eq!(
            *modhex_from_bytes.raw_bytes(),
            [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]
        );
    }

    #[test]
    fn test_to_owned() {
        let bytes = Box::new([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]);
        let bytes_clone = bytes.clone();

        let modhex = ModHex::from(&bytes[..]);
        let owned_modhex = modhex.to_owned();
        drop(bytes);

        assert_eq!(*owned_modhex.raw_bytes(), bytes_clone[..]);
    }

    #[test]
    #[ignore]
    fn test_parse_performance() {
        use rand::Rng;
        use std::time::Instant;

        const NUM_OTPS: usize = 100_000;
        let mut rng = rand::rng();
        let mut otps = Vec::with_capacity(NUM_OTPS);

        for _ in 0..NUM_OTPS {
            let otp: String = (0..44)
                .map(|_| MODHEX_TO_HEX[rng.random_range(0..MODHEX_TO_HEX.len())])
                .collect();
            otps.push(otp);
        }

        // Measure parsing duration
        let start = Instant::now();
        for otp_str in &otps {
            std::hint::black_box(unsafe { ModHex::try_from(otp_str.as_str()).unwrap_unchecked() });
        }
        let duration = start.elapsed();

        println!(
            "Average: {:.2} µs per OTP parsing",
            duration.as_micros() as f64 / NUM_OTPS as f64
        );
    }
}