Architecture review (all P0+P1 issues now fixed): - Add 10s timeout to 6 bare Nostr client.connect() calls - Pin all 12 crypto deps to exact versions from Cargo.lock - Pin all 15 floating container image tags to exact patch versions - Add CI pipeline (cargo fmt + clippy + tests, frontend type-check + build) Self-update system (git.tx1138.com): - scripts/self-update.sh: pull, build, install, restart with rollback - systemd timer checks daily at 3 AM - update.check RPC does git-based checks when repo is present - update.git-apply RPC triggers self-update from UI - Default update URL changed from GitHub to git.tx1138.com - Git added to ISO package list for fresh installs Documentation: - CHANGELOG v1.3.1 with all changes - README updated (version, update system section) - BETA-PROGRESS session #6 logged - architecture-review.html: 4 issues marked FIXED, 8/12 refactoring done Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
318 lines
12 KiB
Rust
318 lines
12 KiB
Rust
//! Nostr node discovery: publish node identity to relays for peer discovery.
|
|
//! Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node".
|
|
//!
|
|
//! Security: Publishing is opt-in (ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED + relays).
|
|
//! All Nostr traffic routes through Tor when ARCHIPELAGO_NOSTR_TOR_PROXY is set.
|
|
//! Legacy revocation overwrites any previously published data on old public relays.
|
|
|
|
use anyhow::{Context, Result};
|
|
use nostr_sdk::prelude::*;
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tokio::fs;
|
|
|
|
/// Parse "host:port" to SocketAddr. Returns None if invalid.
|
|
fn parse_proxy_addr(s: &str) -> Option<SocketAddr> {
|
|
s.trim().parse().ok()
|
|
}
|
|
|
|
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
|
const NOSTR_PUB_FILE: &str = "nostr_pub";
|
|
const NOSTR_REVOKED_FILE: &str = "nostr_revoked";
|
|
const ARCHIPELAGO_KIND: u64 = 30078;
|
|
const D_TAG: &str = "archipelago-node";
|
|
|
|
/// Relays we previously published to (for one-time revocation overwrite only)
|
|
const LEGACY_RELAYS: &[&str] = &[
|
|
"wss://relay.damus.io",
|
|
"wss://relay.nostr.info",
|
|
];
|
|
|
|
/// Load or create Nostr keys (secp256k1) for node discovery.
|
|
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
|
|
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
|
|
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
|
|
|
|
let keys = if secret_path.exists() {
|
|
let hex_secret = fs::read_to_string(&secret_path)
|
|
.await
|
|
.context("Failed to read Nostr secret")?;
|
|
Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?
|
|
} else {
|
|
let keys = Keys::generate();
|
|
fs::create_dir_all(identity_dir)
|
|
.await
|
|
.context("Failed to create identity dir")?;
|
|
let hex = keys.secret_key().to_secret_hex();
|
|
fs::write(&secret_path, hex)
|
|
.await
|
|
.context("Failed to write Nostr secret")?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600))
|
|
.await
|
|
.context("Failed to set Nostr key permissions")?;
|
|
}
|
|
fs::write(&pub_path, keys.public_key().to_hex())
|
|
.await
|
|
.context("Failed to write Nostr pubkey")?;
|
|
tracing::info!("🔑 Generated Nostr discovery key");
|
|
keys
|
|
};
|
|
|
|
Ok(keys)
|
|
}
|
|
|
|
/// Load Nostr keys only if they exist (does not create). Used for revocation.
|
|
async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>> {
|
|
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
|
|
if !secret_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let hex_secret = fs::read_to_string(&secret_path)
|
|
.await
|
|
.context("Failed to read Nostr secret")?;
|
|
let keys = Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")?;
|
|
Ok(Some(keys))
|
|
}
|
|
|
|
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
|
|
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
|
|
/// Requires tor_proxy to avoid leaking IP to relay operators.
|
|
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
|
|
let client = if let Some(proxy_str) = tor_proxy {
|
|
let addr = parse_proxy_addr(proxy_str)
|
|
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
|
|
let connection = Connection::new()
|
|
.proxy(addr)
|
|
.target(ConnectionTarget::All);
|
|
let opts = ClientOptions::new().connection(connection);
|
|
Client::builder().signer(keys).opts(opts).build()
|
|
} else {
|
|
Client::new(keys)
|
|
};
|
|
Ok(client)
|
|
}
|
|
|
|
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
|
|
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
|
|
/// Only call when tor_proxy is set (avoids IP leak).
|
|
pub async fn publish_node_revocation(
|
|
identity_dir: &Path,
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<()> {
|
|
let Some(keys) = load_nostr_keys_if_exists(identity_dir).await? else {
|
|
return Ok(()); // No keys = never published, nothing to revoke
|
|
};
|
|
|
|
let client = build_nostr_client(keys, tor_proxy)?;
|
|
for url in LEGACY_RELAYS {
|
|
let _ = client.add_relay(*url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
// NIP-33 replaceable: empty content overwrites previous event
|
|
let builder = EventBuilder::new(Kind::Custom(ARCHIPELAGO_KIND as u16), "{}")
|
|
.tag(Tag::identifier(D_TAG));
|
|
let _ = client.send_event_builder(builder).await;
|
|
client.disconnect().await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// If we have Nostr keys but haven't revoked yet, publish revocation to overwrite legacy data.
|
|
/// Uses tor_proxy if set; otherwise tries 127.0.0.1:9050 (archy-tor default). Creates nostr_revoked sentinel.
|
|
pub async fn revoke_if_needed(identity_dir: &Path, tor_proxy: Option<&str>) -> Result<()> {
|
|
let revoked_path = identity_dir.join(NOSTR_REVOKED_FILE);
|
|
if revoked_path.exists() {
|
|
return Ok(());
|
|
}
|
|
if load_nostr_keys_if_exists(identity_dir).await?.is_none() {
|
|
return Ok(());
|
|
}
|
|
// Use configured proxy or Tor default (archy-tor exposes 127.0.0.1:9050)
|
|
let proxy = tor_proxy.or(Some("127.0.0.1:9050"));
|
|
|
|
if let Err(e) = publish_node_revocation(identity_dir, proxy).await {
|
|
tracing::warn!("Nostr revocation (non-fatal): {}", e);
|
|
return Ok(());
|
|
}
|
|
|
|
fs::create_dir_all(identity_dir).await?;
|
|
fs::write(&revoked_path, "").await?;
|
|
tracing::info!("🔒 Nostr discovery data revoked (overwritten on legacy relays)");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get Nostr public key for this node (hex).
|
|
pub async fn get_nostr_pubkey(identity_dir: &Path) -> Result<String> {
|
|
let keys = load_or_create_nostr_keys(identity_dir).await?;
|
|
Ok(keys.public_key().to_hex())
|
|
}
|
|
|
|
/// Sign a 32-byte hash with the node's Nostr Schnorr key.
|
|
pub async fn nostr_sign_hash(identity_dir: &Path, hash_hex: &str) -> Result<String> {
|
|
let keys = load_or_create_nostr_keys(identity_dir).await?;
|
|
let hash_bytes = hex::decode(hash_hex).context("Invalid hash hex")?;
|
|
if hash_bytes.len() != 32 {
|
|
anyhow::bail!("Hash must be 32 bytes");
|
|
}
|
|
let message = nostr_sdk::secp256k1::Message::from_digest(
|
|
hash_bytes.try_into().map_err(|_| anyhow::anyhow!("Invalid hash length"))?,
|
|
);
|
|
let sig = keys.sign_schnorr(&message);
|
|
Ok(sig.to_string())
|
|
}
|
|
|
|
/// Verify that our node's Nostr discovery data was revoked on the legacy relays.
|
|
/// Queries relays for our pubkey's kind 30078 events; if latest has empty content, revocation succeeded.
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct RevocationStatus {
|
|
pub revoked: bool,
|
|
pub nostr_pubkey: String,
|
|
pub latest_content: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
pub async fn verify_revocation(
|
|
identity_dir: &Path,
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<RevocationStatus> {
|
|
let keys = match load_nostr_keys_if_exists(identity_dir).await? {
|
|
Some(k) => k,
|
|
None => {
|
|
return Ok(RevocationStatus {
|
|
revoked: true,
|
|
nostr_pubkey: String::new(),
|
|
latest_content: None,
|
|
error: Some("No Nostr keys - never published".to_string()),
|
|
});
|
|
}
|
|
};
|
|
let pubkey_hex = keys.public_key().to_hex();
|
|
|
|
let anon_keys = Keys::generate();
|
|
let client = build_nostr_client(anon_keys, tor_proxy)?;
|
|
for url in LEGACY_RELAYS {
|
|
let _ = client.add_relay(*url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
|
|
.identifier(D_TAG)
|
|
.author(keys.public_key())
|
|
.limit(10);
|
|
let events = client
|
|
.fetch_events(filter, std::time::Duration::from_secs(15))
|
|
.await
|
|
.map(|e| e.to_vec())
|
|
.unwrap_or_default();
|
|
client.disconnect().await;
|
|
|
|
// NIP-33: latest event wins. fetch_events returns sorted by timestamp desc.
|
|
let mut events: Vec<_> = events.into_iter().collect();
|
|
events.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
|
|
|
let latest = events.into_iter().next();
|
|
let (revoked, latest_content) = match latest {
|
|
None => (true, None),
|
|
Some(ev) => {
|
|
let content = ev.content;
|
|
let is_revoked = content == "{}" || content.is_empty() || !content.contains("node_address");
|
|
(is_revoked, Some(content))
|
|
}
|
|
};
|
|
|
|
Ok(RevocationStatus {
|
|
revoked,
|
|
nostr_pubkey: pubkey_hex,
|
|
latest_content,
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
/// Discovered Archipelago node from Nostr.
|
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
pub struct DiscoveredNode {
|
|
pub did: String,
|
|
pub node_address: String,
|
|
pub onion: String,
|
|
pub pubkey: String,
|
|
pub version: String,
|
|
}
|
|
|
|
/// Query Nostr relays for other Archipelago nodes.
|
|
/// Returns empty if relays is empty (opt-in discovery).
|
|
/// When tor_proxy is set, routes through Tor to prevent IP exposure.
|
|
pub async fn discover_archipelago_nodes(
|
|
identity_dir: &Path,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<Vec<DiscoveredNode>> {
|
|
if relays.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let _keys = load_or_create_nostr_keys(identity_dir).await?;
|
|
let anon_keys = Keys::generate();
|
|
let client = build_nostr_client(anon_keys, tor_proxy)?;
|
|
for url in relays {
|
|
let _ = client.add_relay(url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(ARCHIPELAGO_KIND as u16))
|
|
.identifier(D_TAG)
|
|
.limit(50);
|
|
let events = client
|
|
.fetch_events(filter, std::time::Duration::from_secs(15))
|
|
.await
|
|
.map(|e| e.to_vec())
|
|
.unwrap_or_default();
|
|
client.disconnect().await;
|
|
|
|
let mut nodes = Vec::new();
|
|
for event in events {
|
|
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
|
|
// Skip revoked/empty events
|
|
let node_address = content.get("node_address").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
if node_address.is_empty() {
|
|
continue;
|
|
}
|
|
let did = content.get("did").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let version = content.get("version").and_then(|v| v.as_str()).unwrap_or("0.1").to_string();
|
|
// Parse archipelago://xxx.onion#pubkey
|
|
let (onion, pubkey) = if node_address.starts_with("archipelago://") {
|
|
let rest = node_address.trim_start_matches("archipelago://");
|
|
if let Some((o, p)) = rest.split_once('#') {
|
|
(o.to_string(), p.to_string())
|
|
} else {
|
|
(rest.to_string(), "".to_string())
|
|
}
|
|
} else {
|
|
("".to_string(), "".to_string())
|
|
};
|
|
if !onion.is_empty() {
|
|
nodes.push(DiscoveredNode {
|
|
did,
|
|
node_address,
|
|
onion: onion.trim_end_matches('/').to_string(),
|
|
pubkey,
|
|
version,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(nodes)
|
|
}
|