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_to(&token_path(working_dir), token)?;
84
85 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
120pub 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 #[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
170pub fn discover_token() -> Option<AuthToken> {
177 if let Ok(val) = std::env::var("DORA_AUTH_TOKEN")
179 && !val.is_empty()
180 {
181 return Some(AuthToken(val));
182 }
183
184 if let Ok(cwd) = std::env::current_dir()
186 && let Ok(Some(token)) = read_token(&cwd)
187 {
188 return Some(token);
189 }
190
191 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#[cfg(kani)]
204mod verification {
205 use super::constant_time_eq;
206
207 const MAX_LEN: usize = 8;
212
213 #[kani::proof]
217 #[kani::unwind(9)] 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 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 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}