Files
tsunagi/tests/authentication.rs
T

426 lines
14 KiB
Rust
Raw Normal View History

//! 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;
}