Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/keyfile.rs

//! ★ **A private key file is the process's alone** (lane HOL-NCSC, NCSC P3).
//!
//! Every private key and secret Holger loads from a file (a door's TLS key, the evidence
//! signer's key, a mirror's client key, the sync key, the export-push signer, the apk signing
//! key, a static token's file, a passkey session key, the WireGuard identity) goes through
//! [`read_private`] or [`check_private`] first. Refused by name (`holger-key-1`), before a
//! byte is read:
//!
//! * a mode that grants the file's group or others any bit (`mode & 0o077 != 0`);
//! * a file another user owns.
//!
//! **The one other shape accepted is a Kubernetes secret volume**: owned by root, its group
//! the process's own effective group (the pod's `fsGroup`), no write bit and nothing for
//! others (`defaultMode: 0440`). That is the only way a non-root container reads a mounted
//! secret, and nobody but the pod's own group can.
//!
//! Every key file Holger writes is written 0600 (the writers beside each key).

use std::path::Path;

/// The process's effective uid and gid, from `/proc/self/status` (Linux); `None` elsewhere,
/// where only the mode is checked.
fn effective_ids() -> Option<(u32, u32)> {
    let status = std::fs::read_to_string("/proc/self/status").ok()?;
    let field = |name: &str| -> Option<u32> {
        let line = status.lines().find(|l| l.starts_with(name))?;
        line.split_whitespace().nth(2)?.parse().ok()
    };
    Some((field("Uid:")?, field("Gid:")?))
}

/// Pure: why a file with `mode`, owned by `owner:group`, is not a private key file for a
/// process running as `me` (`None`: it is one).
pub fn exposure(mode: u32, owner: u32, group: u32, me: Option<(u32, u32)>) -> Option<String> {
    let mode = mode & 0o7777;
    if let Some((uid, gid)) = me {
        if owner != uid {
            // The Kubernetes secret volume: root:fsGroup, at most group-read.
            if owner == 0 && group == gid && mode & 0o137 == 0 {
                return None;
            }
            return Some(format!(
                "it is owned by uid {owner} and this process runs as uid {uid} (mode {mode:o})"
            ));
        }
    }
    if mode & 0o077 != 0 {
        return Some(format!(
            "its mode {mode:o} grants its group or others access; a key file is 0600 or 0400"
        ));
    }
    None
}

/// ★ **Refuse a key file others can read** (`holger-key-1`), as an `io::Error` of kind
/// `PermissionDenied` whose message carries the code, `what` and the path. A file that is
/// not there is the reader's error, not this one.
pub fn check_private(path: &Path, what: &str) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt as _;
        let Ok(m) = std::fs::metadata(path) else {
            return Ok(());
        };
        if let Some(why) = exposure(m.mode(), m.uid(), m.gid(), effective_ids()) {
            return Err(std::io::Error::new(
                std::io::ErrorKind::PermissionDenied,
                holger_errcode::KEY_FILE_EXPOSED.tag(format!(
                    "{what} {}: {why}; `chmod 600 {}`, owned by this process's user",
                    path.display(),
                    path.display()
                )),
            ));
        }
    }
    #[cfg(not(unix))]
    let _ = (path, what);
    Ok(())
}

/// [`check_private`], then the bytes.
pub fn read_private(path: &Path, what: &str) -> std::io::Result<Vec<u8>> {
    check_private(path, what)?;
    std::fs::read(path)
}

/// **Write a private file**: 0600 from its first byte, a temporary beside it renamed in
/// (SEC-REVIEW 2026-11-01 K2). The temporary has a name of its own each time and is made
/// with `create_new` (`O_EXCL`: an existing file or a symlink there fails the open, never
/// followed), its mode set on the open handle; the rename replaces `path` itself. A left-over
/// `<file>.keytmp` of the old scheme is removed (a link is removed, not followed).
pub fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let _ = std::fs::remove_file(path.with_extension("keytmp"));
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or_default();
    let mut name = path.as_os_str().to_owned();
    name.push(format!(
        ".{}.{nanos}.{}.keytmp",
        std::process::id(),
        SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
    ));
    let tmp = std::path::PathBuf::from(name);
    let written = write_then_rename(&tmp, path, bytes);
    if written.is_err() {
        let _ = std::fs::remove_file(&tmp);
    }
    written?;
    if let Some(dir) = path.parent() {
        if let Ok(d) = std::fs::File::open(dir) {
            let _ = d.sync_all();
        }
    }
    Ok(())
}

/// The fresh temporary (`create_new`, 0600 on its handle), its bytes synced, renamed onto `path`.
fn write_then_rename(tmp: &Path, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    use std::io::Write as _;
    let mut o = std::fs::OpenOptions::new();
    o.write(true).create_new(true);
    #[cfg(unix)]
    std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
    let mut f = o.open(tmp)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
    }
    f.write_all(bytes)?;
    f.sync_all()?;
    std::fs::rename(tmp, path)
}

#[cfg(test)]
mod tests {
    use super::exposure;

    /// **A planted temporary never redirects or exposes a secret** (SEC-REVIEW 2026-11-01 K2).
    /// `write_private` opened the fixed `<file>.keytmp` with `create(true).truncate(true)`, so a
    /// symlink left there wrote the secret wherever it pointed (a repository token, the
    /// replication key, the passkey session key). The temporary is now made fresh
    /// (`create_new`, never following a link) under a unique name and renamed in.
    #[cfg(unix)]
    #[test]
    fn a_planted_temporary_never_redirects_the_secret() {
        use std::os::unix::fs::PermissionsExt as _;
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("session.key");
        let stolen = dir.path().join("stolen");
        std::fs::write(&stolen, b"").unwrap();
        std::os::unix::fs::symlink(&stolen, file.with_extension("keytmp")).unwrap();
        super::write_private(&file, b"the secret").unwrap();
        assert_eq!(
            std::fs::read(&stolen).unwrap(),
            b"",
            "the link's target is untouched"
        );
        assert_eq!(std::fs::read(&file).unwrap(), b"the secret");
        let mode = std::fs::symlink_metadata(&file)
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600);
        assert!(!std::fs::symlink_metadata(&file)
            .unwrap()
            .file_type()
            .is_symlink());
        // A rewrite replaces it, still the process's alone.
        super::write_private(&file, b"the next secret").unwrap();
        assert_eq!(std::fs::read(&file).unwrap(), b"the next secret");
        assert_eq!(std::fs::read(&stolen).unwrap(), b"");
    }

    #[test]
    fn a_key_file_is_its_owners_alone_or_a_kubernetes_secret() {
        let me = Some((1000, 1000));
        assert_eq!(exposure(0o100600, 1000, 1000, me), None);
        assert_eq!(exposure(0o100400, 1000, 1000, me), None);
        for m in [0o644, 0o640, 0o604, 0o660, 0o611, 0o700 | 0o040] {
            assert!(
                exposure(m, 1000, 1000, me)
                    .unwrap()
                    .contains("grants its group or others"),
                "{m:o}"
            );
        }
        assert!(exposure(0o600, 1001, 1000, me)
            .unwrap()
            .contains("owned by uid 1001"));
        // The Kubernetes secret volume: root:fsGroup 0440, and nothing looser.
        assert_eq!(exposure(0o440, 0, 1000, me), None);
        assert_eq!(exposure(0o400, 0, 1000, me), None);
        assert!(
            exposure(0o444, 0, 1000, me).is_some(),
            "others may not read"
        );
        assert!(
            exposure(0o460, 0, 1000, me).is_some(),
            "the group may not write"
        );
        assert!(exposure(0o440, 0, 2000, me).is_some(), "another group");
        // No process ids (not Linux): the mode alone.
        assert_eq!(exposure(0o600, 5, 5, None), None);
        assert!(exposure(0o640, 5, 5, None).is_some());
    }
}