Skip to main content

keyroost_keyring/
lib.rs

1//! Friendly-name registry for security keys, plus device-identity resolution.
2//!
3//! Lets a user attach a memorable label (e.g. `signing-yubikey`) to a physical
4//! key, matched by its stable **serial number**, so commands can target a key
5//! by `--name` instead of a `/dev/hidrawN` path that changes on every replug.
6//!
7//! This crate is pure config + matching logic: it has no hardware or PC/SC
8//! dependencies and never enumerates devices itself. The caller supplies the
9//! list of connected devices (as [`ConnectedKey`]) — for the CLI that's the
10//! HID enumeration plus, for keys without a USB serial, a CCID-read serial.
11//! Front-end concerns (interactive pickers, TTY handling, confirmations) live
12//! in the caller, so both the CLI and the GUI reuse this same core.
13//!
14//! ## Privacy
15//!
16//! Persisting a key's serial to disk is **opt-in**: nothing is written unless
17//! the caller explicitly invokes [`Keyring::save_to`] / [`Keyring::save_default`]
18//! (i.e. the user ran an "add a name" action). Loading and in-memory matching
19//! record nothing.
20
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26
27/// How a key's serial is obtained — recorded for display/diagnostics. Matching
28/// is always by serial-string equality regardless of source.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum IdSource {
32    /// USB `iSerialNumber` (read from sysfs; SoloKeys, Nitrokey, …).
33    #[default]
34    Usb,
35    /// Serial read from a vendor management applet over CCID (e.g. YubiKey).
36    Ccid,
37}
38
39/// One named key in the registry. `serial` is the match key; `name` is the
40/// unique user-facing label.
41#[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/// The on-disk registry (`keys.json`).
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct Keyring {
58    #[serde(default)]
59    pub keys: Vec<KeyEntry>,
60}
61
62/// A currently-connected device as seen by the resolver. The caller builds
63/// these from device enumeration; `serial` is the device's effective serial
64/// (USB or CCID), `None` if it couldn't be determined.
65#[derive(Debug, Clone)]
66pub struct ConnectedKey {
67    pub path: PathBuf,
68    pub serial: Option<String>,
69    pub label: String,
70}
71
72/// Errors loading, saving, or mutating the registry.
73#[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/// Errors resolving a `--name` to a connected device.
127#[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
158/// Validate a friendly name: 1-64 chars of lowercase ASCII, digits, `-`, `_`.
159pub 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        // The rejected name is echoed in the error message; sanitize it so a
169        // hand-edited keys.json can't smuggle terminal escapes through the
170        // very rejection meant to stop them.
171        let mut shown = name.to_string();
172        strip_control_chars(&mut shown);
173        Err(KeyringError::InvalidName(shown))
174    }
175}
176
177/// Remove control characters in place — terminal-escape hygiene for
178/// hand-editable fields that get echoed back to the user. Also strips the
179/// Unicode format characters used for display spoofing (`char::is_control`
180/// covers only Cc): bidi overrides/isolates (RLO can render "key-live" out
181/// of "evil-yek"), zero-width chars, BOM, and the soft/Arabic-letter marks.
182fn strip_control_chars(s: &mut String) {
183    fn spoofing(c: char) -> bool {
184        c.is_control()
185            || matches!(c,
186                '\u{200B}'..='\u{200F}' // zero-width space/joiners, LRM/RLM
187                | '\u{202A}'..='\u{202E}' // bidi embeddings + LRO/RLO
188                | '\u{2066}'..='\u{2069}' // bidi isolates
189                | '\u{FEFF}' // BOM / ZWNBSP
190                | '\u{00AD}' // soft hyphen
191                | '\u{061C}' // Arabic letter mark
192            )
193    }
194    if s.chars().any(spoofing) {
195        s.retain(|c| !spoofing(c));
196    }
197}
198
199/// Default config path: `$XDG_CONFIG_HOME/keyroost/keys.json`, else
200/// `$HOME/.config/keyroost/keys.json`.
201pub 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    /// Load from the default config path. A missing file yields an empty
221    /// registry (reading records nothing).
222    pub fn load_default() -> Result<Keyring, KeyringError> {
223        let path = config_path().ok_or(KeyringError::NoConfigDir)?;
224        Self::load_from(&path)
225    }
226
227    /// Load from a specific path. A missing file yields an empty registry.
228    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                // `add` validates names before they ever reach disk; on the
234                // way back in every field — including the name — is sanitized
235                // rather than rejected. Rejecting would make one hand-edited
236                // entry render the whole registry unloadable, and callers
237                // that fall back to an empty registry on error would then
238                // overwrite keys.json and destroy every other entry on the
239                // next save.
240                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    /// Persist to the default config path, creating parent dirs. Opt-in: only
258    /// call this from an explicit user action. Returns the path written.
259    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    /// Persist to a specific path, creating parent dirs. Opt-in.
266    pub fn save_to(&self, path: &Path) -> Result<(), KeyringError> {
267        if let Some(parent) = path.parent() {
268            fs::create_dir_all(parent)?;
269            // Owner-only on the directory too, matching the file below. Only
270            // tightened when we (may have) just created it — an existing dir
271            // the user deliberately opened up is left alone.
272            #[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        // Write a sibling temp file and rename into place: a crash mid-write
287        // can no longer corrupt the registry, and the file is created
288        // owner-only — which security keys a person owns is their business —
289        // instead of inheriting the umask default (typically world-readable).
290        let tmp = path.with_extension("json.tmp");
291        // Remove any stale temp file so `create_new` below can succeed.
292        // `create_new` (not `create`) matters twice over: a pre-existing file
293        // would keep its old permissions (the 0o600 applies only at creation),
294        // and a symlink planted at the temp path would otherwise be followed.
295        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    /// Add a new entry. Rejects duplicate names and duplicate serials.
315    pub fn add(&mut self, mut entry: KeyEntry) -> Result<(), KeyringError> {
316        validate_name(&entry.name)?;
317        // Sanitize on the way in with the same rules `load_from` applies on
318        // the way out, so a device-reported serial (or note) containing
319        // control characters is stored exactly as it will round-trip —
320        // otherwise the value would silently mutate on the next load, and
321        // duplicate-serial detection would compare against a phantom.
322        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    /// Remove the entry with `name`. Returns true if one was removed.
343    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    /// The friendly name for a connected device's serial, if one is registered.
358    /// Used by `list` to annotate devices.
359    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    /// Resolve a `--name` to a connected device by matching serials.
365    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        // `source` defaults to Usb when absent in JSON.
487        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        // A name with an ANSI escape is sanitized, not fatal: one bad
505        // hand-edited entry must never make the whole registry unloadable
506        // (an unwrap_or_default + save would wipe every other entry).
507        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        // Control chars in free-text fields are stripped, not fatal.
517        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        // A device-reported serial with control chars must be stored in the
532        // same form load_from would produce, or the entry mutates on reload.
533        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        // Unicode format chars (Cf) used for display spoofing go too: RLO
547        // would render "evil-yek" as "key-live" in a terminal listing.
548        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        // No temp file left behind.
581        assert!(!path.with_extension("json.tmp").exists());
582        std::fs::remove_dir_all(&dir).ok();
583    }
584}