Accept authenticated npub-only peering replies with validated DID keys
This commit is contained in:
@@ -274,3 +274,174 @@ async fn federation_metrics_are_collected_values_or_unknown_never_placeholders()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Real encrypted relay -> poll -> persisted membership, including the normal
|
||||||
|
/// npub-only outbound request whose DID is unknown until the authenticated reply.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn npub_only_request_accepts_bound_reply_but_rejects_other_sender_and_forged_did() {
|
||||||
|
use base64::Engine;
|
||||||
|
use nostr_sdk::{EventBuilder, Kind, Tag};
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let local = Keys::parse(&"11".repeat(32)).unwrap();
|
||||||
|
let remote = Keys::parse(&"22".repeat(32)).unwrap();
|
||||||
|
let stranger = Keys::parse(&"55".repeat(32)).unwrap();
|
||||||
|
let remote_key = "44".repeat(32);
|
||||||
|
let remote_did = crate::identity::did_key_from_pubkey_hex(&remote_key).unwrap();
|
||||||
|
let payload = serde_json::json!({"did":remote_did,"pubkey":remote_key,"onion":format!("{}.onion","b".repeat(56)),"token":"fixture-invite"});
|
||||||
|
let event = |sender: &Keys, payload: &serde_json::Value| {
|
||||||
|
let code = format!(
|
||||||
|
"fed1:{}",
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.encode(serde_json::to_vec(payload).unwrap())
|
||||||
|
);
|
||||||
|
let content = nip44::encrypt(
|
||||||
|
sender.secret_key(),
|
||||||
|
&local.public_key(),
|
||||||
|
serde_json::json!({"type":"peer-invite","invite_code":code}).to_string(),
|
||||||
|
nip44::Version::V2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
EventBuilder::new(Kind::EncryptedDirectMessage, content)
|
||||||
|
.tag(Tag::public_key(local.public_key()))
|
||||||
|
.sign_with_keys(sender)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let mut forged = payload.clone();
|
||||||
|
forged["pubkey"] = serde_json::json!("66".repeat(32));
|
||||||
|
let events = Arc::new(std::sync::Mutex::new(vec![
|
||||||
|
event(&stranger, &payload),
|
||||||
|
event(&remote, &forged),
|
||||||
|
]));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let relay_url = format!("ws://{}", listener.local_addr().unwrap());
|
||||||
|
let relay_events = events.clone();
|
||||||
|
let relay = tokio::spawn(async move {
|
||||||
|
while let Ok((socket, _)) = listener.accept().await {
|
||||||
|
let events = relay_events.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok(mut ws) = tokio_tungstenite::accept_async(socket).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
while let Some(Ok(message)) = ws.next().await {
|
||||||
|
let Ok(text) = message.to_text() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if value[0] != "REQ" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stored = events.lock().unwrap().clone();
|
||||||
|
for event in stored {
|
||||||
|
if ws
|
||||||
|
.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||||
|
serde_json::json!(["EVENT", value[1], event]).to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ws
|
||||||
|
.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||||
|
serde_json::json!(["EOSE", value[1]]).to_string(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let identity_dir = dir.path().join("identity");
|
||||||
|
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
|
||||||
|
tokio::fs::write(identity_dir.join("nostr_secret"), "11".repeat(32))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(
|
||||||
|
dir.path()
|
||||||
|
.join(crate::nostr_handshake::DISCOVERY_STATE_FILE),
|
||||||
|
br#"{"enabled":true}"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut config = crate::config::Config::default();
|
||||||
|
config.data_dir = dir.path().into();
|
||||||
|
config.nostr_relays = vec![relay_url];
|
||||||
|
config.nostr_tor_proxy = None;
|
||||||
|
let state = Arc::new(crate::state::StateManager::new());
|
||||||
|
state
|
||||||
|
.mutate_data(|data| {
|
||||||
|
data.server_info.pubkey = identity.pubkey_hex();
|
||||||
|
data.server_info.tor_address = Some(format!("{}.onion", "a".repeat(56)));
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let handler = crate::api::rpc::RpcHandler::new(
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||||
|
crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json")),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let row = pending::insert_outbound(
|
||||||
|
dir.path(),
|
||||||
|
remote.public_key().to_hex(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rejected = handler.handle_handshake_poll().await.unwrap();
|
||||||
|
assert!(rejected["applied_invites"].as_array().unwrap().is_empty());
|
||||||
|
assert!(crate::federation::load_nodes(dir.path())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
pending::find_by_id(dir.path(), &row.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
PendingState::Sent
|
||||||
|
);
|
||||||
|
*events.lock().unwrap() = vec![event(&remote, &payload)];
|
||||||
|
let accepted = handler.handle_handshake_poll().await.unwrap();
|
||||||
|
assert_eq!(accepted["applied_invites"], serde_json::json!([remote_did]));
|
||||||
|
let nodes = crate::federation::load_nodes(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(nodes.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
nodes[0].trust_level,
|
||||||
|
crate::federation::TrustLevel::Observer
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pending::find_by_id(dir.path(), &row.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
PendingState::Approved
|
||||||
|
);
|
||||||
|
let duplicate = handler.handle_handshake_poll().await.unwrap();
|
||||||
|
assert!(duplicate["applied_invites"].as_array().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
crate::federation::load_nodes(dir.path())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
relay.abort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,11 +137,14 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
|
|||||||
/// Bind a Nostr-discovery reply to the node the operator requested, and cap
|
/// Bind a Nostr-discovery reply to the node the operator requested, and cap
|
||||||
/// its grant before any local node entry or callback is written. Legacy invites
|
/// its grant before any local node entry or callback is written. Legacy invites
|
||||||
/// default to Trusted, which must never transiently authorize discovery peers.
|
/// default to Trusted, which must never transiently authorize discovery peers.
|
||||||
|
/// An npub-only outbound request has no DID yet: its caller must already have
|
||||||
|
/// matched the authenticated Nostr sender to the stored requested public key.
|
||||||
|
/// In that case the reply establishes the DID, which must still match its key.
|
||||||
pub(crate) fn restrict_discovery_invite(code: &str, expected_did: &str) -> Result<String> {
|
pub(crate) fn restrict_discovery_invite(code: &str, expected_did: &str) -> Result<String> {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let parsed = parse_invite(code)?;
|
let parsed = parse_invite(code)?;
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!expected_did.is_empty() && parsed.did == expected_did,
|
expected_did.is_empty() || parsed.did == expected_did,
|
||||||
"Peer invite does not match the requested node"
|
"Peer invite does not match the requested node"
|
||||||
);
|
);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@@ -668,7 +671,11 @@ mod discovery_invite_scope_tests {
|
|||||||
assert_eq!(parsed.trust_level, TrustLevel::Observer);
|
assert_eq!(parsed.trust_level, TrustLevel::Observer);
|
||||||
assert_eq!(parsed.token, "test-token");
|
assert_eq!(parsed.token, "test-token");
|
||||||
assert!(restrict_discovery_invite(&code, "did:key:someone-else").is_err());
|
assert!(restrict_discovery_invite(&code, "did:key:someone-else").is_err());
|
||||||
assert!(restrict_discovery_invite(&code, "").is_err());
|
let first_contact = restrict_discovery_invite(&code, "").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parse_invite(&first_contact).unwrap().trust_level,
|
||||||
|
TrustLevel::Observer
|
||||||
|
);
|
||||||
let mut forged = payload;
|
let mut forged = payload;
|
||||||
forged["pubkey"] = serde_json::json!("44".repeat(32));
|
forged["pubkey"] = serde_json::json!("44".repeat(32));
|
||||||
let forged = format!(
|
let forged = format!(
|
||||||
@@ -677,5 +684,6 @@ mod discovery_invite_scope_tests {
|
|||||||
.encode(serde_json::to_vec(&forged).unwrap())
|
.encode(serde_json::to_vec(&forged).unwrap())
|
||||||
);
|
);
|
||||||
assert!(restrict_discovery_invite(&forged, &did).is_err());
|
assert!(restrict_discovery_invite(&forged, &did).is_err());
|
||||||
|
assert!(restrict_discovery_invite(&forged, "").is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user