use std::io;
use std::path::PathBuf;
const DIR_NAME: &str = "ai.parslee.car";
const FILE_NAME: &str = "auth-token";
pub const TOKEN_ENV_VAR: &str = "CAR_AUTH_TOKEN";
pub fn default_path() -> io::Result<PathBuf> {
let dir = default_dir()?;
Ok(dir.join(FILE_NAME))
}
const HOST_FILE_NAME: &str = "host-token";
pub fn host_default_path() -> io::Result<PathBuf> {
Ok(default_dir()?.join(HOST_FILE_NAME))
}
pub fn read_host() -> io::Result<Option<String>> {
read_at(&host_default_path()?)
}
pub fn write_host(token: &str) -> io::Result<PathBuf> {
let path = host_default_path()?;
write_at(&path, token)?;
Ok(path)
}
fn default_dir() -> io::Result<PathBuf> {
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
return Ok(PathBuf::from(home)
.join("Library")
.join("Application Support")
.join(DIR_NAME));
}
}
#[cfg(target_os = "linux")]
{
if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
return Ok(PathBuf::from(rt).join(DIR_NAME));
}
if let Some(home) = std::env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".config").join(DIR_NAME));
}
}
#[cfg(target_os = "windows")]
{
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
return Ok(PathBuf::from(local).join(DIR_NAME));
}
}
if let Some(home) = std::env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".car"));
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"no HOME / XDG_RUNTIME_DIR / LOCALAPPDATA — can't resolve auth-token directory",
))
}
pub fn read() -> io::Result<Option<String>> {
read_at(&default_path()?)
}
pub fn read_at(path: &std::path::Path) -> io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => {
let line = s.trim();
#[cfg(target_os = "windows")]
{
Ok(Some(windows_dpapi::unprotect_from_line(line)?))
}
#[cfg(not(target_os = "windows"))]
{
Ok(Some(line.to_string()))
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn read_for_client() -> io::Result<Option<String>> {
if let Some(env_value) = std::env::var_os(TOKEN_ENV_VAR) {
if let Some(s) = env_value.to_str() {
let trimmed = s.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
}
read()
}
pub fn write(token: &str) -> io::Result<PathBuf> {
let path = default_path()?;
write_at(&path, token)?;
Ok(path)
}
pub fn write_at(path: &std::path::Path, token: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let tmp = path.with_extension("tmp");
#[cfg(target_os = "windows")]
let on_disk = windows_dpapi::protect_to_line(token)?;
#[cfg(target_os = "windows")]
std::fs::write(&tmp, &on_disk)?;
#[cfg(not(target_os = "windows"))]
std::fs::write(&tmp, token)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)?;
#[cfg(target_os = "windows")]
{
harden_windows_acl(path);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn harden_windows_acl(path: &std::path::Path) {
use std::process::Command;
let path_str = match path.to_str() {
Some(s) => s,
None => {
tracing::warn!(
?path,
"auth-token ACL hardening skipped: path is not valid UTF-8"
);
return;
}
};
let _ = run_icacls(&[path_str, "/grant:r", "*S-1-5-18:F"]);
if let Some(username) = std::env::var_os("USERNAME") {
if let Some(u) = username.to_str() {
let grant = format!("{u}:F");
let _ = run_icacls(&[path_str, "/grant:r", &grant]);
}
}
if let Err(e) = run_icacls(&[path_str, "/inheritance:r"]) {
tracing::warn!(?e, ?path, "auth-token ACL hardening: /inheritance:r failed");
}
let _ = run_icacls(&[path_str, "/remove:g", "*S-1-5-32-544"]);
let _ = run_icacls(&[path_str, "/remove:d", "*S-1-5-32-544"]);
if let Some(username) = std::env::var_os("USERNAME") {
if let Some(u) = username.to_str() {
if let Err(e) = run_icacls(&[path_str, "/setowner", u]) {
tracing::warn!(?e, ?path, "auth-token ACL hardening: /setowner failed");
}
}
}
fn run_icacls(args: &[&str]) -> io::Result<()> {
let status = Command::new("icacls").args(args).status()?;
if !status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
format!("icacls {args:?} exited with status {status}"),
));
}
Ok(())
}
}
#[cfg(target_os = "windows")]
mod windows_dpapi {
use super::io;
use base64::Engine as _;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
pub(super) fn protect_to_line(token: &str) -> io::Result<String> {
let plaintext = token.as_bytes();
let in_blob = CRYPT_INTEGER_BLOB {
cbData: plaintext.len() as u32,
pbData: plaintext.as_ptr() as *mut u8,
};
let mut out = CRYPT_INTEGER_BLOB::default();
let cipher = unsafe {
CryptProtectData(
&in_blob,
PCWSTR::null(),
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
.map_err(|e| {
io::Error::new(io::ErrorKind::Other, format!("CryptProtectData failed: {e}"))
})?;
let bytes = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
let _ = LocalFree(HLOCAL(out.pbData as *mut core::ffi::c_void));
bytes
};
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&cipher))
}
pub(super) fn unprotect_from_line(line: &str) -> io::Result<String> {
let mut cipher = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(line.as_bytes())
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("auth-token is not valid base64url DPAPI ciphertext: {e}"),
)
})?;
let in_blob = CRYPT_INTEGER_BLOB {
cbData: cipher.len() as u32,
pbData: cipher.as_mut_ptr(),
};
let mut out = CRYPT_INTEGER_BLOB::default();
let plaintext = unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out,
)
.map_err(|e| {
io::Error::new(
io::ErrorKind::Other,
format!("CryptUnprotectData failed (token not decryptable by this user): {e}"),
)
})?;
let bytes = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
let _ = LocalFree(HLOCAL(out.pbData as *mut core::ffi::c_void));
bytes
};
String::from_utf8(plaintext).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("decrypted auth-token is not valid UTF-8: {e}"),
)
})
}
}
pub fn remove() -> io::Result<()> {
let path = default_path()?;
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn generate() -> String {
use base64::Engine as _;
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
let mut bytes = [0u8; 32];
bytes[..16].copy_from_slice(a.as_bytes());
bytes[16..].copy_from_slice(b.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_token_is_43_chars_base64url() {
let t = generate();
assert_eq!(t.len(), 43);
for c in t.chars() {
assert!(
c.is_ascii_alphanumeric() || c == '-' || c == '_',
"non-base64url char: {c:?}"
);
}
}
#[test]
fn read_at_missing_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("nonexistent.token");
let result = read_at(&path).unwrap();
assert_eq!(result, None);
}
#[test]
fn write_at_then_read_at_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("ai.parslee.car").join("auth-token");
let token = generate();
write_at(&path, &token).unwrap();
assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
assert!(path.exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "token must be 0600");
}
}
#[test]
fn write_at_overwrites_atomically() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("auth-token");
write_at(&path, "first").unwrap();
write_at(&path, "second").unwrap();
assert_eq!(read_at(&path).unwrap().as_deref(), Some("second"));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_token_dacl_excludes_administrators() {
use std::process::Command;
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("ai.parslee.car").join("auth-token");
let token = generate();
write_at(&path, &token).unwrap();
assert!(path.exists());
let path_str = path.to_str().expect("tempdir path is UTF-8");
let script = format!(
"$ErrorActionPreference='Stop'; \
(Get-Acl -LiteralPath '{path_str}').Access | ForEach-Object {{ \
try {{ $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value }} \
catch {{ $_.IdentityReference.Value }} }}"
);
let output = match Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
{
Ok(o) => o,
Err(e) => {
eprintln!(
"skipping windows_token_dacl_excludes_administrators: \
powershell unavailable ({e})"
);
return;
}
};
if !output.status.success() {
eprintln!(
"skipping windows_token_dacl_excludes_administrators: \
Get-Acl read-back failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sids: Vec<&str> = stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
assert!(
!sids.is_empty(),
"Get-Acl returned no ACEs for {path_str}; read-back produced \
nothing to assert on (treat as a test-environment failure, \
not a pass)"
);
assert!(
!sids.iter().any(|s| s.eq_ignore_ascii_case("S-1-5-32-544")),
"auth-token DACL still grants BUILTIN\\Administrators \
(S-1-5-32-544) — harden_windows_acl regression, the \
any-admin read path is re-opened. ACEs: {sids:?}"
);
assert!(
sids.iter().any(|s| s.eq_ignore_ascii_case("S-1-5-18")),
"auth-token DACL is missing NT AUTHORITY\\SYSTEM (S-1-5-18); \
the daemon could lose read access to its token. ACEs: {sids:?}"
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_token_is_encrypted_at_rest() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("ai.parslee.car").join("auth-token");
let token = generate();
write_at(&path, &token).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(
!raw.contains(token.trim()),
"auth-token file contains the plaintext token — DPAPI \
encryption-at-rest regression (#295). Raw on-disk contents \
must be ciphertext, not the secret."
);
assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
}
struct EnvGuard {
var: &'static str,
prev: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn capture(var: &'static str) -> Self {
Self {
var,
prev: std::env::var_os(var),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => unsafe { std::env::set_var(self.var, v) },
None => unsafe { std::env::remove_var(self.var) },
}
}
}
#[test]
fn read_for_client_prefers_env_var_when_set() {
let _lock = crate::env_test_lock();
let _env = EnvGuard::capture(TOKEN_ENV_VAR);
unsafe { std::env::set_var(TOKEN_ENV_VAR, "from-env") };
assert_eq!(
read_for_client().unwrap().as_deref(),
Some("from-env"),
"env var should win when set"
);
}
#[test]
fn read_for_client_treats_empty_env_as_unset() {
let _lock = crate::env_test_lock();
let _env = EnvGuard::capture(TOKEN_ENV_VAR);
for empty_value in ["", " ", "\t\n", " \r\n "] {
unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };
let via_client = read_for_client();
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
let via_direct = read();
unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };
match (&via_client, &via_direct) {
(Ok(a), Ok(b)) => assert_eq!(
a, b,
"with empty env value {empty_value:?}, read_for_client \
must match read()'s outcome (env var should be treated \
as unset)"
),
(Err(_), Err(_)) => {} (a, b) => panic!(
"read_for_client and read disagreed for empty env value \
{empty_value:?}: client={a:?} direct={b:?}"
),
}
}
}
#[test]
fn read_for_client_falls_back_to_read_when_unset() {
let _lock = crate::env_test_lock();
let _env = EnvGuard::capture(TOKEN_ENV_VAR);
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
let via_client = read_for_client();
let via_direct = read();
match (&via_client, &via_direct) {
(Ok(a), Ok(b)) => assert_eq!(
a, b,
"read_for_client without env var must match read() exactly"
),
(Err(_), Err(_)) => {} (a, b) => panic!(
"read_for_client and read disagreed: client={a:?} direct={b:?}"
),
}
}
#[test]
fn read_for_client_trims_whitespace_around_token() {
let _lock = crate::env_test_lock();
let _env = EnvGuard::capture(TOKEN_ENV_VAR);
for (input, expected) in [
(" some-token\n", "some-token"),
("some-token\r\n", "some-token"),
("\tsome-token\t", "some-token"),
] {
unsafe { std::env::set_var(TOKEN_ENV_VAR, input) };
assert_eq!(
read_for_client().unwrap().as_deref(),
Some(expected),
"trim should normalize {input:?} → {expected:?}"
);
}
}
#[cfg(unix)]
#[test]
fn read_for_client_treats_non_utf8_env_as_unset() {
use std::os::unix::ffi::OsStrExt;
let _lock = crate::env_test_lock();
let _env = EnvGuard::capture(TOKEN_ENV_VAR);
let bad = std::ffi::OsStr::from_bytes(&[b'a', 0xFF, b'b']);
unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };
let via_client = read_for_client();
unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
let via_direct = read();
unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };
match (&via_client, &via_direct) {
(Ok(a), Ok(b)) => assert_eq!(
a, b,
"non-UTF-8 env value must be treated as unset and \
fall through to read()"
),
(Err(_), Err(_)) => {}
(a, b) => panic!(
"read_for_client and read disagreed for non-UTF-8: \
client={a:?} direct={b:?}"
),
}
}
}