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}