215 lines
8.1 KiB
Rust
215 lines
8.1 KiB
Rust
//! 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"));
|
|
}
|
|
}
|