From ab81ed2be253ef9da658066fe5a9aa95a536851d Mon Sep 17 00:00:00 2001 From: Anton Kaliaev Date: Fri, 27 Nov 2020 15:00:53 +0400 Subject: [PATCH] p2p: migrate secret_connection from tmkms (#696) * p2p: migrate secret_connection from tmkms Closes #691 * bring back versions, remove generate_key * return proper error * format code * fixes after xla review * fixes after @tony-iqlusion review * specify tendermint and tendermint-proto as path dependencies * more fixes after @xla review * remove warnings from lib.rs https://github.com/melekes/tendermint-rs/commit/9e253dfde60e15b31b029dd07969c31cd6cc4a4d#r44484475 * make amino dependencies optional --- Cargo.toml | 1 + p2p/Cargo.toml | 42 +++ p2p/src/error.rs | 19 + p2p/src/lib.rs | 18 + p2p/src/secret_connection.rs | 424 +++++++++++++++++++++++ p2p/src/secret_connection/amino_types.rs | 15 + p2p/src/secret_connection/kdf.rs | 56 +++ p2p/src/secret_connection/nonce.rs | 67 ++++ p2p/src/secret_connection/protocol.rs | 262 ++++++++++++++ p2p/src/secret_connection/public_key.rs | 84 +++++ 10 files changed, 988 insertions(+) create mode 100644 p2p/Cargo.toml create mode 100644 p2p/src/error.rs create mode 100644 p2p/src/lib.rs create mode 100644 p2p/src/secret_connection.rs create mode 100644 p2p/src/secret_connection/amino_types.rs create mode 100644 p2p/src/secret_connection/kdf.rs create mode 100644 p2p/src/secret_connection/nonce.rs create mode 100644 p2p/src/secret_connection/protocol.rs create mode 100644 p2p/src/secret_connection/public_key.rs diff --git a/Cargo.toml b/Cargo.toml index 916ca27dc..3a0569528 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "light-client", "light-node", + "p2p", "proto", "rpc", "rpc-probe", diff --git a/p2p/Cargo.toml b/p2p/Cargo.toml new file mode 100644 index 000000000..be80a13c7 --- /dev/null +++ b/p2p/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "tendermint-p2p" +version = "0.1.0" +edition = "2018" +license = "Apache-2.0" +repository = "https://github.com/informalsystems/tendermint-rs" +readme = "README.md" +keywords = ["p2p", "tendermint", "cosmos"] +authors = [ + "Tony Arcieri ", + "Ismail Khoffi " +] + +description = """ + The Tendermint P2P stack. + """ + +[dependencies] +chacha20poly1305 = "0.7" +ed25519-dalek = "1" +eyre = "0.6" +hkdf = "0.10.0" +merlin = "2" +prost = "0.6" +rand_core = { version = "0.5", features = ["std"] } +sha2 = "0.9" +subtle = "2" +subtle-encoding = { version = "0.5" } +thiserror = "1" +x25519-dalek = "1.1" +zeroize = "1" + +# path dependencies +tendermint = { path = "../tendermint", version = "=0.17.0-rc3" } +tendermint-proto = { path = "../proto", version = "=0.17.0-rc3" } + +# optional dependencies +prost-amino = { version = "0.6", optional = true } +prost-amino-derive = { version = "0.6", optional = true } + +[features] +amino = ["prost-amino", "prost-amino-derive"] diff --git a/p2p/src/error.rs b/p2p/src/error.rs new file mode 100644 index 000000000..08000414b --- /dev/null +++ b/p2p/src/error.rs @@ -0,0 +1,19 @@ +//! Error types + +use thiserror::Error; + +/// Kinds of errors +#[derive(Copy, Clone, Debug, Error, Eq, PartialEq)] +pub enum Error { + /// Cryptographic operation failed + #[error("cryptographic error")] + CryptoError, + + /// Malformatted or otherwise invalid cryptographic key + #[error("invalid key")] + InvalidKey, + + /// Network protocol-related errors + #[error("protocol error")] + ProtocolError, +} diff --git a/p2p/src/lib.rs b/p2p/src/lib.rs new file mode 100644 index 000000000..8ad619d42 --- /dev/null +++ b/p2p/src/lib.rs @@ -0,0 +1,18 @@ +//! The Tendermint P2P stack. + +#![forbid(unsafe_code)] +#![deny( + trivial_casts, + trivial_numeric_casts, + unused_import_braces, + unused_qualifications, + rust_2018_idioms, + nonstandard_style +)] +#![doc( + html_root_url = "https://docs.rs/tendermint-p2p/0.1.0", + html_logo_url = "https://raw.githubusercontent.com/informalsystems/tendermint-rs/master/img/logo-tendermint-rs_3961x4001.png" +)] + +pub mod error; +pub mod secret_connection; diff --git a/p2p/src/secret_connection.rs b/p2p/src/secret_connection.rs new file mode 100644 index 000000000..4674aed00 --- /dev/null +++ b/p2p/src/secret_connection.rs @@ -0,0 +1,424 @@ +//! `SecretConnection`: Transport layer encryption for Tendermint P2P connections. + +use std::{ + cmp, + convert::{TryFrom, TryInto}, + io::{self, Read, Write}, + marker::{Send, Sync}, + slice, +}; + +use chacha20poly1305::{ + aead::{generic_array::GenericArray, AeadInPlace, NewAead}, + ChaCha20Poly1305, +}; +use ed25519_dalek::{self as ed25519, Signer, Verifier}; +use eyre::{Result, WrapErr}; +use merlin::Transcript; +use rand_core::OsRng; +use subtle::ConstantTimeEq; +use x25519_dalek::{EphemeralSecret, PublicKey as EphemeralPublic}; + +use tendermint_proto as proto; + +pub use self::{kdf::Kdf, nonce::Nonce, protocol::Version, public_key::PublicKey}; +use crate::error::Error; + +#[cfg(feature = "amino")] +mod amino_types; + +mod kdf; +mod nonce; +mod protocol; +mod public_key; + +/// Size of the MAC tag +pub const TAG_SIZE: usize = 16; + +/// Maximum size of a message +pub const DATA_MAX_SIZE: usize = 1024; + +/// 4 + 1024 == 1028 total frame size +const DATA_LEN_SIZE: usize = 4; +const TOTAL_FRAME_SIZE: usize = DATA_MAX_SIZE + DATA_LEN_SIZE; + +/// Encrypted connection between peers in a Tendermint network +pub struct SecretConnection { + io_handler: IoHandler, + protocol_version: Version, + recv_nonce: Nonce, + send_nonce: Nonce, + recv_cipher: ChaCha20Poly1305, + send_cipher: ChaCha20Poly1305, + remote_pubkey: Option, + recv_buffer: Vec, +} + +impl SecretConnection { + /// Returns authenticated remote pubkey + pub fn remote_pubkey(&self) -> PublicKey { + self.remote_pubkey.expect("remote_pubkey uninitialized") + } + + /// Performs handshake and returns a new authenticated SecretConnection. + pub fn new( + mut io_handler: IoHandler, + local_privkey: &ed25519::Keypair, + protocol_version: Version, + ) -> Result> { + let local_pubkey = PublicKey::from(local_privkey); + + // Generate ephemeral keys for perfect forward secrecy. + let (local_eph_pubkey, local_eph_privkey) = gen_eph_keys(); + + // Write local ephemeral pubkey and receive one too. + let remote_eph_pubkey = + share_eph_pubkey(&mut io_handler, &local_eph_pubkey, protocol_version)?; + + // Compute common shared secret. + let shared_secret = EphemeralSecret::diffie_hellman(local_eph_privkey, &remote_eph_pubkey); + + let mut transcript = Transcript::new(b"TENDERMINT_SECRET_CONNECTION_TRANSCRIPT_HASH"); + + // Reject all-zero outputs from X25519 (i.e. from low-order points) + // + // See the following for information on potential attacks this check + // aids in mitigating: + // + // - https://github.com/tendermint/kms/issues/142 + // - https://eprint.iacr.org/2019/526.pdf + if shared_secret.as_bytes().ct_eq(&[0x00; 32]).unwrap_u8() == 1 { + return Err(Error::InvalidKey) + .wrap_err("low-order points found (potential MitM attack!)"); + } + + // Sort by lexical order. + let local_eph_pubkey_bytes = *local_eph_pubkey.as_bytes(); + let (low_eph_pubkey_bytes, high_eph_pubkey_bytes) = + sort32(local_eph_pubkey_bytes, *remote_eph_pubkey.as_bytes()); + + transcript.append_message(b"EPHEMERAL_LOWER_PUBLIC_KEY", &low_eph_pubkey_bytes); + transcript.append_message(b"EPHEMERAL_UPPER_PUBLIC_KEY", &high_eph_pubkey_bytes); + transcript.append_message(b"DH_SECRET", shared_secret.as_bytes()); + + // Check if the local ephemeral public key + // was the least, lexicographically sorted. + let loc_is_least = local_eph_pubkey_bytes == low_eph_pubkey_bytes; + + let kdf = Kdf::derive_secrets_and_challenge(shared_secret.as_bytes(), loc_is_least); + + // Construct SecretConnection. + let mut sc = SecretConnection { + io_handler, + protocol_version, + recv_buffer: vec![], + recv_nonce: Nonce::default(), + send_nonce: Nonce::default(), + recv_cipher: ChaCha20Poly1305::new(&kdf.recv_secret.into()), + send_cipher: ChaCha20Poly1305::new(&kdf.send_secret.into()), + remote_pubkey: None, + }; + + let mut sc_mac: [u8; 32] = [0; 32]; + + transcript.challenge_bytes(b"SECRET_CONNECTION_MAC", &mut sc_mac); + + // Sign the challenge bytes for authentication. + let local_signature = if protocol_version.has_transcript() { + sign_challenge(&sc_mac, local_privkey)? + } else { + sign_challenge(&kdf.challenge, local_privkey)? + }; + + // Share (in secret) each other's pubkey & challenge signature + let auth_sig_msg = match local_pubkey { + PublicKey::Ed25519(ref pk) => share_auth_signature(&mut sc, pk, &local_signature)?, + }; + + let remote_pubkey = auth_sig_msg + .pub_key + .and_then(|pk| match pk.sum? { + proto::crypto::public_key::Sum::Ed25519(ref bytes) => { + ed25519::PublicKey::from_bytes(bytes).ok() + } + }) + .ok_or(Error::CryptoError)?; + + let remote_sig = ed25519::Signature::try_from(auth_sig_msg.sig.as_slice()) + .map_err(|_| Error::CryptoError)?; + + if protocol_version.has_transcript() { + remote_pubkey + .verify(&sc_mac, &remote_sig) + .map_err(|_| Error::CryptoError)?; + } else { + remote_pubkey + .verify(&kdf.challenge, &remote_sig) + .map_err(|_| Error::CryptoError)?; + } + + // We've authorized. + sc.remote_pubkey = Some(remote_pubkey.into()); + + Ok(sc) + } + + /// Encrypt AEAD authenticated data + fn encrypt( + &self, + chunk: &[u8], + sealed_frame: &mut [u8; TAG_SIZE + TOTAL_FRAME_SIZE], + ) -> Result<()> { + debug_assert!(chunk.len() <= TOTAL_FRAME_SIZE - DATA_LEN_SIZE); + sealed_frame[..DATA_LEN_SIZE].copy_from_slice(&(chunk.len() as u32).to_le_bytes()); + sealed_frame[DATA_LEN_SIZE..DATA_LEN_SIZE + chunk.len()].copy_from_slice(chunk); + + let tag = self + .send_cipher + .encrypt_in_place_detached( + GenericArray::from_slice(self.send_nonce.to_bytes()), + b"", + &mut sealed_frame[..TOTAL_FRAME_SIZE], + ) + .map_err(|_| Error::CryptoError)?; + + sealed_frame[TOTAL_FRAME_SIZE..].copy_from_slice(tag.as_slice()); + + Ok(()) + } + + /// Decrypt AEAD authenticated data + fn decrypt(&self, ciphertext: &[u8], out: &mut [u8]) -> Result { + if ciphertext.len() < TAG_SIZE { + return Err(Error::CryptoError).wrap_err_with(|| { + format!( + "ciphertext must be at least as long as a MAC tag {}", + TAG_SIZE + ) + }); + } + + // Split ChaCha20 ciphertext from the Poly1305 tag + let (ct, tag) = ciphertext.split_at(ciphertext.len() - TAG_SIZE); + + if out.len() < ct.len() { + return Err(Error::CryptoError).wrap_err("output buffer is too small"); + } + + let in_out = &mut out[..ct.len()]; + in_out.copy_from_slice(ct); + + self.recv_cipher + .decrypt_in_place_detached( + GenericArray::from_slice(self.recv_nonce.to_bytes()), + b"", + in_out, + tag.into(), + ) + .map_err(|_| Error::CryptoError)?; + + Ok(in_out.len()) + } +} + +impl Read for SecretConnection +where + IoHandler: Read + Write + Send + Sync, +{ + // CONTRACT: data smaller than dataMaxSize is read atomically. + fn read(&mut self, data: &mut [u8]) -> io::Result { + if !self.recv_buffer.is_empty() { + let n = cmp::min(data.len(), self.recv_buffer.len()); + data.copy_from_slice(&self.recv_buffer[..n]); + let mut leftover_portion = vec![0; self.recv_buffer.len().checked_sub(n).unwrap()]; + leftover_portion.clone_from_slice(&self.recv_buffer[n..]); + self.recv_buffer = leftover_portion; + + return Ok(n); + } + + let mut sealed_frame = [0u8; TAG_SIZE + TOTAL_FRAME_SIZE]; + self.io_handler.read_exact(&mut sealed_frame)?; + + // decrypt the frame + let mut frame = [0u8; TOTAL_FRAME_SIZE]; + let res = self.decrypt(&sealed_frame, &mut frame); + + if res.is_err() { + return Err(io::Error::new( + io::ErrorKind::Other, + res.err().unwrap().to_string(), + )); + } + + self.recv_nonce.increment(); + // end decryption + + let chunk_length = u32::from_le_bytes(frame[..4].try_into().unwrap()); + + if chunk_length as usize > DATA_MAX_SIZE { + return Err(io::Error::new( + io::ErrorKind::Other, + "chunk_length is greater than dataMaxSize", + )); + } + + let mut chunk = vec![0; chunk_length as usize]; + chunk.clone_from_slice( + &frame[DATA_LEN_SIZE..(DATA_LEN_SIZE.checked_add(chunk_length as usize).unwrap())], + ); + + let n = cmp::min(data.len(), chunk.len()); + data[..n].copy_from_slice(&chunk[..n]); + self.recv_buffer.copy_from_slice(&chunk[n..]); + + Ok(n) + } +} + +impl Write for SecretConnection +where + IoHandler: Read + Write + Send + Sync, +{ + // Writes encrypted frames of `sealedFrameSize` + // CONTRACT: data smaller than dataMaxSize is read atomically. + fn write(&mut self, data: &[u8]) -> io::Result { + let mut n = 0usize; + let mut data_copy = &data[..]; + while !data_copy.is_empty() { + let chunk: &[u8]; + if DATA_MAX_SIZE < data.len() { + chunk = &data[..DATA_MAX_SIZE]; + data_copy = &data_copy[DATA_MAX_SIZE..]; + } else { + chunk = data_copy; + data_copy = &[0u8; 0]; + } + let sealed_frame = &mut [0u8; TAG_SIZE + TOTAL_FRAME_SIZE]; + let res = self.encrypt(chunk, sealed_frame); + if res.is_err() { + return Err(io::Error::new( + io::ErrorKind::Other, + res.err().unwrap().to_string(), + )); + } + self.send_nonce.increment(); + // end encryption + + self.io_handler.write_all(&sealed_frame[..])?; + n = n.checked_add(chunk.len()).unwrap(); + } + + Ok(n) + } + + fn flush(&mut self) -> io::Result<()> { + self.io_handler.flush() + } +} + +/// Returns pubkey, private key +fn gen_eph_keys() -> (EphemeralPublic, EphemeralSecret) { + let local_privkey = EphemeralSecret::new(&mut OsRng); + let local_pubkey = EphemeralPublic::from(&local_privkey); + (local_pubkey, local_privkey) +} + +/// Returns remote_eph_pubkey +fn share_eph_pubkey( + handler: &mut IoHandler, + local_eph_pubkey: &EphemeralPublic, + protocol_version: Version, +) -> Result { + // Send our pubkey and receive theirs in tandem. + // TODO(ismail): on the go side this is done in parallel, here we do send and receive after + // each other. thread::spawn would require a static lifetime. + // Should still work though. + handler.write_all(&protocol_version.encode_initial_handshake(&local_eph_pubkey))?; + + let mut response_len = 0u8; + handler.read_exact(slice::from_mut(&mut response_len))?; + + let mut buf = vec![0; response_len as usize]; + handler.read_exact(&mut buf)?; + protocol_version.decode_initial_handshake(&buf) +} + +/// Return is of the form lo, hi +fn sort32(first: [u8; 32], second: [u8; 32]) -> ([u8; 32], [u8; 32]) { + if second > first { + (first, second) + } else { + (second, first) + } +} + +/// Sign the challenge with the local private key +fn sign_challenge( + challenge: &[u8; 32], + local_privkey: &dyn Signer, +) -> Result { + local_privkey + .try_sign(challenge) + .map_err(|_| Error::CryptoError.into()) +} + +// TODO(ismail): change from DecodeError to something more generic +// this can also fail while writing / sending +fn share_auth_signature( + sc: &mut SecretConnection, + pubkey: &ed25519::PublicKey, + local_signature: &ed25519::Signature, +) -> Result { + let buf = sc + .protocol_version + .encode_auth_signature(pubkey, &local_signature); + + sc.write_all(&buf)?; + + let mut buf = vec![0; sc.protocol_version.auth_sig_msg_response_len()]; + sc.read_exact(&mut buf)?; + sc.protocol_version.decode_auth_signature(&buf) +} + +#[cfg(tests)] +mod tests { + use super::*; + + #[test] + fn test_sort() { + // sanity check + let t1 = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + let t2 = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, + ]; + let (ref t3, ref t4) = sort32(t1, t2); + assert_eq!(t1, *t3); + assert_eq!(t2, *t4); + } + + #[test] + fn test_dh_compatibility() { + let local_priv = &[ + 15, 54, 189, 54, 63, 255, 158, 244, 56, 168, 155, 63, 246, 79, 208, 192, 35, 194, 39, + 232, 170, 187, 179, 36, 65, 36, 237, 12, 225, 176, 201, 54, + ]; + let remote_pub = &[ + 193, 34, 183, 46, 148, 99, 179, 185, 242, 148, 38, 40, 37, 150, 76, 251, 25, 51, 46, + 143, 189, 201, 169, 218, 37, 136, 51, 144, 88, 196, 10, 20, + ]; + + // generated using computeDHSecret in go + let expected_dh = &[ + 92, 56, 205, 118, 191, 208, 49, 3, 226, 150, 30, 205, 230, 157, 163, 7, 36, 28, 223, + 84, 165, 43, 78, 38, 126, 200, 40, 217, 29, 36, 43, 37, + ]; + let got_dh = diffie_hellman(local_priv, remote_pub); + + assert_eq!(expected_dh, &got_dh); + } +} diff --git a/p2p/src/secret_connection/amino_types.rs b/p2p/src/secret_connection/amino_types.rs new file mode 100644 index 000000000..e7fc58475 --- /dev/null +++ b/p2p/src/secret_connection/amino_types.rs @@ -0,0 +1,15 @@ +//! Amino types used by Secret Connection + +use prost_amino_derive::Message; + +/// Authentication signature message +#[derive(Clone, PartialEq, Message)] +pub struct AuthSigMessage { + /// Public key + #[prost_amino(bytes, tag = "1", amino_name = "tendermint/PubKeyEd25519")] + pub pub_key: Vec, + + /// Signature + #[prost_amino(bytes, tag = "2")] + pub sig: Vec, +} diff --git a/p2p/src/secret_connection/kdf.rs b/p2p/src/secret_connection/kdf.rs new file mode 100644 index 000000000..3c79ee4a9 --- /dev/null +++ b/p2p/src/secret_connection/kdf.rs @@ -0,0 +1,56 @@ +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::Zeroize; + +/// "Info" parameter to HKDF we use to personalize the derivation +const HKDF_INFO: &[u8] = b"TENDERMINT_SECRET_CONNECTION_KEY_AND_CHALLENGE_GEN"; + +/// Key Derivation Function for `SecretConnection` (HKDF) +pub struct Kdf { + /// Receiver's secret + pub recv_secret: [u8; 32], + + /// Sender's secret + pub send_secret: [u8; 32], + + /// Challenge to be signed by peer + pub challenge: [u8; 32], +} + +impl Kdf { + /// Returns recv secret, send secret, challenge as 32 byte arrays + pub fn derive_secrets_and_challenge(shared_secret: &[u8; 32], loc_is_lo: bool) -> Self { + let mut key_material = [0u8; 96]; + + Hkdf::::new(None, shared_secret) + .expand(HKDF_INFO, &mut key_material) + .unwrap(); + + let [mut recv_secret, mut send_secret, mut challenge] = [[0u8; 32]; 3]; + + if loc_is_lo { + recv_secret.copy_from_slice(&key_material[0..32]); + send_secret.copy_from_slice(&key_material[32..64]); + } else { + send_secret.copy_from_slice(&key_material[0..32]); + recv_secret.copy_from_slice(&key_material[32..64]); + } + + challenge.copy_from_slice(&key_material[64..96]); + key_material.as_mut().zeroize(); + + Kdf { + recv_secret, + send_secret, + challenge, + } + } +} + +impl Drop for Kdf { + fn drop(&mut self) { + self.recv_secret.zeroize(); + self.send_secret.zeroize(); + self.challenge.zeroize(); + } +} diff --git a/p2p/src/secret_connection/nonce.rs b/p2p/src/secret_connection/nonce.rs new file mode 100644 index 000000000..59c0038a9 --- /dev/null +++ b/p2p/src/secret_connection/nonce.rs @@ -0,0 +1,67 @@ +//! Secret Connection nonces + +use std::convert::TryInto; + +/// Size of a ChaCha20 (IETF) nonce +pub const SIZE: usize = 12; + +/// SecretConnection nonces (i.e. ChaCha20 nonces) +pub struct Nonce(pub [u8; SIZE]); + +impl Default for Nonce { + fn default() -> Nonce { + Nonce([0u8; SIZE]) + } +} + +impl Nonce { + /// Increment the nonce's counter by 1 + pub fn increment(&mut self) { + let counter: u64 = u64::from_le_bytes(self.0[4..].try_into().unwrap()); + self.0[4..].copy_from_slice(&counter.checked_add(1).unwrap().to_le_bytes()); + } + + /// Serialize nonce as bytes (little endian) + #[inline] + pub fn to_bytes(&self) -> &[u8] { + &self.0[..] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_incr_nonce() { + // make sure we match the golang implementation + let mut check_points: HashMap = HashMap::new(); + check_points.insert(0, &[0u8, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]); + check_points.insert(1, &[0u8, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0]); + check_points.insert(510, &[0u8, 0, 0, 0, 255, 1, 0, 0, 0, 0, 0, 0]); + check_points.insert(511, &[0u8, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0]); + check_points.insert(512, &[0u8, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0]); + check_points.insert(1023, &[0u8, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0]); + + let mut nonce = Nonce::default(); + assert_eq!(nonce.to_bytes().len(), SIZE); + + for i in 0..1024 { + nonce.increment(); + if let Some(want) = check_points.get(&i) { + let got = &nonce.to_bytes(); + assert_eq!(got, want); + } + } + } + #[test] + #[should_panic] + fn test_incr_nonce_overflow() { + // other than in the golang implementation we panic if we incremented more than 64 + // bits allow. + // In golang this would reset to an all zeroes nonce. + let mut nonce = Nonce([0u8, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255]); + nonce.increment(); + } +} diff --git a/p2p/src/secret_connection/protocol.rs b/p2p/src/secret_connection/protocol.rs new file mode 100644 index 000000000..0cdb4554a --- /dev/null +++ b/p2p/src/secret_connection/protocol.rs @@ -0,0 +1,262 @@ +//! Secret Connection Protocol: message framing and versioning + +use std::convert::TryInto; + +use ed25519_dalek as ed25519; +use eyre::{Report, Result, WrapErr}; +use prost::Message as _; + +#[cfg(feature = "amino")] +use prost_amino::Message as _; + +use x25519_dalek::PublicKey as EphemeralPublic; + +use tendermint_proto as proto; + +#[cfg(feature = "amino")] +use super::amino_types; + +use crate::error::Error; + +/// Size of an X25519 or Ed25519 public key +const PUBLIC_KEY_SIZE: usize = 32; + +/// Protocol version (based on the Tendermint version) +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[allow(non_camel_case_types)] +pub enum Version { + /// Tendermint v0.34 + V0_34, + + /// Tendermint v0.33 + V0_33, + + /// Pre-Tendermint v0.33 + Legacy, +} + +impl Version { + /// Does this version of Secret Connection use a transcript hash + pub fn has_transcript(self) -> bool { + self != Version::Legacy + } + + /// Are messages encoded using Protocol Buffers? + pub fn is_protobuf(self) -> bool { + match self { + Version::V0_34 => true, + Version::V0_33 | Version::Legacy => false, + } + } + + /// Encode the initial handshake message (i.e. first one sent by both peers) + pub fn encode_initial_handshake(self, eph_pubkey: &EphemeralPublic) -> Vec { + if self.is_protobuf() { + // Equivalent Go implementation: + // https://github.com/tendermint/tendermint/blob/9e98c74/p2p/conn/secret_connection.go#L307-L312 + // TODO(tarcieri): proper protobuf framing + let mut buf = Vec::new(); + buf.extend_from_slice(&[0x22, 0x0a, 0x20]); + buf.extend_from_slice(eph_pubkey.as_bytes()); + buf + } else { + // Legacy Amino encoded handshake message + // Equivalent Go implementation: + // https://github.com/tendermint/tendermint/blob/013b9ce/p2p/conn/secret_connection.go#L213-L217 + // + // Note: this is not regular protobuf encoding but raw length prefixed amino encoding; + // amino prefixes with the total length, and the raw bytes array's length, too: + let mut buf = Vec::new(); + buf.push(PUBLIC_KEY_SIZE as u8 + 1); + buf.push(PUBLIC_KEY_SIZE as u8); + buf.extend_from_slice(eph_pubkey.as_bytes()); + buf + } + } + + /// Decode the initial handshake message + pub fn decode_initial_handshake(self, bytes: &[u8]) -> Result { + let eph_pubkey = if self.is_protobuf() { + // Equivalent Go implementation: + // https://github.com/tendermint/tendermint/blob/9e98c74/p2p/conn/secret_connection.go#L315-L323 + // TODO(tarcieri): proper protobuf framing + if bytes.len() != 34 || bytes[..2] != [0x0a, 0x20] { + return Err(Error::ProtocolError) + .wrap_err("malformed handshake message (protocol version mismatch?)"); + } + + let eph_pubkey_bytes: [u8; 32] = bytes[2..].try_into().unwrap(); + EphemeralPublic::from(eph_pubkey_bytes) + } else { + // Equivalent Go implementation: + // https://github.com/tendermint/tendermint/blob/013b9ce/p2p/conn/secret_connection.go#L220-L225 + // + // Check that the length matches what we expect and the length prefix is correct + if bytes.len() != 33 || bytes[0] != 32 { + return Err(Error::ProtocolError) + .wrap_err("malformed handshake message (protocol version mismatch?)"); + } + + let eph_pubkey_bytes: [u8; 32] = bytes[1..].try_into().unwrap(); + EphemeralPublic::from(eph_pubkey_bytes) + }; + + // Reject the key if it is of low order + if is_low_order_point(&eph_pubkey) { + return Err(Error::InvalidKey).wrap_err("low order key"); + } + + Ok(eph_pubkey) + } + + /// Encode signature which authenticates the handshake + pub fn encode_auth_signature( + self, + pub_key: &ed25519::PublicKey, + signature: &ed25519::Signature, + ) -> Vec { + if self.is_protobuf() { + // Protobuf `AuthSigMessage` + let pub_key = proto::crypto::PublicKey { + sum: Some(proto::crypto::public_key::Sum::Ed25519( + pub_key.as_ref().to_vec(), + )), + }; + + let msg = proto::p2p::AuthSigMessage { + pub_key: Some(pub_key), + sig: signature.as_ref().to_vec(), + }; + + let mut buf = Vec::new(); + msg.encode_length_delimited(&mut buf) + .expect("couldn't encode AuthSigMessage proto"); + buf + } else { + self.encode_auth_signature_amino(pub_key, signature) + } + } + + /// Get the length of the auth message response for this protocol version + pub fn auth_sig_msg_response_len(self) -> usize { + if self.is_protobuf() { + // 32 + 64 + (proto overhead = 1 prefix + 2 fields + 2 lengths + total length) + 103 + } else { + // 32 + 64 + (amino overhead = 2 fields + 2 lengths + 4 prefix bytes + total length) + 106 + } + } + + /// Decode signature message which authenticates the handshake + pub fn decode_auth_signature(self, bytes: &[u8]) -> Result { + if self.is_protobuf() { + // Parse Protobuf-encoded `AuthSigMessage` + proto::p2p::AuthSigMessage::decode_length_delimited(bytes).map_err(|e| { + let message = format!( + "malformed handshake message (protocol version mismatch?): {}", + e + ); + return Report::new(Error::ProtocolError).wrap_err(message); + }) + } else { + self.decode_auth_signature_amino(bytes) + } + } + + #[cfg(feature = "amino")] + fn encode_auth_signature_amino( + self, + pub_key: &ed25519::PublicKey, + signature: &ed25519::Signature, + ) -> Vec { + // Legacy Amino encoded `AuthSigMessage` + let msg = amino_types::AuthSigMessage { + pub_key: pub_key.as_ref().to_vec(), + sig: signature.as_ref().to_vec(), + }; + + let mut buf = Vec::new(); + msg.encode_length_delimited(&mut buf) + .expect("encode_auth_signature failed"); + buf + } + + #[cfg(not(feature = "amino"))] + fn encode_auth_signature_amino( + self, + _: &ed25519::PublicKey, + _: &ed25519::Signature, + ) -> Vec { + panic!("attempted to encode auth signature using amino, but 'amino' feature is not present") + } + + #[cfg(feature = "amino")] + fn decode_auth_signature_amino(self, bytes: &[u8]) -> Result { + // Legacy Amino encoded `AuthSigMessage` + let amino_msg = amino_types::AuthSigMessage::decode_length_delimited(bytes)?; + let pub_key = proto::crypto::PublicKey { + sum: Some(proto::crypto::public_key::Sum::Ed25519(amino_msg.pub_key)), + }; + + Ok(proto::p2p::AuthSigMessage { + pub_key: Some(pub_key), + sig: amino_msg.sig, + }) + } + + #[cfg(not(feature = "amino"))] + fn decode_auth_signature_amino(self, _: &[u8]) -> Result { + panic!("attempted to decode auth signature using amino, but 'amino' feature is not present") + } +} + +/// Reject low order points listed on +/// +/// These points contain low-order X25519 field elements. Rejecting them is +/// suggested in the "May the Fourth" paper under Section 5: +/// Software Countermeasures (see "Rejecting Known Bad Points" subsection): +/// +/// +fn is_low_order_point(point: &EphemeralPublic) -> bool { + // Note: as these are public points and do not interact with secret-key + // material in any way, this check does not need to be performed in + // constant-time. + match point.as_bytes() { + // 0 (order 4) + &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] => { + true + } + + // 1 (order 1) + [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] => { + true + } + + // 325606250916557431795983626356110631294008115727848805560023387167927233504 (order 8) + &[0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, 0xb8, 0x00] => { + true + } + + // 39382357235489614581723060781553021112529911719440698176882885853963445705823 (order 8) + &[0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef, 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f, 0x11, 0x57] => { + true + } + + // p - 1 (order 2) + [0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f] => { + true + } + + // p (order 4) */ + [0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f] => { + true + } + + // p + 1 (order 1) + [0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f] => { + true + } + _ => false, + } +} diff --git a/p2p/src/secret_connection/public_key.rs b/p2p/src/secret_connection/public_key.rs new file mode 100644 index 000000000..1fc1bfb0f --- /dev/null +++ b/p2p/src/secret_connection/public_key.rs @@ -0,0 +1,84 @@ +//! Secret Connection peer public keys + +use ed25519_dalek as ed25519; +use sha2::{digest::Digest, Sha256}; +use std::fmt::{self, Display}; +use tendermint::{ + error::{self, Error}, + node, +}; + +/// Secret Connection peer public keys (signing, presently Ed25519-only) +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PublicKey { + /// Ed25519 Secret Connection Keys + Ed25519(ed25519::PublicKey), +} + +impl PublicKey { + /// From raw Ed25519 public key bytes + pub fn from_raw_ed25519(bytes: &[u8]) -> Result { + ed25519::PublicKey::from_bytes(bytes) + .map(PublicKey::Ed25519) + .map_err(|_| error::Kind::Crypto.into()) + } + + /// Get Ed25519 public key + pub fn ed25519(self) -> Option { + match self { + PublicKey::Ed25519(pk) => Some(pk), + } + } + + /// Get the remote Peer ID + pub fn peer_id(self) -> node::Id { + match self { + PublicKey::Ed25519(pk) => { + // TODO(tarcieri): use `tendermint::node::Id::from` + let digest = Sha256::digest(pk.as_bytes()); + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(&digest[..20]); + node::Id::new(bytes) + } + } + } +} + +impl Display for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.peer_id()) + } +} + +impl From<&ed25519::Keypair> for PublicKey { + fn from(sk: &ed25519::Keypair) -> PublicKey { + PublicKey::Ed25519(sk.public) + } +} + +impl From for PublicKey { + fn from(pk: ed25519::PublicKey) -> PublicKey { + PublicKey::Ed25519(pk) + } +} + +#[cfg(test)] +mod tests { + use super::PublicKey; + use subtle_encoding::hex; + + const EXAMPLE_SECRET_CONN_KEY: &str = + "F7FEB0B5BA0760B2C58893E329475D1EA81781DD636E37144B6D599AD38AA825"; + + #[test] + fn test_secret_connection_pubkey_serialization() { + let example_key = + PublicKey::from_raw_ed25519(&hex::decode_upper(EXAMPLE_SECRET_CONN_KEY).unwrap()) + .unwrap(); + + assert_eq!( + example_key.to_string(), + "117C95C4FD7E636C38D303493302D2C271A39669" + ); + } +}