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:
@@ -6,6 +6,102 @@ ratio/diff ver `git show <sha>`.
|
||||
|
||||
## 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<SessionCert>` 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
|
||||
|
||||
Reference in New Issue
Block a user