Skip to main content

anvil_ssh/
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 anvil_ssh::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::AnvilError;
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 [`AnvilError`] on the first malformed line.
81    pub fn parse(input: &str) -> Result<Self, AnvilError> {
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                AnvilError::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 [`AnvilError`] if the file cannot be read or contains
101    /// malformed lines.
102    pub fn load(path: &Path) -> Result<Self, AnvilError> {
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 every principal whose entry's public key equals `public_key`,
150    /// regardless of any per-entry `namespaces="..."` restriction.
151    ///
152    /// Mirrors upstream `ssh-keygen -Y find-principals`, whose synopsis takes
153    /// only `-s signature_file -f allowed_signers_file` — namespace filtering
154    /// is not part of the find-principals operation. The namespace-aware
155    /// variant [`find_principals`] remains available for callers that want
156    /// the stricter semantics.
157    #[must_use]
158    pub fn find_principals_any_ns<'a>(&'a self, public_key: &PublicKey) -> Vec<&'a str> {
159        let mut out = Vec::new();
160        for entry in &self.entries {
161            if entry.public_key != *public_key {
162                continue;
163            }
164            for p in &entry.principals {
165                out.push(p.as_str());
166            }
167        }
168        out
169    }
170
171    /// Returns `true` if any entry authorizes `identity` to sign under
172    /// `namespace` with `public_key`.
173    ///
174    /// `identity` is matched against each entry's principal patterns using
175    /// fnmatch-style globs (`*`, `?`, character classes). Negation prefixes
176    /// (`!pattern`) are honored — a matching negation rejects the entry.
177    #[must_use]
178    pub fn is_authorized(&self, identity: &str, public_key: &PublicKey, namespace: &str) -> bool {
179        for entry in &self.entries {
180            if entry.public_key != *public_key {
181                continue;
182            }
183            if let Some(ref allowed) = entry.namespaces {
184                if !allowed.iter().any(|ns| ns == namespace) {
185                    continue;
186                }
187            }
188            if principals_match(&entry.principals, identity) {
189                return true;
190            }
191        }
192        false
193    }
194}
195
196// ── Parser helpers ────────────────────────────────────────────────────────────
197
198/// Parses a single non-blank, non-comment line.
199fn parse_line(line: &str) -> Result<Entry, String> {
200    let mut rest = line;
201
202    // 1. Principals (possibly quoted).
203    let (principals_raw, after) = take_field(rest)?;
204    rest = after.trim_start();
205    let principals = split_principals(&principals_raw);
206    if principals.is_empty() {
207        return Err("empty principals list".to_owned());
208    }
209
210    // 2. Optional options section, then key-type, then base64 key.
211    //
212    // Options are recognised by not being a known SSH key algorithm name.
213    // OpenSSH's ssh-keygen uses the same heuristic.
214    let (maybe_options, after) = take_field(rest)?;
215    let (options_str, key_type, key_base64) = if is_ssh_key_algorithm(&maybe_options) {
216        let (kt, after2) = (maybe_options, after);
217        let (kb, _after3) = take_field(after2.trim_start())?;
218        (String::new(), kt, kb)
219    } else {
220        rest = after.trim_start();
221        let (kt, after2) = take_field(rest)?;
222        if !is_ssh_key_algorithm(&kt) {
223            return Err(format!("expected key algorithm, got {kt:?}"));
224        }
225        let (kb, _after3) = take_field(after2.trim_start())?;
226        (maybe_options, kt, kb)
227    };
228
229    let (namespaces, cert_authority) = parse_options(&options_str);
230
231    // 3. Reassemble the OpenSSH public-key line and parse it.
232    let openssh = format!("{key_type} {key_base64}");
233    let public_key =
234        PublicKey::from_openssh(&openssh).map_err(|e| format!("invalid public key: {e}"))?;
235
236    Ok(Entry {
237        principals,
238        namespaces,
239        cert_authority,
240        public_key,
241    })
242}
243
244/// Consumes the next whitespace-delimited field, honoring `"quoted strings"`.
245fn take_field(input: &str) -> Result<(String, &str), String> {
246    let input = input.trim_start();
247    if input.is_empty() {
248        return Err("unexpected end of line".to_owned());
249    }
250    if let Some(stripped) = input.strip_prefix('"') {
251        let end = stripped
252            .find('"')
253            .ok_or_else(|| "unterminated quoted string".to_owned())?;
254        let field = stripped[..end].to_owned();
255        let remainder = &stripped[end + 1..];
256        Ok((field, remainder))
257    } else {
258        let end = input.find(char::is_whitespace).unwrap_or(input.len());
259        Ok((input[..end].to_owned(), &input[end..]))
260    }
261}
262
263/// Splits a comma-separated principals field into individual patterns.
264fn split_principals(field: &str) -> Vec<String> {
265    field
266        .split(',')
267        .map(str::trim)
268        .filter(|s| !s.is_empty())
269        .map(std::borrow::ToOwned::to_owned)
270        .collect()
271}
272
273/// Parses the options field into `(namespaces, cert_authority)`.
274///
275/// Unknown options (including `valid-after`, `valid-before`,
276/// `verify-required`) are silently accepted and ignored — callers that need
277/// time-bound verification must check them at a higher layer.
278fn parse_options(options: &str) -> (Option<Vec<String>>, bool) {
279    if options.is_empty() {
280        return (None, false);
281    }
282    let mut namespaces = None;
283    let mut cert_authority = false;
284    for opt in split_options(options) {
285        if opt.eq_ignore_ascii_case("cert-authority") {
286            cert_authority = true;
287        } else if let Some(value) = opt.strip_prefix("namespaces=") {
288            let trimmed = value.trim_matches('"');
289            namespaces = Some(
290                trimmed
291                    .split(',')
292                    .map(str::trim)
293                    .filter(|s| !s.is_empty())
294                    .map(std::borrow::ToOwned::to_owned)
295                    .collect(),
296            );
297        }
298    }
299    (namespaces, cert_authority)
300}
301
302/// Splits an options string on commas, respecting `"quoted"` values.
303fn split_options(input: &str) -> Vec<String> {
304    let mut out = Vec::new();
305    let mut current = String::new();
306    let mut in_quote = false;
307    for c in input.chars() {
308        match c {
309            '"' => {
310                in_quote = !in_quote;
311                current.push(c);
312            }
313            ',' if !in_quote => {
314                let s = current.trim().to_owned();
315                if !s.is_empty() {
316                    out.push(s);
317                }
318                current.clear();
319            }
320            _ => current.push(c),
321        }
322    }
323    let s = current.trim().to_owned();
324    if !s.is_empty() {
325        out.push(s);
326    }
327    out
328}
329
330/// Returns `true` when `s` names an SSH public-key algorithm understood by
331/// `ssh-key` 0.6.
332fn is_ssh_key_algorithm(s: &str) -> bool {
333    matches!(
334        s,
335        "ssh-ed25519"
336            | "ssh-rsa"
337            | "rsa-sha2-256"
338            | "rsa-sha2-512"
339            | "ecdsa-sha2-nistp256"
340            | "ecdsa-sha2-nistp384"
341            | "ecdsa-sha2-nistp521"
342            | "ssh-dss"
343            | "sk-ssh-ed25519@openssh.com"
344            | "sk-ecdsa-sha2-nistp256@openssh.com"
345    )
346}
347
348/// Tests whether `identity` matches any positive pattern without being
349/// rejected by a negation (`!pattern`).
350fn principals_match(patterns: &[String], identity: &str) -> bool {
351    let mut matched = false;
352    for p in patterns {
353        let (negated, pat) = p
354            .strip_prefix('!')
355            .map_or((false, p.as_str()), |rest| (true, rest));
356        if glob_match(pat, identity) {
357            if negated {
358                return false;
359            }
360            matched = true;
361        }
362    }
363    matched
364}
365
366/// Fnmatch-style matcher supporting `*` and `?`. Case-sensitive.
367fn glob_match(pattern: &str, text: &str) -> bool {
368    let p: Vec<char> = pattern.chars().collect();
369    let t: Vec<char> = text.chars().collect();
370    glob_match_inner(&p, 0, &t, 0)
371}
372
373fn glob_match_inner(p: &[char], mut pi: usize, t: &[char], mut ti: usize) -> bool {
374    while pi < p.len() {
375        match p[pi] {
376            '*' => {
377                if pi + 1 == p.len() {
378                    return true;
379                }
380                for skip in ti..=t.len() {
381                    if glob_match_inner(p, pi + 1, t, skip) {
382                        return true;
383                    }
384                }
385                return false;
386            }
387            '?' => {
388                if ti >= t.len() {
389                    return false;
390                }
391                pi += 1;
392                ti += 1;
393            }
394            c => {
395                if ti >= t.len() || t[ti] != c {
396                    return false;
397                }
398                pi += 1;
399                ti += 1;
400            }
401        }
402    }
403    ti == t.len()
404}
405
406// ── Tests ─────────────────────────────────────────────────────────────────────
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    const SAMPLE_ED25519: &str =
413        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEr3gQn+Fg1J1K5HT+0n2N1iA3Gn+Yx3hQJ3z4PxZQ7J tim@example.com";
414
415    #[test]
416    fn parse_single_entry() {
417        let input = format!("tim@example.com {SAMPLE_ED25519}");
418        let signers = AllowedSigners::parse(&input).unwrap();
419        assert_eq!(signers.len(), 1);
420        assert_eq!(signers.entries()[0].principals, vec!["tim@example.com"]);
421        assert!(signers.entries()[0].namespaces.is_none());
422    }
423
424    #[test]
425    fn parse_skips_blanks_and_comments() {
426        let input =
427            format!("\n# top comment\n\n   # indented comment\ntim@example.com {SAMPLE_ED25519}\n");
428        let signers = AllowedSigners::parse(&input).unwrap();
429        assert_eq!(signers.len(), 1);
430    }
431
432    #[test]
433    fn parse_namespaces_option() {
434        let input = format!("tim@example.com namespaces=\"git,file\" {SAMPLE_ED25519}");
435        let signers = AllowedSigners::parse(&input).unwrap();
436        let ns = signers.entries()[0].namespaces.as_ref().unwrap();
437        assert_eq!(ns, &vec!["git".to_owned(), "file".to_owned()]);
438    }
439
440    #[test]
441    fn parse_multiple_principals_and_quoted() {
442        let input = format!("\"alice@example.com,bob@example.com\" {SAMPLE_ED25519}");
443        let signers = AllowedSigners::parse(&input).unwrap();
444        assert_eq!(
445            signers.entries()[0].principals,
446            vec!["alice@example.com", "bob@example.com"]
447        );
448    }
449
450    #[test]
451    fn parse_cert_authority() {
452        let input = format!("*@example.com cert-authority {SAMPLE_ED25519}");
453        let signers = AllowedSigners::parse(&input).unwrap();
454        assert!(signers.entries()[0].cert_authority);
455    }
456
457    #[test]
458    fn glob_matches_wildcard() {
459        assert!(glob_match("*@example.com", "tim@example.com"));
460        assert!(!glob_match("*@example.com", "tim@other.org"));
461        assert!(glob_match("*", ""));
462        assert!(glob_match("a?c", "abc"));
463        assert!(!glob_match("a?c", "ac"));
464    }
465
466    #[test]
467    fn is_authorized_respects_negation() {
468        let input = format!("*@example.com,!evil@example.com {SAMPLE_ED25519}");
469        let signers = AllowedSigners::parse(&input).unwrap();
470        let key = &signers.entries()[0].public_key;
471        assert!(signers.is_authorized("tim@example.com", key, "git"));
472        assert!(!signers.is_authorized("evil@example.com", key, "git"));
473    }
474
475    #[test]
476    fn is_authorized_respects_namespace_restriction() {
477        let input = format!("tim@example.com namespaces=\"git\" {SAMPLE_ED25519}");
478        let signers = AllowedSigners::parse(&input).unwrap();
479        let key = &signers.entries()[0].public_key;
480        assert!(signers.is_authorized("tim@example.com", key, "git"));
481        assert!(!signers.is_authorized("tim@example.com", key, "file"));
482    }
483
484    #[test]
485    fn rejects_missing_key() {
486        let err = AllowedSigners::parse("tim@example.com\n").unwrap_err();
487        assert!(err.to_string().contains("line 1"));
488    }
489}