tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definiciones

feat(brahman-handshake): Fase 3 — trust remoto via firma Ed25519

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<HelloSignature> (default None, backwards
  compat para path Unix).
- HelloSignature { public_key: Vec<u8>, signature: Vec<u8> } —
  public_key en formato canonico libp2p (encode_protobuf), firma
  Ed25519 sobre (SIGNATURE_VERSION, WireCard, Option<WitInterface>)
  serializado postcard.

Nuevo modulo signature:
- sign_hello / verify_hello con SignatureError tipado.
- 4 unit tests: roundtrip, peer mismatch, card tampered, sig flipped.

Server:
- Session<S> gana expected_peer: Option<PeerId>.
- 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<Keypair> 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.

c164e9f4227ff17863d8e4c6666768e2ad6a28e3 · por Sergio · (hace 5 meses) · padre 2059af4fb · ver el árbol en este commit

Delta semántico · 32

crates/core/brahman-handshake/src/client.rs

crates/core/brahman-handshake/src/lib.rs

crates/core/brahman-handshake/src/messages.rs

crates/core/brahman-handshake/src/network.rs

crates/core/brahman-handshake/src/server.rs

crates/core/brahman-handshake/src/signature.rs

crates/core/brahman-handshake/tests/handshake.rs

crates/core/brahman-handshake/tests/network_libp2p.rs

crates/shared/brahman-net/src/lib.rs

10 archivos · +541 −27

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 <sha>`.
 
 ## 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<HelloSignature>` (nuevo, default None).
+- `HelloSignature { public_key: Vec<u8>, signature: Vec<u8> }` —
+  public_key en formato canónico libp2p (`encode_protobuf`), firma
+  Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option<WitInterface>)`
+  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<S>` gana `expected_peer: Option<PeerId>`.
+- `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<Keypair>` (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<Keypair>` 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<S> Client<S>
 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<WitInterface>,
+    ) -> Result<Self, ClientError> {
+        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<WitInterface>,
+        keypair: &Keypair,
+    ) -> Result<Self, ClientError> {
+        Self::connect_inner(stream, card, wit, Some(keypair)).await
+    }
+
+    async fn connect_inner(
         mut stream: S,
         card: Card,
         wit: Option<WitInterface>,
+        keypair: Option<&Keypair>,
     ) -> Result<Self, ClientError> {
         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<WitInterface>)` — 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<WitInterface>,
+    /// 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<HelloSignature>,
+}
+
+/// 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<u8>,
+    /// Bytes de la firma Ed25519 sobre el payload canonical.
+    pub signature: Vec<u8>,
 }
 
 /// 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<WitInterface>,
+    keypair: &Keypair,
 ) -> Result<Client<LibP2pHandshakeStream>, 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<Session<UnixStream>> {
         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<S>(&self, stream: S) -> Session<S>
+    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<S>(
+        &self,
+        stream: S,
+        peer: PeerId,
+    ) -> Session<S>
+    where
+        S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
+    {
+        self.session_from_stream_inner(stream, Some(peer))
+    }
+
+    fn session_from_stream_inner<S>(
+        &self,
+        stream: S,
+        expected_peer: Option<PeerId>,
+    ) -> Session<S>
     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<S> {
     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<PeerId>,
 }
 
 impl<S> Session<S>
@@ -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<S>(
     stream: &mut S,
     config: &ServerConfig,
     sessions: &SessionRegistry,
+    expected_peer: Option<PeerId>,
 ) -> std::io::Result<Option<SessionId>>
 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<WitInterface>)`. 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<WitInterface>)` 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<WitInterface>)`.
+fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, 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<WitInterface>,
+) -> Result<HelloSignature, SignatureError> {
+    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<WitInterface>,
+    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<Keypair>,
     cmd_tx: mpsc::UnboundedSender<Command>,
     listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
     /// 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<Self, NodeError> {
-        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<BrahmanBehaviour> = SwarmBuilder::with_existing_identity(keypair)
+        let mut swarm: Swarm<BrahmanBehaviour> = 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<Keypair> {
+        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ó).