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

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

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

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

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

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

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

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

Version bump: 1.3.5 → 1.4.0

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-18 22:57:51 -04:00
co-authored by Claude Opus 4.7
parent f04804ae25
commit c1cfca6212
22 changed files with 1353 additions and 39 deletions
+133
View File
@@ -10,6 +10,8 @@ use tokio::fs;
const NODE_KEY_FILE: &str = "node_key";
const NODE_KEY_PUB_FILE: &str = "node_key.pub";
const FIPS_KEY_FILE: &str = "fips_key";
const FIPS_KEY_PUB_FILE: &str = "fips_key.pub";
/// Persistent node identity (Ed25519 keypair).
/// Survives reboots; used for signing, verification, and node address.
@@ -72,6 +74,8 @@ impl NodeIdentity {
/// Create node identity from a BIP-39 master seed (deterministic derivation).
/// Writes derived key to disk in the same format as load_or_create.
/// Also derives and persists the FIPS mesh transport key so the
/// FIPS system service can be unmasked after onboarding.
pub async fn from_seed(identity_dir: &Path, seed: &crate::seed::MasterSeed) -> Result<Self> {
fs::create_dir_all(identity_dir)
.await
@@ -101,6 +105,8 @@ impl NodeIdentity {
&pubkey_hex[..16]
);
write_fips_key_from_seed(identity_dir, seed).await?;
Ok(Self {
signing_key,
_identity_dir: identity_dir.to_path_buf(),
@@ -174,6 +180,80 @@ impl NodeIdentity {
}
}
// ─── FIPS mesh transport key ────────────────────────────────────────────
//
// FIPS (Free Internetworking Peering System) uses a secp256k1 keypair as its
// native node identity — independent of the Nostr-node key so compromise of
// one surface cannot impersonate on the other. Both are seed-derived, so the
// FIPS npub is recoverable from the master mnemonic.
//
// Key material is written by `NodeIdentity::from_seed` only. Pre-onboarding
// the files do not exist and `archipelago-fips.service` stays masked.
use nostr_sdk::ToBech32;
async fn write_fips_key_from_seed(
identity_dir: &Path,
seed: &crate::seed::MasterSeed,
) -> Result<()> {
let keys = crate::seed::derive_fips_key(seed)?;
let key_path = identity_dir.join(FIPS_KEY_FILE);
let pub_path = identity_dir.join(FIPS_KEY_PUB_FILE);
fs::write(&key_path, keys.secret_key().to_secret_bytes())
.await
.context("Failed to write FIPS key")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
.await
.context("Failed to set FIPS key permissions")?;
}
fs::write(&pub_path, keys.public_key().to_bytes())
.await
.context("Failed to write FIPS public key")?;
let npub = keys.public_key().to_bech32().unwrap_or_default();
tracing::info!(
"Derived FIPS mesh key from seed (npub: {}...)",
npub.chars().take(20).collect::<String>()
);
Ok(())
}
/// Check whether the FIPS keypair has been materialised on disk.
/// Returns true only after onboarding has written the seed-derived key.
#[allow(dead_code)]
pub fn fips_key_exists(identity_dir: &Path) -> bool {
identity_dir.join(FIPS_KEY_FILE).exists()
}
/// Load the persisted FIPS keypair. Returns `Ok(None)` if onboarding has
/// not yet written the key (pre-onboarding node); errors only on I/O or
/// corruption of an existing file.
#[allow(dead_code)]
pub async fn load_fips_keys(identity_dir: &Path) -> Result<Option<nostr_sdk::Keys>> {
let key_path = identity_dir.join(FIPS_KEY_FILE);
match fs::read(&key_path).await {
Ok(bytes) => {
let secret = nostr_sdk::SecretKey::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("Corrupt FIPS key on disk: {}", e))?;
Ok(Some(nostr_sdk::Keys::new(secret)))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).context("Failed to read FIPS key"),
}
}
/// Return the FIPS npub (bech32) if the key has been materialised.
#[allow(dead_code)]
pub async fn fips_npub(identity_dir: &Path) -> Result<Option<String>> {
Ok(load_fips_keys(identity_dir)
.await?
.and_then(|k| k.public_key().to_bech32().ok()))
}
/// Convert Ed25519 pubkey (hex) to did:key format.
/// Used by RPC when identity is loaded from state.
pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result<String> {
@@ -453,4 +533,57 @@ mod tests {
assert!(pubkey_bytes_from_did_key("did:web:example.com").is_err());
assert!(pubkey_bytes_from_did_key("did:key:invalid").is_err());
}
#[tokio::test]
async fn test_fips_key_absent_before_onboarding() {
let dir = tempfile::tempdir().unwrap();
let id_dir = dir.path().join("identity");
fs::create_dir_all(&id_dir).await.unwrap();
assert!(!fips_key_exists(&id_dir));
assert!(load_fips_keys(&id_dir).await.unwrap().is_none());
assert!(fips_npub(&id_dir).await.unwrap().is_none());
}
#[tokio::test]
async fn test_fips_key_written_from_seed_and_roundtrips() {
use crate::seed::MasterSeed;
const M: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
let dir = tempfile::tempdir().unwrap();
let id_dir = dir.path().join("identity");
let (_, seed) = MasterSeed::from_mnemonic_words(M).unwrap();
let _ = NodeIdentity::from_seed(&id_dir, &seed).await.unwrap();
assert!(fips_key_exists(&id_dir));
let loaded = load_fips_keys(&id_dir).await.unwrap().unwrap();
let expected = crate::seed::derive_fips_key(&seed).unwrap();
assert_eq!(
loaded.public_key().to_hex(),
expected.public_key().to_hex(),
"loaded FIPS key must match seed-derived key"
);
let npub = fips_npub(&id_dir).await.unwrap().unwrap();
assert!(npub.starts_with("npub1"), "got: {}", npub);
}
#[tokio::test]
async fn test_fips_private_key_is_chmod_600() {
#[cfg(unix)]
{
use crate::seed::MasterSeed;
use std::os::unix::fs::PermissionsExt;
const M: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
let dir = tempfile::tempdir().unwrap();
let id_dir = dir.path().join("identity");
let (_, seed) = MasterSeed::from_mnemonic_words(M).unwrap();
NodeIdentity::from_seed(&id_dir, &seed).await.unwrap();
let meta = fs::metadata(id_dir.join(FIPS_KEY_FILE)).await.unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "FIPS private key must be 0600, got {:o}", mode);
}
}
}