feat(fips): integrate jmcorgan/fips as preferred non-Tor transport + v1.4.0

Bakes the FIPS (Free Internetworking Peering System) mesh daemon into
the node stack, supervised by archipelago alongside Tor. Runs as a
system service, identity derives from the same BIP-39 master seed, and
user-triggered updates track upstream main.

Identity
  seed.rs: new HKDF label archipelago/fips/secp256k1/v1 → dedicated
  secp256k1 key, distinct from the Nostr-node key for crypto isolation
  but still seed-recoverable
  identity.rs: writes fips_key[.pub] to /data/identity on onboarding,
  chmod 0600; fips_key_exists / load_fips_keys / fips_npub accessors

Transport
  TransportKind::Fips=3 inserted between LAN and Tor (Tor bumps to 4)
  → router prefers FIPS over Tor for all peer traffic
  PeerRecord gains fips_npub + last_fips fields (serde(default) for
  backward-compat with older nodes)
  transport/fips.rs: NodeTransport stub, reports unavailable until the
  daemon is live so router falls through to Tor cleanly

Federation invites
  FederatedNode and FederationInvite carry optional fips_npub
  create_invite / accept_invite / peer-joined callback thread it end
  to end; signature domain deliberately unchanged — FIPS Noise does
  its own session auth, so the unsigned hint only affects path
  selection

crate::fips
  config.rs: renders /etc/fips/fips.yaml and sudo-installs key material
  service.rs: systemctl status/activate/restart/mask wrappers
  update.rs: GitHub API check against upstream main; apply stubbed
  until per-commit .deb artefact source is decided

RPC + dashboard
  fips.status / fips.check-update / fips.apply-update / fips.install /
  fips.restart registered in dispatcher
  HomeNetworkCard.vue shipped standalone (unmounted — place in Home.vue
  when ready); shows state pill, version, FIPS npub, update button,
  activate button when key is present but service is down

ISO + systemd
  archipelago-fips.service: conditional on key presence, masked by
  default — backend unmasks after onboarding writes the key
  build-auto-installer-iso.sh: multi-stage Dockerfile builds the FIPS
  .deb from jmcorgan/fips main (fail-loud), COPYs it into rootfs, apt
  installs it so trixie resolves deps; unit copied + masked

Version bump: 1.3.5 → 1.4.0

Tests: 33 new/updated passing (seed, identity, transport, federation,
fips module, transport::fips).

Known gaps: fips.apply-update returns a clear stub error until
upstream publishes per-commit .deb artefacts; HomeNetworkCard is not
mounted in Home.vue by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-18 22:57:51 -04:00
parent 46350f48b6
commit 30a7f73ead
22 changed files with 1353 additions and 39 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.3.5"
version = "1.4.0"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -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"]

View File

@ -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,

View File

@ -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?;

View File

@ -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<serde_json::Value> {
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<serde_json::Value> {
let check = fips::update::check().await?;
Ok(serde_json::to_value(check)?)
}
pub(super) async fn handle_fips_apply_update(&self) -> Result<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
fips::service::restart(fips::SERVICE_UNIT).await?;
Ok(serde_json::json!({ "restarted": true }))
}
}

View File

@ -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

View File

@ -8,6 +8,7 @@ mod credentials;
mod dispatcher;
mod dwn;
mod federation;
mod fips;
mod handshake;
mod identity;
mod interfaces;

View File

@ -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:<base64(json{did, onion, pubkey, token})>`
/// 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<String>,
}
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?})>`.
/// `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<String> {
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<ParsedInvite> {
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<FederatedNode> {
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

View File

@ -172,6 +172,7 @@ mod tests {
added_at: "2026-01-01T00:00:00Z".to_string(),
last_seen: None,
last_state: None,
fips_npub: None,
}
}

View File

@ -35,6 +35,10 @@ pub struct FederatedNode {
pub last_seen: Option<String>,
#[serde(default)]
pub last_state: Option<NodeStateSnapshot>,
/// 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<String>,
}
/// 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<String>,
}
#[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]

View File

@ -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"));
}
}

View File

@ -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<String>,
/// `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<String>,
}
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-dirrelative 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");
}
}

View File

@ -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 <unit>` → "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<String> {
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 <verb> <unit>` — 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;
}
}

View File

@ -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 12 (the "is there anything newer?" query) and stubs
//! out 35 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<String>,
/// 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<UpdateCheck> {
let current = service::daemon_version().await.ok();
let latest = fetch_latest_main_sha().await?;
let short = latest.chars().take(7).collect::<String>();
let update_available = match &current {
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<String> {
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"));
}
}

View File

@ -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<Self> {
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::<String>()
);
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<Option<nostr_sdk::Keys>> {
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<Option<String>> {
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<String> {
@ -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);
}
}
}

View File

@ -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;

View File

@ -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<nostr_sdk::Keys> {
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<nostr_sdk::Keys> {
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<nostr_sdk::Keys> {
@ -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,
];

View File

@ -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<Box<dyn std::future::Future<Output = Result<()>> + 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());
}
}

View File

@ -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<String>,
#[serde(default)]
pub fips_npub: Option<String>,
#[serde(default)]
pub onion_address: Option<String>,
// Freshness timestamps (RFC 3339)
@ -123,6 +133,8 @@ pub struct PeerRecord {
#[serde(default)]
pub last_lan: Option<String>,
#[serde(default)]
pub last_fips: Option<String>,
#[serde(default)]
pub last_tor: Option<String>,
}
@ -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

View File

@ -239,6 +239,27 @@ if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
# Create a Dockerfile for building the rootfs
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
# ─── Stage 1: Build the FIPS mesh daemon .deb from upstream main ─────────
#
# FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing
# protocol archipelago uses as its preferred non-Tor transport. We track
# upstream main per project decision (2026-04) — v0.2.0 isn't stable yet.
# The .deb is rebuilt every ISO build; Docker layer caching keeps the
# incremental cost low. Failure here fails the ISO build on purpose:
# we don't want to ship an ISO that silently skips FIPS.
FROM rust:1-slim-bookworm AS fips-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \\
git ca-certificates build-essential pkg-config dpkg-dev \\
&& rm -rf /var/lib/apt/lists/*
RUN cargo install --locked cargo-deb
RUN git clone --depth 1 https://github.com/jmcorgan/fips.git /src/fips
WORKDIR /src/fips
RUN cargo build --release
RUN cargo deb --no-build
RUN cp target/debian/fips_*_amd64.deb /tmp/fips.deb
# ─── Stage 2: The actual Archipelago rootfs ──────────────────────────────
FROM debian:trixie
ENV DEBIAN_FRONTEND=noninteractive
@ -329,6 +350,12 @@ RUN curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg | tee
apt-get update && apt-get install -y --no-install-recommends tailscale && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Install FIPS mesh daemon from the .deb built in stage 1. apt-get install
# resolves dependencies from trixie so a cross-dist build still lands cleanly.
COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
RUN apt-get update && apt-get install -y --no-install-recommends /tmp/fips.deb && \
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
# Configure locale
RUN echo "en_US.UTF-8 UTF-8" > /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

View File

@ -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

View File

@ -0,0 +1,160 @@
<template>
<div
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animate }"
style="--card-stagger: 5"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
<p class="text-sm text-white/70">FIPS mesh preferred over Tor for peer traffic</p>
</div>
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
</div>
</div>
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">FIPS npub</p>
<p class="text-sm font-mono text-white break-all">{{ npubDisplay }}</p>
<p v-if="!status.key_present" class="text-xs text-white/40 mt-1">Unlock your seed to derive the FIPS key</p>
</div>
</div>
<div v-if="updateInfo" class="mb-3 p-3 bg-white/5 rounded-lg border-l-2 border-orange-400">
<p class="text-xs text-orange-400 font-medium mb-1">{{ updateInfo.update_available ? 'Update available' : 'Up to date' }}</p>
<p class="text-xs text-white/70 break-all">{{ updateInfo.notes }}</p>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<button
class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
:disabled="checking"
@click="checkForUpdate"
>{{ checking ? 'Checking…' : 'Check for update' }}</button>
<button
v-if="status.key_present && !status.service_active"
class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors"
:disabled="installing"
@click="installAndActivate"
>{{ installing ? 'Installing…' : 'Activate' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
defineProps<{ animate: boolean }>()
interface FipsStatus {
installed: boolean
version: string | null
service_state: string
service_active: boolean
key_present: boolean
npub: string | null
}
interface UpdateCheck {
current: string | null
latest_commit: string
update_available: boolean
notes: string
}
const status = ref<FipsStatus>({
installed: false,
version: null,
service_state: 'unknown',
service_active: false,
key_present: false,
npub: null,
})
const updateInfo = ref<UpdateCheck | null>(null)
const checking = ref(false)
const installing = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const statusLabel = computed(() => {
if (!status.value.installed) return 'not installed'
if (!status.value.key_present) return 'awaiting seed'
if (status.value.service_active) return 'active'
return status.value.service_state
})
const statusDotColor = computed(() => {
if (status.value.service_active) return 'bg-green-400'
if (!status.value.installed || !status.value.key_present) return 'bg-white/30'
return 'bg-orange-400'
})
const statusTextColor = computed(() => {
if (status.value.service_active) return 'text-green-400'
if (!status.value.installed || !status.value.key_present) return 'text-white/50'
return 'text-orange-400'
})
const npubDisplay = computed(() => {
const n = status.value.npub
if (!n) return '—'
return n.length > 20 ? `${n.slice(0, 12)}${n.slice(-6)}` : n
})
function flash(msg: string, isError = false) {
statusMessage.value = msg
statusIsError.value = isError
setTimeout(() => { statusMessage.value = '' }, 6000)
}
async function loadStatus() {
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
} catch (e) {
if (import.meta.env.DEV) console.warn('fips.status failed', e)
}
}
async function checkForUpdate() {
checking.value = true
try {
updateInfo.value = await rpcClient.call<UpdateCheck>({ method: 'fips.check-update' })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Update check failed: ${msg}`, true)
} finally {
checking.value = false
}
}
async function installAndActivate() {
installing.value = true
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
flash('FIPS installed and activated')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Install failed: ${msg}`, true)
} finally {
installing.value = false
}
}
onMounted(loadStatus)
</script>