Skip to main content

lonca_daemon/
home.rs

1//! The daemon's home directory.
2//!
3//! ```text
4//! $LONCA_HOME/
5//!   daemon.key        ed25519 signing key (0600)
6//!   daemon.next.key   the key a rotation in progress moves to (0600)
7//!   tokens.json       sha256(token) -> agent session or human session (0600)
8//!   lonca.sock        Unix socket
9//!   repos/<name>.git  bare Git stores
10//!   scratch/          verifier work trees
11//!   backups/          bundles written by `repo.backup` (or $LONCA_BACKUPS)
12//! ```
13//!
14//! In hardened mode the directory belongs to a dedicated system user, so
15//! agents running as the developer can not read the key or write the stores.
16//! The backups directory is the one place the developer's group may read,
17//! so it lives outside the home there (`--backups`).
18
19use std::collections::BTreeMap;
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24use lonca_core::canonical::{random_bytes, sha256_hex};
25use lonca_core::keys::KeyPair;
26
27use crate::sys::current_uid;
28use lonca_core::{Error, Result};
29use serde::{Deserialize, Serialize};
30
31#[derive(Clone, Debug)]
32pub struct Home {
33    pub root: PathBuf,
34    backups: Option<PathBuf>,
35}
36
37impl Home {
38    pub fn new(root: impl Into<PathBuf>) -> Self {
39        Self {
40            root: root.into(),
41            backups: None,
42        }
43    }
44
45    /// `$LONCA_HOME`, else `~/.lonca`; backups in `$LONCA_BACKUPS` if set.
46    pub fn from_env() -> Self {
47        let home = match std::env::var_os("LONCA_HOME") {
48            Some(p) => Self::new(p),
49            None => {
50                let home = std::env::var_os("HOME")
51                    .map(PathBuf::from)
52                    .unwrap_or_else(|| PathBuf::from("."));
53                Self::new(home.join(".lonca"))
54            }
55        };
56        match std::env::var_os("LONCA_BACKUPS") {
57            Some(p) => home.with_backups(p),
58            None => home,
59        }
60    }
61
62    /// Where `repo.backup` writes its bundles instead of `backups/` in the
63    /// home: in hardened mode a directory the developer's group can read.
64    pub fn with_backups(mut self, dir: impl Into<PathBuf>) -> Self {
65        self.backups = Some(dir.into());
66        self
67    }
68
69    pub fn backups(&self) -> PathBuf {
70        self.backups
71            .clone()
72            .unwrap_or_else(|| self.root.join("backups"))
73    }
74
75    /// Creates the home, or checks the one there: it must belong to this
76    /// process's user, and it is closed to everyone else (mode 0700).
77    pub fn ensure(&self) -> Result<()> {
78        fs::create_dir_all(self.repos())?;
79        owned_by(&self.root, current_uid())?;
80        #[cfg(unix)]
81        {
82            use std::os::unix::fs::PermissionsExt;
83            fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700))?;
84        }
85        Ok(())
86    }
87
88    /// `$LONCA_SOCKET`, else `lonca.sock` in the home. Unix socket paths are
89    /// limited to about 100 bytes, so long homes need `LONCA_SOCKET`.
90    pub fn socket(&self) -> PathBuf {
91        std::env::var_os("LONCA_SOCKET")
92            .map(PathBuf::from)
93            .unwrap_or_else(|| self.root.join("lonca.sock"))
94    }
95
96    pub fn key_path(&self) -> PathBuf {
97        self.root.join("daemon.key")
98    }
99
100    pub fn repos(&self) -> PathBuf {
101        self.root.join("repos")
102    }
103
104    pub fn repo_path(&self, name: &str) -> PathBuf {
105        self.repos().join(format!("{name}.git"))
106    }
107
108    pub fn scratch(&self) -> PathBuf {
109        self.root.join("scratch")
110    }
111
112    pub fn tokens_path(&self) -> PathBuf {
113        self.root.join("tokens.json")
114    }
115
116    /// Written by `lonca-audit --freeze-flag` when it finds a problem.
117    pub fn freeze_flag(&self, repo: &str) -> PathBuf {
118        self.root.join("freeze").join(repo)
119    }
120
121    pub fn load_key(&self) -> Result<KeyPair> {
122        KeyPair::load_or_create(&self.key_path(), current_uid())
123    }
124
125    /// Where a rotation keeps the new key until every repository uses it.
126    pub fn next_key_path(&self) -> PathBuf {
127        self.root.join("daemon.next.key")
128    }
129
130    /// The current key and the key of an unfinished rotation. A crash
131    /// between the two steps of [`Home::promote_next_key`] leaves only the
132    /// next key; it is the current one then.
133    pub fn load_keys(&self) -> Result<(KeyPair, Option<KeyPair>)> {
134        let next = self.next_key_path();
135        if !self.key_path().exists() && next.exists() {
136            fs::rename(&next, self.key_path())?;
137        }
138        let current = self.load_key()?;
139        let next = if next.exists() {
140            Some(KeyPair::load(&next, current_uid())?)
141        } else {
142            None
143        };
144        Ok((current, next))
145    }
146
147    /// The next key, created on first use.
148    pub fn load_or_create_next_key(&self) -> Result<KeyPair> {
149        KeyPair::load_or_create(&self.next_key_path(), current_uid())
150    }
151
152    /// Makes the next key the current one once no repository uses the
153    /// current key. The old secret is deleted: the entries it signed stay
154    /// verifiable through its public key in the log, and nothing may be
155    /// signed with it again.
156    pub fn promote_next_key(&self) -> Result<()> {
157        fs::remove_file(self.key_path())?;
158        fs::rename(self.next_key_path(), self.key_path())?;
159        Ok(())
160    }
161}
162
163/// Refuses a home that another user owns: whoever owns it can replace the
164/// key, the tokens and the stores, and a daemon would sign for them.
165fn owned_by(root: &Path, uid: u32) -> Result<()> {
166    #[cfg(unix)]
167    {
168        use std::os::unix::fs::MetadataExt;
169        let owner = fs::metadata(root)?.uid();
170        if owner != uid {
171            return Err(Error::Invalid(format!(
172                "daemon home {} belongs to uid {owner}, not to this daemon (uid {uid}); \
173                 run the daemon as its owner, or give it a home of its own",
174                root.display()
175            )));
176        }
177    }
178    let _ = (root, uid);
179    Ok(())
180}
181
182/// What a token stands for. Agent entries predate human sessions and are
183/// stored without a tag; the two shapes share no required field, so an
184/// untagged enum reads both.
185#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(untagged)]
187pub enum TokenEntry {
188    /// An agent session of one repository. `expires_ms` is the session's
189    /// own end, from its log entry; entries written before it was stored
190    /// have none, and the log says when they end.
191    Agent {
192        repo: String,
193        session: String,
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        expires_ms: Option<i64>,
196    },
197    /// A human session (`human.login`): the human proved presence with
198    /// `key`, and the session is good wherever that key is enrolled.
199    Human {
200        human: String,
201        key: String,
202        expires_ms: i64,
203    },
204}
205
206/// Session tokens. Only their SHA-256 is stored, never the token itself.
207#[derive(Default)]
208pub struct Tokens {
209    path: Option<PathBuf>,
210    map: BTreeMap<String, TokenEntry>,
211}
212
213impl Tokens {
214    pub fn load(path: &Path) -> Result<Self> {
215        let map = match fs::read(path) {
216            Ok(b) => serde_json::from_slice(&b)?,
217            Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
218            Err(e) => return Err(e.into()),
219        };
220        Ok(Self {
221            path: Some(path.to_path_buf()),
222            map,
223        })
224    }
225
226    /// A token for an agent session that ends at `expires_ms`.
227    pub fn issue(&mut self, repo: &str, session: &str, expires_ms: i64) -> Result<String> {
228        self.insert(
229            "lonca_",
230            TokenEntry::Agent {
231                repo: repo.into(),
232                session: session.into(),
233                expires_ms: Some(expires_ms),
234            },
235        )
236    }
237
238    /// Replaces `old` with a new token for the same session, in one write:
239    /// from then on only the new one works. `None` if `old` is unknown.
240    pub fn rotate(&mut self, old: &str) -> Result<Option<String>> {
241        let Some(entry) = self.map.remove(&sha256_hex(old.as_bytes())) else {
242            return Ok(None);
243        };
244        let prefix = match entry {
245            TokenEntry::Agent { .. } => "lonca_",
246            TokenEntry::Human { .. } => "lonca_h_",
247        };
248        self.insert(prefix, entry).map(Some)
249    }
250
251    pub fn issue_human(&mut self, human: &str, key: &str, expires_ms: i64) -> Result<String> {
252        self.insert(
253            "lonca_h_",
254            TokenEntry::Human {
255                human: human.into(),
256                key: key.into(),
257                expires_ms,
258            },
259        )
260    }
261
262    fn insert(&mut self, prefix: &str, entry: TokenEntry) -> Result<String> {
263        let token = format!("{prefix}{}", hex::encode(random_bytes::<32>()));
264        self.map.insert(sha256_hex(token.as_bytes()), entry);
265        self.save()?;
266        Ok(token)
267    }
268
269    pub fn lookup(&self, token: &str) -> Option<&TokenEntry> {
270        self.map.get(&sha256_hex(token.as_bytes()))
271    }
272
273    /// Drops one token (logout). Unknown tokens are not an error.
274    pub fn revoke(&mut self, token: &str) -> Result<()> {
275        if self.map.remove(&sha256_hex(token.as_bytes())).is_some() {
276            self.save()?;
277        }
278        Ok(())
279    }
280
281    pub fn revoke_sessions(&mut self, repo: &str, sessions: &[String]) -> Result<()> {
282        let before = self.map.len();
283        self.map.retain(|_, e| match e {
284            TokenEntry::Agent {
285                repo: r, session, ..
286            } => !(r == repo && sessions.contains(session)),
287            TokenEntry::Human { .. } => true,
288        });
289        if self.map.len() != before {
290            self.save()?;
291        }
292        Ok(())
293    }
294
295    /// Drops every token that no longer works at `now_ms`: human sessions
296    /// past their end, and agent sessions past theirs or that `live` says
297    /// are over (ended, expired with a parent, of a repository gone).
298    pub fn sweep_expired(&mut self, now_ms: i64, live: impl Fn(&str, &str) -> bool) -> Result<()> {
299        let before = self.map.len();
300        self.map.retain(|_, e| match e {
301            TokenEntry::Agent {
302                repo,
303                session,
304                expires_ms,
305            } => expires_ms.is_none_or(|end| end > now_ms) && live(repo, session),
306            TokenEntry::Human { expires_ms, .. } => *expires_ms > now_ms,
307        });
308        if self.map.len() != before {
309            self.save()?;
310        }
311        Ok(())
312    }
313
314    fn save(&self) -> Result<()> {
315        let Some(path) = &self.path else {
316            return Ok(());
317        };
318        let tmp = path.with_extension("tmp");
319        let mut opts = fs::OpenOptions::new();
320        opts.write(true).create(true).truncate(true);
321        #[cfg(unix)]
322        {
323            use std::os::unix::fs::OpenOptionsExt;
324            opts.mode(0o600);
325        }
326        let mut f = opts.open(&tmp)?;
327        f.write_all(&serde_json::to_vec_pretty(&self.map)?)?;
328        f.sync_all()?;
329        fs::rename(&tmp, path).map_err(Error::from)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn a_home_must_belong_to_the_daemon() {
339        let dir = tempfile::tempdir().unwrap();
340        let home = Home::new(dir.path().join("h"));
341        home.ensure().unwrap();
342        assert!(owned_by(&home.root, current_uid()).is_ok());
343        let e = owned_by(&home.root, current_uid() + 1)
344            .unwrap_err()
345            .to_string();
346        assert!(e.contains("belongs to uid"), "{e}");
347        #[cfg(unix)]
348        {
349            use std::os::unix::fs::PermissionsExt;
350            let mode = fs::metadata(&home.root).unwrap().permissions().mode();
351            assert_eq!(mode & 0o777, 0o700);
352        }
353    }
354
355    #[test]
356    fn tokens_are_stored_as_hashes_and_both_kinds_survive_a_reload() {
357        let dir = tempfile::tempdir().unwrap();
358        let path = dir.path().join("tokens.json");
359        // A file written before human sessions existed.
360        std::fs::write(
361            &path,
362            format!(
363                "{{\"{}\": {{\"repo\": \"demo\", \"session\": \"s0\"}}}}",
364                sha256_hex(b"lonca_old")
365            ),
366        )
367        .unwrap();
368        let mut t = Tokens::load(&path).unwrap();
369        let a = t.issue("demo", "s1", 5_000).unwrap();
370        let h = t.issue_human("ayse", "ab", 2_000).unwrap();
371        assert!(a.starts_with("lonca_") && h.starts_with("lonca_h_"));
372        let text = std::fs::read_to_string(&path).unwrap();
373        assert!(!text.contains(&a) && !text.contains(&h), "{text}");
374        let t = Tokens::load(&path).unwrap();
375        assert_eq!(
376            t.lookup("lonca_old"),
377            Some(&TokenEntry::Agent {
378                repo: "demo".into(),
379                session: "s0".into(),
380                expires_ms: None,
381            })
382        );
383        assert_eq!(
384            t.lookup(&h),
385            Some(&TokenEntry::Human {
386                human: "ayse".into(),
387                key: "ab".into(),
388                expires_ms: 2_000
389            })
390        );
391        let mut t = t;
392        t.revoke_sessions("demo", &["s1".into()]).unwrap();
393        assert!(t.lookup(&a).is_none() && t.lookup(&h).is_some());
394        t.sweep_expired(1_999, |_, _| true).unwrap();
395        assert!(t.lookup(&h).is_some());
396        t.sweep_expired(2_000, |_, _| true).unwrap();
397        assert!(t.lookup(&h).is_none());
398        t.revoke("lonca_old").unwrap();
399        t.revoke("lonca_old").unwrap();
400        assert!(Tokens::load(&path).unwrap().lookup("lonca_old").is_none());
401    }
402
403    /// An agent token goes at its session's end, or when the log says the
404    /// session is over; one without a stored end only by the log.
405    #[test]
406    fn a_sweep_drops_agent_tokens_that_no_longer_work() {
407        let dir = tempfile::tempdir().unwrap();
408        let path = dir.path().join("tokens.json");
409        let mut t = Tokens::load(&path).unwrap();
410        let a = t.issue("demo", "s1", 2_000).unwrap();
411        let b = t.issue("demo", "s2", 9_000).unwrap();
412        t.sweep_expired(1_999, |_, _| true).unwrap();
413        assert!(t.lookup(&a).is_some() && t.lookup(&b).is_some());
414        t.sweep_expired(2_000, |_, _| true).unwrap();
415        assert!(t.lookup(&a).is_none() && t.lookup(&b).is_some());
416        t.sweep_expired(2_000, |repo, s| repo == "demo" && s != "s2")
417            .unwrap();
418        assert!(t.lookup(&b).is_none());
419        let text = std::fs::read_to_string(&path).unwrap();
420        assert!(!text.contains("s1") && !text.contains("s2"), "{text}");
421    }
422
423    #[test]
424    fn a_rotation_replaces_the_token_in_one_write() {
425        let dir = tempfile::tempdir().unwrap();
426        let path = dir.path().join("tokens.json");
427        let mut t = Tokens::load(&path).unwrap();
428        let old = t.issue("demo", "s1", 9_000).unwrap();
429        let new = t.rotate(&old).unwrap().unwrap();
430        assert!(new.starts_with("lonca_") && !new.starts_with("lonca_h_"));
431        assert_ne!(new, old);
432        let t2 = Tokens::load(&path).unwrap();
433        assert!(t2.lookup(&old).is_none());
434        assert_eq!(t2.lookup(&new), t.lookup(&new));
435        assert!(t.rotate(&old).unwrap().is_none(), "the old one is gone");
436        let h = t.issue_human("ayse", "ab", 9_000).unwrap();
437        assert!(t.rotate(&h).unwrap().unwrap().starts_with("lonca_h_"));
438        t.revoke_sessions("demo", &["s1".into()]).unwrap();
439        assert!(
440            t.lookup(&new).is_none(),
441            "revocation reaches a rotated token"
442        );
443    }
444}