Skip to main content

cairn_mod/
credential_file.rs

1//! Shared file-permission invariants for on-disk credentials (§5.1 + §5.3).
2//!
3//! Both the labeler's private signing key (§5.1, read once at startup by
4//! `cairn serve`) and the CLI's PDS session file (§5.3, rewritten on each
5//! auto-refresh) live on disk with identical security requirements:
6//!
7//! - Mode exactly `0o600`. Wider → reject at load time.
8//! - Owned by the current effective UID. Mismatch → reject.
9//! - File-only input. Env-var-delivered key material is rejected at a
10//!   higher layer (see [`reject_env_override`]).
11//!
12//! Extracting this check into one place means a future tightening (say,
13//! rejecting setuid files too) lands once and covers both surfaces.
14
15use std::fs;
16use std::io;
17use std::path::{Path, PathBuf};
18
19use thiserror::Error;
20
21/// Error surface for credential-file invariants. Session and signing-key
22/// modules each map these to their own error taxonomies with `From`
23/// conversions so callers never see a bare `CredentialFileError` in the
24/// top-level CLI error — the wrapping surfaces the context (*which*
25/// credential failed).
26#[derive(Debug, Error)]
27pub enum CredentialFileError {
28    /// Filesystem failure reading the file or its metadata.
29    #[error("io: {0}")]
30    Io(#[from] io::Error),
31    /// File mode is wider than `0o600`. §5.1 / §5.3 rejection —
32    /// the actual mode bits (lower 9) are surfaced for
33    /// diagnostics.
34    #[error("credential file {path} has insecure permissions (mode {mode:o}); expected 600")]
35    InsecurePermissions {
36        /// Path to the offending credential file.
37        path: PathBuf,
38        /// Actual mode bits (lower 9 bits).
39        mode: u32,
40    },
41    /// File owner UID doesn't match the running effective UID
42    /// (§5.1 / §5.3 enforcement).
43    #[error("credential file {path} is owned by another user")]
44    ForeignOwner {
45        /// Path to the offending credential file.
46        path: PathBuf,
47    },
48    /// A named env var was set when `reject_env_override` was
49    /// called — guarded against operators trying to deliver key
50    /// material via env.
51    #[error(
52        "{env} is set — key material is file-only (§5.1); unset the env var and use the file path"
53    )]
54    EnvOverrideRejected {
55        /// Name of the forbidden env var.
56        env: &'static str,
57    },
58    /// Running on a non-Unix platform where the 0600 + owner
59    /// checks can't be enforced.
60    #[error("cairn CLI on this platform is not supported in v1; use a POSIX filesystem")]
61    UnsupportedPlatform,
62}
63
64/// Unix mode + owner invariants. `Ok(())` only if the file's mode is
65/// exactly `0o600` and the file's owner UID equals the current effective
66/// UID.
67pub fn check_mode_and_owner(path: &Path) -> Result<(), CredentialFileError> {
68    #[cfg(unix)]
69    {
70        use std::os::unix::fs::MetadataExt;
71        let meta = fs::metadata(path)?;
72        let mode = meta.mode() & 0o777;
73        check_mode(path, mode)?;
74        let current = current_uid();
75        check_owner(path, meta.uid(), current)?;
76        Ok(())
77    }
78    #[cfg(not(unix))]
79    {
80        let _ = path;
81        Err(CredentialFileError::UnsupportedPlatform)
82    }
83}
84
85/// Refuse startup if `env` is set in the process environment. §5.1
86/// rejects env-var-delivered signing-key material; this is the
87/// codified guardrail.
88pub fn reject_env_override(env: &'static str) -> Result<(), CredentialFileError> {
89    if std::env::var_os(env).is_some() {
90        Err(CredentialFileError::EnvOverrideRejected { env })
91    } else {
92        Ok(())
93    }
94}
95
96#[cfg(unix)]
97fn check_mode(path: &Path, mode: u32) -> Result<(), CredentialFileError> {
98    if mode == 0o600 {
99        Ok(())
100    } else {
101        Err(CredentialFileError::InsecurePermissions {
102            path: path.to_path_buf(),
103            mode,
104        })
105    }
106}
107
108/// Ownership predicate. Pure function so tests can exercise the
109/// foreign-owner branch without needing `chown(2)` to a non-current UID.
110#[cfg(unix)]
111fn check_owner(path: &Path, file_uid: u32, current_uid: u32) -> Result<(), CredentialFileError> {
112    if file_uid == current_uid {
113        Ok(())
114    } else {
115        Err(CredentialFileError::ForeignOwner {
116            path: path.to_path_buf(),
117        })
118    }
119}
120
121#[cfg(unix)]
122fn current_uid() -> u32 {
123    rustix::process::geteuid().as_raw()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn check_owner_accepts_matching_uid() {
132        assert!(check_owner(Path::new("x"), 1000, 1000).is_ok());
133    }
134
135    #[test]
136    fn check_owner_rejects_foreign_uid() {
137        let err = check_owner(Path::new("x"), 0, 1000).unwrap_err();
138        assert!(matches!(err, CredentialFileError::ForeignOwner { .. }));
139    }
140
141    #[test]
142    fn check_mode_accepts_0600() {
143        assert!(check_mode(Path::new("x"), 0o600).is_ok());
144    }
145
146    #[test]
147    fn check_mode_rejects_wider_modes() {
148        for bad in [0o644, 0o640, 0o666, 0o700, 0o755, 0o777] {
149            let err = check_mode(Path::new("x"), bad).unwrap_err();
150            assert!(
151                matches!(
152                    err,
153                    CredentialFileError::InsecurePermissions { mode, .. } if mode == bad
154                ),
155                "expected InsecurePermissions for mode {bad:o}, got {err:?}"
156            );
157        }
158    }
159
160    #[test]
161    fn reject_env_override_passes_when_unset() {
162        // Pick a name no real test harness would set.
163        assert!(reject_env_override("CAIRN_DOES_NOT_EXIST_FOR_TEST").is_ok());
164    }
165}