diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 7ecd7e1e..52f5d965 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -372,7 +372,7 @@ enum CredentialSubCommand { }, /// Revoke a credential by its ID Revoke { - #[arg(help = "credential ID (public key base64)")] + #[arg(help = "credential ID (UUID)")] credential_id: String, }, /// List all active credentials @@ -1440,7 +1440,7 @@ impl CommandHandler<'_> { println!(); println!("To use this credential on a new node:"); println!( - " easytier-core --network-name --secure-mode --credential {}", + " easytier-core --network-name --secure-mode --credential {} -p ", response.credential_secret ); } diff --git a/easytier/src/peers/credential_manager.rs b/easytier/src/peers/credential_manager.rs index e251d9d9..6ed5ecad 100644 --- a/easytier/src/peers/credential_manager.rs +++ b/easytier/src/peers/credential_manager.rs @@ -14,7 +14,7 @@ use crate::proto::peer_rpc::TrustedCredentialPubkey; #[derive(Debug, Clone, Serialize, Deserialize)] struct CredentialEntry { - pubkey_bytes: Vec, + pubkey: String, groups: Vec, allow_relay: bool, allowed_proxy_cidrs: Vec, @@ -46,7 +46,8 @@ impl CredentialManager { ) -> (String, String) { let private = StaticSecret::random_from_rng(rand::rngs::OsRng); let public = PublicKey::from(&private); - let id = BASE64_STANDARD.encode(public.as_bytes()); + let id = uuid::Uuid::new_v4().to_string(); + let pubkey = BASE64_STANDARD.encode(public.as_bytes()); let secret = BASE64_STANDARD.encode(private.as_bytes()); let now = SystemTime::now() @@ -56,7 +57,7 @@ impl CredentialManager { let expiry_unix = now + ttl.as_secs() as i64; let entry = CredentialEntry { - pubkey_bytes: public.as_bytes().to_vec(), + pubkey, groups, allow_relay, allowed_proxy_cidrs, @@ -94,12 +95,13 @@ impl CredentialManager { .values() .filter(|e| e.expiry_unix > now) .map(|e| TrustedCredentialPubkey { - pubkey: e.pubkey_bytes.clone(), + pubkey: Self::decode_pubkey_b64(&e.pubkey).unwrap_or_default(), groups: e.groups.clone(), allow_relay: e.allow_relay, expiry_unix: e.expiry_unix, allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(), }) + .filter(|e| !e.pubkey.is_empty()) .collect() } @@ -109,11 +111,12 @@ impl CredentialManager { .unwrap() .as_secs() as i64; + let encoded = BASE64_STANDARD.encode(pubkey); self.credentials .lock() .unwrap() .values() - .any(|e| e.pubkey_bytes == pubkey && e.expiry_unix > now) + .any(|e| e.pubkey == encoded && e.expiry_unix > now) } pub fn list_credentials(&self) -> Vec { @@ -166,6 +169,14 @@ impl CredentialManager { } } } + + fn decode_pubkey_b64(s: &str) -> Option> { + let decoded = BASE64_STANDARD.decode(s).ok()?; + if decoded.len() != 32 { + return None; + } + Some(decoded) + } } #[cfg(test)] @@ -184,8 +195,11 @@ mod tests { assert!(!id.is_empty()); assert!(!secret.is_empty()); + assert!(uuid::Uuid::parse_str(&id).is_ok()); - let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap(); + let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); + let private = StaticSecret::from(privkey_bytes); + let pubkey_bytes = PublicKey::from(&private).as_bytes().to_vec(); assert!(mgr.is_pubkey_trusted(&pubkey_bytes)); let trusted = mgr.get_trusted_pubkeys(); @@ -201,9 +215,11 @@ mod tests { fn test_expired_credential() { let mgr = CredentialManager::new(None); // TTL of 0 seconds - immediately expired - let (id, _) = mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); + let (_, secret) = mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); - let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap(); + let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); + let private = StaticSecret::from(privkey_bytes); + let pubkey_bytes = PublicKey::from(&private).as_bytes().to_vec(); assert!(!mgr.is_pubkey_trusted(&pubkey_bytes)); assert!(mgr.get_trusted_pubkeys().is_empty()); } @@ -233,9 +249,8 @@ mod tests { let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); let private = StaticSecret::from(privkey_bytes); let derived_public = PublicKey::from(&private); - let derived_id = BASE64_STANDARD.encode(derived_public.as_bytes()); - - assert_eq!(id, derived_id); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert!(mgr.is_pubkey_trusted(derived_public.as_bytes())); } #[test] @@ -247,21 +262,35 @@ mod tests { #[test] fn test_multiple_credentials_independent() { let mgr = CredentialManager::new(None); - let (id1, _) = mgr.generate_credential( + let (id1, secret1) = mgr.generate_credential( vec!["group1".to_string()], false, vec![], Duration::from_secs(3600), ); - let (id2, _) = mgr.generate_credential( + let (_id2, secret2) = mgr.generate_credential( vec!["group2".to_string()], true, vec!["10.0.0.0/8".to_string()], Duration::from_secs(3600), ); - let pk1 = BASE64_STANDARD.decode(&id1).unwrap(); - let pk2 = BASE64_STANDARD.decode(&id2).unwrap(); + let sk1: [u8; 32] = BASE64_STANDARD + .decode(&secret1) + .unwrap() + .try_into() + .unwrap(); + let sk2: [u8; 32] = BASE64_STANDARD + .decode(&secret2) + .unwrap() + .try_into() + .unwrap(); + let pk1 = PublicKey::from(&StaticSecret::from(sk1)) + .as_bytes() + .to_vec(); + let pk2 = PublicKey::from(&StaticSecret::from(sk2)) + .as_bytes() + .to_vec(); assert!(mgr.is_pubkey_trusted(&pk1)); assert!(mgr.is_pubkey_trusted(&pk2)); @@ -284,7 +313,7 @@ mod tests { #[test] fn test_trusted_pubkeys_include_metadata() { let mgr = CredentialManager::new(None); - let (id, _) = mgr.generate_credential( + let (_, secret) = mgr.generate_credential( vec!["admin".to_string(), "ops".to_string()], true, vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()], @@ -302,7 +331,8 @@ mod tests { ); assert!(tc.expiry_unix > 0); - let pk = BASE64_STANDARD.decode(&id).unwrap(); + let sk: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap(); + let pk = PublicKey::from(&StaticSecret::from(sk)).as_bytes().to_vec(); assert_eq!(tc.pubkey, pk); } diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index db242b92..c1c48a9f 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -974,6 +974,16 @@ impl PeerManager { self.my_peer_id } + async fn close_peer(&self, peer_id: PeerId) { + if let Some(peer_map) = self.peers.upgrade() { + let _ = peer_map.close_peer(peer_id).await; + } + + if let Some(foreign_client) = self.foreign_network_client.upgrade() { + let _ = foreign_client.get_peer_map().close_peer(peer_id).await; + } + } + async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option { let peer_map = self.peers.upgrade()?; peer_map.get_peer_identity_type(peer_id) diff --git a/easytier/src/peers/peer_ospf_route.rs b/easytier/src/peers/peer_ospf_route.rs index 35d7584e..dc8a0b22 100644 --- a/easytier/src/peers/peer_ospf_route.rs +++ b/easytier/src/peers/peer_ospf_route.rs @@ -2261,6 +2261,7 @@ impl PeerRouteServiceImpl { let (untrusted, global_trusted_keys) = self.synced_route_info.verify_and_update_credential_trusts(); self.global_ctx.update_trusted_keys(global_trusted_keys); + self.disconnect_untrusted_peers(&untrusted).await; untrusted_changed = !untrusted.is_empty(); } @@ -2274,6 +2275,22 @@ impl PeerRouteServiceImpl { my_peer_info_updated || my_conn_info_updated || my_foreign_network_updated } + async fn disconnect_untrusted_peers(&self, untrusted_peers: &[PeerId]) { + if untrusted_peers.is_empty() { + return; + } + + let interface = self.interface.lock().await; + let Some(interface) = interface.as_ref() else { + return; + }; + + for peer_id in untrusted_peers { + tracing::warn!(?peer_id, "disconnecting untrusted peer"); + interface.close_peer(*peer_id).await; + } + } + fn build_sync_request( &self, session: &SyncRouteSession, @@ -2904,6 +2921,7 @@ impl RouteSessionManager { session.update_dst_session_id(from_session_id); let mut need_update_route_table = false; + let mut untrusted_peers = Vec::new(); if let Some(peer_infos) = &peer_infos { // Step 9b: credential peers can only propagate their own route info @@ -3001,9 +3019,10 @@ impl RouteSessionManager { if need_update_route_table { // Run credential verification and update route table - let (_untrusted, global_trusted_keys) = service_impl + let (untrusted, global_trusted_keys) = service_impl .synced_route_info .verify_and_update_credential_trusts(); + untrusted_peers = untrusted; // Sync trusted keys to GlobalCtx for handshake verification service_impl .global_ctx @@ -3035,6 +3054,11 @@ impl RouteSessionManager { let is_initiator = session.we_are_initiator.load(Ordering::Relaxed); let session_id = session.my_session_id.load(Ordering::Relaxed); + drop(_session_lock); + service_impl + .disconnect_untrusted_peers(&untrusted_peers) + .await; + self.sync_now("sync_route_info"); Ok(SyncRouteInfoResponse { diff --git a/easytier/src/peers/route_trait.rs b/easytier/src/peers/route_trait.rs index e223b3b2..7dc0319e 100644 --- a/easytier/src/peers/route_trait.rs +++ b/easytier/src/peers/route_trait.rs @@ -27,6 +27,7 @@ pub type ForeignNetworkRouteInfoMap = pub trait RouteInterface { async fn list_peers(&self) -> Vec; fn my_peer_id(&self) -> PeerId; + async fn close_peer(&self, _peer_id: PeerId) {} async fn get_peer_identity_type(&self, _peer_id: PeerId) -> Option { None } diff --git a/easytier/src/peers/tests.rs b/easytier/src/peers/tests.rs index 3507c0d2..f5513627 100644 --- a/easytier/src/peers/tests.rs +++ b/easytier/src/peers/tests.rs @@ -903,6 +903,104 @@ async fn credential_revocation_removes_from_routes() { .await; } +#[tokio::test] +async fn credential_expiry_disconnects_from_all_admins() { + let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + let admin_b = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await; + + connect_peer_manager(admin_a.clone(), admin_b.clone()).await; + wait_route_appear(admin_a.clone(), admin_b.clone()) + .await + .unwrap(); + + let (_cred_id, cred_secret) = admin_a + .get_global_ctx() + .get_credential_manager() + .generate_credential(vec![], false, vec![], std::time::Duration::from_secs(2)); + + admin_a + .get_global_ctx() + .issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + + let privkey_bytes: [u8; 32] = base64::engine::general_purpose::STANDARD + .decode(&cred_secret) + .unwrap() + .try_into() + .unwrap(); + let private = x25519_dalek::StaticSecret::from(privkey_bytes); + let cred_c = create_mock_peer_manager_credential("net1".to_string(), &private).await; + let cred_c_id = cred_c.my_peer_id(); + + connect_peer_manager(cred_c.clone(), admin_a.clone()).await; + + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(10), + ) + .await; + + connect_peer_manager(cred_c.clone(), admin_b.clone()).await; + + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .get_peer_map() + .list_peer_conns(cred_c_id) + .await + .is_some_and(|conns| !conns.is_empty()) + } + }, + Duration::from_secs(10), + ) + .await; + + tokio::time::sleep(Duration::from_secs(3)).await; + admin_a + .get_global_ctx() + .issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged); + + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + !admin_b + .list_routes() + .await + .iter() + .any(|r| r.peer_id == cred_c_id) + } + }, + Duration::from_secs(20), + ) + .await; + + wait_for_condition( + || { + let admin_b = admin_b.clone(); + async move { + admin_b + .get_peer_map() + .list_peer_conns(cred_c_id) + .await + .is_none_or(|conns| conns.is_empty()) + } + }, + Duration::from_secs(20), + ) + .await; +} + /// Test: admin node with credential — credential node gets group assignment. /// Verify that the credential node's groups appear in the OSPF sync data. #[tokio::test] diff --git a/easytier/src/proto/api_instance.proto b/easytier/src/proto/api_instance.proto index f79e429a..1230adb9 100644 --- a/easytier/src/proto/api_instance.proto +++ b/easytier/src/proto/api_instance.proto @@ -303,7 +303,7 @@ message GenerateCredentialRequest { } message GenerateCredentialResponse { - string credential_id = 1; // public key base64 + string credential_id = 1; // UUID string credential_secret = 2; // private key base64 } @@ -318,7 +318,7 @@ message RevokeCredentialResponse { message ListCredentialsRequest {} message CredentialInfo { - string credential_id = 1; // public key base64 + string credential_id = 1; // UUID repeated string groups = 2; bool allow_relay = 3; int64 expiry_unix = 4; diff --git a/easytier/src/tests/credential_tests.rs b/easytier/src/tests/credential_tests.rs index ee87c99e..9803816e 100644 --- a/easytier/src/tests/credential_tests.rs +++ b/easytier/src/tests/credential_tests.rs @@ -708,6 +708,18 @@ async fn credential_revocation_propagates() { ) .await; + wait_for_condition( + || async { !ping_test("ns_adm", "10.144.144.2", None).await }, + Duration::from_secs(10), + ) + .await; + + wait_for_condition( + || async { !ping_test("ns_c1", "10.144.144.1", None).await }, + Duration::from_secs(10), + ) + .await; + drop_insts(vec![admin_inst, cred_inst]).await; }