git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

src/bin/cce-keyring-sync/serve.rs (5.2K)

  1 //! The daemon's socket: one-time codes for cce-secrets.
  2 //!
  3 //! The daemon holds the session's `op` authorization (daemon.rs), so it is
  4 //! the one process that can ask 1Password for a code without raising an
  5 //! Authorize dialog. cce-secrets asks here, one request per connection:
  6 //!
  7 //! ```text
  8 //! otp <item-id>\n  →  otp <code> <seconds left>\n | none\n | err <text>\n
  9 //! ```
 10 //!
 11 //! The socket is 0600 under `$XDG_RUNTIME_DIR/cce`, and only items the base
 12 //! snapshot pairs are served — the mirror's own set, not whatever else the
 13 //! account holds. The trade, stated plainly: a same-user process could
 14 //! already read every mirrored password from the unlocked keyring; this
 15 //! adds the current codes without a dialog. Never the seeds — `op item get
 16 //! --otp` computes the code app-side (op.rs).
 17 
 18 use std::path::{Path, PathBuf};
 19 use std::time::Duration;
 20 
 21 use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
 22 use tokio::net::{UnixListener, UnixStream};
 23 
 24 use crate::op::OnePassword;
 25 use crate::{now_unix, State};
 26 
 27 /// TOTP's near-universal period. A code with another period still comes
 28 /// back right; only its countdown would be off.
 29 const PERIOD: i64 = 30;
 30 
 31 /// Where the daemon listens and cce-secrets connects. `None` without a
 32 /// runtime dir — a session has one; nothing else should be serving.
 33 pub fn socket_path() -> Option<PathBuf> {
 34     let dir = std::env::var("XDG_RUNTIME_DIR").ok().filter(|s| !s.is_empty())?;
 35     Some(PathBuf::from(dir).join("cce/keyring-sync.sock"))
 36 }
 37 
 38 pub async fn serve(state_path: PathBuf) {
 39     let Some(path) = socket_path() else {
 40         eprintln!("no XDG_RUNTIME_DIR; one-time codes are not served");
 41         return;
 42     };
 43     if let Some(dir) = path.parent() {
 44         let _ = std::fs::create_dir_all(dir);
 45     }
 46     // A previous daemon's socket file outlives it; bind would refuse.
 47     let _ = std::fs::remove_file(&path);
 48     let listener = match UnixListener::bind(&path) {
 49         Ok(l) => l,
 50         Err(e) => {
 51             eprintln!("binding {}: {e}; one-time codes are not served", path.display());
 52             return;
 53         }
 54     };
 55     {
 56         use std::os::unix::fs::PermissionsExt;
 57         let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
 58     }
 59     loop {
 60         let Ok((stream, _)) = listener.accept().await else { continue };
 61         // Its own task, so a slow reply holds no other connection. The
 62         // `op` call itself queues behind any in flight (op.rs, `run_as`):
 63         // two calls waiting on authorization would raise two dialogs.
 64         let state_path = state_path.clone();
 65         tokio::spawn(async move { handle(stream, &state_path).await });
 66     }
 67 }
 68 
 69 async fn handle(stream: UnixStream, state_path: &Path) {
 70     let (r, mut w) = stream.into_split();
 71     let mut line = String::new();
 72     let mut r = BufReader::new(r.take(256));
 73     match tokio::time::timeout(Duration::from_secs(5), r.read_line(&mut line)).await {
 74         Ok(Ok(n)) if n > 0 => {}
 75         _ => return,
 76     }
 77     let reply = answer(line.trim(), state_path).await;
 78     let _ = w.write_all(reply.as_bytes()).await;
 79 }
 80 
 81 async fn answer(req: &str, state_path: &Path) -> String {
 82     let Some(id) = req.strip_prefix("otp ") else {
 83         return "err unknown request\n".into();
 84     };
 85     if !is_item_id(id) {
 86         return "err bad item id\n".into();
 87     }
 88     let state: State = std::fs::read_to_string(state_path)
 89         .ok()
 90         .and_then(|s| serde_json::from_str(&s).ok())
 91         .unwrap_or_default();
 92     if state.backend != "onepassword" || !state.entries.contains_key(id) {
 93         return "err not a mirrored item\n".into();
 94     }
 95     match OnePassword::new(&state.vault).otp(id).await {
 96         Ok(Some(code)) => format!("otp {code} {}\n", PERIOD - now_unix().rem_euclid(PERIOD)),
 97         Ok(None) => "none\n".into(),
 98         Err(e) => format!("err {}\n", e.replace('\n', " ")),
 99     }
100 }
101 
102 /// 1Password ids are 26 lowercase base32 characters. Alphanumeric only is
103 /// what matters: the id lands on `op`'s argv, where a dash would read as a
104 /// flag.
105 fn is_item_id(s: &str) -> bool {
106     !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric())
107 }
108 
109 #[cfg(test)]
110 mod tests {
111     use super::*;
112 
113     #[test]
114     fn only_bare_ids_reach_op() {
115         assert!(is_item_id("v6ybyyfp6b2vnaqm3ar4z3kzqa"));
116         assert!(!is_item_id(""));
117         assert!(!is_item_id("--vault"));
118         assert!(!is_item_id("abc def"));
119         assert!(!is_item_id(&"a".repeat(65)));
120     }
121 
122     #[tokio::test]
123     async fn requests_outside_the_mirror_are_refused() {
124         let dir = std::env::temp_dir().join(format!("cce-keyring-sync-serve-{}", std::process::id()));
125         std::fs::create_dir_all(&dir).unwrap();
126         let state_path = dir.join("state.json");
127         std::fs::write(
128             &state_path,
129             r#"{"version":2,"last_run":0,"backend":"onepassword","vault":"Personal","entries":{}}"#,
130         )
131         .unwrap();
132         assert_eq!(answer("otp aaaaaaaaaaaaaaaaaaaaaaaaaa", &state_path).await, "err not a mirrored item\n");
133         assert_eq!(answer("otp -x", &state_path).await, "err bad item id\n");
134         assert_eq!(answer("sync", &state_path).await, "err unknown request\n");
135         let _ = std::fs::remove_dir_all(&dir);
136     }
137 }