Skip to main content

scema_daemon/
auth.rs

1//! The token, and the two attacks it is not enough to stop on its own.
2//!
3//! Binding to loopback keeps this off the network. It does **not** make the daemon private:
4//! every other process on the machine can reach `127.0.0.1`, and so — via the user's own
5//! browser — can every web page they visit. Three layers, each covering what the previous
6//! one misses:
7//!
8//! 1. **Loopback bind** (`http::loopback`) — nothing off-machine can connect.
9//! 2. **Bearer token** — a local process, or a page, must know a 256-bit secret it has no
10//!    way to read. This is the layer that actually authorises.
11//! 3. **Host header check** ([`host_is_local`]) — defeats DNS rebinding, where an attacker
12//!    points `evil.example` at `127.0.0.1` so that a page's requests become *same-origin*
13//!    with the daemon and the browser hands over the responses. The token would still be
14//!    unknown to the page, but a rebinding attack is precisely how a same-origin page gets
15//!    to read a response, so the cheap check goes in.
16//!
17//! Plus a fourth, by omission: the server emits no `Access-Control-Allow-Origin` and
18//! handles no `OPTIONS`, so an ordinary cross-origin page cannot read a reply even if it
19//! guesses a route. The browser extension is unaffected because it fetches from its service
20//! worker under `host_permissions`, which is not subject to CORS.
21//!
22//! ## Comparison is constant-time
23//!
24//! `==` on a `String` returns as soon as two bytes differ, which leaks the length of the
25//! matching prefix to anything that can time it. A local attacker can time it very
26//! precisely.
27
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32
33/// Bytes of entropy in a token. 256 bits, hex-encoded to 64 characters.
34pub const TOKEN_BYTES: usize = 32;
35
36/// Where the token lives, relative to the state root.
37pub const TOKEN_FILE: &str = "omnid.token";
38
39/// Load the token for a state root, generating one on first run.
40///
41/// The file is the pairing mechanism: the operator reads it once and pastes it into the
42/// extension. It is regenerated only if deleted, so a paired client stays paired across
43/// restarts — a daemon that rotated its token on every start would be unusable.
44pub fn load_or_create(root: &Path) -> Result<String> {
45    let path = token_path(root);
46    if let Ok(existing) = fs::read_to_string(&path) {
47        let trimmed = existing.trim().to_string();
48        if trimmed.len() >= 32 {
49            return Ok(trimmed);
50        }
51        // A short or empty token file is a truncated write, not a policy choice. Replacing
52        // it is safe; honouring it would install a weak secret nobody chose.
53    }
54    let token = generate()?;
55    fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
56    // The daemon is the one surface that brings `.scema/` into existence without the
57    // operator having decided anything, and it is also the one that puts a **secret** in
58    // there. If the directory is inside a git working tree, an untracked token is exactly
59    // what somebody commits by reflex on their next `git add -A`.
60    scema_verify::store::self_ignore(root);
61    let tmp = path.with_extension("token.tmp");
62    fs::write(&tmp, &token).with_context(|| format!("writing {}", tmp.display()))?;
63    fs::rename(&tmp, &path).with_context(|| format!("renaming into {}", path.display()))?;
64    restrict(&path);
65    Ok(token)
66}
67
68pub fn token_path(root: &Path) -> PathBuf {
69    root.join(TOKEN_FILE)
70}
71
72fn generate() -> Result<String> {
73    let mut buf = [0u8; TOKEN_BYTES];
74    getrandom::getrandom(&mut buf).context("reading OS entropy for the daemon token")?;
75    Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
76}
77
78/// Best-effort permission tightening.
79///
80/// Unix only; on Windows the file inherits the user profile's ACL, which already excludes
81/// other users. Deliberately not fatal on failure — a daemon that refuses to start because
82/// it could not chmod is a daemon that does not start on a network share, and the token's
83/// security does not rest on the mode bits.
84#[cfg(unix)]
85fn restrict(path: &Path) {
86    use std::os::unix::fs::PermissionsExt;
87    let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
88}
89
90#[cfg(not(unix))]
91fn restrict(_path: &Path) {}
92
93/// Constant-time equality over the full length of both inputs.
94///
95/// Length is compared without early return too: an early `len` check leaks whether the
96/// guess was the right size, which for a fixed-length token is uninteresting but for a
97/// future variable-length one would not be.
98pub fn secret_eq(a: &str, b: &str) -> bool {
99    let (a, b) = (a.as_bytes(), b.as_bytes());
100    let mut diff = (a.len() ^ b.len()) as u8;
101    let n = a.len().max(b.len());
102    for i in 0..n {
103        let x = a.get(i).copied().unwrap_or(0);
104        let y = b.get(i).copied().unwrap_or(0);
105        diff |= x ^ y;
106    }
107    diff == 0
108}
109
110/// Extract a bearer token from an `Authorization` header value.
111///
112/// Also accepts a bare token, because `curl -H "Authorization: <token>"` is what an
113/// operator types first and refusing it teaches nothing.
114pub fn bearer(header: &str) -> &str {
115    let h = header.trim();
116    match h.strip_prefix("Bearer ").or_else(|| h.strip_prefix("bearer ")) {
117        Some(rest) => rest.trim(),
118        None => h,
119    }
120}
121
122/// Is the `Host` header one of this daemon's own names?
123///
124/// Anything else means the request arrived through a name that resolves here but is not
125/// here — the shape of a DNS rebinding attack. An absent `Host` is rejected: HTTP/1.1
126/// requires it, and the only clients that omit it are hand-written ones.
127pub fn host_is_local(host: Option<&str>, port: u16) -> bool {
128    let Some(host) = host else {
129        return false;
130    };
131    let host = host.trim();
132    // Strip the port if present; compare the name only.
133    let name = match host.rsplit_once(':') {
134        // An IPv6 literal is bracketed, so a colon inside brackets is not a port separator.
135        Some((n, p)) if !n.ends_with(']') || p.chars().all(|c| c.is_ascii_digit()) => {
136            if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() {
137                let declared: u16 = p.parse().unwrap_or(0);
138                if declared != port {
139                    return false;
140                }
141            }
142            n
143        }
144        _ => host,
145    };
146    matches!(name, "127.0.0.1" | "localhost" | "[::1]" | "::1")
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    fn scratch() -> PathBuf {
154        let p = std::env::temp_dir().join(format!(
155            "scema-omni-auth-{}-{}",
156            std::process::id(),
157            std::time::SystemTime::now()
158                .duration_since(std::time::UNIX_EPOCH)
159                .unwrap()
160                .as_nanos()
161        ));
162        fs::create_dir_all(&p).unwrap();
163        p
164    }
165
166    #[test]
167    fn a_token_is_generated_once_and_then_reused() {
168        // A daemon that rotated its token on every restart would unpair the extension every
169        // time the operator reboots.
170        let dir = scratch();
171        let a = load_or_create(&dir).unwrap();
172        let b = load_or_create(&dir).unwrap();
173        assert_eq!(a, b);
174        assert_eq!(a.len(), TOKEN_BYTES * 2);
175        fs::remove_dir_all(&dir).ok();
176    }
177
178    #[test]
179    fn two_daemons_do_not_share_a_token() {
180        let (d1, d2) = (scratch(), scratch());
181        assert_ne!(load_or_create(&d1).unwrap(), load_or_create(&d2).unwrap());
182        fs::remove_dir_all(&d1).ok();
183        fs::remove_dir_all(&d2).ok();
184    }
185
186    #[test]
187    fn a_truncated_token_file_is_replaced_not_honoured() {
188        let dir = scratch();
189        fs::write(token_path(&dir), "abc").unwrap();
190        let t = load_or_create(&dir).unwrap();
191        assert_eq!(t.len(), TOKEN_BYTES * 2, "a short secret nobody chose must not be installed");
192        fs::remove_dir_all(&dir).ok();
193    }
194
195    #[test]
196    fn secret_comparison_matches_only_the_exact_token() {
197        assert!(secret_eq("abc", "abc"));
198        assert!(!secret_eq("abc", "abd"));
199        assert!(!secret_eq("abc", "abcd"), "a prefix must not authenticate");
200        assert!(!secret_eq("abcd", "abc"));
201        assert!(!secret_eq("", "a"));
202        assert!(secret_eq("", ""));
203    }
204
205    #[test]
206    fn bearer_accepts_both_the_prefixed_and_the_bare_form() {
207        assert_eq!(bearer("Bearer deadbeef"), "deadbeef");
208        assert_eq!(bearer("bearer deadbeef"), "deadbeef");
209        assert_eq!(bearer("  deadbeef  "), "deadbeef");
210    }
211
212    #[test]
213    fn a_rebinding_host_is_rejected() {
214        // The attack: evil.example resolves to 127.0.0.1, so the page's requests become
215        // same-origin with the daemon and the browser hands over the responses.
216        assert!(!host_is_local(Some("evil.example:7842"), 7842));
217        assert!(!host_is_local(Some("attacker.test"), 7842));
218        assert!(!host_is_local(None, 7842), "HTTP/1.1 requires a Host header");
219    }
220
221    #[test]
222    fn the_daemons_own_names_are_accepted() {
223        assert!(host_is_local(Some("127.0.0.1:7842"), 7842));
224        assert!(host_is_local(Some("localhost:7842"), 7842));
225        assert!(host_is_local(Some("127.0.0.1"), 7842));
226        assert!(host_is_local(Some("[::1]:7842"), 7842));
227    }
228
229    #[test]
230    fn a_local_name_on_the_wrong_port_is_rejected() {
231        // Another rebinding shape: a page on localhost:3000 addressing the daemon's port
232        // through a Host that names its own.
233        assert!(!host_is_local(Some("localhost:3000"), 7842));
234    }
235}