//! Declarative, self-healing generation of app secrets. //! //! An app declares `generated_secrets` in its manifest; this module materialises //! them just before `secret_env` is resolved. That keeps the migration's //! data-driven bar: an app installs from its manifest alone — no host //! provisioning and no per-app Rust — and every secret lands `0600`, owned by //! the unprivileged (rootless) service user. //! //! Two properties make it safe to call on every install/reconcile tick: //! //! * **Idempotent** — a target file that already exists, is readable and //! non-empty is left untouched, so values are stable across ticks. //! * **Self-healing without privilege** — a target file that exists but is //! *unreadable* (the classic `root:root`-owned secret left by some earlier //! path) is unlinked and rewritten. Unlinking needs write on the //! service-owned secrets dir, not on the file, so this recovers the broken //! state with no `chown` and no root — exactly what a rootless node needs. use anyhow::{Context, Result}; use archipelago_container::{AppManifest, GeneratedSecret, SecretGenKind}; use rand::RngCore; use std::fs; use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; /// Plaintext-password length (bytes of entropy) for [`SecretGenKind::Bcrypt`]. const BCRYPT_PASSWORD_BYTES: usize = 24; /// Materialise every declared generated secret for `manifest` under /// `secrets_dir`. No-op when the manifest declares none. Safe to call on every /// reconcile/install tick (idempotent + self-healing). pub fn ensure_generated_secrets(secrets_dir: &Path, manifest: &AppManifest) -> Result<()> { let specs = &manifest.app.container.generated_secrets; if specs.is_empty() { return Ok(()); } fs::create_dir_all(secrets_dir) .with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?; for gs in specs { ensure_one(secrets_dir, gs).with_context(|| format!("generating secret '{}'", gs.name))?; } Ok(()) } fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> { let files = gs.target_files(); // Idempotent fast path: every target file present, readable and non-empty. if files.iter().all(|f| readable_nonempty(&dir.join(f))) { return Ok(()); } // Self-heal: drop any stale/unreadable target so the write below recreates // it owned by us. Unlinking uses the (service-owned) dir's write bit, so a // wrongly root-owned secret is recovered with no privilege escalation. for f in &files { let p = dir.join(f); if p.exists() && !readable_nonempty(&p) { tracing::warn!("regenerating unreadable/stale secret {}", p.display()); fs::remove_file(&p) .with_context(|| format!("removing stale secret {}", p.display()))?; } } match gs.kind { SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?, SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?, SecretGenKind::Base64 => write_secret(&dir.join(&gs.name), &random_base64(32))?, SecretGenKind::Bcrypt => { let password = random_hex(BCRYPT_PASSWORD_BYTES); let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST) .context("bcrypt-hashing generated password")?; // Primary (server-facing hash) first, then the plaintext sibling. write_secret(&dir.join(&gs.name), &hash)?; write_secret(&dir.join(format!("{}.pw", gs.name)), &password)?; } } Ok(()) } /// True when `path` exists, is readable by this process, and is non-empty after /// trimming. Any error (missing, permission denied, empty) reads as false. fn readable_nonempty(path: &Path) -> bool { fs::read_to_string(path) .map(|s| !s.trim().is_empty()) .unwrap_or(false) } fn random_hex(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::thread_rng().fill_bytes(&mut buf); hex::encode(buf) } /// `bytes` of entropy, standard base64 (with padding). For keys that a service /// base64-decodes to recover the raw bytes (e.g. netbird's store encryptionKey). fn random_base64(bytes: usize) -> String { use base64::Engine as _; let mut buf = vec![0u8; bytes]; rand::thread_rng().fill_bytes(&mut buf); base64::engine::general_purpose::STANDARD.encode(buf) } /// Atomically write a `0600` secret: a temp file in the same dir (so the rename /// is atomic), fsynced, then renamed over the target. fn write_secret(path: &Path, value: &str) -> Result<()> { let dir = path .parent() .context("secret path has no parent directory")?; let name = path .file_name() .and_then(|n| n.to_str()) .context("secret path has no filename")?; let tmp = dir.join(format!(".{name}.tmp")); let mut f = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&tmp) .with_context(|| format!("creating temp secret {}", tmp.display()))?; f.write_all(value.as_bytes()) .with_context(|| format!("writing temp secret {}", tmp.display()))?; f.sync_all() .with_context(|| format!("fsync temp secret {}", tmp.display()))?; drop(f); fs::rename(&tmp, path) .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?; Ok(()) } #[cfg(test)] mod tests { use super::*; use archipelago_container::SecretGenKind; use std::os::unix::fs::PermissionsExt; fn manifest_with(secrets: Vec) -> AppManifest { let mut m: AppManifest = serde_yaml::from_str( "app:\n id: t\n name: t\n version: 1.0.0\n container:\n image: x:y\n", ) .unwrap(); m.app.container.generated_secrets = secrets; m } fn gs(name: &str, kind: SecretGenKind) -> GeneratedSecret { GeneratedSecret { name: name.to_string(), kind, } } #[test] fn generates_hex_and_bcrypt_with_0600() { let dir = tempfile::tempdir().unwrap(); let m = manifest_with(vec![ gs("tok", SecretGenKind::Hex16), gs("admin", SecretGenKind::Bcrypt), ]); ensure_generated_secrets(dir.path(), &m).unwrap(); let tok = std::fs::read_to_string(dir.path().join("tok")).unwrap(); assert_eq!(tok.trim().len(), 32, "hex16 = 16 bytes = 32 hex chars"); let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap(); let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap(); assert!(hash.starts_with("$2"), "bcrypt hash shape"); assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash"); for f in ["tok", "admin", "admin.pw"] { let mode = std::fs::metadata(dir.path().join(f)) .unwrap() .permissions() .mode() & 0o777; assert_eq!(mode, 0o600, "{f} must be 0600"); } } #[test] fn idempotent_value_is_stable() { let dir = tempfile::tempdir().unwrap(); let m = manifest_with(vec![gs("tok", SecretGenKind::Hex32)]); ensure_generated_secrets(dir.path(), &m).unwrap(); let first = std::fs::read_to_string(dir.path().join("tok")).unwrap(); ensure_generated_secrets(dir.path(), &m).unwrap(); let second = std::fs::read_to_string(dir.path().join("tok")).unwrap(); assert_eq!(first, second, "a present readable secret is never rewritten"); } #[test] fn self_heals_unreadable_secret() { // Simulate the root-owned case: a present-but-unreadable file. We can't // chmod-away read as the owner in a unit test, so emulate "unreadable" // via the empty-file branch (readable_nonempty == false), which drives // the same unlink+regenerate path. let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("tok"), "").unwrap(); let m = manifest_with(vec![gs("tok", SecretGenKind::Hex16)]); ensure_generated_secrets(dir.path(), &m).unwrap(); let v = std::fs::read_to_string(dir.path().join("tok")).unwrap(); assert_eq!(v.trim().len(), 32, "stale/empty secret was regenerated"); } }