1use std::{
8 fmt, fs,
9 io::Write,
10 path::{Path, PathBuf},
11};
12
13const TOKEN_BYTES: usize = 32;
15
16const TOKEN_FILENAME: &str = ".dora-token";
18
19#[derive(Clone)]
24pub struct AuthToken(String);
25
26impl AuthToken {
27 pub fn from_hex(hex: impl Into<String>) -> Self {
29 Self(hex.into())
30 }
31
32 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
44pub 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
56pub 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
64pub fn token_path(working_dir: &Path) -> PathBuf {
66 working_dir.join(TOKEN_FILENAME)
67}
68
69pub fn config_token_path() -> Option<PathBuf> {
74 dirs::config_dir().map(|d| d.join("dora").join(TOKEN_FILENAME))
75}
76
77pub 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 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 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 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
155pub 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 #[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
205pub fn discover_token() -> Option<AuthToken> {
212 if let Ok(val) = std::env::var("DORA_AUTH_TOKEN")
214 && !val.is_empty()
215 {
216 return Some(AuthToken(val));
217 }
218
219 if let Ok(cwd) = std::env::current_dir()
221 && let Ok(Some(token)) = read_token(&cwd)
222 {
223 return Some(token);
224 }
225
226 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#[cfg(kani)]
239mod verification {
240 use super::constant_time_eq;
241
242 const MAX_LEN: usize = 8;
247
248 #[kani::proof]
252 #[kani::unwind(9)] 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 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 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 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}