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.
This commit is contained in:
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user