Skip to main content

car_auth/
authority_hint.rs

1use serde::{Deserialize, Serialize};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, Ordering};
5
6const AUTHORITY_HINT_FILE: &str = "parslee-auth-authority.json";
7static FORCE_UNKNOWN: AtomicBool = AtomicBool::new(false);
8
9/// Non-secret presentation state for passive auth surfaces.
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum CredentialAuthorityState {
13    Unknown,
14    SignedOut,
15    Configured,
16}
17
18/// Non-secret routing/presentation metadata. `Configured` is never proof of
19/// authenticated identity; authoritative operations still read the OS store.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(deny_unknown_fields)]
22pub struct CredentialAuthorityHint {
23    pub state: CredentialAuthorityState,
24    pub generation: u64,
25    pub updated_at_unix_ms: u64,
26}
27
28impl CredentialAuthorityHint {
29    pub fn unknown() -> Self {
30        Self {
31            state: CredentialAuthorityState::Unknown,
32            generation: 0,
33            updated_at_unix_ms: 0,
34        }
35    }
36
37    pub fn signed_out(generation: u64, updated_at_unix_ms: u64) -> Self {
38        Self {
39            state: CredentialAuthorityState::SignedOut,
40            generation,
41            updated_at_unix_ms,
42        }
43    }
44
45    pub fn configured(generation: u64, updated_at_unix_ms: u64) -> Self {
46        Self {
47            state: CredentialAuthorityState::Configured,
48            generation,
49            updated_at_unix_ms,
50        }
51    }
52}
53
54pub(crate) struct AuthorityHintStore {
55    root: PathBuf,
56}
57
58impl AuthorityHintStore {
59    pub(crate) fn new(root: PathBuf) -> Self {
60        Self { root }
61    }
62
63    pub(crate) fn path(&self) -> PathBuf {
64        self.root.join(AUTHORITY_HINT_FILE)
65    }
66
67    pub(crate) fn load(&self) -> CredentialAuthorityHint {
68        let path = self.path();
69        let metadata = match std::fs::symlink_metadata(&path) {
70            Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => metadata,
71            _ => return CredentialAuthorityHint::unknown(),
72        };
73        if metadata.len() > 16 * 1024 {
74            return CredentialAuthorityHint::unknown();
75        }
76        std::fs::read(&path)
77            .ok()
78            .and_then(|raw| serde_json::from_slice(&raw).ok())
79            .unwrap_or_else(CredentialAuthorityHint::unknown)
80    }
81
82    pub(crate) fn publish(&self, hint: CredentialAuthorityHint) -> io::Result<()> {
83        use std::io::Write;
84
85        std::fs::create_dir_all(&self.root)?;
86        let temp_path = self.root.join(format!(
87            ".{AUTHORITY_HINT_FILE}.{}.tmp",
88            uuid::Uuid::new_v4().simple()
89        ));
90        let result = (|| {
91            let mut file = open_private_temp(&temp_path)?;
92            let body = serde_json::to_vec(&hint)
93                .map_err(|error| io::Error::other(format!("serialize authority hint: {error}")))?;
94            file.write_all(&body)?;
95            file.sync_all()?;
96            atomic_replace(&temp_path, &self.path())?;
97            sync_directory(&self.root)?;
98            Ok(())
99        })();
100        if result.is_err() {
101            let _ = std::fs::remove_file(&temp_path);
102        }
103        result
104    }
105
106    pub(crate) fn discard(&self) -> io::Result<()> {
107        match std::fs::remove_file(self.path()) {
108            Ok(()) => sync_directory(&self.root),
109            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
110            Err(error) => Err(error),
111        }
112    }
113}
114
115#[cfg(unix)]
116fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
117    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
118
119    let file = std::fs::OpenOptions::new()
120        .write(true)
121        .create_new(true)
122        .mode(0o600)
123        .open(path)?;
124    // `OpenOptionsExt::mode` is filtered through the process umask. Reset the
125    // final mode explicitly so even an unusually restrictive inherited umask
126    // cannot violate the persisted 0600 contract.
127    file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
128    Ok(file)
129}
130
131#[cfg(not(unix))]
132fn open_private_temp(path: &Path) -> io::Result<std::fs::File> {
133    std::fs::OpenOptions::new()
134        .write(true)
135        .create_new(true)
136        .open(path)
137}
138
139#[cfg(not(windows))]
140fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
141    std::fs::rename(source, destination)
142}
143
144#[cfg(windows)]
145fn atomic_replace(source: &Path, destination: &Path) -> io::Result<()> {
146    use std::os::windows::ffi::OsStrExt;
147
148    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
149    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
150    #[link(name = "kernel32")]
151    unsafe extern "system" {
152        fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
153    }
154    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
155    let destination: Vec<u16> = destination
156        .as_os_str()
157        .encode_wide()
158        .chain(Some(0))
159        .collect();
160    // SAFETY: both pointers address NUL-terminated UTF-16 buffers for the
161    // duration of the call, and the flags request atomic replacement.
162    let replaced = unsafe {
163        MoveFileExW(
164            source.as_ptr(),
165            destination.as_ptr(),
166            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
167        )
168    };
169    if replaced == 0 {
170        Err(io::Error::last_os_error())
171    } else {
172        Ok(())
173    }
174}
175
176#[cfg(unix)]
177fn sync_directory(path: &Path) -> io::Result<()> {
178    std::fs::File::open(path)?.sync_all()
179}
180
181#[cfg(not(unix))]
182fn sync_directory(_path: &Path) -> io::Result<()> {
183    Ok(())
184}
185
186/// Read the passive, non-authoritative auth hint from `CAR_HOME`.
187pub fn credential_authority_hint() -> CredentialAuthorityHint {
188    if FORCE_UNKNOWN.load(Ordering::Acquire) {
189        return CredentialAuthorityHint::unknown();
190    }
191    let Some(root) = car_home::root() else {
192        return CredentialAuthorityHint::unknown();
193    };
194    AuthorityHintStore::new(root).load()
195}
196
197pub(crate) fn publish_for_state(state: &crate::state::AuthStateV2) -> io::Result<()> {
198    let root = car_home::root().ok_or_else(|| {
199        FORCE_UNKNOWN.store(true, Ordering::Release);
200        io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
201    })?;
202    let now = crate::epoch_millis();
203    let hint = if state.active.is_some() {
204        CredentialAuthorityHint::configured(state.generation, now)
205    } else {
206        CredentialAuthorityHint::signed_out(state.generation, now)
207    };
208    match AuthorityHintStore::new(root).publish(hint) {
209        Ok(()) => {
210            FORCE_UNKNOWN.store(false, Ordering::Release);
211            Ok(())
212        }
213        Err(error) => {
214            FORCE_UNKNOWN.store(true, Ordering::Release);
215            Err(error)
216        }
217    }
218}
219
220pub(crate) fn degrade_to_unknown() -> io::Result<()> {
221    let root = car_home::root().ok_or_else(|| {
222        FORCE_UNKNOWN.store(true, Ordering::Release);
223        io::Error::new(io::ErrorKind::NotFound, "CAR state root is unavailable")
224    })?;
225    let store = AuthorityHintStore::new(root);
226    if let Err(error) = store.publish(CredentialAuthorityHint::unknown()) {
227        FORCE_UNKNOWN.store(true, Ordering::Release);
228        if store.discard().is_err() {
229            FORCE_UNKNOWN.store(true, Ordering::Release);
230        }
231        return Err(error);
232    }
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[cfg(unix)]
241    const FAILURE_CHILD_ENV: &str = "CAR_AUTHORITY_HINT_FAILURE_CHILD";
242
243    #[cfg(unix)]
244    fn run_failed_degradation_scenario() {
245        use std::os::unix::fs::PermissionsExt;
246
247        let dir = tempfile::tempdir().unwrap();
248        std::env::set_var(car_home::ENV_VAR, dir.path());
249        let store = AuthorityHintStore::new(dir.path().to_path_buf());
250        store
251            .publish(CredentialAuthorityHint::configured(7, 1234))
252            .unwrap();
253
254        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
255        let mut signed_out = crate::state::AuthStateV2::signed_out();
256        signed_out.generation = 8;
257        assert!(publish_for_state(&signed_out).is_err());
258        assert!(degrade_to_unknown().is_err());
259        let during_failure = credential_authority_hint();
260
261        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
262        signed_out.generation = 9;
263        publish_for_state(&signed_out).unwrap();
264        let after_recovery = credential_authority_hint();
265        std::env::remove_var(car_home::ENV_VAR);
266
267        assert_eq!(during_failure, CredentialAuthorityHint::unknown());
268        assert_eq!(after_recovery.state, CredentialAuthorityState::SignedOut);
269        assert_eq!(after_recovery.generation, 9);
270    }
271
272    #[cfg(unix)]
273    #[test]
274    fn failed_hint_degradation_forces_public_reads_unknown_until_success() {
275        if std::env::var_os(FAILURE_CHILD_ENV).is_some() {
276            run_failed_degradation_scenario();
277            return;
278        }
279
280        let output = std::process::Command::new(std::env::current_exe().unwrap())
281            .args([
282                "--exact",
283                "authority_hint::tests::failed_hint_degradation_forces_public_reads_unknown_until_success",
284                "--nocapture",
285            ])
286            .env(FAILURE_CHILD_ENV, "1")
287            .output()
288            .unwrap();
289        assert!(
290            output.status.success(),
291            "isolated authority-hint regression failed:\nstdout:\n{}\nstderr:\n{}",
292            String::from_utf8_lossy(&output.stdout),
293            String::from_utf8_lossy(&output.stderr)
294        );
295    }
296
297    #[test]
298    fn malformed_or_missing_hint_is_unknown_and_contains_no_identity() {
299        let dir = tempfile::tempdir().unwrap();
300        let store = AuthorityHintStore::new(dir.path().to_path_buf());
301        assert_eq!(store.load(), CredentialAuthorityHint::unknown());
302
303        std::fs::write(
304            store.path(),
305            br#"{"state":"configured","account_id":"forbidden"}"#,
306        )
307        .unwrap();
308        assert_eq!(store.load(), CredentialAuthorityHint::unknown());
309    }
310
311    #[test]
312    fn published_hint_is_atomic_private_and_token_free() {
313        let dir = tempfile::tempdir().unwrap();
314        let store = AuthorityHintStore::new(dir.path().to_path_buf());
315        store
316            .publish(CredentialAuthorityHint::configured(7, 1234))
317            .unwrap();
318
319        let body = std::fs::read_to_string(store.path()).unwrap();
320        assert!(!body.contains("token"));
321        assert!(!body.contains("account"));
322        assert_eq!(store.load().generation, 7);
323
324        store
325            .publish(CredentialAuthorityHint::signed_out(8, 2345))
326            .unwrap();
327        assert_eq!(store.load().generation, 8);
328        assert_eq!(store.load().state, CredentialAuthorityState::SignedOut);
329        #[cfg(unix)]
330        {
331            use std::os::unix::fs::PermissionsExt;
332            assert_eq!(
333                std::fs::metadata(store.path())
334                    .unwrap()
335                    .permissions()
336                    .mode()
337                    & 0o777,
338                0o600
339            );
340        }
341        assert!(
342            std::fs::read_dir(dir.path())
343                .unwrap()
344                .all(|entry| entry.unwrap().path() == store.path()),
345            "atomic publication must not leave staging files behind"
346        );
347    }
348}