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    /// The daemon's copies of bundles from the import directory, each
109    /// removed after its fetch; emptied at every start.
110    pub fn imports(&self) -> PathBuf {
111        self.root.join("imports")
112    }
113
114    pub fn scratch(&self) -> PathBuf {
115        self.root.join("scratch")
116    }
117
118    pub fn tokens_path(&self) -> PathBuf {
119        self.root.join("tokens.json")
120    }
121
122    /// Written by `lonca-audit --freeze-flag` when it finds a problem.
123    pub fn freeze_flag(&self, repo: &str) -> PathBuf {
124        self.root.join("freeze").join(repo)
125    }
126
127    pub fn load_key(&self) -> Result<KeyPair> {
128        KeyPair::load_or_create(&self.key_path(), current_uid())
129    }
130
131    /// Where a rotation keeps the new key until every repository uses it.
132    pub fn next_key_path(&self) -> PathBuf {
133        self.root.join("daemon.next.key")
134    }
135
136    /// The current key and the key of an unfinished rotation. A crash
137    /// between the two steps of [`Home::promote_next_key`] leaves only the
138    /// next key; it is the current one then.
139    pub fn load_keys(&self) -> Result<(KeyPair, Option<KeyPair>)> {
140        let next = self.next_key_path();
141        if !self.key_path().exists() && next.exists() {
142            fs::rename(&next, self.key_path())?;
143        }
144        let current = self.load_key()?;
145        let next = if next.exists() {
146            Some(KeyPair::load(&next, current_uid())?)
147        } else {
148            None
149        };
150        Ok((current, next))
151    }
152
153    /// The next key, created on first use.
154    pub fn load_or_create_next_key(&self) -> Result<KeyPair> {
155        KeyPair::load_or_create(&self.next_key_path(), current_uid())
156    }
157
158    /// Makes the next key the current one once no repository uses the
159    /// current key. The old secret is deleted: the entries it signed stay
160    /// verifiable through its public key in the log, and nothing may be
161    /// signed with it again.
162    pub fn promote_next_key(&self) -> Result<()> {
163        fs::remove_file(self.key_path())?;
164        fs::rename(self.next_key_path(), self.key_path())?;
165        Ok(())
166    }
167}
168
169/// Refuses a home that another user owns: whoever owns it can replace the
170/// key, the tokens and the stores, and a daemon would sign for them.
171fn owned_by(root: &Path, uid: u32) -> Result<()> {
172    #[cfg(unix)]
173    {
174        use std::os::unix::fs::MetadataExt;
175        let owner = fs::metadata(root)?.uid();
176        if owner != uid {
177            return Err(Error::Invalid(format!(
178                "daemon home {} belongs to uid {owner}, not to this daemon (uid {uid}); \
179                 run the daemon as its owner, or give it a home of its own",
180                root.display()
181            )));
182        }
183    }
184    let _ = (root, uid);
185    Ok(())
186}
187
188/// What a token stands for. Agent entries predate human sessions and are
189/// stored without a tag; the two shapes share no required field, so an
190/// untagged enum reads both.
191#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(untagged)]
193pub enum TokenEntry {
194    /// An agent session of one repository. `expires_ms` is the session's
195    /// own end, from its log entry; entries written before it was stored
196    /// have none, and the log says when they end.
197    Agent {
198        repo: String,
199        session: String,
200        #[serde(default, skip_serializing_if = "Option::is_none")]
201        expires_ms: Option<i64>,
202    },
203    /// A human session (`human.login`): the human proved presence with
204    /// `key`, and the session is good wherever that key is enrolled.
205    Human {
206        human: String,
207        key: String,
208        expires_ms: i64,
209    },
210}
211
212/// Session tokens. Only their SHA-256 is stored, never the token itself.
213#[derive(Default)]
214pub struct Tokens {
215    path: Option<PathBuf>,
216    map: BTreeMap<String, TokenEntry>,
217}
218
219impl Tokens {
220    pub fn load(path: &Path) -> Result<Self> {
221        let map = match fs::read(path) {
222            Ok(b) => serde_json::from_slice(&b)?,
223            Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
224            Err(e) => return Err(e.into()),
225        };
226        Ok(Self {
227            path: Some(path.to_path_buf()),
228            map,
229        })
230    }
231
232    /// A token for an agent session that ends at `expires_ms`.
233    pub fn issue(&mut self, repo: &str, session: &str, expires_ms: i64) -> Result<String> {
234        self.insert(
235            "lonca_",
236            TokenEntry::Agent {
237                repo: repo.into(),
238                session: session.into(),
239                expires_ms: Some(expires_ms),
240            },
241        )
242    }
243
244    /// Replaces `old` with a new token for the same session, in one write:
245    /// from then on only the new one works. `None` if `old` is unknown.
246    pub fn rotate(&mut self, old: &str) -> Result<Option<String>> {
247        let Some(entry) = self.map.remove(&sha256_hex(old.as_bytes())) else {
248            return Ok(None);
249        };
250        let prefix = match entry {
251            TokenEntry::Agent { .. } => "lonca_",
252            TokenEntry::Human { .. } => "lonca_h_",
253        };
254        self.insert(prefix, entry).map(Some)
255    }
256
257    pub fn issue_human(&mut self, human: &str, key: &str, expires_ms: i64) -> Result<String> {
258        self.insert(
259            "lonca_h_",
260            TokenEntry::Human {
261                human: human.into(),
262                key: key.into(),
263                expires_ms,
264            },
265        )
266    }
267
268    fn insert(&mut self, prefix: &str, entry: TokenEntry) -> Result<String> {
269        let token = format!("{prefix}{}", hex::encode(random_bytes::<32>()));
270        self.map.insert(sha256_hex(token.as_bytes()), entry);
271        self.save()?;
272        Ok(token)
273    }
274
275    pub fn lookup(&self, token: &str) -> Option<&TokenEntry> {
276        self.map.get(&sha256_hex(token.as_bytes()))
277    }
278
279    /// Drops one token (logout). Unknown tokens are not an error.
280    pub fn revoke(&mut self, token: &str) -> Result<()> {
281        if self.map.remove(&sha256_hex(token.as_bytes())).is_some() {
282            self.save()?;
283        }
284        Ok(())
285    }
286
287    pub fn revoke_sessions(&mut self, repo: &str, sessions: &[String]) -> Result<()> {
288        let before = self.map.len();
289        self.map.retain(|_, e| match e {
290            TokenEntry::Agent {
291                repo: r, session, ..
292            } => !(r == repo && sessions.contains(session)),
293            TokenEntry::Human { .. } => true,
294        });
295        if self.map.len() != before {
296            self.save()?;
297        }
298        Ok(())
299    }
300
301    /// Drops every token that no longer works at `now_ms`: human sessions
302    /// past their end, and agent sessions past theirs or that `live` says
303    /// are over (ended, expired with a parent, of a repository gone).
304    pub fn sweep_expired(&mut self, now_ms: i64, live: impl Fn(&str, &str) -> bool) -> Result<()> {
305        let before = self.map.len();
306        self.map.retain(|_, e| match e {
307            TokenEntry::Agent {
308                repo,
309                session,
310                expires_ms,
311            } => expires_ms.is_none_or(|end| end > now_ms) && live(repo, session),
312            TokenEntry::Human { expires_ms, .. } => *expires_ms > now_ms,
313        });
314        if self.map.len() != before {
315            self.save()?;
316        }
317        Ok(())
318    }
319
320    fn save(&self) -> Result<()> {
321        let Some(path) = &self.path else {
322            return Ok(());
323        };
324        let tmp = path.with_extension("tmp");
325        let mut opts = fs::OpenOptions::new();
326        opts.write(true).create(true).truncate(true);
327        #[cfg(unix)]
328        {
329            use std::os::unix::fs::OpenOptionsExt;
330            opts.mode(0o600);
331        }
332        let mut f = opts.open(&tmp)?;
333        f.write_all(&serde_json::to_vec_pretty(&self.map)?)?;
334        f.sync_all()?;
335        fs::rename(&tmp, path).map_err(Error::from)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn a_home_must_belong_to_the_daemon() {
345        let dir = tempfile::tempdir().unwrap();
346        let home = Home::new(dir.path().join("h"));
347        home.ensure().unwrap();
348        assert!(owned_by(&home.root, current_uid()).is_ok());
349        let e = owned_by(&home.root, current_uid() + 1)
350            .unwrap_err()
351            .to_string();
352        assert!(e.contains("belongs to uid"), "{e}");
353        #[cfg(unix)]
354        {
355            use std::os::unix::fs::PermissionsExt;
356            let mode = fs::metadata(&home.root).unwrap().permissions().mode();
357            assert_eq!(mode & 0o777, 0o700);
358        }
359    }
360
361    #[test]
362    fn tokens_are_stored_as_hashes_and_both_kinds_survive_a_reload() {
363        let dir = tempfile::tempdir().unwrap();
364        let path = dir.path().join("tokens.json");
365        // A file written before human sessions existed.
366        std::fs::write(
367            &path,
368            format!(
369                "{{\"{}\": {{\"repo\": \"demo\", \"session\": \"s0\"}}}}",
370                sha256_hex(b"lonca_old")
371            ),
372        )
373        .unwrap();
374        let mut t = Tokens::load(&path).unwrap();
375        let a = t.issue("demo", "s1", 5_000).unwrap();
376        let h = t.issue_human("ayse", "ab", 2_000).unwrap();
377        assert!(a.starts_with("lonca_") && h.starts_with("lonca_h_"));
378        let text = std::fs::read_to_string(&path).unwrap();
379        assert!(!text.contains(&a) && !text.contains(&h), "{text}");
380        let t = Tokens::load(&path).unwrap();
381        assert_eq!(
382            t.lookup("lonca_old"),
383            Some(&TokenEntry::Agent {
384                repo: "demo".into(),
385                session: "s0".into(),
386                expires_ms: None,
387            })
388        );
389        assert_eq!(
390            t.lookup(&h),
391            Some(&TokenEntry::Human {
392                human: "ayse".into(),
393                key: "ab".into(),
394                expires_ms: 2_000
395            })
396        );
397        let mut t = t;
398        t.revoke_sessions("demo", &["s1".into()]).unwrap();
399        assert!(t.lookup(&a).is_none() && t.lookup(&h).is_some());
400        t.sweep_expired(1_999, |_, _| true).unwrap();
401        assert!(t.lookup(&h).is_some());
402        t.sweep_expired(2_000, |_, _| true).unwrap();
403        assert!(t.lookup(&h).is_none());
404        t.revoke("lonca_old").unwrap();
405        t.revoke("lonca_old").unwrap();
406        assert!(Tokens::load(&path).unwrap().lookup("lonca_old").is_none());
407    }
408
409    /// An agent token goes at its session's end, or when the log says the
410    /// session is over; one without a stored end only by the log.
411    #[test]
412    fn a_sweep_drops_agent_tokens_that_no_longer_work() {
413        let dir = tempfile::tempdir().unwrap();
414        let path = dir.path().join("tokens.json");
415        let mut t = Tokens::load(&path).unwrap();
416        let a = t.issue("demo", "s1", 2_000).unwrap();
417        let b = t.issue("demo", "s2", 9_000).unwrap();
418        t.sweep_expired(1_999, |_, _| true).unwrap();
419        assert!(t.lookup(&a).is_some() && t.lookup(&b).is_some());
420        t.sweep_expired(2_000, |_, _| true).unwrap();
421        assert!(t.lookup(&a).is_none() && t.lookup(&b).is_some());
422        t.sweep_expired(2_000, |repo, s| repo == "demo" && s != "s2")
423            .unwrap();
424        assert!(t.lookup(&b).is_none());
425        let text = std::fs::read_to_string(&path).unwrap();
426        assert!(!text.contains("s1") && !text.contains("s2"), "{text}");
427    }
428
429    #[test]
430    fn a_rotation_replaces_the_token_in_one_write() {
431        let dir = tempfile::tempdir().unwrap();
432        let path = dir.path().join("tokens.json");
433        let mut t = Tokens::load(&path).unwrap();
434        let old = t.issue("demo", "s1", 9_000).unwrap();
435        let new = t.rotate(&old).unwrap().unwrap();
436        assert!(new.starts_with("lonca_") && !new.starts_with("lonca_h_"));
437        assert_ne!(new, old);
438        let t2 = Tokens::load(&path).unwrap();
439        assert!(t2.lookup(&old).is_none());
440        assert_eq!(t2.lookup(&new), t.lookup(&new));
441        assert!(t.rotate(&old).unwrap().is_none(), "the old one is gone");
442        let h = t.issue_human("ayse", "ab", 9_000).unwrap();
443        assert!(t.rotate(&h).unwrap().unwrap().starts_with("lonca_h_"));
444        t.revoke_sessions("demo", &["s1".into()]).unwrap();
445        assert!(
446            t.lookup(&new).is_none(),
447            "revocation reaches a rotated token"
448        );
449    }
450}