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 }