diff --git a/Cargo.lock b/Cargo.lock index 4c1985f..cb99045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "anstream" version = "0.6.18" @@ -52,18 +67,60 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "apdu-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5ab921a56bbe68325ba6d3711ee2c681239fe4c9c295c6a1c2fe6992e27f86" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + [[package]] name = "clap" version = "4.5.28" @@ -101,7 +158,7 @@ version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -119,26 +176,135 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "iso7816-tlv" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7660d28d24a831d690228a275d544654a30f3b167a8e491cf31af5fe5058b546" +dependencies = [ + "untrusted", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + [[package]] name = "oath-rs-experiments" version = "0.1.0" dependencies = [ + "apdu-core", + "base32", + "base64", "byteorder", "clap", "clap-stdin", + "hmac", + "iso7816-tlv", + "lazy_static", + "once_cell", + "openssl", + "ouroboros", "pcsc", + "regex", + "sha1", + "sha2", ] [[package]] @@ -147,6 +313,68 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "pcsc" version = "2.9.0" @@ -181,6 +409,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.38" @@ -190,12 +431,81 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.98" @@ -227,18 +537,42 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-ident" version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "windows-sys" version = "0.59.0" @@ -311,3 +645,9 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index f1140c6..611ca7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,22 @@ [dependencies] +apdu-core = "0.4.0" +base32 = "0.5.1" +base64 = "0.22.1" byteorder = "1.5.0" clap = {version = "4.5.23", features = ["derive"]} clap-stdin = "0.6.0" +hmac = "0.12.1" +iso7816-tlv = "0.4.4" +lazy_static = "1.5.0" +once_cell = "1.20.2" +openssl = "0.10.70" +ouroboros = "0.18.5" # serde_json = "1.0.134" # serde = { version = "1.0", features = ["derive"] } pcsc = "2.9.0" +regex = "1.11.1" +sha1 = "0.10.6" +sha2 = "0.10.8" [package] name = "oath-rs-experiments" diff --git a/flake.nix b/flake.nix index 060dacc..63b115b 100644 --- a/flake.nix +++ b/flake.nix @@ -42,16 +42,21 @@ in { devShells = forAllPkgs (pkgs: { - default = pkgs.mkShell { - nativeBuildInputs = [ - ]; - buildInputs = with pkgs; [ - pkg-config - pcsclite.dev - rustup - (pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml) - ]; - }; + default = + let + rust = pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + in + pkgs.mkShell { + nativeBuildInputs = [ + ]; + buildInputs = with pkgs; [ + pkg-config + pcsclite.dev + openssl.dev + rust + ]; + shellHook = "ln -s ${rust} ./.direnv/rust"; + }; }); packages = forAllPkgs (pkgs: { diff --git a/src/lib_ykoath.rs b/src/lib_ykoath.rs deleted file mode 100644 index 377183e..0000000 --- a/src/lib_ykoath.rs +++ /dev/null @@ -1,439 +0,0 @@ -extern crate byteorder; -/// Utilities for interacting with YubiKey OATH/TOTP functionality -extern crate pcsc; - -use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; -use std::ffi::CString; -use std::io::{Cursor, Read, Write}; -use std::time::SystemTime; - -pub type DetectResult<'a> = Result>, pcsc::Error>; - -pub const INS_SELECT: u8 = 0xa4; -pub const OATH_AID: [u8; 7] = [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01]; - -pub enum ErrorResponse { - NoSpace = 0x6a84, - CommandAborted = 0x6f00, - InvalidInstruction = 0x6d00, - AuthRequired = 0x6982, - WrongSyntax = 0x6a80, - GenericError = 0x6581, - NoSuchObject = 0x6984, -} - -pub enum SuccessResponse { - MoreData = 0x61, - Okay = 0x9000, -} - -pub fn format_code(code: u32, digits: OathDigits) -> String { - let mut code_string = code.to_string(); - - match digits { - OathDigits::Six => { - if code_string.len() <= 6 { - format!("{:0>6}", code_string) - } else { - code_string.split_off(code_string.len() - 6) - } - } - OathDigits::Eight => { - if code_string.len() <= 8 { - format!("{:0>8}", code_string) - } else { - code_string.split_off(code_string.len() - 8) - } - } - } -} - -fn to_error_response(sw1: u8, sw2: u8) -> Option { - let code: usize = (sw1 as usize | sw2 as usize) << 8; - - match code { - code if code == ErrorResponse::GenericError as usize => Some(String::from("Generic error")), - code if code == ErrorResponse::NoSpace as usize => Some(String::from("No space on device")), - code if code == ErrorResponse::CommandAborted as usize => { - Some(String::from("Command was aborted")) - } - code if code == ErrorResponse::AuthRequired as usize => { - Some(String::from("Authentication required")) - } - code if code == ErrorResponse::WrongSyntax as usize => Some(String::from("Wrong syntax")), - code if code == ErrorResponse::InvalidInstruction as usize => { - Some(String::from("Invalid instruction")) - } - code if code == SuccessResponse::Okay as usize => None, - sw1 if sw1 == SuccessResponse::MoreData as usize => None, - _ => Some(String::from("Unknown error")), - } -} - -fn to_tlv(tag: Tag, value: &[u8]) -> Vec { - let mut buf = vec![tag as u8]; - let len = value.len(); - - if len < 0x80 { - buf.push(len as u8); - } else if len < 0xff { - buf.push(0x81); - buf.push(len as u8); - } else { - buf.push(0x82); - buf.write_u16::(len as u16).unwrap(); - } - - buf.write(value).unwrap(); - buf -} - -fn time_challenge(timestamp: Option) -> Vec { - let mut buf = Vec::new(); - let ts = match timestamp { - Some(datetime) => { - datetime - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - / 30 - } - None => { - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - / 30 - } - }; - buf.write_u64::(ts).unwrap(); - buf -} - -pub enum Instruction { - Put = 0x01, - Delete = 0x02, - SetCode = 0x03, - Reset = 0x04, - List = 0xa1, - Calculate = 0xa2, - Validate = 0xa3, - CalculateAll = 0xa4, - SendRemaining = 0xa5, -} - -#[repr(u8)] -pub enum Mask { - Algo = 0x0f, - Type = 0xf0, -} - -#[repr(u8)] -pub enum Tag { - Name = 0x71, - NameList = 0x72, - Key = 0x73, - Challenge = 0x74, - Response = 0x75, - TruncatedResponse = 0x76, - Hotp = 0x77, - Property = 0x78, - Version = 0x79, - Imf = 0x7a, - Algorithm = 0x7b, - Touch = 0x7c, -} - -#[derive(Debug, PartialEq)] -#[repr(u8)] -pub enum OathAlgo { - Sha1 = 0x01, - Sha256 = 0x02, -} - -#[derive(Debug, PartialEq)] -#[repr(u8)] -pub enum OathType { - Totp = 0x10, - Hotp = 0x20, -} - -#[derive(Debug, PartialEq)] -pub struct OathCredential { - pub name: String, - pub code: OathCode, - // TODO: Support this stuff - // pub oath_type: OathType, - // pub touch: bool, - // pub algo: OathAlgo, - // pub hidden: bool, - // pub steam: bool, -} - -impl OathCredential { - pub fn new(name: &str, code: OathCode) -> OathCredential { - OathCredential { - name: name.to_string(), - code: code, - // oath_type: oath_type, - // touch: touch, - // algo: algo, - // hidden: name.starts_with("_hidden:"), - // steam: name.starts_with("Steam:"), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum OathDigits { - Six = 6, - Eight = 8, -} - -#[derive(Debug, PartialEq)] -pub struct OathCode { - pub digits: OathDigits, - pub value: u32, - // pub expiration: u32, - // pub steam: bool, -} - -pub struct ApduResponse { - pub buf: Vec, - pub sw1: u8, - pub sw2: u8, -} - -pub struct YubiKey<'a> { - pub name: &'a str, -} - -impl<'a> YubiKey<'a> { - /// Read the OATH codes from the device - pub fn get_oath_codes(&self) -> Result, String> { - // Establish a PC/SC context - let ctx = match pcsc::Context::establish(pcsc::Scope::User) { - Ok(ctx) => ctx, - Err(err) => return Err(format!("{}", err)), - }; - - // Connect to the card - let mut card = match ctx.connect( - &CString::new(self.name).unwrap(), - pcsc::ShareMode::Shared, - pcsc::Protocols::ANY, - ) { - Ok(card) => card, - Err(err) => return Err(format!("{}", err)), - }; - - // Create a transaction context - let tx = match card.transaction() { - Ok(tx) => tx, - Err(err) => return Err(format!("{}", err)), - }; - - // Switch to the OATH applet - if let Err(e) = self.apdu(&tx, 0, INS_SELECT, 0x04, 0, Some(&OATH_AID)) { - return Err(format!("{}", e)); - } - - // Store the response buffer - let mut response_buf = Vec::new(); - - // Request OATH codes from device - let response = self.apdu( - &tx, - 0, - Instruction::CalculateAll as u8, - 0, - 0x01, - Some(&to_tlv( - Tag::Challenge, - &time_challenge(Some(SystemTime::now())), - )), - ); - - // Handle errors from command - match response { - Ok(resp) => { - let mut sw1 = resp.sw1; - let mut sw2 = resp.sw2; - response_buf.extend(resp.buf); - - while sw1 == (SuccessResponse::MoreData as u8) { - let ins = Instruction::SendRemaining as u8; - - match self.apdu(&tx, 0, ins, 0, 0, None) { - Ok(more_resp) => { - sw1 = more_resp.sw1; - sw2 = more_resp.sw2; - response_buf.extend(more_resp.buf); - } - Err(e) => { - return Err(format!("{}", e)); - } - } - } - - if let Some(msg) = to_error_response(sw1, sw2) { - return Err(format!("{}", msg)); - } - - return Ok(self.parse_list(&response_buf).unwrap()); - } - Err(e) => { - return Err(format!("{}", e)); - } - } - } - - /// Accepts a raw byte buffer payload and parses it - pub fn parse_list(&self, b: &[u8]) -> Result, String> { - let mut rdr = Cursor::new(b); - let mut results = Vec::new(); - - loop { - if let Err(_) = rdr.read_u8() { - break; - }; - - let mut len: u16 = match rdr.read_u8() { - Ok(len) => len as u16, - Err(_) => break, - }; - - if len > 0x80 { - let n_bytes = len - 0x80; - - if n_bytes == 1 { - len = match rdr.read_u8() { - Ok(len) => len as u16, - Err(_) => break, - }; - } else if n_bytes == 2 { - len = match rdr.read_u16::() { - Ok(len) => len, - Err(_) => break, - }; - } - } - - let mut name = Vec::with_capacity(len as usize); - - unsafe { - name.set_len(len as usize); - } - - if let Err(_) = rdr.read_exact(&mut name) { - break; - }; - - rdr.read_u8().unwrap(); // TODO: Don't discard the response tag - rdr.read_u8().unwrap(); // TODO: Don't discard the response lenght + 1 - - let digits = match rdr.read_u8() { - Ok(6) => OathDigits::Six, - Ok(8) => OathDigits::Eight, - Ok(_) => break, - Err(_) => break, - }; - - let value = match rdr.read_u32::() { - Ok(val) => val, - Err(_) => break, - }; - - results.push(OathCredential::new( - &String::from_utf8(name).unwrap(), - OathCode { digits, value }, - )); - } - - Ok(results) - } - - /// Sends the APDU package to the device - pub fn apdu( - &self, - tx: &pcsc::Transaction, - class: u8, - instruction: u8, - parameter1: u8, - parameter2: u8, - data: Option<&[u8]>, - ) -> Result { - // Create a container for the transaction payload - let mut tx_buf = Vec::new(); - - // Construct an empty buffer to hold the response - let mut rx_buf = [0; pcsc::MAX_BUFFER_SIZE]; - - // Number of bytes of data - let nc = match data { - Some(ref data) => data.len(), - None => 0, - }; - - // Construct and attach the header - tx_buf.push(class); - tx_buf.push(instruction); - tx_buf.push(parameter1); - tx_buf.push(parameter2); - - // Construct and attach the data's byte count - if nc > 255 { - tx_buf.push(0); - tx_buf.write_u16::(nc as u16).unwrap(); - } else { - tx_buf.push(nc as u8); - } - - // Attach the data itself if included - if let Some(data) = data { - tx_buf.write(data).unwrap(); - } - - // DEBUG - { - let mut s = String::new(); - for byte in &tx_buf { - s += &format!("{:02X} ", byte); - } - println!("DEBUG (SEND) >> {}", s); - } - - // Write the payload to the device and error if there is a problem - let rx_buf = match tx.transmit(&tx_buf, &mut rx_buf) { - Ok(slice) => slice, - Err(err) => return Err(err), - }; - - // DEBUG - { - let mut s = String::new(); - for byte in &rx_buf.to_vec() { - s += &format!("{:02X} ", byte); - } - println!("DEBUG (RECV) << {}", s); - } - - let sw1 = match rx_buf.get((rx_buf.len() - 2) as usize) { - Some(sw1) => sw1, - None => return Err(pcsc::Error::UnknownError), - }; - let sw2 = match rx_buf.get((rx_buf.len() - 1) as usize) { - Some(sw2) => sw2, - None => return Err(pcsc::Error::UnknownError), - }; - - let mut buf = rx_buf.to_vec(); - buf.truncate(rx_buf.len() - 2); - - Ok(ApduResponse { - buf, - sw1: *sw1, - sw2: *sw2, - }) - } -} diff --git a/src/lib_ykoath2.rs b/src/lib_ykoath2.rs new file mode 100644 index 0000000..13d34ba --- /dev/null +++ b/src/lib_ykoath2.rs @@ -0,0 +1,712 @@ +extern crate byteorder; +/// Utilities for interacting with YubiKey OATH/TOTP functionality +extern crate pcsc; +use base32::Alphabet; +use core::borrow; +use iso7816_tlv::simple::{Tag as TlvTag, Tlv}; +use openssl::hash::MessageDigest; +use openssl::version; +use ouroboros::self_referencing; +use regex::Regex; +use std::mem::transmute_copy; +use std::str::{self, FromStr}; + +use once_cell::unsync::OnceCell; + +use apdu_core::{Command, Response}; + +use base64::{engine::general_purpose, Engine as _}; +use hmac::{Hmac, Mac}; +use openssl::pkcs5::pbkdf2_hmac; +use pcsc::{Card, Context, Transaction}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +use lazy_static::lazy_static; +use std::cmp::Ordering; +use std::hash::{Hash, Hasher}; + +use byteorder::{BigEndian, ByteOrder, ReadBytesExt, WriteBytesExt}; +use std::ffi::CString; +use std::io::{Cursor, Read, Write}; +use std::time::SystemTime; + +pub type DetectResult<'a> = Result>, pcsc::Error>; + +pub const INS_SELECT: u8 = 0xa4; +pub const OATH_AID: [u8; 7] = [0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01]; + +pub const DEFAULT_PERIOD: u32 = 30; +pub const DEFAULT_DIGITS: OathDigits = OathDigits::Six; +pub const DEFAULT_IMF: u32 = 0; + +pub enum ErrorResponse { + NoSpace = 0x6a84, + CommandAborted = 0x6f00, + InvalidInstruction = 0x6d00, + AuthRequired = 0x6982, + WrongSyntax = 0x6a80, + GenericError = 0x6581, + NoSuchObject = 0x6984, +} + +lazy_static::lazy_static! { + static ref TOTP_ID_PATTERN: Regex = Regex::new(r"^([A-Za-z0-9]+):([A-Za-z0-9]+):([A-Za-z0-9]+):([0-9]+)?:([0-9]+)$").unwrap(); +} + +pub enum SuccessResponse { + MoreData = 0x61, + Okay = 0x9000, +} + +pub enum Instruction { + Put = 0x01, + Delete = 0x02, + SetCode = 0x03, + Reset = 0x04, + Rename = 0x05, + List = 0xa1, + Calculate = 0xa2, + Validate = 0xa3, + CalculateAll = 0xa4, + SendRemaining = 0xa5, +} + +#[repr(u8)] +pub enum Mask { + Algo = 0x0f, + Type = 0xf0, +} + +#[repr(u8)] +pub enum Tag { + Name = 0x71, + NameList = 0x72, + Key = 0x73, + Challenge = 0x74, + Response = 0x75, + TruncatedResponse = 0x76, + Hotp = 0x77, + Property = 0x78, + Version = 0x79, + Imf = 0x7a, + Algorithm = 0x7b, + Touch = 0x7c, +} + +#[derive(Debug, PartialEq)] +#[repr(u8)] +pub enum HashAlgo { + Sha1 = 0x01, + Sha256 = 0x02, + Sha512 = 0x03, +} + +#[derive(Debug, PartialEq, Copy, Clone, Eq)] +#[repr(u8)] +pub enum OathType { + Totp = 0x10, + Hotp = 0x20, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OathDigits { + Six = 6, + Eight = 8, +} + +pub struct ApduResponse { + pub buf: Vec, + pub sw1: u8, + pub sw2: u8, +} + +pub struct YubiKey<'a> { + pub name: &'a str, +} + +pub fn parse_b32_key(key: String) -> u32 { + let stripped = key.to_uppercase().replace(" ", ""); + let pad = 8 - (stripped.len() % 8); + let padded = stripped + (&"=".repeat(pad)); + let bytes = base32::decode(Alphabet::Rfc4648 { padding: true }, &padded).unwrap(); + let mut bytes_array: [u8; 4] = [0, 0, 0, 0]; + for i in 0..4 { + bytes_array[i] = bytes.get(i).map(|x| *x).unwrap_or(0); + } + + return u32::from_be_bytes(bytes_array); // fixme: be or le? +} + +pub struct CredentialData<'a> { + pub name: &'a str, + oath_type: OathType, + hash_algorithm: HashAlgo, + // secret: bytes, + digits: OathDigits, // = DEFAULT_DIGITS, + period: u32, // = DEFAULT_PERIOD, + counter: u32, // = DEFAULT_IMF, + issuer: Option<&'a str>, +} + +impl<'a> CredentialData<'a> { + // TODO: parse_uri + + pub fn get_id(&self) -> Vec { + return _format_cred_id(self.issuer, self.name, self.oath_type, self.period); + } +} + +#[derive(Debug, PartialEq)] +pub struct OathCode { + pub digits: OathDigits, + pub value: u32, + pub valid_from: u64, + pub valid_to: u64, +} + +#[derive(Debug)] +pub struct OathCredential<'a> { + device_id: &'a str, + id: Vec, + issuer: Option<&'a str>, + name: &'a str, + oath_type: OathType, + period: u64, + touch_required: Option, + // TODO: Support this stuff + // pub oath_type: OathType, + // pub touch: bool, + // pub algo: OathAlgo, + // pub hidden: bool, + // pub steam: bool, +} + +impl<'a> OathCredential<'a> { + /* pub fn new(name: &str, code: OathCode) -> OathCredential { + OathCredential { + name, + code, + // oath_type: oath_type, + // touch: touch, + // algo: algo, + // hidden: name.starts_with("_hidden:"), + // steam: name.starts_with("Steam:"), + } + } */ +} + +impl<'a> PartialOrd for OathCredential<'a> { + fn partial_cmp(&self, other: &Self) -> Option { + let a = ( + self.issuer + .clone() + .unwrap_or_else(|| self.name) + .to_lowercase(), + self.name.to_lowercase(), + ); + let b = ( + other + .issuer + .clone() + .unwrap_or_else(|| other.name) + .to_lowercase(), + other.name.to_lowercase(), + ); + Some(a.cmp(&b)) + } +} + +impl<'a> PartialEq for OathCredential<'a> { + fn eq(&self, other: &Self) -> bool { + self.device_id == other.device_id && self.id == other.id + } +} + +impl<'a> Hash for OathCredential<'a> { + fn hash(&self, state: &mut H) { + self.device_id.hash(state); + self.id.hash(state); + } +} + +fn _format_cred_id(issuer: Option<&str>, name: &str, oath_type: OathType, period: u32) -> Vec { + let mut cred_id = String::new(); + + if oath_type == OathType::Totp && period != DEFAULT_PERIOD { + cred_id.push_str(&format!("{}/", period)); + } + + if let Some(issuer) = issuer { + cred_id.push_str(&format!("{}:", issuer)); + } + + cred_id.push_str(name); + return cred_id.into_bytes(); // Convert the string to bytes +} + +// Function to parse the credential ID +fn _parse_cred_id(cred_id: &[u8], oath_type: OathType) -> (Option, String, u32) { + let data = match str::from_utf8(cred_id) { + Ok(d) => d.to_string(), + Err(_) => return (None, String::new(), 0), // Handle invalid UTF-8 + }; + + if oath_type == OathType::Totp { + if let Some(caps) = TOTP_ID_PATTERN.captures(&data) { + let period_str = caps.get(2).map(|m| m.as_str()).unwrap_or(""); + let period = if !period_str.is_empty() { + period_str.parse::().unwrap_or(DEFAULT_PERIOD) + } else { + DEFAULT_PERIOD + }; + + return (Some(caps[4].to_string()), caps[5].to_string(), period); + } else { + return (None, data, DEFAULT_PERIOD); + } + } else { + let (issuer, rest) = if let Some(pos) = data.find(':') { + if data.chars().next() != Some(':') { + let issuer = data[..pos].to_string(); + let rest = data[pos + 1..].to_string(); + (Some(issuer), rest) + } else { + (None, data) + } + } else { + (None, data) + }; + + return (issuer, rest, 0); + } +} + +fn _get_device_id(salt: Vec) -> String { + // Create SHA-256 hash of the salt + let mut hasher = Sha256::new(); + hasher.update(salt); + let result = hasher.finalize(); + + // Get the first 16 bytes of the hash + let hash_16_bytes = &result[..16]; + + // Base64 encode the result and remove padding ('=') + return general_purpose::URL_SAFE_NO_PAD.encode(hash_16_bytes); +} +fn _hmac_sha1(key: &[u8], message: &[u8]) -> Vec { + let mut mac = Hmac::::new_from_slice(key).expect("Invalid key length"); + mac.update(message); + mac.finalize().into_bytes().to_vec() +} + +fn _derive_key(salt: &[u8], passphrase: &str) -> Vec { + let mut key = vec![0u8; 16]; // Allocate 16 bytes for the key + pbkdf2_hmac( + passphrase.as_bytes(), + salt, + 1000, + MessageDigest::sha1(), + &mut key, + ) + .unwrap(); + key +} + +fn get_message_digest(algo: HashAlgo) -> MessageDigest { + match algo { + HashAlgo::Sha1 => MessageDigest::sha1(), + HashAlgo::Sha256 => MessageDigest::sha256(), + HashAlgo::Sha512 => MessageDigest::sha512(), + } +} + +fn _hmac_shorten_key(key: &[u8], algo: MessageDigest) -> Vec { + if key.len() > algo.block_size() { + let mut hasher = openssl::hash::Hasher::new(algo).unwrap(); + hasher.update(key).unwrap(); + return hasher.finish().unwrap().to_vec(); + } + + key.to_vec() +} + +fn _get_challenge(timestamp: u32, period: u32) -> [u8; 8] { + let time_step = timestamp / period; + + let mut buffer = [0u8; 8]; + let mut cursor = &mut buffer[..]; + cursor.write_u64::(time_step as u64).unwrap(); + + buffer +} + +fn format_code(credential: &OathCredential, timestamp: u64, truncated: &[u8]) -> OathCode { + let (valid_from, valid_to) = match credential.oath_type { + OathType::Totp => { + let time_step = timestamp / credential.period; + let valid_from = time_step * credential.period; + let valid_to = (time_step + 1) * credential.period; + (valid_from, valid_to) + } + OathType::Hotp => (timestamp, 0x7FFFFFFFFFFFFFFF), + }; + + let digits = truncated[0] as usize; + + // Convert the truncated bytes to an integer and mask with 0x7FFFFFFF, then apply mod 10^digits + let code_value = BigEndian::read_u32(&truncated[1..]) & 0x7FFFFFFF; // Adjust endianess here + let mod_value = 10u32.pow(digits as u32); + let code_str = format!("{:0width$}", (code_value % mod_value), width = digits); + + OathCode { + digits: if digits == 6 { + OathDigits::Six + } else if digits == 8 { + OathDigits::Eight + } else { + panic!() + }, + value: code_value, + valid_from, + valid_to, + } +} + +/// Sends the APDU package to the device +pub fn apdu( + tx: &pcsc::Transaction, + class: u8, + instruction: u8, + parameter1: u8, + parameter2: u8, + data: Option<&[u8]>, +) -> Result { + let command = if let Some(data) = data { + Command::new_with_payload(class, instruction, parameter1, parameter2, data) + } else { + Command::new(class, instruction, parameter1, parameter2) + }; + + let tx_buf: Vec = command.into(); + + // Construct an empty buffer to hold the response + let mut rx_buf = [0; pcsc::MAX_BUFFER_SIZE]; + + // Write the payload to the device and error if there is a problem + let rx_buf = match tx.transmit(&tx_buf, &mut rx_buf) { + Ok(slice) => slice, + Err(err) => return Err(format!("{}", err)), + }; + + let resp = Response::from(rx_buf); + let error_context = to_error_response(resp.trailer.0, resp.trailer.1); + + if let Some(err) = error_context { + return Err(err); + } + + Ok(ApduResponse { + buf: resp.payload.to_vec(), + sw1: resp.trailer.0, + sw2: resp.trailer.1, + }) +} + +pub fn apdu_read_all( + tx: &pcsc::Transaction, + class: u8, + instruction: u8, + parameter1: u8, + parameter2: u8, + data: Option<&[u8]>, +) -> Result, String> { + let mut response_buf = Vec::new(); + let mut resp = apdu(tx, class, instruction, parameter1, parameter2, data)?; + response_buf.extend(resp.buf); + while resp.sw1 == (SuccessResponse::MoreData as u8) { + resp = apdu(tx, 0, Instruction::SendRemaining as u8, 0, 0, None)?; + response_buf.extend(resp.buf); + } + Ok(response_buf) +} + +fn to_error_response(sw1: u8, sw2: u8) -> Option { + let code: usize = (sw1 as usize | sw2 as usize) << 8; + + match code { + code if code == ErrorResponse::GenericError as usize => Some(String::from("Generic error")), + code if code == ErrorResponse::NoSpace as usize => Some(String::from("No space on device")), + code if code == ErrorResponse::CommandAborted as usize => { + Some(String::from("Command was aborted")) + } + code if code == ErrorResponse::AuthRequired as usize => { + Some(String::from("Authentication required")) + } + code if code == ErrorResponse::WrongSyntax as usize => Some(String::from("Wrong syntax")), + code if code == ErrorResponse::InvalidInstruction as usize => { + Some(String::from("Invalid instruction")) + } + code if code == SuccessResponse::Okay as usize => None, + sw1 if sw1 == SuccessResponse::MoreData as usize => None, + _ => Some(String::from("Unknown error")), + } +} + +#[self_referencing] +struct TransactionContext { + card: Card, + #[borrows(mut card)] + #[covariant] + transaction: Transaction<'this>, +} + +impl TransactionContext { + pub fn from_name(name: &str) -> Self { + // FIXME: error handling here + + // Establish a PC/SC context + let ctx = pcsc::Context::establish(pcsc::Scope::User).unwrap(); + + // Connect to the card + let card = ctx + .connect( + &CString::new(name).unwrap(), + pcsc::ShareMode::Shared, + pcsc::Protocols::ANY, + ) + .unwrap(); + + TransactionContextBuilder { + card, + transaction_builder: |c| c.transaction().unwrap(), + } + .build() + } + + pub fn apdu( + &self, + class: u8, + instruction: u8, + parameter1: u8, + parameter2: u8, + data: Option<&[u8]>, + ) -> Result { + apdu( + self.borrow_transaction(), + class, + instruction, + parameter1, + parameter2, + data, + ) + } + + pub fn apdu_read_all( + &self, + class: u8, + instruction: u8, + parameter1: u8, + parameter2: u8, + data: Option<&[u8]>, + ) -> Result, String> { + apdu_read_all( + self.borrow_transaction(), + class, + instruction, + parameter1, + parameter2, + data, + ) + } +} + +pub struct OathSession<'a> { + version: OnceCell<&'a str>, + transaction_context: TransactionContext, + pub name: &'a str, +} + +impl<'a> OathSession<'a> { + pub fn new(name: &'a str) -> Self { + let transaction_context = TransactionContext::from_name(name); + let info_buffer = transaction_context + .apdu_read_all(0, INS_SELECT, 0x04, 0, Some(&OATH_AID)) + .unwrap(); + + OathSession { + version: OnceCell::new(), + name, + transaction_context, + } + } + + fn fetch_version(&self) -> &'a str { + return "test"; + } + + fn get_version(&self) -> &'a str { + *self.version.get_or_init(|| self.fetch_version()) + } + + /// Read the OATH codes from the device + pub fn get_oath_codes(&self) -> Result, String> { + // Request OATH codes from device + let response = self.transaction_context.apdu_read_all( + 0, + Instruction::CalculateAll as u8, + 0, + 0x01, + Some(&to_tlv( + Tag::Challenge, + &time_challenge(Some(SystemTime::now())), + )), + ); + + self.parse_list(&response?) + } + /// Accepts a raw byte buffer payload and parses it + pub fn parse_list(&self, b: &[u8]) -> Result, String> { + let mut rdr = Cursor::new(b); + let mut results = Vec::new(); + + loop { + if let Err(_) = rdr.read_u8() { + break; + }; + + let mut len: u16 = match rdr.read_u8() { + Ok(len) => len as u16, + Err(_) => break, + }; + + if len > 0x80 { + let n_bytes = len - 0x80; + + if n_bytes == 1 { + len = match rdr.read_u8() { + Ok(len) => len as u16, + Err(_) => break, + }; + } else if n_bytes == 2 { + len = match rdr.read_u16::() { + Ok(len) => len, + Err(_) => break, + }; + } + } + + let mut name = Vec::with_capacity(len as usize); + + unsafe { + name.set_len(len as usize); + } + + if let Err(_) = rdr.read_exact(&mut name) { + break; + }; + + rdr.read_u8().unwrap(); // TODO: Don't discard the response tag + rdr.read_u8().unwrap(); // TODO: Don't discard the response lenght + 1 + + let digits = match rdr.read_u8() { + Ok(6) => OathDigits::Six, + Ok(8) => OathDigits::Eight, + Ok(_) => break, + Err(_) => break, + }; + + let value = match rdr.read_u32::() { + Ok(val) => val, + Err(_) => break, + }; + + results.push(LegacyOathCredential::new( + &String::from_utf8(name).unwrap(), + OathCode { + digits, + value, + valid_from: 0, + valid_to: 0x7FFFFFFFFFFFFFFF, + }, + )); + } + + Ok(results) + } +} + +#[derive(Debug, PartialEq)] +pub struct LegacyOathCredential { + pub name: String, + pub code: OathCode, + // TODO: Support this stuff + // pub oath_type: OathType, + // pub touch: bool, + // pub algo: OathAlgo, + // pub hidden: bool, + // pub steam: bool, +} + +impl LegacyOathCredential { + pub fn new(name: &str, code: OathCode) -> LegacyOathCredential { + LegacyOathCredential { + name: name.to_string(), + code: code, + // oath_type: oath_type, + // touch: touch, + // algo: algo, + // hidden: name.starts_with("_hidden:"), + // steam: name.starts_with("Steam:"), + } + } +} + +fn to_tlv(tag: Tag, value: &[u8]) -> Vec { + Tlv::new(TlvTag::try_from(tag as u8).unwrap(), value.to_vec()) + .unwrap() + .to_vec() +} + +fn time_challenge(timestamp: Option) -> Vec { + let mut buf = Vec::new(); + let ts = match timestamp { + Some(datetime) => { + datetime + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + / 30 + } + None => { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + / 30 + } + }; + buf.write_u64::(ts).unwrap(); + buf +} + +pub fn legacy_format_code(code: u32, digits: OathDigits) -> String { + let mut code_string = code.to_string(); + + match digits { + OathDigits::Six => { + if code_string.len() <= 6 { + format!("{:0>6}", code_string) + } else { + code_string.split_off(code_string.len() - 6) + } + } + OathDigits::Eight => { + if code_string.len() <= 8 { + format!("{:0>8}", code_string) + } else { + code_string.split_off(code_string.len() - 8) + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 6708496..ade7dc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause mod args; -mod lib_ykoath; +mod lib_ykoath2; +use lib_ykoath2::OathSession; use pcsc; use std::process; // use crate::args::Cli; @@ -17,13 +18,11 @@ fn main() { let readers = context.list_readers(&mut readers_buf).unwrap(); // Initialize a vector to track all our detected devices - let mut yubikeys: Vec = Vec::new(); + let mut yubikeys: Vec<&str> = Vec::new(); // Iterate over the connected USB devices for reader in readers { - yubikeys.push(lib_ykoath::YubiKey { - name: reader.to_str().unwrap(), - }); + yubikeys.push(reader.to_str().unwrap()); } // Show message if no YubiKey(s) @@ -34,9 +33,10 @@ fn main() { // Print device info for all the YubiKeys we detected for yubikey in yubikeys { - let device_label: String = yubikey.name.to_owned(); + let device_label: &str = yubikey; println!("Found device with label {}", device_label); - let codes = match yubikey.get_oath_codes() { + let session = OathSession::new(yubikey); + let codes = match session.get_oath_codes() { Ok(codes) => codes, Err(e) => { println!("ERROR {}", e); @@ -51,7 +51,7 @@ fn main() { // Enumerate the OATH codes for oath in codes { - let code = lib_ykoath::format_code(oath.code.value, oath.code.digits); + let code = lib_ykoath2::legacy_format_code(oath.code.value, oath.code.digits); let name_clone = oath.name.clone(); let mut label_vec: Vec<&str> = name_clone.split(":").collect(); let mut code_entry_label: String = String::from(label_vec.remove(0));