Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
//! Encrypted peer-discovery handshake via Nostr NIP-44.
|
||||
//!
|
||||
//! Goals:
|
||||
//! - A node can opt in to being *discoverable* on Nostr by publishing a
|
||||
//! minimal presence event (DID + nostr pubkey, NIP-33 kind 30078). The
|
||||
//! presence event NEVER contains the onion address, the federation list,
|
||||
//! the app inventory, or anything beyond a DID + nostr pubkey + version.
|
||||
//! - Another node that sees the presence event can send an encrypted
|
||||
//! `PeerRequest` (NIP-44 DM, kind 4) asking to peer. The request also
|
||||
//! does NOT contain the requester's onion — it carries only the DID
|
||||
//! the requester claims, an optional friendly name, and an optional
|
||||
//! one-line message.
|
||||
//! - The recipient does NOT auto-accept and does NOT auto-respond.
|
||||
//! Instead, the request is queued in `federation::pending` for the
|
||||
//! user to manually approve or reject in the Federation UI.
|
||||
//! - On approval, the recipient generates a one-shot federation invite
|
||||
//! code (which contains *their* onion + pubkey) and ships it back via
|
||||
//! NIP-44 encrypted to the requester's nostr pubkey. The requester's
|
||||
//! poll loop receives the invite, applies it via the existing
|
||||
//! `federation.join` flow, and the two boxes complete the trust
|
||||
//! exchange over Tor — never over a public relay.
|
||||
//!
|
||||
//! Result: the only thing ever visible on a public Nostr relay is the
|
||||
//! presence event (a DID + a npub + a version). Everything actionable
|
||||
//! lives inside NIP-44 ciphertext addressed to a specific nostr pubkey.
|
||||
|
||||
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 exchanged inside NIP-44 encrypted DMs (kind 4).
|
||||
///
|
||||
/// Note: NONE of these variants carry an onion address. The onion is only
|
||||
/// transmitted as part of a `PeerInvite { invite_code }`, where the invite
|
||||
/// code is generated by the local approval flow and points at the local
|
||||
/// federation hidden service. The legacy `connect-request` / `connect-response`
|
||||
/// variants from earlier development are preserved on the deserialize side
|
||||
/// (`#[serde(other)]`-style fallback via untagged Unknown) so that an old
|
||||
/// peer that hasn't been upgraded yet can't crash the parser, but we no
|
||||
/// longer construct or act on them.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HandshakeMessage {
|
||||
/// Inbound peer-discovery request. The sender proves they hold the
|
||||
/// nostr secret key by signing the kind-4 envelope (Nostr does this
|
||||
/// at the protocol layer). They claim a `from_did` here, but we do
|
||||
/// not trust it until the federation invite round-trip completes.
|
||||
#[serde(rename = "peer-request")]
|
||||
PeerRequest {
|
||||
from_did: String,
|
||||
version: String,
|
||||
name: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// Approval reply: contains a one-shot federation invite code
|
||||
/// generated by the approver. The invite code embeds the approver's
|
||||
/// onion + pubkey, but the whole envelope is NIP-44 encrypted to the
|
||||
/// requester's nostr pubkey, so only the requester can read it.
|
||||
#[serde(rename = "peer-invite")]
|
||||
PeerInvite { invite_code: String },
|
||||
/// Rejection reply. Optional one-line reason for the user.
|
||||
#[serde(rename = "peer-reject")]
|
||||
PeerReject { reason: Option<String> },
|
||||
/// Cancellation of a previously-sent `PeerRequest`. Sent by the
|
||||
/// original requester to withdraw the handshake before the target
|
||||
/// has approved/rejected it. The recipient removes the matching
|
||||
/// inbound pending row so it no longer shows in their UI.
|
||||
#[serde(rename = "peer-cancel")]
|
||||
PeerCancel {
|
||||
/// Optional one-line reason (shown in the target's UI if they
|
||||
/// still have the row). Typically empty.
|
||||
reason: 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)
|
||||
}
|
||||
|
||||
/// Encrypt and publish a `HandshakeMessage` to a recipient's nostr pubkey.
|
||||
/// Used by both the request-side (PeerRequest) and the approver-side
|
||||
/// (PeerInvite / PeerReject) flows. The message is wrapped in a NIP-44 v2
|
||||
/// ciphertext addressed to `recipient_nostr_pubkey` and posted as a kind-4
|
||||
/// encrypted DM, so only the holder of that nostr secret key can decrypt
|
||||
/// the contents. Relays only ever see the ciphertext.
|
||||
async fn send_handshake_message(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
msg: &HandshakeMessage,
|
||||
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 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;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
||||
/// include an onion address — the recipient learns nothing that would let
|
||||
/// them dial us directly. They learn only our claimed DID, version,
|
||||
/// optional friendly name, and the optional message.
|
||||
pub async fn send_peer_request(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
message: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerRequest {
|
||||
from_did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
message: message.map(String::from),
|
||||
};
|
||||
send_handshake_message(
|
||||
identity_dir,
|
||||
recipient_nostr_pubkey,
|
||||
&msg,
|
||||
relays,
|
||||
tor_proxy,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"🤝 Sent peer-request to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerInvite` reply containing a one-shot federation invite code.
|
||||
/// The code embeds our onion + pubkey, but the entire envelope is NIP-44
|
||||
/// encrypted to the requester's nostr pubkey, so the onion is only ever
|
||||
/// readable by them.
|
||||
pub async fn send_peer_invite(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
invite_code: &str,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerInvite {
|
||||
invite_code: invite_code.to_string(),
|
||||
};
|
||||
send_handshake_message(
|
||||
identity_dir,
|
||||
recipient_nostr_pubkey,
|
||||
&msg,
|
||||
relays,
|
||||
tor_proxy,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"🤝 Sent peer-invite to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerReject` reply with an optional reason.
|
||||
pub async fn send_peer_reject(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
reason: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerReject {
|
||||
reason: reason.map(String::from),
|
||||
};
|
||||
send_handshake_message(
|
||||
identity_dir,
|
||||
recipient_nostr_pubkey,
|
||||
&msg,
|
||||
relays,
|
||||
tor_proxy,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"🚫 Sent peer-reject to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerCancel` notice to withdraw a previously-sent PeerRequest.
|
||||
/// The recipient will drop their matching inbound pending row.
|
||||
pub async fn send_peer_cancel(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
reason: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerCancel {
|
||||
reason: reason.map(String::from),
|
||||
};
|
||||
send_handshake_message(
|
||||
identity_dir,
|
||||
recipient_nostr_pubkey,
|
||||
&msg,
|
||||
relays,
|
||||
tor_proxy,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"↩️ Sent peer-cancel 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)
|
||||
}
|
||||
Reference in New Issue
Block a user