summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/transport/challenge.rs277
-rw-r--r--src/transport/mod.rs3
2 files changed, 280 insertions, 0 deletions
diff --git a/src/transport/challenge.rs b/src/transport/challenge.rs
new file mode 100644
index 0000000..f06d2fe
--- /dev/null
+++ b/src/transport/challenge.rs
@@ -0,0 +1,277 @@
+//! A challenge handshake that proves, at the start of a connection, which key is at the
+//! other end: for transports that don't authenticate their peers themselves (an onion
+//! service learns nothing about who connects to it, `transport::tor`).
+//!
+//! Both sides send a HELLO: their public key and a fresh random nonce. Then each sends
+//! a PROOF: its signature over a label, its role (dialer or listener), the connection's
+//! context, and both HELLOs (its own first). Each checks the other's signature against
+//! the key in the other's HELLO, and from then on knows that key is at the other end.
+//!
+//! * A recorded PROOF is useless elsewhere: it signs the other side's nonce, fresh for
+//! each connection.
+//! * The roles keep a side's own PROOF from being reflected back to it.
+//! * The context binds the handshake to the connection as the dialer meant it: the
+//! listener's address, as the dialer dialed it and as the listener knows itself. A
+//! party in the middle that passes one side's handshake on to someone else fails
+//! there: the context it was signed for is a different one.
+//!
+//! What it doesn't do: protect what follows. That is the connection's job, so the
+//! handshake is only enough over one that is encrypted and tamper-proof end to end, and
+//! authenticates the listener to the dialer (a Tor stream to an onion service). Over a
+//! bare network (UDP, say), it would need key agreement and protected messages as well.
+//!
+//! The signing key is the node key (`crate::identity`); the label keeps these
+//! signatures apart from the node key's others (WHO and ME).
+
+use std::time::Duration;
+
+use anyhow::{Result, anyhow, bail};
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+
+use crate::identity::{self, NodeKey};
+
+const LABEL: &[u8] = b"continuum/challenge/1";
+/// The first byte of a HELLO: this handshake's version.
+const VERSION: u8 = 1;
+const HELLO_BYTES: usize = 1 + 32 + 32;
+const PROOF_BYTES: usize = 64;
+
+/// Which end of the connection a side is.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum Role {
+ Dialer,
+ Listener,
+}
+
+impl Role {
+ fn byte(self) -> u8 {
+ match self {
+ Self::Dialer => 0,
+ Self::Listener => 1,
+ }
+ }
+
+ fn other(self) -> Self {
+ match self {
+ Self::Dialer => Self::Listener,
+ Self::Listener => Self::Dialer,
+ }
+ }
+}
+
+/// One side's HELLO.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Hello {
+ pub(crate) key: [u8; 32],
+ pub(crate) nonce: [u8; 32],
+}
+
+impl Hello {
+ /// Ours, with a fresh nonce.
+ pub(crate) fn new(key: &NodeKey) -> Result<Self> {
+ let mut nonce = [0u8; 32];
+ getrandom::fill(&mut nonce).map_err(|error| anyhow!("No randomness: {error}"))?;
+ Ok(Self {
+ key: key.public(),
+ nonce,
+ })
+ }
+
+ pub(crate) fn to_bytes(self) -> [u8; HELLO_BYTES] {
+ let mut bytes = [0u8; HELLO_BYTES];
+ bytes[0] = VERSION;
+ bytes[1..33].copy_from_slice(&self.key);
+ bytes[33..].copy_from_slice(&self.nonce);
+ bytes
+ }
+
+ pub(crate) fn from_bytes(bytes: &[u8; HELLO_BYTES]) -> Result<Self> {
+ if bytes[0] != VERSION {
+ bail!(
+ "challenge handshake version {} (we speak {VERSION})",
+ bytes[0]
+ );
+ }
+ let mut hello = Self {
+ key: [0; 32],
+ nonce: [0; 32],
+ };
+ hello.key.copy_from_slice(&bytes[1..33]);
+ hello.nonce.copy_from_slice(&bytes[33..]);
+ Ok(hello)
+ }
+}
+
+/// What the side in `role` signs: the label, its role, the context, then its own HELLO
+/// and the other side's.
+fn signed(role: Role, context: &str, own: &Hello, other: &Hello) -> Vec<u8> {
+ let mut bytes = LABEL.to_vec();
+ bytes.push(role.byte());
+ bytes.extend_from_slice(&(context.len() as u32).to_be_bytes());
+ bytes.extend_from_slice(context.as_bytes());
+ for hello in [own, other] {
+ bytes.extend_from_slice(&hello.key);
+ bytes.extend_from_slice(&hello.nonce);
+ }
+ bytes
+}
+
+/// Our PROOF, as the side in `role`.
+pub(crate) fn prove(
+ key: &NodeKey,
+ role: Role,
+ context: &str,
+ own: &Hello,
+ other: &Hello,
+) -> [u8; PROOF_BYTES] {
+ key.sign(&signed(role, context, own, other))
+}
+
+/// Whether `proof` is the other side's (the one not in `role`) for this handshake.
+pub(crate) fn verify(
+ role: Role,
+ context: &str,
+ own: &Hello,
+ other: &Hello,
+ proof: &[u8; PROOF_BYTES],
+) -> bool {
+ identity::verify(
+ &other.key,
+ &signed(role.other(), context, other, own),
+ proof,
+ )
+}
+
+/// Runs the handshake on `stream` as the side in `role`, within `timeout`: returns the
+/// public key at the other end. `context`: the listener's address, as dialed (module
+/// docs).
+pub(crate) async fn run<S>(
+ stream: &mut S,
+ key: &NodeKey,
+ role: Role,
+ context: &str,
+ timeout: Duration,
+) -> Result<[u8; 32]>
+where
+ S: AsyncRead + AsyncWrite + Unpin,
+{
+ let exchange = async {
+ let own = Hello::new(key)?;
+ stream.write_all(&own.to_bytes()).await?;
+ stream.flush().await?;
+ let mut hello = [0u8; HELLO_BYTES];
+ stream.read_exact(&mut hello).await?;
+ let other = Hello::from_bytes(&hello)?;
+ if other.key == own.key {
+ bail!("the other end has our own key");
+ }
+ stream
+ .write_all(&prove(key, role, context, &own, &other))
+ .await?;
+ stream.flush().await?;
+ let mut proof = [0u8; PROOF_BYTES];
+ stream.read_exact(&mut proof).await?;
+ if !verify(role, context, &own, &other, &proof) {
+ bail!("the other end's proof doesn't hold");
+ }
+ Ok(other.key)
+ };
+ tokio::time::timeout(timeout, exchange)
+ .await
+ .map_err(|_| anyhow!("no handshake within {timeout:?}"))?
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn key(seed: u8) -> NodeKey {
+ NodeKey::from_seed([seed; 32])
+ }
+
+ #[test]
+ fn a_proof_holds_for_its_handshake_only() {
+ let (a, b) = (key(1), key(2));
+ let (ha, hb) = (Hello::new(&a).unwrap(), Hello::new(&b).unwrap());
+ let context = "example.onion:9663";
+ let proof = prove(&a, Role::Dialer, context, &ha, &hb);
+ // b, the listener, checks a's proof.
+ assert!(verify(Role::Listener, context, &hb, &ha, &proof));
+ // Another context (passed on to another listener).
+ assert!(!verify(
+ Role::Listener,
+ "other.onion:9663",
+ &hb,
+ &ha,
+ &proof
+ ));
+ // Another connection: b's nonce is a new one.
+ let hb2 = Hello::new(&b).unwrap();
+ assert!(!verify(Role::Listener, context, &hb2, &ha, &proof));
+ // Reflected: a can't be fooled by its own proof as if b had sent it.
+ assert!(!verify(Role::Dialer, context, &ha, &hb, &proof));
+ // Someone else's key in the HELLO.
+ let hc = Hello {
+ key: key(3).public(),
+ nonce: ha.nonce,
+ };
+ assert!(!verify(Role::Listener, context, &hb, &hc, &proof));
+ }
+
+ #[test]
+ fn hellos_round_trip_and_versions_are_checked() {
+ let hello = Hello::new(&key(1)).unwrap();
+ assert_eq!(Hello::from_bytes(&hello.to_bytes()).unwrap(), hello);
+ let mut bytes = hello.to_bytes();
+ bytes[0] = VERSION + 1;
+ assert!(Hello::from_bytes(&bytes).is_err());
+ }
+
+ #[tokio::test]
+ async fn both_ends_learn_each_others_key() {
+ let (mut dialer, mut listener) = tokio::io::duplex(1024);
+ let (a, b) = (key(1), key(2));
+ let context = "example.onion:9663";
+ let (at_a, at_b) = tokio::join!(
+ run(
+ &mut dialer,
+ &a,
+ Role::Dialer,
+ context,
+ Duration::from_secs(5)
+ ),
+ run(
+ &mut listener,
+ &b,
+ Role::Listener,
+ context,
+ Duration::from_secs(5)
+ ),
+ );
+ assert_eq!(at_a.unwrap(), b.public());
+ assert_eq!(at_b.unwrap(), a.public());
+ }
+
+ #[tokio::test]
+ async fn a_different_context_fails_on_both_ends() {
+ let (mut dialer, mut listener) = tokio::io::duplex(1024);
+ let (a, b) = (key(1), key(2));
+ let (at_a, at_b) = tokio::join!(
+ run(
+ &mut dialer,
+ &a,
+ Role::Dialer,
+ "meant.onion:1",
+ Duration::from_secs(5)
+ ),
+ run(
+ &mut listener,
+ &b,
+ Role::Listener,
+ "actual.onion:1",
+ Duration::from_secs(5)
+ ),
+ );
+ assert!(at_a.is_err() && at_b.is_err());
+ }
+}
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index 80b71cd..f28ac1f 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -11,6 +11,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
pub(crate) use crate::document::replica::Epoch;
+// Used by the transports that don't authenticate their peers themselves (`tor`).
+#[cfg_attr(not(feature = "tor"), allow(dead_code))]
+pub(crate) mod challenge;
pub(crate) mod firewall;
pub(crate) mod identity;
pub(crate) mod mycelium;