feat: deploy-to-target supports .253 + mesh/federation/VPN updates
- Add deploy_secondary() function for deploying to multiple LAN nodes - --both now deploys to .198 and .253 (previously .198 only) - Fleet deploy updated for 3 LAN nodes - Mesh DM fixes: protocol frame format, DM-via-channel routing - Federation pending requests, discover modal - VPN status UI improvements - Image versions and container specs updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e210376e05
commit
9dd802998c
@@ -1,15 +1,28 @@
|
||||
//! Encrypted peer handshake via Nostr NIP-44.
|
||||
//! Encrypted peer-discovery handshake via Nostr NIP-44.
|
||||
//!
|
||||
//! Instead of publishing onion addresses publicly on relays, nodes exchange
|
||||
//! them privately via NIP-44 encrypted DMs:
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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::*;
|
||||
@@ -22,26 +35,39 @@ use tracing::warn;
|
||||
|
||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
||||
|
||||
/// Message types for the encrypted handshake protocol
|
||||
/// 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 {
|
||||
#[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,
|
||||
/// 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> },
|
||||
}
|
||||
|
||||
/// Result of polling for incoming handshake messages
|
||||
@@ -219,16 +245,16 @@ pub async fn discover_nodes(
|
||||
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(
|
||||
/// 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,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
msg: &HandshakeMessage,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
@@ -239,20 +265,10 @@ pub async fn send_connect_request(
|
||||
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 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 plaintext = serde_json::to_string(msg).context("Failed to serialize handshake")?;
|
||||
let encrypted = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&recipient_pk,
|
||||
@@ -265,79 +281,86 @@ pub async fn send_connect_request(
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||
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 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 encrypted connect request to {}...{}",
|
||||
"🤝 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 an encrypted connection response to a peer.
|
||||
pub async fn send_connect_response(
|
||||
/// 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,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
invite_code: &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 msg = HandshakeMessage::PeerInvite {
|
||||
invite_code: invite_code.to_string(),
|
||||
};
|
||||
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;
|
||||
|
||||
send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?;
|
||||
tracing::info!(
|
||||
"🤝 Sent encrypted connect response to {}...{}",
|
||||
"🤝 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)..]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user