Files
fips/src/bin/fipsctl.rs
T
Johnathan Corgan e99654d464 Add identity provisioning with persistent key file
Implement identity management for the FIPS daemon with two modes:

Ephemeral (default): A fresh keypair is generated on every start.
Key files (fips.key, fips.pub) are written for operator visibility
but overwritten on each restart. This is privacy-friendly and
requires no configuration.

Persistent (opt-in via `node.identity.persistent: true`): Uses
three-tier identity resolution:
1. Explicit nsec in config file (advanced users)
2. Persistent key file alongside config (reused across restarts)
3. Generate new keypair, persist to key file

Key files follow the SSH id_ed25519/id_ed25519.pub convention:
- fips.key: bare bech32 nsec string, mode 0600
- fips.pub: bare bech32 npub string, mode 0644

The daemon always writes fips.pub on startup so operators can
find their current identity via `cat /etc/fips/fips.pub`.

Identity resolution extracted into testable `resolve_identity()`
function in config module. Graceful degradation: key file write
failure silently falls back to ephemeral identity.

Add `fipsctl keygen` subcommand for manual key generation:
- `-d <dir>` output directory (default: /etc/fips)
- `-f` force overwrite existing files
- `-s` print nsec/npub to stdout instead of writing files
- Warns that `persistent: true` must be set in config
- Works without a running daemon

Includes unit tests for key file read/write roundtrip, file
permissions, whitespace trimming, empty file error, path derivation,
ephemeral-by-default behavior, ephemeral key cycling, persistent
key file loading, and persistent generate-and-reuse lifecycle.
2026-03-06 20:33:52 +00:00

234 lines
6.9 KiB
Rust

//! fipsctl — FIPS control client
//!
//! Connects to the FIPS daemon's Unix domain control socket, sends a
//! query command, and pretty-prints the JSON response.
use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file};
use fips::{encode_nsec, Identity};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// FIPS control client
#[derive(Parser, Debug)]
#[command(name = "fipsctl", version, about = "Query a running FIPS daemon")]
struct Cli {
/// Control socket path override
#[arg(short = 's', long)]
socket: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Show node information
Show {
#[command(subcommand)]
what: ShowCommands,
},
/// Generate a new FIPS identity keypair
Keygen {
/// Output directory for fips.key and fips.pub
#[arg(short = 'd', long = "dir", default_value = "/etc/fips")]
dir: PathBuf,
/// Overwrite existing key files
#[arg(short = 'f', long = "force")]
force: bool,
/// Print nsec and npub to stdout instead of writing files
#[arg(short = 's', long = "stdout")]
stdout: bool,
},
}
#[derive(Subcommand, Debug)]
enum ShowCommands {
/// Node status overview
Status,
/// Authenticated peers
Peers,
/// Active links
Links,
/// Spanning tree state
Tree,
/// End-to-end sessions
Sessions,
/// Bloom filter state
Bloom,
/// MMP metrics summary
Mmp,
/// Coordinate cache stats
Cache,
/// Pending handshake connections
Connections,
/// Transport instances
Transports,
/// Routing table summary
Routing,
}
impl ShowCommands {
fn command_name(&self) -> &'static str {
match self {
ShowCommands::Status => "show_status",
ShowCommands::Peers => "show_peers",
ShowCommands::Links => "show_links",
ShowCommands::Tree => "show_tree",
ShowCommands::Sessions => "show_sessions",
ShowCommands::Bloom => "show_bloom",
ShowCommands::Mmp => "show_mmp",
ShowCommands::Cache => "show_cache",
ShowCommands::Connections => "show_connections",
ShowCommands::Transports => "show_transports",
ShowCommands::Routing => "show_routing",
}
}
}
/// Determine the default socket path.
fn default_socket_path() -> PathBuf {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else if Path::new("/run/fips/control.sock").exists() {
PathBuf::from("/run/fips/control.sock")
} else {
PathBuf::from("/tmp/fips-control.sock")
}
}
fn main() {
let cli = Cli::parse();
// Commands that don't require a running daemon
match &cli.command {
Commands::Keygen {
dir,
force,
stdout,
} => {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if *stdout {
println!("{}", nsec);
println!("{}", npub);
return;
}
let key_path = dir.join("fips.key");
let pub_path = dir.join("fips.pub");
if key_path.exists() && !force {
eprintln!("error: key file already exists: {}", key_path.display());
eprintln!("Use --force to overwrite.");
std::process::exit(1);
}
if let Err(e) = std::fs::create_dir_all(dir) {
eprintln!("error: cannot create directory {}: {}", dir.display(), e);
std::process::exit(1);
}
if let Err(e) = write_key_file(&key_path, &nsec) {
eprintln!("error: failed to write key file: {}", e);
std::process::exit(1);
}
if let Err(e) = write_pub_file(&pub_path, &npub) {
eprintln!("error: failed to write pub file: {}", e);
std::process::exit(1);
}
eprintln!("{}", npub);
eprintln!("Key files written to: {}/", dir.display());
eprintln!();
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
eprintln!(" or these keys will be overwritten on next daemon start.");
return;
}
Commands::Show { .. } => {}
}
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
let command_name = match &cli.command {
Commands::Show { what } => what.command_name(),
Commands::Keygen { .. } => unreachable!(),
};
// Connect to the control socket
let mut stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: cannot connect to {}: {}",
socket_path.display(),
e
);
eprintln!("Is the FIPS daemon running?");
std::process::exit(1);
}
};
// Set timeouts
let timeout = Duration::from_secs(2);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
// Send request
let request = format!("{{\"command\":\"{}\"}}\n", command_name);
if let Err(e) = stream.write_all(request.as_bytes()) {
eprintln!("error: failed to send request: {}", e);
std::process::exit(1);
}
// Shutdown write half to signal end of request
let _ = stream.shutdown(std::net::Shutdown::Write);
// Read response
let reader = BufReader::new(&stream);
let response_line = match reader.lines().next() {
Some(Ok(line)) => line,
Some(Err(e)) => {
eprintln!("error: failed to read response: {}", e);
std::process::exit(1);
}
None => {
eprintln!("error: no response from daemon");
std::process::exit(1);
}
};
// Parse and pretty-print
let Ok(value) = serde_json::from_str::<serde_json::Value>(&response_line) else {
// Not JSON, print raw
println!("{}", response_line);
return;
};
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if status == "error" {
let msg = value
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("error: {}", msg);
std::process::exit(1);
}
// Pretty-print the data field (or whole response if no data field)
let output = if let Some(data) = value.get("data") {
serde_json::to_string_pretty(data).unwrap_or(response_line)
} else {
serde_json::to_string_pretty(&value).unwrap_or(response_line)
};
println!("{}", output);
}