1use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum IdSource {
32 #[default]
34 Usb,
35 Ccid,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct KeyEntry {
43 pub name: String,
44 pub serial: String,
45 #[serde(default)]
46 pub source: IdSource,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub vendor: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub aaguid: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub note: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct Keyring {
58 #[serde(default)]
59 pub keys: Vec<KeyEntry>,
60}
61
62#[derive(Debug, Clone)]
66pub struct ConnectedKey {
67 pub path: PathBuf,
68 pub serial: Option<String>,
69 pub label: String,
70}
71
72#[derive(Debug)]
74pub enum KeyringError {
75 Io(io::Error),
76 Parse(String),
77 NoConfigDir,
78 DuplicateName(String),
79 DuplicateSerial {
80 serial: String,
81 existing_name: String,
82 },
83 InvalidName(String),
84}
85
86impl fmt::Display for KeyringError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 KeyringError::Io(e) => write!(f, "keyring I/O error: {}", e),
90 KeyringError::Parse(s) => write!(f, "keyring config parse error: {}", s),
91 KeyringError::NoConfigDir => {
92 write!(
93 f,
94 "could not determine config dir (set HOME or XDG_CONFIG_HOME)"
95 )
96 }
97 KeyringError::DuplicateName(n) => write!(f, "a key named '{}' already exists", n),
98 KeyringError::DuplicateSerial {
99 serial,
100 existing_name,
101 } => {
102 write!(f, "serial {} is already named '{}'", serial, existing_name)
103 }
104 KeyringError::InvalidName(n) => {
105 write!(f, "invalid key name '{}': use 1-64 chars of [a-z0-9_-]", n)
106 }
107 }
108 }
109}
110
111impl std::error::Error for KeyringError {
112 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
113 match self {
114 KeyringError::Io(e) => Some(e),
115 _ => None,
116 }
117 }
118}
119
120impl From<io::Error> for KeyringError {
121 fn from(e: io::Error) -> Self {
122 KeyringError::Io(e)
123 }
124}
125
126#[derive(Debug)]
128pub enum ResolveError {
129 UnknownName { name: String, known: Vec<String> },
130 NotConnected { name: String, serial: String },
131}
132
133impl fmt::Display for ResolveError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 match self {
136 ResolveError::UnknownName { name, known } if known.is_empty() => write!(
137 f,
138 "no key named '{}': no named keys yet — add one with `keyroostctl key-name add`",
139 name
140 ),
141 ResolveError::UnknownName { name, known } => {
142 write!(
143 f,
144 "no key named '{}'. Known names: {}",
145 name,
146 known.join(", ")
147 )
148 }
149 ResolveError::NotConnected { name, serial } => {
150 write!(f, "key '{}' (serial {}) is not connected", name, serial)
151 }
152 }
153 }
154}
155
156impl std::error::Error for ResolveError {}
157
158pub fn validate_name(name: &str) -> Result<(), KeyringError> {
160 let ok = !name.is_empty()
161 && name.len() <= 64
162 && name
163 .chars()
164 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
165 if ok {
166 Ok(())
167 } else {
168 let mut shown = name.to_string();
172 strip_control_chars(&mut shown);
173 Err(KeyringError::InvalidName(shown))
174 }
175}
176
177fn strip_control_chars(s: &mut String) {
183 fn spoofing(c: char) -> bool {
184 c.is_control()
185 || matches!(c,
186 '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' | '\u{00AD}' | '\u{061C}' )
193 }
194 if s.chars().any(spoofing) {
195 s.retain(|c| !spoofing(c));
196 }
197}
198
199pub fn config_path() -> Option<PathBuf> {
202 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
203 if !xdg.is_empty() {
204 return Some(PathBuf::from(xdg).join("keyroost").join("keys.json"));
205 }
206 }
207 let home = std::env::var_os("HOME")?;
208 if home.is_empty() {
209 return None;
210 }
211 Some(
212 PathBuf::from(home)
213 .join(".config")
214 .join("keyroost")
215 .join("keys.json"),
216 )
217}
218
219impl Keyring {
220 pub fn load_default() -> Result<Keyring, KeyringError> {
223 let path = config_path().ok_or(KeyringError::NoConfigDir)?;
224 Self::load_from(&path)
225 }
226
227 pub fn load_from(path: &Path) -> Result<Keyring, KeyringError> {
229 match fs::read_to_string(path) {
230 Ok(s) => {
231 let mut ring: Keyring =
232 serde_json::from_str(&s).map_err(|e| KeyringError::Parse(e.to_string()))?;
233 for entry in &mut ring.keys {
241 strip_control_chars(&mut entry.name);
242 strip_control_chars(&mut entry.serial);
243 for field in [&mut entry.vendor, &mut entry.aaguid, &mut entry.note]
244 .into_iter()
245 .flatten()
246 {
247 strip_control_chars(field);
248 }
249 }
250 Ok(ring)
251 }
252 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Keyring::default()),
253 Err(e) => Err(KeyringError::Io(e)),
254 }
255 }
256
257 pub fn save_default(&self) -> Result<PathBuf, KeyringError> {
260 let path = config_path().ok_or(KeyringError::NoConfigDir)?;
261 self.save_to(&path)?;
262 Ok(path)
263 }
264
265 pub fn save_to(&self, path: &Path) -> Result<(), KeyringError> {
267 if let Some(parent) = path.parent() {
268 fs::create_dir_all(parent)?;
269 #[cfg(unix)]
273 {
274 use std::os::unix::fs::PermissionsExt;
275 if let Ok(meta) = fs::metadata(parent) {
276 let mut perms = meta.permissions();
277 if perms.mode() & 0o077 != 0 && parent.ends_with("keyroost") {
278 perms.set_mode(0o700);
279 let _ = fs::set_permissions(parent, perms);
280 }
281 }
282 }
283 }
284 let json =
285 serde_json::to_string_pretty(self).map_err(|e| KeyringError::Parse(e.to_string()))?;
286 let tmp = path.with_extension("json.tmp");
291 let _ = fs::remove_file(&tmp);
296 {
297 let mut opts = fs::OpenOptions::new();
298 opts.write(true).create_new(true);
299 #[cfg(unix)]
300 {
301 use std::os::unix::fs::OpenOptionsExt;
302 opts.mode(0o600);
303 }
304 use std::io::Write;
305 let mut f = opts.open(&tmp)?;
306 f.write_all(json.as_bytes())?;
307 f.write_all(b"\n")?;
308 f.sync_all()?;
309 }
310 fs::rename(&tmp, path)?;
311 Ok(())
312 }
313
314 pub fn add(&mut self, mut entry: KeyEntry) -> Result<(), KeyringError> {
316 validate_name(&entry.name)?;
317 strip_control_chars(&mut entry.serial);
323 for field in [&mut entry.vendor, &mut entry.aaguid, &mut entry.note]
324 .into_iter()
325 .flatten()
326 {
327 strip_control_chars(field);
328 }
329 if self.keys.iter().any(|k| k.name == entry.name) {
330 return Err(KeyringError::DuplicateName(entry.name));
331 }
332 if let Some(existing) = self.keys.iter().find(|k| k.serial == entry.serial) {
333 return Err(KeyringError::DuplicateSerial {
334 serial: entry.serial.clone(),
335 existing_name: existing.name.clone(),
336 });
337 }
338 self.keys.push(entry);
339 Ok(())
340 }
341
342 pub fn remove(&mut self, name: &str) -> bool {
344 let before = self.keys.len();
345 self.keys.retain(|k| k.name != name);
346 self.keys.len() != before
347 }
348
349 pub fn by_name(&self, name: &str) -> Option<&KeyEntry> {
350 self.keys.iter().find(|k| k.name == name)
351 }
352
353 pub fn by_serial(&self, serial: &str) -> Option<&KeyEntry> {
354 self.keys.iter().find(|k| k.serial == serial)
355 }
356
357 pub fn name_for(&self, serial: Option<&str>) -> Option<&str> {
360 let serial = serial?;
361 self.by_serial(serial).map(|k| k.name.as_str())
362 }
363
364 pub fn resolve<'a>(
366 &self,
367 name: &str,
368 connected: &'a [ConnectedKey],
369 ) -> Result<&'a ConnectedKey, ResolveError> {
370 let entry = self
371 .by_name(name)
372 .ok_or_else(|| ResolveError::UnknownName {
373 name: name.to_string(),
374 known: self.keys.iter().map(|k| k.name.clone()).collect(),
375 })?;
376 connected
377 .iter()
378 .find(|d| d.serial.as_deref() == Some(entry.serial.as_str()))
379 .ok_or_else(|| ResolveError::NotConnected {
380 name: name.to_string(),
381 serial: entry.serial.clone(),
382 })
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn entry(name: &str, serial: &str) -> KeyEntry {
391 KeyEntry {
392 name: name.into(),
393 serial: serial.into(),
394 source: IdSource::Usb,
395 vendor: None,
396 aaguid: None,
397 note: None,
398 }
399 }
400
401 #[test]
402 fn name_validation() {
403 assert!(validate_name("signing-yubikey").is_ok());
404 assert!(validate_name("test_solo2").is_ok());
405 assert!(validate_name("").is_err());
406 assert!(validate_name("Bad Name").is_err());
407 assert!(validate_name("UPPER").is_err());
408 }
409
410 #[test]
411 fn add_rejects_duplicates() {
412 let mut k = Keyring::default();
413 k.add(entry("a", "111")).unwrap();
414 assert!(matches!(
415 k.add(entry("a", "222")),
416 Err(KeyringError::DuplicateName(_))
417 ));
418 assert!(matches!(
419 k.add(entry("b", "111")),
420 Err(KeyringError::DuplicateSerial { .. })
421 ));
422 k.add(entry("b", "222")).unwrap();
423 assert_eq!(k.keys.len(), 2);
424 }
425
426 #[test]
427 fn remove_and_lookup() {
428 let mut k = Keyring::default();
429 k.add(entry("solo", "ABC")).unwrap();
430 assert_eq!(k.by_name("solo").map(|e| e.serial.as_str()), Some("ABC"));
431 assert_eq!(k.name_for(Some("ABC")), Some("solo"));
432 assert_eq!(k.name_for(Some("XYZ")), None);
433 assert_eq!(k.name_for(None), None);
434 assert!(k.remove("solo"));
435 assert!(!k.remove("solo"));
436 }
437
438 #[test]
439 fn resolve_matches_by_serial() {
440 let mut k = Keyring::default();
441 k.add(entry("solo", "ABC")).unwrap();
442 let connected = vec![
443 ConnectedKey {
444 path: "/dev/hidraw5".into(),
445 serial: Some("ABC".into()),
446 label: "Solo 2".into(),
447 },
448 ConnectedKey {
449 path: "/dev/hidraw9".into(),
450 serial: None,
451 label: "YubiKey".into(),
452 },
453 ];
454 assert_eq!(
455 k.resolve("solo", &connected).unwrap().path,
456 PathBuf::from("/dev/hidraw5")
457 );
458 assert!(matches!(
459 k.resolve("nope", &connected),
460 Err(ResolveError::UnknownName { .. })
461 ));
462 assert!(matches!(
463 k.resolve("solo", &[]),
464 Err(ResolveError::NotConnected { .. })
465 ));
466 }
467
468 #[test]
469 fn json_round_trip_and_defaults() {
470 let mut k = Keyring::default();
471 k.add(KeyEntry {
472 name: "signing-yubikey".into(),
473 serial: "37806840".into(),
474 source: IdSource::Ccid,
475 vendor: Some("yubico".into()),
476 aaguid: None,
477 note: Some("daily".into()),
478 })
479 .unwrap();
480 let json = serde_json::to_string_pretty(&k).unwrap();
481 let back: Keyring = serde_json::from_str(&json).unwrap();
482 assert_eq!(back.keys[0].name, "signing-yubikey");
483 assert_eq!(back.keys[0].source, IdSource::Ccid);
484 assert_eq!(back.keys[0].vendor.as_deref(), Some("yubico"));
485
486 let minimal: Keyring =
488 serde_json::from_str(r#"{"keys":[{"name":"x","serial":"S1"}]}"#).unwrap();
489 assert_eq!(minimal.keys[0].source, IdSource::Usb);
490 }
491
492 #[test]
493 fn load_missing_is_empty() {
494 let k = Keyring::load_from(Path::new("/nonexistent/keyroost/keys.json")).unwrap();
495 assert!(k.keys.is_empty());
496 }
497
498 #[test]
499 fn load_sanitizes_invalid_names_and_strips_control_chars() {
500 let dir = std::env::temp_dir().join(format!("keyroost-test-{}", std::process::id()));
501 std::fs::create_dir_all(&dir).unwrap();
502 let path = dir.join("keys.json");
503
504 std::fs::write(
508 &path,
509 "{\"keys\":[{\"name\":\"evil\\u001b[31m\",\"serial\":\"S1\"},{\"name\":\"good\",\"serial\":\"S2\"}]}",
510 )
511 .unwrap();
512 let k = Keyring::load_from(&path).unwrap();
513 assert_eq!(k.keys[0].name, "evil[31m");
514 assert_eq!(k.keys[1].name, "good");
515
516 std::fs::write(
518 &path,
519 "{\"keys\":[{\"name\":\"ok\",\"serial\":\"S\\u001b[2J1\",\"note\":\"a\\u0007b\"}]}",
520 )
521 .unwrap();
522 let k = Keyring::load_from(&path).unwrap();
523 assert_eq!(k.keys[0].serial, "S[2J1");
524 assert_eq!(k.keys[0].note.as_deref(), Some("ab"));
525
526 std::fs::remove_dir_all(&dir).ok();
527 }
528
529 #[test]
530 fn add_sanitizes_device_supplied_fields() {
531 let mut k = Keyring::default();
534 k.add(KeyEntry {
535 name: "weird".into(),
536 serial: "AB\u{1b}[31mCD".into(),
537 source: IdSource::Usb,
538 vendor: None,
539 aaguid: None,
540 note: Some("x\u{7}y".into()),
541 })
542 .unwrap();
543 assert_eq!(k.keys[0].serial, "AB[31mCD");
544 assert_eq!(k.keys[0].note.as_deref(), Some("xy"));
545
546 let mut k2 = Keyring::default();
549 k2.add(KeyEntry {
550 name: "bidi".into(),
551 serial: "S\u{202E}9\u{200B}9".into(),
552 source: IdSource::Usb,
553 vendor: None,
554 aaguid: None,
555 note: None,
556 })
557 .unwrap();
558 assert_eq!(k2.keys[0].serial, "S99");
559 }
560
561 #[cfg(unix)]
562 #[test]
563 fn save_creates_owner_only_file() {
564 use std::os::unix::fs::PermissionsExt;
565 let dir = std::env::temp_dir().join(format!("keyroost-perm-{}", std::process::id()));
566 let path = dir.join("keys.json");
567 let mut k = Keyring::default();
568 k.add(KeyEntry {
569 name: "test-key".into(),
570 serial: "S1".into(),
571 source: IdSource::Usb,
572 vendor: None,
573 aaguid: None,
574 note: None,
575 })
576 .unwrap();
577 k.save_to(&path).unwrap();
578 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
579 assert_eq!(mode & 0o777, 0o600, "keys.json must be owner-only");
580 assert!(!path.with_extension("json.tmp").exists());
582 std::fs::remove_dir_all(&dir).ok();
583 }
584}