From ea0b091414f62625bfd53ff55a40a7141b3ac640 Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Sun, 23 Mar 2025 18:23:36 +0800 Subject: [PATCH] feat: 1.10.21, add hmac-encrypt, hmac-decrypt --- Cargo.lock | 63 ++++++++++++++++++++++++++-- Cargo.toml | 3 +- src/cmd_chall.rs | 28 +------------ src/cmd_hmacdecrypt.rs | 49 ++++++++++++++++++++++ src/cmd_hmacencrypt.rs | 49 ++++++++++++++++++++++ src/cmd_signjwtsoft.rs | 5 ++- src/hmacutil.rs | 95 +++++++++++++++++++++++++++++++++++++++++- src/main.rs | 4 ++ 8 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 src/cmd_hmacdecrypt.rs create mode 100644 src/cmd_hmacencrypt.rs diff --git a/Cargo.lock b/Cargo.lock index 1480845..4d6c06b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,30 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if 1.0.0", + "cipher 0.4.4", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d763ade72c376e5db452ec14f4c966fc907c036ef5cd2972a888e69fe2f811e5" +dependencies = [ + "aes 0.8.4", + "cipher 0.4.4", + "ghash", + "zeroize", +] + [[package]] name = "aho-corasick" version = "0.7.15" @@ -487,8 +511,9 @@ dependencies = [ [[package]] name = "card-cli" -version = "1.10.20" +version = "1.10.21" dependencies = [ + "aes-gcm-stream", "authenticator 0.3.1", "base64 0.21.7", "bech32", @@ -1339,6 +1364,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.24.0" @@ -2662,6 +2697,18 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3565,7 +3612,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "752ad8923d32fb5c117b8a9983266e294edc072401133720aa9af3959d63248d" dependencies = [ - "aes", + "aes 0.7.5", "authenticator 0.4.1", "base64 0.13.1", "bcrypt-pbkdf", @@ -4199,6 +4246,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -4752,7 +4809,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff64094218f27836b8683bd93fa7f257475d217449eb7629746fa8eaee068eee" dependencies = [ - "aes", + "aes 0.7.5", "bitflags 1.3.2", "block-modes", "hmac 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 8371d77..2cca125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "card-cli" -version = "1.10.20" +version = "1.10.21" authors = ["Hatter Jiang "] edition = "2018" @@ -52,6 +52,7 @@ der-parser = "9.0" sshcerts = "0.13" swift-rs = { version = "1.0.7", optional = true } regex = "1.4.6" +aes-gcm-stream = "0.2.4" #lazy_static = "1.4.0" #ssh-key = "0.4.0" #ctap-hid-fido2 = "2.1.3" diff --git a/src/cmd_chall.rs b/src/cmd_chall.rs index 2174079..52ee849 100644 --- a/src/cmd_chall.rs +++ b/src/cmd_chall.rs @@ -1,10 +1,6 @@ -use std::ops::Deref; - use clap::{App, Arg, ArgMatches, SubCommand}; use rust_util::util_clap::{Command, CommandError}; use rust_util::util_msg; -use yubico_manager::config::{Config, Mode, Slot}; -use yubico_manager::Yubico; use crate::hmacutil; @@ -29,28 +25,8 @@ impl Command for CommandImpl { if json_output { util_msg::set_logger_std_out(false); } let challenge_bytes = hmacutil::get_challenge_bytes(sub_arg_matches)?; - - let mut yubi = Yubico::new(); - let device = match yubi.find_yubikey() { - Ok(device) => device, - Err(_) => { - warning!("YubiKey not found"); - return Ok(Some(1)); - } - }; - - success!("Found key, Vendor ID: {:?}, Product ID: {:?}", device.vendor_id, device.product_id); - let config = Config::default() - .set_vendor_id(device.vendor_id) - .set_product_id(device.product_id) - .set_variable_size(true) - .set_mode(Mode::Sha1) - .set_slot(Slot::Slot2); - - // In HMAC Mode, the result will always be the SAME for the SAME provided challenge - let hmac_result = opt_result!(yubi.challenge_response_hmac(&challenge_bytes, config), "Challenge HMAC failed: {}"); - - hmacutil::output_hmac_result(sub_arg_matches, json_output, challenge_bytes, hmac_result.deref()); + let hmac_result = hmacutil::compute_yubikey_hmac(&challenge_bytes)?; + hmacutil::output_hmac_result(sub_arg_matches, json_output, challenge_bytes, &hmac_result); Ok(None) } diff --git a/src/cmd_hmacdecrypt.rs b/src/cmd_hmacdecrypt.rs new file mode 100644 index 0000000..05e356a --- /dev/null +++ b/src/cmd_hmacdecrypt.rs @@ -0,0 +1,49 @@ +use clap::{App, Arg, ArgMatches, SubCommand}; +use rust_util::util_clap::{Command, CommandError}; +use rust_util::util_msg; +use std::collections::BTreeMap; + +use crate::hmacutil; + +pub struct CommandImpl; + +impl Command for CommandImpl { + fn name(&self) -> &str { + "hmac-decrypt" + } + + fn subcommand<'a>(&self) -> App<'a, 'a> { + SubCommand::with_name(self.name()) + .about("Yubikey HMAC decrypt") + .arg( + Arg::with_name("ciphertext") + .long("ciphertext") + .takes_value(true) + .help("Ciphertext"), + ) + .arg(Arg::with_name("json").long("json").help("JSON output")) + } + + fn run(&self, _arg_matches: &ArgMatches, sub_arg_matches: &ArgMatches) -> CommandError { + let json_output = sub_arg_matches.is_present("json"); + if json_output { + util_msg::set_logger_std_out(false); + } + + let ciphertext = sub_arg_matches.value_of("ciphertext").unwrap(); + let plaintext = hmacutil::hmac_decrypt_to_string(&ciphertext)?; + + if json_output { + let mut json = BTreeMap::<&'_ str, String>::new(); + json.insert("plaintext", plaintext); + println!( + "{}", + serde_json::to_string_pretty(&json).expect("Convert to JSON failed!") + ); + } else { + success!("Plaintext: {}", plaintext); + } + + Ok(None) + } +} diff --git a/src/cmd_hmacencrypt.rs b/src/cmd_hmacencrypt.rs new file mode 100644 index 0000000..d5f1bb3 --- /dev/null +++ b/src/cmd_hmacencrypt.rs @@ -0,0 +1,49 @@ +use clap::{App, Arg, ArgMatches, SubCommand}; +use rust_util::util_clap::{Command, CommandError}; +use rust_util::util_msg; +use std::collections::BTreeMap; + +use crate::hmacutil; + +pub struct CommandImpl; + +impl Command for CommandImpl { + fn name(&self) -> &str { + "hmac-encrypt" + } + + fn subcommand<'a>(&self) -> App<'a, 'a> { + SubCommand::with_name(self.name()) + .about("Yubikey HMAC encrypt") + .arg( + Arg::with_name("plaintext") + .long("plaintext") + .takes_value(true) + .help("Plaintext"), + ) + .arg(Arg::with_name("json").long("json").help("JSON output")) + } + + fn run(&self, _arg_matches: &ArgMatches, sub_arg_matches: &ArgMatches) -> CommandError { + let json_output = sub_arg_matches.is_present("json"); + if json_output { + util_msg::set_logger_std_out(false); + } + + let plaintext = sub_arg_matches.value_of("plaintext").unwrap(); + let hmac_encrypt_ciphertext = hmacutil::hmac_encrypt_from_string(plaintext)?; + + if json_output { + let mut json = BTreeMap::<&'_ str, String>::new(); + json.insert("ciphertext", hmac_encrypt_ciphertext); + println!( + "{}", + serde_json::to_string_pretty(&json).expect("Convert to JSON failed!") + ); + } else { + success!("HMAC encrypt ciphertext: {}", hmac_encrypt_ciphertext); + } + + Ok(None) + } +} diff --git a/src/cmd_signjwtsoft.rs b/src/cmd_signjwtsoft.rs index b09a86a..405c042 100644 --- a/src/cmd_signjwtsoft.rs +++ b/src/cmd_signjwtsoft.rs @@ -11,7 +11,7 @@ use yubikey::piv::{sign_data, AlgorithmId, SlotId}; use yubikey::{Certificate, YubiKey}; use crate::ecdsautil::parse_ecdsa_to_rs; -use crate::{cmd_signjwt, digest, ecdsautil, pivutil, rsautil, util}; +use crate::{cmd_signjwt, digest, ecdsautil, hmacutil, pivutil, rsautil, util}; const SEPARATOR: &str = "."; @@ -45,6 +45,7 @@ impl Command for CommandImpl { sub_arg_matches.value_of("private-key"), "Private key PKCS#8 DER base64 encoded or PEM" ); + let private_key = hmacutil::try_hmac_decrypt_to_string(private_key)?; let key_id = sub_arg_matches.value_of("key-id"); let claims = sub_arg_matches.values_of("claims"); @@ -117,7 +118,7 @@ impl Command for CommandImpl { } } - let token_string = sign_jwt(private_key, header, &payload, &jwt_claims)?; + let token_string = sign_jwt(&private_key, header, &payload, &jwt_claims)?; debugging!("Singed JWT: {}", token_string); if json_output { json.insert("token", token_string.clone()); diff --git a/src/hmacutil.rs b/src/hmacutil.rs index 1f3945d..0855d89 100644 --- a/src/hmacutil.rs +++ b/src/hmacutil.rs @@ -1,9 +1,100 @@ -use std::collections::BTreeMap; - use clap::ArgMatches; use rust_util::XResult; +use std::collections::BTreeMap; +use std::ops::Deref; +use aes_gcm_stream::{Aes256GcmStreamDecryptor, Aes256GcmStreamEncryptor}; +use rand::random; +use yubico_manager::config::{Config, Mode, Slot}; use yubico_manager::hmacmode::HmacKey; use yubico_manager::sec::hmac_sha1; +use yubico_manager::Yubico; +use crate::digest::{copy_sha256, sha256, sha256_bytes}; +use crate::util::{base64_decode, base64_encode}; + +const HMAC_ENC_PREFIX: &str = "hmac_enc:"; + +// hmac encrypt, format: hmac_enc::: +pub fn hmac_encrypt_from_string(plaintext: &str) -> XResult { + hmac_encrypt(plaintext.as_bytes()) +} + +pub fn hmac_encrypt(plaintext: &[u8]) -> XResult { + let hmac_nonce: [u8; 8] = random(); + let aes_gcm_nonce: [u8; 16] = random(); + + let hmac_key = compute_yubikey_hmac(&hmac_nonce)?; + let key = copy_sha256(&sha256_bytes(&hmac_key))?; + + let mut encryptor = Aes256GcmStreamEncryptor::new(key, &aes_gcm_nonce); + let mut ciphertext = encryptor.update(plaintext); + let (final_part, tag) = encryptor.finalize(); + ciphertext.extend_from_slice(&final_part); + ciphertext.extend_from_slice(&tag); + + Ok(format!("{}{}:{}:{}", + HMAC_ENC_PREFIX, + hex::encode(&hmac_nonce), + hex::encode(&aes_gcm_nonce), + base64_encode(&ciphertext) + )) +} + +pub fn is_hmac_encrypted(ciphertext: &str) -> bool { + ciphertext.starts_with(HMAC_ENC_PREFIX) +} + +pub fn try_hmac_decrypt_to_string(ciphertext: &str) -> XResult { + if is_hmac_encrypted(ciphertext) { + hmac_decrypt_to_string(ciphertext) + } else { + Ok(ciphertext.to_string()) + } +} + +pub fn hmac_decrypt_to_string(ciphertext: &str) -> XResult { + let plaintext = hmac_decrypt(ciphertext)?; + Ok(String::from_utf8(plaintext)?) +} + +pub fn hmac_decrypt(ciphertext: &str) -> XResult> { + if !is_hmac_encrypted(ciphertext) { + return simple_error!("Invalid ciphertext: {}", ciphertext); + } + let parts = ciphertext.split(":").collect::>(); + let hmac_nonce = hex::decode(parts[1])?; + let aes_gcm_nonce = hex::decode(parts[2])?; + let ciphertext = base64_decode(parts[3])?; + + let hmac_key = compute_yubikey_hmac(&hmac_nonce)?; + let key = copy_sha256(&sha256_bytes(&hmac_key))?; + + let mut decryptor = Aes256GcmStreamDecryptor::new(key, &aes_gcm_nonce); + let mut plaintext = decryptor.update(&ciphertext); + let final_part = decryptor.finalize()?; + plaintext.extend_from_slice(&final_part); + + Ok(plaintext) +} + +pub fn compute_yubikey_hmac(challenge_bytes: &[u8]) -> XResult> { + let mut yubi = Yubico::new(); + let device = match yubi.find_yubikey() { + Ok(device) => device, + Err(_) => { + return simple_error!("YubiKey not found"); + } + }; + debugging!("Found key, Vendor ID: {:?}, Product ID: {:?}", device.vendor_id, device.product_id); + let config = Config::default() + .set_vendor_id(device.vendor_id) + .set_product_id(device.product_id) + .set_variable_size(true) + .set_mode(Mode::Sha1) + .set_slot(Slot::Slot2); + + let hmac_result = opt_result!(yubi.challenge_response_hmac(&challenge_bytes, config), "Challenge HMAC failed: {}"); + Ok(hmac_result.deref().to_vec()) +} pub fn get_challenge_bytes(sub_arg_matches: &ArgMatches) -> XResult> { let challenge_bytes: Vec = if let Some(challenge) = sub_arg_matches.value_of("challenge") { diff --git a/src/main.rs b/src/main.rs index f3e83d3..93cd1e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,8 @@ mod cmd_chall; mod cmd_challconfig; mod cmd_ecverify; mod cmd_hmac_sha1; +mod cmd_hmacencrypt; +mod cmd_hmacdecrypt; mod cmd_list; #[cfg(feature = "with-sequoia-openpgp")] mod cmd_pgp; @@ -101,6 +103,8 @@ fn inner_main() -> CommandError { Box::new(cmd_list::CommandImpl), Box::new(cmd_chall::CommandImpl), Box::new(cmd_hmac_sha1::CommandImpl), + Box::new(cmd_hmacencrypt::CommandImpl), + Box::new(cmd_hmacdecrypt::CommandImpl), Box::new(cmd_challconfig::CommandImpl), Box::new(cmd_rsaencrypt::CommandImpl), Box::new(cmd_rsadecrypt::CommandImpl),