Skip to main content

dora_message/
auth.rs

1//! Shared-token authentication for coordinator connections.
2//!
3//! On startup the coordinator generates a random hex token and writes it to
4//! a well-known file. The CLI and daemons read this file before connecting
5//! and append `?token=<hex>` to the WebSocket URL.
6
7use std::{
8    fmt, fs,
9    io::Write,
10    path::{Path, PathBuf},
11};
12
13/// Length of the raw token in bytes (32 bytes = 64 hex chars).
14const TOKEN_BYTES: usize = 32;
15
16/// File name used for storing the auth token.
17const TOKEN_FILENAME: &str = ".dora-token";
18
19/// Opaque authentication token.
20///
21/// `PartialEq`/`Eq` are intentionally not derived to prevent accidental
22/// non-constant-time comparisons. Use [`constant_time_eq`] instead.
23#[derive(Clone)]
24pub struct AuthToken(String);
25
26impl AuthToken {
27    /// Create a token from a hex string (e.g. read from file or env var).
28    pub fn from_hex(hex: impl Into<String>) -> Self {
29        Self(hex.into())
30    }
31
32    /// Return the hex representation.
33    pub fn as_hex(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Debug for AuthToken {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "AuthToken(***)")
41    }
42}
43
44/// Constant-time byte comparison to prevent timing side-channel attacks.
45pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
46    if a.len() != b.len() {
47        return false;
48    }
49    let mut diff = 0u8;
50    for (x, y) in a.iter().zip(b.iter()) {
51        diff |= x ^ y;
52    }
53    diff == 0
54}
55
56/// Generate a cryptographically random 32-byte token.
57pub fn generate_token() -> AuthToken {
58    let mut buf = [0u8; TOKEN_BYTES];
59    getrandom::fill(&mut buf).expect("failed to generate random bytes");
60    let hex: String = buf.iter().map(|b| format!("{b:02x}")).collect();
61    AuthToken(hex)
62}
63
64/// Compute the token file path for a given working directory.
65pub fn token_path(working_dir: &Path) -> PathBuf {
66    working_dir.join(TOKEN_FILENAME)
67}
68
69/// Return the token path inside the user's config directory
70/// (e.g. `~/.config/dora/.dora-token` on Linux).
71///
72/// Returns `None` if no config directory can be determined.
73pub fn config_token_path() -> Option<PathBuf> {
74    dirs::config_dir().map(|d| d.join("dora").join(TOKEN_FILENAME))
75}
76
77/// Write the token to `<working_dir>/.dora-token` **and** to the user config
78/// directory (e.g. `~/.config/dora/.dora-token`).
79///
80/// On Unix, each file is written to a fresh `0o600` inode that replaces the
81/// old one, so the secret is never readable by other users.
82pub fn write_token(working_dir: &Path, token: &AuthToken) -> std::io::Result<()> {
83    write_token_with_config_copy(
84        &token_path(working_dir),
85        config_token_path().as_deref(),
86        token,
87    )
88}
89
90fn write_token_with_config_copy(
91    path: &Path,
92    config_path: Option<&Path>,
93    token: &AuthToken,
94) -> std::io::Result<()> {
95    let result = write_token_to(path, token);
96
97    // Best-effort write to config dir so CLIs in other directories can find
98    // it — attempted even if the working-dir write failed.
99    if let Some(config_path) = config_path {
100        if let Some(parent) = config_path.parent() {
101            let _ = fs::create_dir_all(parent);
102        }
103        if let Err(e) = write_token_to(config_path, token) {
104            log::warn!("failed to write token to config dir: {e}");
105        }
106    }
107
108    result
109}
110
111fn write_token_to(path: &Path, token: &AuthToken) -> std::io::Result<()> {
112    #[cfg(unix)]
113    {
114        use std::os::unix::fs::OpenOptionsExt;
115        // Write a fresh 0600 inode and rename it over `path` instead of
116        // reusing an existing file: `mode` only applies on creation, and
117        // tightening an existing file's mode does not revoke descriptors
118        // other users already hold on it. `create_new` (O_EXCL) also refuses
119        // to follow a symlink planted at the temporary path, and the rename
120        // replaces a symlink at `path` rather than writing through it.
121        let file_name = path
122            .file_name()
123            .ok_or_else(|| std::io::Error::other("token path has no file name"))?;
124        let mut tmp_name = std::ffi::OsString::from(".");
125        tmp_name.push(file_name);
126        tmp_name.push(format!(".tmp-{}", std::process::id()));
127        let tmp = path.with_file_name(tmp_name);
128        // A leftover from a crashed write by this pid would block `create_new`.
129        let _ = fs::remove_file(&tmp);
130
131        let written = (|| {
132            let mut file = fs::OpenOptions::new()
133                .write(true)
134                .create_new(true)
135                .mode(0o600)
136                .open(&tmp)?;
137            file.write_all(token.as_hex().as_bytes())?;
138            file.write_all(b"\n")?;
139            fs::rename(&tmp, path)
140        })();
141        if written.is_err() {
142            let _ = fs::remove_file(&tmp);
143        }
144        written?;
145    }
146    #[cfg(not(unix))]
147    {
148        let mut file = fs::File::create(path)?;
149        file.write_all(token.as_hex().as_bytes())?;
150        file.write_all(b"\n")?;
151    }
152    Ok(())
153}
154
155/// Read the token from `<working_dir>/.dora-token`.
156///
157/// Returns `None` if the file does not exist.
158pub fn read_token(working_dir: &Path) -> std::io::Result<Option<AuthToken>> {
159    read_token_from_path(&token_path(working_dir))
160}
161
162fn read_token_from_path(path: &Path) -> std::io::Result<Option<AuthToken>> {
163    let file = match fs::File::open(path) {
164        Ok(f) => f,
165        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
166        Err(e) => return Err(e),
167    };
168
169    // On Unix, verify ownership/permissions on the *open* fd (fstat) to
170    // avoid a TOCTOU race between read and metadata check.
171    #[cfg(unix)]
172    {
173        use std::os::unix::fs::MetadataExt;
174        if let Ok(meta) = file.metadata() {
175            let uid = unsafe { libc::geteuid() };
176            if meta.uid() != uid {
177                log::warn!(
178                    "ignoring token file {} (owned by uid {}, expected {})",
179                    path.display(),
180                    meta.uid(),
181                    uid
182                );
183                return Ok(None);
184            }
185            if meta.mode() & 0o077 != 0 {
186                log::warn!(
187                    "ignoring token file {} (mode {:o} is too permissive, expected 0600)",
188                    path.display(),
189                    meta.mode() & 0o777
190                );
191                return Ok(None);
192            }
193        }
194    }
195
196    let content = std::io::read_to_string(file)?;
197    let trimmed = content.trim();
198    if trimmed.is_empty() {
199        Ok(None)
200    } else {
201        Ok(Some(AuthToken(trimmed.to_string())))
202    }
203}
204
205/// Attempt to read the auth token from (in order):
206/// 1. `DORA_AUTH_TOKEN` environment variable
207/// 2. `<cwd>/.dora-token` file
208/// 3. User config directory (e.g. `~/.config/dora/.dora-token`)
209///
210/// Returns `None` if no token is found.
211pub fn discover_token() -> Option<AuthToken> {
212    // 1. Environment variable override
213    if let Ok(val) = std::env::var("DORA_AUTH_TOKEN")
214        && !val.is_empty()
215    {
216        return Some(AuthToken(val));
217    }
218
219    // 2. Token file in current working directory
220    if let Ok(cwd) = std::env::current_dir()
221        && let Ok(Some(token)) = read_token(&cwd)
222    {
223        return Some(token);
224    }
225
226    // 3. Token file in user config directory
227    if let Some(config_path) = config_token_path()
228        && let Ok(Some(token)) = read_token_from_path(&config_path)
229    {
230        return Some(token);
231    }
232
233    None
234}
235
236/// Kani proof harnesses (`make qa-kani`). Compiled only under `cargo kani`,
237/// never in normal builds or tests. See `docs/formal-verification.md`.
238#[cfg(kani)]
239mod verification {
240    use super::constant_time_eq;
241
242    /// Maximum slice length explored by the proofs. Token comparison inputs
243    /// are attacker-controlled strings of arbitrary length, but the loop
244    /// body is length-uniform, so a small bound suffices to cover all
245    /// control-flow paths (equal/unequal lengths, differing byte positions).
246    const MAX_LEN: usize = 8;
247
248    /// Functional correctness: `constant_time_eq` agrees with `==` on all
249    /// slice pairs up to `MAX_LEN`, including length mismatches and the
250    /// empty slice. Also proves the function never panics on these inputs.
251    #[kani::proof]
252    #[kani::unwind(9)] // MAX_LEN + 1
253    fn constant_time_eq_matches_slice_equality() {
254        let a: [u8; MAX_LEN] = kani::any();
255        let b: [u8; MAX_LEN] = kani::any();
256        let a_len: usize = kani::any();
257        let b_len: usize = kani::any();
258        kani::assume(a_len <= MAX_LEN);
259        kani::assume(b_len <= MAX_LEN);
260        assert_eq!(
261            constant_time_eq(&a[..a_len], &b[..b_len]),
262            a[..a_len] == b[..b_len]
263        );
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn generate_token_is_64_hex_chars() {
273        let token = generate_token();
274        assert_eq!(token.as_hex().len(), 64);
275        assert!(token.as_hex().chars().all(|c| c.is_ascii_hexdigit()));
276    }
277
278    #[test]
279    fn generate_tokens_are_unique() {
280        let a = generate_token();
281        let b = generate_token();
282        assert_ne!(a.as_hex(), b.as_hex());
283    }
284
285    #[test]
286    fn write_and_read_token() {
287        let dir = tempfile::tempdir().unwrap();
288
289        let token = generate_token();
290        write_token(dir.path(), &token).unwrap();
291
292        let read_back = read_token(dir.path()).unwrap().unwrap();
293        assert_eq!(token.as_hex(), read_back.as_hex());
294    }
295
296    #[cfg(unix)]
297    #[test]
298    fn write_token_tightens_an_existing_permissive_file() {
299        use std::os::unix::fs::PermissionsExt;
300
301        let dir = tempfile::tempdir().unwrap();
302        let path = dir.path().join(".dora-token");
303        std::fs::write(&path, "stale token that is longer than the new one\n").unwrap();
304        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
305
306        // Another user who opened the loose file earlier keeps their fd.
307        let mut early_reader = std::fs::File::open(&path).unwrap();
308
309        let token = generate_token();
310        write_token_to(&path, &token).unwrap();
311
312        let mut seen = String::new();
313        std::io::Read::read_to_string(&mut early_reader, &mut seen).unwrap();
314        assert!(
315            !seen.contains(token.as_hex()),
316            "a descriptor opened before the write must not see the new secret"
317        );
318
319        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
320        assert_eq!(mode, 0o600, "the secret must not stay world-readable");
321        let read_back = read_token_from_path(&path).unwrap();
322        assert_eq!(
323            read_back.map(|t| t.as_hex().to_owned()),
324            Some(token.as_hex().to_owned())
325        );
326    }
327
328    #[test]
329    fn config_token_path_returns_some() {
330        // Should return a path on any system with a home directory
331        let path = config_token_path();
332        if let Some(p) = &path {
333            assert!(p.ends_with("dora/.dora-token"));
334        }
335    }
336
337    #[test]
338    fn write_token_creates_config_dir_copy() {
339        let dir = tempfile::tempdir().unwrap();
340        let token = generate_token();
341        write_token(dir.path(), &token).unwrap();
342
343        // Config dir copy is best-effort; just verify the working-dir copy works
344        let read_back = read_token(dir.path()).unwrap().unwrap();
345        assert_eq!(token.as_hex(), read_back.as_hex());
346    }
347
348    #[test]
349    fn config_copy_is_written_even_if_the_working_dir_write_fails() {
350        let dir = tempfile::tempdir().unwrap();
351        let unwritable = dir.path().join("missing-dir").join(".dora-token");
352        let config = dir.path().join("config").join(".dora-token");
353
354        let token = generate_token();
355        assert!(write_token_with_config_copy(&unwritable, Some(&config), &token).is_err());
356        let read_back = read_token_from_path(&config).unwrap().unwrap();
357        assert_eq!(read_back.as_hex(), token.as_hex());
358    }
359
360    #[test]
361    fn constant_time_eq_works() {
362        assert!(constant_time_eq(b"hello", b"hello"));
363        assert!(!constant_time_eq(b"hello", b"world"));
364        assert!(!constant_time_eq(b"hello", b"hell"));
365        assert!(!constant_time_eq(b"", b"x"));
366        assert!(constant_time_eq(b"", b""));
367    }
368}