Skip to main content

biscuit_ssh_core/
verifier.rs

1use std::time::{Duration, Instant};
2
3use biscuit_auth::error::{Logic, RunLimit, Token as TokenError};
4use biscuit_auth::{AuthorizerBuilder, AuthorizerLimits, Biscuit, PublicKey};
5
6use crate::revocation::hex;
7use crate::vocabulary::{request_facts, time_fact, unix_seconds};
8use crate::{AccessRequest, Error, RevocationList};
9
10/// Grants a request when the token holds the matching `right` in its
11/// authority block. Facts added by attenuation blocks are not visible to
12/// verifier policies, so a holder cannot grant themselves new rights.
13pub const DEFAULT_POLICY: &str = "allow if server($s), user($u), right($s, $u);";
14
15/// Longest accepted token, in base64 characters. Real tokens are a few
16/// hundred bytes per block; the limit bounds parsing work on hostile input.
17pub const MAX_TOKEN_LEN: usize = 16 * 1024;
18
19/// Longest lifetime [`Verifier::authorize`] accepts, as it evaluates the
20/// token once per second of it.
21pub const MAX_LIFETIME: Duration = Duration::from_secs(600);
22
23/// Bounds each Datalog evaluation so a crafted token cannot exhaust the
24/// verifier. A request contributes at most ~140 facts (mostly `source_net`
25/// for IPv6).
26const LIMITS: AuthorizerLimits = AuthorizerLimits {
27    max_facts: 1_000,
28    max_iterations: 100,
29    max_time: Duration::from_millis(20),
30};
31
32/// Bounds the total time [`Verifier::authorize`] spends on one request.
33const BUDGET: Duration = Duration::from_millis(500);
34
35/// Decides whether a token allows an access request, and for how long.
36#[derive(Debug, Clone)]
37pub struct Verifier {
38    root_key: PublicKey,
39    policies: AuthorizerBuilder,
40}
41
42/// A successful authorization: the request it was granted for, and for how
43/// long.
44///
45/// Only [`Verifier::authorize`] creates it, so holding one proves that this
46/// exact request was checked; credentials must be derived from it alone.
47#[derive(Debug)]
48pub struct Authorization {
49    request: AccessRequest,
50    revocation_ids: Vec<String>,
51    valid_from: u64,
52    valid_until: u64,
53}
54
55impl Authorization {
56    /// The request that was authorized.
57    pub fn request(&self) -> &AccessRequest {
58        &self.request
59    }
60
61    /// Revocation identifiers of the token's blocks, in order, the authority
62    /// block first.
63    pub fn revocation_ids(&self) -> &[String] {
64        &self.revocation_ids
65    }
66
67    /// Revocation identifier of the token's last block, which names the
68    /// exact token presented (its ancestors have the earlier ones). Logged by
69    /// the services and used as certificate key ID.
70    pub fn token_id(&self) -> &str {
71        // A token always has at least its authority block.
72        self.revocation_ids.last().map_or("", String::as_str)
73    }
74
75    /// Start of the validity window: the request time, in seconds since the
76    /// Unix epoch.
77    pub fn valid_from(&self) -> u64 {
78        self.valid_from
79    }
80
81    /// End of the validity window (exclusive), in seconds since the Unix
82    /// epoch: the token allows the request at every second from
83    /// [`Self::valid_from`] up to, not including, this one.
84    pub fn valid_until(&self) -> u64 {
85        self.valid_until
86    }
87}
88
89impl Verifier {
90    /// Creates a verifier accepting tokens signed by `root_key`, and applying
91    /// `policies` (Datalog policies, see [`DEFAULT_POLICY`]).
92    pub fn new(root_key: PublicKey, policies: &str) -> Result<Self, Error> {
93        let policies = AuthorizerBuilder::new()
94            .code(policies)
95            .map_err(Error::Datalog)?
96            .set_limits(LIMITS);
97        Ok(Self { root_key, policies })
98    }
99
100    /// The root public key tokens must be signed with.
101    pub fn root_key(&self) -> PublicKey {
102        self.root_key
103    }
104
105    /// Checks that `token` allows `request`, and finds how long it keeps
106    /// allowing it, up to `lifetime`.
107    ///
108    /// Token checks are arbitrary Datalog, so the expiry cannot be read from
109    /// them. Instead the request is evaluated at each second (the resolution
110    /// of Biscuit dates) from `request.time`, and the window stops at the
111    /// first refusal: credentials bounded by [`Authorization::valid_until`]
112    /// never outlive what the token allows, whatever its time checks are.
113    ///
114    /// Only a policy refusal ends the window; any other failure (execution
115    /// limits, Datalog errors) rejects the whole request.
116    pub fn authorize(
117        &self,
118        token: &str,
119        request: &AccessRequest,
120        revoked: &RevocationList,
121        lifetime: Duration,
122    ) -> Result<Authorization, Error> {
123        let deadline = Instant::now() + BUDGET;
124        if lifetime < Duration::from_secs(1) || lifetime > MAX_LIFETIME {
125            return Err(Error::invalid("lifetime", "must be between 1 second and 10 minutes"));
126        }
127        let start = unix_seconds(request.time)?;
128        let token = token.trim();
129        if token.len() > MAX_TOKEN_LEN {
130            return Err(Error::invalid("token", "too long"));
131        }
132        let token = Biscuit::from_base64(token, self.root_key).map_err(Error::InvalidToken)?;
133
134        let revocation_ids: Vec<String> = token.revocation_identifiers().iter().map(|id| hex(id)).collect();
135        if let Some(id) = revoked.find(&revocation_ids) {
136            return Err(Error::Revoked(id.clone()));
137        }
138
139        let mut context = self.policies.clone();
140        for fact in request_facts(request) {
141            context = context.fact(fact).map_err(Error::Datalog)?;
142        }
143        let evaluate = |second| context.clone().fact(time_fact(second))?.build(&token)?.authorize();
144
145        evaluate(start).map_err(refusal)?;
146        let end = start
147            .checked_add(lifetime.as_secs())
148            .ok_or(Error::invalid("time", "too far in the future"))?;
149        let mut valid_until = end;
150        for second in start + 1..end {
151            if Instant::now() > deadline {
152                return Err(Error::Overloaded);
153            }
154            match evaluate(second) {
155                Ok(_) => {}
156                Err(TokenError::FailedLogic(Logic::Unauthorized { .. } | Logic::NoMatchingPolicy { .. })) => {
157                    valid_until = second;
158                    break;
159                }
160                Err(e) => return Err(refusal(e)),
161            }
162        }
163        Ok(Authorization {
164            request: request.clone(),
165            revocation_ids,
166            valid_from: start,
167            valid_until,
168        })
169    }
170}
171
172/// The error for a failed evaluation.
173///
174/// Running out of time may come from the load, so it is told apart from a
175/// refusal: the issuer answers it as a temporary failure, and the bastion
176/// retries while the lease lasts. Too many facts or iterations depend on the
177/// token alone, and stay a refusal.
178fn refusal(error: TokenError) -> Error {
179    match error {
180        TokenError::RunLimit(RunLimit::Timeout) => Error::Overloaded,
181        error => Error::Denied(error),
182    }
183}
184
185/// The token's own checks refusing `request`, as a hint to explain a
186/// refusal to the token holder.
187///
188/// Only the token's checks are evaluated, under a policy allowing anything:
189/// the verifier's policies, and any facts or rules they add, are unknown to
190/// the caller and never involved. An empty result only means that no token
191/// check fails in this context, not why the verifier refused. The returned
192/// checks quote the token's Datalog: show them to its holder, never log them.
193pub fn failed_checks(root_key: PublicKey, token: &str, request: &AccessRequest) -> Result<Vec<String>, Error> {
194    let verifier = Verifier::new(root_key, "allow if true;")?;
195    let result = verifier.authorize(token, request, &RevocationList::default(), Duration::from_secs(1));
196    match result {
197        Ok(_) => Ok(Vec::new()),
198        Err(Error::Denied(TokenError::FailedLogic(
199            Logic::Unauthorized { checks, .. } | Logic::NoMatchingPolicy { checks },
200        ))) => Ok(checks.iter().map(ToString::to_string).collect()),
201        Err(e) => Err(e),
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use biscuit_auth::{BlockBuilder, KeyPair, UnverifiedBiscuit};
208
209    use std::time::{SystemTime, UNIX_EPOCH};
210
211    use super::*;
212    use crate::{Mode, Network, Restriction, Right, attenuate, mint};
213
214    const KEY: &str = "SHA256:test";
215    const LIFETIME: Duration = Duration::from_secs(120);
216    /// A fixed request time: tokens are evaluated against request facts,
217    /// never against the wall clock.
218    const T0: u64 = 2_000_000_000;
219
220    fn at(seconds: u64) -> SystemTime {
221        UNIX_EPOCH + Duration::from_secs(seconds)
222    }
223
224    fn request(server: &str, user: &str, ip: &str) -> AccessRequest {
225        AccessRequest {
226            time: at(T0),
227            source_ip: ip.parse().unwrap(),
228            server: server.parse().unwrap(),
229            user: user.parse().unwrap(),
230            command: None,
231            public_key: KEY.to_owned(),
232            mode: Mode::Certificate,
233        }
234    }
235
236    fn setup(restrictions: &[Restriction]) -> (Verifier, KeyPair, String) {
237        let root = KeyPair::new();
238        let rights: Vec<Right> = vec!["web-1:deploy".parse().unwrap(), "web-2:admin".parse().unwrap()];
239        let token = mint(&root, &rights, restrictions).unwrap();
240        (Verifier::new(root.public(), DEFAULT_POLICY).unwrap(), root, token)
241    }
242
243    fn authorize(verifier: &Verifier, token: &str, request: &AccessRequest) -> Result<Authorization, Error> {
244        verifier.authorize(token, request, &RevocationList::default(), LIFETIME)
245    }
246
247    fn allowed(verifier: &Verifier, token: &str, request: &AccessRequest) -> bool {
248        authorize(verifier, token, request).is_ok()
249    }
250
251    /// Appends raw Datalog to a token, as a malicious holder could.
252    fn append_code(token: &str, code: &str) -> String {
253        let block = BlockBuilder::new().code(code).unwrap();
254        let token = UnverifiedBiscuit::from_base64(token).unwrap();
255        token.append(block).unwrap().to_base64().unwrap()
256    }
257
258    #[test]
259    fn tells_overload_from_refusal() {
260        assert!(matches!(
261            refusal(TokenError::RunLimit(RunLimit::Timeout)),
262            Error::Overloaded
263        ));
264        assert_eq!(Error::Overloaded.kind(), "overloaded");
265        for limit in [RunLimit::TooManyFacts, RunLimit::TooManyIterations] {
266            assert!(matches!(refusal(TokenError::RunLimit(limit)), Error::Denied(_)));
267        }
268    }
269
270    #[test]
271    fn explains_refusals_with_the_token_checks_only() {
272        let (v, _, token) = setup(&[Restriction::Servers(vec!["web-2".parse().unwrap()])]);
273        let failed = failed_checks(v.root_key(), &token, &request("web-1", "deploy", "192.0.2.7")).unwrap();
274        assert_eq!(failed.len(), 1);
275        assert!(failed[0].contains("web-2"), "{failed:?}");
276
277        // No token check fails: the refusal comes from the policy (no right).
278        let failed = failed_checks(v.root_key(), &token, &request("web-2", "deploy", "192.0.2.7")).unwrap();
279        assert!(failed.is_empty());
280        assert!(!allowed(&v, &token, &request("web-2", "deploy", "192.0.2.7")));
281    }
282
283    #[test]
284    fn lists_only_authority_rights() {
285        let (v, _, token) = setup(&[]);
286        let forged = append_code(&token, r#"right("web-9", "root");"#);
287        let rights: Vec<String> = crate::granted_rights(v.root_key(), &forged)
288            .unwrap()
289            .iter()
290            .map(ToString::to_string)
291            .collect();
292        assert_eq!(rights, ["web-1:deploy", "web-2:admin"]);
293        let (_, other_root, _) = setup(&[]);
294        assert!(crate::granted_rights(other_root.public(), &token).is_err());
295    }
296
297    #[test]
298    fn grants_only_listed_rights() {
299        let (v, _, token) = setup(&[]);
300        assert!(allowed(&v, &token, &request("web-1", "deploy", "192.0.2.7")));
301        assert!(allowed(&v, &token, &request("web-2", "admin", "192.0.2.7")));
302        assert!(!allowed(&v, &token, &request("web-1", "admin", "192.0.2.7")));
303        assert!(!allowed(&v, &token, &request("web-3", "deploy", "192.0.2.7")));
304    }
305
306    #[test]
307    fn rejects_foreign_corrupt_or_oversized_tokens() {
308        let (v, _, _) = setup(&[]);
309        let (_, _, foreign) = setup(&[]);
310        let req = request("web-1", "deploy", "192.0.2.7");
311        assert!(matches!(authorize(&v, &foreign, &req), Err(Error::InvalidToken(_))));
312        assert!(matches!(authorize(&v, "garbage", &req), Err(Error::InvalidToken(_))));
313        let huge = "A".repeat(MAX_TOKEN_LEN + 1);
314        assert!(matches!(authorize(&v, &huge, &req), Err(Error::InvalidInput { .. })));
315    }
316
317    #[test]
318    fn rejects_invalid_times_and_lifetimes() {
319        let (v, _, token) = setup(&[]);
320        let mut req = request("web-1", "deploy", "192.0.2.7");
321        let none = RevocationList::default();
322        for lifetime in [
323            Duration::ZERO,
324            Duration::from_millis(999),
325            MAX_LIFETIME + Duration::from_secs(1),
326        ] {
327            assert!(matches!(
328                v.authorize(&token, &req, &none, lifetime),
329                Err(Error::InvalidInput { .. })
330            ));
331        }
332        req.time = UNIX_EPOCH - Duration::from_secs(1);
333        assert!(matches!(authorize(&v, &token, &req), Err(Error::InvalidInput { .. })));
334        assert!(Restriction::ExpiresAt(req.time).to_check().is_err());
335    }
336
337    #[test]
338    fn window_is_capped_by_expiration() {
339        let req = request("web-1", "deploy", "192.0.2.7");
340
341        let (v, _, token) = setup(&[]);
342        assert_eq!(authorize(&v, &token, &req).unwrap().valid_until(), T0 + 120);
343
344        let (v, _, token) = setup(&[Restriction::ExpiresAt(at(T0 + 30))]);
345        assert_eq!(authorize(&v, &token, &req).unwrap().valid_until(), T0 + 31);
346
347        let (v, _, token) = setup(&[Restriction::ExpiresAt(at(T0 - 1))]);
348        assert!(matches!(authorize(&v, &token, &req), Err(Error::Denied(_))));
349    }
350
351    #[test]
352    fn window_stops_at_first_refusal() {
353        let (v, _, token) = setup(&[]);
354        let gap = format!(
355            "check if time($t), $t < {} or time($t), $t > {};",
356            rfc3339(T0 + 10),
357            rfc3339(T0 + 20)
358        );
359        let token = append_code(&token, &gap);
360        let req = request("web-1", "deploy", "192.0.2.7");
361        assert_eq!(authorize(&v, &token, &req).unwrap().valid_until(), T0 + 10);
362    }
363
364    #[test]
365    fn forged_facts_do_not_bypass_earlier_checks() {
366        let (v, _, token) = setup(&[
367            Restriction::ExpiresAt(at(T0 - 1)),
368            Restriction::Servers(vec!["web-2".parse().unwrap()]),
369        ]);
370        let forged = append_code(&token, &format!(r#"time({}); server("web-2");"#, rfc3339(T0 - 10)));
371        assert!(!allowed(&v, &forged, &request("web-2", "admin", "192.0.2.7")));
372    }
373
374    #[test]
375    fn enforces_source_networks() {
376        let nets = vec![
377            "192.0.2.0/24".parse().unwrap(),
378            "2001:db8::/32".parse::<Network>().unwrap(),
379        ];
380        let (v, _, token) = setup(&[Restriction::SourceNetworks(nets)]);
381        assert!(allowed(&v, &token, &request("web-1", "deploy", "192.0.2.7")));
382        assert!(allowed(&v, &token, &request("web-1", "deploy", "::ffff:192.0.2.7")));
383        assert!(allowed(&v, &token, &request("web-1", "deploy", "2001:db8::1")));
384        assert!(!allowed(&v, &token, &request("web-1", "deploy", "198.51.100.1")));
385    }
386
387    #[test]
388    fn host_networks_match_whatever_the_spelling() {
389        for (restriction, client) in [
390            ("::ffff:192.0.2.7", "192.0.2.7"),
391            ("192.0.2.7", "::ffff:192.0.2.7"),
392            ("::ffff:192.0.2.7/128", "192.0.2.7"),
393            ("2001:DB8:0::1", "2001:db8::1"),
394            ("2001:db8::1/128", "2001:db8::1"),
395        ] {
396            let (v, _, token) = setup(&[Restriction::SourceNetworks(vec![restriction.parse().unwrap()])]);
397            assert!(
398                allowed(&v, &token, &request("web-1", "deploy", client)),
399                "{restriction} vs {client}"
400            );
401        }
402    }
403
404    #[test]
405    fn enforces_commands_and_keys() {
406        let (v, _, token) = setup(&[
407            Restriction::Commands(vec!["uptime".parse().unwrap()]),
408            Restriction::PublicKeys(vec![KEY.to_owned()]),
409        ]);
410        let mut req = request("web-1", "deploy", "192.0.2.7");
411        assert!(!allowed(&v, &token, &req), "an interactive session must be refused");
412        req.command = Some("uptime".parse().unwrap());
413        assert!(allowed(&v, &token, &req));
414        req.command = Some("reboot".parse().unwrap());
415        assert!(!allowed(&v, &token, &req));
416        req.command = Some("uptime".parse().unwrap());
417        req.public_key = "SHA256:other".to_owned();
418        assert!(!allowed(&v, &token, &req));
419    }
420
421    #[test]
422    fn attenuation_only_narrows() {
423        let (v, _, token) = setup(&[]);
424        let narrowed = attenuate(&token, &[Restriction::Servers(vec!["web-2".parse().unwrap()])]).unwrap();
425        assert!(allowed(&v, &narrowed, &request("web-2", "admin", "192.0.2.7")));
426        assert!(!allowed(&v, &narrowed, &request("web-1", "deploy", "192.0.2.7")));
427
428        // A right asserted by an attenuation block is ignored by the policy.
429        let forged = append_code(&token, r#"right("web-9", "root");"#);
430        assert!(!allowed(&v, &forged, &request("web-9", "root", "192.0.2.7")));
431    }
432
433    #[test]
434    fn honors_revocation() {
435        let (v, _, token) = setup(&[]);
436        let narrowed = attenuate(&token, &[Restriction::Mode(Mode::Certificate)]).unwrap();
437        let req = request("web-1", "deploy", "192.0.2.7");
438        let auth = authorize(&v, &narrowed, &req).unwrap();
439        assert_eq!(auth.valid_from(), T0);
440        assert_eq!(auth.token_id(), auth.revocation_ids()[1]);
441        let ids = auth.revocation_ids();
442        assert_eq!(ids.len(), 2);
443
444        // Revoking the root block revokes every derived token.
445        let revoked = RevocationList::parse(&ids[0]).unwrap();
446        for token in [&narrowed, &token] {
447            assert!(matches!(
448                v.authorize(token, &req, &revoked, LIFETIME),
449                Err(Error::Revoked(_))
450            ));
451        }
452    }
453
454    /// Formats a Biscuit date literal.
455    fn rfc3339(seconds: u64) -> String {
456        let check = Restriction::ExpiresAt(at(seconds)).to_check().unwrap().to_string();
457        // "check if time($t), $t <= <date>"
458        check.rsplit(' ').next().unwrap().to_owned()
459    }
460}