426 lines
14 KiB
Rust
426 lines
14 KiB
Rust
//! Scenarios 3 and 9: an attacker without the secret, and the protocol's
|
|||
|
|
//! boundaries.
|
||
|
|
//!
|
||
|
|
//! These tests speak the wire protocol directly against a real agent, because
|
||
|
|
//! that is the only way to present a *correct* public network id with a wrong
|
||
|
|
//! proof, replay a captured proof on a second connection, or send a message
|
||
|
|
//! before authenticating.
|
||
|
|
|
||
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
|
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use common::{TestAgent, network, settle, wait_event};
|
||
|
|
use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets};
|
||
|
|
use iroh::{Endpoint, EndpointAddr, RelayMode};
|
||
|
|
use tsunagi::agent::Event;
|
||
|
|
use tsunagi::discovery::SharedMemoryDiscovery;
|
||
|
|
use tsunagi::identity::{NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||
|
|
use tsunagi::proto::handshake::{ROLE_INITIATOR, ROLE_RESPONDER};
|
||
|
|
use tsunagi::proto::message::{
|
||
|
|
ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode,
|
||
|
|
};
|
||
|
|
use tsunagi::proto::{read_frame, write_frame};
|
||
|
|
use tsunagi::test_support;
|
||
|
|
|
||
|
|
/// A bare iroh endpoint with no tsunagi agent behind it.
|
||
|
|
async fn raw_endpoint() -> Endpoint {
|
||
|
|
Endpoint::builder(presets::Minimal)
|
||
|
|
.alpns(vec![ALPN.to_vec()])
|
||
|
|
.relay_mode(RelayMode::Disabled)
|
||
|
|
.clear_address_lookup()
|
||
|
|
.portmapper_config(PortmapperConfig::Disabled)
|
||
|
|
.clear_ip_transports()
|
||
|
|
.bind_addr("127.0.0.1:0")
|
||
|
|
.unwrap()
|
||
|
|
.bind()
|
||
|
|
.await
|
||
|
|
.unwrap()
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn open_control_stream(
|
||
|
|
endpoint: &Endpoint,
|
||
|
|
target: EndpointAddr,
|
||
|
|
) -> (Connection, SendStream, RecvStream) {
|
||
|
|
let conn = endpoint.connect(target, ALPN).await.unwrap();
|
||
|
|
let (send, recv) = conn.open_bi().await.unwrap();
|
||
|
|
(conn, send, recv)
|
||
|
|
}
|
||
|
|
|
||
|
|
const LIMIT: usize = 64 * 1024;
|
||
|
|
|
||
|
|
/// Sends `Hello` and reads the responder's `HelloAck`.
|
||
|
|
async fn exchange_hellos(
|
||
|
|
send: &mut SendStream,
|
||
|
|
recv: &mut RecvStream,
|
||
|
|
version: u16,
|
||
|
|
network_id: NetworkId,
|
||
|
|
nonce: [u8; 16],
|
||
|
|
) -> HelloAck {
|
||
|
|
let hello = Hello {
|
||
|
|
version,
|
||
|
|
network_id: *network_id.as_bytes(),
|
||
|
|
nonce,
|
||
|
|
};
|
||
|
|
write_frame(send, &encode(&hello).unwrap(), LIMIT)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
decode(&read_frame(recv, LIMIT).await.unwrap()).unwrap()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn an_attacker_who_knows_the_public_network_id_is_still_rejected() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("closed-network");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
// The attacker knows the address and the correct *public* network id. It
|
||
|
|
// does not know the secret, so it cannot compute a valid proof.
|
||
|
|
let attacker = raw_endpoint().await;
|
||
|
|
let (conn, mut send, mut recv) =
|
||
|
|
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||
|
|
|
||
|
|
let _ack = exchange_hellos(
|
||
|
|
&mut send,
|
||
|
|
&mut recv,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
network_id,
|
||
|
|
[9u8; 16],
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
let bogus = AuthProof { proof: [0xAB; 32] };
|
||
|
|
write_frame(&mut send, &encode(&bogus).unwrap(), LIMIT)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
|
||
|
|
// The responder must not reveal a proof of its own and must reject us.
|
||
|
|
let reply = read_frame(&mut recv, LIMIT).await;
|
||
|
|
assert!(
|
||
|
|
reply.is_err(),
|
||
|
|
"the agent must not answer an invalid proof, got {reply:?}"
|
||
|
|
);
|
||
|
|
|
||
|
|
let reason = wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
assert!(
|
||
|
|
reason.contains("authentication failed"),
|
||
|
|
"unexpected reason: {reason}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// The victim is unharmed: no session, and the network is still running.
|
||
|
|
let status = victim.agent.network_status(network_id).await.unwrap();
|
||
|
|
assert!(status.peers.is_empty());
|
||
|
|
assert_eq!(status.metrics.sessions_established, 0);
|
||
|
|
|
||
|
|
conn.close(0u32.into(), b"done");
|
||
|
|
attacker.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_wrong_secret_under_the_same_name_lands_in_a_different_space() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let name = NetworkName::new("same-name").unwrap();
|
||
|
|
|
||
|
|
let good = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let bad = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
|
||
|
|
let good_id = good
|
||
|
|
.agent
|
||
|
|
.join_network(&name, &NetworkSecret::generate())
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let bad_id = bad
|
||
|
|
.agent
|
||
|
|
.join_network(&name, &NetworkSecret::generate())
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert_ne!(good_id, bad_id);
|
||
|
|
|
||
|
|
// Even with discovery wired together, the two never form a session.
|
||
|
|
//
|
||
|
|
// This test only shows that discovery separated them; the authoritative
|
||
|
|
// check that the secret itself gates membership is
|
||
|
|
// `an_attacker_who_knows_the_public_network_id_is_still_rejected`.
|
||
|
|
settle().await;
|
||
|
|
let status = good.agent.network_status(good_id).await.unwrap();
|
||
|
|
assert!(status.peers.is_empty());
|
||
|
|
|
||
|
|
good.agent.shutdown().await;
|
||
|
|
bad.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn an_unsupported_protocol_version_is_rejected() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("versioned");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
let attacker = raw_endpoint().await;
|
||
|
|
let (conn, mut send, mut recv) =
|
||
|
|
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||
|
|
|
||
|
|
let hello = Hello {
|
||
|
|
version: PROTOCOL_VERSION + 7,
|
||
|
|
network_id: *network_id.as_bytes(),
|
||
|
|
nonce: [1u8; 16],
|
||
|
|
};
|
||
|
|
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||
|
|
|
||
|
|
let reason = wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
assert!(reason.contains("version"), "unexpected reason: {reason}");
|
||
|
|
|
||
|
|
// The agent is still alive and usable afterwards.
|
||
|
|
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||
|
|
|
||
|
|
conn.close(0u32.into(), b"done");
|
||
|
|
attacker.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_control_message_before_authentication_is_rejected() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("no-early-messages");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
let attacker = raw_endpoint().await;
|
||
|
|
let (conn, mut send, mut recv) =
|
||
|
|
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||
|
|
|
||
|
|
// A perfectly well formed control message, sent where a Hello belongs.
|
||
|
|
let envelope = Envelope {
|
||
|
|
network_id: *network_id.as_bytes(),
|
||
|
|
message: ControlMessage::Ping {
|
||
|
|
seq: 1,
|
||
|
|
payload: b"too early".to_vec(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
write_frame(&mut send, &encode(&envelope).unwrap(), LIMIT)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||
|
|
|
||
|
|
wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected { .. } => Some(()),
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let status = victim.agent.network_status(network_id).await.unwrap();
|
||
|
|
assert!(status.peers.is_empty());
|
||
|
|
|
||
|
|
conn.close(0u32.into(), b"done");
|
||
|
|
attacker.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn an_oversized_frame_is_rejected_before_it_is_allocated() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("bounded-frames");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
let attacker = raw_endpoint().await;
|
||
|
|
let (conn, mut send, mut recv) =
|
||
|
|
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||
|
|
|
||
|
|
// Announce four gigabytes and then send nothing. The agent must reject the
|
||
|
|
// header instead of allocating the buffer.
|
||
|
|
send.write_all(&u32::MAX.to_be_bytes()).await.unwrap();
|
||
|
|
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||
|
|
|
||
|
|
wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected { reason, .. } if reason.contains("exceeds") => Some(()),
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
|
||
|
|
// Still serving other work.
|
||
|
|
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||
|
|
|
||
|
|
conn.close(0u32.into(), b"done");
|
||
|
|
attacker.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_proof_cannot_be_replayed_on_another_connection() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("channel-bound");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
// A legitimate member: it knows the secret and can compute real proofs.
|
||
|
|
let keys = NetworkKeys::derive(&name, &secret);
|
||
|
|
let auth_key = test_support::auth_key(&keys);
|
||
|
|
let member = raw_endpoint().await;
|
||
|
|
let local = *member.id().as_bytes();
|
||
|
|
let remote = *victim.agent.endpoint_id().as_bytes();
|
||
|
|
let network_bytes = *network_id.as_bytes();
|
||
|
|
|
||
|
|
// Connection one: a genuine, successful handshake.
|
||
|
|
let (conn1, mut send1, mut recv1) =
|
||
|
|
open_control_stream(&member, victim.agent.local_addr()).await;
|
||
|
|
let nonce_i = [11u8; 16];
|
||
|
|
let ack1 = exchange_hellos(
|
||
|
|
&mut send1,
|
||
|
|
&mut recv1,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
network_id,
|
||
|
|
nonce_i,
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
let cb1 = test_support::channel_binding(&conn1, &network_bytes).unwrap();
|
||
|
|
let genuine = test_support::compute_proof(
|
||
|
|
&auth_key,
|
||
|
|
ROLE_INITIATOR,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
&network_bytes,
|
||
|
|
&local,
|
||
|
|
&remote,
|
||
|
|
&cb1,
|
||
|
|
&nonce_i,
|
||
|
|
&ack1.nonce,
|
||
|
|
);
|
||
|
|
write_frame(
|
||
|
|
&mut send1,
|
||
|
|
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||
|
|
LIMIT,
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
let their_proof: AuthProof = decode(&read_frame(&mut recv1, LIMIT).await.unwrap()).unwrap();
|
||
|
|
|
||
|
|
// The responder proved membership too, and it used the responder role.
|
||
|
|
let expected = test_support::compute_proof(
|
||
|
|
&auth_key,
|
||
|
|
ROLE_RESPONDER,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
&network_bytes,
|
||
|
|
&local,
|
||
|
|
&remote,
|
||
|
|
&cb1,
|
||
|
|
&nonce_i,
|
||
|
|
&ack1.nonce,
|
||
|
|
);
|
||
|
|
assert_eq!(their_proof.proof, expected, "responder proof must verify");
|
||
|
|
assert_ne!(
|
||
|
|
their_proof.proof, genuine,
|
||
|
|
"roles must not produce the same proof, or it could be reflected"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Connection two: replay the captured proof verbatim. The TLS exporter
|
||
|
|
// differs per connection, so the proof no longer matches.
|
||
|
|
let (conn2, mut send2, mut recv2) =
|
||
|
|
open_control_stream(&member, victim.agent.local_addr()).await;
|
||
|
|
let ack2 = exchange_hellos(
|
||
|
|
&mut send2,
|
||
|
|
&mut recv2,
|
||
|
|
PROTOCOL_VERSION,
|
||
|
|
network_id,
|
||
|
|
nonce_i,
|
||
|
|
)
|
||
|
|
.await;
|
||
|
|
let cb2 = test_support::channel_binding(&conn2, &network_bytes).unwrap();
|
||
|
|
assert_ne!(cb1, cb2, "channel binding must differ between connections");
|
||
|
|
let _ = ack2;
|
||
|
|
|
||
|
|
write_frame(
|
||
|
|
&mut send2,
|
||
|
|
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||
|
|
LIMIT,
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert!(
|
||
|
|
read_frame(&mut recv2, LIMIT).await.is_err(),
|
||
|
|
"a replayed proof must be rejected"
|
||
|
|
);
|
||
|
|
|
||
|
|
let reason = wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected { reason, .. } if reason.contains("authentication failed") => {
|
||
|
|
Some(reason.clone())
|
||
|
|
}
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
assert!(reason.contains("authentication failed"));
|
||
|
|
|
||
|
|
conn1.close(0u32.into(), b"done");
|
||
|
|
conn2.close(0u32.into(), b"done");
|
||
|
|
member.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn a_hello_for_an_inactive_network_is_rejected() {
|
||
|
|
let discovery = SharedMemoryDiscovery::new();
|
||
|
|
let (name, secret) = network("active-only");
|
||
|
|
|
||
|
|
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||
|
|
let mut events = victim.agent.subscribe();
|
||
|
|
victim.agent.join_network(&name, &secret).await.unwrap();
|
||
|
|
|
||
|
|
// A different, perfectly valid network space the victim is not in.
|
||
|
|
let other = NetworkKeys::derive(
|
||
|
|
&NetworkName::new("somewhere-else").unwrap(),
|
||
|
|
&NetworkSecret::generate(),
|
||
|
|
);
|
||
|
|
|
||
|
|
let attacker = raw_endpoint().await;
|
||
|
|
let (conn, mut send, mut recv) =
|
||
|
|
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||
|
|
let hello = Hello {
|
||
|
|
version: PROTOCOL_VERSION,
|
||
|
|
network_id: *other.network_id().as_bytes(),
|
||
|
|
nonce: [3u8; 16],
|
||
|
|
};
|
||
|
|
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||
|
|
|
||
|
|
let reason = wait_event(&mut events, |event| match event {
|
||
|
|
Event::HandshakeRejected {
|
||
|
|
network, reason, ..
|
||
|
|
} => {
|
||
|
|
// Before a successful handshake the claimed network is unverified,
|
||
|
|
// so it must not be reported as fact.
|
||
|
|
assert!(network.is_none());
|
||
|
|
Some(reason.clone())
|
||
|
|
}
|
||
|
|
_ => None,
|
||
|
|
})
|
||
|
|
.await;
|
||
|
|
assert!(reason.contains("unknown"), "unexpected reason: {reason}");
|
||
|
|
|
||
|
|
conn.close(0u32.into(), b"done");
|
||
|
|
attacker.close().await;
|
||
|
|
victim.agent.shutdown().await;
|
||
|
|
}
|