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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user