Dorian c1cfca6212 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>
2026-04-18 22:57:51 -04:00

653 lines
22 KiB
Rust

// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Transport abstraction layer for Archipelago node-to-node communication.
//!
//! 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;
use crate::federation::TrustLevel;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::RwLock;
use tracing::{info, warn};
// ─── Transport Kind ─────────────────────────────────────────────────────
/// Transport backend type, ordered by priority (lower = preferred).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportKind {
Mesh = 1,
Lan = 2,
Fips = 3,
Tor = 4,
}
impl std::fmt::Display for TransportKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mesh => write!(f, "mesh"),
Self::Lan => write!(f, "lan"),
Self::Fips => write!(f, "fips"),
Self::Tor => write!(f, "tor"),
}
}
}
// ─── Message Types ──────────────────────────────────────────────────────
/// Type of transport-level message.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageType {
StateSync,
PeerMessage,
FederationRpc,
}
/// A message sent between nodes via any transport.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransportMessage {
pub from_did: String,
pub payload: Vec<u8>,
pub message_type: MessageType,
}
// ─── NodeTransport Trait ────────────────────────────────────────────────
/// Trait implemented by each transport backend (Tor, Mesh, LAN).
pub trait NodeTransport: Send + Sync {
/// Which transport this is.
fn kind(&self) -> TransportKind;
/// Whether this transport is currently operational.
fn is_available(&self) -> bool;
/// Send raw bytes to a peer at their transport-specific address.
/// 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,
message: &'a TransportMessage,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>;
}
// ─── Peer Registry ──────────────────────────────────────────────────────
/// How we discovered this peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PeerSource {
Federation,
MeshDiscovery,
LanDiscovery,
NostrHandshake,
Manual,
}
/// Unified peer record with per-transport capabilities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRecord {
pub did: String,
pub pubkey_hex: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub trust_level: Option<TrustLevel>,
#[serde(default)]
pub source: Option<PeerSource>,
// Transport-specific addresses
#[serde(default)]
pub mesh_contact_id: Option<u32>,
#[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)
#[serde(default)]
pub last_mesh: Option<String>,
#[serde(default)]
pub last_lan: Option<String>,
#[serde(default)]
pub last_fips: Option<String>,
#[serde(default)]
pub last_tor: Option<String>,
}
impl PeerRecord {
/// Get the transport-specific address for a given transport kind.
pub fn address_for(&self, kind: TransportKind) -> Option<String> {
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. 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 {
// No timestamp means we haven't confirmed it, but the address exists.
// Allow it — the send will fail if unreachable.
return true;
};
let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) else {
return false;
};
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
}
/// List available transport kinds for this peer, in priority order.
pub fn available_transports(&self) -> Vec<TransportKind> {
let mut result = Vec::new();
if self.mesh_contact_id.is_some() {
result.push(TransportKind::Mesh);
}
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);
}
result
}
}
const PEERS_FILE: &str = "transport-peers.json";
/// Thread-safe registry of all known peers with their transport capabilities.
pub struct PeerRegistry {
peers: RwLock<HashMap<String, PeerRecord>>,
data_dir: PathBuf,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PeersFile {
peers: Vec<PeerRecord>,
}
impl PeerRegistry {
/// Load peer registry from disk (or create empty).
pub async fn load(data_dir: &Path) -> Result<Self> {
let path = data_dir.join(PEERS_FILE);
let peers = if path.exists() {
let content = fs::read_to_string(&path)
.await
.context("Failed to read transport peers")?;
let file: PeersFile = serde_json::from_str(&content).unwrap_or_default();
file.peers.into_iter().map(|p| (p.did.clone(), p)).collect()
} else {
HashMap::new()
};
Ok(Self {
peers: RwLock::new(peers),
data_dir: data_dir.to_path_buf(),
})
}
/// Persist current state to disk.
pub async fn save(&self) -> Result<()> {
let peers = self.peers.read().await;
let file = PeersFile {
peers: peers.values().cloned().collect(),
};
let content =
serde_json::to_string_pretty(&file).context("Failed to serialize transport peers")?;
fs::write(self.data_dir.join(PEERS_FILE), content)
.await
.context("Failed to write transport peers")?;
Ok(())
}
/// Register or update a peer.
pub async fn register_peer(
&self,
did: &str,
pubkey_hex: &str,
source: PeerSource,
) -> PeerRecord {
let mut peers = self.peers.write().await;
let record = peers.entry(did.to_string()).or_insert_with(|| PeerRecord {
did: did.to_string(),
pubkey_hex: pubkey_hex.to_string(),
name: None,
trust_level: None,
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
if record.pubkey_hex != pubkey_hex {
record.pubkey_hex = pubkey_hex.to_string();
}
record.clone()
}
/// Set the mesh contact ID for a peer.
pub async fn set_mesh_id(&self, did: &str, contact_id: u32) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.mesh_contact_id = Some(contact_id);
peer.last_mesh = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the LAN address for a peer.
pub async fn set_lan_address(&self, did: &str, addr: SocketAddr) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.lan_address = Some(addr.to_string());
peer.last_lan = Some(chrono::Utc::now().to_rfc3339());
}
}
/// Set the onion address for a peer.
pub async fn set_onion(&self, did: &str, onion: &str) {
let mut peers = self.peers.write().await;
if let Some(peer) = peers.get_mut(did) {
peer.onion_address = Some(onion.to_string());
peer.last_tor = Some(chrono::Utc::now().to_rfc3339());
}
}
/// 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;
if let Some(peer) = peers.get_mut(did) {
peer.name = Some(name.to_string());
}
}
/// Get a peer by DID.
pub async fn get_peer(&self, did: &str) -> Option<PeerRecord> {
self.peers.read().await.get(did).cloned()
}
/// Get all peers.
pub async fn all_peers(&self) -> Vec<PeerRecord> {
self.peers.read().await.values().cloned().collect()
}
/// Count of registered peers.
pub async fn count(&self) -> usize {
self.peers.read().await.len()
}
}
// ─── Transport Router ───────────────────────────────────────────────────
/// Routes messages to the best available transport per peer.
pub struct TransportRouter {
transports: Vec<Box<dyn NodeTransport>>,
pub registry: Arc<PeerRegistry>,
mesh_only: RwLock<bool>,
}
impl TransportRouter {
pub fn new(
transports: Vec<Box<dyn NodeTransport>>,
registry: Arc<PeerRegistry>,
mesh_only: bool,
) -> Self {
Self {
transports,
registry,
mesh_only: RwLock::new(mesh_only),
}
}
/// Send a message to a peer by DID, using the best available transport.
pub async fn send_to_peer(
&self,
did: &str,
message: &TransportMessage,
) -> Result<TransportKind> {
let peer = self
.registry
.get_peer(did)
.await
.ok_or_else(|| anyhow::anyhow!("Unknown peer: {}", did))?;
let candidates = self.route(&peer).await;
if candidates.is_empty() {
anyhow::bail!("No available transport for peer {}", did);
}
let mut last_err = None;
for kind in &candidates {
let transport = match self.transports.iter().find(|t| t.kind() == *kind) {
Some(t) => t,
None => continue,
};
let address = match peer.address_for(*kind) {
Some(a) => a,
None => continue,
};
match transport.send(&address, message).await {
Ok(()) => {
info!(transport = %kind, peer = %did, "Message sent");
return Ok(*kind);
}
Err(e) => {
warn!(transport = %kind, peer = %did, error = %e, "Transport failed, trying next");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("All transports failed for peer {}", did)))
}
/// Determine transport priority for a peer.
async fn route(&self, peer: &PeerRecord) -> Vec<TransportKind> {
let mesh_only = *self.mesh_only.read().await;
let mut available = Vec::new();
if mesh_only {
// Off-grid mode: only mesh
if peer.mesh_contact_id.is_some() {
available.push(TransportKind::Mesh);
}
} else {
// Normal mode: priority order, check freshness
if peer.mesh_contact_id.is_some() && peer.is_fresh(TransportKind::Mesh) {
if let Some(t) = self
.transports
.iter()
.find(|t| t.kind() == TransportKind::Mesh)
{
if t.is_available() {
available.push(TransportKind::Mesh);
}
}
}
if peer.lan_address.is_some() && peer.is_fresh(TransportKind::Lan) {
if let Some(t) = self
.transports
.iter()
.find(|t| t.kind() == TransportKind::Lan)
{
if t.is_available() {
available.push(TransportKind::Lan);
}
}
}
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
.iter()
.find(|t| t.kind() == TransportKind::Tor)
{
if t.is_available() {
available.push(TransportKind::Tor);
}
}
}
}
available
}
/// Set mesh-only (off-grid) mode.
pub async fn set_mesh_only(&self, enabled: bool) {
*self.mesh_only.write().await = enabled;
}
/// Get current mesh-only mode status.
pub async fn is_mesh_only(&self) -> bool {
*self.mesh_only.read().await
}
/// Get status of all transports.
pub fn transport_status(&self) -> Vec<(TransportKind, bool)> {
self.transports
.iter()
.map(|t| (t.kind(), t.is_available()))
.collect()
}
}
// ─── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_kind_ordering() {
assert!(TransportKind::Mesh < TransportKind::Lan);
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]
fn test_peer_record_address_for() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: Some("test-node".to_string()),
trust_level: None,
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!(
peer.address_for(TransportKind::Mesh),
Some("42".to_string())
);
assert_eq!(
peer.address_for(TransportKind::Lan),
Some("192.168.1.100:5678".to_string())
);
assert_eq!(
peer.address_for(TransportKind::Tor),
Some("abc123.onion".to_string())
);
}
#[test]
fn test_peer_record_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: 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();
assert_eq!(transports, vec![TransportKind::Mesh, TransportKind::Tor]);
}
#[test]
fn test_freshness_no_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
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)
assert!(peer.is_fresh(TransportKind::Mesh));
}
#[test]
fn test_freshness_recent_timestamp() {
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
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));
}
#[test]
fn test_freshness_stale_timestamp() {
let stale = chrono::Utc::now() - chrono::Duration::minutes(10);
let peer = PeerRecord {
did: "did:key:z6MkTest".to_string(),
pubkey_hex: "aabb".to_string(),
name: None,
trust_level: None,
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
assert!(!peer.is_fresh(TransportKind::Mesh));
}
#[tokio::test]
async fn test_peer_registry_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let registry = PeerRegistry::load(dir.path()).await.unwrap();
registry
.register_peer("did:key:z6MkTest", "aabbccdd", PeerSource::MeshDiscovery)
.await;
registry.set_mesh_id("did:key:z6MkTest", 42).await;
registry
.set_onion("did:key:z6MkTest", "test123.onion")
.await;
registry.save().await.unwrap();
// Reload from disk
let registry2 = PeerRegistry::load(dir.path()).await.unwrap();
let peer = registry2.get_peer("did:key:z6MkTest").await.unwrap();
assert_eq!(peer.mesh_contact_id, Some(42));
assert_eq!(peer.onion_address, Some("test123.onion".to_string()));
assert_eq!(peer.pubkey_hex, "aabbccdd");
}
}