From c164e9f4227ff17863d8e4c6666768e2ad6a28e3 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 14:50:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(brahman-handshake):=20Fase=203=20=E2=80=94?= =?UTF-8?q?=20trust=20remoto=20via=20firma=20Ed25519?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuarto paso del plan "el encuentro entre Entes no se restringe a local". Cierra la falla de seguridad que dejaba la red P2P abierta: hasta ahora un atacante que pudiera dial-ar al multiaddr del Init podia registrar cualquier Card con cualquier label/flow. Fase 3 exige que el Hello via libp2p venga firmado con la misma keypair Ed25519 que produce el peer_id autenticado por Noise. Modelo de trust: - Local Unix: SO_PEERCRED del kernel autentica. Firma opcional pero verificada si presente (defensa en profundidad). - Remoto libp2p: firma obligatoria; public key del Hello debe derivar al peer_id autenticado por Noise. Si falta o no coincide -> HandshakeError::Unauthorized. Wire: - Hello.signature: Option (default None, backwards compat para path Unix). - HelloSignature { public_key: Vec, signature: Vec } — public_key en formato canonico libp2p (encode_protobuf), firma Ed25519 sobre (SIGNATURE_VERSION, WireCard, Option) serializado postcard. Nuevo modulo signature: - sign_hello / verify_hello con SignatureError tipado. - 4 unit tests: roundtrip, peer mismatch, card tampered, sig flipped. Server: - Session gana expected_peer: Option. - session_from_libp2p_stream(stream, peer) para path remoto; session_from_stream sin peer para Unix. - do_handshake exige firma + verifica peer match si expected_peer. Client: - connect_with_stream_signed(stream, card, wit, &Keypair) (nuevo). - connect_libp2p ahora requiere &Keypair (breaking change). BrahmanNet: - Almacena Arc internamente; expose keypair() accessor. Truco: ed25519::Keypair SI es Clone, se duplica para que swarm (Noise) y caller (signing) compartan identidad sin copiar bytes. - with_keypair rechaza no-Ed25519. Tests: 4 unit signature + 1 E2E negativo nuevo (libp2p_handshake_rejects_mismatched_signing_key) + E2E positivo ya pasaba con keypair correcta. 90+ tests verdes en brahman-handshake/brahman-net/brahman-card/minga-p2p. Lo que cierra: la cadena completa de discovery + connect + trust funciona cross-machine sin paths hardcodeados ni confianza implicita. Brahman-net es una malla publicamente dial-able CON autenticacion criptografica end-to-end. Pendientes futuros: stop_providing en cleanup, wire de Arje con ServerConfig.net configurado, allowlist/denylist de peers, persistencia de la keypair entre reboots. --- CHANGELOG.md | 94 +++++++++++ crates/core/brahman-handshake/src/client.rs | 48 +++++- crates/core/brahman-handshake/src/lib.rs | 1 + crates/core/brahman-handshake/src/messages.rs | 24 +++ crates/core/brahman-handshake/src/network.rs | 19 ++- crates/core/brahman-handshake/src/server.rs | 134 +++++++++++++-- .../core/brahman-handshake/src/signature.rs | 155 ++++++++++++++++++ .../core/brahman-handshake/tests/handshake.rs | 1 + .../brahman-handshake/tests/network_libp2p.rs | 56 ++++++- crates/shared/brahman-net/src/lib.rs | 36 +++- 10 files changed, 541 insertions(+), 27 deletions(-) create mode 100644 crates/core/brahman-handshake/src/signature.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca484ee..09b6fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,100 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p +Cuarto y último paso del plan "el encuentro entre Entes no se +restringe a local". Cierra la falla de seguridad que dejaba la red +P2P abierta: hasta ahora, un atacante que pudiera dial-ar al +multiaddr del Init podía registrar cualquier Card con cualquier +label/flow. Fase 3 cierra esto exigiendo que el Hello vía libp2p +venga firmado con la **misma keypair Ed25519 que produce el +`peer_id` autenticado por Noise**. + +Modelo: +- **Local Unix**: SO_PEERCRED del kernel autentica al cliente. Firma + opcional. Si está presente, igual se verifica (defensa en + profundidad). +- **Remoto libp2p**: firma obligatoria. La public key del Hello debe + derivar al `peer_id` que Noise ya autenticó. Si falta o no coincide + → `HandshakeError::Unauthorized`. + +Wire (`brahman_handshake::messages`): +- `Hello.signature: Option` (nuevo, default None). +- `HelloSignature { public_key: Vec, signature: Vec }` — + public_key en formato canónico libp2p (`encode_protobuf`), firma + Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option)` + serializado postcard. +- `SIGNATURE_VERSION = 1` documenta el esquema del payload firmado; + bump al cambiar. + +Nuevo módulo `brahman_handshake::signature`: +- `sign_hello(keypair, card, wit) -> HelloSignature`. +- `verify_hello(sig, card, wit, expected_peer) -> Result<(), SignatureError>`. +- `SignatureError` tipado (`DecodeKey`, `EncodePayload`, `Invalid`, + `PeerMismatch`, `Missing`, `Unexpected`). + +Server: +- `Session` gana `expected_peer: Option`. +- `Server::session_from_libp2p_stream(stream, peer)` (nuevo) + construye Session con `expected_peer = Some(peer)`. + `session_from_stream` (Unix/in-memory) sigue con `None`. +- `do_handshake` exige firma + verifica peer match cuando + `expected_peer.is_some()`. Si no, verifica firma presente por + consistencia interna pero no exige que esté. +- `network::run_libp2p_accept_loop` ahora usa + `session_from_libp2p_stream(stream.compat(), peer)` para propagar + la identidad libp2p al gate de trust. + +Client: +- `Client::connect_with_stream_signed(stream, card, wit, &Keypair)` + (nuevo) firma el Hello antes de mandarlo. +- `Client::connect_with_stream` sigue existiendo sin firma (path + Unix / tests). +- `Client::connect`/`connect_with` (Unix) no cambian — siguen sin + firma porque SO_PEERCRED autentica. +- `network::connect_libp2p(net, peer, card, wit, keypair)` + **breaking change**: gana parámetro `keypair: &Keypair`. + +BrahmanNet: +- Almacena la `Keypair` en `Arc` (libp2p Keypair no es + Clone; el truco es duplicar el `ed25519::Keypair` interno que sí + es Clone, una copia para Noise/swarm y otra para signing). +- `BrahmanNet::keypair() -> Arc` accessor para que callers + puedan firmar con la misma identidad libp2p del nodo sin tener + que mantener la keypair por separado. +- `with_keypair` rechaza keypairs no-Ed25519 (RSA/ECDSA/Secp256k1 + vendrían a futuro si se necesitan). + +Tests: +- 4 unit en `signature::tests`: roundtrip propio, peer mismatch, + card tampered, signature flipped. +- 1 E2E nuevo en `tests/network_libp2p.rs`: + `libp2p_handshake_rejects_mismatched_signing_key` — el cliente + intenta firmar con keypair distinta a la del net; server rechaza. +- E2E positivo (`libp2p_handshake_roundtrip`) ahora pasa la keypair + del client_net y debe verificar OK. +- Discovery + handshake legacy + signature: 90+ tests verdes en + brahman-handshake/brahman-net/brahman-card/minga-p2p. + +Lo que esto cierra: +- Brahman-net es una malla públicamente dial-able **con + autenticación criptográfica end-to-end**: Noise para el transport, + Ed25519 para las Cards. +- La cadena completa de discovery + connect + trust funciona + cross-machine sin paths hardcodeados ni confianza implícita. +- El plan original ("el encuentro entre Entes no se restringe a + local, la ejecución remota está pensada desde el principio") + está implementado y testeado. + +Pendientes (futuro, no de hoy): +- `stop_providing` al cleanup de sesión (records DHT viven hasta + TTL ~24h). +- Wire de Arje (`ente-zero`) para arrancar opcionalmente con + `BrahmanNet` configurado y `ServerConfig.net = Some(...)`. +- Allowlist/Denylist de peers (hoy cualquier peer Ed25519-válido + pasa el trust gate; producción podría querer un PKI explícito). +- Persistencia de la keypair de identidad del nodo entre reboots. + ### feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type Tercer paso del plan "el encuentro entre Entes no se restringe a local". Cuando un Init local acepta una sesión cuya Card declara diff --git a/crates/core/brahman-handshake/src/client.rs b/crates/core/brahman-handshake/src/client.rs index 8b08d6f..025e5ad 100644 --- a/crates/core/brahman-handshake/src/client.rs +++ b/crates/core/brahman-handshake/src/client.rs @@ -5,12 +5,14 @@ use std::path::Path; use std::time::Duration; use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION}; +use brahman_net::Keypair; use thiserror::Error; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::UnixStream; use crate::codec::{read_frame, write_frame}; use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId}; +use crate::signature::{sign_hello, SignatureError}; /// Errores del cliente. #[derive(Debug, Error)] @@ -29,6 +31,11 @@ pub enum ClientError { /// La Card que el cliente intentó enviar no pasa su propia validación. #[error("card inválida pre-envío: {0}")] InvalidCard(String), + + /// Firma del Hello falló al construirse (rara — sólo puede pasar + /// si la keypair pasada está en un estado inválido). + #[error("firma del Hello falló: {0}")] + Signature(#[from] SignatureError), } /// Cliente conectado y autenticado. Tras `connect` ya completó el handshake @@ -72,23 +79,54 @@ impl Client where S: AsyncRead + AsyncWrite + Unpin + Send, { - /// Constructor genérico: arranca el handshake sobre un stream - /// arbitrario que ya esté abierto. Es el punto de entrada para - /// transports alternativos (libp2p, in-memory para tests, etc.) - /// que reusan toda la lógica del handshake post-stream-open. + /// Constructor genérico sobre un stream ya abierto, **sin firma**. + /// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica) + /// o tests in-memory. Para libp2p remoto usá + /// [`connect_with_stream_signed`](Self::connect_with_stream_signed) — + /// el server libp2p rechaza Hello sin firma. pub async fn connect_with_stream( + stream: S, + card: Card, + wit: Option, + ) -> Result { + Self::connect_inner(stream, card, wit, None).await + } + + /// Igual que `connect_with_stream` pero firma el Hello con + /// `keypair`. Usar para conexiones libp2p donde el server exige + /// firma. La public key derivada de `keypair` debe coincidir con + /// el `peer_id` libp2p autenticado por Noise — típicamente la + /// keypair pasada a [`brahman_net::BrahmanNet::with_keypair`]. + pub async fn connect_with_stream_signed( + stream: S, + card: Card, + wit: Option, + keypair: &Keypair, + ) -> Result { + Self::connect_inner(stream, card, wit, Some(keypair)).await + } + + async fn connect_inner( mut stream: S, card: Card, wit: Option, + keypair: Option<&Keypair>, ) -> Result { card.validate() .map_err(|e| ClientError::InvalidCard(e.to_string()))?; + let wire_card = brahman_card::WireCard::from(card); + let signature = match keypair { + Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?), + None => None, + }; + let hello = Hello { schema_version: CARD_SCHEMA_VERSION, protocol_version: brahman_card::PROTOCOL_VERSION.to_string(), - card: card.into(), // Card → WireCard: descarta extensions + card: wire_card, wit, + signature, }; write_frame(&mut stream, &Frame::Hello(hello)).await?; diff --git a/crates/core/brahman-handshake/src/lib.rs b/crates/core/brahman-handshake/src/lib.rs index a16188e..117dd11 100644 --- a/crates/core/brahman-handshake/src/lib.rs +++ b/crates/core/brahman-handshake/src/lib.rs @@ -22,6 +22,7 @@ pub mod messages; pub mod server; pub mod client; pub mod network; +pub mod signature; pub mod transport; pub use brahman_card::PROTOCOL_VERSION; diff --git a/crates/core/brahman-handshake/src/messages.rs b/crates/core/brahman-handshake/src/messages.rs index ca7432d..6cbdbdd 100644 --- a/crates/core/brahman-handshake/src/messages.rs +++ b/crates/core/brahman-handshake/src/messages.rs @@ -17,6 +17,14 @@ pub type SessionId = Ulid; /// la convierte a `Card` para uso interno. Opcionalmente, una /// `WitInterface` ya extraída — si está presente, el módulo es /// "consciente" y el server lo registra como `ResolvedCard::from_conscious`. +/// +/// **Firma (Fase 3, trust remoto)**: el campo `signature` es +/// obligatorio para conexiones libp2p (donde el server exige que la +/// public key derive al `peer_id` autenticado por Noise) y opcional +/// para Unix socket (donde SO_PEERCRED del kernel ya provee +/// autenticación). La firma cubre los bytes postcard de +/// `(WireCard, Option)` — ver +/// [`HelloSignature::sign_payload`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Hello { /// Versión del schema de Card que el cliente sigue. @@ -29,6 +37,22 @@ pub struct Hello { /// `brahman-card-wit`). `None` si el módulo es agnóstico. #[serde(default)] pub wit: Option, + /// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones + /// remotas (libp2p); opcional para Unix socket. Ver módulo + /// [`super::signature`] para construcción y verificación. + #[serde(default)] + pub signature: Option, +} + +/// Firma de un Hello. La `public_key` viaja en el formato canónico +/// libp2p (protobuf) — el verificador la decodifica y compara su +/// `peer_id` derivado con la identidad libp2p autenticada por Noise. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HelloSignature { + /// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`. + pub public_key: Vec, + /// Bytes de la firma Ed25519 sobre el payload canonical. + pub signature: Vec, } /// Respuesta del servidor a un `Hello` aceptado. diff --git a/crates/core/brahman-handshake/src/network.rs b/crates/core/brahman-handshake/src/network.rs index c1af3a4..0c0d496 100644 --- a/crates/core/brahman-handshake/src/network.rs +++ b/crates/core/brahman-handshake/src/network.rs @@ -44,7 +44,7 @@ use std::sync::Arc; use brahman_card::{Card, TypeRef, WitInterface}; -use brahman_net::{BrahmanNet, OpenStreamError, PeerId, Stream, StreamProtocol}; +use brahman_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol}; use futures::StreamExt; use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt}; use tracing::{debug, warn}; @@ -107,7 +107,9 @@ async fn handle_libp2p_session( stream: Stream, peer: PeerId, ) { - let session = server.session_from_stream(stream.compat()); + // session_from_libp2p_stream propaga el peer_id al `do_handshake`, + // que exige firma del Hello cuya public key derive a este peer. + let session = server.session_from_libp2p_stream(stream.compat(), peer); if let Err(e) = session.handle().await { warn!( target: "brahman_handshake::network", @@ -119,8 +121,14 @@ async fn handle_libp2p_session( } /// Conecta como cliente a un Ente remoto vía libp2p y completa el -/// handshake. Requiere que `net` ya tenga conexión (o pueda dial-ar) -/// al `peer`; típicamente el caller hace `net.dial(multiaddr)` antes. +/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga +/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace +/// `net.dial(multiaddr)` antes. +/// +/// La `keypair` debe ser la misma que la del nodo libp2p (la que +/// pasaste a [`brahman_net::BrahmanNet::with_keypair`]). Si no coincide +/// con el `peer_id` autenticado por Noise, el server rechaza el Hello +/// con `Unauthorized`. /// /// Devuelve un `Client` típico — los métodos `ping`, `await_event`, /// `farewell` funcionan idéntico al path Unix. El stream subyacente @@ -130,12 +138,13 @@ pub async fn connect_libp2p( peer: PeerId, card: Card, wit: Option, + keypair: &Keypair, ) -> Result, NetworkError> { let mut control = net.control.clone(); let stream = control .open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL) .await?; - let client = Client::connect_with_stream(stream.compat(), card, wit).await?; + let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?; Ok(client) } diff --git a/crates/core/brahman-handshake/src/server.rs b/crates/core/brahman-handshake/src/server.rs index 61a363f..898d2bb 100644 --- a/crates/core/brahman-handshake/src/server.rs +++ b/crates/core/brahman-handshake/src/server.rs @@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use brahman_broker::{Broker, Endpoint}; use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION}; -use brahman_net::BrahmanNet; +use brahman_net::{BrahmanNet, PeerId}; use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{mpsc, Mutex}; @@ -118,22 +118,44 @@ impl Server { /// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`. /// No corre el handler — eso es responsabilidad del llamante. + /// Path Unix → `expected_peer = None` (firma del Hello opcional; + /// SO_PEERCRED del kernel ya autentica al cliente). pub async fn accept_one(&self) -> std::io::Result> { let (stream, _addr) = self.listener.accept().await?; Ok(self.session_from_stream(stream)) } /// Construye una `Session` a partir de un stream arbitrario que - /// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Es el - /// punto de entrada para transports alternativos (libp2p en - /// `brahman_handshake::network`, in-memory para tests, etc.) que - /// quieren reutilizar la lógica del handshake sin venir por el - /// listener Unix. - /// - /// Las tablas compartidas (sessions/push/last_matches/broker) se - /// clonan, así que sesiones construidas por esta vía conviven - /// indistinguibles en el mismo `Server`. + /// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path + /// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer` + /// queda en `None`, así que la firma del Hello es opcional. pub fn session_from_stream(&self, stream: S) -> Session + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + self.session_from_stream_inner(stream, None) + } + + /// Variante para conexiones libp2p: el `peer_id` viene autenticado + /// por Noise. La sesión exige firma del Hello cuya public key + /// derive a este `peer_id` exacto. Ver + /// [`super::network::run_libp2p_accept_loop`]. + pub fn session_from_libp2p_stream( + &self, + stream: S, + peer: PeerId, + ) -> Session + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + self.session_from_stream_inner(stream, Some(peer)) + } + + fn session_from_stream_inner( + &self, + stream: S, + expected_peer: Option, + ) -> Session where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { @@ -143,6 +165,7 @@ impl Server { push_table: self.push_table.clone(), last_matches: self.last_matches.clone(), config: self.config.clone(), + expected_peer, } } @@ -181,6 +204,11 @@ pub struct Session { push_table: SessionTxTable, last_matches: LastMatches, config: ServerConfig, + /// Si está set, el path es libp2p y `do_handshake` exige firma + /// del Hello cuya public key derive a este `peer_id`. Si es + /// `None`, el path es Unix/in-memory y la firma es opcional + /// (pero si está, se verifica anyway por defensa en profundidad). + expected_peer: Option, } impl Session @@ -207,9 +235,11 @@ where push_table, last_matches, config, + expected_peer, } = self; - let session_id = match do_handshake(&mut stream, &config, &sessions).await? { + let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await? + { Some(id) => id, None => return Ok(()), // Hello rechazado, no se registró nada }; @@ -473,11 +503,13 @@ async fn broadcast_match_diffs( } } -/// Lee el Hello, valida, registra la sesión y emite HelloAck. +/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la +/// sesión y emite HelloAck. async fn do_handshake( stream: &mut S, config: &ServerConfig, sessions: &SessionRegistry, + expected_peer: Option, ) -> std::io::Result> where S: AsyncRead + AsyncWrite + Unpin, @@ -502,6 +534,84 @@ where return Ok(None); } + // Trust gate: en path libp2p (expected_peer = Some), exigir + // firma cuya public key derive al peer autenticado por Noise. + // En path Unix (expected_peer = None), si la firma viene se + // verifica anyway por defensa en profundidad — no es un error + // que esté ahí, pero si está debe ser válida. + if let Some(peer) = expected_peer { + let sig = match &hello.signature { + Some(s) => s, + None => { + write_frame( + stream, + &Frame::Error(HandshakeError::Unauthorized( + "Hello sin firma en conexión remota libp2p".into(), + )), + ) + .await?; + return Ok(None); + } + }; + if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) { + write_frame( + stream, + &Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))), + ) + .await?; + debug!(peer = %peer, error = %e, "firma rechazada"); + return Ok(None); + } + } else if let Some(sig) = &hello.signature { + // Firma presente en path local: no exigida pero verificada. + // Si está y no valida, es un signo de Hello mal-construido y + // rechazamos por seguridad. + // Para Unix no tenemos peer_id contra el cual comparar; se + // verifica sólo la consistencia interna (firma sobre payload + // con la public_key declarada). + match brahman_net::PublicKey::try_decode_protobuf(&sig.public_key) { + Ok(pk) => { + let payload = match postcard::to_allocvec(&( + crate::signature::SIGNATURE_VERSION, + &hello.card, + &hello.wit, + )) { + Ok(b) => b, + Err(_) => { + write_frame( + stream, + &Frame::Error(HandshakeError::Internal( + "no pude codificar payload para verificar firma".into(), + )), + ) + .await?; + return Ok(None); + } + }; + if !pk.verify(&payload, &sig.signature) { + write_frame( + stream, + &Frame::Error(HandshakeError::Unauthorized( + "firma del Hello presente pero inválida".into(), + )), + ) + .await?; + return Ok(None); + } + } + Err(e) => { + write_frame( + stream, + &Frame::Error(HandshakeError::Unauthorized(format!( + "public_key inválida en firma: {e}" + ))), + ) + .await?; + return Ok(None); + } + } + } + let session_id = Ulid::new(); let card: Card = hello.card.into(); register_session(session_id, card, hello.wit, config, sessions).await; diff --git a/crates/core/brahman-handshake/src/signature.rs b/crates/core/brahman-handshake/src/signature.rs new file mode 100644 index 0000000..5a2161a --- /dev/null +++ b/crates/core/brahman-handshake/src/signature.rs @@ -0,0 +1,155 @@ +//! Firma y verificación del payload del `Hello` para trust remoto. +//! +//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer +//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica +//! del Ente a la identidad de transporte: si Noise autenticó al +//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión. +//! +//! ## Payload firmado +//! +//! Bytes postcard de la tupla `(WireCard, Option)`. Se +//! eligió postcard porque ya es el wire format del resto del protocolo: +//! mismo determinismo, sin convertir a otro formato sólo para firmar. +//! +//! Cualquier campo que entre al payload firmado en el futuro debe +//! añadirse al final de la tupla (postcard es position-dependent), o +//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas. + +use brahman_card::{WireCard, WitInterface}; +use brahman_net::{Keypair, PeerId, PublicKey}; + +use crate::messages::HelloSignature; + +/// Versión del esquema de payload firmado. Si cambia el shape de +/// `(WireCard, Option)` o cómo se serializa, bump este +/// número y el verificador rechaza firmas antiguas. +pub const SIGNATURE_VERSION: u8 = 1; + +/// Errores de verificación de firma. +#[derive(Debug, thiserror::Error)] +pub enum SignatureError { + #[error("public_key inválida (libp2p decode protobuf): {0}")] + DecodeKey(String), + #[error("encode del payload falló: {0}")] + EncodePayload(String), + #[error("firma rechazada: bytes inválidos para la public_key")] + Invalid, + #[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")] + PeerMismatch { signer: PeerId, expected: PeerId }, + #[error("firma del Hello faltante (requerida para conexión remota libp2p)")] + Missing, + #[error("firma del Hello inesperada en path local sin trust remoto")] + Unexpected, +} + +/// Construye los bytes canónicos a firmar/verificar para un Hello. +/// Postcard determinístico de `(version, WireCard, Option)`. +fn payload_bytes(card: &WireCard, wit: &Option) -> Result, SignatureError> { + let tup = (SIGNATURE_VERSION, card, wit); + postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string())) +} + +/// Firma `(card, wit)` con la `keypair`. La public key derivada de +/// `keypair` debe coincidir con la identidad libp2p del peer cuando +/// el verificador la chequee. +pub fn sign_hello( + keypair: &Keypair, + card: &WireCard, + wit: &Option, +) -> Result { + let bytes = payload_bytes(card, wit)?; + let signature_bytes = keypair + .sign(&bytes) + .map_err(|e| SignatureError::EncodePayload(e.to_string()))?; + Ok(HelloSignature { + public_key: keypair.public().encode_protobuf(), + signature: signature_bytes, + }) +} + +/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que +/// la public key declarada coincide con `expected_peer` (la identidad +/// libp2p autenticada por Noise). +/// +/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto. +pub fn verify_hello( + sig: &HelloSignature, + card: &WireCard, + wit: &Option, + expected_peer: PeerId, +) -> Result<(), SignatureError> { + let public_key = PublicKey::try_decode_protobuf(&sig.public_key) + .map_err(|e| SignatureError::DecodeKey(e.to_string()))?; + let signer_peer = public_key.to_peer_id(); + if signer_peer != expected_peer { + return Err(SignatureError::PeerMismatch { + signer: signer_peer, + expected: expected_peer, + }); + } + let bytes = payload_bytes(card, wit)?; + if !public_key.verify(&bytes, &sig.signature) { + return Err(SignatureError::Invalid); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::Card; + + fn sample_card() -> WireCard { + Card::new("test.signed").into() + } + + #[test] + fn sign_then_verify_roundtrip() { + let kp = Keypair::generate_ed25519(); + let peer = kp.public().to_peer_id(); + let card = sample_card(); + let wit = None; + let sig = sign_hello(&kp, &card, &wit).unwrap(); + verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar"); + } + + #[test] + fn verify_rejects_wrong_peer() { + let kp = Keypair::generate_ed25519(); + let other = Keypair::generate_ed25519().public().to_peer_id(); + let card = sample_card(); + let wit = None; + let sig = sign_hello(&kp, &card, &wit).unwrap(); + let err = verify_hello(&sig, &card, &wit, other).unwrap_err(); + assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}"); + } + + #[test] + fn verify_rejects_tampered_card() { + let kp = Keypair::generate_ed25519(); + let peer = kp.public().to_peer_id(); + let original = sample_card(); + let wit = None; + let sig = sign_hello(&kp, &original, &wit).unwrap(); + + // Verificamos contra una Card distinta (mismo shape, distinto label). + let tampered: WireCard = Card::new("test.tampered").into(); + let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err(); + assert!(matches!(err, SignatureError::Invalid), "got {err:?}"); + } + + #[test] + fn verify_rejects_corrupted_signature() { + let kp = Keypair::generate_ed25519(); + let peer = kp.public().to_peer_id(); + let card = sample_card(); + let wit = None; + let mut sig = sign_hello(&kp, &card, &wit).unwrap(); + // Flip un bit de la firma. + if let Some(b) = sig.signature.last_mut() { + *b ^= 0x01; + } + let err = verify_hello(&sig, &card, &wit, peer).unwrap_err(); + assert!(matches!(err, SignatureError::Invalid), "got {err:?}"); + } +} diff --git a/crates/core/brahman-handshake/tests/handshake.rs b/crates/core/brahman-handshake/tests/handshake.rs index 23b7a05..cbe558c 100644 --- a/crates/core/brahman-handshake/tests/handshake.rs +++ b/crates/core/brahman-handshake/tests/handshake.rs @@ -125,6 +125,7 @@ async fn server_rejects_protocol_mismatch() { protocol_version: "999.0.0".into(), card: sample_card("future-module").into(), wit: None, + signature: None, }; write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap(); diff --git a/crates/core/brahman-handshake/tests/network_libp2p.rs b/crates/core/brahman-handshake/tests/network_libp2p.rs index 94a6527..d978662 100644 --- a/crates/core/brahman-handshake/tests/network_libp2p.rs +++ b/crates/core/brahman-handshake/tests/network_libp2p.rs @@ -20,7 +20,7 @@ use brahman_card::{ }; use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop}; use brahman_handshake::server::{Server, ServerConfig}; -use brahman_net::{BrahmanNet, Multiaddr, PeerId, Protocol}; +use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol}; use tempfile::TempDir; use tokio::sync::Mutex; @@ -86,7 +86,8 @@ async fn libp2p_handshake_roundtrip() { tokio::time::sleep(Duration::from_millis(200)).await; let card = sample_card("test.remote_ente"); - let mut client = connect_libp2p(&client_net, server_peer_id, card, None) + let client_kp = client_net.keypair(); + let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp) .await .expect("handshake remoto debería completar"); @@ -116,3 +117,54 @@ async fn libp2p_handshake_roundtrip() { // peer_id no usado aquí, pero validamos que la API existe. let _ = PeerId::random(); } + +/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair +/// distinta a la del peer libp2p. El server (que verifica que la +/// public key del Hello derive al peer_id autenticado por Noise) debe +/// rechazar con `Unauthorized`. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn libp2p_handshake_rejects_mismatched_signing_key() { + let tmp = TempDir::new().unwrap(); + let unix_socket = tmp.path().join("brahman-init.sock"); + + let server = Arc::new( + Server::bind( + &unix_socket, + ServerConfig { + init_attached: true, + broker: None, + net: None, + }, + ) + .unwrap(), + ); + let sessions = server.sessions(); + + let server_net = Arc::new(BrahmanNet::new().unwrap()); + let server_peer = server_net.peer_id; + let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap(); + let actual = server_net.listen(listen_addr).await; + let mut full = actual.clone(); + full.push(Protocol::P2p(server_peer)); + + tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone())); + + let client_net = BrahmanNet::new().unwrap(); + client_net.dial(full); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Keypair fraudulenta: NO es la del client_net. + let evil_keypair = Keypair::generate_ed25519(); + + let card = sample_card("test.evil"); + let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await; + + assert!( + result.is_err(), + "handshake con keypair fraudulenta debe fallar" + ); + + // Sanidad: ninguna sesión registrada. + let s = sessions.lock().await; + assert_eq!(s.len(), 0, "no debería haber sesión registrada"); +} diff --git a/crates/shared/brahman-net/src/lib.rs b/crates/shared/brahman-net/src/lib.rs index 0d38819..59e1a8e 100644 --- a/crates/shared/brahman-net/src/lib.rs +++ b/crates/shared/brahman-net/src/lib.rs @@ -42,6 +42,7 @@ #![warn(rust_2018_idioms)] use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use futures::StreamExt; @@ -53,7 +54,11 @@ use libp2p::{ use libp2p_stream as stream; use tokio::sync::{mpsc, oneshot, Mutex}; -pub use libp2p::{multiaddr::Protocol, Multiaddr, PeerId, Stream, StreamProtocol}; +pub use libp2p::{ + identity::{Keypair, PublicKey}, + multiaddr::Protocol, + Multiaddr, PeerId, Stream, StreamProtocol, +}; pub use libp2p_stream::OpenStreamError; const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0"; @@ -96,6 +101,11 @@ pub struct BrahmanNet { /// keypair (efímera por default; persistente si pasaste una /// vía [`with_keypair`]). pub peer_id: PeerId, + /// Keypair compartida (Arc para compartir con consumers que + /// necesitan firmar mensajes con la misma identidad — p. ej. + /// `brahman_handshake::network::connect_libp2p` que firma el + /// Hello). NO se expone públicamente; usar [`Self::keypair`]. + keypair: Arc, cmd_tx: mpsc::UnboundedSender, listen_rx: Mutex>, /// Control para abrir y aceptar streams. Cada protocolo @@ -117,10 +127,21 @@ impl BrahmanNet { /// `peer_id` estable (por ejemplo si tu identidad se persiste a /// disco, o si la derivás de la identidad criptográfica del /// módulo). + /// + /// Sólo Ed25519 se soporta — la `keypair` se duplica internamente + /// vía clone del `ed25519::Keypair` para que tanto el swarm + /// (Noise auth) como el caller (firma de Cards) compartan la + /// misma identidad sin la fricción de que `identity::Keypair` no + /// implemente `Clone`. pub fn with_keypair(keypair: identity::Keypair) -> Result { - let peer_id = keypair.public().to_peer_id(); + let ed_kp = keypair + .try_into_ed25519() + .map_err(|_| NodeError::Build("brahman-net sólo soporta keypairs Ed25519".into()))?; + let kp_for_swarm = identity::Keypair::from(ed_kp.clone()); + let kp_for_storage = Arc::new(identity::Keypair::from(ed_kp)); + let peer_id = kp_for_swarm.public().to_peer_id(); - let mut swarm: Swarm = SwarmBuilder::with_existing_identity(keypair) + let mut swarm: Swarm = SwarmBuilder::with_existing_identity(kp_for_swarm) .with_tokio() .with_tcp( tcp::Config::default(), @@ -274,12 +295,21 @@ impl BrahmanNet { Ok(Self { peer_id, + keypair: kp_for_storage, cmd_tx, listen_rx: Mutex::new(listen_rx), control, }) } + /// Acceso a la keypair de identidad del nodo. Usar para firmar + /// payloads que viajan asociados al `peer_id` (handshake brahman + /// firmado, futuros sub-protocolos con autenticación). El `Arc` + /// permite compartir sin copia — la keypair libp2p no es `Clone`. + pub fn keypair(&self) -> Arc { + self.keypair.clone() + } + /// Empieza a escuchar en `addr`. Bloquea hasta que el listener /// publique su dirección real (Multiaddr resuelta — útil cuando /// pediste `/ip4/0.0.0.0/tcp/0` y querés saber qué puerto te tocó).