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:
parent
46350f48b6
commit
30a7f73ead
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.5"
|
version = "1.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.5"
|
version = "1.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@ -404,6 +404,13 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
"monitoring.export" => self.handle_monitoring_export(params).await,
|
"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
|
// System updates
|
||||||
"update.check" => self.handle_update_check().await,
|
"update.check" => self.handle_update_check().await,
|
||||||
"update.status" => self.handle_update_status().await,
|
"update.status" => self.handle_update_status().await,
|
||||||
|
|||||||
@ -44,9 +44,19 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Tor address not available. Tor may not be running.");
|
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!({
|
Ok(serde_json::json!({
|
||||||
"code": code,
|
"code": code,
|
||||||
"did": did,
|
"did": did,
|
||||||
@ -72,12 +82,14 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
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(
|
let node = federation::accept_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
code,
|
code,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|data| node_identity.sign(data),
|
|data| node_identity.sign(data),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -402,6 +414,12 @@ impl RpcHandler {
|
|||||||
.get("pubkey")
|
.get("pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
.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)
|
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||||
let signature = params.get("signature").and_then(|v| v.as_str());
|
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?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
||||||
// If already known but missing onion/pubkey, update them
|
// If already known but missing onion/pubkey/fips_npub, update them
|
||||||
if existing.onion.is_empty() || existing.pubkey.is_empty() {
|
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();
|
let mut updated = existing.clone();
|
||||||
if existing.onion.is_empty() && !onion.is_empty() {
|
if needs_onion && !onion.is_empty() {
|
||||||
updated.onion = onion.to_string();
|
updated.onion = onion.to_string();
|
||||||
}
|
}
|
||||||
if existing.pubkey.is_empty() && !pubkey.is_empty() {
|
if needs_pubkey && !pubkey.is_empty() {
|
||||||
updated.pubkey = pubkey.to_string();
|
updated.pubkey = pubkey.to_string();
|
||||||
}
|
}
|
||||||
|
if needs_fips {
|
||||||
|
updated.fips_npub = fips_npub.clone();
|
||||||
|
}
|
||||||
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||||
federation::update_node(&self.config.data_dir, &updated).await?;
|
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 }));
|
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||||
}
|
}
|
||||||
@ -451,6 +475,7 @@ impl RpcHandler {
|
|||||||
added_at: chrono::Utc::now().to_rfc3339(),
|
added_at: chrono::Utc::now().to_rfc3339(),
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
last_state: None,
|
last_state: None,
|
||||||
|
fips_npub,
|
||||||
};
|
};
|
||||||
|
|
||||||
federation::add_node(&self.config.data_dir, node).await?;
|
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
|
// Generate a one-shot federation invite. The code embeds OUR onion
|
||||||
// and OUR pubkey, but it leaves this box only inside the NIP-44
|
// and OUR pubkey, but it leaves this box only inside the NIP-44
|
||||||
// ciphertext below.
|
// 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(
|
let invite_code = federation::create_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
47
core/archipelago/src/api/rpc/fips.rs
Normal file
47
core/archipelago/src/api/rpc/fips.rs
Normal 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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -278,12 +278,16 @@ impl RpcHandler {
|
|||||||
let identity_dir2 = self.config.data_dir.join("identity");
|
let identity_dir2 = self.config.data_dir.join("identity");
|
||||||
let node_identity =
|
let node_identity =
|
||||||
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
|
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(
|
match crate::federation::accept_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
invite_code,
|
invite_code,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|bytes| node_identity.sign(bytes),
|
|bytes| node_identity.sign(bytes),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -8,6 +8,7 @@ mod credentials;
|
|||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
mod dwn;
|
mod dwn;
|
||||||
mod federation;
|
mod federation;
|
||||||
|
mod fips;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
|
|||||||
@ -6,12 +6,28 @@ use std::path::Path;
|
|||||||
use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes};
|
use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes};
|
||||||
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
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(
|
pub async fn create_invite(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
did: &str,
|
did: &str,
|
||||||
onion: &str,
|
onion: &str,
|
||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
|
fips_npub: Option<&str>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@ -20,12 +36,15 @@ pub async fn create_invite(
|
|||||||
rand::thread_rng().fill(&mut token_bytes);
|
rand::thread_rng().fill(&mut token_bytes);
|
||||||
let token = hex::encode(token_bytes);
|
let token = hex::encode(token_bytes);
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
let mut payload = serde_json::json!({
|
||||||
"did": did,
|
"did": did,
|
||||||
"onion": onion,
|
"onion": onion,
|
||||||
"pubkey": pubkey,
|
"pubkey": pubkey,
|
||||||
"token": token,
|
"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 json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||||
let code = format!(
|
let code = format!(
|
||||||
"fed1:{}",
|
"fed1:{}",
|
||||||
@ -39,6 +58,7 @@ pub async fn create_invite(
|
|||||||
pubkey: pubkey.to_string(),
|
pubkey: pubkey.to_string(),
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
accepted: false,
|
accepted: false,
|
||||||
|
fips_npub: fips_npub.map(|s| s.to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut invites = load_invites(data_dir).await?;
|
let mut invites = load_invites(data_dir).await?;
|
||||||
@ -49,7 +69,7 @@ pub async fn create_invite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an invite code into its components.
|
/// 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;
|
use base64::Engine;
|
||||||
|
|
||||||
let encoded = code
|
let encoded = code
|
||||||
@ -79,8 +99,18 @@ pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||||
.to_string();
|
.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.
|
/// 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_did: &str,
|
||||||
local_onion: &str,
|
local_onion: &str,
|
||||||
local_pubkey: &str,
|
local_pubkey: &str,
|
||||||
|
local_fips_npub: Option<&str>,
|
||||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||||
) -> Result<FederatedNode> {
|
) -> 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
|
// Make accept idempotent: drop any existing entry that conflicts with
|
||||||
// this invite — same DID (same node, refreshing the link), same onion
|
// 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(),
|
added_at: chrono::Utc::now().to_rfc3339(),
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
last_state: None,
|
last_state: None,
|
||||||
|
fips_npub: fips_npub.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
add_node(data_dir, node.clone()).await?;
|
add_node(data_dir, node.clone()).await?;
|
||||||
@ -138,11 +176,20 @@ pub async fn accept_invite(
|
|||||||
pubkey: node.pubkey.clone(),
|
pubkey: node.pubkey.clone(),
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
fips_npub,
|
||||||
});
|
});
|
||||||
save_invites(data_dir, &invites).await?;
|
save_invites(data_dir, &invites).await?;
|
||||||
|
|
||||||
// Notify remote node (best-effort over Tor)
|
// 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)
|
Ok(node)
|
||||||
}
|
}
|
||||||
@ -154,6 +201,7 @@ async fn notify_join(
|
|||||||
local_did: &str,
|
local_did: &str,
|
||||||
local_onion: &str,
|
local_onion: &str,
|
||||||
local_pubkey: &str,
|
local_pubkey: &str,
|
||||||
|
local_fips_npub: Option<&str>,
|
||||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let host = if remote_onion.ends_with(".onion") {
|
let host = if remote_onion.ends_with(".onion") {
|
||||||
@ -164,17 +212,26 @@ async fn notify_join(
|
|||||||
let url = format!("http://{}/rpc/v1", host);
|
let url = format!("http://{}/rpc/v1", host);
|
||||||
|
|
||||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
// 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 sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||||
let signature = sign_fn(sign_data.as_bytes());
|
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!({
|
let body = serde_json::json!({
|
||||||
"method": "federation.peer-joined",
|
"method": "federation.peer-joined",
|
||||||
"params": {
|
"params": params,
|
||||||
"did": local_did,
|
|
||||||
"onion": local_onion,
|
|
||||||
"pubkey": local_pubkey,
|
|
||||||
"signature": signature,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let proxy =
|
let proxy =
|
||||||
@ -197,16 +254,48 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_create_and_parse_invite() {
|
async fn test_create_and_parse_invite() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(code.starts_with("fed1:"));
|
assert!(code.starts_with("fed1:"));
|
||||||
|
|
||||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
let parsed = parse_invite(&code).unwrap();
|
||||||
assert_eq!(did, "did:key:z1");
|
assert_eq!(parsed.did, "did:key:z1");
|
||||||
assert_eq!(onion, "test.onion");
|
assert_eq!(parsed.onion, "test.onion");
|
||||||
assert_eq!(pubkey, "aabbcc");
|
assert_eq!(parsed.pubkey, "aabbcc");
|
||||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
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]
|
#[test]
|
||||||
@ -218,9 +307,15 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_accept_invite_creates_node() {
|
async fn test_accept_invite_creates_node() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
let code = create_invite(
|
||||||
.await
|
dir.path(),
|
||||||
.unwrap();
|
"did:key:zRemote",
|
||||||
|
"remote.onion",
|
||||||
|
"remotepub",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Accept from a different "local" perspective
|
// Accept from a different "local" perspective
|
||||||
let dir2 = tempfile::tempdir().unwrap();
|
let dir2 = tempfile::tempdir().unwrap();
|
||||||
@ -230,6 +325,7 @@ mod tests {
|
|||||||
"did:key:zLocal",
|
"did:key:zLocal",
|
||||||
"local.onion",
|
"local.onion",
|
||||||
"localpub",
|
"localpub",
|
||||||
|
None,
|
||||||
|_| "test-sig".to_string(),
|
|_| "test-sig".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -242,6 +338,36 @@ mod tests {
|
|||||||
assert_eq!(nodes.len(), 1);
|
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]
|
#[tokio::test]
|
||||||
async fn test_accept_invite_is_idempotent() {
|
async fn test_accept_invite_is_idempotent() {
|
||||||
// Re-accepting the same invite is a no-op refresh — it must not
|
// 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
|
// UI relies on: clicking "Join" twice or refreshing after an
|
||||||
// identity rotation always converges to one entry.
|
// identity rotation always converges to one entry.
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
let code = create_invite(
|
||||||
.await
|
dir.path(),
|
||||||
.unwrap();
|
"did:key:zRemote",
|
||||||
|
"remote.onion",
|
||||||
|
"remotepub",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let dir2 = tempfile::tempdir().unwrap();
|
let dir2 = tempfile::tempdir().unwrap();
|
||||||
accept_invite(
|
accept_invite(
|
||||||
@ -260,6 +392,7 @@ mod tests {
|
|||||||
"did:key:zLocal",
|
"did:key:zLocal",
|
||||||
"local.onion",
|
"local.onion",
|
||||||
"localpub",
|
"localpub",
|
||||||
|
None,
|
||||||
|_| "test-sig".to_string(),
|
|_| "test-sig".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -271,6 +404,7 @@ mod tests {
|
|||||||
"did:key:zLocal",
|
"did:key:zLocal",
|
||||||
"local.onion",
|
"local.onion",
|
||||||
"localpub",
|
"localpub",
|
||||||
|
None,
|
||||||
|_| "test-sig".to_string(),
|
|_| "test-sig".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -172,6 +172,7 @@ mod tests {
|
|||||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
last_state: None,
|
last_state: None,
|
||||||
|
fips_npub: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,10 @@ pub struct FederatedNode {
|
|||||||
pub last_seen: Option<String>,
|
pub last_seen: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_state: Option<NodeStateSnapshot>,
|
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.
|
/// State snapshot received from a federated peer during sync.
|
||||||
@ -85,6 +89,9 @@ pub struct FederationInvite {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub accepted: bool,
|
pub accepted: bool,
|
||||||
|
/// Inviter's FIPS mesh npub if advertised in the code.
|
||||||
|
#[serde(default)]
|
||||||
|
pub fips_npub: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -111,12 +118,28 @@ mod tests {
|
|||||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
last_state: None,
|
last_state: None,
|
||||||
|
fips_npub: None,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&node).unwrap();
|
let json = serde_json::to_string(&node).unwrap();
|
||||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed.did, "did:key:zABC");
|
assert_eq!(parsed.did, "did:key:zABC");
|
||||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||||
assert!(parsed.last_state.is_none());
|
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]
|
#[test]
|
||||||
|
|||||||
144
core/archipelago/src/fips/config.rs
Normal file
144
core/archipelago/src/fips/config.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
140
core/archipelago/src/fips/mod.rs
Normal file
140
core/archipelago/src/fips/mod.rs
Normal 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-dir–relative identity directory path.
|
||||||
|
/// Mirrors the convention used elsewhere in the codebase so callers don't
|
||||||
|
/// have to repeat the `.join("identity")` each time.
|
||||||
|
pub fn identity_dir_from(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join("identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_status_reports_no_key_pre_onboarding() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let id_dir = dir.path().join("identity");
|
||||||
|
tokio::fs::create_dir_all(&id_dir).await.unwrap();
|
||||||
|
|
||||||
|
let status = FipsStatus::query(&id_dir).await;
|
||||||
|
assert!(!status.key_present, "no key before onboarding");
|
||||||
|
assert!(status.npub.is_none());
|
||||||
|
// `installed`, `service_state`, `version` depend on the host and are
|
||||||
|
// not asserted here — query() must return cleanly regardless.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_identity_dir_from() {
|
||||||
|
let data = Path::new("/var/lib/archipelago");
|
||||||
|
assert_eq!(
|
||||||
|
identity_dir_from(data),
|
||||||
|
Path::new("/var/lib/archipelago/identity")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_constants_have_expected_shape() {
|
||||||
|
assert!(SERVICE_UNIT.ends_with(".service"));
|
||||||
|
assert!(DAEMON_CONFIG_PATH.starts_with('/'));
|
||||||
|
assert!(DAEMON_KEY_PATH.starts_with('/'));
|
||||||
|
assert_eq!(UPSTREAM_REPO, "jmcorgan/fips");
|
||||||
|
}
|
||||||
|
}
|
||||||
120
core/archipelago/src/fips/service.rs
Normal file
120
core/archipelago/src/fips/service.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
core/archipelago/src/fips/update.rs
Normal file
130
core/archipelago/src/fips/update.rs
Normal 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 1–2 (the "is there anything newer?" query) and stubs
|
||||||
|
//! out 3–5 so the RPC/UI can wire through. The apply path returns a
|
||||||
|
//! clear "not yet available" error until the artefact source is decided.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{service, UPSTREAM_REPO};
|
||||||
|
|
||||||
|
const GITHUB_API: &str = "https://api.github.com";
|
||||||
|
const USER_AGENT: &str = "archipelago-fips-updater";
|
||||||
|
|
||||||
|
/// Result of `check_update()` — what the dashboard renders.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateCheck {
|
||||||
|
/// Currently installed daemon version (from `fipsctl --version`).
|
||||||
|
pub current: Option<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 ¤t {
|
||||||
|
Some(v) => !v.contains(&short),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let notes = if update_available {
|
||||||
|
format!(
|
||||||
|
"Upstream main is at {}; installed: {}",
|
||||||
|
short,
|
||||||
|
current.as_deref().unwrap_or("not installed")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Up to date ({})", short)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(UpdateCheck {
|
||||||
|
current,
|
||||||
|
latest_commit: short,
|
||||||
|
update_available,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the update. Stubbed pending a stable artefact source for
|
||||||
|
/// per-commit builds of the `fips` debian package. When this is wired
|
||||||
|
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
|
||||||
|
pub async fn apply() -> Result<()> {
|
||||||
|
anyhow::bail!(
|
||||||
|
"FIPS auto-apply not yet wired — upstream does not publish stable \
|
||||||
|
per-commit .deb artefacts for main. Upgrade manually for now: \
|
||||||
|
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_latest_main_sha() -> Result<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ use tokio::fs;
|
|||||||
|
|
||||||
const NODE_KEY_FILE: &str = "node_key";
|
const NODE_KEY_FILE: &str = "node_key";
|
||||||
const NODE_KEY_PUB_FILE: &str = "node_key.pub";
|
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).
|
/// Persistent node identity (Ed25519 keypair).
|
||||||
/// Survives reboots; used for signing, verification, and node address.
|
/// 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).
|
/// Create node identity from a BIP-39 master seed (deterministic derivation).
|
||||||
/// Writes derived key to disk in the same format as load_or_create.
|
/// 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> {
|
pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result<Self> {
|
||||||
fs::create_dir_all(identity_dir)
|
fs::create_dir_all(identity_dir)
|
||||||
.await
|
.await
|
||||||
@ -101,6 +105,8 @@ impl NodeIdentity {
|
|||||||
&pubkey_hex[..16]
|
&pubkey_hex[..16]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
write_fips_key_from_seed(identity_dir, seed).await?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
signing_key,
|
signing_key,
|
||||||
_identity_dir: identity_dir.to_path_buf(),
|
_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.
|
/// Convert Ed25519 pubkey (hex) to did:key format.
|
||||||
/// Used by RPC when identity is loaded from state.
|
/// Used by RPC when identity is loaded from state.
|
||||||
pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result<String> {
|
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:web:example.com").is_err());
|
||||||
assert!(pubkey_bytes_from_did_key("did:key:invalid").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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ mod data_model;
|
|||||||
mod disk_monitor;
|
mod disk_monitor;
|
||||||
mod electrs_status;
|
mod electrs_status;
|
||||||
mod federation;
|
mod federation;
|
||||||
|
mod fips;
|
||||||
mod health_monitor;
|
mod health_monitor;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod identity_manager;
|
mod identity_manager;
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
//! → Master Seed (64 bytes)
|
//! → Master Seed (64 bytes)
|
||||||
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
|
||||||
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr 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
|
//! ├── 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/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
|
||||||
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
|
//! ├── 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.
|
// HKDF info strings for domain-separated key derivation.
|
||||||
const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
|
||||||
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/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";
|
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||||
|
|
||||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||||
@ -103,6 +105,16 @@ pub fn derive_node_nostr_key(seed: &MasterSeed) -> Result<nostr_sdk::Keys> {
|
|||||||
Ok(nostr_sdk::Keys::new(secret))
|
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.
|
/// Derive an identity's Nostr secp256k1 key via BIP-32.
|
||||||
/// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant).
|
/// Path: m/44'/1237'/0'/0/{index} (NIP-06 compliant).
|
||||||
pub fn derive_nostr_identity_key(seed: &MasterSeed, index: u32) -> Result<nostr_sdk::Keys> {
|
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());
|
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]
|
#[test]
|
||||||
fn test_bitcoin_xprv_deterministic() {
|
fn test_bitcoin_xprv_deterministic() {
|
||||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
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_ed = derive_node_ed25519(&seed).unwrap();
|
||||||
let node_nostr = derive_node_nostr_key(&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_ed = derive_identity_ed25519(&seed, 0).unwrap();
|
||||||
let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap();
|
let id0_nostr = derive_nostr_identity_key(&seed, 0).unwrap();
|
||||||
let _btc = derive_bitcoin_xprv(&seed).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 node_ed_hex = hex::encode(node_ed.verifying_key().as_bytes());
|
||||||
let id0_ed_hex = hex::encode(id0_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 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 id0_nostr_hex = id0_nostr.public_key().to_hex();
|
||||||
let lnd_hex = hex::encode(lnd);
|
let lnd_hex = hex::encode(lnd);
|
||||||
|
|
||||||
@ -498,6 +531,7 @@ mod tests {
|
|||||||
&node_ed_hex,
|
&node_ed_hex,
|
||||||
&id0_ed_hex,
|
&id0_ed_hex,
|
||||||
&node_nostr_hex,
|
&node_nostr_hex,
|
||||||
|
&fips_hex,
|
||||||
&id0_nostr_hex,
|
&id0_nostr_hex,
|
||||||
&lnd_hex,
|
&lnd_hex,
|
||||||
];
|
];
|
||||||
|
|||||||
75
core/archipelago/src/transport/fips.rs
Normal file
75
core/archipelago/src/transport/fips.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,17 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
//! Transport abstraction layer for Archipelago node-to-node communication.
|
//! Transport abstraction layer for Archipelago node-to-node communication.
|
||||||
//!
|
//!
|
||||||
//! Unifies mesh radio (LoRa), LAN (mDNS), and Tor under a common trait.
|
//! Unifies mesh radio (LoRa), LAN (mDNS), FIPS (Free Internetworking Peering
|
||||||
//! Routes messages to peers via the best available transport with automatic
|
//! System overlay), and Tor under a common trait. Routes messages to peers via
|
||||||
//! fallback: Mesh (priority 1) > LAN (2) > Tor (3).
|
//! 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 chunking;
|
||||||
pub mod delta;
|
pub mod delta;
|
||||||
|
pub mod fips;
|
||||||
pub mod lan;
|
pub mod lan;
|
||||||
pub mod mesh_transport;
|
pub mod mesh_transport;
|
||||||
pub mod tor;
|
pub mod tor;
|
||||||
@ -31,7 +36,8 @@ use tracing::{info, warn};
|
|||||||
pub enum TransportKind {
|
pub enum TransportKind {
|
||||||
Mesh = 1,
|
Mesh = 1,
|
||||||
Lan = 2,
|
Lan = 2,
|
||||||
Tor = 3,
|
Fips = 3,
|
||||||
|
Tor = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for TransportKind {
|
impl std::fmt::Display for TransportKind {
|
||||||
@ -39,6 +45,7 @@ impl std::fmt::Display for TransportKind {
|
|||||||
match self {
|
match self {
|
||||||
Self::Mesh => write!(f, "mesh"),
|
Self::Mesh => write!(f, "mesh"),
|
||||||
Self::Lan => write!(f, "lan"),
|
Self::Lan => write!(f, "lan"),
|
||||||
|
Self::Fips => write!(f, "fips"),
|
||||||
Self::Tor => write!(f, "tor"),
|
Self::Tor => write!(f, "tor"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,6 +84,7 @@ pub trait NodeTransport: Send + Sync {
|
|||||||
/// For Tor: address is an onion hostname.
|
/// For Tor: address is an onion hostname.
|
||||||
/// For Mesh: address is a contact_id as string.
|
/// For Mesh: address is a contact_id as string.
|
||||||
/// For LAN: address is "ip:port".
|
/// For LAN: address is "ip:port".
|
||||||
|
/// For FIPS: address is the peer's FIPS npub (bech32); implementation maps to fd00::/8.
|
||||||
fn send<'a>(
|
fn send<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
address: &'a str,
|
address: &'a str,
|
||||||
@ -115,6 +123,8 @@ pub struct PeerRecord {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lan_address: Option<String>,
|
pub lan_address: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub fips_npub: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub onion_address: Option<String>,
|
pub onion_address: Option<String>,
|
||||||
|
|
||||||
// Freshness timestamps (RFC 3339)
|
// Freshness timestamps (RFC 3339)
|
||||||
@ -123,6 +133,8 @@ pub struct PeerRecord {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_lan: Option<String>,
|
pub last_lan: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub last_fips: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub last_tor: Option<String>,
|
pub last_tor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,16 +144,18 @@ impl PeerRecord {
|
|||||||
match kind {
|
match kind {
|
||||||
TransportKind::Mesh => self.mesh_contact_id.map(|id| id.to_string()),
|
TransportKind::Mesh => self.mesh_contact_id.map(|id| id.to_string()),
|
||||||
TransportKind::Lan => self.lan_address.clone(),
|
TransportKind::Lan => self.lan_address.clone(),
|
||||||
|
TransportKind::Fips => self.fips_npub.clone(),
|
||||||
TransportKind::Tor => self.onion_address.clone(),
|
TransportKind::Tor => self.onion_address.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the last-seen timestamp for a transport is fresh enough.
|
/// 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 {
|
pub fn is_fresh(&self, kind: TransportKind) -> bool {
|
||||||
let timestamp = match kind {
|
let timestamp = match kind {
|
||||||
TransportKind::Mesh => self.last_mesh.as_deref(),
|
TransportKind::Mesh => self.last_mesh.as_deref(),
|
||||||
TransportKind::Lan => self.last_lan.as_deref(),
|
TransportKind::Lan => self.last_lan.as_deref(),
|
||||||
|
TransportKind::Fips => self.last_fips.as_deref(),
|
||||||
TransportKind::Tor => self.last_tor.as_deref(),
|
TransportKind::Tor => self.last_tor.as_deref(),
|
||||||
};
|
};
|
||||||
let Some(ts) = timestamp else {
|
let Some(ts) = timestamp else {
|
||||||
@ -155,6 +169,7 @@ impl PeerRecord {
|
|||||||
let age = chrono::Utc::now().signed_duration_since(parsed);
|
let age = chrono::Utc::now().signed_duration_since(parsed);
|
||||||
let max_age = match kind {
|
let max_age = match kind {
|
||||||
TransportKind::Mesh | TransportKind::Lan => chrono::Duration::minutes(5),
|
TransportKind::Mesh | TransportKind::Lan => chrono::Duration::minutes(5),
|
||||||
|
TransportKind::Fips => chrono::Duration::minutes(30),
|
||||||
TransportKind::Tor => chrono::Duration::hours(1),
|
TransportKind::Tor => chrono::Duration::hours(1),
|
||||||
};
|
};
|
||||||
age < max_age
|
age < max_age
|
||||||
@ -169,6 +184,9 @@ impl PeerRecord {
|
|||||||
if self.lan_address.is_some() {
|
if self.lan_address.is_some() {
|
||||||
result.push(TransportKind::Lan);
|
result.push(TransportKind::Lan);
|
||||||
}
|
}
|
||||||
|
if self.fips_npub.is_some() {
|
||||||
|
result.push(TransportKind::Fips);
|
||||||
|
}
|
||||||
if self.onion_address.is_some() {
|
if self.onion_address.is_some() {
|
||||||
result.push(TransportKind::Tor);
|
result.push(TransportKind::Tor);
|
||||||
}
|
}
|
||||||
@ -239,9 +257,11 @@ impl PeerRegistry {
|
|||||||
source: Some(source.clone()),
|
source: Some(source.clone()),
|
||||||
mesh_contact_id: None,
|
mesh_contact_id: None,
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
|
fips_npub: None,
|
||||||
onion_address: None,
|
onion_address: None,
|
||||||
last_mesh: None,
|
last_mesh: None,
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
});
|
});
|
||||||
// Update pubkey if it changed
|
// 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.
|
/// Set the display name for a peer.
|
||||||
pub async fn set_name(&self, did: &str, name: &str) {
|
pub async fn set_name(&self, did: &str, name: &str) {
|
||||||
let mut peers = self.peers.write().await;
|
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 peer.onion_address.is_some() {
|
||||||
if let Some(t) = self
|
if let Some(t) = self
|
||||||
.transports
|
.transports
|
||||||
@ -446,7 +486,31 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_transport_kind_ordering() {
|
fn test_transport_kind_ordering() {
|
||||||
assert!(TransportKind::Mesh < TransportKind::Lan);
|
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]
|
#[test]
|
||||||
@ -459,9 +523,11 @@ mod tests {
|
|||||||
source: None,
|
source: None,
|
||||||
mesh_contact_id: Some(42),
|
mesh_contact_id: Some(42),
|
||||||
lan_address: Some("192.168.1.100:5678".to_string()),
|
lan_address: Some("192.168.1.100:5678".to_string()),
|
||||||
|
fips_npub: None,
|
||||||
onion_address: Some("abc123.onion".to_string()),
|
onion_address: Some("abc123.onion".to_string()),
|
||||||
last_mesh: None,
|
last_mesh: None,
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -488,9 +554,11 @@ mod tests {
|
|||||||
source: None,
|
source: None,
|
||||||
mesh_contact_id: Some(1),
|
mesh_contact_id: Some(1),
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
|
fips_npub: None,
|
||||||
onion_address: Some("test.onion".to_string()),
|
onion_address: Some("test.onion".to_string()),
|
||||||
last_mesh: None,
|
last_mesh: None,
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
};
|
};
|
||||||
let transports = peer.available_transports();
|
let transports = peer.available_transports();
|
||||||
@ -507,9 +575,11 @@ mod tests {
|
|||||||
source: None,
|
source: None,
|
||||||
mesh_contact_id: Some(1),
|
mesh_contact_id: Some(1),
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
|
fips_npub: None,
|
||||||
onion_address: None,
|
onion_address: None,
|
||||||
last_mesh: None,
|
last_mesh: None,
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
};
|
};
|
||||||
// No timestamp = considered fresh (allows first attempt)
|
// No timestamp = considered fresh (allows first attempt)
|
||||||
@ -526,9 +596,11 @@ mod tests {
|
|||||||
source: None,
|
source: None,
|
||||||
mesh_contact_id: Some(1),
|
mesh_contact_id: Some(1),
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
|
fips_npub: None,
|
||||||
onion_address: None,
|
onion_address: None,
|
||||||
last_mesh: Some(chrono::Utc::now().to_rfc3339()),
|
last_mesh: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
};
|
};
|
||||||
assert!(peer.is_fresh(TransportKind::Mesh));
|
assert!(peer.is_fresh(TransportKind::Mesh));
|
||||||
@ -545,9 +617,11 @@ mod tests {
|
|||||||
source: None,
|
source: None,
|
||||||
mesh_contact_id: Some(1),
|
mesh_contact_id: Some(1),
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
|
fips_npub: None,
|
||||||
onion_address: None,
|
onion_address: None,
|
||||||
last_mesh: Some(stale.to_rfc3339()),
|
last_mesh: Some(stale.to_rfc3339()),
|
||||||
last_lan: None,
|
last_lan: None,
|
||||||
|
last_fips: None,
|
||||||
last_tor: None,
|
last_tor: None,
|
||||||
};
|
};
|
||||||
// 10 minutes old > 5 minute mesh freshness threshold
|
// 10 minutes old > 5 minute mesh freshness threshold
|
||||||
|
|||||||
@ -239,6 +239,27 @@ if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
|
|||||||
|
|
||||||
# Create a Dockerfile for building the rootfs
|
# Create a Dockerfile for building the rootfs
|
||||||
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
|
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
|
FROM debian:trixie
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
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 update && apt-get install -y --no-install-recommends tailscale && \
|
||||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
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
|
# Configure locale
|
||||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
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 nostr-vpn.service /etc/systemd/system/nostr-vpn.service
|
||||||
COPY archipelago-wg.service /etc/systemd/system/archipelago-wg.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-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.service /etc/systemd/system/nostr-relay.service
|
||||||
COPY nostr-relay-config.toml /etc/archipelago/nostr-relay-config.toml
|
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
|
# archipelago-wg + wg-address: enabled by first-boot after WG key is generated
|
||||||
# nostr-vpn: enabled by first-boot after Nostr identity 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)
|
# (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
|
# Remove policy-rc.d so services can start on first boot
|
||||||
RUN rm -f /usr/sbin/policy-rc.d
|
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"
|
cp "$SCRIPT_DIR/configs/archipelago-wg-address.service" "$WORK_DIR/archipelago-wg-address.service"
|
||||||
echo " Using archipelago-wg-address.service from configs/"
|
echo " Using archipelago-wg-address.service from configs/"
|
||||||
fi
|
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)
|
# Copy private Nostr relay service (native, for NostrVPN signaling)
|
||||||
if [ -f "$SCRIPT_DIR/configs/nostr-relay.service" ]; then
|
if [ -f "$SCRIPT_DIR/configs/nostr-relay.service" ]; then
|
||||||
|
|||||||
21
image-recipe/configs/archipelago-fips.service
Normal file
21
image-recipe/configs/archipelago-fips.service
Normal 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
|
||||||
160
neode-ui/src/views/home/HomeNetworkCard.vue
Normal file
160
neode-ui/src/views/home/HomeNetworkCard.vue
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user