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:
Sergio
2026-05-09 15:27:15 +00:00
parent 2e6afd0973
commit 505748dd41
8 changed files with 425 additions and 1 deletions
@@ -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();
@@ -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(),
@@ -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");
}
}