feat(brahman-handshake): multi-key identity — rotacion de session sin perder peer_id logico

Cierra el ultimo pendiente del plan de red P2P. Hasta ahora, rotar
la keypair libp2p de un nodo cambiaba su peer_id, lo que invalidaba
todas las allowlists/denylists remotas que lo referenciaban.
Imposible rotar sin coordinar con todos los pares.

Solucion: separar identity master (Ed25519 persistente forever,
identifica al nodo como entidad logica) de session libp2p (Ed25519
efimera, rotable). El master firma certs de session con expiracion.
La politica de admision se evalua contra el master_peer_id del cert
— el session peer_id puede cambiar libremente sin tocar allowlists.

API nueva en brahman_handshake::identity:
- Identity::from_keypair / master_peer_id / issue_session_cert.
- SessionCert::verify devuelve (master_peer_id, session_peer_id).
- SessionCert::verify_against_session(expected_session_pk) verify +
  exige que el cert vincule esa session pubkey (previene reuso de
  certs ajenos).
- CertError tipado: UnknownVersion, DecodeMaster, DecodeSession,
  InvalidSignature, Expired, SessionMismatch, Sign.
- DEFAULT_SESSION_TTL = 24h. SESSION_CERT_VERSION = 1 documenta
  esquema; bump al cambiar canonicalizacion.

Wire:
- Hello.identity_cert: Option<SessionCert> agregado (default None,
  back-compat).
- Client::connect_with_stream_signed_with_cert variante que adjunta
  cert.
- network::connect_libp2p_with_cert paralelo a connect_libp2p.

Server (do_handshake): nuevo paso ANTES del policy gate. Si el
Hello trae cert, verify_against_session(&hello.signature.public_key)
y el logical_peer = master_peer_id derivado. Sin cert (path Fase 3),
logical_peer = expected_peer (compat). Cert invalido -> Unauthorized
antes de evaluar policy.

Migracion gradual: clientes sin cert siguen funcionando contra
servers con policy basada en session peer_ids.

Tests: 8 unit en identity::tests (issue+verify, mismatch, expired,
tampered sig/expires_at, unknown version, rotated_session_with_same_
master_yields_same_master_peer_id — la propiedad fundamental).

E2E definitivo identity_cert_allows_session_rotation_without_policy_
change: A allowlist[master_peer]; B conecta con session1+cert ->
admitido; B rota a session2!=session1 con cert nuevo del MISMO
master -> admitido SIN tocar la allowlist; sanity: session sin cert
es rechazada.

40 tests verdes en brahman-handshake + brahman-net.

Wire en Arje queda como follow-up: ente-zero es server-only y no
necesita identity (su keypair libp2p ya es estable). La API esta
lista para cuando algun modulo haga conexiones salientes con cert.
This commit is contained in:
Sergio
2026-05-09 15:55:36 +00:00
parent 7a0481962e
commit f9a3c33586
9 changed files with 704 additions and 9 deletions
+21 -2
View File
@@ -11,6 +11,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame};
use crate::identity::SessionCert;
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
use crate::signature::{sign_hello, SignatureError};
@@ -89,7 +90,7 @@ where
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, None).await
Self::connect_inner(stream, card, wit, None, None).await
}
/// Igual que `connect_with_stream` pero firma el Hello con
@@ -103,7 +104,23 @@ where
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(keypair)).await
Self::connect_inner(stream, card, wit, Some(keypair), None).await
}
/// Igual que `connect_with_stream_signed` pero además adjunta un
/// `SessionCert` que vincula la session keypair a una identity
/// master estable. El server, al recibir el cert, evalúa la
/// política de admisión contra el `master_peer_id` (no contra
/// el session peer_id) — permitiendo rotar la session sin perder
/// la identidad reconocida en allowlists remotas.
pub async fn connect_with_stream_signed_with_cert(
stream: S,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
identity_cert: SessionCert,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
}
async fn connect_inner(
@@ -111,6 +128,7 @@ where
card: Card,
wit: Option<WitInterface>,
keypair: Option<&Keypair>,
identity_cert: Option<SessionCert>,
) -> Result<Self, ClientError> {
card.validate()
.map_err(|e| ClientError::InvalidCard(e.to_string()))?;
@@ -127,6 +145,7 @@ where
card: wire_card,
wit,
signature,
identity_cert,
};
write_frame(&mut stream, &Frame::Hello(hello)).await?;
@@ -0,0 +1,358 @@
//! Identidad multi-key del nodo: separación entre **identity** (master,
//! persistente forever) y **session** (keypair libp2p efímera, rotable).
//!
//! ## Problema que resuelve
//!
//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
//! keypair (por compromiso, por higiene, por cambio de hardware)
//! cambiaba el peer_id, lo que invalidaba todas las allowlists
//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
//! coordinar.
//!
//! ## Modelo
//!
//! Cada nodo tiene **dos** keypairs Ed25519:
//!
//! - **Identity** (master): persistente para siempre. Identifica al
//! nodo como entidad lógica. Su `peer_id` es lo que va en
//! allowlists/denylists remotas.
//! - **Session** (operacional): la que libp2p usa para Noise. Puede
//! rotarse libremente sin coordinar — el nodo emite un
//! [`SessionCert`] firmado con la identity que prueba "esta session
//! key pertenece a mí".
//!
//! ## Wire
//!
//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
//! server valida:
//! 1. La session key del cert == public key de `Hello.signature` ==
//! deriva al peer_id autenticado por Noise (consistencia interna).
//! 2. La firma del cert verifica con la master pubkey declarada.
//! 3. El cert no está expirado.
//! 4. La política (allowlist/denylist) se evalúa contra
//! `master.to_peer_id()`, NO contra el session peer_id.
//!
//! Sin cert, el server cae al modelo de Fase 3: policy contra session
//! peer_id (compat). Esto permite migración gradual.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use brahman_net::{Keypair, PeerId, PublicKey};
use serde::{Deserialize, Serialize};
/// TTL recomendado para un session cert: 24 horas. Suficiente para
/// que un nodo "viva" un día sin re-emitir; corto enough para que
/// un cert robado no sirva por mucho. Operadores con políticas
/// estrictas pueden bajarlo; con uptime largo, subirlo.
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Identidad lógica del nodo. Wraps la master keypair y emite certs
/// de session firmados.
///
/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
/// Sólo se usa para firmar certs locales y para derivar
/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
/// session keypair.
#[derive(Clone)]
pub struct Identity {
master: Arc<Keypair>,
}
impl Identity {
/// Construye una Identity a partir de una keypair existente.
/// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
pub fn from_keypair(master: Keypair) -> Self {
Self {
master: Arc::new(master),
}
}
/// Variante para callers que ya tienen la keypair en `Arc`.
pub fn from_arc(master: Arc<Keypair>) -> Self {
Self { master }
}
/// PeerId derivado de la master pubkey. Ésta es la identidad
/// "lógica" estable del nodo — lo que va en allowlists/denylists.
pub fn master_peer_id(&self) -> PeerId {
self.master.public().to_peer_id()
}
/// Emite un [`SessionCert`] firmado: certifica que la session
/// keypair `session` pertenece a esta identity hasta `now + ttl`.
pub fn issue_session_cert(
&self,
session: &Keypair,
ttl: Duration,
) -> Result<SessionCert, CertError> {
let now_ms = now_unix_ms();
let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
let session_pubkey = session.public().encode_protobuf();
let master_pubkey = self.master.public().encode_protobuf();
let payload = sign_payload(&session_pubkey, expires_at_ms);
let signature = self
.master
.sign(&payload)
.map_err(|e| CertError::Sign(e.to_string()))?;
Ok(SessionCert {
version: SESSION_CERT_VERSION,
session_pubkey,
master_pubkey,
expires_at_ms,
signature,
})
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("master_peer_id", &self.master_peer_id())
.finish()
}
}
/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
/// el shape de `SessionCert`.
pub const SESSION_CERT_VERSION: u8 = 1;
/// Certificado firmado por la identity que vincula una session key
/// libp2p a la identidad master del nodo, con expiración.
///
/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
/// formato canónico libp2p (`encode_protobuf`) — mismo encoding que
/// `HelloSignature.public_key`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionCert {
/// Versión del esquema (ver `SESSION_CERT_VERSION`).
pub version: u8,
/// Public key de la session libp2p (la que firma el Hello), en
/// formato libp2p protobuf.
pub session_pubkey: Vec<u8>,
/// Public key de la master identity, en formato libp2p protobuf.
/// El verificador deriva el `master_peer_id` desde acá.
pub master_pubkey: Vec<u8>,
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
/// cert no es válido y el nodo debe re-emitirse uno nuevo
/// (rotando o re-firmando la misma session).
pub expires_at_ms: u64,
/// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
pub signature: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum CertError {
#[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
UnknownVersion(u8),
#[error("decode master_pubkey: {0}")]
DecodeMaster(String),
#[error("decode session_pubkey: {0}")]
DecodeSession(String),
#[error("firma del cert inválida")]
InvalidSignature,
#[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
Expired { expires: u64, now: u64 },
#[error("session_pubkey del cert no coincide con la del Hello.signature")]
SessionMismatch,
#[error("error al firmar: {0}")]
Sign(String),
}
impl SessionCert {
/// Verifica el cert: versión, firma criptográfica, no expiración.
/// Devuelve el `(master_peer_id, session_peer_id)` derivados.
///
/// El caller debe además chequear que `session_peer_id` coincide
/// con el peer_id autenticado por Noise (lo verifica
/// [`verify_against_session`]).
pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
if self.version != SESSION_CERT_VERSION {
return Err(CertError::UnknownVersion(self.version));
}
let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
.map_err(|e| CertError::DecodeMaster(e.to_string()))?;
let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
.map_err(|e| CertError::DecodeSession(e.to_string()))?;
let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
if !master_pk.verify(&payload, &self.signature) {
return Err(CertError::InvalidSignature);
}
let now = now_unix_ms();
if now >= self.expires_at_ms {
return Err(CertError::Expired {
expires: self.expires_at_ms,
now,
});
}
Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
}
/// Verifica el cert Y exige que su `session_pubkey` matchee a
/// `expected_session_pubkey` (la que firmó el Hello). Esto
/// previene que un atacante reutilice un cert válido con una
/// session key distinta.
///
/// Devuelve el `master_peer_id` derivado, que es el que el server
/// debe usar para evaluar la política de admisión.
pub fn verify_against_session(
&self,
expected_session_pubkey: &[u8],
) -> Result<PeerId, CertError> {
if self.session_pubkey.as_slice() != expected_session_pubkey {
return Err(CertError::SessionMismatch);
}
let (master_peer, _session_peer) = self.verify()?;
Ok(master_peer)
}
}
/// Concat canónico de los campos firmados. Cualquier cambio aquí
/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
buf.push(SESSION_CERT_VERSION);
buf.extend_from_slice(b"sess");
buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
buf.extend_from_slice(session_pubkey);
buf.extend_from_slice(&expires_at_ms.to_le_bytes());
buf
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_and_verify_cert() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let (master_peer, session_peer) = cert.verify().unwrap();
assert_eq!(master_peer, id.master_peer_id());
assert_eq!(session_peer, session.public().to_peer_id());
}
#[test]
fn verify_against_session_admits_matching() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let session_pk = session.public().encode_protobuf();
let master_peer = cert.verify_against_session(&session_pk).unwrap();
assert_eq!(master_peer, id.master_peer_id());
}
#[test]
fn verify_against_session_rejects_mismatch() {
let master = Keypair::generate_ed25519();
let session_a = Keypair::generate_ed25519();
let session_b = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
.unwrap();
let other_pk = session_b.public().encode_protobuf();
let err = cert.verify_against_session(&other_pk).unwrap_err();
assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
}
#[test]
fn cert_with_zero_ttl_is_expired() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, Duration::from_secs(0))
.unwrap();
// Pequeña espera para asegurar que now_ms > expires_at_ms.
std::thread::sleep(Duration::from_millis(5));
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
}
#[test]
fn tampered_signature_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
if let Some(b) = cert.signature.last_mut() {
*b ^= 0x01;
}
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn tampered_expires_at_rejected() {
// Si alguien extiende el expires_at sin re-firmar, la firma
// no cuadra → InvalidSignature.
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn unknown_version_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.version = 99;
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
}
#[test]
fn rotated_session_with_same_master_yields_same_master_peer_id() {
// La propiedad fundamental: rotar la session key NO cambia el
// master_peer_id derivado del cert.
let master = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let original_master_peer = id.master_peer_id();
let session1 = Keypair::generate_ed25519();
let cert1 = id
.issue_session_cert(&session1, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert1, _) = cert1.verify().unwrap();
// Rotar: nueva session keypair, mismo master.
let session2 = Keypair::generate_ed25519();
let cert2 = id
.issue_session_cert(&session2, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert2, _) = cert2.verify().unwrap();
assert_eq!(master_from_cert1, original_master_peer);
assert_eq!(master_from_cert2, original_master_peer);
assert_eq!(
master_from_cert1, master_from_cert2,
"rotar session NO debe cambiar el master_peer_id"
);
}
}
+1
View File
@@ -18,6 +18,7 @@
#![warn(rust_2018_idioms)]
pub mod codec;
pub mod identity;
pub mod messages;
pub mod server;
pub mod client;
@@ -42,6 +42,17 @@ pub struct Hello {
/// [`super::signature`] para construcción y verificación.
#[serde(default)]
pub signature: Option<HelloSignature>,
/// Cert opcional que vincula la session keypair (la que firma el
/// Hello) a una **identity master** estable. Si está presente,
/// la política de admisión se evalúa contra el `master_peer_id`
/// derivado del cert — no contra el session peer_id. Esto permite
/// rotar la session sin invalidar las allowlists remotas.
///
/// Ver [`super::identity::SessionCert`] para shape y semantics.
/// Si es `None`, fallback al modelo de Fase 3: la política
/// evalúa el session peer_id directamente.
#[serde(default)]
pub identity_cert: Option<crate::identity::SessionCert>,
}
/// Firma de un Hello. La `public_key` viaja en el formato canónico
@@ -45,6 +45,8 @@ use std::sync::Arc;
use brahman_card::{Card, TypeRef, WitInterface};
use brahman_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
use crate::identity::SessionCert;
use futures::StreamExt;
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
use tracing::{debug, warn};
@@ -148,6 +150,39 @@ pub async fn connect_libp2p(
Ok(client)
}
/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
/// El server, al verificar el cert, evalúa la política de admisión
/// contra el `master_peer_id` derivado — no contra el `peer_id`
/// libp2p. Esto permite **rotar** la session keypair sin perder
/// reconocimiento en allowlists remotas.
///
/// El `keypair` debe ser la session libp2p (la que firma la conexión
/// Noise); el `cert` debe haber sido emitido por una identity master
/// para esa misma session pubkey (ver
/// [`crate::identity::Identity::issue_session_cert`]).
pub async fn connect_libp2p_with_cert(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
cert: SessionCert,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed_with_cert(
stream.compat(),
card,
wit,
session_keypair,
cert,
)
.await?;
Ok(client)
}
// =====================================================================
// Discovery remoto via DHT — Fase 2
// =====================================================================
+53 -6
View File
@@ -547,13 +547,60 @@ where
return Ok(None);
}
// Identity cert (multi-key identity, opcional): si el cliente
// adjuntó cert, la "identidad lógica" del peer es el master
// derivado del cert (estable across rotaciones), no el session
// peer_id (efímero). Sin cert, fallback al modelo de Fase 3
// (logical = session). Esto permite migración gradual y
// backwards compatibility con clientes que no usan identity.
let logical_peer = if let (Some(session_peer), Some(cert)) =
(expected_peer, &hello.identity_cert)
{
let session_pk_bytes: &[u8] = match &hello.signature {
Some(sig) => &sig.public_key,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello con identity_cert requiere también signature".into(),
)),
)
.await?;
return Ok(None);
}
};
match cert.verify_against_session(session_pk_bytes) {
Ok(master_peer) => {
debug!(
session = %session_peer,
master = %master_peer,
"identity cert válido — policy se evalúa contra master_peer"
);
Some(master_peer)
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"identity cert inválido: {e}"
))),
)
.await?;
debug!(peer = %session_peer, error = %e, "cert rechazado");
return Ok(None);
}
}
} else {
expected_peer
};
// Policy gate (path libp2p): si está configurada, el peer
// autenticado por Noise debe pasar la política (deny first,
// luego allow). Se chequea ANTES de la firma porque es
// comparación O(log n) sin crypto — ahorra ciclos contra peers
// no permitidos. La política no se aplica al path Unix
// (autenticación por SO_PEERCRED, no por libp2p PeerId).
if let (Some(peer), Some(policy)) = (expected_peer, &config.policy) {
// autenticado debe pasar la política (deny first, luego allow).
// El peer evaluado es `logical_peer`: master si hay cert,
// session si no. Se chequea ANTES de la firma porque es
// comparación O(log n) sin crypto. La política no se aplica
// al path Unix (autenticación por SO_PEERCRED, no por PeerId).
if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
let decision = policy.evaluate(&peer);
if !decision.is_admitted() {
write_frame(
@@ -126,6 +126,7 @@ async fn server_rejects_protocol_mismatch() {
card: sample_card("future-module").into(),
wit: None,
signature: None,
identity_cert: None,
};
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
@@ -18,7 +18,8 @@ use brahman_card::{
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop};
use brahman_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
use brahman_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
use brahman_handshake::peer_policy::PeerPolicy;
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
@@ -396,3 +397,129 @@ async fn swarm_level_deny_blocks_before_noise() {
}
}
}
/// Multi-key identity: la propiedad fundamental que cierra el
/// proyecto. El cliente B tiene una identity master estable; el
/// server A le permite el master_peer en allowlist. B se conecta con
/// **session1**; pasa. B "rota": genera **session2** distinta, emite
/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
/// también — sin que A toque su allowlist.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn identity_cert_allows_session_rotation_without_policy_change() {
// Master de B (estable, persistente).
let master_kp = Keypair::generate_ed25519();
let master_peer = master_kp.public().to_peer_id();
let identity = Identity::from_keypair(master_kp);
// A configura policy: allowlist con master_peer (NO sessions).
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,
policy: Some(PeerPolicy::from_sets(
Some([master_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.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()));
// ---- Conexión 1: session1 ----
let session1_kp = Keypair::generate_ed25519();
let cert1 = identity
.issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
net1.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client1 = connect_libp2p_with_cert(
&net1,
server_peer,
sample_card("test.session1"),
None,
&session1_kp,
cert1,
)
.await
.expect("session1 con cert válido del master allowlisted debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session1 registrada");
}
client1.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- ROTACIÓN: session2 distinta, mismo master ----
let session2_kp = Keypair::generate_ed25519();
assert_ne!(
session1_kp.public().to_peer_id(),
session2_kp.public().to_peer_id(),
"test inválido si las sessions son iguales"
);
let cert2 = identity
.issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
net2.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client2 = connect_libp2p_with_cert(
&net2,
server_peer,
sample_card("test.session2"),
None,
&session2_kp,
cert2,
)
.await
.expect(
"session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session2 registrada");
}
client2.farewell().await.ok();
// Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
// NO está en la allowlist (porque la allowlist tiene master, no
// sessions) DEBE ser rechazada.
let session_other = Keypair::generate_ed25519();
let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
net_other.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let result = connect_libp2p(
&net_other,
server_peer,
sample_card("test.no_cert"),
None,
&session_other,
)
.await;
assert!(
result.is_err(),
"sin cert, session_peer_id (no listado) debe ser rechazado"
);
}