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
co-authored by Claude Opus 4.7
parent f04804ae25
commit c1cfca6212
22 changed files with 1353 additions and 39 deletions
+80 -6
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