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.
This commit is contained in:
Johnathan Corgan
2026-03-06 20:33:52 +00:00
parent 920d93571a
commit e99654d464
3 changed files with 486 additions and 6 deletions
+17 -6
View File
@@ -3,6 +3,7 @@
//! Loads configuration and creates the top-level node instance.
use clap::Parser;
use fips::config::{resolve_identity, IdentitySource};
use fips::{Config, Node};
use std::path::PathBuf;
use tracing::{error, info, warn, Level};
@@ -63,14 +64,24 @@ async fn main() {
}
}
// Log identity status
if config.has_identity() {
info!("Using configured identity");
} else {
warn!("No identity configured, generating ephemeral keypair");
// Identity provisioning: config nsec > key file > generate ephemeral
let resolved = match resolve_identity(&config, &loaded_paths) {
Ok(r) => r,
Err(e) => {
error!("Failed to resolve identity: {}", e);
std::process::exit(1);
}
};
match &resolved.source {
IdentitySource::Config => info!("Using identity from configuration"),
IdentitySource::KeyFile(p) => info!(path = %p.display(), "Loaded persistent identity from key file"),
IdentitySource::Generated(p) => info!(path = %p.display(), "Generated persistent identity, saved to key file"),
IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"),
}
// Create node
// Create node with resolved identity
let mut config = config;
config.node.identity.nsec = Some(resolved.nsec);
info!("Creating node");
let mut node = match Node::new(config) {
Ok(node) => node,
+66
View File
@@ -4,6 +4,8 @@
//! 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};
@@ -28,6 +30,18 @@ enum Commands {
#[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)]
@@ -88,9 +102,61 @@ fn default_socket_path() -> PathBuf {
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
+403
View File
@@ -38,6 +38,218 @@ pub use transport::{EthernetConfig, TcpConfig, TransportInstances, TransportsCon
/// Default config filename.
const CONFIG_FILENAME: &str = "fips.yaml";
/// Default key filename, placed alongside the config file.
const KEY_FILENAME: &str = "fips.key";
/// Default public key filename, placed alongside the key file.
const PUB_FILENAME: &str = "fips.pub";
/// Derive the key file path from a config file path.
pub fn key_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(KEY_FILENAME)
}
/// Derive the public key file path from a config file path.
pub fn pub_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(PUB_FILENAME)
}
/// Read a bare bech32 nsec from a key file.
pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
let nsec = contents.trim().to_string();
if nsec.is_empty() {
return Err(ConfigError::EmptyKeyFile {
path: path.to_path_buf(),
});
}
Ok(nsec)
}
/// Write a bare bech32 nsec to a key file with restricted permissions (mode 0600).
pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(nsec.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
/// Write a bare bech32 npub to a public key file (mode 0644).
pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o644)
.open(path)
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(npub.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
/// Resolve identity from config and key file.
///
/// Behavior depends on `node.identity.persistent`:
///
/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
/// every start. Key files are written for operator visibility but overwritten
/// on each restart.
///
/// - **`persistent: true`**: use three-tier resolution:
/// 1. Explicit nsec in config — highest priority
/// 2. Persistent key file (`fips.key`) — reused across restarts
/// 3. Generate new — creates keypair, writes `fips.key` and `fips.pub`
///
/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`.
///
/// Returns the nsec string (bech32 or hex) to be used for identity creation.
pub fn resolve_identity(
config: &Config,
loaded_paths: &[PathBuf],
) -> Result<ResolvedIdentity, ConfigError> {
use crate::encode_nsec;
// Explicit nsec in config always wins
if let Some(nsec) = &config.node.identity.nsec {
return Ok(ResolvedIdentity {
nsec: nsec.clone(),
source: IdentitySource::Config,
});
}
// Determine key file directory from loaded config paths
let config_ref = if let Some(path) = loaded_paths.last() {
path.clone()
} else {
Config::search_paths()
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("./fips.yaml"))
};
let key_path = key_file_path(&config_ref);
let pub_path = pub_file_path(&config_ref);
if config.node.identity.persistent {
// Persistent mode: load existing key file or generate-and-persist
if key_path.exists() {
let nsec = read_key_file(&key_path)?;
let identity = Identity::from_secret_str(&nsec)?;
let _ = write_pub_file(&pub_path, &identity.npub());
return Ok(ResolvedIdentity {
nsec,
source: IdentitySource::KeyFile(key_path),
});
}
// No key file yet — generate and persist
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match write_key_file(&key_path, &nsec) {
Ok(()) => {
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Generated(key_path),
})
}
Err(_) => Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
}),
}
} else {
// Ephemeral mode (default): fresh keypair every start, write key files
// for operator visibility
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = write_key_file(&key_path, &nsec);
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
})
}
}
/// Result of identity resolution.
pub struct ResolvedIdentity {
/// The nsec string (bech32 or hex) for creating an Identity.
pub nsec: String,
/// Where the identity came from.
pub source: IdentitySource,
}
/// Where a resolved identity originated.
pub enum IdentitySource {
/// From explicit nsec in config file.
Config,
/// Loaded from a persistent key file.
KeyFile(PathBuf),
/// Generated and saved to a new key file.
Generated(PathBuf),
/// Generated but could not be persisted.
Ephemeral,
}
/// Errors that can occur during configuration loading.
#[derive(Debug, Error)]
pub enum ConfigError {
@@ -53,6 +265,15 @@ pub enum ConfigError {
source: serde_yaml::Error,
},
#[error("key file is empty: {path}")]
EmptyKeyFile { path: PathBuf },
#[error("failed to write key file {path}: {source}")]
WriteKeyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
}
@@ -64,6 +285,12 @@ pub struct IdentityConfig {
/// If not specified, a new keypair will be generated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsec: Option<String>,
/// Whether to persist the identity across restarts (`node.identity.persistent`).
/// When false (default), a fresh ephemeral keypair is generated each start.
/// When true, the key file is reused across restarts.
#[serde(default)]
pub persistent: bool,
}
/// Root configuration structure.
@@ -172,6 +399,9 @@ impl Config {
if other.node.identity.nsec.is_some() {
self.node.identity.nsec = other.node.identity.nsec;
}
if other.node.identity.persistent {
self.node.identity.persistent = true;
}
// Merge node.leaf_only
if other.node.leaf_only {
self.node.leaf_only = true;
@@ -446,6 +676,179 @@ node:
assert!(yaml.contains("test_nsec"));
}
#[test]
fn test_key_file_write_read_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
let loaded_nsec = read_key_file(&key_path).unwrap();
assert_eq!(loaded_nsec, nsec);
// Verify the loaded nsec produces the same identity
let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
assert_eq!(loaded_identity.npub(), identity.npub());
}
#[test]
fn test_key_file_permissions() {
use std::os::unix::fs::MetadataExt;
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
write_key_file(&key_path, "nsec1test").unwrap();
let metadata = fs::metadata(&key_path).unwrap();
assert_eq!(metadata.mode() & 0o777, 0o600);
}
#[test]
fn test_pub_file_permissions() {
use std::os::unix::fs::MetadataExt;
let temp_dir = TempDir::new().unwrap();
let pub_path = temp_dir.path().join("fips.pub");
write_pub_file(&pub_path, "npub1test").unwrap();
let metadata = fs::metadata(&pub_path).unwrap();
assert_eq!(metadata.mode() & 0o777, 0o644);
}
#[test]
fn test_key_file_empty_error() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
fs::write(&key_path, "").unwrap();
let result = read_key_file(&key_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_key_file_whitespace_trimmed() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
fs::write(&key_path, " nsec1test \n").unwrap();
let nsec = read_key_file(&key_path).unwrap();
assert_eq!(nsec, "nsec1test");
}
#[test]
fn test_key_file_path_derivation() {
let config_path = PathBuf::from("/etc/fips/fips.yaml");
assert_eq!(key_file_path(&config_path), PathBuf::from("/etc/fips/fips.key"));
assert_eq!(pub_file_path(&config_path), PathBuf::from("/etc/fips/fips.pub"));
}
#[test]
fn test_resolve_identity_from_config() {
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
let resolved = resolve_identity(&config, &[]).unwrap();
assert!(matches!(resolved.source, IdentitySource::Config));
}
#[test]
fn test_resolve_identity_ephemeral_by_default() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(!config.node.identity.persistent);
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
// Key files should still be written for operator visibility
let key_path = temp_dir.path().join("fips.key");
let pub_path = temp_dir.path().join("fips.pub");
assert!(key_path.exists());
assert!(pub_path.exists());
}
#[test]
fn test_resolve_identity_ephemeral_changes_each_call() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
// Each call generates a different key
assert_ne!(first.nsec, second.nsec);
}
#[test]
fn test_resolve_identity_persistent_from_key_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
let key_path = temp_dir.path().join("fips.key");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
// Write a key file
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(config.node.identity.persistent);
let resolved = resolve_identity(&config, &[config_path]).unwrap();
assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
assert_eq!(resolved.nsec, nsec);
}
#[test]
fn test_resolve_identity_persistent_generates_and_persists() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
let config = Config::load_file(&config_path).unwrap();
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved.source, IdentitySource::Generated(_)));
// Key file and pub file should now exist
let key_path = temp_dir.path().join("fips.key");
let pub_path = temp_dir.path().join("fips.pub");
assert!(key_path.exists());
assert!(pub_path.exists());
// Second resolve should load from key file (not generate new)
let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
assert_eq!(resolved.nsec, resolved2.nsec);
}
#[test]
fn test_to_yaml_empty_nsec_omitted() {
let config = Config::new();