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);
}
}