feat(fips): integrate jmcorgan/fips as preferred non-Tor transport + v1.4.0
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 2s

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 f04804ae25
commit c1cfca6212
22 changed files with 1353 additions and 39 deletions

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;