Files
buh/crates/buh-api/tests/pqmtls_handshake.rs
rob thijssen 9a9cf0d609 feat(tls): decentralised PQ-mTLS ingress + per-node CA trust model (Phase 6)
Every node is its own CA — no central PKI, no step-ca. Peers are pinned per-CA
by fingerprint (not a shared root); a peer is refused the instant its CA is
distrusted. The TLS key exchange is the X25519MLKEM768 hybrid, so a recorded
handshake is not harvest-now-decrypt-later material.

- buh-core: NodePki/NodeLeaf/PeerTrustRegistry/TrustedPeer ports (DER bytes +
  fingerprint strings only — core stays free of rustls/rcgen); Ctx.pki +
  Ctx.peer_trust.
- buh-data: RcgenNodeCa (ECDSA-P256 CA persisted under the pki dir; fingerprint
  stable across restart; load_or_init/rekey/issue_leaf) and TursoPeerTrust
  (migration 0002_peer_trust.sql). DataStack::with_node_pki.
- buh-api/src/tls.rs: pq_provider() (X25519MLKEM768 first), custom
  PinnedClient/ServerCertVerifier (sync, pin CA-by-fingerprint at the chain tail
  + verify leaf↔CA signature + validity via x509-parser), RotatingResolver,
  NodeTls. main.rs serves plain on `bind` OR PQ-mTLS on [pki].node_bind
  (BUH_NODE_PORT) via tokio-rustls + hyper-util, with an in-process
  leaf-rotation/trust-refresh timer. /v1/health advertises ca_fingerprint.
- buh-cli: ca init|show|rotate --force, peer trust|distrust|list.
- web: invite carries the node's real CA fingerprint (from /v1/health, zero in
  plain dev); relay.pinCa/verifyPinnedCa make the app-layer pin explicit; demo
  + UI surface it.
- test: hermetic two-node handshake succeeds only with mutual per-CA pinning AND
  X25519MLKEM768; refused without a pin; refused after distrust.

The CA signatures are classical (ECDSA-P256) by design: HNDL threatens
confidentiality (the key exchange, which is PQ), not signatures that only need
to hold at handshake time.

The node never links buh-crypto; buh-crypto links none of the TLS deps.
cargo test/clippy(--all-features)/fmt + wasm-pack + web build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EB3LjarCdXxqrJ4tFLn8LB
2026-06-30 15:47:06 +03:00

193 lines
6.2 KiB
Rust

//! Hermetic PQ-mTLS handshake between two in-process nodes (`doc/design.md` §5.1, Phase 6).
//!
//! No external CA, no network beyond loopback. Each node is its own CA; a handshake succeeds
//! only when **each side pins the other's CA fingerprint**, the key exchange is the post-quantum
//! X25519MLKEM768 hybrid, and a peer is refused the instant its CA is distrusted.
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::{TlsAcceptor, TlsConnector};
use rustls::NamedGroup;
use rustls::pki_types::ServerName;
use buh_api::tls::{NodeTls, TrustStore};
use buh_core::NodePki;
use buh_data::RcgenNodeCa;
/// Spin up a node CA in a fresh temp dir. The dir is leaked so the CA files outlive the call.
fn node_ca() -> Arc<dyn NodePki> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
std::mem::forget(dir);
Arc::new(
RcgenNodeCa::load_or_init(path, vec!["node".to_string()], Duration::from_secs(3600))
.expect("init node CA"),
)
}
/// Run a one-shot PQ-mTLS server with `server_tls`; it accepts a single connection, echoes one
/// byte, and reports back the negotiated key-exchange group (or `None` if the handshake failed).
async fn run_server(
server_tls: NodeTls,
) -> (
std::net::SocketAddr,
tokio::task::JoinHandle<Option<NamedGroup>>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let acceptor = TlsAcceptor::from(Arc::new(server_tls.server_config().unwrap()));
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.ok()?;
let mut tls = acceptor.accept(stream).await.ok()?;
let group = tls
.get_ref()
.1
.negotiated_key_exchange_group()
.map(|g| g.name());
let mut buf = [0u8; 1];
let _ = tls.read_exact(&mut buf).await;
let _ = tls.write_all(b"!").await;
let _ = tls.shutdown().await;
group
});
(addr, handle)
}
/// Attempt a client handshake to `addr` with `client_tls`. On success returns the negotiated
/// key-exchange group; any handshake/verification failure is surfaced as `Err`.
async fn try_client(addr: std::net::SocketAddr, client_tls: NodeTls) -> Result<NamedGroup, String> {
let connector = TlsConnector::from(Arc::new(
client_tls.client_config().map_err(|e| e.to_string())?,
));
let stream = TcpStream::connect(addr).await.map_err(|e| e.to_string())?;
let name = ServerName::try_from("node").unwrap();
let mut tls = connector
.connect(name, stream)
.await
.map_err(|e| e.to_string())?;
let group = tls
.get_ref()
.1
.negotiated_key_exchange_group()
.map(|g| g.name())
.ok_or_else(|| "no kx group".to_string())?;
tls.write_all(b"?").await.map_err(|e| e.to_string())?;
let mut buf = [0u8; 1];
tls.read_exact(&mut buf).await.map_err(|e| e.to_string())?;
Ok(group)
}
#[tokio::test]
async fn handshake_succeeds_when_each_pins_the_other_and_uses_x25519mlkem768() {
let a = node_ca();
let b = node_ca();
// A trusts B's CA; B trusts A's CA.
let a_tls = NodeTls::new(
a.clone(),
TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]),
)
.unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
let (addr, server) = run_server(a_tls).await;
let client_group = try_client(addr, b_tls)
.await
.expect("handshake should succeed");
let server_group = server
.await
.unwrap()
.expect("server handshake should succeed");
assert_eq!(
client_group,
NamedGroup::X25519MLKEM768,
"client kx must be PQ hybrid"
);
assert_eq!(
server_group,
NamedGroup::X25519MLKEM768,
"server kx must be PQ hybrid"
);
}
#[tokio::test]
async fn handshake_refused_when_server_does_not_pin_the_client_ca() {
let a = node_ca();
let b = node_ca();
// A trusts B, but B is NOT trusted by A's client-cert verifier… invert: A trusts *nobody*,
// so B's client certificate is rejected even though B pins A.
let a_tls = NodeTls::new(a.clone(), TrustStore::new()).unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(result.is_err(), "client must be refused: {result:?}");
assert!(
server.await.unwrap().is_none(),
"server must reject the handshake"
);
}
#[tokio::test]
async fn handshake_refused_when_client_does_not_pin_the_server_ca() {
let a = node_ca();
let b = node_ca();
// A trusts B's client cert, but B pins nobody, so B rejects A's server certificate.
let a_tls = NodeTls::new(
a.clone(),
TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]),
)
.unwrap();
let b_tls = NodeTls::new(b.clone(), TrustStore::new()).unwrap();
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(
result.is_err(),
"client must refuse the unpinned server: {result:?}"
);
let _ = server.await;
}
#[tokio::test]
async fn distrust_refuses_a_previously_trusted_peer() {
let a = node_ca();
let b = node_ca();
let a_trust = TrustStore::from_fingerprints([b.ca_fingerprint().to_string()]);
let a_tls = NodeTls::new(a.clone(), a_trust.clone()).unwrap();
let b_tls = NodeTls::new(
b.clone(),
TrustStore::from_fingerprints([a.ca_fingerprint().to_string()]),
)
.unwrap();
// Distrust B before any connection: replace A's trust set with the empty set.
a_trust.replace(std::iter::empty());
let (addr, server) = run_server(a_tls).await;
let result = try_client(addr, b_tls).await;
assert!(
result.is_err(),
"distrusted peer must be refused: {result:?}"
);
assert!(server.await.unwrap().is_none());
}