use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum IdSource {
#[default]
Usb,
Ccid,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyEntry {
pub name: String,
pub serial: String,
#[serde(default)]
pub source: IdSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aaguid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Keyring {
#[serde(default)]
pub keys: Vec<KeyEntry>,
}
#[derive(Debug, Clone)]
pub struct ConnectedKey {
pub path: PathBuf,
pub serial: Option<String>,
pub label: String,
}
#[derive(Debug)]
pub enum KeyringError {
Io(io::Error),
Parse(String),
NoConfigDir,
DuplicateName(String),
DuplicateSerial {
serial: String,
existing_name: String,
},
InvalidName(String),
}
impl fmt::Display for KeyringError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyringError::Io(e) => write!(f, "keyring I/O error: {}", e),
KeyringError::Parse(s) => write!(f, "keyring config parse error: {}", s),
KeyringError::NoConfigDir => {
write!(
f,
"could not determine config dir (set HOME or XDG_CONFIG_HOME)"
)
}
KeyringError::DuplicateName(n) => write!(f, "a key named '{}' already exists", n),
KeyringError::DuplicateSerial {
serial,
existing_name,
} => {
write!(f, "serial {} is already named '{}'", serial, existing_name)
}
KeyringError::InvalidName(n) => {
write!(f, "invalid key name '{}': use 1-64 chars of [a-z0-9_-]", n)
}
}
}
}
impl std::error::Error for KeyringError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
KeyringError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for KeyringError {
fn from(e: io::Error) -> Self {
KeyringError::Io(e)
}
}
#[derive(Debug)]
pub enum ResolveError {
UnknownName { name: String, known: Vec<String> },
NotConnected { name: String, serial: String },
}
impl fmt::Display for ResolveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ResolveError::UnknownName { name, known } if known.is_empty() => write!(
f,
"no key named '{}': no named keys yet — add one with `keyroostctl key-name add`",
name
),
ResolveError::UnknownName { name, known } => {
write!(
f,
"no key named '{}'. Known names: {}",
name,
known.join(", ")
)
}
ResolveError::NotConnected { name, serial } => {
write!(f, "key '{}' (serial {}) is not connected", name, serial)
}
}
}
}
impl std::error::Error for ResolveError {}
pub fn validate_name(name: &str) -> Result<(), KeyringError> {
let ok = !name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
if ok {
Ok(())
} else {
let mut shown = name.to_string();
strip_control_chars(&mut shown);
Err(KeyringError::InvalidName(shown))
}
}
fn strip_control_chars(s: &mut String) {
fn spoofing(c: char) -> bool {
c.is_control()
|| matches!(c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' | '\u{00AD}' | '\u{061C}' )
}
if s.chars().any(spoofing) {
s.retain(|c| !spoofing(c));
}
}
pub fn config_path() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("keyroost").join("keys.json"));
}
}
let home = std::env::var_os("HOME")?;
if home.is_empty() {
return None;
}
Some(
PathBuf::from(home)
.join(".config")
.join("keyroost")
.join("keys.json"),
)
}
impl Keyring {
pub fn load_default() -> Result<Keyring, KeyringError> {
let path = config_path().ok_or(KeyringError::NoConfigDir)?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Keyring, KeyringError> {
match fs::read_to_string(path) {
Ok(s) => {
let mut ring: Keyring =
serde_json::from_str(&s).map_err(|e| KeyringError::Parse(e.to_string()))?;
for entry in &mut ring.keys {
strip_control_chars(&mut entry.name);
strip_control_chars(&mut entry.serial);
for field in [&mut entry.vendor, &mut entry.aaguid, &mut entry.note]
.into_iter()
.flatten()
{
strip_control_chars(field);
}
}
Ok(ring)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Keyring::default()),
Err(e) => Err(KeyringError::Io(e)),
}
}
pub fn save_default(&self) -> Result<PathBuf, KeyringError> {
let path = config_path().ok_or(KeyringError::NoConfigDir)?;
self.save_to(&path)?;
Ok(path)
}
pub fn save_to(&self, path: &Path) -> Result<(), KeyringError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(parent) {
let mut perms = meta.permissions();
if perms.mode() & 0o077 != 0 && parent.ends_with("keyroost") {
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
}
}
let json =
serde_json::to_string_pretty(self).map_err(|e| KeyringError::Parse(e.to_string()))?;
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
{
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write;
let mut f = opts.open(&tmp)?;
f.write_all(json.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
}
fs::rename(&tmp, path)?;
Ok(())
}
pub fn add(&mut self, mut entry: KeyEntry) -> Result<(), KeyringError> {
validate_name(&entry.name)?;
strip_control_chars(&mut entry.serial);
for field in [&mut entry.vendor, &mut entry.aaguid, &mut entry.note]
.into_iter()
.flatten()
{
strip_control_chars(field);
}
if self.keys.iter().any(|k| k.name == entry.name) {
return Err(KeyringError::DuplicateName(entry.name));
}
if let Some(existing) = self.keys.iter().find(|k| k.serial == entry.serial) {
return Err(KeyringError::DuplicateSerial {
serial: entry.serial.clone(),
existing_name: existing.name.clone(),
});
}
self.keys.push(entry);
Ok(())
}
pub fn remove(&mut self, name: &str) -> bool {
let before = self.keys.len();
self.keys.retain(|k| k.name != name);
self.keys.len() != before
}
pub fn by_name(&self, name: &str) -> Option<&KeyEntry> {
self.keys.iter().find(|k| k.name == name)
}
pub fn by_serial(&self, serial: &str) -> Option<&KeyEntry> {
self.keys.iter().find(|k| k.serial == serial)
}
pub fn name_for(&self, serial: Option<&str>) -> Option<&str> {
let serial = serial?;
self.by_serial(serial).map(|k| k.name.as_str())
}
pub fn resolve<'a>(
&self,
name: &str,
connected: &'a [ConnectedKey],
) -> Result<&'a ConnectedKey, ResolveError> {
let entry = self
.by_name(name)
.ok_or_else(|| ResolveError::UnknownName {
name: name.to_string(),
known: self.keys.iter().map(|k| k.name.clone()).collect(),
})?;
connected
.iter()
.find(|d| d.serial.as_deref() == Some(entry.serial.as_str()))
.ok_or_else(|| ResolveError::NotConnected {
name: name.to_string(),
serial: entry.serial.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, serial: &str) -> KeyEntry {
KeyEntry {
name: name.into(),
serial: serial.into(),
source: IdSource::Usb,
vendor: None,
aaguid: None,
note: None,
}
}
#[test]
fn name_validation() {
assert!(validate_name("signing-yubikey").is_ok());
assert!(validate_name("test_solo2").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("Bad Name").is_err());
assert!(validate_name("UPPER").is_err());
}
#[test]
fn add_rejects_duplicates() {
let mut k = Keyring::default();
k.add(entry("a", "111")).unwrap();
assert!(matches!(
k.add(entry("a", "222")),
Err(KeyringError::DuplicateName(_))
));
assert!(matches!(
k.add(entry("b", "111")),
Err(KeyringError::DuplicateSerial { .. })
));
k.add(entry("b", "222")).unwrap();
assert_eq!(k.keys.len(), 2);
}
#[test]
fn remove_and_lookup() {
let mut k = Keyring::default();
k.add(entry("solo", "ABC")).unwrap();
assert_eq!(k.by_name("solo").map(|e| e.serial.as_str()), Some("ABC"));
assert_eq!(k.name_for(Some("ABC")), Some("solo"));
assert_eq!(k.name_for(Some("XYZ")), None);
assert_eq!(k.name_for(None), None);
assert!(k.remove("solo"));
assert!(!k.remove("solo"));
}
#[test]
fn resolve_matches_by_serial() {
let mut k = Keyring::default();
k.add(entry("solo", "ABC")).unwrap();
let connected = vec![
ConnectedKey {
path: "/dev/hidraw5".into(),
serial: Some("ABC".into()),
label: "Solo 2".into(),
},
ConnectedKey {
path: "/dev/hidraw9".into(),
serial: None,
label: "YubiKey".into(),
},
];
assert_eq!(
k.resolve("solo", &connected).unwrap().path,
PathBuf::from("/dev/hidraw5")
);
assert!(matches!(
k.resolve("nope", &connected),
Err(ResolveError::UnknownName { .. })
));
assert!(matches!(
k.resolve("solo", &[]),
Err(ResolveError::NotConnected { .. })
));
}
#[test]
fn json_round_trip_and_defaults() {
let mut k = Keyring::default();
k.add(KeyEntry {
name: "signing-yubikey".into(),
serial: "37806840".into(),
source: IdSource::Ccid,
vendor: Some("yubico".into()),
aaguid: None,
note: Some("daily".into()),
})
.unwrap();
let json = serde_json::to_string_pretty(&k).unwrap();
let back: Keyring = serde_json::from_str(&json).unwrap();
assert_eq!(back.keys[0].name, "signing-yubikey");
assert_eq!(back.keys[0].source, IdSource::Ccid);
assert_eq!(back.keys[0].vendor.as_deref(), Some("yubico"));
let minimal: Keyring =
serde_json::from_str(r#"{"keys":[{"name":"x","serial":"S1"}]}"#).unwrap();
assert_eq!(minimal.keys[0].source, IdSource::Usb);
}
#[test]
fn load_missing_is_empty() {
let k = Keyring::load_from(Path::new("/nonexistent/keyroost/keys.json")).unwrap();
assert!(k.keys.is_empty());
}
#[test]
fn load_sanitizes_invalid_names_and_strips_control_chars() {
let dir = std::env::temp_dir().join(format!("keyroost-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("keys.json");
std::fs::write(
&path,
"{\"keys\":[{\"name\":\"evil\\u001b[31m\",\"serial\":\"S1\"},{\"name\":\"good\",\"serial\":\"S2\"}]}",
)
.unwrap();
let k = Keyring::load_from(&path).unwrap();
assert_eq!(k.keys[0].name, "evil[31m");
assert_eq!(k.keys[1].name, "good");
std::fs::write(
&path,
"{\"keys\":[{\"name\":\"ok\",\"serial\":\"S\\u001b[2J1\",\"note\":\"a\\u0007b\"}]}",
)
.unwrap();
let k = Keyring::load_from(&path).unwrap();
assert_eq!(k.keys[0].serial, "S[2J1");
assert_eq!(k.keys[0].note.as_deref(), Some("ab"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn add_sanitizes_device_supplied_fields() {
let mut k = Keyring::default();
k.add(KeyEntry {
name: "weird".into(),
serial: "AB\u{1b}[31mCD".into(),
source: IdSource::Usb,
vendor: None,
aaguid: None,
note: Some("x\u{7}y".into()),
})
.unwrap();
assert_eq!(k.keys[0].serial, "AB[31mCD");
assert_eq!(k.keys[0].note.as_deref(), Some("xy"));
let mut k2 = Keyring::default();
k2.add(KeyEntry {
name: "bidi".into(),
serial: "S\u{202E}9\u{200B}9".into(),
source: IdSource::Usb,
vendor: None,
aaguid: None,
note: None,
})
.unwrap();
assert_eq!(k2.keys[0].serial, "S99");
}
#[cfg(unix)]
#[test]
fn save_creates_owner_only_file() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("keyroost-perm-{}", std::process::id()));
let path = dir.join("keys.json");
let mut k = Keyring::default();
k.add(KeyEntry {
name: "test-key".into(),
serial: "S1".into(),
source: IdSource::Usb,
vendor: None,
aaguid: None,
note: None,
})
.unwrap();
k.save_to(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "keys.json must be owner-only");
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(&dir).ok();
}
}