Skip to main content

biscuit_ssh_core/
revocation.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::Error;
5
6/// Shortest accepted identifier, in hex characters (32 bytes).
7const MIN_ID_LEN: usize = 64;
8
9/// Revoked block identifiers, as lowercase hex strings.
10///
11/// Identifiers are block signatures (64 bytes for Ed25519). Shorter entries
12/// are refused, so a truncated copy fails loudly instead of revoking nothing.
13///
14/// Every block of a token has its own revocation identifier, and revoking
15/// any of them revokes the token and every token derived from it by
16/// attenuation. `biscuit-ssh token inspect` prints them.
17#[derive(Debug, Clone, Default)]
18pub struct RevocationList(HashSet<String>);
19
20impl RevocationList {
21    /// Parses one hex identifier per line; blank lines and lines starting
22    /// with `#` are ignored.
23    pub fn parse(text: &str) -> Result<Self, Error> {
24        text.lines()
25            .map(str::trim)
26            .filter(|line| !line.is_empty() && !line.starts_with('#'))
27            .map(|line| {
28                if line.len() < MIN_ID_LEN || line.len() % 2 != 0 || !line.bytes().all(|b| b.is_ascii_hexdigit()) {
29                    return Err(Error::invalid(
30                        "revocation identifier",
31                        "expected at least 32 hex-encoded bytes",
32                    ));
33                }
34                Ok(line.to_ascii_lowercase())
35            })
36            .collect::<Result<_, _>>()
37            .map(Self)
38    }
39
40    /// Reads and parses the file at `path`.
41    pub fn load(path: &Path) -> std::io::Result<Self> {
42        let text = std::fs::read_to_string(path)?;
43        Self::parse(&text).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
44    }
45
46    /// The first of `ids` that is revoked, if any.
47    pub(crate) fn find<'a>(&self, ids: &'a [String]) -> Option<&'a String> {
48        ids.iter().find(|id| self.0.contains(*id))
49    }
50}
51
52/// Lowercase hex encoding, matching [`RevocationList`] entries.
53pub(crate) fn hex(bytes: &[u8]) -> String {
54    bytes.iter().map(|b| format!("{b:02x}")).collect()
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn parses_identifiers() {
63        let (a, b) = ("ab".repeat(64), "cd".repeat(64));
64        let list = RevocationList::parse(&format!("# revoked\n\n{}\n  {b} \n", a.to_uppercase())).unwrap();
65        let ids = ["ef".repeat(64), a];
66        assert_eq!(list.find(&ids), Some(&ids[1]));
67        assert_eq!(list.find(&ids[..1]), None);
68    }
69
70    #[test]
71    fn rejects_malformed_identifiers() {
72        for bad in ["abcd".to_owned(), "a".repeat(129), "z".repeat(128)] {
73            assert!(RevocationList::parse(&bad).is_err(), "{bad}");
74        }
75    }
76}