feat(brahman-handshake+ente-zero): allowlist explicita de peers libp2p
Capa de politica sobre el trust criptografico de Fase 3. Hasta ahora
cualquier peer Ed25519-valido pasaba el handshake remoto; con
allowlist activa, solo los peers explicitamente listados. Aplica
unicamente al path libp2p — el path Unix sigue usando SO_PEERCRED
del kernel.
API nueva en brahman_handshake::peer_allowlist:
- PeerAllowlist::from_iter / from_file con AllowlistError tipado.
- Formato del archivo: PeerId base58 por linea, # comentarios (linea
entera o inline), lineas vacias ignoradas. Errores de parseo
reportan numero de linea.
- is_allowed, len, is_empty, iter.
Wire en el server:
- ServerConfig.allowlist: Option<PeerAllowlist>. None = modo abierto
(compat). Some = solo los listados.
- Gate en do_handshake ANTES de la verificacion de firma — la
comparacion BTreeSet O(log n) es mas barata que crypto, asi que
rechazamos peers invalidos antes de gastar ciclos.
- HandshakeError::Unauthorized("peer X no esta en la allowlist").
Wire en Arje (ente-zero):
- Env var BRAHMAN_PEER_ALLOWLIST apuntando a un archivo.
- setup_brahman_allowlist carga al startup; degrada a None si el
archivo falla (doctrina PID 1: no romper por subsistemas
opcionales).
Activacion end-to-end:
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 + 1 E2E en network_libp2p
(libp2p_handshake_allowlist_admits_listed_rejects_others). 25 tests
verdes en brahman-handshake. Sin regresion en ente-zero.
Pendientes: denylist explicita, hot reload via SIGHUP/watch, aplicar
politica a nivel de swarm via libp2p_allow_block_list::Behaviour
para rechazar ANTES del Noise handshake.
This commit is contained in:
@@ -6,6 +6,78 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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<PeerAllowlist>`. `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
|
### 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
|
Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión
|
||||||
con outputs cerraba (Farewell, EOF, error), el record que la
|
con outputs cerraba (Farewell, EOF, error), el record que la
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod messages;
|
|||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod peer_allowlist;
|
||||||
pub mod signature;
|
pub mod signature;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
|
|||||||
@@ -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<PeerId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<I>(peers: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = PeerId>,
|
||||||
|
{
|
||||||
|
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<Path>) -> Result<Self, AllowlistError> {
|
||||||
|
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::<PeerId>()
|
||||||
|
.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<Item = &PeerId> {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ pub struct ServerConfig {
|
|||||||
/// locales (lo cual es correcto cuando no hay conectividad o no
|
/// locales (lo cual es correcto cuando no hay conectividad o no
|
||||||
/// se desea exponer al exterior).
|
/// se desea exponer al exterior).
|
||||||
pub net: Option<Arc<BrahmanNet>>,
|
pub net: Option<Arc<BrahmanNet>>,
|
||||||
|
/// 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<crate::peer_allowlist::PeerAllowlist>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm
|
// 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("init_attached", &self.init_attached)
|
||||||
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
|
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
|
||||||
.field("net", &self.net.as_ref().map(|_| "<net>"))
|
.field("net", &self.net.as_ref().map(|_| "<net>"))
|
||||||
|
.field("allowlist", &self.allowlist.as_ref().map(|a| a.len()))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,6 +548,26 @@ where
|
|||||||
return Ok(None);
|
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
|
// Trust gate: en path libp2p (expected_peer = Some), exigir
|
||||||
// firma cuya public key derive al peer autenticado por Noise.
|
// firma cuya public key derive al peer autenticado por Noise.
|
||||||
// En path Unix (expected_peer = None), si la firma viene se
|
// En path Unix (expected_peer = None), si la firma viene se
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ fn sock_path(name: &str) -> std::path::PathBuf {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn full_handshake_roundtrip() {
|
async fn full_handshake_roundtrip() {
|
||||||
let path = sock_path("happy");
|
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({
|
let session_handle = tokio::spawn({
|
||||||
async move {
|
async move {
|
||||||
@@ -195,6 +195,7 @@ async fn broker_registers_and_unregisters_with_session() {
|
|||||||
init_attached: false,
|
init_attached: false,
|
||||||
broker: Some(broker.clone()),
|
broker: Some(broker.clone()),
|
||||||
net: None,
|
net: None,
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -238,6 +239,7 @@ async fn broker_matches_two_live_modules() {
|
|||||||
init_attached: false,
|
init_attached: false,
|
||||||
broker: Some(broker.clone()),
|
broker: Some(broker.clone()),
|
||||||
net: None,
|
net: None,
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -314,6 +316,7 @@ async fn match_event_pushed_on_producer_arrival() {
|
|||||||
init_attached: false,
|
init_attached: false,
|
||||||
broker: Some(broker.clone()),
|
broker: Some(broker.clone()),
|
||||||
net: None,
|
net: None,
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async fn dht_discovery_finds_remote_provider() {
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: Some(a_broker.clone()),
|
broker: Some(a_broker.clone()),
|
||||||
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
|
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -171,6 +172,7 @@ async fn dht_discovery_negative_unknown_flow() {
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: Some(a_broker),
|
broker: Some(a_broker),
|
||||||
net: Some(a_net.clone()),
|
net: Some(a_net.clone()),
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -249,6 +251,7 @@ async fn dht_discovery_withdraws_on_session_cleanup() {
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: Some(a_broker),
|
broker: Some(a_broker),
|
||||||
net: Some(a_net.clone()),
|
net: Some(a_net.clone()),
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use brahman_card::{
|
|||||||
CARD_SCHEMA_VERSION,
|
CARD_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop};
|
use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop};
|
||||||
|
use brahman_handshake::peer_allowlist::PeerAllowlist;
|
||||||
use brahman_handshake::server::{Server, ServerConfig};
|
use brahman_handshake::server::{Server, ServerConfig};
|
||||||
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
|
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
@@ -56,6 +57,7 @@ async fn libp2p_handshake_roundtrip() {
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: Some(broker.clone()),
|
broker: Some(broker.clone()),
|
||||||
net: None,
|
net: None,
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -134,6 +136,7 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() {
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: None,
|
broker: None,
|
||||||
net: None,
|
net: None,
|
||||||
|
allowlist: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -168,3 +171,79 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() {
|
|||||||
let s = sessions.lock().await;
|
let s = sessions.lock().await;
|
||||||
assert_eq!(s.len(), 0, "no debería haber sesión registrada");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ async fn primordial_loop(
|
|||||||
// reboots).
|
// reboots).
|
||||||
let brahman_net = setup_brahman_net(dev_mode).await;
|
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();
|
let brahman_sock = brahman_handshake::transport::default_socket_path();
|
||||||
match brahman_handshake::server::Server::bind(
|
match brahman_handshake::server::Server::bind(
|
||||||
&brahman_sock,
|
&brahman_sock,
|
||||||
@@ -175,6 +181,7 @@ async fn primordial_loop(
|
|||||||
init_attached: true,
|
init_attached: true,
|
||||||
broker: Some(brahman_broker.clone()),
|
broker: Some(brahman_broker.clone()),
|
||||||
net: brahman_net.clone(),
|
net: brahman_net.clone(),
|
||||||
|
allowlist: brahman_allowlist.clone(),
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Ok(server) => {
|
Ok(server) => {
|
||||||
@@ -698,3 +705,36 @@ async fn setup_brahman_net(
|
|||||||
|
|
||||||
Some(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<brahman_handshake::peer_allowlist::PeerAllowlist> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user