use std::path::PathBuf;
use std::process::Command;
use rand::Rng;
use tracing::{debug, info};
use crate::{CredError, Result};
pub const SLOT: u8 = 2;
pub const CHALLENGE_SIZE: usize = 32;
pub const RESPONSE_SIZE: usize = 20;
const CHALLENGE_FILE: &str = "challenge";
pub fn challenge_response(challenge: &[u8]) -> Result<[u8; RESPONSE_SIZE]> {
let challenge_hex = hex::encode(challenge);
#[cfg(windows)]
let output = try_ykchallenge(&challenge_hex)?;
#[cfg(not(windows))]
let output = try_ykman_calculate(&challenge_hex)
.or_else(|first| try_python_ykman_calculate(&challenge_hex).map_err(|_| first))?;
let decoded = hex::decode(output.trim())
.map_err(|e| CredError::YubiKey(format!("invalid hex response from YubiKey: {}", e)))?;
if decoded.len() != RESPONSE_SIZE {
return Err(CredError::YubiKey(format!(
"unexpected HMAC response length: {} (expected {})",
decoded.len(),
RESPONSE_SIZE
)));
}
let mut response = [0u8; RESPONSE_SIZE];
response.copy_from_slice(&decoded);
debug!("YubiKey challenge-response ok ({} bytes)", RESPONSE_SIZE);
Ok(response)
}
pub fn is_available() -> bool {
#[cfg(windows)]
{
Command::new("ykchallenge")
.arg("--info")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(windows))]
{
Command::new("ykman")
.arg("info")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
pub fn program_hmac_secret(secret: &[u8]) -> Result<()> {
if secret.len() != RESPONSE_SIZE {
return Err(CredError::YubiKey(format!(
"HMAC secret must be exactly {} bytes",
RESPONSE_SIZE
)));
}
let secret_hex = hex::encode(secret);
#[cfg(not(windows))]
{
try_ykman_program(&secret_hex)
.or_else(|first| try_python_ykman_program(&secret_hex).map_err(|_| first))?;
}
#[cfg(windows)]
{
let out = Command::new("ykman")
.args(["otp", "chalresp", &SLOT.to_string(), "--force", &secret_hex])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman program failed: {}",
stderr.trim()
)));
}
}
info!("programmed HMAC-SHA1 secret on slot {}", SLOT);
Ok(())
}
pub fn delete_slot() -> Result<()> {
#[cfg(not(windows))]
{
try_ykman_delete().or_else(|first| try_python_ykman_delete().map_err(|_| first))?;
}
#[cfg(windows)]
{
let out = Command::new("ykman")
.args(["otp", "delete", &SLOT.to_string(), "--force"])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman delete failed: {}",
stderr.trim()
)));
}
}
info!("deleted slot {} configuration", SLOT);
Ok(())
}
pub fn device_info() -> Result<String> {
#[cfg(not(windows))]
{
try_ykman_info().or_else(|first| try_python_ykman_info().map_err(|_| first))
}
#[cfg(windows)]
{
let out = Command::new("ykman")
.args(["info"])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman info failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
}
pub fn derive_master_key() -> Result<[u8; crate::crypto::KEY_SIZE]> {
let challenge = get_or_create_challenge()?;
let response = challenge_response(&challenge)?;
Ok(crate::crypto::derive_key_legacy(&response))
}
pub fn get_or_create_challenge() -> Result<[u8; CHALLENGE_SIZE]> {
let dir = config_dir();
let path = dir.join(CHALLENGE_FILE);
if path.exists() {
let data = std::fs::read(&path)
.map_err(|e| CredError::YubiKey(format!("read challenge {}: {}", path.display(), e)))?;
if data.len() != CHALLENGE_SIZE {
return Err(CredError::YubiKey(format!(
"challenge file {} has wrong size ({} bytes, expected {}); refusing to overwrite -- back up and delete manually if you know what you are doing",
path.display(),
data.len(),
CHALLENGE_SIZE
)));
}
let mut out = [0u8; CHALLENGE_SIZE];
out.copy_from_slice(&data);
return Ok(out);
}
let mut challenge = [0u8; CHALLENGE_SIZE];
rand::rng().fill(&mut challenge);
std::fs::create_dir_all(&dir)
.map_err(|e| CredError::YubiKey(format!("mkdir {}: {}", dir.display(), e)))?;
std::fs::write(&path, challenge)
.map_err(|e| CredError::YubiKey(format!("write challenge {}: {}", path.display(), e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| CredError::YubiKey(format!("chmod 600 {}: {}", path.display(), e)))?;
}
info!("generated new engram challenge at {}", path.display());
Ok(challenge)
}
pub fn software_hmac(secret: &[u8], challenge: &[u8; CHALLENGE_SIZE]) -> [u8; RESPONSE_SIZE] {
use hmac::{digest::FixedOutput, Hmac, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(challenge);
let out = mac.finalize_fixed();
let mut response = [0u8; RESPONSE_SIZE];
response.copy_from_slice(&out[..RESPONSE_SIZE]);
response
}
fn config_dir() -> PathBuf {
std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".config"))
.unwrap_or_else(|_| PathBuf::from("."))
})
.join("engram")
}
#[cfg(windows)]
fn try_ykchallenge(challenge_hex: &str) -> Result<String> {
let out = Command::new("ykchallenge")
.arg(challenge_hex)
.output()
.map_err(|e| {
CredError::YubiKey(format!(
"ykchallenge.exe not found on PATH (expected at ~/.local/bin/ykchallenge.exe): {}",
e
))
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykchallenge failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_ykman_calculate(challenge_hex: &str) -> Result<String> {
let out = Command::new("ykman")
.args(["otp", "calculate", &SLOT.to_string(), challenge_hex])
.output()
.map_err(|e| {
CredError::YubiKey(format!(
"ykman not found on PATH (install yubikey-manager): {}",
e
))
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman otp calculate failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_python_ykman_calculate(challenge_hex: &str) -> Result<String> {
let script = format!(
"import sys\nfrom ykman._cli.__main__ import main\nsys.argv = ['ykman', 'otp', 'calculate', '{}', '{}']\nmain()\n",
SLOT, challenge_hex
);
let out = Command::new("sudo")
.args(["python3", "-c", &script])
.output()
.map_err(|e| CredError::YubiKey(format!("sudo python3 ykman failed: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"python ykman calculate failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_ykman_program(secret_hex: &str) -> Result<String> {
let out = Command::new("ykman")
.args(["otp", "chalresp", &SLOT.to_string(), "--force", secret_hex])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman program failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_python_ykman_program(secret_hex: &str) -> Result<String> {
let script = format!(
"import sys\nfrom ykman._cli.__main__ import main\nsys.argv = ['ykman', 'otp', 'chalresp', '{}', '--force', '{}']\nmain()\n",
SLOT, secret_hex
);
let out = Command::new("sudo")
.args(["python3", "-c", &script])
.output()
.map_err(|e| CredError::YubiKey(format!("sudo python3 ykman failed: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"python ykman program failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_ykman_delete() -> Result<String> {
let out = Command::new("ykman")
.args(["otp", "delete", &SLOT.to_string(), "--force"])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman delete failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_python_ykman_delete() -> Result<String> {
let script = format!(
"import sys\nfrom ykman._cli.__main__ import main\nsys.argv = ['ykman', 'otp', 'delete', '{}', '--force']\nmain()\n",
SLOT
);
let out = Command::new("sudo")
.args(["python3", "-c", &script])
.output()
.map_err(|e| CredError::YubiKey(format!("sudo python3 ykman failed: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"python ykman delete failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_ykman_info() -> Result<String> {
let out = Command::new("ykman")
.args(["info"])
.output()
.map_err(|e| CredError::YubiKey(format!("ykman not found: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"ykman info failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(not(windows))]
fn try_python_ykman_info() -> Result<String> {
let script =
"import sys\nfrom ykman._cli.__main__ import main\nsys.argv = ['ykman', 'info']\nmain()\n";
let out = Command::new("sudo")
.args(["python3", "-c", script])
.output()
.map_err(|e| CredError::YubiKey(format!("sudo python3 ykman failed: {}", e)))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(CredError::YubiKey(format!(
"python ykman info failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn software_hmac_deterministic() {
let secret = b"test-secret-key";
let mut challenge = [0u8; CHALLENGE_SIZE];
challenge[0] = 1;
challenge[31] = 2;
let r1 = software_hmac(secret, &challenge);
let r2 = software_hmac(secret, &challenge);
assert_eq!(r1, r2);
}
#[test]
fn software_hmac_varies_with_secret() {
let mut challenge = [0u8; CHALLENGE_SIZE];
challenge[0] = 1;
let r1 = software_hmac(b"secret1", &challenge);
let r2 = software_hmac(b"secret2", &challenge);
assert_ne!(r1, r2);
}
#[test]
fn software_hmac_varies_with_challenge() {
let secret = b"shared-secret";
let mut c1 = [0u8; CHALLENGE_SIZE];
let mut c2 = [0u8; CHALLENGE_SIZE];
c1[0] = 1;
c2[0] = 2;
let r1 = software_hmac(secret, &c1);
let r2 = software_hmac(secret, &c2);
assert_ne!(r1, r2);
}
#[test]
fn software_hmac_matches_rfc2202_case_1() {
use hmac::{digest::FixedOutput, Hmac, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
let key = [0x0bu8; 20];
let mut mac = HmacSha1::new_from_slice(&key).unwrap();
mac.update(b"Hi There");
let expected = mac.finalize_fixed();
assert_eq!(
hex::encode(expected),
"b617318655057264e28bc0b6fb378c8ef146be00"
);
}
#[test]
fn challenge_size_constants_are_sane() {
assert_eq!(CHALLENGE_SIZE, 32);
assert_eq!(RESPONSE_SIZE, 20);
assert_eq!(SLOT, 2);
}
}