diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d2177..f02f39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,102 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico +Cierra el último 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. + +Solución: separar **identity master** (Ed25519 persistente forever, +identifica al nodo como entidad lógica) de **session libp2p** +(Ed25519 efímera, rotable). El master firma certs de session con +expiración. La política de admisión se evalúa contra el +`master_peer_id` del cert — el session peer_id puede cambiar +libremente sin tocar las allowlists. + +API nueva en `brahman_handshake::identity`: +- `Identity::from_keypair(master)` — wrapper sobre la master kp. +- `Identity::master_peer_id()` — el peer_id estable del nodo. +- `Identity::issue_session_cert(session_kp, ttl) -> SessionCert` — + firma un cert que vincula session_pubkey + expires_at_ms. +- `SessionCert::verify()` — chequea versión, firma criptográfica, + no expiración. 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 con keypairs distintas). +- `CertError` tipado: `UnknownVersion`, `DecodeMaster`, + `DecodeSession`, `InvalidSignature`, `Expired`, `SessionMismatch`, + `Sign`. +- `DEFAULT_SESSION_TTL = 24h`. + +Wire: +- `Hello.identity_cert: Option` agregado (default None, + back-compat). +- `Client::connect_with_stream_signed_with_cert(stream, card, wit, + session_kp, cert)` — variante que adjunta el cert. +- `network::connect_libp2p_with_cert(net, peer, card, wit, + session_kp, cert)` — paralelo a `connect_libp2p`. + +Server (`do_handshake`): +- Nuevo paso ANTES del policy gate: si `Hello.identity_cert.is_some()`, + se verifica con `verify_against_session(&hello.signature.public_key)`. + El `logical_peer` que se evalúa contra la policy es el + `master_peer_id` derivado, NO el session peer_id. +- Sin cert (path Fase 3): `logical_peer = expected_peer` (compat). +- Si el cert es inválido (firma rota, expirado, session mismatch), + rechazo con `Unauthorized` antes de evaluar policy. +- Migración gradual: clientes sin cert siguen funcionando contra + servers con policy basada en session peer_ids. + +Canonicalización del payload firmado: +``` +[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms] +``` +`SESSION_CERT_VERSION = 1` documenta el esquema; cualquier cambio +fuerza bump (clientes viejos no validan certs nuevos). + +Sobre el swarm-level deny: +- El `block_list` del swarm sigue operando con session peer_ids + (Noise sólo conoce eso). Si la operatoria lista master_peer_ids + en deny, el handshake-level gate los para; el swarm-level no. + El operador elige granularity: listar masters = robust a + rotaciones; listar sessions = rechazo más temprano. + +Tests: 8 unit en `identity::tests`: +- `issue_and_verify_cert` — roundtrip básico, peer_ids derivados. +- `verify_against_session_admits_matching` y + `_rejects_mismatch` — el cert vincula 1 sola session pubkey. +- `cert_with_zero_ttl_is_expired` — expiración chequeada con tiempo + real. +- `tampered_signature_rejected` y `tampered_expires_at_rejected` — + cualquier mutación del cert post-firma falla. +- `unknown_version_rejected` — schema versionado defensivamente. +- `rotated_session_with_same_master_yields_same_master_peer_id` — + la propiedad fundamental: rotar session NO cambia master_peer_id. + +Plus 1 E2E definitivo en `network_libp2p.rs`: +`identity_cert_allows_session_rotation_without_policy_change`. +- A configura `policy = allowlist[B.master_peer_id]` (master, no + session). +- B se conecta con session1 + cert(master, session1) → admitido. + Sesión registrada, farewell limpio. +- B "rota": genera session2 ≠ session1, mismo master, emite cert2. +- B se conecta con session2 + cert2 → admitido también, **sin que + A toque su allowlist**. +- Sanity: una session sin cert (cuyo session_peer NO está en allow) + es rechazada. + +40 tests verdes en brahman-handshake + brahman-net (24 unit +incluyendo identity + 7 handshake + 3 discovery + 6 libp2p +incluyendo rotation E2E). Ningún regreso. + +Wire en Arje queda como follow-up: ente-zero hoy es server-only y +no necesita identity (su keypair libp2p ya es estable vía +keypair_store). Cuando algún módulo de Arje haga conexiones +salientes con cert, se cargará la identity master separada de la +session vía nueva env `BRAHMAN_IDENTITY_PATH`. La API ya está +lista. + ### feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p Optimización de seguridad: la denylist ya no espera al handshake brahman para rechazar — ahora se proyecta al `block_list` behaviour diff --git a/crates/core/brahman-handshake/src/client.rs b/crates/core/brahman-handshake/src/client.rs index 025e5ad..8977f37 100644 --- a/crates/core/brahman-handshake/src/client.rs +++ b/crates/core/brahman-handshake/src/client.rs @@ -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, ) -> Result { - 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, keypair: &Keypair, ) -> Result { - 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, + session_keypair: &Keypair, + identity_cert: SessionCert, + ) -> Result { + 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, keypair: Option<&Keypair>, + identity_cert: Option, ) -> Result { 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?; diff --git a/crates/core/brahman-handshake/src/identity.rs b/crates/core/brahman-handshake/src/identity.rs new file mode 100644 index 0000000..ab18438 --- /dev/null +++ b/crates/core/brahman-handshake/src/identity.rs @@ -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`. 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, +} + +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) -> 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 { + 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, + /// Public key de la master identity, en formato libp2p protobuf. + /// El verificador deriva el `master_peer_id` desde acá. + pub master_pubkey: Vec, + /// 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, +} + +#[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 { + 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 { + 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" + ); + } +} diff --git a/crates/core/brahman-handshake/src/lib.rs b/crates/core/brahman-handshake/src/lib.rs index 198babe..8014a66 100644 --- a/crates/core/brahman-handshake/src/lib.rs +++ b/crates/core/brahman-handshake/src/lib.rs @@ -18,6 +18,7 @@ #![warn(rust_2018_idioms)] pub mod codec; +pub mod identity; pub mod messages; pub mod server; pub mod client; diff --git a/crates/core/brahman-handshake/src/messages.rs b/crates/core/brahman-handshake/src/messages.rs index 6cbdbdd..05e31b1 100644 --- a/crates/core/brahman-handshake/src/messages.rs +++ b/crates/core/brahman-handshake/src/messages.rs @@ -42,6 +42,17 @@ pub struct Hello { /// [`super::signature`] para construcción y verificación. #[serde(default)] pub signature: Option, + /// 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, } /// Firma de un Hello. La `public_key` viaja en el formato canónico diff --git a/crates/core/brahman-handshake/src/network.rs b/crates/core/brahman-handshake/src/network.rs index ceb818d..23dcf90 100644 --- a/crates/core/brahman-handshake/src/network.rs +++ b/crates/core/brahman-handshake/src/network.rs @@ -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, + session_keypair: &Keypair, + cert: SessionCert, +) -> Result, 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 // ===================================================================== diff --git a/crates/core/brahman-handshake/src/server.rs b/crates/core/brahman-handshake/src/server.rs index 4e74783..a04d68c 100644 --- a/crates/core/brahman-handshake/src/server.rs +++ b/crates/core/brahman-handshake/src/server.rs @@ -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( diff --git a/crates/core/brahman-handshake/tests/handshake.rs b/crates/core/brahman-handshake/tests/handshake.rs index 5e5b417..ba173de 100644 --- a/crates/core/brahman-handshake/tests/handshake.rs +++ b/crates/core/brahman-handshake/tests/handshake.rs @@ -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(); diff --git a/crates/core/brahman-handshake/tests/network_libp2p.rs b/crates/core/brahman-handshake/tests/network_libp2p.rs index d0310dd..92ae6e6 100644 --- a/crates/core/brahman-handshake/tests/network_libp2p.rs +++ b/crates/core/brahman-handshake/tests/network_libp2p.rs @@ -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" + ); +}