diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a76088..c4fe9e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Packaging & deployment +- `fipsctl address [npub|hostname]` prints a node's `fd00::/8` mesh address and + nothing else, without contacting the daemon. With no argument it derives the + local node's address from `fips.key` in the default key directory, falling + back to the world-readable `fips.pub` beside it; `--key PATH` names a key or + public key file elsewhere. This lets an installer or image build write a mesh + address into a config file at a point where no node is running and none can + be, and keeps the derivation in one place rather than reimplemented by + whatever needs it. + - `packaging/debian/build-deb.sh --features ` builds the `.deb` with a Cargo feature list, which is how an instrumented package is produced for a measurement run. The auto-derived dev Version gains a matching `+` diff --git a/docs/reference/cli-fipsctl.md b/docs/reference/cli-fipsctl.md index ffa26936..e90c0b9f 100644 --- a/docs/reference/cli-fipsctl.md +++ b/docs/reference/cli-fipsctl.md @@ -16,8 +16,8 @@ one JSON request, and pretty-prints the response. Exits with a non-zero status if the socket cannot be reached, the daemon returns an error, or the request times out. -`fipsctl keygen` is a special case: it does not contact the daemon and -operates purely on local files. +`fipsctl keygen` and `fipsctl address` are special cases: they do not +contact the daemon and operate purely on local files. For the line-delimited JSON wire protocol, see [control-socket.md](control-socket.md). For the YAML configuration @@ -98,6 +98,30 @@ daemon. on Unix. After running `keygen`, set `node.identity.persistent: true` in `fips.yaml` or the daemon will overwrite the keys on next start. +### `address [identity] [options]` + +Print a node's mesh address (`fd00::/8`) and nothing else, so it can be +captured in a shell substitution. Does not contact the daemon, which +makes it usable from an installer or image build where no node is +running. + +| Argument | Description | +| -------- | ----------- | +| `identity` | npub (bech32) or hostname from `/etc/fips/hosts`. Omit to use this node's own identity. | + +| Flag | Argument | Default | Description | +| ---- | -------- | ------- | ----------- | +| `-k`, `--key` | `PATH` | *(none)* | Derive from this key file (an `nsec`) or public key file (an `npub`). Conflicts with `identity`. | + +With neither an `identity` nor `--key`, the address comes from +`fips.key` in the default key directory (the same directory `keygen` +writes to), falling back to `fips.pub` beside it, since `fips.key` is +mode `0600` and an unprivileged run cannot read it. + +The address is derived from the public key exactly as the daemon +derives its own: `fd` followed by the first 15 bytes of +SHA-256(pubkey). + ### `connect
` Tell the daemon to dial a peer over a specific transport. diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 8fb7fe3e..3159c910 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -7,10 +7,10 @@ //! On Windows, uses a TCP connection to localhost. use clap::{Parser, Subcommand}; -use fips::config::{write_key_file, write_pub_file}; +use fips::config::{read_key_file, write_key_file, write_pub_file}; use fips::upper::hosts::HostMap; use fips::version; -use fips::{Identity, encode_nsec}; +use fips::{ConfigError, Identity, PeerIdentity, encode_nsec}; use std::io::{BufRead, BufReader, IsTerminal, Write}; use std::net::{Ipv6Addr, SocketAddrV6}; use std::path::{Path, PathBuf}; @@ -58,6 +58,15 @@ enum Commands { #[arg(short = 's', long = "stdout")] stdout: bool, }, + /// Print a node's mesh address, without contacting the daemon + Address { + /// npub (bech32) or hostname from /etc/fips/hosts. Defaults to this + /// node's own identity, read from its key files. + identity: Option, + /// Derive from this key file (an nsec) or public key file (an npub) + #[arg(short = 'k', long = "key", conflicts_with = "identity")] + key: Option, + }, /// Connect to a peer Connect { /// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts @@ -423,6 +432,63 @@ fn resolve_peer(peer: &str) -> String { } } +/// Derive the mesh address for whichever identity the arguments name. +/// +/// Precedence is the order the arguments are documented in: an explicit npub +/// or hostname, then an explicit key file, then this node's own key files in +/// the default key directory. Nothing here touches the control socket, so the +/// address is available to a maintainer script with no daemon running. +fn mesh_address(identity: Option<&str>, key: Option<&Path>) -> Result { + match (identity, key) { + (Some(peer), _) => address_from_npub(&resolve_peer(peer)), + (None, Some(path)) => address_from_file(path), + (None, None) => address_from_key_dir(&default_key_dir()), + } +} + +/// Derive a mesh address from a bech32 npub. +fn address_from_npub(npub: &str) -> Result { + let peer = PeerIdentity::from_npub(npub).map_err(|e| format!("invalid npub: {e}"))?; + Ok(peer.address().to_ipv6()) +} + +/// Derive a mesh address from a key file holding an nsec, or from a public +/// key file holding an npub. +/// +/// The file contents are the private key in the first case, so they are held +/// in a guard and cleared on every exit path. +fn address_from_file(path: &Path) -> Result { + let contents = Zeroizing::new(read_key_file(path).map_err(|e| match e { + // ReadFile's own text names the file a config file, which this is not. + ConfigError::ReadFile { source, .. } => { + format!("cannot read {}: {source}", path.display()) + } + other => other.to_string(), + })?); + + if contents.starts_with("npub1") { + return address_from_npub(contents.as_str()); + } + + let identity = Identity::from_secret_str(contents.as_str()) + .map_err(|e| format!("{} does not hold a usable key: {e}", path.display()))?; + Ok(identity.address().to_ipv6()) +} + +/// Derive this node's mesh address from the key files in `dir`. +/// +/// Tries `fips.key` first and falls back to `fips.pub`: the private key is +/// mode 0600, so an unprivileged run can still answer from the world-readable +/// public key beside it. When neither is readable both attempts are reported, +/// since either file would have answered. +fn address_from_key_dir(dir: &Path) -> Result { + match address_from_file(&dir.join("fips.key")) { + Ok(addr) => Ok(addr), + Err(key_err) => address_from_file(&dir.join("fips.pub")) + .map_err(|pub_err| format!("{key_err}\n{pub_err}")), + } +} + fn main() { let cli = Cli::parse(); @@ -492,6 +558,17 @@ fn main() { return; } + if let Commands::Address { identity, key } = &cli.command { + match mesh_address(identity.as_deref(), key.as_deref()) { + Ok(address) => println!("{address}"), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + return; + } + let socket_path = cli.socket.unwrap_or_else(default_socket_path); let request = match &cli.command { @@ -566,7 +643,7 @@ fn main() { ProfileTickAction::Status => build_query("profile_tick_status"), }, }, - Commands::Keygen { .. } => unreachable!(), + Commands::Keygen { .. } | Commands::Address { .. } => unreachable!(), }; // For plot output we need to post-process the JSON response rather @@ -1513,6 +1590,85 @@ mod tests { assert_eq!(default_key_dir(), PathBuf::from("/etc/fips")); } + /// Build a key file for `identity` in `dir` and return its path. + fn write_identity_key(dir: &Path, identity: &Identity) -> PathBuf { + let mut keypair = identity.keypair(); + let mut secret_key = keypair.secret_key(); + let nsec = Zeroizing::new(encode_nsec(&secret_key)); + secret_key.non_secure_erase(); + keypair.non_secure_erase(); + let path = dir.join("fips.key"); + write_key_file(&path, &nsec).unwrap(); + path + } + + #[test] + fn the_address_derived_from_an_npub_is_the_one_its_owner_uses() { + let identity = Identity::generate(); + let derived = address_from_npub(&identity.npub()).unwrap(); + assert_eq!(derived, identity.address().to_ipv6()); + assert_eq!(derived.octets()[0], 0xfd); + } + + #[test] + fn a_malformed_npub_is_refused_rather_than_hashed() { + assert!(address_from_npub("npub1notarealkey").is_err()); + assert!(address_from_npub("").is_err()); + } + + #[test] + fn the_address_derived_from_a_key_file_matches_its_npub() { + let dir = tempfile::tempdir().unwrap(); + let identity = Identity::generate(); + let key_path = write_identity_key(dir.path(), &identity); + + let expected = identity.address().to_ipv6(); + assert_eq!(address_from_file(&key_path).unwrap(), expected); + assert_eq!(address_from_key_dir(dir.path()).unwrap(), expected); + assert_eq!( + mesh_address(None, Some(key_path.as_path())).unwrap(), + expected + ); + } + + #[test] + fn the_public_key_file_answers_when_the_private_one_is_absent() { + let dir = tempfile::tempdir().unwrap(); + let identity = Identity::generate(); + write_pub_file(&dir.path().join("fips.pub"), &identity.npub()).unwrap(); + + assert_eq!( + address_from_key_dir(dir.path()).unwrap(), + identity.address().to_ipv6() + ); + } + + #[test] + fn a_missing_key_file_reports_every_path_that_was_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = address_from_key_dir(dir.path()).unwrap_err(); + assert!(err.contains("fips.key"), "{err}"); + assert!(err.contains("fips.pub"), "{err}"); + assert!(address_from_file(&dir.path().join("fips.key")).is_err()); + } + + #[test] + fn an_empty_or_unparsable_key_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + + let empty = dir.path().join("empty.key"); + std::fs::write(&empty, "\n").unwrap(); + assert!(address_from_file(&empty).unwrap_err().contains("empty")); + + let junk = dir.path().join("junk.key"); + std::fs::write(&junk, "not-a-key\n").unwrap(); + assert!( + address_from_file(&junk) + .unwrap_err() + .contains("does not hold a usable key") + ); + } + #[test] fn test_acl_show_command_name() { assert_eq!(AclCommands::Show.command_name(), "show_acl");