Skip to main content

choir_node/
ssh.rs

1//! SSH transport for git (D31): the host's `sshd`, a forced command, and
2//! this shim.
3//!
4//! The node speaks git over HTTPS with basic auth, which is what every
5//! agent uses and what no human expects. `git@host:owner/repo.git` is the
6//! address a person types, and the way to serve it here is the way Gitea
7//! and gitolite serve it: one OS account, one line per registered key in
8//! its `authorized_keys`, and a forced command that never gives out a
9//! shell.
10//!
11//! ```text
12//! command="choir-ssh --root /srv/repos --user alice --acl-file /etc/choir/acl \
13//!   --handoff /srv/repos/.choir/ssh-handoff",restrict ssh-ed25519 AAAA... alice
14//! ```
15//!
16//! There is deliberately no SSH server inside the daemon. Every Rust SSH
17//! library within reach is tokio-based, and this workspace is synchronous
18//! threads everywhere except `choir-actor`; an in-process server would
19//! make an async runtime the largest dependency the project has taken, to
20//! terminate a protocol the operating system already terminates.
21//!
22//! The three questions stay split exactly as they are on the HTTP path:
23//!
24//! * *which key* — sshd answers it, by matching the presented public key
25//!   against `authorized_keys`;
26//! * *which actor* — the matched line answers it, because `--user` is
27//!   written into that key's forced command by the operator. The client
28//!   cannot influence it: sshd runs the forced command and puts whatever
29//!   the client asked for in `SSH_ORIGINAL_COMMAND` instead;
30//! * *which repository* — [`Acl`] answers it, the same file and the same
31//!   [`crate::acl::git_requirement`] mapping the HTTP route consults, so
32//!   the two paths cannot drift into granting different things.
33//!
34//! A push then runs the repository's `pre-receive` hook like any other
35//! push, because [`Shim::decide`] hands the sequencer callback to git in
36//! the environment. That is the point of the whole exercise: an SSH push
37//! joins the same total order as an HTTPS one, rather than being a second,
38//! unsequenced way in. A shim with no `--handoff` refuses pushes outright
39//! rather than let one through unsequenced.
40//!
41//! The operator's guide to every way a client reaches a node:
42//!
43#![doc = include_str!("../../../docs/operating/transports.md")]
44
45use std::path::{Path, PathBuf};
46
47use crate::acl::{self, Acl, Level};
48
49/// The git service a client asked for.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Service {
52    /// Serving a clone or fetch: `git-upload-pack`.
53    UploadPack,
54    /// Receiving a push: `git-receive-pack`.
55    ReceivePack,
56}
57
58impl Service {
59    /// git's subcommand name, as passed to the real binary.
60    #[must_use]
61    pub fn verb(self) -> &'static str {
62        match self {
63            Self::UploadPack => "upload-pack",
64            Self::ReceivePack => "receive-pack",
65        }
66    }
67
68    /// The smart-HTTP path doing the same thing, used to borrow the HTTP
69    /// route's own grant decision rather than restate it. See
70    /// [`Shim::required_level`].
71    fn http_endpoint(self) -> &'static str {
72        match self {
73            Self::UploadPack => "git-upload-pack",
74            Self::ReceivePack => "git-receive-pack",
75        }
76    }
77}
78
79/// A parsed `SSH_ORIGINAL_COMMAND`: what the client asked to run, and on
80/// which repository, before any of it is believed.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Request {
83    /// Which of the two services was named.
84    pub service: Service,
85    /// The repository argument exactly as the client spelled it, still
86    /// unvalidated. [`canonical_repo`] is what turns it into a name this
87    /// node will act on.
88    pub repo: String,
89}
90
91/// The git invocation a resolved request becomes.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Exec {
94    /// git subcommand: `upload-pack` or `receive-pack`.
95    pub verb: &'static str,
96    /// Absolute path of the bare repository to serve.
97    pub dir: PathBuf,
98    /// Environment to add for the child, which is how the `pre-receive`
99    /// hook learns where to send this push. Empty for a fetch, which runs
100    /// no hook.
101    pub env: Vec<(String, String)>,
102}
103
104/// Where the shim sends the sequencer callback, and the loopback secret
105/// that callback authenticates with.
106///
107/// Written by the daemon at startup ([`crate::Node::write_ssh_handoff`])
108/// and read by the shim on each invocation. It exists because both values
109/// are runtime state: the port may be ephemeral and the secret is minted
110/// per process, so neither can be written into an `authorized_keys` line
111/// that has to survive restarts.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Handoff {
114    /// Base URL of the running daemon, e.g. `http://127.0.0.1:8417`.
115    pub api: String,
116    /// The daemon's loopback secret, sent as `X-Choir-Internal`.
117    pub secret: String,
118    /// The ACL file the daemon itself is enforcing, when it has one.
119    ///
120    /// A forced command whose `--acl-file` was forgotten would otherwise
121    /// be an authenticated key reaching every repository on a node that
122    /// authorizes every HTTP request — the ACL bypassed by the transport
123    /// rather than by a grant. [`Shim::decide`] falls back to this path,
124    /// so forgetting the flag costs an operator nothing and grants
125    /// nobody anything.
126    pub acl: Option<String>,
127    /// The self-service credential store the daemon is enforcing (D36),
128    /// when it has one.
129    ///
130    /// The grants a token was issued with live there rather than in the
131    /// ACL file, so a shim that read only the file would refuse every
132    /// self-served account — the transport disagreeing with the daemon
133    /// about who holds what. Read-only here: the shim never writes an
134    /// account, and takes only the grants.
135    pub accounts: Option<String>,
136}
137
138impl Handoff {
139    /// Parses the two-key file format: `<key> <value>` lines, `#`
140    /// comments, blank lines ignored.
141    ///
142    /// # Errors
143    ///
144    /// Returns a message when a line is malformed, a key is unknown, or
145    /// either key is missing. A half-read handoff is refused rather than
146    /// used, because the failure it produces downstream is a push that
147    /// silently misses the sequencer.
148    pub fn parse(text: &str) -> Result<Self, String> {
149        let (mut api, mut secret, mut acl, mut accounts) = (None, None, None, None);
150        for (index, raw) in text.lines().enumerate() {
151            let line = raw.split('#').next().unwrap_or("").trim();
152            if line.is_empty() {
153                continue;
154            }
155            let number = index + 1;
156            let Some((key, value)) = line.split_once(char::is_whitespace) else {
157                return Err(format!("line {number}: expected `<key> <value>`"));
158            };
159            match key {
160                "api" => api = Some(value.trim().to_string()),
161                "secret" => secret = Some(value.trim().to_string()),
162                "acl" => acl = Some(value.trim().to_string()),
163                "accounts" => accounts = Some(value.trim().to_string()),
164                other => return Err(format!("line {number}: unknown key `{other}`")),
165            }
166        }
167        match (api, secret) {
168            (Some(api), Some(secret)) => Ok(Self {
169                api,
170                secret,
171                acl,
172                accounts,
173            }),
174            (None, _) => Err("no `api` line".to_string()),
175            (_, None) => Err("no `secret` line".to_string()),
176        }
177    }
178
179    /// Reads and parses the file at `path`.
180    ///
181    /// # Errors
182    ///
183    /// Returns a message when the file cannot be read or does not parse.
184    pub fn load(path: &Path) -> Result<Self, String> {
185        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
186        Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
187    }
188}
189
190/// Writes a [`Handoff`] file, readable only by the user that wrote it.
191///
192/// # Errors
193///
194/// Any I/O error creating, writing or chmod-ing the file.
195pub fn write_handoff(
196    path: &Path,
197    api: &str,
198    secret: &str,
199    acl: Option<&Path>,
200    accounts: Option<&Path>,
201) -> std::io::Result<()> {
202    if let Some(parent) = path.parent() {
203        std::fs::create_dir_all(parent)?;
204    }
205    let acl = acl.map_or(String::new(), |path| format!("acl {}\n", path.display()));
206    let accounts = accounts.map_or(String::new(), |path| {
207        format!("accounts {}\n", path.display())
208    });
209    std::fs::write(
210        path,
211        format!(
212            "# choir ssh handoff, rewritten on every start. Not a file to \
213             share: the secret line authenticates the sequencer callback.\n\
214             api {api}\nsecret {secret}\n{acl}{accounts}"
215        ),
216    )?;
217    #[cfg(unix)]
218    {
219        use std::os::unix::fs::PermissionsExt;
220        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
221    }
222    Ok(())
223}
224
225/// Splits a command line the way sshd's client wrote it: single quotes,
226/// no expansion, no operators.
227///
228/// git builds this string itself (`git-upload-pack '<path>'`, quoting by
229/// the same rules `sq_quote` uses), so the grammar accepted here is
230/// deliberately the smallest one that reads what git writes. Anything
231/// carrying shell punctuation is refused rather than interpreted: the
232/// shim never runs a shell, but a string it cannot fully account for is
233/// one it should not act on either.
234fn split_words(command: &str) -> Result<Vec<String>, String> {
235    let mut words = Vec::new();
236    let mut word = String::new();
237    let mut started = false;
238    let mut quoted = false;
239    for c in command.chars() {
240        if quoted {
241            if c == '\'' {
242                quoted = false;
243            } else {
244                word.push(c);
245            }
246            continue;
247        }
248        match c {
249            '\'' => {
250                quoted = true;
251                started = true;
252            }
253            c if c.is_whitespace() => {
254                if started {
255                    words.push(std::mem::take(&mut word));
256                    started = false;
257                }
258            }
259            ';' | '&' | '|' | '<' | '>' | '`' | '$' | '(' | ')' | '{' | '}' | '\\' | '"' | '*'
260            | '?' | '#' | '~' | '=' | '!' | '\n' => {
261                return Err(format!("`{c}` is not allowed in a git command over ssh"));
262            }
263            c => {
264                word.push(c);
265                started = true;
266            }
267        }
268    }
269    if quoted {
270        return Err("unterminated quote".to_string());
271    }
272    if started {
273        words.push(word);
274    }
275    Ok(words)
276}
277
278/// Parses the command sshd put in `SSH_ORIGINAL_COMMAND`.
279///
280/// Accepts exactly what a git client sends — `git-upload-pack '<repo>'`,
281/// `git-receive-pack '<repo>'`, and the dashless `git upload-pack
282/// '<repo>'` spelling — with exactly one argument. Everything else,
283/// `git-upload-archive` included, is refused: this account exists to
284/// serve two services and a surface it does not serve is a surface it
285/// cannot get wrong.
286///
287/// # Errors
288///
289/// Returns a message safe to print to whoever ran the command.
290pub fn parse_command(original: &str) -> Result<Request, String> {
291    let mut parts = split_words(original)?;
292    // `git upload-pack <repo>` is the same request as `git-upload-pack
293    // <repo>`; join the two spellings before looking at the verb.
294    if parts.first().map(String::as_str) == Some("git") && parts.len() > 1 {
295        let joined = format!("git-{}", parts[1]);
296        parts.splice(0..2, [joined]);
297    }
298    let (verb, rest) = parts
299        .split_first()
300        .ok_or_else(|| "no command given".to_string())?;
301    let service = match verb.as_str() {
302        "git-upload-pack" => Service::UploadPack,
303        "git-receive-pack" => Service::ReceivePack,
304        other => {
305            return Err(format!(
306                "`{other}` is not served here; this account serves git-upload-pack and \
307                 git-receive-pack"
308            ))
309        }
310    };
311    match rest {
312        [repo] => Ok(Request {
313            service,
314            repo: repo.clone(),
315        }),
316        [] => Err(format!("{verb} needs a repository")),
317        _ => Err(format!("{verb} takes exactly one repository")),
318    }
319}
320
321/// Canonical repository name for a path a client sent: `owner/repo.git`,
322/// the same spelling the smart-HTTP path derives from a URL.
323///
324/// The spelling matters beyond tidiness. Ref names in the op log are
325/// `<repo>:<refname>` with `<repo>` in exactly this form, so a shim that
326/// normalized differently would push into a second, parallel namespace
327/// that looks fine in isolation and never converges with the HTTP one.
328///
329/// Both `owner/repo` and `owner/repo.git` are accepted, because both are
330/// spellings humans type. Two segments are required: a grant cannot be
331/// written for any other shape (see [`crate::acl`]), so a deeper path
332/// could never be authorized anyway.
333///
334/// # Errors
335///
336/// Returns a message naming what is wrong with the path.
337pub fn canonical_repo(raw: &str) -> Result<String, String> {
338    let path = raw.trim_start_matches('/');
339    let segments: Vec<&str> = path.split('/').collect();
340    let [owner, name] = segments.as_slice() else {
341        return Err(format!(
342            "`{raw}` is not a repository; write `owner/repo.git`"
343        ));
344    };
345    for segment in [owner, name] {
346        if segment.is_empty() {
347            return Err(format!(
348                "`{raw}` is not a repository; write `owner/repo.git`"
349            ));
350        }
351        // A leading dot would put `<root>/.choir` — the node's key, its
352        // log, this very handoff file — one well-chosen path away from a
353        // fetch. `.` and `..` fall out of the same rule, so traversal
354        // needs no separate check.
355        if segment.starts_with('.') {
356            return Err(format!("`{segment}` may not start with a dot"));
357        }
358        if let Some(bad) = segment
359            .chars()
360            .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
361        {
362            return Err(format!("`{bad}` is not allowed in a repository name"));
363        }
364    }
365    Ok(format!("{owner}/{}.git", acl::normalize_repo(name)))
366}
367
368/// One invocation's configuration, all of it from the flags the operator
369/// wrote into the forced command.
370#[derive(Debug, Clone)]
371pub struct Shim {
372    /// Repository root, the same directory the daemon serves.
373    pub root: PathBuf,
374    /// The choir user this key belongs to. Written by the operator into
375    /// the key's own `authorized_keys` line; the client cannot reach it.
376    pub user: String,
377    /// The ACL file (D29), re-read on every invocation so an edited grant
378    /// takes effect on the next command rather than on a restart. `None`
379    /// falls back to the ACL the daemon named in the [`Handoff`], and
380    /// only when that is absent too does this become the daemon's
381    /// authenticate-only behaviour: any registered key reaches every
382    /// repository.
383    pub acl_file: Option<PathBuf>,
384    /// The daemon's [`Handoff`] file: where a push reports itself, and
385    /// which ACL the daemon is enforcing. `None` refuses pushes.
386    pub handoff: Option<PathBuf>,
387}
388
389impl Shim {
390    /// The grant this request needs, obtained by asking the HTTP route's
391    /// own mapping about the URL that would do the same thing.
392    ///
393    /// Restating the mapping here would work today and drift later: the
394    /// day a level changes in [`crate::acl::git_requirement`], an SSH
395    /// client would keep getting the old answer. Borrowing it means there
396    /// is one mapping on the node and both transports read it.
397    fn required_level(repo: &str, service: Service) -> Result<Level, String> {
398        let url = format!("/{repo}/{}", service.http_endpoint());
399        acl::git_requirement("POST", &url)
400            .map(|(_, level)| level)
401            .ok_or_else(|| "no such repository".to_string())
402    }
403
404    /// Resolves one `SSH_ORIGINAL_COMMAND` into the git invocation to
405    /// become, or the message to fail with.
406    ///
407    /// # Errors
408    ///
409    /// Every refusal path: an unparseable or unserved command, a name
410    /// that is not a repository, a missing grant, a repository that is
411    /// not there, and a push with nowhere to send the sequencer callback.
412    pub fn decide(&self, original: &str) -> Result<Exec, String> {
413        let request = parse_command(original)?;
414        let repo = canonical_repo(&request.repo)?;
415        let level = Self::required_level(&repo, request.service)?;
416        // Read before the ACL decision and for fetches too, not only for
417        // pushes: the daemon's own ACL path lives in here, so a handoff
418        // that cannot be read is a request whose authorization cannot be
419        // established. That fails the fetch rather than serving it
420        // ungated.
421        let handoff = self.handoff.as_deref().map(Handoff::load).transpose()?;
422        // The flag if the operator wrote one, otherwise whatever the
423        // daemon says it is enforcing. Forgetting `--acl-file` on one
424        // `authorized_keys` line should not be the difference between a
425        // gated repository and an open one.
426        let acl_file = self.acl_file.clone().or_else(|| {
427            handoff
428                .as_ref()
429                .and_then(|h| h.acl.as_deref())
430                .map(PathBuf::from)
431        });
432        // Read the table per invocation rather than caching it: a shim
433        // process handles one command and exits, so "hot reload" here is
434        // just not holding a stale copy.
435        //
436        // Both halves of the daemon's table, for the same reason the
437        // daemon merges them (D36): a grant issued through self-service
438        // lives in the store rather than in the file, and a transport
439        // that consulted only one of them would answer a different
440        // question than the HTTP route answers about the same user.
441        let file_table = acl_file.as_deref().map(Acl::load).transpose()?;
442        let store_table = handoff
443            .as_ref()
444            .and_then(|h| h.accounts.as_deref())
445            .map(|path| crate::accounts::grants_acl(Path::new(path)))
446            .transpose()?;
447        let table = match (file_table, store_table) {
448            (Some(file), Some(store)) => Some(file.merged(&store)),
449            (Some(one), None) | (None, Some(one)) => Some(one),
450            (None, None) => None,
451        };
452        if let Some(table) = table {
453            // Dated here rather than at load, the same rule the HTTP
454            // chokepoint follows (D66): a grant with a lapsed deadline
455            // must not reach a check, and ssh gets one process per
456            // connection so there is nothing cached to go stale.
457            if let Some(denial) = table.at(crate::accounts::now_secs()).check(
458                &self.user,
459                &acl::Scope::Repo(acl::normalize_repo(&repo)),
460                level,
461            ) {
462                return Err(denial.reason);
463            }
464        }
465        let dir = self.root.join(&repo);
466        // Deliberately the same words the ACL uses for a repository the
467        // caller may not read: whether a name is unreadable or absent is
468        // not something an unauthorized caller gets to learn.
469        if !dir.join("objects").is_dir() {
470            return Err("no such repository".to_string());
471        }
472        let mut env = Vec::new();
473        if request.service == Service::ReceivePack {
474            let Some(handoff) = handoff else {
475                return Err(
476                    "this account cannot accept pushes: it was installed without --handoff, \
477                     so a push would bypass the sequencer"
478                        .to_string(),
479                );
480            };
481            let api = handoff.api.trim_end_matches('/');
482            env = vec![
483                ("CHOIR_API".to_string(), format!("{api}/api/git-update")),
484                ("CHOIR_ABORT".to_string(), format!("{api}/api/git-abort")),
485                ("CHOIR_REPO".to_string(), repo),
486                ("CHOIR_USER".to_string(), self.user.clone()),
487                ("CHOIR_INTERNAL".to_string(), handoff.secret),
488            ];
489        }
490        Ok(Exec {
491            verb: request.service.verb(),
492            dir,
493            env,
494        })
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn parses_what_git_sends() {
504        assert_eq!(
505            parse_command("git-upload-pack 'owner/repo.git'").unwrap(),
506            Request {
507                service: Service::UploadPack,
508                repo: "owner/repo.git".to_string()
509            }
510        );
511        assert_eq!(
512            parse_command("git-receive-pack 'owner/repo.git'")
513                .unwrap()
514                .service,
515            Service::ReceivePack
516        );
517        // The dashless spelling, and the leading slash an `ssh://` URL
518        // produces, are the same request.
519        assert_eq!(
520            parse_command("git upload-pack '/owner/repo.git'")
521                .unwrap()
522                .repo,
523            "/owner/repo.git"
524        );
525        // Unquoted is legal too; some clients do not quote a plain name.
526        assert_eq!(
527            parse_command("git-upload-pack owner/repo.git")
528                .unwrap()
529                .repo,
530            "owner/repo.git"
531        );
532    }
533
534    #[test]
535    fn refuses_anything_but_the_two_services() {
536        for command in [
537            "git-upload-archive 'owner/repo.git'",
538            "scp -f /etc/passwd",
539            "sh",
540            "",
541        ] {
542            assert!(parse_command(command).is_err(), "accepted {command:?}");
543        }
544    }
545
546    #[test]
547    fn refuses_shell_punctuation_and_extra_arguments() {
548        for command in [
549            "git-upload-pack 'owner/repo.git'; rm -rf /",
550            "git-upload-pack 'owner/repo.git' && curl evil",
551            "git-upload-pack `whoami`",
552            "git-upload-pack $(whoami)",
553            "git-upload-pack 'a/b.git' 'c/d.git'",
554            "git-upload-pack 'owner/repo.git",
555        ] {
556            assert!(parse_command(command).is_err(), "accepted {command:?}");
557        }
558    }
559
560    #[test]
561    fn punctuation_is_refused_where_the_rule_lives() {
562        // Not what stops an injection — the one-argument rule and the
563        // name charset do that, and both are tested above. This rule is
564        // tested here because it is otherwise invisible: every string it
565        // rejects is also rejected further along, so a mutation that
566        // deleted it would leave every other test green.
567        for command in [
568            "git-upload-pack 'a/b.git' > /tmp/x",
569            "git-upload-pack `id`",
570            "git-upload-pack $(id)",
571            "git-upload-pack a/b.git | tee /tmp/x",
572        ] {
573            assert!(split_words(command).is_err(), "accepted {command:?}");
574        }
575    }
576
577    #[test]
578    fn canonicalizes_to_the_spelling_the_op_log_uses() {
579        for spelling in ["owner/repo", "owner/repo.git", "/owner/repo.git"] {
580            assert_eq!(canonical_repo(spelling).unwrap(), "owner/repo.git");
581        }
582    }
583
584    #[test]
585    fn refuses_paths_that_are_not_two_plain_segments() {
586        for path in [
587            "../etc/passwd",
588            "owner/../../etc/passwd",
589            "a/b/c.git",
590            "owner",
591            "",
592            "/",
593            "owner/",
594            ".choir/node.key",
595            "owner/.choir",
596            "own*er/repo",
597            "owner/re po",
598        ] {
599            assert!(canonical_repo(path).is_err(), "accepted {path:?}");
600        }
601    }
602
603    #[test]
604    fn asks_for_the_level_the_http_route_asks_for() {
605        assert_eq!(
606            Shim::required_level("owner/repo.git", Service::UploadPack).unwrap(),
607            Level::Read
608        );
609        assert_eq!(
610            Shim::required_level("owner/repo.git", Service::ReceivePack).unwrap(),
611            // `propose` since D60, and this test is the proof the
612            // borrowing works: nothing in this file changed to say so.
613            // The refname half is transport-agnostic too, because an SSH
614            // push runs the same `pre-receive` hook and reaches the same
615            // `/api/git-update`.
616            Level::Propose
617        );
618    }
619
620    // A push with no `--handoff` is refused against a real repository in
621    // `tests/it/ssh.rs`; here there is nothing on disk to refuse for the
622    // right reason, and the wrong reason ("no such repository") would
623    // have passed a laxer assertion.
624
625    #[test]
626    fn handoff_round_trips_and_partial_files_are_refused() {
627        let parsed = Handoff::parse("# comment\napi http://127.0.0.1:1/\n\nsecret abc\n").unwrap();
628        assert_eq!(parsed.api, "http://127.0.0.1:1/");
629        assert_eq!(parsed.secret, "abc");
630        assert_eq!(parsed.acl, None);
631        // A daemon enforcing an ACL says so, and the shim inherits it.
632        let gated = Handoff::parse("api http://x\nsecret abc\nacl /etc/choir/acl\n").unwrap();
633        assert_eq!(gated.acl.as_deref(), Some("/etc/choir/acl"));
634        assert!(Handoff::parse("api http://x\n").is_err());
635        assert!(Handoff::parse("secret abc\n").is_err());
636        assert!(Handoff::parse("port 22\n").is_err());
637        assert!(Handoff::parse("api\n").is_err());
638    }
639}