Skip to main content

mnemo_core/
auth.rs

1//! Minimal shared-secret bearer-token check for the network entrypoints
2//! (REST + gRPC). This is the floor — "don't run an open memory server" — not
3//! a full auth system: one operator-held secret in `MNEMO_AUTH_TOKEN`, compared
4//! in constant time. There are no users, scopes, or rotation here; per-record
5//! authorization is the separate ACL/RBAC layer in [`crate::model::acl`].
6
7/// Constant-time byte comparison. Returns `false` immediately on a length
8/// mismatch (length is not secret here), otherwise compares every byte so the
9/// time taken does not leak how many leading bytes matched.
10fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
11    if a.len() != b.len() {
12        return false;
13    }
14    let mut diff = 0u8;
15    for (x, y) in a.iter().zip(b.iter()) {
16        diff |= x ^ y;
17    }
18    diff == 0
19}
20
21/// Validate an HTTP/gRPC `Authorization` header value against the expected
22/// shared secret.
23///
24/// Accepts `Authorization: Bearer <token>` (case-insensitive scheme) or a bare
25/// token. Returns `true` only on an exact, constant-time match. A missing
26/// header (`None`) is always `false`.
27///
28/// `expected` is assumed non-empty; an empty expected secret means "auth not
29/// configured" and callers should not even reach this function (run open mode
30/// + warn instead of accepting an empty token).
31pub fn bearer_token_matches(authorization_header: Option<&str>, expected: &str) -> bool {
32    if expected.is_empty() {
33        return false;
34    }
35    let Some(raw) = authorization_header else {
36        return false;
37    };
38    let token = match raw.split_once(' ') {
39        Some((scheme, rest)) if scheme.eq_ignore_ascii_case("bearer") => rest.trim(),
40        _ => raw.trim(),
41    };
42    constant_time_eq(token.as_bytes(), expected.as_bytes())
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn missing_header_is_rejected() {
51        assert!(!bearer_token_matches(None, "s3cret"));
52    }
53
54    #[test]
55    fn empty_expected_never_matches() {
56        assert!(!bearer_token_matches(Some("Bearer "), ""));
57        assert!(!bearer_token_matches(Some("Bearer x"), ""));
58    }
59
60    #[test]
61    fn correct_bearer_matches() {
62        assert!(bearer_token_matches(Some("Bearer s3cret"), "s3cret"));
63        // case-insensitive scheme, bare token also accepted
64        assert!(bearer_token_matches(Some("bearer s3cret"), "s3cret"));
65        assert!(bearer_token_matches(Some("s3cret"), "s3cret"));
66    }
67
68    #[test]
69    fn wrong_token_is_rejected() {
70        assert!(!bearer_token_matches(Some("Bearer nope"), "s3cret"));
71        assert!(!bearer_token_matches(Some("Bearer s3cre"), "s3cret"));
72        assert!(!bearer_token_matches(Some("Bearer s3cretX"), "s3cret"));
73    }
74
75    #[test]
76    fn surrounding_whitespace_is_trimmed() {
77        // `Bearer <token> ` trims to the bare token and still matches.
78        assert!(bearer_token_matches(Some("Bearer  s3cret "), "s3cret"));
79    }
80
81    #[test]
82    fn length_mismatch_is_rejected() {
83        assert!(!constant_time_eq(b"ab", b"abc"));
84        assert!(constant_time_eq(b"abc", b"abc"));
85    }
86}