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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user