use age::cli_common::UiCallbacks;
use age::plugin::{Identity, IdentityPluginV1, Recipient, RecipientPluginV1};
use anyhow::{Context, Result, anyhow, bail};
use std::str::FromStr;
const PLUGIN: &str = "age-plugin-yubikey";
const PLUGIN_NAME: &str = "yubikey";
pub fn recipient(text: &str) -> Result<Box<dyn age::Recipient + Send>> {
let parsed = Recipient::from_str(text.trim())
.map_err(|e| anyhow!("{text} is not an age recipient: {e}"))?;
if parsed.plugin() != PLUGIN_NAME {
bail!(
"{} is a {} recipient, not a YubiKey one",
text,
parsed.plugin()
);
}
let name = parsed.plugin().to_string();
let plugin = RecipientPluginV1::new(&name, &[parsed], &[], UiCallbacks)
.context("age-plugin-yubikey is not on PATH")?;
Ok(Box::new(plugin))
}
pub fn identity() -> Result<Box<dyn age::Identity>> {
let found = Identity::default_for_plugin(PLUGIN_NAME)
.map_err(|e| anyhow!("no YubiKey identity: {e}"))?;
let plugin = IdentityPluginV1::new(PLUGIN_NAME, &[found], UiCallbacks)
.context("age-plugin-yubikey is not on PATH")?;
Ok(Box::new(plugin))
}
pub fn offer_install() -> Result<()> {
if installed() {
return Ok(());
}
let brew = std::process::Command::new("brew").arg("--version").output();
if brew.is_err() || !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
bail!("{PLUGIN} is not on PATH, install it with `brew install {PLUGIN}`");
}
eprintln!("passbox needs {PLUGIN} to talk to the token, and it is not installed.");
eprintln!("It is the age plugin that holds the key in the YubiKey's secure element.");
eprint!("Install it with Homebrew now? [y/N] ");
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("could not read the answer")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
bail!("install it with `brew install {PLUGIN}` and run this again");
}
let status = std::process::Command::new("brew")
.args(["install", PLUGIN])
.status()
.context("could not run brew")?;
if !status.success() || !installed() {
bail!("brew could not install {PLUGIN}");
}
Ok(())
}
fn piv_info() -> Option<String> {
let out = std::process::Command::new("ykman")
.args(["piv", "info"])
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn piv_slots_in_use() -> Option<Vec<String>> {
Some(
piv_info()?
.lines()
.filter(|l| l.trim_start().starts_with("Slot "))
.map(|l| l.trim().to_string())
.collect(),
)
}
pub fn management_key_is_supported() -> Result<()> {
match piv_info() {
None => Ok(()),
Some(info) => check_management_key(&info),
}
}
fn check_management_key(info: &str) -> Result<()> {
let Some(algorithm) = info
.lines()
.find_map(|l| l.trim().strip_prefix("Management key algorithm:"))
else {
return Ok(());
};
let algorithm = algorithm.trim();
if algorithm.eq_ignore_ascii_case("TDES") {
return Ok(());
}
const DEFAULT_TDES: &str = "010203040506070801020304050607080102030405060708";
bail!(
"the PIV management key is {algorithm}, and {PLUGIN} can only use TDES.\n\
Firmware 5.7 sets {algorithm} on a new token and on a factory reset, so this\n\
is the state a new YubiKey arrives in. Change it, which touches the PIV\n\
applet only and leaves OpenPGP keys alone:\n\
\n ykman piv access change-management-key -a tdes -n {DEFAULT_TDES}\n"
)
}
pub fn scdaemon_is_running() -> bool {
std::process::Command::new("pgrep")
.args(["-x", "scdaemon"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn offer_to_stop_scdaemon() -> Result<()> {
if !scdaemon_is_running() {
return Ok(());
}
eprintln!("GPG's scdaemon is running and is holding the smart card.");
eprintln!("It takes the card back mid-generation, and the plugin fails with");
eprintln!("an authentication error that names neither the cause nor the fix.");
eprintln!("Stopping it frees the card. GPG starts it again on its next use,");
eprintln!("and your OpenPGP keys and PIN are untouched either way.");
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
bail!("stop it with `gpgconf --kill scdaemon` and run this again");
}
eprint!("Stop scdaemon for this step? [y/N] ");
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("could not read the answer")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
bail!("stop it with `gpgconf --kill scdaemon` and run this again");
}
let status = std::process::Command::new("gpgconf")
.args(["--kill", "scdaemon"])
.status()
.context("could not run gpgconf")?;
if !status.success() || scdaemon_is_running() {
bail!("scdaemon is still running, stop it with `gpgconf --kill scdaemon`");
}
Ok(())
}
#[derive(Debug, PartialEq)]
pub struct Plugged {
pub recipient: String,
pub serial: Option<String>,
}
pub fn recipients() -> Result<Vec<Plugged>> {
let out = std::process::Command::new(PLUGIN)
.arg("--list")
.output()
.context("could not run age-plugin-yubikey")?;
if !out.status.success() {
bail!(
"age-plugin-yubikey --list failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(parse_list(&String::from_utf8_lossy(&out.stdout)))
}
fn parse_list(out: &str) -> Vec<Plugged> {
let mut found: Vec<Plugged> = Vec::new();
let mut serial = None;
for line in out.lines() {
if let Some(at) = line.find("Serial:") {
serial = Some(line[at..].trim().to_string());
}
let Some(recipient) = line
.split_whitespace()
.find(|w| w.starts_with("age1yubikey1"))
else {
continue;
};
if !found.iter().any(|p| p.recipient == recipient) {
found.push(Plugged {
recipient: recipient.to_string(),
serial: serial.clone(),
});
}
}
found
}
pub fn not_added(plugged: Vec<Plugged>, stored: &[String]) -> Vec<Plugged> {
plugged
.into_iter()
.filter(|p| !stored.contains(&p.recipient))
.collect()
}
pub fn choose(
mut found: Vec<Plugged>,
seen: usize,
terminal: bool,
answer: impl FnOnce() -> Result<String>,
) -> Result<String> {
if found.len() <= 1 {
let token = found.pop().with_context(|| {
if seen == 0 {
"no YubiKey recipient found, is the token plugged in?".to_string()
} else {
format!(
"the {seen} plugged token(s) already open this store, \
plug in only the new one to set it up"
)
}
})?;
eprintln!("using {}", token.recipient);
return Ok(token.recipient);
}
if !terminal {
bail!("several new tokens are plugged in, pass the recipient to `passbox yubikey-add`");
}
eprintln!("Several tokens are plugged in and new to this store:");
for (i, token) in found.iter().enumerate() {
let serial = token.serial.as_deref().unwrap_or("serial unknown");
eprintln!(" {}) {serial} {}", i + 1, token.recipient);
}
eprint!("Which? [1-{}] ", found.len());
let answer = answer()?;
let pick = answer
.trim()
.parse::<usize>()
.ok()
.filter(|n| (1..=found.len()).contains(n))
.with_context(|| format!("expected 1 to {}, got {}", found.len(), answer.trim()))?;
Ok(found.swap_remove(pick - 1).recipient)
}
pub fn generate() -> Result<()> {
let status = std::process::Command::new(PLUGIN)
.arg("--generate")
.status()
.context("could not run age-plugin-yubikey --generate")?;
if !status.success() {
bail!("age-plugin-yubikey --generate did not finish");
}
Ok(())
}
pub fn installed() -> bool {
std::process::Command::new(PLUGIN)
.arg("--version")
.output()
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_x25519_recipient_is_refused() {
let err = match recipient("age1yyc8rqn67hcupyasv6d0tdpghndh2nnkk599emt3z8cqzkhr5ccqg4xglx")
{
Ok(_) => panic!("an x25519 recipient was accepted as a YubiKey one"),
Err(e) => e.to_string(),
};
assert!(err.contains("not an age recipient"), "{err}");
}
const AES_INFO: &str = "PIV version: 5.7.4\nPIN tries remaining: 3/3\nManagement key algorithm: AES192\nManagement key is stored on the YubiKey, protected by PIN.\n";
#[test]
fn an_aes_management_key_is_refused_with_the_fix() {
let err = check_management_key(AES_INFO).unwrap_err().to_string();
assert!(err.contains("AES192"), "{err}");
assert!(err.contains("change-management-key -a tdes"), "{err}");
}
#[test]
fn a_tdes_management_key_passes() {
let info = AES_INFO.replace("AES192", "TDES");
assert!(check_management_key(&info).is_ok());
}
#[test]
fn an_unreadable_applet_does_not_block_the_plugin() {
assert!(check_management_key("").is_ok());
assert!(check_management_key("PIV version: 5.7.4\n").is_ok());
}
#[test]
fn a_real_yubikey_recipient_is_accepted() {
const REAL: &str =
"age1yubikey1q0vlphw7w6z47hqzfl8x950zv7hy66aed5amgcvyx8cu4rqcfh7fswwqa7n";
let parsed = Recipient::from_str(REAL).expect("a valid yubikey recipient");
assert_eq!(parsed.plugin(), PLUGIN_NAME);
}
const LIST: &str = "# Serial: 111, Slot: 1\n# Name: age identity 1\n# Created: Mon, 1 Jan 2024\n# PIN policy: Once\n# Touch policy: Always\nage1yubikey1aaa\n\n# Serial: 222, Slot: 1\n# Name: age identity 2\nage1yubikey1bbb\n";
#[test]
fn list_pairs_each_recipient_with_its_serial() {
let found = parse_list(LIST);
assert_eq!(found.len(), 2);
assert_eq!(found[0].recipient, "age1yubikey1aaa");
assert_eq!(found[0].serial.as_deref(), Some("Serial: 111, Slot: 1"));
assert_eq!(found[1].serial.as_deref(), Some("Serial: 222, Slot: 1"));
assert!(parse_list("").is_empty());
}
#[test]
fn stored_recipients_are_filtered_out() {
let stored = |s: &[&str]| s.iter().map(|r| r.to_string()).collect::<Vec<_>>();
let none = not_added(
parse_list(LIST),
&stored(&["age1yubikey1aaa", "age1yubikey1bbb"]),
);
assert!(none.is_empty());
let one = not_added(parse_list(LIST), &stored(&["age1yubikey1aaa"]));
assert_eq!(one.len(), 1);
assert_eq!(one[0].recipient, "age1yubikey1bbb");
let many = not_added(parse_list(LIST), &stored(&[]));
assert_eq!(many.len(), 2);
}
#[test]
fn list_skips_garbage_and_repeats() {
assert!(parse_list("error: no smart card\n\x00\u{fffd}age1yubi\n").is_empty());
let twice = parse_list("age1yubikey1aaa\n# Serial: 1, Slot: 1\nage1yubikey1aaa\n");
assert_eq!(
twice,
vec![Plugged {
recipient: "age1yubikey1aaa".into(),
serial: None
}]
);
}
fn plugged(names: &[&str]) -> Vec<Plugged> {
names
.iter()
.map(|r| Plugged {
recipient: r.to_string(),
serial: None,
})
.collect()
}
fn never() -> Result<String> {
panic!("one candidate or none must not ask")
}
#[test]
fn choose_takes_the_one_new_token_without_asking() {
let got = choose(plugged(&["age1yubikey1bbb"]), 2, false, never).unwrap();
assert_eq!(got, "age1yubikey1bbb");
}
#[test]
fn choose_with_nothing_names_why() {
let none = choose(Vec::new(), 0, true, never).unwrap_err().to_string();
assert!(none.contains("is the token plugged in"), "{none}");
let stored = choose(Vec::new(), 2, true, never).unwrap_err().to_string();
assert!(
stored.contains("2 plugged token(s) already open"),
"{stored}"
);
}
#[test]
fn choose_among_several_asks_only_on_a_terminal() {
let two = || plugged(&["age1yubikey1aaa", "age1yubikey1bbb"]);
let headless = choose(two(), 2, false, never).unwrap_err().to_string();
assert!(headless.contains("pass the recipient"), "{headless}");
let got = choose(two(), 2, true, || Ok("2\n".into())).unwrap();
assert_eq!(got, "age1yubikey1bbb");
for bad in ["0\n", "3\n", "b\n", ""] {
let err = choose(two(), 2, true, || Ok(bad.into())).unwrap_err();
assert!(
err.to_string().contains("expected 1 to 2"),
"{bad:?}: {err}"
);
}
}
#[test]
fn nonsense_is_refused() {
assert!(recipient("hello").err().is_some());
assert!(recipient("").err().is_some());
}
}