- R1: Add health RPC endpoint with crash recovery status, uptime, and version - R2: Wrap all 5 Nostr client.connect() calls in 10s timeout - R3: Make backup restore atomic with staging dir and rollback on failure - I1: Add rate limiting, body size, and proxy timeouts to unauthenticated nginx endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
415 lines
13 KiB
Rust
415 lines
13 KiB
Rust
//! Encrypted peer handshake via Nostr NIP-44.
|
|
//!
|
|
//! Instead of publishing onion addresses publicly on relays, nodes exchange
|
|
//! them privately via NIP-44 encrypted DMs:
|
|
//!
|
|
//! 1. Node publishes presence-only event (DID + Nostr pubkey, NO onion address)
|
|
//! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey
|
|
//! containing A's onion address + Ed25519 node pubkey
|
|
//! 3. Node B auto-responds with its own onion address + pubkey
|
|
//! 4. Both nodes add each other as known peers
|
|
//!
|
|
//! Uses NIP-44 (ChaCha20-Poly1305) for encryption, kind 4 for DMs.
|
|
|
|
use anyhow::{Context, Result};
|
|
use nostr_sdk::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tokio::fs;
|
|
use tracing::warn;
|
|
|
|
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
|
|
|
/// Message types for the encrypted handshake protocol
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum HandshakeMessage {
|
|
#[serde(rename = "connect-request")]
|
|
ConnectRequest {
|
|
onion: String,
|
|
node_pubkey: String,
|
|
did: String,
|
|
version: String,
|
|
name: Option<String>,
|
|
},
|
|
#[serde(rename = "connect-response")]
|
|
ConnectResponse {
|
|
onion: String,
|
|
node_pubkey: String,
|
|
did: String,
|
|
version: String,
|
|
name: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Result of polling for incoming handshake messages
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct IncomingHandshake {
|
|
/// Sender's Nostr public key in hex
|
|
pub from_nostr_pubkey: String,
|
|
/// Sender's Nostr public key in bech32 npub format (NIP-19)
|
|
pub from_nostr_npub: String,
|
|
pub message: HandshakeMessage,
|
|
pub timestamp: String,
|
|
}
|
|
|
|
/// Parse "host:port" to SocketAddr.
|
|
fn parse_proxy_addr(s: &str) -> Option<SocketAddr> {
|
|
s.trim().parse().ok()
|
|
}
|
|
|
|
/// Load existing Nostr keys (secp256k1). Returns None if no keys exist.
|
|
async fn load_nostr_keys(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))
|
|
}
|
|
|
|
/// Build a Nostr client with optional Tor proxy.
|
|
fn build_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 presence-only event to Nostr relays.
|
|
/// Content: { did, nostr_pubkey, version } — NO onion address.
|
|
/// Uses NIP-33 replaceable events (kind 30078) with d-tag "archipelago-node".
|
|
pub async fn publish_presence(
|
|
identity_dir: &Path,
|
|
did: &str,
|
|
version: &str,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<()> {
|
|
if relays.is_empty() {
|
|
anyhow::bail!("No relays configured");
|
|
}
|
|
|
|
let keys = load_nostr_keys(identity_dir)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("No Nostr keys — generate them first"))?;
|
|
|
|
let nostr_pubkey = keys.public_key().to_hex();
|
|
let nostr_npub = keys.public_key().to_bech32().unwrap_or_default();
|
|
let client = build_client(keys, tor_proxy)?;
|
|
|
|
let content = serde_json::json!({
|
|
"did": did,
|
|
"nostr_pubkey": nostr_pubkey,
|
|
"nostr_npub": nostr_npub,
|
|
"version": version,
|
|
// No onion address — exchanged only via encrypted DM
|
|
})
|
|
.to_string();
|
|
|
|
for url in relays {
|
|
let _ = client.add_relay(url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
|
.tag(Tag::identifier("archipelago-node"));
|
|
let _ = client.send_event_builder(builder).await;
|
|
client.disconnect().await;
|
|
|
|
tracing::info!("📡 Published presence (no onion) to {} relays", relays.len());
|
|
Ok(())
|
|
}
|
|
|
|
/// Discover other Archipelago nodes (presence-only — no onion addresses).
|
|
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DiscoverableNode {
|
|
/// Nostr secp256k1 public key in hex
|
|
pub nostr_pubkey: String,
|
|
/// Nostr public key in bech32 npub format (NIP-19)
|
|
pub nostr_npub: String,
|
|
pub did: String,
|
|
pub version: String,
|
|
}
|
|
|
|
pub async fn discover_nodes(
|
|
_identity_dir: &Path,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<Vec<DiscoverableNode>> {
|
|
if relays.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let anon_keys = Keys::generate();
|
|
let client = build_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() {
|
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let filter = Filter::new()
|
|
.kind(Kind::Custom(30078))
|
|
.identifier("archipelago-node")
|
|
.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) {
|
|
let nostr_pubkey = content
|
|
.get("nostr_pubkey")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
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();
|
|
|
|
// Skip entries that still have node_address (legacy public format)
|
|
if content.get("node_address").is_some() {
|
|
continue;
|
|
}
|
|
|
|
if !nostr_pubkey.is_empty() {
|
|
// Derive npub (bech32 NIP-19) from hex
|
|
let nostr_npub = nostr_sdk::PublicKey::from_hex(&nostr_pubkey)
|
|
.ok()
|
|
.and_then(|pk| pk.to_bech32().ok())
|
|
.unwrap_or_default();
|
|
nodes.push(DiscoverableNode {
|
|
nostr_pubkey,
|
|
nostr_npub,
|
|
did,
|
|
version,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(nodes)
|
|
}
|
|
|
|
/// Send an encrypted connection request to a peer's Nostr pubkey.
|
|
/// Uses NIP-44 encrypted DM (kind 4) containing our onion address.
|
|
pub async fn send_connect_request(
|
|
identity_dir: &Path,
|
|
recipient_nostr_pubkey: &str,
|
|
our_onion: &str,
|
|
our_node_pubkey: &str,
|
|
our_did: &str,
|
|
our_version: &str,
|
|
our_name: Option<&str>,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<()> {
|
|
if relays.is_empty() {
|
|
anyhow::bail!("No relays configured");
|
|
}
|
|
|
|
let keys = load_nostr_keys(identity_dir)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
|
|
|
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
|
.context("Invalid recipient Nostr pubkey")?;
|
|
|
|
let msg = HandshakeMessage::ConnectRequest {
|
|
onion: our_onion.to_string(),
|
|
node_pubkey: our_node_pubkey.to_string(),
|
|
did: our_did.to_string(),
|
|
version: our_version.to_string(),
|
|
name: our_name.map(String::from),
|
|
};
|
|
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
|
|
|
// NIP-44 encrypt
|
|
let encrypted = nip44::encrypt(
|
|
keys.secret_key(),
|
|
&recipient_pk,
|
|
&plaintext,
|
|
nip44::Version::V2,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?;
|
|
|
|
let client = build_client(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() {
|
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
// Kind 4 encrypted DM with p-tag for recipient
|
|
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
|
.tag(Tag::public_key(recipient_pk));
|
|
let _ = client.send_event_builder(builder).await;
|
|
client.disconnect().await;
|
|
|
|
tracing::info!(
|
|
"🤝 Sent encrypted connect request to {}...{}",
|
|
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
|
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Send an encrypted connection response to a peer.
|
|
pub async fn send_connect_response(
|
|
identity_dir: &Path,
|
|
recipient_nostr_pubkey: &str,
|
|
our_onion: &str,
|
|
our_node_pubkey: &str,
|
|
our_did: &str,
|
|
our_version: &str,
|
|
our_name: Option<&str>,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<()> {
|
|
if relays.is_empty() {
|
|
anyhow::bail!("No relays configured");
|
|
}
|
|
|
|
let keys = load_nostr_keys(identity_dir)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
|
|
|
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
|
.context("Invalid recipient Nostr pubkey")?;
|
|
|
|
let msg = HandshakeMessage::ConnectResponse {
|
|
onion: our_onion.to_string(),
|
|
node_pubkey: our_node_pubkey.to_string(),
|
|
did: our_did.to_string(),
|
|
version: our_version.to_string(),
|
|
name: our_name.map(String::from),
|
|
};
|
|
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
|
|
|
let encrypted = nip44::encrypt(
|
|
keys.secret_key(),
|
|
&recipient_pk,
|
|
&plaintext,
|
|
nip44::Version::V2,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?;
|
|
|
|
let client = build_client(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() {
|
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
|
.tag(Tag::public_key(recipient_pk));
|
|
let _ = client.send_event_builder(builder).await;
|
|
client.disconnect().await;
|
|
|
|
tracing::info!(
|
|
"🤝 Sent encrypted connect response to {}...{}",
|
|
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
|
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Poll relays for incoming encrypted handshake DMs addressed to us.
|
|
/// Returns new handshake messages since `since` timestamp.
|
|
pub async fn poll_handshakes(
|
|
identity_dir: &Path,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
since: Option<Timestamp>,
|
|
) -> Result<Vec<IncomingHandshake>> {
|
|
if relays.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let keys = load_nostr_keys(identity_dir)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
|
|
|
let our_pk = keys.public_key();
|
|
let client = build_client(keys.clone(), 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() {
|
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
// Query for encrypted DMs addressed to us
|
|
let mut filter = Filter::new()
|
|
.kind(Kind::EncryptedDirectMessage)
|
|
.pubkey(our_pk)
|
|
.limit(50);
|
|
if let Some(ts) = since {
|
|
filter = filter.since(ts);
|
|
}
|
|
|
|
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 handshakes = Vec::new();
|
|
for event in events {
|
|
// Skip our own events
|
|
if event.pubkey == our_pk {
|
|
continue;
|
|
}
|
|
|
|
// Try NIP-44 decryption
|
|
let plaintext = match nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) {
|
|
Ok(pt) => pt,
|
|
Err(_) => continue, // Not a handshake message or wrong key
|
|
};
|
|
|
|
// Try to parse as HandshakeMessage
|
|
if let Ok(msg) = serde_json::from_str::<HandshakeMessage>(&plaintext) {
|
|
let from_npub = event.pubkey.to_bech32().unwrap_or_default();
|
|
handshakes.push(IncomingHandshake {
|
|
from_nostr_pubkey: event.pubkey.to_hex(),
|
|
from_nostr_npub: from_npub,
|
|
message: msg,
|
|
timestamp: event.created_at.to_human_datetime(),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(handshakes)
|
|
}
|