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
@@ -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"
);
}