⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/core/brahman-handshake/src/identity.rs
History
Who touched what
raw
feat(brahman-handshake): multi-key identity — rotacion de session sin perder peer_id logico
1//! Identidad multi-key del nodo: separación entre **identity** (master,
2//! persistente forever) y **session** (keypair libp2p efímera, rotable).
3//!
4//! ## Problema que resuelve
5//!
6//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
7//! keypair (por compromiso, por higiene, por cambio de hardware)
8//! cambiaba el peer_id, lo que invalidaba todas las allowlists
9//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
10//! coordinar.
11//!
12//! ## Modelo
13//!
14//! Cada nodo tiene **dos** keypairs Ed25519:
15//!
16//! - **Identity** (master): persistente para siempre. Identifica al
17//! nodo como entidad lógica. Su `peer_id` es lo que va en
18//! allowlists/denylists remotas.
19//! - **Session** (operacional): la que libp2p usa para Noise. Puede
20//! rotarse libremente sin coordinar — el nodo emite un
21//! [`SessionCert`] firmado con la identity que prueba "esta session
22//! key pertenece a mí".
23//!
24//! ## Wire
25//!
26//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
27//! server valida:
28//! 1. La session key del cert == public key de `Hello.signature` ==
29//! deriva al peer_id autenticado por Noise (consistencia interna).
30//! 2. La firma del cert verifica con la master pubkey declarada.
31//! 3. El cert no está expirado.
32//! 4. La política (allowlist/denylist) se evalúa contra
33//! `master.to_peer_id()`, NO contra el session peer_id.
34//!
35//! Sin cert, el server cae al modelo de Fase 3: policy contra session
36//! peer_id (compat). Esto permite migración gradual.
37
38use std::sync::Arc;
39use std::time::{Duration, SystemTime, UNIX_EPOCH};
40
41use brahman_net::{Keypair, PeerId, PublicKey};
42use serde::{Deserialize, Serialize};
43
44/// TTL recomendado para un session cert: 24 horas. Suficiente para
45/// que un nodo "viva" un día sin re-emitir; corto enough para que
46/// un cert robado no sirva por mucho. Operadores con políticas
47/// estrictas pueden bajarlo; con uptime largo, subirlo.
48pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
49
50/// Identidad lógica del nodo. Wraps la master keypair y emite certs
51/// de session firmados.
52///
53/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
54/// Sólo se usa para firmar certs locales y para derivar
55/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
56/// session keypair.
57#[derive(Clone)]
58pub struct Identity {
59 master: Arc<Keypair>,
60}
61
62impl Identity {
63 /// Construye una Identity a partir de una keypair existente.
64 /// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
65 pub fn from_keypair(master: Keypair) -> Self {
66 Self {
67 master: Arc::new(master),
68 }
69 }
70
71 /// Variante para callers que ya tienen la keypair en `Arc`.
72 pub fn from_arc(master: Arc<Keypair>) -> Self {
73 Self { master }
74 }
75
76 /// PeerId derivado de la master pubkey. Ésta es la identidad
77 /// "lógica" estable del nodo — lo que va en allowlists/denylists.
78 pub fn master_peer_id(&self) -> PeerId {
79 self.master.public().to_peer_id()
80 }
81
82 /// Emite un [`SessionCert`] firmado: certifica que la session
83 /// keypair `session` pertenece a esta identity hasta `now + ttl`.
84 pub fn issue_session_cert(
85 &self,
86 session: &Keypair,
87 ttl: Duration,
88 ) -> Result<SessionCert, CertError> {
89 let now_ms = now_unix_ms();
90 let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
91 let session_pubkey = session.public().encode_protobuf();
92 let master_pubkey = self.master.public().encode_protobuf();
93 let payload = sign_payload(&session_pubkey, expires_at_ms);
94 let signature = self
95 .master
96 .sign(&payload)
97 .map_err(|e| CertError::Sign(e.to_string()))?;
98 Ok(SessionCert {
99 version: SESSION_CERT_VERSION,
100 session_pubkey,
101 master_pubkey,
102 expires_at_ms,
103 signature,
104 })
105 }
106}
107
108impl std::fmt::Debug for Identity {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("Identity")
111 .field("master_peer_id", &self.master_peer_id())
112 .finish()
113 }
114}
115
116/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
117/// el shape de `SessionCert`.
118pub const SESSION_CERT_VERSION: u8 = 1;
119
120/// Certificado firmado por la identity que vincula una session key
121/// libp2p a la identidad master del nodo, con expiración.
122///
123/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
124/// formato canónico libp2p (`encode_protobuf`) — mismo encoding que
125/// `HelloSignature.public_key`.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct SessionCert {
128 /// Versión del esquema (ver `SESSION_CERT_VERSION`).
129 pub version: u8,
130 /// Public key de la session libp2p (la que firma el Hello), en
131 /// formato libp2p protobuf.
132 pub session_pubkey: Vec<u8>,
133 /// Public key de la master identity, en formato libp2p protobuf.
134 /// El verificador deriva el `master_peer_id` desde acá.
135 pub master_pubkey: Vec<u8>,
136 /// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
137 /// cert no es válido y el nodo debe re-emitirse uno nuevo
138 /// (rotando o re-firmando la misma session).
139 pub expires_at_ms: u64,
140 /// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
141 pub signature: Vec<u8>,
142}
143
144#[derive(Debug, thiserror::Error)]
145pub enum CertError {
146 #[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
147 UnknownVersion(u8),
148 #[error("decode master_pubkey: {0}")]
149 DecodeMaster(String),
150 #[error("decode session_pubkey: {0}")]
151 DecodeSession(String),
152 #[error("firma del cert inválida")]
153 InvalidSignature,
154 #[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
155 Expired { expires: u64, now: u64 },
156 #[error("session_pubkey del cert no coincide con la del Hello.signature")]
157 SessionMismatch,
158 #[error("error al firmar: {0}")]
159 Sign(String),
160}
161
162impl SessionCert {
163 /// Verifica el cert: versión, firma criptográfica, no expiración.
164 /// Devuelve el `(master_peer_id, session_peer_id)` derivados.
165 ///
166 /// El caller debe además chequear que `session_peer_id` coincide
167 /// con el peer_id autenticado por Noise (lo verifica
168 /// [`verify_against_session`]).
169 pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
170 if self.version != SESSION_CERT_VERSION {
171 return Err(CertError::UnknownVersion(self.version));
172 }
173 let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
174 .map_err(|e| CertError::DecodeMaster(e.to_string()))?;
175 let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
176 .map_err(|e| CertError::DecodeSession(e.to_string()))?;
177 let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
178 if !master_pk.verify(&payload, &self.signature) {
179 return Err(CertError::InvalidSignature);
180 }
181 let now = now_unix_ms();
182 if now >= self.expires_at_ms {
183 return Err(CertError::Expired {
184 expires: self.expires_at_ms,
185 now,
186 });
187 }
188 Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
189 }
190
191 /// Verifica el cert Y exige que su `session_pubkey` matchee a
192 /// `expected_session_pubkey` (la que firmó el Hello). Esto
193 /// previene que un atacante reutilice un cert válido con una
194 /// session key distinta.
195 ///
196 /// Devuelve el `master_peer_id` derivado, que es el que el server
197 /// debe usar para evaluar la política de admisión.
198 pub fn verify_against_session(
199 &self,
200 expected_session_pubkey: &[u8],
201 ) -> Result<PeerId, CertError> {
202 if self.session_pubkey.as_slice() != expected_session_pubkey {
203 return Err(CertError::SessionMismatch);
204 }
205 let (master_peer, _session_peer) = self.verify()?;
206 Ok(master_peer)
207 }
208}
209
210/// Concat canónico de los campos firmados. Cualquier cambio aquí
211/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
212fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
213 let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
214 buf.push(SESSION_CERT_VERSION);
215 buf.extend_from_slice(b"sess");
216 buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
217 buf.extend_from_slice(session_pubkey);
218 buf.extend_from_slice(&expires_at_ms.to_le_bytes());
219 buf
220}
221
222fn now_unix_ms() -> u64 {
223 SystemTime::now()
224 .duration_since(UNIX_EPOCH)
225 .map(|d| d.as_millis() as u64)
226 .unwrap_or(0)
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn issue_and_verify_cert() {
235 let master = Keypair::generate_ed25519();
236 let session = Keypair::generate_ed25519();
237 let id = Identity::from_keypair(master);
238 let cert = id
239 .issue_session_cert(&session, DEFAULT_SESSION_TTL)
240 .unwrap();
241 let (master_peer, session_peer) = cert.verify().unwrap();
242 assert_eq!(master_peer, id.master_peer_id());
243 assert_eq!(session_peer, session.public().to_peer_id());
244 }
245
246 #[test]
247 fn verify_against_session_admits_matching() {
248 let master = Keypair::generate_ed25519();
249 let session = Keypair::generate_ed25519();
250 let id = Identity::from_keypair(master);
251 let cert = id
252 .issue_session_cert(&session, DEFAULT_SESSION_TTL)
253 .unwrap();
254 let session_pk = session.public().encode_protobuf();
255 let master_peer = cert.verify_against_session(&session_pk).unwrap();
256 assert_eq!(master_peer, id.master_peer_id());
257 }
258
259 #[test]
260 fn verify_against_session_rejects_mismatch() {
261 let master = Keypair::generate_ed25519();
262 let session_a = Keypair::generate_ed25519();
263 let session_b = Keypair::generate_ed25519();
264 let id = Identity::from_keypair(master);
265 let cert = id
266 .issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
267 .unwrap();
268 let other_pk = session_b.public().encode_protobuf();
269 let err = cert.verify_against_session(&other_pk).unwrap_err();
270 assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
271 }
272
273 #[test]
274 fn cert_with_zero_ttl_is_expired() {
275 let master = Keypair::generate_ed25519();
276 let session = Keypair::generate_ed25519();
277 let id = Identity::from_keypair(master);
278 let cert = id
279 .issue_session_cert(&session, Duration::from_secs(0))
280 .unwrap();
281 // Pequeña espera para asegurar que now_ms > expires_at_ms.
282 std::thread::sleep(Duration::from_millis(5));
283 let err = cert.verify().unwrap_err();
284 assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
285 }
286
287 #[test]
288 fn tampered_signature_rejected() {
289 let master = Keypair::generate_ed25519();
290 let session = Keypair::generate_ed25519();
291 let id = Identity::from_keypair(master);
292 let mut cert = id
293 .issue_session_cert(&session, DEFAULT_SESSION_TTL)
294 .unwrap();
295 if let Some(b) = cert.signature.last_mut() {
296 *b ^= 0x01;
297 }
298 let err = cert.verify().unwrap_err();
299 assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
300 }
301
302 #[test]
303 fn tampered_expires_at_rejected() {
304 // Si alguien extiende el expires_at sin re-firmar, la firma
305 // no cuadra → InvalidSignature.
306 let master = Keypair::generate_ed25519();
307 let session = Keypair::generate_ed25519();
308 let id = Identity::from_keypair(master);
309 let mut cert = id
310 .issue_session_cert(&session, DEFAULT_SESSION_TTL)
311 .unwrap();
312 cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
313 let err = cert.verify().unwrap_err();
314 assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
315 }
316
317 #[test]
318 fn unknown_version_rejected() {
319 let master = Keypair::generate_ed25519();
320 let session = Keypair::generate_ed25519();
321 let id = Identity::from_keypair(master);
322 let mut cert = id
323 .issue_session_cert(&session, DEFAULT_SESSION_TTL)
324 .unwrap();
325 cert.version = 99;
326 let err = cert.verify().unwrap_err();
327 assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
328 }
329
330 #[test]
331 fn rotated_session_with_same_master_yields_same_master_peer_id() {
332 // La propiedad fundamental: rotar la session key NO cambia el
333 // master_peer_id derivado del cert.
334 let master = Keypair::generate_ed25519();
335 let id = Identity::from_keypair(master);
336 let original_master_peer = id.master_peer_id();
337
338 let session1 = Keypair::generate_ed25519();
339 let cert1 = id
340 .issue_session_cert(&session1, DEFAULT_SESSION_TTL)
341 .unwrap();
342 let (master_from_cert1, _) = cert1.verify().unwrap();
343
344 // Rotar: nueva session keypair, mismo master.
345 let session2 = Keypair::generate_ed25519();
346 let cert2 = id
347 .issue_session_cert(&session2, DEFAULT_SESSION_TTL)
348 .unwrap();
349 let (master_from_cert2, _) = cert2.verify().unwrap();
350
351 assert_eq!(master_from_cert1, original_master_peer);
352 assert_eq!(master_from_cert2, original_master_peer);
353 assert_eq!(
354 master_from_cert1, master_from_cert2,
355 "rotar session NO debe cambiar el master_peer_id"
356 );
357 }
358}