diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e5c6c..7fafb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,78 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p +Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora +cualquier peer con keypair Ed25519 válida pasaba el handshake remoto; +con allowlist activa, sólo los peers explícitamente listados. Aplica +únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED +del kernel, que es autenticación de proceso local, no de red. + +API nueva en `brahman_handshake::peer_allowlist`: +- `PeerAllowlist::from_iter([peer_id, ...])` para tests/inline. +- `PeerAllowlist::from_file(path)` parsea texto plano: un PeerId + base58 por línea, `#` para comentarios (línea entera o inline), + líneas vacías ignoradas. Errores de parseo incluyen número de + línea para debug rápido. +- `is_allowed(peer)`, `len()`, `is_empty()`, `iter()`. +- `AllowlistError { Io, InvalidPeerId }`. + +Wire en el server: +- `ServerConfig.allowlist: Option`. `None` = modo + abierto (compat con todo lo anterior). `Some` = sólo los listados. +- Gate en `do_handshake` ANTES de la verificación de firma — la + comparación O(log n) en BTreeSet es más barata que crypto, así + que rechazamos peers inválidos antes de gastar ciclos. Se devuelve + `HandshakeError::Unauthorized("peer X no está en la allowlist")`. + +Wire en Arje (`ente-zero`): +- Nueva env var `BRAHMAN_PEER_ALLOWLIST` apuntando a un archivo. +- `setup_brahman_allowlist()` carga al startup; degrada a `None` + (modo abierto) si el archivo falla, consistente con la doctrina + PID 1 de no romper por subsistemas opcionales. + +Ejemplo de archivo de allowlist: +```text +# Peers permitidos en la malla brahman de prod-eu-1 +# Generados con: ente-zero (peer_id loggeado al arrancar) +12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz +12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux # operador 2 +``` + +Activación end-to-end: +```sh +BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\ +BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\ +ente-zero +``` + +Tests: +- 6 unit en `peer_allowlist::tests`: from_iter, parse limpio, parse + con comentarios inline, parse rechaza PeerId inválido (y reporta + número de línea), I/O error en archivo faltante, empty list + rechaza todo. +- 1 E2E en `network_libp2p.rs`: + `libp2p_handshake_allowlist_admits_listed_rejects_others`. A + configura `allowlist = [allowed_peer]`. Cliente con keypair + permitida pasa el handshake (sesión registrada, farewell limpio). + Segundo cliente con keypair distinta es rechazado con error + ANTES de que se le verifique la firma. Sanidad: el conteo de + sesiones del server queda en 0 tras el rechazo. + +25 tests verdes en brahman-handshake (12 unit + 7 handshake legacy ++ 3 discovery + 3 libp2p). Ningún regreso en ente-zero (4/4 +keypair_store). + +Pendiente futuro: +- Denylist explícita (negada — banear peers específicos sin tener + que listar a todos los demás). +- Hot reload de la allowlist sin restart del Init (signal SIGHUP o + watch del archivo). +- Aplicar la política a nivel de swarm vía + `libp2p_allow_block_list::Behaviour` para rechazar conexiones + ANTES del Noise handshake (hoy se rechaza después, gastando un + round-trip TCP+Noise por cada intento denegado). + ### feat(brahman-net+handshake): stop_providing automático en cleanup de sesión Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión con outputs cerraba (Farewell, EOF, error), el record que la diff --git a/crates/core/brahman-handshake/src/lib.rs b/crates/core/brahman-handshake/src/lib.rs index 117dd11..c8ea5da 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 peer_allowlist; pub mod signature; pub mod transport; diff --git a/crates/core/brahman-handshake/src/peer_allowlist.rs b/crates/core/brahman-handshake/src/peer_allowlist.rs new file mode 100644 index 0000000..a7683b8 --- /dev/null +++ b/crates/core/brahman-handshake/src/peer_allowlist.rs @@ -0,0 +1,198 @@ +//! Allowlist explícita de `PeerId`s para el trust gate remoto. +//! +//! Capa de política sobre el trust criptográfico de Fase 3 +//! (firma Ed25519 anclada al peer libp2p). Hoy cualquier peer con +//! keypair Ed25519 válida pasa el handshake; con allowlist activa, +//! sólo los peers explícitamente listados. +//! +//! Aplica únicamente al path libp2p — el path Unix sigue usando +//! `SO_PEERCRED` del kernel para autenticación local. Sin allowlist +//! configurada, comportamiento abierto (compatible con todo lo +//! anterior). +//! +//! ## Formato del archivo +//! +//! Texto plano, una entrada por línea: +//! - `PeerId` en formato base58 (canónico de libp2p, lo que muestra +//! `peer_id.to_string()` y lo que aparece en multiaddrs `/p2p/...`). +//! - Líneas vacías y líneas que empiezan con `#` se ignoran. +//! +//! Ejemplo: +//! +//! ```text +//! # allowlist brahman para máquina prod-eu-1 +//! 12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz +//! # operador secundario +//! 12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux +//! ``` + +use std::collections::BTreeSet; +use std::path::Path; + +use brahman_net::PeerId; + +/// Política de admisión por `PeerId` para conexiones libp2p. +/// +/// **Nota**: la allowlist sólo se evalúa cuando el server tiene +/// `expected_peer = Some(...)` (path libp2p). El path Unix la +/// ignora — autenticación local va por SO_PEERCRED. +#[derive(Debug, Clone)] +pub struct PeerAllowlist { + allowed: BTreeSet, +} + +#[derive(Debug, thiserror::Error)] +pub enum AllowlistError { + #[error("leer allowlist en {path}: {source}")] + Io { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + #[error("línea {line_no} de {path}: PeerId inválido '{value}'")] + InvalidPeerId { + path: std::path::PathBuf, + line_no: usize, + value: String, + }, +} + +impl PeerAllowlist { + /// Construye una allowlist a partir de un iterable de `PeerId`s. + /// Útil para tests o para listas hardcodeadas. + pub fn from_iter(peers: I) -> Self + where + I: IntoIterator, + { + Self { + allowed: peers.into_iter().collect(), + } + } + + /// Carga la allowlist desde un archivo. Cada línea no-vacía y no + /// comentario debe ser un `PeerId` parseable. Errores incluyen + /// el número de línea para facilitar el debug. + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path).map_err(|e| AllowlistError::Io { + path: path.to_path_buf(), + source: e, + })?; + let mut allowed = BTreeSet::new(); + for (idx, raw) in contents.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.split('#').next().unwrap_or("").trim(); + if trimmed.is_empty() { + continue; + } + let peer = trimmed + .parse::() + .map_err(|_| AllowlistError::InvalidPeerId { + path: path.to_path_buf(), + line_no, + value: trimmed.to_string(), + })?; + allowed.insert(peer); + } + Ok(Self { allowed }) + } + + /// Indica si `peer` está en la lista. Si la lista está vacía, + /// devuelve `false` (la "lista vacía" no es lo mismo que "sin + /// política" — para sin política, no construyas un `PeerAllowlist` + /// y dejá `ServerConfig.allowlist = None`). + pub fn is_allowed(&self, peer: &PeerId) -> bool { + self.allowed.contains(peer) + } + + /// Cantidad de peers en la lista. Útil para logs. + pub fn len(&self) -> usize { + self.allowed.len() + } + + /// `true` si no hay ningún peer en la lista. + pub fn is_empty(&self) -> bool { + self.allowed.is_empty() + } + + /// Itera los `PeerId`s permitidos en orden determinístico. + pub fn iter(&self) -> impl Iterator { + self.allowed.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_net::Keypair; + use tempfile::TempDir; + + fn fresh_peer() -> PeerId { + Keypair::generate_ed25519().public().to_peer_id() + } + + #[test] + fn from_iter_and_is_allowed() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let p3 = fresh_peer(); + let list = PeerAllowlist::from_iter([p1, p2]); + assert!(list.is_allowed(&p1)); + assert!(list.is_allowed(&p2)); + assert!(!list.is_allowed(&p3)); + assert_eq!(list.len(), 2); + } + + #[test] + fn from_file_parses_clean() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("allow.txt"); + let content = format!( + "# header comment\n\n{}\n # indented comment ignored\n{}\n\n", + p1, p2 + ); + std::fs::write(&path, content).unwrap(); + let list = PeerAllowlist::from_file(&path).unwrap(); + assert_eq!(list.len(), 2); + assert!(list.is_allowed(&p1)); + assert!(list.is_allowed(&p2)); + } + + #[test] + fn from_file_supports_inline_comment() { + let p1 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("allow.txt"); + let content = format!("{} # operador foo\n", p1); + std::fs::write(&path, content).unwrap(); + let list = PeerAllowlist::from_file(&path).unwrap(); + assert!(list.is_allowed(&p1)); + } + + #[test] + fn from_file_rejects_invalid_peer_id() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("bad.txt"); + std::fs::write(&path, "not-a-real-peer-id\n").unwrap(); + let err = PeerAllowlist::from_file(&path).unwrap_err(); + match err { + AllowlistError::InvalidPeerId { line_no, .. } => assert_eq!(line_no, 1), + other => panic!("wrong error: {other:?}"), + } + } + + #[test] + fn from_file_missing_returns_io_error() { + let err = PeerAllowlist::from_file("/no/such/path/allow.txt").unwrap_err(); + assert!(matches!(err, AllowlistError::Io { .. })); + } + + #[test] + fn empty_list_rejects_everything() { + let list = PeerAllowlist::from_iter(std::iter::empty()); + assert!(list.is_empty()); + assert!(!list.is_allowed(&fresh_peer())); + } +} diff --git a/crates/core/brahman-handshake/src/server.rs b/crates/core/brahman-handshake/src/server.rs index af93bd6..4140fc0 100644 --- a/crates/core/brahman-handshake/src/server.rs +++ b/crates/core/brahman-handshake/src/server.rs @@ -57,6 +57,13 @@ pub struct ServerConfig { /// locales (lo cual es correcto cuando no hay conectividad o no /// se desea exponer al exterior). pub net: Option>, + /// Política de admisión de peers libp2p. Si está presente, el + /// trust gate del path libp2p exige además que el `peer_id` + /// autenticado por Noise esté en la lista. `None` → modo abierto + /// (cualquier peer Ed25519-válido pasa, comportamiento de Fase 3 + /// sin restricción adicional). El path Unix la ignora — la + /// allowlist es a nivel libp2p, no de filesystem. + pub allowlist: Option, } // Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm @@ -67,6 +74,7 @@ impl std::fmt::Debug for ServerConfig { .field("init_attached", &self.init_attached) .field("broker", &self.broker.as_ref().map(|_| "")) .field("net", &self.net.as_ref().map(|_| "")) + .field("allowlist", &self.allowlist.as_ref().map(|a| a.len())) .finish() } } @@ -540,6 +548,26 @@ where return Ok(None); } + // Allowlist gate (path libp2p): si está configurada, el peer + // autenticado por Noise debe estar en la lista. Se chequea + // ANTES de la firma porque es comparación O(log n) sin crypto + // — ahorra ciclos contra peers no permitidos. La allowlist no + // se aplica al path Unix (autenticación por SO_PEERCRED, no + // por libp2p PeerId). + if let (Some(peer), Some(allowlist)) = (expected_peer, &config.allowlist) { + if !allowlist.is_allowed(&peer) { + write_frame( + stream, + &Frame::Error(HandshakeError::Unauthorized(format!( + "peer {peer} no está en la allowlist" + ))), + ) + .await?; + debug!(peer = %peer, "rechazado por allowlist"); + 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 diff --git a/crates/core/brahman-handshake/tests/handshake.rs b/crates/core/brahman-handshake/tests/handshake.rs index cbe558c..eee1bfc 100644 --- a/crates/core/brahman-handshake/tests/handshake.rs +++ b/crates/core/brahman-handshake/tests/handshake.rs @@ -58,7 +58,7 @@ fn sock_path(name: &str) -> std::path::PathBuf { #[tokio::test] async fn full_handshake_roundtrip() { let path = sock_path("happy"); - let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None }).unwrap(); + let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, allowlist: None }).unwrap(); let session_handle = tokio::spawn({ async move { @@ -195,6 +195,7 @@ async fn broker_registers_and_unregisters_with_session() { init_attached: false, broker: Some(broker.clone()), net: None, + allowlist: None, }, ) .unwrap(); @@ -238,6 +239,7 @@ async fn broker_matches_two_live_modules() { init_attached: false, broker: Some(broker.clone()), net: None, + allowlist: None, }, ) .unwrap(); @@ -314,6 +316,7 @@ async fn match_event_pushed_on_producer_arrival() { init_attached: false, broker: Some(broker.clone()), net: None, + allowlist: None, }, ) .unwrap(); diff --git a/crates/core/brahman-handshake/tests/network_discovery.rs b/crates/core/brahman-handshake/tests/network_discovery.rs index 08a8d3d..430c55d 100644 --- a/crates/core/brahman-handshake/tests/network_discovery.rs +++ b/crates/core/brahman-handshake/tests/network_discovery.rs @@ -85,6 +85,7 @@ async fn dht_discovery_finds_remote_provider() { init_attached: true, broker: Some(a_broker.clone()), net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT + allowlist: None, }, ) .unwrap(), @@ -171,6 +172,7 @@ async fn dht_discovery_negative_unknown_flow() { init_attached: true, broker: Some(a_broker), net: Some(a_net.clone()), + allowlist: None, }, ) .unwrap(), @@ -249,6 +251,7 @@ async fn dht_discovery_withdraws_on_session_cleanup() { init_attached: true, broker: Some(a_broker), net: Some(a_net.clone()), + allowlist: None, }, ) .unwrap(), diff --git a/crates/core/brahman-handshake/tests/network_libp2p.rs b/crates/core/brahman-handshake/tests/network_libp2p.rs index d978662..a829e40 100644 --- a/crates/core/brahman-handshake/tests/network_libp2p.rs +++ b/crates/core/brahman-handshake/tests/network_libp2p.rs @@ -19,6 +19,7 @@ use brahman_card::{ CARD_SCHEMA_VERSION, }; use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop}; +use brahman_handshake::peer_allowlist::PeerAllowlist; use brahman_handshake::server::{Server, ServerConfig}; use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol}; use tempfile::TempDir; @@ -56,6 +57,7 @@ async fn libp2p_handshake_roundtrip() { init_attached: true, broker: Some(broker.clone()), net: None, + allowlist: None, }, ) .unwrap(), @@ -134,6 +136,7 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() { init_attached: true, broker: None, net: None, + allowlist: None, }, ) .unwrap(), @@ -168,3 +171,79 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() { let s = sessions.lock().await; assert_eq!(s.len(), 0, "no debería haber sesión registrada"); } + +/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`. +/// Un cliente con peer_id en la lista pasa el handshake; otro con +/// peer_id distinto es rechazado con `Unauthorized` ANTES de la +/// verificación de firma (la allowlist se chequea primero, es más +/// barata). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn libp2p_handshake_allowlist_admits_listed_rejects_others() { + // Pre-generamos las dos identidades cliente para que A pueda + // construir la allowlist conociendo cuál es la "permitida". + let allowed_kp = Keypair::generate_ed25519(); + let allowed_peer = allowed_kp.public().to_peer_id(); + let denied_kp = Keypair::generate_ed25519(); + // (denied_peer no se necesita para la lista — sólo para clarity) + let _ = denied_kp.public().to_peer_id(); + + // ---- Server con allowlist activa ---- + 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, + allowlist: Some(PeerAllowlist::from_iter([allowed_peer])), + }, + ) + .unwrap(), + ); + let sessions = server.sessions(); + + let server_net = Arc::new(BrahmanNet::new().unwrap()); + let server_peer = server_net.peer_id; + let actual = server_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await; + let mut full = actual.clone(); + full.push(Protocol::P2p(server_peer)); + + tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone())); + + // ---- Cliente PERMITIDO ---- + let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap(); + allowed_net.dial(full.clone()); + tokio::time::sleep(Duration::from_millis(200)).await; + + let card_ok = sample_card("test.allowed"); + let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp) + .await + .expect("peer en allowlist debe pasar"); + + { + let s = sessions.lock().await; + assert_eq!(s.len(), 1, "sesión del peer permitido registrada"); + } + allowed_client.farewell().await.ok(); + tokio::time::sleep(Duration::from_millis(100)).await; + + // ---- Cliente DENEGADO ---- + let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap(); + denied_net.dial(full.clone()); + tokio::time::sleep(Duration::from_millis(200)).await; + + let card_no = sample_card("test.denied"); + let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await; + + assert!( + result.is_err(), + "peer fuera de allowlist debe ser rechazado, got: {:?}", + result.is_ok() + ); + { + let s = sessions.lock().await; + assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado"); + } +} diff --git a/crates/core/ente-zero/src/main.rs b/crates/core/ente-zero/src/main.rs index bc5711d..78b07b6 100644 --- a/crates/core/ente-zero/src/main.rs +++ b/crates/core/ente-zero/src/main.rs @@ -168,6 +168,12 @@ async fn primordial_loop( // reboots). let brahman_net = setup_brahman_net(dev_mode).await; + // Allowlist opcional de peers libp2p: si BRAHMAN_PEER_ALLOWLIST + // apunta a un archivo, cualquier handshake remoto requiere que + // su peer_id esté en la lista. Sin la env, modo abierto (todo + // peer Ed25519-válido pasa el trust gate de Fase 3). + let brahman_allowlist = setup_brahman_allowlist(); + let brahman_sock = brahman_handshake::transport::default_socket_path(); match brahman_handshake::server::Server::bind( &brahman_sock, @@ -175,6 +181,7 @@ async fn primordial_loop( init_attached: true, broker: Some(brahman_broker.clone()), net: brahman_net.clone(), + allowlist: brahman_allowlist.clone(), }, ) { Ok(server) => { @@ -698,3 +705,36 @@ async fn setup_brahman_net( Some(net) } + +/// Carga la allowlist de peers libp2p desde el archivo apuntado por +/// `BRAHMAN_PEER_ALLOWLIST`. Sin la env, devuelve `None` (modo abierto: +/// cualquier peer Ed25519-válido pasa el trust gate). Si la env está +/// pero el archivo falla, loggea y degrada a None — la doctrina PID 1 +/// de no romper por subsistemas opcionales se mantiene. +fn setup_brahman_allowlist() -> Option { + let path = match std::env::var("BRAHMAN_PEER_ALLOWLIST") { + Ok(s) if !s.is_empty() => s, + _ => { + tracing::debug!("BRAHMAN_PEER_ALLOWLIST no set — modo abierto (todo peer pasa)"); + return None; + } + }; + match brahman_handshake::peer_allowlist::PeerAllowlist::from_file(&path) { + Ok(list) => { + info!( + path = %path, + peers = list.len(), + "allowlist de peers libp2p cargada" + ); + Some(list) + } + Err(e) => { + warn!( + path = %path, + ?e, + "BRAHMAN_PEER_ALLOWLIST inválido — degradando a modo abierto (sin restricción)" + ); + None + } + } +}