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:
co-authored by
Claude Opus 4.7
parent
f04804ae25
commit
c1cfca6212
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user