//! FIPS daemon config + key materialisation. //! //! Writes `/etc/fips/fips.yaml`, `/etc/fips/fips.key`, and //! `/etc/fips/fips.pub` from the archipelago node's seed-derived FIPS //! keypair, then chmod 0600 the private key. //! //! Privileged filesystem writes go through a `sudo install` invocation //! rather than opening `/etc/fips/*` directly — the archipelago service //! user cannot write `/etc` itself. The sudoers policy in the ISO //! whitelists `install` into `/etc/fips/`. use anyhow::{Context, Result}; use std::path::Path; use tokio::process::Command; use super::{ DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT, }; /// Write the FIPS daemon config based on the local npub and default /// transports. Overwrites any existing file — callers are expected to /// re-run this whenever the key or daemon version changes. /// /// Schema is intentionally minimal: node identity comes from the key /// file on disk (the daemon handles it), transports enable UDP + TCP /// (matching upstream factory default), IPv6 TUN + DNS on defaults. /// Static peer list is empty — archipelago feeds peers dynamically via /// the seed-anchors apply loop and federation-invite hooks. pub fn render_config_yaml() -> String { // Schema matches upstream jmcorgan/fips as of 2026-04. With // `node.identity.persistent: true` the daemon reuses the key file at // config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr` // rather than `enabled: true / port: N`. Both UDP and TCP are // enabled by default because the public anchor (fips.v0l.io) // currently answers on TCP/8443 only, and networks that block UDP // outbound can still bootstrap via TCP. Upstream fips no longer // has a `tor:` transport variant — archipelago's own Tor fallback // handles that layer. format!( "# Generated by archipelago — do not edit by hand.\n\ # Regenerated on every key change and daemon upgrade.\n\ node:\n \ identity:\n \ persistent: true\n\ tun:\n \ enabled: true\n \ name: fips0\n \ mtu: 1280\n\ dns:\n \ enabled: true\n \ bind_addr: \"127.0.0.1\"\n\ transports:\n \ udp:\n \ bind_addr: \"0.0.0.0:{udp}\"\n \ tcp:\n \ bind_addr: \"0.0.0.0:{tcp}\"\n\ peers: []\n", udp = DEFAULT_UDP_PORT, tcp = DEFAULT_TCP_PORT, ) } /// Install the local FIPS key + rendered config into `/etc/fips/`. /// Requires the seed-derived key to already exist at `identity_dir/fips_key`. pub async fn install(identity_dir: &Path) -> Result<()> { let src_key = identity_dir.join("fips_key"); let src_pub = identity_dir.join("fips_key.pub"); if !src_key.exists() { anyhow::bail!( "FIPS key not materialised at {} — run seed onboarding first", src_key.display() ); } // Ensure /etc/fips exists with mode 0755. sudo_install_dir("/etc/fips").await?; // Render + write the yaml via a staging file the archipelago user owns, // then `sudo install` it into place so we never need to write to // /etc directly. let yaml = render_config_yaml(); let stage = std::env::temp_dir().join(format!("fips-{}.yaml", std::process::id())); tokio::fs::write(&stage, yaml) .await .context("Failed to stage fips.yaml")?; let install_result = sudo_install_file(&stage, DAEMON_CONFIG_PATH, "0644").await; let _ = tokio::fs::remove_file(&stage).await; install_result?; sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?; // Heal a legacy fips_key.pub that was written as bech32 npub text // (pre-fix identity::write_fips_key_from_seed did this). Upstream // fips expects 32 raw bytes; a text file silently passes through // and then the daemon can't identify itself to peers. This // rewrites the source file in place with the correct binary form // derived from fips_key before staging it to /etc/fips/fips.pub. normalize_pub_file(&src_key, &src_pub).await?; sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?; Ok(()) } /// Ensure `fips_key.pub` is 32 raw bytes. If it's a bech32 npub text /// file (from the pre-fix writer), decode it and rewrite in place. If /// the file is missing or its content doesn't match either format, /// re-derive the public key from `fips_key` and write that. pub async fn normalize_pub_file(key_path: &Path, pub_path: &Path) -> Result<()> { // Happy path: already 32 raw bytes. if let Ok(bytes) = tokio::fs::read(pub_path).await { if bytes.len() == 32 { return Ok(()); } // bech32 npub text from the pre-fix writer: decode in place. if let Ok(s) = std::str::from_utf8(&bytes) { let trimmed = s.trim(); if trimmed.starts_with("npub1") { if let Ok(pk) = nostr_sdk::PublicKey::parse(trimmed) { let raw: [u8; 32] = pk.to_bytes(); tokio::fs::write(pub_path, raw) .await .context("rewriting fips_key.pub as 32 raw bytes")?; tracing::info!( "Migrated legacy bech32 fips_key.pub to raw-byte form at {}", pub_path.display() ); return Ok(()); } } } } // Fallback: no pub file, or unreadable format. Re-derive from the // private key file (already validated by load_fips_keys). let secret_bytes = tokio::fs::read(key_path) .await .with_context(|| format!("read {} to derive public", key_path.display()))?; let text = std::str::from_utf8(&secret_bytes) .context("fips_key is not UTF-8 — can't derive public")?; let secret = nostr_sdk::SecretKey::parse(text.trim()) .context("fips_key not parseable as bech32 nsec")?; let keys = nostr_sdk::Keys::new(secret); let raw: [u8; 32] = keys.public_key().to_bytes(); tokio::fs::write(pub_path, raw) .await .context("writing re-derived fips_key.pub")?; tracing::info!("Re-derived fips_key.pub from fips_key"); Ok(()) } async fn sudo_install_dir(path: &str) -> Result<()> { let out = Command::new("sudo") .args(["install", "-d", "-m", "0755", path]) .output() .await .with_context(|| format!("sudo install -d {}", path))?; if !out.status.success() { anyhow::bail!( "sudo install -d {}: {}", path, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } async fn sudo_install_file(src: &Path, dest: &str, mode: &str) -> Result<()> { let out = Command::new("sudo") .args([ "install", "-m", mode, src.to_str().context("Non-UTF8 source path")?, dest, ]) .output() .await .with_context(|| format!("sudo install {} -> {}", src.display(), dest))?; if !out.status.success() { anyhow::bail!( "sudo install {} -> {}: {}", src.display(), dest, String::from_utf8_lossy(&out.stderr).trim() ); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_rendered_yaml_matches_upstream_schema() { let yaml = render_config_yaml(); assert!(yaml.contains("persistent: true")); assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT))); assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT))); assert!(yaml.contains("udp:")); assert!(yaml.contains("tcp:")); assert!(yaml.contains("tun:")); assert!(yaml.contains("name: fips0")); // Upstream fips dropped the `tor:` transport variant; archipelago // handles Tor fallback itself. Make sure we didn't regress. assert!(!yaml.contains("tor:")); } #[tokio::test] async fn test_install_refuses_when_key_missing() { let dir = tempfile::tempdir().unwrap(); let err = install(dir.path()).await.unwrap_err(); assert!(err.to_string().contains("FIPS key not materialised")); } }