Skip to main content

gitway_lib/
allowed_signers.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-04-21
3//! Parser for the OpenSSH `allowed_signers` file format.
4//!
5//! Git uses this file to map SSH public keys to the principals (usually email
6//! addresses) that are authorized to sign commits under a given namespace.
7//! The format is documented in `ssh-keygen(1)` under the `ALLOWED SIGNERS`
8//! heading.
9//!
10//! Each non-blank, non-comment line has the form:
11//!
12//! ```text
13//! principals [options] key-type base64-key [comment]
14//! ```
15//!
16//! - `principals` is a comma-separated list of fnmatch-style patterns (a
17//!   quoted string if any pattern contains spaces).
18//! - `options` is an optional comma-separated list of `key[="value"]` pairs.
19//!   Only `namespaces="<list>"` is honored for git's purposes.
20//! - `key-type` + `base64-key` is the public key, in the same wire form used
21//!   by `authorized_keys`.
22//!
23//! # Examples
24//!
25//! ```no_run
26//! use gitway_lib::allowed_signers::AllowedSigners;
27//!
28//! let signers = AllowedSigners::load(std::path::Path::new("~/.config/git/allowed_signers"))
29//!     .unwrap();
30//! for entry in signers.entries() {
31//!     println!("{:?}", entry.principals);
32//! }
33//! ```
34//!
35//! # Errors
36//!
37//! [`AllowedSigners::parse`] rejects lines that are syntactically ill-formed
38//! (missing key type, unterminated quoted principals, invalid base64). Blank
39//! lines and `#`-comments are skipped silently.
40
41use std::fs;
42use std::path::Path;
43
44use ssh_key::PublicKey;
45
46use crate::GitwayError;
47
48// ── Types ─────────────────────────────────────────────────────────────────────
49
50/// A single principal-to-key mapping parsed from an `allowed_signers` file.
51#[derive(Debug, Clone)]
52pub struct Entry {
53    /// Fnmatch-style patterns separated by commas in the source file.
54    ///
55    /// Each pattern may be prefixed with `!` for negation, as in OpenSSH's
56    /// `Match` block syntax.
57    pub principals: Vec<String>,
58    /// Comma-separated list of namespaces the key is authorized to sign under,
59    /// parsed from a `namespaces="..."` option.
60    ///
61    /// `None` means "any namespace is accepted" (the default per OpenSSH).
62    pub namespaces: Option<Vec<String>>,
63    /// Whether the entry is marked as a certificate authority (`cert-authority`).
64    pub cert_authority: bool,
65    /// The public key in OpenSSH wire form.
66    pub public_key: PublicKey,
67}
68
69/// The parsed contents of an `allowed_signers` file.
70#[derive(Debug, Clone)]
71pub struct AllowedSigners {
72    entries: Vec<Entry>,
73}
74
75impl AllowedSigners {
76    /// Parses an `allowed_signers` document from a string.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`GitwayError`] on the first malformed line.
81    pub fn parse(input: &str) -> Result<Self, GitwayError> {
82        let mut entries = Vec::new();
83        for (lineno, raw) in input.lines().enumerate() {
84            let line = raw.trim();
85            if line.is_empty() || line.starts_with('#') {
86                continue;
87            }
88            let entry = parse_line(line).map_err(|msg| {
89                GitwayError::invalid_config(format!("allowed_signers line {}: {msg}", lineno + 1))
90            })?;
91            entries.push(entry);
92        }
93        Ok(Self { entries })
94    }
95
96    /// Loads and parses an `allowed_signers` file from disk.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`GitwayError`] if the file cannot be read or contains
101    /// malformed lines.
102    pub fn load(path: &Path) -> Result<Self, GitwayError> {
103        let contents = fs::read_to_string(path)?;
104        Self::parse(&contents)
105    }
106
107    /// Returns the number of parsed entries.
108    #[must_use]
109    pub fn len(&self) -> usize {
110        self.entries.len()
111    }
112
113    /// Returns `true` if the file contained no entries.
114    #[must_use]
115    pub fn is_empty(&self) -> bool {
116        self.entries.is_empty()
117    }
118
119    /// Returns all entries.
120    #[must_use]
121    pub fn entries(&self) -> &[Entry] {
122        &self.entries
123    }
124
125    /// Returns the principals authorized to sign under `namespace` with `public_key`.
126    ///
127    /// An entry matches when its public key equals `public_key` exactly and
128    /// either has no `namespaces` restriction or includes `namespace` in its
129    /// list.
130    #[must_use]
131    pub fn find_principals<'a>(&'a self, public_key: &PublicKey, namespace: &str) -> Vec<&'a str> {
132        let mut out = Vec::new();
133        for entry in &self.entries {
134            if entry.public_key != *public_key {
135                continue;
136            }
137            if let Some(ref allowed) = entry.namespaces {
138                if !allowed.iter().any(|ns| ns == namespace) {
139                    continue;
140                }
141            }
142            for p in &entry.principals {
143                out.push(p.as_str());
144            }
145        }
146        out
147    }
148
149    /// Returns `true` if any entry authorizes `identity` to sign under
150    /// `namespace` with `public_key`.
151    ///
152    /// `identity` is matched against each entry's principal patterns using
153    /// fnmatch-style globs (`*`, `?`, character classes). Negation prefixes
154    /// (`!pattern`) are honored — a matching negation rejects the entry.
155    #[must_use]
156    pub fn is_authorized(&self, identity: &str, public_key: &PublicKey, namespace: &str) -> bool {
157        for entry in &self.entries {
158            if entry.public_key != *public_key {
159                continue;
160            }
161            if let Some(ref allowed) = entry.namespaces {
162                if !allowed.iter().any(|ns| ns == namespace) {
163                    continue;
164                }
165            }
166            if principals_match(&entry.principals, identity) {
167                return true;
168            }
169        }
170        false
171    }
172}
173
174// ── Parser helpers ────────────────────────────────────────────────────────────
175
176/// Parses a single non-blank, non-comment line.
177fn parse_line(line: &str) -> Result<Entry, String> {
178    let mut rest = line;
179
180    // 1. Principals (possibly quoted).
181    let (principals_raw, after) = take_field(rest)?;
182    rest = after.trim_start();
183    let principals = split_principals(&principals_raw);
184    if principals.is_empty() {
185        return Err("empty principals list".to_owned());
186    }
187
188    // 2. Optional options section, then key-type, then base64 key.
189    //
190    // Options are recognised by not being a known SSH key algorithm name.
191    // OpenSSH's ssh-keygen uses the same heuristic.
192    let (maybe_options, after) = take_field(rest)?;
193    let (options_str, key_type, key_base64) = if is_ssh_key_algorithm(&maybe_options) {
194        let (kt, after2) = (maybe_options, after);
195        let (kb, _after3) = take_field(after2.trim_start())?;
196        (String::new(), kt, kb)
197    } else {
198        rest = after.trim_start();
199        let (kt, after2) = take_field(rest)?;
200        if !is_ssh_key_algorithm(&kt) {
201            return Err(format!("expected key algorithm, got {kt:?}"));
202        }
203        let (kb, _after3) = take_field(after2.trim_start())?;
204        (maybe_options, kt, kb)
205    };
206
207    let (namespaces, cert_authority) = parse_options(&options_str);
208
209    // 3. Reassemble the OpenSSH public-key line and parse it.
210    let openssh = format!("{key_type} {key_base64}");
211    let public_key =
212        PublicKey::from_openssh(&openssh).map_err(|e| format!("invalid public key: {e}"))?;
213
214    Ok(Entry {
215        principals,
216        namespaces,
217        cert_authority,
218        public_key,
219    })
220}
221
222/// Consumes the next whitespace-delimited field, honoring `"quoted strings"`.
223fn take_field(input: &str) -> Result<(String, &str), String> {
224    let input = input.trim_start();
225    if input.is_empty() {
226        return Err("unexpected end of line".to_owned());
227    }
228    if let Some(stripped) = input.strip_prefix('"') {
229        let end = stripped
230            .find('"')
231            .ok_or_else(|| "unterminated quoted string".to_owned())?;
232        let field = stripped[..end].to_owned();
233        let remainder = &stripped[end + 1..];
234        Ok((field, remainder))
235    } else {
236        let end = input.find(char::is_whitespace).unwrap_or(input.len());
237        Ok((input[..end].to_owned(), &input[end..]))
238    }
239}
240
241/// Splits a comma-separated principals field into individual patterns.
242fn split_principals(field: &str) -> Vec<String> {
243    field
244        .split(',')
245        .map(str::trim)
246        .filter(|s| !s.is_empty())
247        .map(std::borrow::ToOwned::to_owned)
248        .collect()
249}
250
251/// Parses the options field into `(namespaces, cert_authority)`.
252///
253/// Unknown options (including `valid-after`, `valid-before`,
254/// `verify-required`) are silently accepted and ignored — callers that need
255/// time-bound verification must check them at a higher layer.
256fn parse_options(options: &str) -> (Option<Vec<String>>, bool) {
257    if options.is_empty() {
258        return (None, false);
259    }
260    let mut namespaces = None;
261    let mut cert_authority = false;
262    for opt in split_options(options) {
263        if opt.eq_ignore_ascii_case("cert-authority") {
264            cert_authority = true;
265        } else if let Some(value) = opt.strip_prefix("namespaces=") {
266            let trimmed = value.trim_matches('"');
267            namespaces = Some(
268                trimmed
269                    .split(',')
270                    .map(str::trim)
271                    .filter(|s| !s.is_empty())
272                    .map(std::borrow::ToOwned::to_owned)
273                    .collect(),
274            );
275        }
276    }
277    (namespaces, cert_authority)
278}
279
280/// Splits an options string on commas, respecting `"quoted"` values.
281fn split_options(input: &str) -> Vec<String> {
282    let mut out = Vec::new();
283    let mut current = String::new();
284    let mut in_quote = false;
285    for c in input.chars() {
286        match c {
287            '"' => {
288                in_quote = !in_quote;
289                current.push(c);
290            }
291            ',' if !in_quote => {
292                let s = current.trim().to_owned();
293                if !s.is_empty() {
294                    out.push(s);
295                }
296                current.clear();
297            }
298            _ => current.push(c),
299        }
300    }
301    let s = current.trim().to_owned();
302    if !s.is_empty() {
303        out.push(s);
304    }
305    out
306}
307
308/// Returns `true` when `s` names an SSH public-key algorithm understood by
309/// `ssh-key` 0.6.
310fn is_ssh_key_algorithm(s: &str) -> bool {
311    matches!(
312        s,
313        "ssh-ed25519"
314            | "ssh-rsa"
315            | "rsa-sha2-256"
316            | "rsa-sha2-512"
317            | "ecdsa-sha2-nistp256"
318            | "ecdsa-sha2-nistp384"
319            | "ecdsa-sha2-nistp521"
320            | "ssh-dss"
321            | "sk-ssh-ed25519@openssh.com"
322            | "sk-ecdsa-sha2-nistp256@openssh.com"
323    )
324}
325
326/// Tests whether `identity` matches any positive pattern without being
327/// rejected by a negation (`!pattern`).
328fn principals_match(patterns: &[String], identity: &str) -> bool {
329    let mut matched = false;
330    for p in patterns {
331        let (negated, pat) = p
332            .strip_prefix('!')
333            .map_or((false, p.as_str()), |rest| (true, rest));
334        if glob_match(pat, identity) {
335            if negated {
336                return false;
337            }
338            matched = true;
339        }
340    }
341    matched
342}
343
344/// Fnmatch-style matcher supporting `*` and `?`. Case-sensitive.
345fn glob_match(pattern: &str, text: &str) -> bool {
346    let p: Vec<char> = pattern.chars().collect();
347    let t: Vec<char> = text.chars().collect();
348    glob_match_inner(&p, 0, &t, 0)
349}
350
351fn glob_match_inner(p: &[char], mut pi: usize, t: &[char], mut ti: usize) -> bool {
352    while pi < p.len() {
353        match p[pi] {
354            '*' => {
355                if pi + 1 == p.len() {
356                    return true;
357                }
358                for skip in ti..=t.len() {
359                    if glob_match_inner(p, pi + 1, t, skip) {
360                        return true;
361                    }
362                }
363                return false;
364            }
365            '?' => {
366                if ti >= t.len() {
367                    return false;
368                }
369                pi += 1;
370                ti += 1;
371            }
372            c => {
373                if ti >= t.len() || t[ti] != c {
374                    return false;
375                }
376                pi += 1;
377                ti += 1;
378            }
379        }
380    }
381    ti == t.len()
382}
383
384// ── Tests ─────────────────────────────────────────────────────────────────────
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    const SAMPLE_ED25519: &str =
391        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEr3gQn+Fg1J1K5HT+0n2N1iA3Gn+Yx3hQJ3z4PxZQ7J tim@example.com";
392
393    #[test]
394    fn parse_single_entry() {
395        let input = format!("tim@example.com {SAMPLE_ED25519}");
396        let signers = AllowedSigners::parse(&input).unwrap();
397        assert_eq!(signers.len(), 1);
398        assert_eq!(signers.entries()[0].principals, vec!["tim@example.com"]);
399        assert!(signers.entries()[0].namespaces.is_none());
400    }
401
402    #[test]
403    fn parse_skips_blanks_and_comments() {
404        let input =
405            format!("\n# top comment\n\n   # indented comment\ntim@example.com {SAMPLE_ED25519}\n");
406        let signers = AllowedSigners::parse(&input).unwrap();
407        assert_eq!(signers.len(), 1);
408    }
409
410    #[test]
411    fn parse_namespaces_option() {
412        let input = format!("tim@example.com namespaces=\"git,file\" {SAMPLE_ED25519}");
413        let signers = AllowedSigners::parse(&input).unwrap();
414        let ns = signers.entries()[0].namespaces.as_ref().unwrap();
415        assert_eq!(ns, &vec!["git".to_owned(), "file".to_owned()]);
416    }
417
418    #[test]
419    fn parse_multiple_principals_and_quoted() {
420        let input = format!("\"alice@example.com,bob@example.com\" {SAMPLE_ED25519}");
421        let signers = AllowedSigners::parse(&input).unwrap();
422        assert_eq!(
423            signers.entries()[0].principals,
424            vec!["alice@example.com", "bob@example.com"]
425        );
426    }
427
428    #[test]
429    fn parse_cert_authority() {
430        let input = format!("*@example.com cert-authority {SAMPLE_ED25519}");
431        let signers = AllowedSigners::parse(&input).unwrap();
432        assert!(signers.entries()[0].cert_authority);
433    }
434
435    #[test]
436    fn glob_matches_wildcard() {
437        assert!(glob_match("*@example.com", "tim@example.com"));
438        assert!(!glob_match("*@example.com", "tim@other.org"));
439        assert!(glob_match("*", ""));
440        assert!(glob_match("a?c", "abc"));
441        assert!(!glob_match("a?c", "ac"));
442    }
443
444    #[test]
445    fn is_authorized_respects_negation() {
446        let input = format!("*@example.com,!evil@example.com {SAMPLE_ED25519}");
447        let signers = AllowedSigners::parse(&input).unwrap();
448        let key = &signers.entries()[0].public_key;
449        assert!(signers.is_authorized("tim@example.com", key, "git"));
450        assert!(!signers.is_authorized("evil@example.com", key, "git"));
451    }
452
453    #[test]
454    fn is_authorized_respects_namespace_restriction() {
455        let input = format!("tim@example.com namespaces=\"git\" {SAMPLE_ED25519}");
456        let signers = AllowedSigners::parse(&input).unwrap();
457        let key = &signers.entries()[0].public_key;
458        assert!(signers.is_authorized("tim@example.com", key, "git"));
459        assert!(!signers.is_authorized("tim@example.com", key, "file"));
460    }
461
462    #[test]
463    fn rejects_missing_key() {
464        let err = AllowedSigners::parse("tim@example.com\n").unwrap_err();
465        assert!(err.to_string().contains("line 1"));
466    }
467}