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

nordisk/monetize

crates/monetize-product-wasm/src/secrets.rs

//! **Secrets are files the host reads, and a guest never sees one.**
//!
//! A credential a guest needs — a vendor's API key, a webhook's signing
//! secret, a client certificate — is a file `<secrets_dir>/secret.<name>`,
//! owner-only (no group or other bits), read by the HOST at every use. The
//! guest names what it needs by being given a block that names it
//! (`http_auth`, `hmac_secrets`); the value never crosses the ABI, never enters
//! a log line and never appears in an error: every message here names the
//! secret by its NAME.
//!
//! Read at every use and never cached as text, so an operator (or vm1's feed)
//! that rewrites a file rotates the credential on the next call — no restart.
//!
//! [`write`] is the one writer: what vm1's feed and the plugin insert door call,
//! so a file is created 0600 before a byte of it exists and appears whole.

use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};

/// The file name prefix: `secret.<name>`.
pub const PREFIX: &str = "secret.";

/// The longest name a secret may have.
pub const MAX_NAME: usize = 64;

/// The largest secret the host will read: a PEM chain with its key is a few
/// kilobytes; anything near this is not a credential.
pub const MAX_BYTES: u64 = 256 * 1024;

/// **A secret's bytes, held by the host for the length of one use.** `Debug`
/// prints the length only, and the bytes are overwritten when it drops.
pub struct Secret(Vec<u8>);

impl Secret {
    pub fn bytes(&self) -> &[u8] {
        &self.0
    }
    /// The bytes as text, for a header. A secret that is not UTF-8 cannot be a
    /// header value and is refused by name.
    pub fn text(&self, name: &str) -> Result<&str, String> {
        std::str::from_utf8(&self.0).map_err(|_| format!("[secret-not-text] secret.{name} is not UTF-8 text, so it cannot be sent as a header"))
    }
}

impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Secret({} bytes)", self.0.len())
    }
}

impl Drop for Secret {
    fn drop(&mut self) {
        for b in self.0.iter_mut() {
            // SAFETY: a valid, aligned, exclusively borrowed byte.
            unsafe { std::ptr::write_volatile(b, 0) };
        }
    }
}

/// `[A-Za-z0-9_.-]`, 1..=64, and not starting with a dot.
pub fn valid_name(name: &str) -> Result<(), String> {
    let ok = !name.is_empty()
        && name.len() <= MAX_NAME
        && !name.starts_with('.')
        && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'));
    if ok {
        Ok(())
    } else {
        Err(format!("[secret-name] {name:?} is not a secret name: 1..{MAX_NAME} of A-Z a-z 0-9 _ . - and not starting with a dot"))
    }
}

/// `<dir>/secret.<name>`.
pub fn path(dir: &Path, name: &str) -> PathBuf {
    dir.join(format!("{PREFIX}{name}"))
}

/// **Read `secret.<name>`**, refusing by name anything that is not an
/// owner-only regular file with something in it. One trailing newline is
/// dropped, because every editor adds one and no credential ends with it.
pub fn read(dir: &Path, name: &str) -> Result<Secret, String> {
    valid_name(name)?;
    let p = path(dir, name);
    let meta = std::fs::symlink_metadata(&p).map_err(|e| format!("[secret-missing] secret.{name} cannot be read in {}: {}", dir.display(), e.kind()))?;
    if !meta.file_type().is_file() {
        return Err(format!("[secret-not-a-file] secret.{name} in {} is not a regular file (a link or a directory is not a secret)", dir.display()));
    }
    let mode = meta.permissions().mode() & 0o777;
    if mode & 0o077 != 0 {
        return Err(format!(
            "[secret-mode] secret.{name} in {} is mode {mode:03o}; a secret is readable by its owner only (0600 or 0400), so it is not used",
            dir.display()
        ));
    }
    if meta.len() > MAX_BYTES {
        return Err(format!("[secret-too-large] secret.{name} is {} bytes; the ceiling is {MAX_BYTES}", meta.len()));
    }
    let mut bytes = std::fs::read(&p).map_err(|e| format!("[secret-missing] secret.{name} cannot be read: {}", e.kind()))?;
    if bytes.ends_with(b"\r\n") {
        bytes.truncate(bytes.len() - 2);
    } else if bytes.ends_with(b"\n") {
        bytes.truncate(bytes.len() - 1);
    }
    if bytes.is_empty() {
        return Err(format!("[secret-empty] secret.{name} is empty; an empty key authenticates nothing and an empty HMAC secret verifies every forgery"));
    }
    Ok(Secret(bytes))
}

/// A cheap fingerprint of the file (length and modification time), for a
/// client that must be rebuilt when its certificate is rotated. Never the bytes.
pub fn stamp(dir: &Path, name: &str) -> Option<(u64, std::time::SystemTime)> {
    let m = std::fs::metadata(path(dir, name)).ok()?;
    Some((m.len(), m.modified().ok()?))
}

/// **Write `secret.<name>`**, the one writer: the directory is created 0700,
/// the file is created 0600 BEFORE a byte is written, synced, and renamed into
/// place, so a reader sees the old secret or the new one and never half of one.
pub fn write(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf, String> {
    valid_name(name)?;
    if bytes.is_empty() {
        return Err(format!("[secret-empty] secret.{name}: refusing to write an empty secret"));
    }
    if !dir.exists() {
        std::fs::create_dir_all(dir).map_err(|e| format!("[secret-dir] cannot create {}: {e}", dir.display()))?;
        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| format!("[secret-dir] cannot make {} 0700: {e}", dir.display()))?;
    }
    let target = path(dir, name);
    let tmp = dir.join(format!("{PREFIX}{name}.tmp"));
    let _ = std::fs::remove_file(&tmp);
    let mut f = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&tmp)
        .map_err(|e| format!("[secret-write] secret.{name}: cannot create in {}: {e}", dir.display()))?;
    f.write_all(bytes).map_err(|e| format!("[secret-write] secret.{name}: {e}"))?;
    f.sync_all().map_err(|e| format!("[secret-write] secret.{name}: {e}"))?;
    drop(f);
    std::fs::rename(&tmp, &target).map_err(|e| format!("[secret-write] secret.{name}: {e}"))?;
    Ok(target)
}

/// Remove `secret.<name>`. Absent is not an error.
pub fn remove(dir: &Path, name: &str) -> Result<(), String> {
    valid_name(name)?;
    match std::fs::remove_file(path(dir, name)) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(format!("[secret-write] secret.{name}: {e}")),
    }
}

/// The NAMES of the secrets in `dir` — never a value.
pub fn names(dir: &Path) -> Result<Vec<String>, String> {
    let mut out = Vec::new();
    let rd = match std::fs::read_dir(dir) {
        Ok(r) => r,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(e) => return Err(format!("[secret-dir] {}: {e}", dir.display())),
    };
    for e in rd.flatten() {
        let n = e.file_name().to_string_lossy().into_owned();
        if let Some(name) = n.strip_prefix(PREFIX) {
            if !name.ends_with(".tmp") && valid_name(name).is_ok() {
                out.push(name.to_string());
            }
        }
    }
    out.sort();
    Ok(out)
}

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

    fn scratch(tag: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!("monetize-secrets-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        p
    }

    #[test]
    fn a_written_secret_is_owner_only_and_reads_back_without_its_newline() {
        let d = scratch("rw");
        let p = write(&d, "stripe_key", b"rk_test_abc\n").unwrap();
        assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
        assert_eq!(std::fs::metadata(&d).unwrap().permissions().mode() & 0o777, 0o700);
        let s = read(&d, "stripe_key").unwrap();
        assert_eq!(s.bytes(), b"rk_test_abc");
        assert_eq!(format!("{s:?}"), "Secret(11 bytes)", "Debug shows the length only");
        assert_eq!(names(&d).unwrap(), vec!["stripe_key"]);
        remove(&d, "stripe_key").unwrap();
        assert!(names(&d).unwrap().is_empty());
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn a_wide_mode_an_empty_file_a_link_and_a_bad_name_are_refused_by_name_and_never_by_value() {
        let d = scratch("refuse");
        write(&d, "k", b"PLANTED-VALUE").unwrap();
        std::fs::set_permissions(path(&d, "k"), std::fs::Permissions::from_mode(0o644)).unwrap();
        let e = read(&d, "k").unwrap_err();
        assert!(e.contains("[secret-mode]") && e.contains("secret.k") && !e.contains("PLANTED"), "{e}");
        std::fs::write(path(&d, "empty"), b"\n").unwrap();
        std::fs::set_permissions(path(&d, "empty"), std::fs::Permissions::from_mode(0o600)).unwrap();
        assert!(read(&d, "empty").unwrap_err().contains("[secret-empty]"));
        std::os::unix::fs::symlink(path(&d, "k"), path(&d, "link")).unwrap();
        assert!(read(&d, "link").unwrap_err().contains("[secret-not-a-file]"));
        assert!(read(&d, "../etc/passwd").unwrap_err().contains("[secret-name]"));
        assert!(read(&d, "absent").unwrap_err().contains("[secret-missing]"));
        let _ = std::fs::remove_dir_all(&d);
    }
}