From dce3b14c8ffc985f089338a080a2b0afdca642cc Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 3 Feb 2026 17:41:26 -0500 Subject: [PATCH 1/9] Expose handshake_hash --- src/cipher.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ src/state_machine.rs | 15 ++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/cipher.rs b/src/cipher.rs index da03466..dc49eb9 100644 --- a/src/cipher.rs +++ b/src/cipher.rs @@ -57,6 +57,14 @@ impl State { Self::Invalid => None, } } + + /// Get the handshake hash if available (only in Ready state). + fn handshake_hash(&self) -> Option<&[u8]> { + match self { + Self::Ready(s) => Some(s.handshake_hash()), + _ => None, + } + } } /// A ["Sans-IO"](https://fasterthanli.me/articles/the-case-for-sans-io) implementation of all the @@ -256,6 +264,11 @@ impl SansIoCipher { fn get_remote_static(&self) -> Option<[u8; PUBLIC_KEYLEN]> { self.state.get_remote_static() } + + /// Get the handshake hash if available (only after handshake completes). + fn handshake_hash(&self) -> Option<&[u8]> { + self.state.handshake_hash() + } } impl Debug for SansIoCipher { @@ -527,6 +540,16 @@ impl Cipher { pub fn get_remote_static(&self) -> Option<[u8; PUBLIC_KEYLEN]> { self.inner.get_remote_static() } + + /// Get the handshake hash. + /// + /// This is a unique identifier for this encrypted session, the same on both sides. + /// Used for capability verification in hypercore replication. + /// + /// Returns `None` until the handshake is complete. + pub fn handshake_hash(&self) -> Option<&[u8]> { + self.inner.handshake_hash() + } } impl Stream for Cipher { @@ -1041,4 +1064,33 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_handshake_hash_same_on_both_sides() -> Result<(), Error> { + let (_, (mut lm, mut rm)) = connected_machines(); + + // Before handshake: no handshake hash available + assert!(lm.handshake_hash().is_none()); + assert!(rm.handshake_hash().is_none()); + + let (rl, rr) = join!(lm.complete_handshake(), rm.complete_handshake()); + rl?; + rr?; + + // After handshake: both sides should have the same handshake hash + let lm_hash = lm.handshake_hash(); + let rm_hash = rm.handshake_hash(); + + assert!(lm_hash.is_some(), "initiator should have handshake hash"); + assert!(rm_hash.is_some(), "responder should have handshake hash"); + assert_eq!( + lm_hash, rm_hash, + "handshake hash should be identical on both sides" + ); + + // Hash should be 64 bytes (BLAKE2b output) + assert_eq!(lm_hash.unwrap().len(), 64); + + Ok(()) + } } diff --git a/src/state_machine.rs b/src/state_machine.rs index a7039c3..6f6e3da 100644 --- a/src/state_machine.rs +++ b/src/state_machine.rs @@ -136,6 +136,7 @@ pub struct EncryptorReady { pub struct Ready { puller: PullStream, pusher: PushStream, + handshake_hash: Vec, } impl Debug for EncryptorReady { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -151,6 +152,7 @@ impl Debug for Ready { f.debug_struct("Ready") .field("pusher", &"PushStream(..)") .field("puller", &"PullStream(..)") + .field("handshake_hash", &self.handshake_hash) .finish() } } @@ -474,7 +476,11 @@ impl SecStream { is_initiator, state, msg_buf, - step: Ready { pusher, puller }, + step: Ready { + pusher, + puller, + handshake_hash, + }, }) } @@ -503,4 +509,11 @@ impl SecStream { pub fn pull(&mut self, msg: &mut Vec, associated_data: &[u8]) -> Result { Ok(self.step.puller.pull(msg, associated_data)?) } + /// Get the handshake hash. + /// + /// This is a unique identifier for this encrypted session, the same on both sides. + /// Used for capability verification in hypercore replication. + pub fn handshake_hash(&self) -> &[u8] { + &self.step.handshake_hash + } } From 88601564d7e570e3253554e87d2a560a285493dd Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Mon, 9 Feb 2026 18:42:10 -0500 Subject: [PATCH 2/9] Add integration tests --- tests/common/js/Makefile | 7 ++ tests/common/js/mod.rs | 18 ++++ tests/common/js/package.json | 9 ++ tests/common/js/yarn.lock | 162 +++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 5 ++ tests/js_integration.rs | 157 +++++++++++++++++++++++++++++++++ 6 files changed, 358 insertions(+) create mode 100644 tests/common/js/Makefile create mode 100644 tests/common/js/mod.rs create mode 100644 tests/common/js/package.json create mode 100644 tests/common/js/yarn.lock create mode 100644 tests/common/mod.rs create mode 100644 tests/js_integration.rs diff --git a/tests/common/js/Makefile b/tests/common/js/Makefile new file mode 100644 index 0000000..e9f3e30 --- /dev/null +++ b/tests/common/js/Makefile @@ -0,0 +1,7 @@ +JS_SOURCES := $(wildcard *.js); + +data: node_modules $(JS_SOURCES) + yarn node index.js + +node_modules: package.json + yarn install diff --git a/tests/common/js/mod.rs b/tests/common/js/mod.rs new file mode 100644 index 0000000..e82946c --- /dev/null +++ b/tests/common/js/mod.rs @@ -0,0 +1,18 @@ +use rusty_nodejs_repl::{ + integration_utils::{git_root, run_make}, + join_paths, +}; + +use std::{path::PathBuf, sync::LazyLock}; + +pub static REL_PATH_TO_NODE_MODULES: &str = "./tests/common/js/node_modules"; +pub static REL_PATH_TO_JS_DIR: &str = "./tests/common/js"; + +pub static REQUIRE_JS: LazyLock<()> = LazyLock::new(|| { + let _ = run_make(REL_PATH_TO_JS_DIR, "node_modules").expect("Failed to setup node_modules"); +}); + +pub fn path_to_node_modules() -> Result> { + let p = join_paths!(git_root()?, &REL_PATH_TO_NODE_MODULES); + Ok(p.into()) +} diff --git a/tests/common/js/package.json b/tests/common/js/package.json new file mode 100644 index 0000000..c7ff26a --- /dev/null +++ b/tests/common/js/package.json @@ -0,0 +1,9 @@ +{ + "name": "stuff", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@hyperswarm/secret-stream": "^6.9.1" + } +} diff --git a/tests/common/js/yarn.lock b/tests/common/js/yarn.lock new file mode 100644 index 0000000..722f5e6 --- /dev/null +++ b/tests/common/js/yarn.lock @@ -0,0 +1,162 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@hyperswarm/secret-stream@^6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz#8cb2572777d667519652aceceaf462522275e238" + integrity sha512-xb0S5y3YJwBakD77JOGBHlBxdp63mHClZoXBYoLv+9wH8e054ESKlmQptWqjJK5dv5VMUIVYOJB4MaOpB0JdGw== + dependencies: + b4a "^1.1.0" + hypercore-crypto "^3.3.1" + noise-curve-ed "^2.0.1" + noise-handshake "^4.0.0" + sodium-secretstream "^1.1.0" + sodium-universal "^5.0.0" + streamx "^2.14.0" + timeout-refresh "^2.0.0" + unslab "^1.3.0" + +b4a@^1.1.0, b4a@^1.1.1, b4a@^1.3.0, b4a@^1.6.4, b4a@^1.6.6: + version "1.7.3" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.7.3.tgz#24cf7ccda28f5465b66aec2bac69e32809bf112f" + integrity sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q== + +bare-addon-resolve@^1.3.0: + version "1.9.7" + resolved "https://registry.yarnpkg.com/bare-addon-resolve/-/bare-addon-resolve-1.9.7.tgz#ef2f667433d67c8550df8a4f3ebb2f3514bfb3b9" + integrity sha512-cIo93Y4Maw8ZNrfq5qlvRBqCHUyeSzMyFBqJRNi91SOspeWiFq3ixiq0ExYWmtOBauIkCLod2Mng73xr7fmkXA== + dependencies: + bare-module-resolve "^1.10.0" + bare-semver "^1.0.0" + +bare-events@^2.7.0: + version "2.8.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" + integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== + +bare-module-resolve@^1.10.0: + version "1.12.1" + resolved "https://registry.yarnpkg.com/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz#4847c1c91a6fce124b45bc36f97caa6bf6658d42" + integrity sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg== + dependencies: + bare-semver "^1.0.0" + +bare-semver@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bare-semver/-/bare-semver-1.0.2.tgz#3cfc47ed5d3e809b369daec534ce916b70b83b8c" + integrity sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA== + +compact-encoding@^2.15.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/compact-encoding/-/compact-encoding-2.18.0.tgz#d4a017525086eb05ccb118ecc97c3a2b1fd016ee" + integrity sha512-goACAOlhMI2xo5jGOMUDfOLnGdRE1jGfyZ+zie8N5114nHrbPIqf6GLUtzbLof6DSyrERlYRm3EcBplte5LcQw== + dependencies: + b4a "^1.3.0" + +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + +fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + +hypercore-crypto@^3.3.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/hypercore-crypto/-/hypercore-crypto-3.6.1.tgz#582bc3d4c4e3e6f8adf6b86222c8db73006aadfa" + integrity sha512-ltIz2uDwy9pO/ZGTvqcjzyBkvt6O4cVm4r/nNxh0GFs/RbQtqP/i4wCvLEdmU7ptgtnw7fI67WYD1aHPuv4OVA== + dependencies: + b4a "^1.6.6" + compact-encoding "^2.15.0" + sodium-universal "^5.0.0" + +nanoassert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-2.0.0.tgz#a05f86de6c7a51618038a620f88878ed1e490c09" + integrity sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA== + +noise-curve-ed@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz#38ab0748439baa0f21d49c78d7b8be3d5bb94f42" + integrity sha512-zAzJx+VwZM3w6EA1hTmDhJfvAnCeBQn/1FAeZ0LtGxCcCtlAK/uJXQVF/eDVUOaAZ286lHlx77WJ+qj9SmsRRg== + dependencies: + b4a "^1.1.0" + nanoassert "^2.0.0" + sodium-universal "^5.0.0" + +noise-handshake@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/noise-handshake/-/noise-handshake-4.2.0.tgz#e1acf924bff735b3c5f8766e9ca4e2ca750e951a" + integrity sha512-9O/VTNX/E2/AToyMTTDU0J/4WhaXMTdqc2DHs9vf+snoZ0cenSBq0dNYTVV1snYYEkmo6QeRrYMxtqtoYnY+LA== + dependencies: + b4a "^1.1.0" + nanoassert "^2.0.0" + sodium-universal "^5.0.0" + +require-addon@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/require-addon/-/require-addon-1.2.0.tgz#b6a969805b82f5ed8b2ecf29453b090ca9933c89" + integrity sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA== + dependencies: + bare-addon-resolve "^1.3.0" + +sodium-native@^5.0.1: + version "5.0.10" + resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-5.0.10.tgz#a7ce4ce80c8fcd405fab1b5f0444b5367c700462" + integrity sha512-UIw+0AbpCQRuTJF88JWrZomP4O+PXhlWvdopiAJOsUivTyHTf3korMyStxkZuPngSbBEtEfDdc4ewEd8/T4/lA== + dependencies: + require-addon "^1.1.0" + which-runtime "^1.2.1" + +sodium-secretstream@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/sodium-secretstream/-/sodium-secretstream-1.2.0.tgz#256c6e7c79907abce73038de187128c985aeed35" + integrity sha512-q/DbraNFXm1KfCiiZvapmz5UC3OlpirYFIvBK2MhGaOFSb3gRyk8OXTi17UI9SGfshQNCpsVvlopogbzZNyW6Q== + dependencies: + b4a "^1.1.1" + sodium-universal "^5.0.0" + +sodium-universal@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/sodium-universal/-/sodium-universal-5.0.1.tgz#b06c0a52256f19d2bf071ea031476f4306831dfd" + integrity sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q== + dependencies: + sodium-native "^5.0.1" + +streamx@^2.14.0: + version "2.23.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.23.0.tgz#7d0f3d00d4a6c5de5728aecd6422b4008d66fd0b" + integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + +text-decoder@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" + integrity sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA== + dependencies: + b4a "^1.6.4" + +timeout-refresh@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/timeout-refresh/-/timeout-refresh-2.0.1.tgz#f8ec7cf1f9d93b2635b7d4388cb820c5f6c16f98" + integrity sha512-SVqEcMZBsZF9mA78rjzCrYrUs37LMJk3ShZ851ygZYW1cMeIjs9mL57KO6Iv5mmjSQnOe/29/VAfGXo+oRCiVw== + +unslab@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/unslab/-/unslab-1.3.0.tgz#6b5c21c873f2adc34ac10475438344f51f681988" + integrity sha512-YATkfKAFj47kTzmiQrWXMyRvaVrHsW6MEALa4bm+FhiA2YG4oira+Z3DXN6LrYOYn2Y8eO94Lwl9DOHjs1FpoQ== + dependencies: + b4a "^1.6.6" + +which-runtime@^1.2.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/which-runtime/-/which-runtime-1.3.2.tgz#66b9cde497c3be5801b24ce9ea9aba74e427d194" + integrity sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw== diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..ba81b24 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,5 @@ +pub mod js; + +pub type Result = core::result::Result>; + +pub static LOOPBACK: &str = "127.0.0.1"; diff --git a/tests/js_integration.rs b/tests/js_integration.rs new file mode 100644 index 0000000..e9cd169 --- /dev/null +++ b/tests/js_integration.rs @@ -0,0 +1,157 @@ +mod common; + +use futures::SinkExt; +use futures_lite::StreamExt; +use hypercore_handshake::{Cipher, CipherEvent, Error, state_machine::SecStream}; +use tokio::{join, net::TcpListener}; +use tokio_util::compat::TokioAsyncReadCompatExt; +use uint24le_framing::Uint24LELengthPrefixedFraming; + +use rusty_nodejs_repl::{Config, Repl}; + +use common::{ + LOOPBACK, Result, + js::{REQUIRE_JS, path_to_node_modules}, +}; + +async fn setup_rust_responder_js_initiator() -> Result<(Repl, Cipher)> { + let _ = &*REQUIRE_JS; + let kp = hypercore_handshake::state_machine::hc_specific::generate_keypair()?; + + let listener = TcpListener::bind(format!("{}:0", LOOPBACK)).await?; + let port = listener.local_addr()?.port(); + let hostname = LOOPBACK; + let setup_rs = async move { + let tcp = listener.accept().await?.0; + + // Setup Cipher here + let framed = Uint24LELengthPrefixedFraming::new(tcp.compat()); + let resp = SecStream::new_responder(&kp.private)?; + let cipher = Cipher::new_resp(Box::new(framed), resp); + Ok::<_, Error>(cipher) + }; + + let setup_js = async move { + let pub_key_str = kp + .public + .iter() + .map(|x| x.to_string()) + .collect::>() + .join(", "); + let pub_key_str = format!("[{pub_key_str}]"); + + let mut conf = Config::build()?; + conf.imports.push( + " +NoiseSecretStream = require('@hyperswarm/secret-stream'); +net = require('net'); + " + .to_string(), + ); + conf.path_to_node_modules = Some(path_to_node_modules()?.display().to_string()); + let mut repl = conf.start().await?; + repl.run(format!( + " +socket = net.connect('{port}', '{hostname}'); +noiseStream = new NoiseSecretStream(true, socket, {{ + pattern: 'IK', + remotePublicKey: Buffer.from({pub_key_str}, ) +}}); + " + )) + .await?; + Ok::>(repl) + }; + let (cipher, repl) = join!(setup_rs, setup_js); + let cipher = cipher?; + let repl: Repl = repl?; + Ok((repl, cipher)) +} +#[tokio::test] +async fn rust_responder_js_initiator_js_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_rust_responder_js_initiator().await?; + + let rs = async move { + let x = cipher.next().await.unwrap(); + assert!(matches!(x, CipherEvent::HandshakePayload(_))); + + let CipherEvent::Message(msg) = cipher.next().await.unwrap() else { + panic!(); + }; + assert_eq!(msg, b"aaaa"); + cipher.send(b"zzzz".to_vec()).await?; + Ok::<_, Error>(cipher) + }; + let js = async move { + let _ = repl + .run( + " + +js_rx_first_msg = Deferred(); +datas = [] +noiseStream.on('data', (data) => {{ + js_rx_first_msg.resolve([...data]); + datas.push(data); +}}) +// js sends first message +noiseStream.write(Buffer.from('aaaa')); +", + ) + .await?; + + let js_rx_first_msg: Vec = repl.get_name("js_rx_first_msg").await?; + assert_eq!(js_rx_first_msg, b"zzzz"); + Ok::>(repl) + }; + let (cipher, repl) = join!(rs, js); + cipher?; + repl?; + Ok(()) +} + +#[tokio::test] +async fn rust_responder_js_initiator_rs_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_rust_responder_js_initiator().await?; + + let rs = async move { + // TODO FIXME why do I have to listen for the HandshakePayload before sending? + let x = cipher.next().await.unwrap(); + assert!(matches!(x, CipherEvent::HandshakePayload(_))); + cipher.send(b"zzzz".to_vec()).await?; + let CipherEvent::Message(msg) = cipher.next().await.unwrap() else { + panic!(); + }; + assert_eq!(msg, b"aaa"); + Ok::<_, Error>(cipher) + }; + let js = async move { + let _ = repl + .run( + " + +js_rx_first_msg = Deferred(); +datas = [] +noiseStream.on('data', (data) => {{ + js_rx_first_msg.resolve([...data]); + datas.push(data); +}}) +// js wait to send msg +", + ) + .await?; + + let js_rx_first_msg: Vec = repl.get_name("js_rx_first_msg").await?; + assert_eq!(js_rx_first_msg, b"zzzz"); + repl.run( + " +noiseStream.write(Buffer.from('aaa')); + ", + ) + .await?; + Ok::>(repl) + }; + let (cipher, repl) = join!(rs, js); + cipher?; + repl?; + Ok(()) +} From a0ec13f1dd6270764d19f7a75f9b44e14a6b9833 Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 00:01:44 -0500 Subject: [PATCH 3/9] fix bug getting handshake_hash --- src/cipher.rs | 1 + src/state_machine.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/cipher.rs b/src/cipher.rs index dc49eb9..9a5623f 100644 --- a/src/cipher.rs +++ b/src/cipher.rs @@ -62,6 +62,7 @@ impl State { fn handshake_hash(&self) -> Option<&[u8]> { match self { Self::Ready(s) => Some(s.handshake_hash()), + Self::EncReady(s) => Some(s.handshake_hash()), _ => None, } } diff --git a/src/state_machine.rs b/src/state_machine.rs index 6f6e3da..6083288 100644 --- a/src/state_machine.rs +++ b/src/state_machine.rs @@ -446,6 +446,13 @@ impl SecStream> { } impl SecStream { + /// Get the handshake hash. + /// + /// This is a unique identifier for this encrypted session, the same on both sides. + /// Used for capability verification in hypercore replication. + pub fn handshake_hash(&self) -> &[u8] { + &self.step.handshake_hash + } /// Recieve message the last message, used to set up the decryption stream pub fn read_msg(self, msg: &[u8]) -> Result, Error> { let Self { From d320705153472ab570bd9663704891a6fddf9e47 Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 00:01:59 -0500 Subject: [PATCH 4/9] better Debug --- src/cipher.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/cipher.rs b/src/cipher.rs index 9a5623f..4d0d2d9 100644 --- a/src/cipher.rs +++ b/src/cipher.rs @@ -30,18 +30,14 @@ pub(crate) enum State { impl Debug for State { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self::InitiatorStart(_) => "InitiatorStart", - Self::InitiatorSent(_) => "InitiatorSent", - Self::RespStart(_) => "RespStart", - Self::EncReady(_) => "EncReady", - Self::Ready(_) => "Ready", - Self::Invalid => "Invalid", - } - ) + match self { + Self::InitiatorStart(arg0) => f.debug_tuple("InitiatorStart").field(arg0).finish(), + Self::InitiatorSent(arg0) => f.debug_tuple("InitiatorSent").field(arg0).finish(), + Self::RespStart(arg0) => f.debug_tuple("RespStart").field(arg0).finish(), + Self::EncReady(arg0) => f.debug_tuple("EncReady").field(arg0).finish(), + Self::Ready(arg0) => f.debug_tuple("Ready").field(arg0).finish(), + Self::Invalid => write!(f, "Invalid"), + } } } From 2db959466bbf97e3b39e898041010f266eae2348 Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 00:03:50 -0500 Subject: [PATCH 5/9] rm extra debug --- src/cipher.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/cipher.rs b/src/cipher.rs index 4d0d2d9..58f29ed 100644 --- a/src/cipher.rs +++ b/src/cipher.rs @@ -451,14 +451,6 @@ impl Cipher { /// Encrypt outgoing messages, and decrypt encomming messages. /// This also processes messages to complete the handshake. fn poll_encrypt_decrypt(&mut self) -> Result, IoError> { - trace!( - state =? self.inner.state, - plain_tx = self.inner.plain_tx.len(), - plain_rx = self.inner.plain_rx.len(), - enc_tx = self.inner.encrypted_tx.len(), - enc_rx = self.inner.encrypted_rx.len(), - "poll_encrypt_decrypt before" - ); self.inner.poll_encrypt_decrypt() } From 22d95d810d87158779c8260d06271298c777a98a Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 00:07:07 -0500 Subject: [PATCH 6/9] hide useles Debug info --- src/state_machine.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/state_machine.rs b/src/state_machine.rs index 6083288..20f4242 100644 --- a/src/state_machine.rs +++ b/src/state_machine.rs @@ -70,8 +70,6 @@ impl std::fmt::Debug for SecStream { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SecStream") .field("is_initiator", &self.is_initiator) - .field("state", &self.state) - .field("msg_buf", &"[...]") .field("step", &self.step) .finish() } @@ -140,20 +138,12 @@ pub struct Ready { } impl Debug for EncryptorReady { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("InitiatorEnc") - .field("rx", &"Key(..)") - .field("pusher", &"PushStream(..)") - .field("handshake_hash", &self.handshake_hash) - .finish() + f.debug_struct("EncryptorReady").finish() } } impl Debug for Ready { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Ready") - .field("pusher", &"PushStream(..)") - .field("puller", &"PullStream(..)") - .field("handshake_hash", &self.handshake_hash) - .finish() + f.debug_struct("Ready").finish() } } pub mod hc_specific { From c7fc511277589bae8b27745fd826957b2fe7d3a1 Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 12:01:43 -0500 Subject: [PATCH 7/9] tests working!!! but need commit upstream stuff --- Cargo.toml | 4 ++ tests/js_integration.rs | 147 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 138 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 88e7a10..59b0636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,4 +20,8 @@ curve25519-dalek = "4" ed25519-dalek = { version = "2", features = ["rand_core"] } [dev-dependencies] +futures-lite = "2.6.1" +rusty_nodejs_repl = { version = "0.4.0", features = ["integration_utils"] } tokio = { version = "1.27.0", features = ["macros", "rt-multi-thread", "time" ] } +tokio-util = { version = "0.7.18", features = ["compat"]} +uint24le_framing = "0.2.0" diff --git a/tests/js_integration.rs b/tests/js_integration.rs index e9cd169..1d71b4c 100644 --- a/tests/js_integration.rs +++ b/tests/js_integration.rs @@ -1,3 +1,6 @@ +//! Test [`hypercore_handshake::Cipher`] against JavaScript. +//! Note: where tests are named "rust_initiator_..." then JavaScript is the responder (and rust is +//! the initiator) and vice versa. Likewise where we say '..._rust_tx_first'. mod common; use futures::SinkExt; @@ -14,13 +17,81 @@ use common::{ js::{REQUIRE_JS, path_to_node_modules}, }; -async fn setup_rust_responder_js_initiator() -> Result<(Repl, Cipher)> { +fn js_list_from_bytes(b: &[u8]) -> String { + let out_str = b + .iter() + .map(|x| x.to_string()) + .collect::>() + .join(", "); + format!("[{out_str}]") +} + +async fn setup_rust_initiator() -> Result<(Repl, Cipher)> { let _ = &*REQUIRE_JS; let kp = hypercore_handshake::state_machine::hc_specific::generate_keypair()?; + let pub_key_str = js_list_from_bytes(&kp.public); + let full_secret: Vec = [&kp.private[..], &kp.public[..]].concat(); + let sec_key_str = js_list_from_bytes(&full_secret); let listener = TcpListener::bind(format!("{}:0", LOOPBACK)).await?; let port = listener.local_addr()?.port(); let hostname = LOOPBACK; + + let setup_rs = async move { + let tcp = listener.accept().await?.0; + + // Setup Cipher here + let framed = Uint24LELengthPrefixedFraming::new(tcp.compat()); + + let init = SecStream::new_initiator(&kp.public.try_into().unwrap(), &[])?; + let cipher = Cipher::new_init(Box::new(framed), init); + + Ok::<_, Error>(cipher) + }; + + let setup_js = async move { + let mut conf = Config::build()?; + conf.imports.push( + " +NoiseSecretStream = require('@hyperswarm/secret-stream'); +net = require('net'); + " + .to_string(), + ); + conf.path_to_node_modules = Some(path_to_node_modules()?.display().to_string()); + let mut repl = conf.start().await?; + repl.run(format!( + " +console.log({pub_key_str}); +console.log({sec_key_str}); +socket = net.connect('{port}', '{hostname}'); +noiseStream = new NoiseSecretStream(false, socket, {{ + pattern: 'IK', + keyPair: {{ + publicKey: Buffer.from({pub_key_str}), + secretKey: Buffer.from({sec_key_str}), + + }}, +}}); + " + )) + .await?; + Ok::>(repl) + }; + let (cipher, repl) = join!(setup_rs, setup_js); + let cipher = cipher?; + let repl: Repl = repl?; + Ok((repl, cipher)) +} +async fn setup_js_initiator() -> Result<(Repl, Cipher)> { + let _ = &*REQUIRE_JS; + let kp = hypercore_handshake::state_machine::hc_specific::generate_keypair()?; + let pub_key_str = js_list_from_bytes(&kp.public); + + let listener = TcpListener::bind(format!("{}:0", LOOPBACK)).await?; + let port = listener.local_addr()?.port(); + let hostname = LOOPBACK; + let setup_rs = async move { let tcp = listener.accept().await?.0; @@ -32,14 +103,6 @@ async fn setup_rust_responder_js_initiator() -> Result<(Repl, Cipher)> { }; let setup_js = async move { - let pub_key_str = kp - .public - .iter() - .map(|x| x.to_string()) - .collect::>() - .join(", "); - let pub_key_str = format!("[{pub_key_str}]"); - let mut conf = Config::build()?; conf.imports.push( " @@ -68,8 +131,63 @@ noiseStream = new NoiseSecretStream(true, socket, {{ Ok((repl, cipher)) } #[tokio::test] -async fn rust_responder_js_initiator_js_tx_first() -> Result<()> { - let (mut repl, mut cipher) = setup_rust_responder_js_initiator().await?; +async fn rust_initiator_js_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_rust_initiator().await?; + repl.run( + " +console.log('ran'); +noiseStream.on('data', (data) => {{ + + console.log('got data!'); + console.log(data); +}}) +noiseStream.write(Buffer.from('ccc')); +", + ) + .await?; + let x = cipher.next().await.unwrap(); + assert!(matches!(x, CipherEvent::HandshakePayload(_))); + let CipherEvent::Message(msg) = cipher.next().await.unwrap() else { + panic!(); + }; + assert_eq!(msg, b"ccc"); + Ok(()) +} + +#[tokio::test] +async fn rust_initiator_rs_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_rust_initiator().await?; + cipher.send(b"hello from rust".to_vec()).await?; + repl.run( + " +js_rx_msg = Deferred(); +noiseStream.on('data', (data) => {{ + console.log('got data!', data); + js_rx_msg.resolve([...data]); +}}) +", + ) + .await?; + let js_rx_msg: Vec = repl.get_name("js_rx_msg").await?; + assert_eq!(js_rx_msg, b"hello from rust"); + repl.run( + " +noiseStream.write(Buffer.from('hello from js')); +", + ) + .await?; + let x = cipher.next().await.unwrap(); + assert!(matches!(x, CipherEvent::HandshakePayload(_))); + let CipherEvent::Message(msg) = cipher.next().await.unwrap() else { + panic!() + }; + assert_eq!(msg, b"hello from js"); + Ok(()) +} + +#[tokio::test] +async fn js_initiator_js_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_js_initiator().await?; let rs = async move { let x = cipher.next().await.unwrap(); @@ -110,13 +228,14 @@ noiseStream.write(Buffer.from('aaaa')); } #[tokio::test] -async fn rust_responder_js_initiator_rs_tx_first() -> Result<()> { - let (mut repl, mut cipher) = setup_rust_responder_js_initiator().await?; +async fn js_initiator_rs_tx_first() -> Result<()> { + let (mut repl, mut cipher) = setup_js_initiator().await?; let rs = async move { // TODO FIXME why do I have to listen for the HandshakePayload before sending? let x = cipher.next().await.unwrap(); assert!(matches!(x, CipherEvent::HandshakePayload(_))); + cipher.send(b"zzzz".to_vec()).await?; let CipherEvent::Message(msg) = cipher.next().await.unwrap() else { panic!(); @@ -124,6 +243,7 @@ async fn rust_responder_js_initiator_rs_tx_first() -> Result<()> { assert_eq!(msg, b"aaa"); Ok::<_, Error>(cipher) }; + let js = async move { let _ = repl .run( @@ -150,6 +270,7 @@ noiseStream.write(Buffer.from('aaa')); .await?; Ok::>(repl) }; + let (cipher, repl) = join!(rs, js); cipher?; repl?; From 904b4ac79aa7dce555c4595d997d87f6754d536e Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 12:40:33 -0500 Subject: [PATCH 8/9] Add node_modules to .gitignore --- tests/common/js/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/common/js/.gitignore diff --git a/tests/common/js/.gitignore b/tests/common/js/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/tests/common/js/.gitignore @@ -0,0 +1 @@ +node_modules From ebc8dde3f61479969f1c34eacc9e99fe12795a93 Mon Sep 17 00:00:00 2001 From: Blake Griffith Date: Tue, 10 Feb 2026 12:48:04 -0500 Subject: [PATCH 9/9] unused make targets --- tests/common/js/Makefile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/common/js/Makefile b/tests/common/js/Makefile index e9f3e30..6d8b9fe 100644 --- a/tests/common/js/Makefile +++ b/tests/common/js/Makefile @@ -1,7 +1,2 @@ -JS_SOURCES := $(wildcard *.js); - -data: node_modules $(JS_SOURCES) - yarn node index.js - node_modules: package.json yarn install