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, files are created with mode `0o600` atomically to prevent
81/// a TOCTOU window where the file is briefly world-readable.
82pub fn write_token(working_dir: &Path, token: &AuthToken) -> std::io::Result<()> {
83    write_token_to(&token_path(working_dir), token)?;
84
85    // Best-effort write to config dir so CLIs in other directories can find it.
86    if let Some(config_path) = config_token_path() {
87        if let Some(parent) = config_path.parent() {
88            let _ = fs::create_dir_all(parent);
89        }
90        if let Err(e) = write_token_to(&config_path, token) {
91            log::warn!("failed to write token to config dir: {e}");
92        }
93    }
94
95    Ok(())
96}
97
98fn write_token_to(path: &Path, token: &AuthToken) -> std::io::Result<()> {
99    #[cfg(unix)]
100    {
101        use std::os::unix::fs::OpenOptionsExt;
102        let mut file = fs::OpenOptions::new()
103            .write(true)
104            .create(true)
105            .truncate(true)
106            .mode(0o600)
107            .open(path)?;
108        file.write_all(token.as_hex().as_bytes())?;
109        file.write_all(b"\n")?;
110    }
111    #[cfg(not(unix))]
112    {
113        let mut file = fs::File::create(path)?;
114        file.write_all(token.as_hex().as_bytes())?;
115        file.write_all(b"\n")?;
116    }
117    Ok(())
118}
119
120/// Read the token from `<working_dir>/.dora-token`.
121///
122/// Returns `None` if the file does not exist.
123pub fn read_token(working_dir: &Path) -> std::io::Result<Option<AuthToken>> {
124    read_token_from_path(&token_path(working_dir))
125}
126
127fn read_token_from_path(path: &Path) -> std::io::Result<Option<AuthToken>> {
128    let file = match fs::File::open(path) {
129        Ok(f) => f,
130        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
131        Err(e) => return Err(e),
132    };
133
134    // On Unix, verify ownership/permissions on the *open* fd (fstat) to
135    // avoid a TOCTOU race between read and metadata check.
136    #[cfg(unix)]
137    {
138        use std::os::unix::fs::MetadataExt;
139        if let Ok(meta) = file.metadata() {
140            let uid = unsafe { libc::geteuid() };
141            if meta.uid() != uid {
142                log::warn!(
143                    "ignoring token file {} (owned by uid {}, expected {})",
144                    path.display(),
145                    meta.uid(),
146                    uid
147                );
148                return Ok(None);
149            }
150            if meta.mode() & 0o077 != 0 {
151                log::warn!(
152                    "ignoring token file {} (mode {:o} is too permissive, expected 0600)",
153                    path.display(),
154                    meta.mode() & 0o777
155                );
156                return Ok(None);
157            }
158        }
159    }
160
161    let content = std::io::read_to_string(file)?;
162    let trimmed = content.trim();
163    if trimmed.is_empty() {
164        Ok(None)
165    } else {
166        Ok(Some(AuthToken(trimmed.to_string())))
167    }
168}
169
170/// Attempt to read the auth token from (in order):
171/// 1. `DORA_AUTH_TOKEN` environment variable
172/// 2. `<cwd>/.dora-token` file
173/// 3. User config directory (e.g. `~/.config/dora/.dora-token`)
174///
175/// Returns `None` if no token is found.
176pub fn discover_token() -> Option<AuthToken> {
177    // 1. Environment variable override
178    if let Ok(val) = std::env::var("DORA_AUTH_TOKEN")
179        && !val.is_empty()
180    {
181        return Some(AuthToken(val));
182    }
183
184    // 2. Token file in current working directory
185    if let Ok(cwd) = std::env::current_dir()
186        && let Ok(Some(token)) = read_token(&cwd)
187    {
188        return Some(token);
189    }
190
191    // 3. Token file in user config directory
192    if let Some(config_path) = config_token_path()
193        && let Ok(Some(token)) = read_token_from_path(&config_path)
194    {
195        return Some(token);
196    }
197
198    None
199}
200
201/// Kani proof harnesses (`make qa-kani`). Compiled only under `cargo kani`,
202/// never in normal builds or tests. See `docs/formal-verification.md`.
203#[cfg(kani)]
204mod verification {
205    use super::constant_time_eq;
206
207    /// Maximum slice length explored by the proofs. Token comparison inputs
208    /// are attacker-controlled strings of arbitrary length, but the loop
209    /// body is length-uniform, so a small bound suffices to cover all
210    /// control-flow paths (equal/unequal lengths, differing byte positions).
211    const MAX_LEN: usize = 8;
212
213    /// Functional correctness: `constant_time_eq` agrees with `==` on all
214    /// slice pairs up to `MAX_LEN`, including length mismatches and the
215    /// empty slice. Also proves the function never panics on these inputs.
216    #[kani::proof]
217    #[kani::unwind(9)] // MAX_LEN + 1
218    fn constant_time_eq_matches_slice_equality() {
219        let a: [u8; MAX_LEN] = kani::any();
220        let b: [u8; MAX_LEN] = kani::any();
221        let a_len: usize = kani::any();
222        let b_len: usize = kani::any();
223        kani::assume(a_len <= MAX_LEN);
224        kani::assume(b_len <= MAX_LEN);
225        assert_eq!(
226            constant_time_eq(&a[..a_len], &b[..b_len]),
227            a[..a_len] == b[..b_len]
228        );
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn generate_token_is_64_hex_chars() {
238        let token = generate_token();
239        assert_eq!(token.as_hex().len(), 64);
240        assert!(token.as_hex().chars().all(|c| c.is_ascii_hexdigit()));
241    }
242
243    #[test]
244    fn generate_tokens_are_unique() {
245        let a = generate_token();
246        let b = generate_token();
247        assert_ne!(a.as_hex(), b.as_hex());
248    }
249
250    #[test]
251    fn write_and_read_token() {
252        let dir = tempfile::tempdir().unwrap();
253
254        let token = generate_token();
255        write_token(dir.path(), &token).unwrap();
256
257        let read_back = read_token(dir.path()).unwrap().unwrap();
258        assert_eq!(token.as_hex(), read_back.as_hex());
259    }
260
261    #[test]
262    fn config_token_path_returns_some() {
263        // Should return a path on any system with a home directory
264        let path = config_token_path();
265        if let Some(p) = &path {
266            assert!(p.ends_with("dora/.dora-token"));
267        }
268    }
269
270    #[test]
271    fn write_token_creates_config_dir_copy() {
272        let dir = tempfile::tempdir().unwrap();
273        let token = generate_token();
274        write_token(dir.path(), &token).unwrap();
275
276        // Config dir copy is best-effort; just verify the working-dir copy works
277        let read_back = read_token(dir.path()).unwrap().unwrap();
278        assert_eq!(token.as_hex(), read_back.as_hex());
279    }
280
281    #[test]
282    fn constant_time_eq_works() {
283        assert!(constant_time_eq(b"hello", b"hello"));
284        assert!(!constant_time_eq(b"hello", b"world"));
285        assert!(!constant_time_eq(b"hello", b"hell"));
286        assert!(!constant_time_eq(b"", b"x"));
287        assert!(constant_time_eq(b"", b""));
288    }
289}