diff --git a/core/Cargo.lock b/core/Cargo.lock index 9c4d0d61..f2323973 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.3.5" +version = "1.4.0" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 4295a1b5..bc38b694 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.3.5" +version = "1.4.0" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index adfbe180..4e996c62 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -404,6 +404,13 @@ impl RpcHandler { } "monitoring.export" => self.handle_monitoring_export(params).await, + // FIPS mesh transport + "fips.status" => self.handle_fips_status().await, + "fips.check-update" => self.handle_fips_check_update().await, + "fips.apply-update" => self.handle_fips_apply_update().await, + "fips.install" => self.handle_fips_install().await, + "fips.restart" => self.handle_fips_restart().await, + // System updates "update.check" => self.handle_update_check().await, "update.status" => self.handle_update_status().await, diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index 666e5e57..9e728498 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -44,9 +44,19 @@ impl RpcHandler { anyhow::bail!("Tor address not available. Tor may not be running."); } - let code = federation::create_invite(&self.config.data_dir, &did, &onion, &pubkey).await?; + let identity_dir = self.config.data_dir.join("identity"); + let fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None); - info!(did = %did, "Generated federation invite"); + let code = federation::create_invite( + &self.config.data_dir, + &did, + &onion, + &pubkey, + fips_npub.as_deref(), + ) + .await?; + + info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite"); Ok(serde_json::json!({ "code": code, "did": did, @@ -72,12 +82,14 @@ impl RpcHandler { let identity_dir = self.config.data_dir.join("identity"); let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; + let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None); let node = federation::accept_invite( &self.config.data_dir, code, &local_did, &local_onion, &local_pubkey, + local_fips_npub.as_deref(), |data| node_identity.sign(data), ) .await?; @@ -402,6 +414,12 @@ impl RpcHandler { .get("pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?; + // Optional, unsigned: peer's FIPS mesh npub. Carried for transport + // selection only; FIPS handshake re-authenticates the session. + let fips_npub = params + .get("fips_npub") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); // Verify ed25519 signature to prevent federation spoofing (H2 security fix) let signature = params.get("signature").and_then(|v| v.as_str()); @@ -426,18 +444,24 @@ impl RpcHandler { let nodes = federation::load_nodes(&self.config.data_dir).await?; if let Some(existing) = nodes.iter().find(|n| n.did == did) { - // If already known but missing onion/pubkey, update them - if existing.onion.is_empty() || existing.pubkey.is_empty() { + // If already known but missing onion/pubkey/fips_npub, update them + let needs_onion = existing.onion.is_empty(); + let needs_pubkey = existing.pubkey.is_empty(); + let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some(); + if needs_onion || needs_pubkey || needs_fips { let mut updated = existing.clone(); - if existing.onion.is_empty() && !onion.is_empty() { + if needs_onion && !onion.is_empty() { updated.onion = onion.to_string(); } - if existing.pubkey.is_empty() && !pubkey.is_empty() { + if needs_pubkey && !pubkey.is_empty() { updated.pubkey = pubkey.to_string(); } + if needs_fips { + updated.fips_npub = fips_npub.clone(); + } updated.last_seen = Some(chrono::Utc::now().to_rfc3339()); federation::update_node(&self.config.data_dir, &updated).await?; - info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with missing onion/pubkey"); + info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields"); } return Ok(serde_json::json!({ "accepted": true, "already_known": true })); } @@ -451,6 +475,7 @@ impl RpcHandler { added_at: chrono::Utc::now().to_rfc3339(), last_seen: None, last_state: None, + fips_npub, }; federation::add_node(&self.config.data_dir, node).await?; @@ -866,11 +891,14 @@ impl RpcHandler { // Generate a one-shot federation invite. The code embeds OUR onion // and OUR pubkey, but it leaves this box only inside the NIP-44 // ciphertext below. + let identity_dir = self.config.data_dir.join("identity"); + let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None); let invite_code = federation::create_invite( &self.config.data_dir, &local_did, &local_onion, &local_pubkey, + local_fips_npub.as_deref(), ) .await?; diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs new file mode 100644 index 00000000..3013f68a --- /dev/null +++ b/core/archipelago/src/api/rpc/fips.rs @@ -0,0 +1,47 @@ +//! RPC handlers for the FIPS mesh transport subsystem. +//! +//! Surface is deliberately thin: a read-only `fips.status`, a user-gated +//! `fips.check-update`, a stubbed `fips.apply-update`, and a +//! `fips.install` that (re-)materialises the daemon config + key and +//! activates the service. All writes go through `sudo` helpers in +//! `crate::fips`. + +use super::RpcHandler; +use crate::fips; +use anyhow::Result; + +impl RpcHandler { + pub(super) async fn handle_fips_status(&self) -> Result { + let identity_dir = fips::identity_dir_from(&self.config.data_dir); + let status = fips::FipsStatus::query(&identity_dir).await; + Ok(serde_json::to_value(status)?) + } + + pub(super) async fn handle_fips_check_update(&self) -> Result { + let check = fips::update::check().await?; + Ok(serde_json::to_value(check)?) + } + + pub(super) async fn handle_fips_apply_update(&self) -> Result { + fips::update::apply().await?; + Ok(serde_json::json!({ "applied": true })) + } + + /// Install config + key into /etc/fips and activate the service. + /// Intended to be called: + /// - once by the seed-onboarding flow, right after the FIPS key + /// is written to /data/identity/fips_key, and + /// - on user demand from the dashboard if something drifted. + pub(super) async fn handle_fips_install(&self) -> Result { + let identity_dir = fips::identity_dir_from(&self.config.data_dir); + fips::config::install(&identity_dir).await?; + fips::service::activate(fips::SERVICE_UNIT).await?; + let status = fips::FipsStatus::query(&identity_dir).await; + Ok(serde_json::to_value(status)?) + } + + pub(super) async fn handle_fips_restart(&self) -> Result { + fips::service::restart(fips::SERVICE_UNIT).await?; + Ok(serde_json::json!({ "restarted": true })) + } +} diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 92ad2104..8cd0a432 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -278,12 +278,16 @@ impl RpcHandler { let identity_dir2 = self.config.data_dir.join("identity"); let node_identity = crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?; + let local_fips_npub = crate::identity::fips_npub(&identity_dir2) + .await + .unwrap_or(None); match crate::federation::accept_invite( &self.config.data_dir, invite_code, &local_did, &local_onion, &local_pubkey, + local_fips_npub.as_deref(), |bytes| node_identity.sign(bytes), ) .await diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index d03a782e..175f595f 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -8,6 +8,7 @@ mod credentials; mod dispatcher; mod dwn; mod federation; +mod fips; mod handshake; mod identity; mod interfaces; diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index 0bb0f133..57148a66 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -6,12 +6,28 @@ use std::path::Path; use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes}; use super::types::{FederatedNode, FederationInvite, TrustLevel}; -/// Generate an invite code. Format: `fed1:` +/// Parsed contents of a federation invite code. +#[derive(Debug, Clone)] +pub struct ParsedInvite { + pub did: String, + pub onion: String, + pub pubkey: String, + /// Per-invite randomness; retained by parsers but not consumed + /// end-to-end — the outer signature binds the relationship. + #[allow(dead_code)] + pub token: String, + /// Inviter's FIPS npub if advertised in the code. + pub fips_npub: Option, +} + +/// Generate an invite code. Format: `fed1:`. +/// `fips_npub` is only included when the local node has a materialised FIPS key. pub async fn create_invite( data_dir: &Path, did: &str, onion: &str, pubkey: &str, + fips_npub: Option<&str>, ) -> Result { use base64::Engine; use rand::Rng; @@ -20,12 +36,15 @@ pub async fn create_invite( rand::thread_rng().fill(&mut token_bytes); let token = hex::encode(token_bytes); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "did": did, "onion": onion, "pubkey": pubkey, "token": token, }); + if let Some(npub) = fips_npub { + payload["fips_npub"] = serde_json::Value::String(npub.to_string()); + } let json = serde_json::to_string(&payload).context("Failed to serialize invite")?; let code = format!( "fed1:{}", @@ -39,6 +58,7 @@ pub async fn create_invite( pubkey: pubkey.to_string(), created_at: chrono::Utc::now().to_rfc3339(), accepted: false, + fips_npub: fips_npub.map(|s| s.to_string()), }; let mut invites = load_invites(data_dir).await?; @@ -49,7 +69,7 @@ pub async fn create_invite( } /// Parse an invite code into its components. -pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> { +pub fn parse_invite(code: &str) -> Result { use base64::Engine; let encoded = code @@ -79,8 +99,18 @@ pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> { .as_str() .ok_or_else(|| anyhow::anyhow!("Missing token in invite"))? .to_string(); + let fips_npub = payload + .get("fips_npub") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); - Ok((did, onion, pubkey, token)) + Ok(ParsedInvite { + did, + onion, + pubkey, + token, + fips_npub, + }) } /// Accept an invite: parse code, verify the remote node, add to federation. @@ -90,9 +120,16 @@ pub async fn accept_invite( local_did: &str, local_onion: &str, local_pubkey: &str, + local_fips_npub: Option<&str>, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result { - let (did, onion, pubkey, _token) = parse_invite(code)?; + let ParsedInvite { + did, + onion, + pubkey, + token: _, + fips_npub, + } = parse_invite(code)?; // Make accept idempotent: drop any existing entry that conflicts with // this invite — same DID (same node, refreshing the link), same onion @@ -125,6 +162,7 @@ pub async fn accept_invite( added_at: chrono::Utc::now().to_rfc3339(), last_seen: None, last_state: None, + fips_npub: fips_npub.clone(), }; add_node(data_dir, node.clone()).await?; @@ -138,11 +176,20 @@ pub async fn accept_invite( pubkey: node.pubkey.clone(), created_at: chrono::Utc::now().to_rfc3339(), accepted: true, + fips_npub, }); save_invites(data_dir, &invites).await?; // Notify remote node (best-effort over Tor) - let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await; + let _ = notify_join( + &node.onion, + local_did, + local_onion, + local_pubkey, + local_fips_npub, + sign_fn, + ) + .await; Ok(node) } @@ -154,6 +201,7 @@ async fn notify_join( local_did: &str, local_onion: &str, local_pubkey: &str, + local_fips_npub: Option<&str>, sign_fn: impl FnOnce(&[u8]) -> String, ) -> Result<()> { let host = if remote_onion.ends_with(".onion") { @@ -164,17 +212,26 @@ async fn notify_join( let url = format!("http://{}/rpc/v1", host); // Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}" + // Signature domain intentionally unchanged — fips_npub is carried + // as an unsigned informational field. The FIPS daemon's own Noise + // handshake authenticates the actual transport session, so a + // stripped/substituted npub here merely downgrades the path to Tor. let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey); let signature = sign_fn(sign_data.as_bytes()); + let mut params = serde_json::json!({ + "did": local_did, + "onion": local_onion, + "pubkey": local_pubkey, + "signature": signature, + }); + if let Some(npub) = local_fips_npub { + params["fips_npub"] = serde_json::Value::String(npub.to_string()); + } + let body = serde_json::json!({ "method": "federation.peer-joined", - "params": { - "did": local_did, - "onion": local_onion, - "pubkey": local_pubkey, - "signature": signature, - } + "params": params, }); let proxy = @@ -197,16 +254,48 @@ mod tests { #[tokio::test] async fn test_create_and_parse_invite() { let dir = tempfile::tempdir().unwrap(); - let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc") + let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", None) .await .unwrap(); assert!(code.starts_with("fed1:")); - let (did, onion, pubkey, token) = parse_invite(&code).unwrap(); - assert_eq!(did, "did:key:z1"); - assert_eq!(onion, "test.onion"); - assert_eq!(pubkey, "aabbcc"); - assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars + let parsed = parse_invite(&code).unwrap(); + assert_eq!(parsed.did, "did:key:z1"); + assert_eq!(parsed.onion, "test.onion"); + assert_eq!(parsed.pubkey, "aabbcc"); + assert_eq!(parsed.token.len(), 32); // 16 bytes = 32 hex chars + assert!(parsed.fips_npub.is_none()); + } + + #[tokio::test] + async fn test_invite_roundtrips_fips_npub() { + let dir = tempfile::tempdir().unwrap(); + let fips = "npub1fipstest0000000000000000000000000000000000"; + let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", Some(fips)) + .await + .unwrap(); + let parsed = parse_invite(&code).unwrap(); + assert_eq!(parsed.fips_npub.as_deref(), Some(fips)); + } + + #[tokio::test] + async fn test_parse_invite_tolerates_missing_fips() { + // Older invites minted before fips_npub existed must still parse. + use base64::Engine; + let legacy = serde_json::json!({ + "did": "did:key:zOld", + "onion": "old.onion", + "pubkey": "00", + "token": "aa", + }); + let code = format!( + "fed1:{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_string(&legacy).unwrap()) + ); + let parsed = parse_invite(&code).unwrap(); + assert_eq!(parsed.did, "did:key:zOld"); + assert!(parsed.fips_npub.is_none()); } #[test] @@ -218,9 +307,15 @@ mod tests { #[tokio::test] async fn test_accept_invite_creates_node() { let dir = tempfile::tempdir().unwrap(); - let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub") - .await - .unwrap(); + let code = create_invite( + dir.path(), + "did:key:zRemote", + "remote.onion", + "remotepub", + None, + ) + .await + .unwrap(); // Accept from a different "local" perspective let dir2 = tempfile::tempdir().unwrap(); @@ -230,6 +325,7 @@ mod tests { "did:key:zLocal", "local.onion", "localpub", + None, |_| "test-sig".to_string(), ) .await @@ -242,6 +338,36 @@ mod tests { assert_eq!(nodes.len(), 1); } + #[tokio::test] + async fn test_accept_invite_persists_fips_npub() { + let dir = tempfile::tempdir().unwrap(); + let fips = "npub1remotefipsaddrxxxxxxxxxxxxxxxxxxxxxxxxxx"; + let code = create_invite( + dir.path(), + "did:key:zRemote", + "remote.onion", + "remotepub", + Some(fips), + ) + .await + .unwrap(); + + let dir2 = tempfile::tempdir().unwrap(); + let node = accept_invite( + dir2.path(), + &code, + "did:key:zLocal", + "local.onion", + "localpub", + None, + |_| "test-sig".to_string(), + ) + .await + .unwrap(); + + assert_eq!(node.fips_npub.as_deref(), Some(fips)); + } + #[tokio::test] async fn test_accept_invite_is_idempotent() { // Re-accepting the same invite is a no-op refresh — it must not @@ -249,9 +375,15 @@ mod tests { // UI relies on: clicking "Join" twice or refreshing after an // identity rotation always converges to one entry. let dir = tempfile::tempdir().unwrap(); - let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub") - .await - .unwrap(); + let code = create_invite( + dir.path(), + "did:key:zRemote", + "remote.onion", + "remotepub", + None, + ) + .await + .unwrap(); let dir2 = tempfile::tempdir().unwrap(); accept_invite( @@ -260,6 +392,7 @@ mod tests { "did:key:zLocal", "local.onion", "localpub", + None, |_| "test-sig".to_string(), ) .await @@ -271,6 +404,7 @@ mod tests { "did:key:zLocal", "local.onion", "localpub", + None, |_| "test-sig".to_string(), ) .await diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index 2e632d93..fe627357 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -172,6 +172,7 @@ mod tests { added_at: "2026-01-01T00:00:00Z".to_string(), last_seen: None, last_state: None, + fips_npub: None, } } diff --git a/core/archipelago/src/federation/types.rs b/core/archipelago/src/federation/types.rs index f772844b..788bd246 100644 --- a/core/archipelago/src/federation/types.rs +++ b/core/archipelago/src/federation/types.rs @@ -35,6 +35,10 @@ pub struct FederatedNode { pub last_seen: Option, #[serde(default)] pub last_state: Option, + /// FIPS mesh npub (bech32) for this peer, when they advertise one. + /// Lets the transport router prefer FIPS over Tor for peer traffic. + #[serde(default)] + pub fips_npub: Option, } /// State snapshot received from a federated peer during sync. @@ -85,6 +89,9 @@ pub struct FederationInvite { pub created_at: String, #[serde(default)] pub accepted: bool, + /// Inviter's FIPS mesh npub if advertised in the code. + #[serde(default)] + pub fips_npub: Option, } #[cfg(test)] @@ -111,12 +118,28 @@ mod tests { added_at: "2026-01-01T00:00:00Z".to_string(), last_seen: None, last_state: None, + fips_npub: None, }; let json = serde_json::to_string(&node).unwrap(); let parsed: FederatedNode = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.did, "did:key:zABC"); assert_eq!(parsed.trust_level, TrustLevel::Trusted); assert!(parsed.last_state.is_none()); + assert!(parsed.fips_npub.is_none()); + } + + #[test] + fn test_federated_node_deserializes_without_fips_field() { + // Backward compat: nodes on older versions omit fips_npub entirely. + let json = r#"{ + "did": "did:key:zOld", + "pubkey": "0011", + "onion": "old.onion", + "trust_level": "trusted", + "added_at": "2026-01-01T00:00:00Z" + }"#; + let parsed: FederatedNode = serde_json::from_str(json).unwrap(); + assert!(parsed.fips_npub.is_none()); } #[test] diff --git a/core/archipelago/src/fips/config.rs b/core/archipelago/src/fips/config.rs new file mode 100644 index 00000000..62007903 --- /dev/null +++ b/core/archipelago/src/fips/config.rs @@ -0,0 +1,144 @@ +//! 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_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 + Tor, +/// IPv6 TUN + DNS on defaults. Static peer list is empty — archipelago +/// feeds peers dynamically via federation updates. +pub fn render_config_yaml() -> String { + format!( + "# Generated by archipelago — do not edit by hand.\n\ + # Regenerated on every key change and daemon upgrade.\n\ + identity:\n \ + key_file: {key_path}\n \ + pub_file: {pub_path}\n\ + transports:\n \ + udp:\n \ + enabled: true\n \ + port: {port}\n \ + tor:\n \ + enabled: true\n\ + tun:\n \ + enabled: true\n\ + dns:\n \ + enabled: true\n \ + suffix: .fips\n\ + peers: []\n", + key_path = DAEMON_KEY_PATH, + pub_path = DAEMON_PUB_PATH, + port = DEFAULT_UDP_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?; + sudo_install_file(&src_pub, DAEMON_PUB_PATH, "0644").await?; + + 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_contains_paths_and_port() { + let yaml = render_config_yaml(); + assert!(yaml.contains(DAEMON_KEY_PATH)); + assert!(yaml.contains(DAEMON_PUB_PATH)); + assert!(yaml.contains(&DEFAULT_UDP_PORT.to_string())); + assert!(yaml.contains("udp:")); + assert!(yaml.contains("tor:")); + assert!(yaml.contains("tun:")); + } + + #[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")); + } +} diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs new file mode 100644 index 00000000..53d61f64 --- /dev/null +++ b/core/archipelago/src/fips/mod.rs @@ -0,0 +1,140 @@ +//! FIPS (Free Internetworking Peering System) daemon integration. +//! +//! github.com/jmcorgan/fips — a spanning-tree mesh routing protocol that +//! uses Nostr secp256k1 keys as native node identity. Archipelago ships +//! the daemon as an apt package, feeds it the seed-derived key from +//! `/data/identity/fips_key`, and supervises it via +//! `archipelago-fips.service`. +//! +//! This module is the in-process bridge: +//! - [`service`]: systemctl status / start / stop / restart / unmask. +//! - [`config`]: materialise `/etc/fips/fips.yaml` + install the key. +//! - [`update`]: query GitHub (tracking `main`) for a newer build, +//! verify SHA256, install via dpkg, restart. +//! +//! Privileged operations shell out via `sudo systemctl …` and `sudo dpkg …` +//! (mirroring the vpn/update patterns already in the codebase); the +//! sudoers rule shipped in the ISO whitelists exactly those commands for +//! the `archipelago` service user. +//! +//! FIPS is dark on the wire until onboarding writes the key. Before that, +//! `FipsStatus::installed` reports the package state and `service_active` +//! returns false; the transport router keeps routing via Tor. + +// Consumers land in the next phase (RPC endpoints + onboarding hookup); +// the module is deliberately API-ready ahead of those call-sites. +#![allow(dead_code)] + +pub mod config; +pub mod service; +pub mod update; + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Systemd unit name supervised by archipelago. +pub const SERVICE_UNIT: &str = "archipelago-fips.service"; + +/// Path the FIPS daemon reads its config from (Debian package default). +pub const DAEMON_CONFIG_PATH: &str = "/etc/fips/fips.yaml"; + +/// Path the FIPS daemon reads its private key from. +pub const DAEMON_KEY_PATH: &str = "/etc/fips/fips.key"; + +/// Path the FIPS daemon reads its public key from. +pub const DAEMON_PUB_PATH: &str = "/etc/fips/fips.pub"; + +/// Upstream repository the updater tracks (branch `main`). +pub const UPSTREAM_REPO: &str = "jmcorgan/fips"; + +/// Default UDP port the daemon listens on. +pub const DEFAULT_UDP_PORT: u16 = 8668; + +/// Aggregated runtime status of the FIPS subsystem, surfaced to the dashboard. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FipsStatus { + /// Whether the `fips` debian package is installed on the host. + pub installed: bool, + /// Installed daemon version string reported by `fipsctl --version`, + /// or None if not installed / not queryable. + pub version: Option, + /// `systemctl is-active archipelago-fips.service` result: "active", + /// "inactive", "failed", "masked", "unknown". + pub service_state: String, + /// True iff service_state == "active". + pub service_active: bool, + /// Whether the seed-derived FIPS key has been materialised on disk. + /// The service cannot start meaningfully until this is true. + pub key_present: bool, + /// Local FIPS npub (bech32), present only once the key is on disk. + pub npub: Option, +} + +impl FipsStatus { + /// Snapshot the current state across package, key, and service. + pub async fn query(identity_dir: &Path) -> Self { + let installed = service::package_installed().await; + let version = if installed { + service::daemon_version().await.ok() + } else { + None + }; + let service_state = service::unit_state(SERVICE_UNIT).await; + let service_active = service_state == "active"; + let key_present = crate::identity::fips_key_exists(identity_dir); + let npub = crate::identity::fips_npub(identity_dir) + .await + .unwrap_or(None); + + Self { + installed, + version, + service_state, + service_active, + key_present, + npub, + } + } +} + +/// Compose a data-dir–relative identity directory path. +/// Mirrors the convention used elsewhere in the codebase so callers don't +/// have to repeat the `.join("identity")` each time. +pub fn identity_dir_from(data_dir: &Path) -> PathBuf { + data_dir.join("identity") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_status_reports_no_key_pre_onboarding() { + let dir = tempfile::tempdir().unwrap(); + let id_dir = dir.path().join("identity"); + tokio::fs::create_dir_all(&id_dir).await.unwrap(); + + let status = FipsStatus::query(&id_dir).await; + assert!(!status.key_present, "no key before onboarding"); + assert!(status.npub.is_none()); + // `installed`, `service_state`, `version` depend on the host and are + // not asserted here — query() must return cleanly regardless. + } + + #[test] + fn test_identity_dir_from() { + let data = Path::new("/var/lib/archipelago"); + assert_eq!( + identity_dir_from(data), + Path::new("/var/lib/archipelago/identity") + ); + } + + #[test] + fn test_constants_have_expected_shape() { + assert!(SERVICE_UNIT.ends_with(".service")); + assert!(DAEMON_CONFIG_PATH.starts_with('/')); + assert!(DAEMON_KEY_PATH.starts_with('/')); + assert_eq!(UPSTREAM_REPO, "jmcorgan/fips"); + } +} diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs new file mode 100644 index 00000000..56603346 --- /dev/null +++ b/core/archipelago/src/fips/service.rs @@ -0,0 +1,120 @@ +//! systemctl + dpkg-query helpers for the FIPS daemon. +//! +//! Read-only queries (`is-active`, `--version`, `dpkg-query`) run as the +//! archipelago service user. Write operations (`unmask`, `start`, `stop`, +//! `restart`) go through `sudo`, matching the pattern established in +//! `src/vpn.rs` and `src/api/rpc/vpn.rs`. The sudoers rule shipped in the +//! ISO whitelists exactly these invocations. + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// `systemctl is-active ` → "active" / "inactive" / "failed" / "masked" +/// / "unknown". Never errors; returns "unknown" on any failure. +pub async fn unit_state(unit: &str) -> String { + match Command::new("systemctl") + .args(["is-active", unit]) + .output() + .await + { + Ok(out) => { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + "unknown".to_string() + } else { + s + } + } + Err(_) => "unknown".to_string(), + } +} + +/// Whether the `fips` debian package is installed on the host. +pub async fn package_installed() -> bool { + // dpkg-query -W -f='${Status}' fips → "install ok installed" when present. + let out = Command::new("dpkg-query") + .args(["-W", "-f=${Status}", "fips"]) + .output() + .await; + match out { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout).contains("install ok installed") + } + _ => false, + } +} + +/// `fipsctl --version` output stripped of the "fipsctl " prefix if present. +pub async fn daemon_version() -> Result { + let out = Command::new("fipsctl") + .arg("--version") + .output() + .await + .context("fipsctl --version failed to launch")?; + if !out.status.success() { + anyhow::bail!("fipsctl exited with non-zero status"); + } + let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(raw + .strip_prefix("fipsctl ") + .map(|s| s.to_string()) + .unwrap_or(raw)) +} + +/// `sudo systemctl ` — returns stderr on non-zero exit. +async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> { + let out = Command::new("sudo") + .args(["systemctl", verb, unit]) + .output() + .await + .with_context(|| format!("sudo systemctl {} {} failed to launch", verb, unit))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + anyhow::bail!("systemctl {} {}: {}", verb, unit, stderr); + } + Ok(()) +} + +/// Unmask + start + enable the FIPS service. Idempotent — safe to call +/// on every backend startup once the key is on disk. +pub async fn activate(unit: &str) -> Result<()> { + // Order matters: unmask before enable/start, otherwise enable fails + // on a masked unit. + sudo_systemctl("unmask", unit).await?; + sudo_systemctl("enable", unit).await?; + sudo_systemctl("start", unit).await?; + Ok(()) +} + +pub async fn stop(unit: &str) -> Result<()> { + sudo_systemctl("stop", unit).await +} + +pub async fn restart(unit: &str) -> Result<()> { + sudo_systemctl("restart", unit).await +} + +pub async fn mask(unit: &str) -> Result<()> { + let _ = sudo_systemctl("stop", unit).await; + let _ = sudo_systemctl("disable", unit).await; + sudo_systemctl("mask", unit).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_unit_state_returns_string_for_bogus_unit() { + // Nonexistent unit: systemctl returns "inactive" or "unknown" — we + // just care that the helper doesn't panic and returns *something*. + let s = unit_state("archipelago-bogus-test.service").await; + assert!(!s.is_empty()); + } + + #[tokio::test] + async fn test_package_installed_is_bool() { + // Must not panic regardless of host state. + let _ = package_installed().await; + } +} diff --git a/core/archipelago/src/fips/update.rs b/core/archipelago/src/fips/update.rs new file mode 100644 index 00000000..f0605c17 --- /dev/null +++ b/core/archipelago/src/fips/update.rs @@ -0,0 +1,130 @@ +//! User-triggered FIPS upgrade from upstream `main`. +//! +//! Flow (no auto-update, no background polling — user clicks a button): +//! 1. Query GitHub for the latest commit on `main` of jmcorgan/fips. +//! 2. Compare with the installed daemon version reported by +//! `fipsctl --version`. If identical, report "up to date". +//! 3. Fetch the built .deb artefact for that commit + its SHA256. +//! 4. SHA256-verify the download. +//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service. +//! +//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't +//! publish stable release assets for `main` builds. This module currently +//! implements steps 1–2 (the "is there anything newer?" query) and stubs +//! out 3–5 so the RPC/UI can wire through. The apply path returns a +//! clear "not yet available" error until the artefact source is decided. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::{service, UPSTREAM_REPO}; + +const GITHUB_API: &str = "https://api.github.com"; +const USER_AGENT: &str = "archipelago-fips-updater"; + +/// Result of `check_update()` — what the dashboard renders. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheck { + /// Currently installed daemon version (from `fipsctl --version`). + pub current: Option, + /// Short SHA of the latest commit on upstream `main`. + pub latest_commit: String, + /// True when the installed version string does not mention the latest SHA. + pub update_available: bool, + /// Human-readable note for the UI. + pub notes: String, +} + +/// Query GitHub for the latest commit on `main` and compare to the +/// installed version. Never errors on "no package installed" — that is +/// itself a valid state where an update is available (install needed). +pub async fn check() -> Result { + let current = service::daemon_version().await.ok(); + let latest = fetch_latest_main_sha().await?; + let short = latest.chars().take(7).collect::(); + + let update_available = match ¤t { + Some(v) => !v.contains(&short), + None => true, + }; + + let notes = if update_available { + format!( + "Upstream main is at {}; installed: {}", + short, + current.as_deref().unwrap_or("not installed") + ) + } else { + format!("Up to date ({})", short) + }; + + Ok(UpdateCheck { + current, + latest_commit: short, + update_available, + notes, + }) +} + +/// Apply the update. Stubbed pending a stable artefact source for +/// per-commit builds of the `fips` debian package. When this is wired +/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart. +pub async fn apply() -> Result<()> { + anyhow::bail!( + "FIPS auto-apply not yet wired — upstream does not publish stable \ + per-commit .deb artefacts for main. Upgrade manually for now: \ + `git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`." + ) +} + +async fn fetch_latest_main_sha() -> Result { + let url = format!("{}/repos/{}/commits/main", GITHUB_API, UPSTREAM_REPO); + let client = reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(std::time::Duration::from_secs(15)) + .build() + .context("Build HTTP client")?; + let resp = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .await + .context("GitHub commits API")?; + if !resp.status().is_success() { + anyhow::bail!("GitHub API returned {}", resp.status()); + } + let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?; + let sha = body + .get("sha") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))?; + Ok(sha.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_apply_returns_clear_stub_error() { + let err = apply().await.unwrap_err().to_string(); + assert!( + err.contains("not yet wired"), + "apply() should return an explicit not-yet-wired error, got: {}", + err + ); + } + + #[test] + fn test_update_check_serialises() { + let uc = UpdateCheck { + current: Some("0.2.0-abc1234".to_string()), + latest_commit: "def5678".to_string(), + update_available: true, + notes: "test".to_string(), + }; + let json = serde_json::to_string(&uc).unwrap(); + assert!(json.contains("latest_commit")); + assert!(json.contains("update_available")); + } +} diff --git a/core/archipelago/src/identity.rs b/core/archipelago/src/identity.rs index 5318844a..c7bfc8e7 100644 --- a/core/archipelago/src/identity.rs +++ b/core/archipelago/src/identity.rs @@ -10,6 +10,8 @@ use tokio::fs; const NODE_KEY_FILE: &str = "node_key"; const NODE_KEY_PUB_FILE: &str = "node_key.pub"; +const FIPS_KEY_FILE: &str = "fips_key"; +const FIPS_KEY_PUB_FILE: &str = "fips_key.pub"; /// Persistent node identity (Ed25519 keypair). /// Survives reboots; used for signing, verification, and node address. @@ -72,6 +74,8 @@ impl NodeIdentity { /// Create node identity from a BIP-39 master seed (deterministic derivation). /// Writes derived key to disk in the same format as load_or_create. + /// Also derives and persists the FIPS mesh transport key so the + /// FIPS system service can be unmasked after onboarding. pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result { fs::create_dir_all(identity_dir) .await @@ -101,6 +105,8 @@ impl NodeIdentity { &pubkey_hex[..16] ); + write_fips_key_from_seed(identity_dir, seed).await?; + Ok(Self { signing_key, _identity_dir: identity_dir.to_path_buf(), @@ -174,6 +180,80 @@ impl NodeIdentity { } } +// ─── FIPS mesh transport key ──────────────────────────────────────────── +// +// FIPS (Free Internetworking Peering System) uses a secp256k1 keypair as its +// native node identity — independent of the Nostr-node key so compromise of +// one surface cannot impersonate on the other. Both are seed-derived, so the +// FIPS npub is recoverable from the master mnemonic. +// +// Key material is written by `NodeIdentity::from_seed` only. Pre-onboarding +// the files do not exist and `archipelago-fips.service` stays masked. + +use nostr_sdk::ToBech32; + +async fn write_fips_key_from_seed( + identity_dir: &Path, + seed: &crate::seed::MasterSeed, +) -> Result<()> { + let keys = crate::seed::derive_fips_key(seed)?; + let key_path = identity_dir.join(FIPS_KEY_FILE); + let pub_path = identity_dir.join(FIPS_KEY_PUB_FILE); + + fs::write(&key_path, keys.secret_key().to_secret_bytes()) + .await + .context("Failed to write FIPS key")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)) + .await + .context("Failed to set FIPS key permissions")?; + } + fs::write(&pub_path, keys.public_key().to_bytes()) + .await + .context("Failed to write FIPS public key")?; + + let npub = keys.public_key().to_bech32().unwrap_or_default(); + tracing::info!( + "Derived FIPS mesh key from seed (npub: {}...)", + npub.chars().take(20).collect::() + ); + Ok(()) +} + +/// Check whether the FIPS keypair has been materialised on disk. +/// Returns true only after onboarding has written the seed-derived key. +#[allow(dead_code)] +pub fn fips_key_exists(identity_dir: &Path) -> bool { + identity_dir.join(FIPS_KEY_FILE).exists() +} + +/// Load the persisted FIPS keypair. Returns `Ok(None)` if onboarding has +/// not yet written the key (pre-onboarding node); errors only on I/O or +/// corruption of an existing file. +#[allow(dead_code)] +pub async fn load_fips_keys(identity_dir: &Path) -> Result> { + let key_path = identity_dir.join(FIPS_KEY_FILE); + match fs::read(&key_path).await { + Ok(bytes) => { + let secret = nostr_sdk::SecretKey::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("Corrupt FIPS key on disk: {}", e))?; + Ok(Some(nostr_sdk::Keys::new(secret))) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).context("Failed to read FIPS key"), + } +} + +/// Return the FIPS npub (bech32) if the key has been materialised. +#[allow(dead_code)] +pub async fn fips_npub(identity_dir: &Path) -> Result> { + Ok(load_fips_keys(identity_dir) + .await? + .and_then(|k| k.public_key().to_bech32().ok())) +} + /// Convert Ed25519 pubkey (hex) to did:key format. /// Used by RPC when identity is loaded from state. pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result { @@ -453,4 +533,57 @@ mod tests { assert!(pubkey_bytes_from_did_key("did:web:example.com").is_err()); assert!(pubkey_bytes_from_did_key("did:key:invalid").is_err()); } + + #[tokio::test] + async fn test_fips_key_absent_before_onboarding() { + let dir = tempfile::tempdir().unwrap(); + let id_dir = dir.path().join("identity"); + fs::create_dir_all(&id_dir).await.unwrap(); + + assert!(!fips_key_exists(&id_dir)); + assert!(load_fips_keys(&id_dir).await.unwrap().is_none()); + assert!(fips_npub(&id_dir).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_fips_key_written_from_seed_and_roundtrips() { + use crate::seed::MasterSeed; + const M: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + let dir = tempfile::tempdir().unwrap(); + let id_dir = dir.path().join("identity"); + let (_, seed) = MasterSeed::from_mnemonic_words(M).unwrap(); + + let _ = NodeIdentity::from_seed(&id_dir, &seed).await.unwrap(); + + assert!(fips_key_exists(&id_dir)); + let loaded = load_fips_keys(&id_dir).await.unwrap().unwrap(); + let expected = crate::seed::derive_fips_key(&seed).unwrap(); + assert_eq!( + loaded.public_key().to_hex(), + expected.public_key().to_hex(), + "loaded FIPS key must match seed-derived key" + ); + + let npub = fips_npub(&id_dir).await.unwrap().unwrap(); + assert!(npub.starts_with("npub1"), "got: {}", npub); + } + + #[tokio::test] + async fn test_fips_private_key_is_chmod_600() { + #[cfg(unix)] + { + use crate::seed::MasterSeed; + use std::os::unix::fs::PermissionsExt; + const M: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + let dir = tempfile::tempdir().unwrap(); + let id_dir = dir.path().join("identity"); + let (_, seed) = MasterSeed::from_mnemonic_words(M).unwrap(); + + NodeIdentity::from_seed(&id_dir, &seed).await.unwrap(); + + let meta = fs::metadata(id_dir.join(FIPS_KEY_FILE)).await.unwrap(); + let mode = meta.permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "FIPS private key must be 0600, got {:o}", mode); + } + } } diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 157ac414..d7468d20 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -37,6 +37,7 @@ mod data_model; mod disk_monitor; mod electrs_status; mod federation; +mod fips; mod health_monitor; mod identity; mod identity_manager; diff --git a/core/archipelago/src/seed.rs b/core/archipelago/src/seed.rs index 8f7f3dea..a0430dd3 100644 --- a/core/archipelago/src/seed.rs +++ b/core/archipelago/src/seed.rs @@ -7,6 +7,7 @@ //! → Master Seed (64 bytes) //! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key //! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key +//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key //! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519 //! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06) //! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet @@ -31,6 +32,7 @@ const ENCRYPTED_SEED_FILE: &str = "master_seed.enc"; // HKDF info strings for domain-separated key derivation. const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1"; const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1"; +const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1"; const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1"; // ─── MasterSeed ───────────────────────────────────────────────────────── @@ -103,6 +105,16 @@ pub fn derive_node_nostr_key(seed: &MasterSeed) -> Result { Ok(nostr_sdk::Keys::new(secret)) } +/// Derive the FIPS mesh transport secp256k1 key. +/// Distinct from the Nostr-node key so compromise of one surface does not +/// impersonate on the other; still seed-recoverable. +pub fn derive_fips_key(seed: &MasterSeed) -> Result { + let derived = hkdf_derive_32(seed.as_bytes(), FIPS_KEY_INFO)?; + let secret = nostr_sdk::SecretKey::from_slice(&derived) + .map_err(|e| anyhow::anyhow!("Invalid secp256k1 key from HKDF: {}", e))?; + Ok(nostr_sdk::Keys::new(secret)) +} + /// Derive an identity's Nostr secp256k1 key via BIP-32. /// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant). pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result { @@ -395,6 +407,25 @@ mod tests { assert_eq!(keys1.public_key().to_hex(), keys2.public_key().to_hex()); } + #[test] + fn test_fips_key_deterministic_and_distinct() { + let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); + let fips1 = derive_fips_key(&seed).unwrap(); + let fips2 = derive_fips_key(&seed).unwrap(); + assert_eq!( + fips1.public_key().to_hex(), + fips2.public_key().to_hex(), + "FIPS key must be deterministic for a given seed" + ); + + let nostr = derive_node_nostr_key(&seed).unwrap(); + assert_ne!( + fips1.public_key().to_hex(), + nostr.public_key().to_hex(), + "FIPS key must differ from the Nostr-node key" + ); + } + #[test] fn test_bitcoin_xprv_deterministic() { let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap(); @@ -482,6 +513,7 @@ mod tests { let node_ed = derive_node_ed25519(&seed).unwrap(); let node_nostr = derive_node_nostr_key(&seed).unwrap(); + let fips = derive_fips_key(&seed).unwrap(); let id0_ed = derive_identity_ed25519(&seed, 0).unwrap(); let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap(); let _btc = derive_bitcoin_xprv(&seed).unwrap(); @@ -491,6 +523,7 @@ mod tests { let node_ed_hex = hex::encode(node_ed.verifying_key().as_bytes()); let id0_ed_hex = hex::encode(id0_ed.verifying_key().as_bytes()); let node_nostr_hex = node_nostr.public_key().to_hex(); + let fips_hex = fips.public_key().to_hex(); let id0_nostr_hex = id0_nostr.public_key().to_hex(); let lnd_hex = hex::encode(lnd); @@ -498,6 +531,7 @@ mod tests { &node_ed_hex, &id0_ed_hex, &node_nostr_hex, + &fips_hex, &id0_nostr_hex, &lnd_hex, ]; diff --git a/core/archipelago/src/transport/fips.rs b/core/archipelago/src/transport/fips.rs new file mode 100644 index 00000000..6e4faba9 --- /dev/null +++ b/core/archipelago/src/transport/fips.rs @@ -0,0 +1,75 @@ +//! FIPS mesh transport (Free Internetworking Peering System). +//! +//! Delegates the actual wire protocol to the `fips` system daemon +//! (github.com/jmcorgan/fips), which archipelago supervises via the +//! `archipelago-fips.service` unit. This module is the in-process +//! `NodeTransport` adapter: it checks daemon liveness, maps a peer's +//! FIPS npub to a `fd00::/8` IPv6 TUN address, and POSTs the +//! `TransportMessage` payload over it. +//! +//! Sits at priority 3 between LAN and Tor — preferred over Tor for +//! federation and peer traffic but yielding to direct LAN. +//! +//! Currently a stub: `is_available()` returns false until the FIPS +//! daemon integration in `crate::fips` lands and the key at +//! `/data/identity/fips_key` is materialised via onboarding. + +use super::{NodeTransport, TransportKind, TransportMessage}; +use anyhow::Result; +use std::path::{Path, PathBuf}; + +pub struct FipsTransport { + identity_dir: PathBuf, +} + +impl FipsTransport { + pub fn new(identity_dir: &Path) -> Self { + Self { + identity_dir: identity_dir.to_path_buf(), + } + } +} + +impl NodeTransport for FipsTransport { + fn kind(&self) -> TransportKind { + TransportKind::Fips + } + + fn is_available(&self) -> bool { + // Readiness gate: key must be on disk AND daemon wiring must exist. + // The daemon-liveness check is added alongside `crate::fips` — until + // then we deliberately report unavailable so the router falls through + // to Tor and no traffic is misrouted onto a missing TUN. + let _key_present = crate::identity::fips_key_exists(&self.identity_dir); + false + } + + fn send<'a>( + &'a self, + _address: &'a str, + _message: &'a TransportMessage, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + anyhow::bail!("FIPS transport not yet wired; daemon integration pending") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kind_is_fips() { + let t = FipsTransport::new(std::path::Path::new("/tmp")); + assert_eq!(t.kind(), TransportKind::Fips); + } + + #[test] + fn test_reports_unavailable_pre_wiring() { + let dir = tempfile::tempdir().unwrap(); + let t = FipsTransport::new(dir.path()); + // Stub: always unavailable until daemon integration lands. + assert!(!t.is_available()); + } +} diff --git a/core/archipelago/src/transport/mod.rs b/core/archipelago/src/transport/mod.rs index 2db9e074..99158562 100644 --- a/core/archipelago/src/transport/mod.rs +++ b/core/archipelago/src/transport/mod.rs @@ -2,12 +2,17 @@ #![allow(dead_code)] //! Transport abstraction layer for Archipelago node-to-node communication. //! -//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait. -//! Routes messages to peers via the best available transport with automatic -//! fallback: Mesh (priority 1) > LAN (2) > Tor (3). +//! Unifies mesh radio (LoRa), LAN (mDNS), FIPS (Free Internetworking Peering +//! System overlay), and Tor under a common trait. Routes messages to peers via +//! the best available transport with automatic fallback: +//! Mesh (1) > LAN (2) > FIPS (3) > Tor (4). +//! +//! FIPS sits between LAN and Tor: faster than Tor for WAN peering, but still +//! defers to direct LAN connectivity when peers are on the same network. pub mod chunking; pub mod delta; +pub mod fips; pub mod lan; pub mod mesh_transport; pub mod tor; @@ -31,7 +36,8 @@ use tracing::{info, warn}; pub enum TransportKind { Mesh = 1, Lan = 2, - Tor = 3, + Fips = 3, + Tor = 4, } impl std::fmt::Display for TransportKind { @@ -39,6 +45,7 @@ impl std::fmt::Display for TransportKind { match self { Self::Mesh => write!(f, "mesh"), Self::Lan => write!(f, "lan"), + Self::Fips => write!(f, "fips"), Self::Tor => write!(f, "tor"), } } @@ -77,6 +84,7 @@ pub trait NodeTransport: Send + Sync { /// For Tor: address is an onion hostname. /// For Mesh: address is a contact_id as string. /// For LAN: address is "ip:port". + /// For FIPS: address is the peer's FIPS npub (bech32); implementation maps to fd00::/8. fn send<'a>( &'a self, address: &'a str, @@ -115,6 +123,8 @@ pub struct PeerRecord { #[serde(default)] pub lan_address: Option, #[serde(default)] + pub fips_npub: Option, + #[serde(default)] pub onion_address: Option, // Freshness timestamps (RFC 3339) @@ -123,6 +133,8 @@ pub struct PeerRecord { #[serde(default)] pub last_lan: Option, #[serde(default)] + pub last_fips: Option, + #[serde(default)] pub last_tor: Option, } @@ -132,16 +144,18 @@ impl PeerRecord { match kind { TransportKind::Mesh => self.mesh_contact_id.map(|id| id.to_string()), TransportKind::Lan => self.lan_address.clone(), + TransportKind::Fips => self.fips_npub.clone(), TransportKind::Tor => self.onion_address.clone(), } } /// Check if the last-seen timestamp for a transport is fresh enough. - /// Mesh/LAN: 5 minutes. Tor: 1 hour. + /// Mesh/LAN: 5 minutes. FIPS: 30 minutes. Tor: 1 hour. pub fn is_fresh(&self, kind: TransportKind) -> bool { let timestamp = match kind { TransportKind::Mesh => self.last_mesh.as_deref(), TransportKind::Lan => self.last_lan.as_deref(), + TransportKind::Fips => self.last_fips.as_deref(), TransportKind::Tor => self.last_tor.as_deref(), }; let Some(ts) = timestamp else { @@ -155,6 +169,7 @@ impl PeerRecord { let age = chrono::Utc::now().signed_duration_since(parsed); let max_age = match kind { TransportKind::Mesh | TransportKind::Lan => chrono::Duration::minutes(5), + TransportKind::Fips => chrono::Duration::minutes(30), TransportKind::Tor => chrono::Duration::hours(1), }; age < max_age @@ -169,6 +184,9 @@ impl PeerRecord { if self.lan_address.is_some() { result.push(TransportKind::Lan); } + if self.fips_npub.is_some() { + result.push(TransportKind::Fips); + } if self.onion_address.is_some() { result.push(TransportKind::Tor); } @@ -239,9 +257,11 @@ impl PeerRegistry { source: Some(source.clone()), mesh_contact_id: None, lan_address: None, + fips_npub: None, onion_address: None, last_mesh: None, last_lan: None, + last_fips: None, last_tor: None, }); // Update pubkey if it changed @@ -278,6 +298,15 @@ impl PeerRegistry { } } + /// Set the FIPS npub for a peer (bech32 pubkey used by the FIPS mesh). + pub async fn set_fips_npub(&self, did: &str, npub: &str) { + let mut peers = self.peers.write().await; + if let Some(peer) = peers.get_mut(did) { + peer.fips_npub = Some(npub.to_string()); + peer.last_fips = Some(chrono::Utc::now().to_rfc3339()); + } + } + /// Set the display name for a peer. pub async fn set_name(&self, did: &str, name: &str) { let mut peers = self.peers.write().await; @@ -402,6 +431,17 @@ impl TransportRouter { } } } + if peer.fips_npub.is_some() && peer.is_fresh(TransportKind::Fips) { + if let Some(t) = self + .transports + .iter() + .find(|t| t.kind() == TransportKind::Fips) + { + if t.is_available() { + available.push(TransportKind::Fips); + } + } + } if peer.onion_address.is_some() { if let Some(t) = self .transports @@ -446,7 +486,31 @@ mod tests { #[test] fn test_transport_kind_ordering() { assert!(TransportKind::Mesh < TransportKind::Lan); - assert!(TransportKind::Lan < TransportKind::Tor); + assert!(TransportKind::Lan < TransportKind::Fips); + assert!(TransportKind::Fips < TransportKind::Tor); + } + + #[test] + fn test_fips_preferred_over_tor_in_available_transports() { + let peer = PeerRecord { + did: "did:key:z6MkTest".to_string(), + pubkey_hex: "aabb".to_string(), + name: None, + trust_level: None, + source: None, + mesh_contact_id: None, + lan_address: None, + fips_npub: Some("npub1exampleexampleexampleexampleexampleexample".to_string()), + onion_address: Some("abc.onion".to_string()), + last_mesh: None, + last_lan: None, + last_fips: None, + last_tor: None, + }; + let ts = peer.available_transports(); + let fips_idx = ts.iter().position(|k| *k == TransportKind::Fips).unwrap(); + let tor_idx = ts.iter().position(|k| *k == TransportKind::Tor).unwrap(); + assert!(fips_idx < tor_idx, "FIPS must be listed before Tor"); } #[test] @@ -459,9 +523,11 @@ mod tests { source: None, mesh_contact_id: Some(42), lan_address: Some("192.168.1.100:5678".to_string()), + fips_npub: None, onion_address: Some("abc123.onion".to_string()), last_mesh: None, last_lan: None, + last_fips: None, last_tor: None, }; assert_eq!( @@ -488,9 +554,11 @@ mod tests { source: None, mesh_contact_id: Some(1), lan_address: None, + fips_npub: None, onion_address: Some("test.onion".to_string()), last_mesh: None, last_lan: None, + last_fips: None, last_tor: None, }; let transports = peer.available_transports(); @@ -507,9 +575,11 @@ mod tests { source: None, mesh_contact_id: Some(1), lan_address: None, + fips_npub: None, onion_address: None, last_mesh: None, last_lan: None, + last_fips: None, last_tor: None, }; // No timestamp = considered fresh (allows first attempt) @@ -526,9 +596,11 @@ mod tests { source: None, mesh_contact_id: Some(1), lan_address: None, + fips_npub: None, onion_address: None, last_mesh: Some(chrono::Utc::now().to_rfc3339()), last_lan: None, + last_fips: None, last_tor: None, }; assert!(peer.is_fresh(TransportKind::Mesh)); @@ -545,9 +617,11 @@ mod tests { source: None, mesh_contact_id: Some(1), lan_address: None, + fips_npub: None, onion_address: None, last_mesh: Some(stale.to_rfc3339()), last_lan: None, + last_fips: None, last_tor: None, }; // 10 minutes old > 5 minute mesh freshness threshold diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 5a1428d4..eca0a01e 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -239,6 +239,27 @@ if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then # Create a Dockerfile for building the rootfs cat > "$WORK_DIR/Dockerfile.rootfs" < /etc/locale.gen && locale-gen @@ -377,6 +404,7 @@ COPY archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path COPY nostr-vpn.service /etc/systemd/system/nostr-vpn.service COPY archipelago-wg.service /etc/systemd/system/archipelago-wg.service COPY archipelago-wg-address.service /etc/systemd/system/archipelago-wg-address.service +COPY archipelago-fips.service /etc/systemd/system/archipelago-fips.service COPY nostr-relay.service /etc/systemd/system/nostr-relay.service COPY nostr-relay-config.toml /etc/archipelago/nostr-relay-config.toml @@ -416,6 +444,11 @@ RUN systemctl enable NetworkManager || true && \ # archipelago-wg + wg-address: enabled by first-boot after WG key is generated # nostr-vpn: enabled by first-boot after Nostr identity is generated # (env file doesn't exist until onboarding, so pre-enabling causes crash-loop) +# archipelago-fips: masked by default; archipelago backend unmasks + +# starts it via `fips.install` RPC once the seed-derived fips_key is on +# disk and the fips daemon package is installed. Pre-onboarding the node +# stays dark on FIPS so no traffic leaves an ephemeral identity. +RUN systemctl mask archipelago-fips.service || true # Remove policy-rc.d so services can start on first boot RUN rm -f /usr/sbin/policy-rc.d @@ -517,6 +550,10 @@ NGINXCONF cp "$SCRIPT_DIR/configs/archipelago-wg-address.service" "$WORK_DIR/archipelago-wg-address.service" echo " Using archipelago-wg-address.service from configs/" fi + if [ -f "$SCRIPT_DIR/configs/archipelago-fips.service" ]; then + cp "$SCRIPT_DIR/configs/archipelago-fips.service" "$WORK_DIR/archipelago-fips.service" + echo " Using archipelago-fips.service from configs/" + fi # Copy private Nostr relay service (native, for NostrVPN signaling) if [ -f "$SCRIPT_DIR/configs/nostr-relay.service" ]; then diff --git a/image-recipe/configs/archipelago-fips.service b/image-recipe/configs/archipelago-fips.service new file mode 100644 index 00000000..7449cb3f --- /dev/null +++ b/image-recipe/configs/archipelago-fips.service @@ -0,0 +1,21 @@ +[Unit] +Description=Archipelago FIPS mesh transport (wraps upstream fips daemon) +# Stay dark until onboarding materialises the seed-derived key. Archipelago +# backend unmasks + starts this unit via `sudo systemctl` once the key is +# present; pre-onboarding the unit must be masked so no traffic is sent +# from an ephemeral identity. +ConditionPathExists=/var/lib/archipelago/identity/fips_key +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=/bin/sh -c 'test -x /usr/bin/fips || { echo "fips daemon not installed — run fips.install from dashboard" >&2; exit 1; }' +ExecStart=/usr/bin/fips --config /etc/fips/fips.yaml +Restart=on-failure +RestartSec=5 +# UDP 8668 is reachable on all interfaces by default; the daemon does its +# own Noise authentication so no firewall gate is added here. + +[Install] +WantedBy=multi-user.target diff --git a/neode-ui/src/views/home/HomeNetworkCard.vue b/neode-ui/src/views/home/HomeNetworkCard.vue new file mode 100644 index 00000000..703194a7 --- /dev/null +++ b/neode-ui/src/views/home/HomeNetworkCard.vue @@ -0,0 +1,160 @@ + + +