use std::{
fmt, fs,
io::Write,
path::{Path, PathBuf},
};
const TOKEN_BYTES: usize = 32;
const TOKEN_FILENAME: &str = ".dora-token";
#[derive(Clone)]
pub struct AuthToken(String);
impl AuthToken {
pub fn from_hex(hex: impl Into<String>) -> Self {
Self(hex.into())
}
pub fn as_hex(&self) -> &str {
&self.0
}
}
impl fmt::Debug for AuthToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AuthToken(***)")
}
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub fn generate_token() -> AuthToken {
let mut buf = [0u8; TOKEN_BYTES];
getrandom::fill(&mut buf).expect("failed to generate random bytes");
let hex: String = buf.iter().map(|b| format!("{b:02x}")).collect();
AuthToken(hex)
}
pub fn token_path(working_dir: &Path) -> PathBuf {
working_dir.join(TOKEN_FILENAME)
}
pub fn config_token_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("dora").join(TOKEN_FILENAME))
}
pub fn write_token(working_dir: &Path, token: &AuthToken) -> std::io::Result<()> {
write_token_to(&token_path(working_dir), token)?;
if let Some(config_path) = config_token_path() {
if let Some(parent) = config_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = write_token_to(&config_path, token) {
log::warn!("failed to write token to config dir: {e}");
}
}
Ok(())
}
fn write_token_to(path: &Path, token: &AuthToken) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(token.as_hex().as_bytes())?;
file.write_all(b"\n")?;
}
#[cfg(not(unix))]
{
let mut file = fs::File::create(path)?;
file.write_all(token.as_hex().as_bytes())?;
file.write_all(b"\n")?;
}
Ok(())
}
pub fn read_token(working_dir: &Path) -> std::io::Result<Option<AuthToken>> {
read_token_from_path(&token_path(working_dir))
}
fn read_token_from_path(path: &Path) -> std::io::Result<Option<AuthToken>> {
let file = match fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = file.metadata() {
let uid = unsafe { libc::geteuid() };
if meta.uid() != uid {
log::warn!(
"ignoring token file {} (owned by uid {}, expected {})",
path.display(),
meta.uid(),
uid
);
return Ok(None);
}
if meta.mode() & 0o077 != 0 {
log::warn!(
"ignoring token file {} (mode {:o} is too permissive, expected 0600)",
path.display(),
meta.mode() & 0o777
);
return Ok(None);
}
}
}
let content = std::io::read_to_string(file)?;
let trimmed = content.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(AuthToken(trimmed.to_string())))
}
}
pub fn discover_token() -> Option<AuthToken> {
if let Ok(val) = std::env::var("DORA_AUTH_TOKEN")
&& !val.is_empty()
{
return Some(AuthToken(val));
}
if let Ok(cwd) = std::env::current_dir()
&& let Ok(Some(token)) = read_token(&cwd)
{
return Some(token);
}
if let Some(config_path) = config_token_path()
&& let Ok(Some(token)) = read_token_from_path(&config_path)
{
return Some(token);
}
None
}
#[cfg(kani)]
mod verification {
use super::constant_time_eq;
const MAX_LEN: usize = 8;
#[kani::proof]
#[kani::unwind(9)] fn constant_time_eq_matches_slice_equality() {
let a: [u8; MAX_LEN] = kani::any();
let b: [u8; MAX_LEN] = kani::any();
let a_len: usize = kani::any();
let b_len: usize = kani::any();
kani::assume(a_len <= MAX_LEN);
kani::assume(b_len <= MAX_LEN);
assert_eq!(
constant_time_eq(&a[..a_len], &b[..b_len]),
a[..a_len] == b[..b_len]
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_token_is_64_hex_chars() {
let token = generate_token();
assert_eq!(token.as_hex().len(), 64);
assert!(token.as_hex().chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn generate_tokens_are_unique() {
let a = generate_token();
let b = generate_token();
assert_ne!(a.as_hex(), b.as_hex());
}
#[test]
fn write_and_read_token() {
let dir = tempfile::tempdir().unwrap();
let token = generate_token();
write_token(dir.path(), &token).unwrap();
let read_back = read_token(dir.path()).unwrap().unwrap();
assert_eq!(token.as_hex(), read_back.as_hex());
}
#[test]
fn config_token_path_returns_some() {
let path = config_token_path();
if let Some(p) = &path {
assert!(p.ends_with("dora/.dora-token"));
}
}
#[test]
fn write_token_creates_config_dir_copy() {
let dir = tempfile::tempdir().unwrap();
let token = generate_token();
write_token(dir.path(), &token).unwrap();
let read_back = read_token(dir.path()).unwrap().unwrap();
assert_eq!(token.as_hex(), read_back.as_hex());
}
#[test]
fn constant_time_eq_works() {
assert!(constant_time_eq(b"hello", b"hello"));
assert!(!constant_time_eq(b"hello", b"world"));
assert!(!constant_time_eq(b"hello", b"hell"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
}