mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 10:14:35 +00:00
feat(credential): implement credential peer auth and trust propagation (#1968)
- add credential manager and RPC/CLI for generate/list/revoke - support credential-based Noise authentication and revocation handling - propagate trusted credential metadata through OSPF route sync - classify direct peers by auth level in session maintenance - normalize sender credential flag for legacy non-secure compatibility - add unit/integration tests for credential join, relay and revocation
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::proto::peer_rpc::TrustedCredentialPubkey;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CredentialEntry {
|
||||
pubkey_bytes: Vec<u8>,
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
expiry_unix: i64,
|
||||
created_at_unix: i64,
|
||||
}
|
||||
|
||||
pub struct CredentialManager {
|
||||
credentials: Mutex<HashMap<String, CredentialEntry>>,
|
||||
storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CredentialManager {
|
||||
pub fn new(storage_path: Option<PathBuf>) -> Self {
|
||||
let mgr = CredentialManager {
|
||||
credentials: Mutex::new(HashMap::new()),
|
||||
storage_path,
|
||||
};
|
||||
mgr.load_from_disk();
|
||||
mgr
|
||||
}
|
||||
|
||||
pub fn generate_credential(
|
||||
&self,
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
ttl: Duration,
|
||||
) -> (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 secret = BASE64_STANDARD.encode(private.as_bytes());
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let expiry_unix = now + ttl.as_secs() as i64;
|
||||
|
||||
let entry = CredentialEntry {
|
||||
pubkey_bytes: public.as_bytes().to_vec(),
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
expiry_unix,
|
||||
created_at_unix: now,
|
||||
};
|
||||
|
||||
self.credentials.lock().unwrap().insert(id.clone(), entry);
|
||||
self.save_to_disk();
|
||||
(id, secret)
|
||||
}
|
||||
|
||||
pub fn revoke_credential(&self, credential_id: &str) -> bool {
|
||||
let removed = self
|
||||
.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(credential_id)
|
||||
.is_some();
|
||||
if removed {
|
||||
self.save_to_disk();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn get_trusted_pubkeys(&self) -> Vec<TrustedCredentialPubkey> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.expiry_unix > now)
|
||||
.map(|e| TrustedCredentialPubkey {
|
||||
pubkey: e.pubkey_bytes.clone(),
|
||||
groups: e.groups.clone(),
|
||||
allow_relay: e.allow_relay,
|
||||
expiry_unix: e.expiry_unix,
|
||||
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|e| e.pubkey_bytes == pubkey && e.expiry_unix > now)
|
||||
}
|
||||
|
||||
pub fn list_credentials(&self) -> Vec<crate::proto::api::instance::CredentialInfo> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, e)| e.expiry_unix > now)
|
||||
.map(|(id, e)| crate::proto::api::instance::CredentialInfo {
|
||||
credential_id: id.clone(),
|
||||
groups: e.groups.clone(),
|
||||
allow_relay: e.allow_relay,
|
||||
expiry_unix: e.expiry_unix,
|
||||
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_to_disk(&self) {
|
||||
let Some(path) = &self.storage_path else {
|
||||
return;
|
||||
};
|
||||
let creds = self.credentials.lock().unwrap();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&*creds) {
|
||||
if let Err(e) = std::fs::write(path, json) {
|
||||
tracing::warn!(?e, "failed to save credentials to disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_disk(&self) {
|
||||
let Some(path) = &self.storage_path else {
|
||||
return;
|
||||
};
|
||||
let Ok(data) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
match serde_json::from_str::<HashMap<String, CredentialEntry>>(&data) {
|
||||
Ok(loaded) => {
|
||||
*self.credentials.lock().unwrap() = loaded;
|
||||
tracing::info!("loaded credentials from {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "failed to parse credentials file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_revoke() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, secret) = mgr.generate_credential(
|
||||
vec!["guest".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
assert!(!id.is_empty());
|
||||
assert!(!secret.is_empty());
|
||||
|
||||
let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert!(mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(trusted[0].groups, vec!["guest".to_string()]);
|
||||
|
||||
assert!(mgr.revoke_credential(&id));
|
||||
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_credentials() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(
|
||||
vec!["a".to_string()],
|
||||
true,
|
||||
vec!["10.0.0.0/24".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keypair_validity() {
|
||||
// Verify the generated private key can derive the same public key
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, secret) =
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revoke_nonexistent() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
assert!(!mgr.revoke_credential("nonexistent_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_credentials_independent() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id1, _) = mgr.generate_credential(
|
||||
vec!["group1".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
let (id2, _) = 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();
|
||||
|
||||
assert!(mgr.is_pubkey_trusted(&pk1));
|
||||
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||
|
||||
// Revoke first, second should still be trusted
|
||||
mgr.revoke_credential(&id1);
|
||||
assert!(!mgr.is_pubkey_trusted(&pk1));
|
||||
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(trusted[0].groups, vec!["group2".to_string()]);
|
||||
assert!(trusted[0].allow_relay);
|
||||
assert_eq!(
|
||||
trusted[0].allowed_proxy_cidrs,
|
||||
vec!["10.0.0.0/8".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_pubkeys_include_metadata() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, _) = 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()],
|
||||
Duration::from_secs(7200),
|
||||
);
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
let tc = &trusted[0];
|
||||
assert_eq!(tc.groups, vec!["admin".to_string(), "ops".to_string()]);
|
||||
assert!(tc.allow_relay);
|
||||
assert_eq!(
|
||||
tc.allowed_proxy_cidrs,
|
||||
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
|
||||
);
|
||||
assert!(tc.expiry_unix > 0);
|
||||
|
||||
let pk = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert_eq!(tc.pubkey, pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_pubkey_not_trusted() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
let random_key = [42u8; 32];
|
||||
assert!(!mgr.is_pubkey_trusted(&random_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("creds.json");
|
||||
|
||||
// Create and save
|
||||
{
|
||||
let mgr = CredentialManager::new(Some(path.clone()));
|
||||
mgr.generate_credential(
|
||||
vec!["persist_group".to_string()],
|
||||
true,
|
||||
vec!["10.0.0.0/24".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
assert_eq!(mgr.list_credentials().len(), 1);
|
||||
}
|
||||
|
||||
// Load from disk
|
||||
{
|
||||
let mgr = CredentialManager::new(Some(path));
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].groups, vec!["persist_group".to_string()]);
|
||||
assert!(list[0].allow_relay);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_credentials_filters_expired() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); // expired
|
||||
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ impl ForeignNetworkClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) {
|
||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||
tracing::warn!(peer_conn = ?peer_conn.get_conn_info(), network = ?peer_conn.get_network_identity(), "add new peer conn in foreign network client");
|
||||
self.peer_map.add_new_peer_conn(peer_conn).await
|
||||
}
|
||||
|
||||
@@ -686,7 +686,7 @@ impl ForeignNetworkManager {
|
||||
}
|
||||
}
|
||||
|
||||
entry.peer_map.add_new_peer_conn(peer_conn).await;
|
||||
entry.peer_map.add_new_peer_conn(peer_conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod graph_algo;
|
||||
|
||||
pub mod acl_filter;
|
||||
pub mod credential_manager;
|
||||
pub mod peer;
|
||||
pub mod peer_conn;
|
||||
pub mod peer_conn_ping;
|
||||
|
||||
+157
-4
@@ -17,6 +17,7 @@ use crate::{
|
||||
global_ctx::{ArcGlobalCtx, GlobalCtxEvent},
|
||||
PeerId,
|
||||
},
|
||||
proto::peer_rpc::PeerIdentityType,
|
||||
tunnel::packet_def::ZCPacket,
|
||||
};
|
||||
use crate::{
|
||||
@@ -40,6 +41,7 @@ pub struct Peer {
|
||||
shutdown_notifier: Arc<tokio::sync::Notify>,
|
||||
|
||||
default_conn_id: Arc<AtomicCell<PeerConnId>>,
|
||||
peer_identity_type: Arc<AtomicCell<Option<PeerIdentityType>>>,
|
||||
default_conn_id_clear_task: ScopedTask<()>,
|
||||
}
|
||||
|
||||
@@ -52,6 +54,8 @@ impl Peer {
|
||||
let conns: ConnMap = Arc::new(DashMap::new());
|
||||
let (close_event_sender, mut close_event_receiver) = mpsc::channel(10);
|
||||
let shutdown_notifier = Arc::new(tokio::sync::Notify::new());
|
||||
let peer_identity_type = Arc::new(AtomicCell::new(None));
|
||||
let peer_identity_type_copy = peer_identity_type.clone();
|
||||
|
||||
let conns_copy = conns.clone();
|
||||
let shutdown_notifier_copy = shutdown_notifier.clone();
|
||||
@@ -76,6 +80,9 @@ impl Peer {
|
||||
conn.get_conn_info(),
|
||||
));
|
||||
shrink_dashmap(&conns_copy, Some(4));
|
||||
if conns_copy.is_empty() {
|
||||
peer_identity_type_copy.store(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +125,25 @@ impl Peer {
|
||||
|
||||
shutdown_notifier,
|
||||
default_conn_id,
|
||||
peer_identity_type,
|
||||
default_conn_id_clear_task,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_peer_conn(&self, mut conn: PeerConn) {
|
||||
pub async fn add_peer_conn(&self, mut conn: PeerConn) -> Result<(), Error> {
|
||||
let conn_identity_type = conn.get_peer_identity_type();
|
||||
let peer_identity_type = self.peer_identity_type.load();
|
||||
if let Some(peer_identity_type) = peer_identity_type {
|
||||
if peer_identity_type != conn_identity_type {
|
||||
return Err(Error::SecretKeyError(format!(
|
||||
"peer identity type mismatch. peer: {:?}, conn: {:?}",
|
||||
peer_identity_type, conn_identity_type
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
self.peer_identity_type.store(Some(conn_identity_type));
|
||||
}
|
||||
|
||||
let close_notifier = conn.get_close_notifier();
|
||||
let conn_info = conn.get_conn_info();
|
||||
|
||||
@@ -143,6 +164,7 @@ impl Peer {
|
||||
|
||||
self.global_ctx
|
||||
.issue_event(GlobalCtxEvent::PeerConnAdded(conn_info));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_conn(&self) -> Option<ArcPeerConn> {
|
||||
@@ -221,6 +243,10 @@ impl Peer {
|
||||
pub fn get_default_conn_id(&self) -> PeerConnId {
|
||||
self.default_conn_id.load()
|
||||
}
|
||||
|
||||
pub fn get_peer_identity_type(&self) -> Option<PeerIdentityType> {
|
||||
self.peer_identity_type.load()
|
||||
}
|
||||
}
|
||||
|
||||
// pritn on drop
|
||||
@@ -238,17 +264,38 @@ impl Drop for Peer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
||||
use rand::rngs::OsRng;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::{
|
||||
common::{global_ctx::tests::get_mock_global_ctx, new_peer_id},
|
||||
common::{
|
||||
config::{NetworkIdentity, PeerConfig},
|
||||
global_ctx::{tests::get_mock_global_ctx, GlobalCtx},
|
||||
new_peer_id,
|
||||
},
|
||||
peers::{create_packet_recv_chan, peer_conn::PeerConn, peer_session::PeerSessionStore},
|
||||
proto::common::SecureModeConfig,
|
||||
tunnel::ring::create_ring_tunnel_pair,
|
||||
};
|
||||
|
||||
use super::Peer;
|
||||
|
||||
fn set_secure_mode_cfg(global_ctx: &GlobalCtx, enabled: bool) {
|
||||
if !enabled {
|
||||
global_ctx.config.set_secure_mode(None);
|
||||
} else {
|
||||
let private = x25519_dalek::StaticSecret::random_from_rng(OsRng);
|
||||
let public = x25519_dalek::PublicKey::from(&private);
|
||||
global_ctx.config.set_secure_mode(Some(SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_peer() {
|
||||
let (local_packet_send, _local_packet_recv) = create_packet_recv_chan();
|
||||
@@ -284,8 +331,8 @@ mod tests {
|
||||
|
||||
let local_conn_id = local_peer_conn.get_conn_id();
|
||||
|
||||
local_peer.add_peer_conn(local_peer_conn).await;
|
||||
remote_peer.add_peer_conn(remote_peer_conn).await;
|
||||
local_peer.add_peer_conn(local_peer_conn).await.unwrap();
|
||||
remote_peer.add_peer_conn(remote_peer_conn).await.unwrap();
|
||||
|
||||
assert_eq!(local_peer.list_peer_conns().await.len(), 1);
|
||||
assert_eq!(remote_peer.list_peer_conns().await.len(), 1);
|
||||
@@ -305,4 +352,110 @@ mod tests {
|
||||
println!("wait for close handler");
|
||||
close_handler.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_peer_conn_with_mismatched_identity_type() {
|
||||
let (packet_send, _packet_recv) = create_packet_recv_chan();
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let local_peer_id = new_peer_id();
|
||||
let remote_peer_id = new_peer_id();
|
||||
let peer = Peer::new(remote_peer_id, packet_send, global_ctx);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
|
||||
let (shared_client_tunnel, shared_server_tunnel) = create_ring_tunnel_pair();
|
||||
let shared_client_ctx = get_mock_global_ctx();
|
||||
let shared_server_ctx = get_mock_global_ctx();
|
||||
shared_client_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||
shared_server_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity {
|
||||
network_name: "net2".to_string(),
|
||||
network_secret: None,
|
||||
network_secret_digest: None,
|
||||
});
|
||||
set_secure_mode_cfg(&shared_client_ctx, true);
|
||||
set_secure_mode_cfg(&shared_server_ctx, true);
|
||||
let remote_url: url::Url = shared_client_tunnel
|
||||
.info()
|
||||
.unwrap()
|
||||
.remote_addr
|
||||
.unwrap()
|
||||
.url
|
||||
.parse()
|
||||
.unwrap();
|
||||
shared_client_ctx.config.set_peers(vec![PeerConfig {
|
||||
uri: remote_url,
|
||||
peer_public_key: Some(
|
||||
shared_server_ctx
|
||||
.config
|
||||
.get_secure_mode()
|
||||
.unwrap()
|
||||
.local_public_key
|
||||
.unwrap(),
|
||||
),
|
||||
}]);
|
||||
let mut shared_client_conn = PeerConn::new(
|
||||
local_peer_id,
|
||||
shared_client_ctx,
|
||||
Box::new(shared_client_tunnel),
|
||||
ps.clone(),
|
||||
);
|
||||
let mut shared_server_conn = PeerConn::new(
|
||||
remote_peer_id,
|
||||
shared_server_ctx,
|
||||
Box::new(shared_server_tunnel),
|
||||
ps.clone(),
|
||||
);
|
||||
let (c1, s1) = tokio::join!(
|
||||
shared_client_conn.do_handshake_as_client(),
|
||||
shared_server_conn.do_handshake_as_server()
|
||||
);
|
||||
c1.unwrap();
|
||||
s1.unwrap();
|
||||
assert_eq!(
|
||||
shared_client_conn.get_peer_identity_type(),
|
||||
crate::proto::peer_rpc::PeerIdentityType::SharedNode
|
||||
);
|
||||
|
||||
let (admin_client_tunnel, admin_server_tunnel) = create_ring_tunnel_pair();
|
||||
let admin_client_ctx = get_mock_global_ctx();
|
||||
let admin_server_ctx = get_mock_global_ctx();
|
||||
admin_client_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||
admin_server_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||
set_secure_mode_cfg(&admin_client_ctx, true);
|
||||
set_secure_mode_cfg(&admin_server_ctx, true);
|
||||
let mut admin_client_conn = PeerConn::new(
|
||||
local_peer_id,
|
||||
admin_client_ctx,
|
||||
Box::new(admin_client_tunnel),
|
||||
Arc::new(PeerSessionStore::new()),
|
||||
);
|
||||
let mut admin_server_conn = PeerConn::new(
|
||||
remote_peer_id,
|
||||
admin_server_ctx,
|
||||
Box::new(admin_server_tunnel),
|
||||
Arc::new(PeerSessionStore::new()),
|
||||
);
|
||||
let (c2, s2) = tokio::join!(
|
||||
admin_client_conn.do_handshake_as_client(),
|
||||
admin_server_conn.do_handshake_as_server()
|
||||
);
|
||||
c2.unwrap();
|
||||
s2.unwrap();
|
||||
assert_eq!(
|
||||
admin_client_conn.get_peer_identity_type(),
|
||||
crate::proto::peer_rpc::PeerIdentityType::Admin
|
||||
);
|
||||
|
||||
peer.add_peer_conn(shared_client_conn).await.unwrap();
|
||||
let ret = peer.add_peer_conn(admin_client_conn).await;
|
||||
assert!(ret.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+450
-56
@@ -43,7 +43,7 @@ use crate::{
|
||||
common::{LimiterConfig, SecureModeConfig, TunnelInfo},
|
||||
peer_rpc::{
|
||||
HandshakeRequest, PeerConnNoiseMsg1Pb, PeerConnNoiseMsg2Pb, PeerConnNoiseMsg3Pb,
|
||||
PeerConnSessionActionPb, SecureAuthLevel,
|
||||
PeerConnSessionActionPb, PeerIdentityType, SecureAuthLevel,
|
||||
},
|
||||
},
|
||||
tunnel::{
|
||||
@@ -83,6 +83,7 @@ struct NoiseHandshakeResult {
|
||||
remote_static_pubkey: Vec<u8>,
|
||||
handshake_hash: Vec<u8>,
|
||||
secure_auth_level: SecureAuthLevel,
|
||||
peer_identity_type: PeerIdentityType,
|
||||
remote_network_name: String,
|
||||
|
||||
secret_digest: Vec<u8>,
|
||||
@@ -677,6 +678,99 @@ impl PeerConn {
|
||||
Ok(self.sink.send(pkt).await?)
|
||||
}
|
||||
|
||||
/// Unified remote peer authentication verification.
|
||||
///
|
||||
/// Auth outcome matrix (current behavior):
|
||||
///
|
||||
/// | Client role | Server role | Typical credential condition | Client auth level | Server auth level | Client sees server type | Server sees client type |
|
||||
/// | --- | --- | --- | --- | --- | --- | --- |
|
||||
/// | Admin | Admin | same network_secret, proof verified | NetworkSecretConfirmed | NetworkSecretConfirmed | Admin | Admin |
|
||||
/// | Credential | Admin | client pubkey is trusted by admin | EncryptedUnauthenticated | PeerVerified | Admin | Credential |
|
||||
/// | Credential | Admin | client pubkey is unknown | handshake may fail | handshake reject | unknown | unknown |
|
||||
/// | Admin | SharedNode | pinned key match | PeerVerified | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||
/// | Admin | SharedNode | local has no pinned key requirement | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||
/// | Credential | SharedNode | no pin and not trusted | EncryptedUnauthenticated | EncryptedUnauthenticated | SharedNode | SharedNode |
|
||||
/// | Credential | Credential | both keys trusted by admin distribution | PeerVerified | PeerVerified | Credential | Credential |
|
||||
///
|
||||
/// Logic (in priority order):
|
||||
/// 1. **NetworkSecretConfirmed**: proof verification succeeds
|
||||
/// 2. **PeerVerified**: pinned_pubkey matches and is in trusted list
|
||||
/// (if no network_secret, pinned_pubkey must be in trusted list)
|
||||
/// 3. **PeerVerified**: pubkey is in trusted list
|
||||
/// 4. **EncryptedUnauthenticated**: initiator without network_secret
|
||||
/// 5. **Reject**: none of the above
|
||||
fn verify_remote_auth(
|
||||
&self,
|
||||
proof: Option<&[u8]>,
|
||||
handshake_hash: &[u8],
|
||||
remote_pubkey: &[u8],
|
||||
pinned_pubkey: Option<&[u8]>,
|
||||
has_network_secret: bool,
|
||||
is_initiator: bool,
|
||||
) -> Result<SecureAuthLevel, Error> {
|
||||
// 1. Verify proof
|
||||
if let Some(proof) = proof {
|
||||
if let Some(mac) = self.global_ctx.get_secret_proof(handshake_hash) {
|
||||
if mac.verify_slice(proof).is_ok() {
|
||||
return Ok(SecureAuthLevel::NetworkSecretConfirmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check pinned pubkey
|
||||
if let Some(pinned) = pinned_pubkey {
|
||||
if pinned != remote_pubkey {
|
||||
return Err(Error::WaitRespError(
|
||||
"pinned remote static pubkey mismatch".to_owned(),
|
||||
));
|
||||
}
|
||||
// If no network_secret, pinned key must be in trusted list
|
||||
if !has_network_secret && !self.global_ctx.is_pubkey_trusted(remote_pubkey) {
|
||||
return Err(Error::WaitRespError(
|
||||
"pinned pubkey not in trusted list".to_owned(),
|
||||
));
|
||||
}
|
||||
return Ok(SecureAuthLevel::PeerVerified);
|
||||
}
|
||||
|
||||
// 3. Check if pubkey is in trusted list
|
||||
if self.global_ctx.is_pubkey_trusted(remote_pubkey) {
|
||||
return Ok(SecureAuthLevel::PeerVerified);
|
||||
}
|
||||
|
||||
// 4. If we are the initiator without network_secret, keep encrypted channel only.
|
||||
if is_initiator && !has_network_secret {
|
||||
return Ok(SecureAuthLevel::EncryptedUnauthenticated);
|
||||
}
|
||||
|
||||
// 5. Reject
|
||||
Err(Error::WaitRespError(
|
||||
"authentication failed: invalid proof and unknown credential".to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
fn classify_remote_identity(
|
||||
&self,
|
||||
remote_network_name: &str,
|
||||
secure_auth_level: SecureAuthLevel,
|
||||
remote_role_hint_is_same_network: bool,
|
||||
remote_sent_secret_proof: bool,
|
||||
) -> PeerIdentityType {
|
||||
if !remote_role_hint_is_same_network
|
||||
|| remote_network_name != self.global_ctx.get_network_name()
|
||||
{
|
||||
return PeerIdentityType::SharedNode;
|
||||
}
|
||||
|
||||
if matches!(secure_auth_level, SecureAuthLevel::NetworkSecretConfirmed)
|
||||
|| remote_sent_secret_proof
|
||||
{
|
||||
return PeerIdentityType::Admin;
|
||||
}
|
||||
|
||||
PeerIdentityType::Credential
|
||||
}
|
||||
|
||||
async fn do_noise_handshake_as_client(&self) -> Result<NoiseHandshakeResult, Error> {
|
||||
let prologue = b"easytier-peerconn-noise".to_vec();
|
||||
|
||||
@@ -715,8 +809,6 @@ impl PeerConn {
|
||||
.local_private_key(&local_private_key)?
|
||||
.build_initiator()?;
|
||||
|
||||
let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated;
|
||||
|
||||
self.send_noise_msg(
|
||||
msg1_pb,
|
||||
PacketType::NoiseHandshakeMsg1,
|
||||
@@ -751,29 +843,12 @@ impl PeerConn {
|
||||
let action = PeerConnSessionActionPb::try_from(msg2_pb.action)
|
||||
.map_err(|_| Error::WaitRespError("invalid session action".to_owned()))?;
|
||||
let remote_network_name = msg2_pb.b_network_name.clone();
|
||||
let remote_sent_secret_proof = msg2_pb.secret_proof_32.is_some();
|
||||
|
||||
if remote_network_name == network.network_name {
|
||||
if msg2_pb.role_hint != 1 {
|
||||
return Err(Error::WaitRespError(
|
||||
"role_hint must be 1 when network_name is same".to_owned(),
|
||||
));
|
||||
}
|
||||
let Some(secret_proof_32) = msg2_pb.secret_proof_32 else {
|
||||
return Err(Error::WaitRespError(
|
||||
"secret_proof_32 must be present when role_hint is 1".to_owned(),
|
||||
));
|
||||
};
|
||||
let verify_result = self
|
||||
.global_ctx
|
||||
.get_secret_proof(&server_handshake_hash)
|
||||
.map(|mac| mac.verify_slice(&secret_proof_32).is_ok());
|
||||
if verify_result != Some(true) {
|
||||
return Err(Error::WaitRespError(format!(
|
||||
"secret_proof_32 verify failed: {verify_result:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
secure_auth_level = secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed);
|
||||
if remote_network_name == network.network_name && msg2_pb.role_hint != 1 {
|
||||
return Err(Error::WaitRespError(
|
||||
"role_hint must be 1 when network_name is same".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let handshake_hash_for_proof = hs.get_handshake_hash().to_vec();
|
||||
@@ -817,16 +892,25 @@ impl PeerConn {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(pinned) = pinned_remote_pubkey.as_ref() {
|
||||
if pinned.as_slice() == remote_static.as_slice() {
|
||||
secure_auth_level =
|
||||
secure_auth_level.max(SecureAuthLevel::SharedNodePubkeyVerified);
|
||||
} else {
|
||||
return Err(Error::WaitRespError(
|
||||
"pinned remote static pubkey mismatch".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Verify server authentication using unified logic
|
||||
let secure_auth_level = if msg2_pb.role_hint != 1 && pinned_remote_pubkey.is_none() {
|
||||
SecureAuthLevel::EncryptedUnauthenticated
|
||||
} else {
|
||||
self.verify_remote_auth(
|
||||
msg2_pb.secret_proof_32.as_deref(),
|
||||
&server_handshake_hash,
|
||||
&remote_static,
|
||||
pinned_remote_pubkey.as_deref(),
|
||||
network.network_secret.is_some(),
|
||||
true, // is_initiator
|
||||
)?
|
||||
};
|
||||
let peer_identity_type = self.classify_remote_identity(
|
||||
&remote_network_name,
|
||||
secure_auth_level,
|
||||
msg2_pb.role_hint == 1,
|
||||
remote_sent_secret_proof,
|
||||
);
|
||||
|
||||
let handshake_hash = hs.get_handshake_hash().to_vec();
|
||||
|
||||
@@ -863,6 +947,7 @@ impl PeerConn {
|
||||
remote_static_pubkey: remote_static,
|
||||
handshake_hash,
|
||||
secure_auth_level,
|
||||
peer_identity_type,
|
||||
remote_network_name,
|
||||
// we have authorized the peer with noise handshake, so just set secret digest same as us even remote is a shared node.
|
||||
secret_digest,
|
||||
@@ -1043,24 +1128,6 @@ impl PeerConn {
|
||||
));
|
||||
}
|
||||
|
||||
let mut secure_auth_level = SecureAuthLevel::EncryptedUnauthenticated;
|
||||
let Some(proof) = msg3_pb.secret_proof_32.as_ref() else {
|
||||
return Err(Error::WaitRespError(
|
||||
"noise msg3 secret_proof_32 is required".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
if role_hint == 1 {
|
||||
if let Some(mac) = self.global_ctx.get_secret_proof(&handshake_hash_for_proof) {
|
||||
if mac.verify_slice(proof).is_ok() {
|
||||
secure_auth_level =
|
||||
secure_auth_level.max(SecureAuthLevel::NetworkSecretConfirmed);
|
||||
} else {
|
||||
return Err(Error::WaitRespError("invalid secret_proof".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remote_static = hs
|
||||
.get_remote_static()
|
||||
.map(|x: &[u8]| x.to_vec())
|
||||
@@ -1074,6 +1141,30 @@ impl PeerConn {
|
||||
};
|
||||
session.check_or_set_peer_static_pubkey(remote_static_key)?;
|
||||
|
||||
// Verify client authentication using unified logic
|
||||
// Note: Server doesn't use pinned_pubkey since it's the responder
|
||||
let secure_auth_level = if role_hint == 1 {
|
||||
self.verify_remote_auth(
|
||||
msg3_pb.secret_proof_32.as_deref(),
|
||||
&handshake_hash_for_proof,
|
||||
&remote_static,
|
||||
None, // Server doesn't have pinned_remote_pubkey
|
||||
self.global_ctx
|
||||
.get_network_identity()
|
||||
.network_secret
|
||||
.is_some(),
|
||||
false, // is_initiator
|
||||
)?
|
||||
} else {
|
||||
SecureAuthLevel::EncryptedUnauthenticated
|
||||
};
|
||||
let peer_identity_type = self.classify_remote_identity(
|
||||
&remote_network_name,
|
||||
secure_auth_level,
|
||||
role_hint == 1,
|
||||
msg3_pb.secret_proof_32.is_some(),
|
||||
);
|
||||
|
||||
let handshake_hash = hs.get_handshake_hash().to_vec();
|
||||
|
||||
Ok(NoiseHandshakeResult {
|
||||
@@ -1083,11 +1174,12 @@ impl PeerConn {
|
||||
remote_static_pubkey: remote_static,
|
||||
handshake_hash,
|
||||
secure_auth_level,
|
||||
peer_identity_type,
|
||||
remote_network_name,
|
||||
secret_digest: msg3_pb.secret_digest,
|
||||
client_secret_proof: Some(SecretProof {
|
||||
client_secret_proof: msg3_pb.secret_proof_32.as_ref().map(|p| SecretProof {
|
||||
challenge: handshake_hash_for_proof,
|
||||
proof: proof.clone(),
|
||||
proof: p.clone(),
|
||||
}),
|
||||
|
||||
my_encrypt_algo: self.my_encrypt_algo.clone(),
|
||||
@@ -1392,9 +1484,21 @@ impl PeerConn {
|
||||
.as_ref()
|
||||
.map(|x| x.secure_auth_level as i32)
|
||||
.unwrap_or_default(),
|
||||
peer_identity_type: self
|
||||
.noise_handshake_result
|
||||
.as_ref()
|
||||
.map(|x| x.peer_identity_type as i32)
|
||||
.unwrap_or(PeerIdentityType::Admin as i32),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_peer_identity_type(&self) -> PeerIdentityType {
|
||||
self.noise_handshake_result
|
||||
.as_ref()
|
||||
.map(|x| x.peer_identity_type)
|
||||
.unwrap_or(PeerIdentityType::Admin)
|
||||
}
|
||||
|
||||
pub fn set_peer_id(&mut self, peer_id: PeerId) {
|
||||
if self.info.is_some() {
|
||||
panic!("set_peer_id should only be called before handshake");
|
||||
@@ -1758,6 +1862,14 @@ pub mod tests {
|
||||
s_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::Admin as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::Admin as i32,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1809,7 +1921,66 @@ pub mod tests {
|
||||
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::SharedNodePubkeyVerified as i32,
|
||||
SecureAuthLevel::PeerVerified as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::SharedNode as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::SharedNode as i32,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_conn_secure_mode_shared_node_without_pin_is_unauthenticated() {
|
||||
let (c, s) = create_ring_tunnel_pair();
|
||||
|
||||
let c_peer_id = new_peer_id();
|
||||
let s_peer_id = new_peer_id();
|
||||
|
||||
let c_ctx = get_mock_global_ctx();
|
||||
let s_ctx = get_mock_global_ctx();
|
||||
|
||||
c_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new("net1".to_string(), "sec2".to_string()));
|
||||
s_ctx.config.set_network_identity(NetworkIdentity {
|
||||
network_name: "net2".to_string(),
|
||||
network_secret: None,
|
||||
network_secret_digest: None,
|
||||
});
|
||||
|
||||
set_secure_mode_cfg(&c_ctx, true);
|
||||
set_secure_mode_cfg(&s_ctx, true);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
c_peer.do_handshake_as_client(),
|
||||
s_peer.do_handshake_as_server()
|
||||
);
|
||||
c_ret.unwrap();
|
||||
s_ret.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::SharedNode as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::SharedNode as i32,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1903,4 +2074,227 @@ pub mod tests {
|
||||
.unwrap_err();
|
||||
let _ = tokio::join!(j);
|
||||
}
|
||||
|
||||
/// Helper: set up a credential node's GlobalCtx with a specific private key
|
||||
/// (no network_secret, secure mode enabled with the given keypair)
|
||||
fn set_credential_mode_cfg(
|
||||
global_ctx: &GlobalCtx,
|
||||
network_name: &str,
|
||||
private_key: &x25519_dalek::StaticSecret,
|
||||
) {
|
||||
use crate::common::config::NetworkIdentity;
|
||||
let public = x25519_dalek::PublicKey::from(private_key);
|
||||
global_ctx
|
||||
.config
|
||||
.set_network_identity(NetworkIdentity::new_credential(network_name.to_string()));
|
||||
global_ctx.config.set_secure_mode(Some(SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Test: credential node connects to admin node, admin has credential in trusted list.
|
||||
/// Handshake should succeed with PeerVerified auth level on server side.
|
||||
#[tokio::test]
|
||||
async fn peer_conn_credential_node_connects_to_admin() {
|
||||
let (c, s) = create_ring_tunnel_pair();
|
||||
|
||||
let c_peer_id = new_peer_id();
|
||||
let s_peer_id = new_peer_id();
|
||||
|
||||
// Admin node (server) has network_secret
|
||||
let s_ctx = get_mock_global_ctx();
|
||||
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||
"net1".to_string(),
|
||||
"secret".to_string(),
|
||||
));
|
||||
set_secure_mode_cfg(&s_ctx, true);
|
||||
|
||||
// Generate a credential on admin and get the private key for the client
|
||||
let (cred_id, cred_secret) = s_ctx.get_credential_manager().generate_credential(
|
||||
vec!["guest".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
// Credential node (client) uses credential private key
|
||||
let c_ctx = get_mock_global_ctx();
|
||||
let privkey_bytes: [u8; 32] = BASE64_STANDARD
|
||||
.decode(&cred_secret)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||
set_credential_mode_cfg(&c_ctx, "net1", &private);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
c_peer.do_handshake_as_client(),
|
||||
s_peer.do_handshake_as_server()
|
||||
);
|
||||
|
||||
c_ret.unwrap();
|
||||
s_ret.unwrap();
|
||||
|
||||
// Server should see credential node as PeerVerified
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::PeerVerified as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::Credential as i32,
|
||||
);
|
||||
|
||||
// Client (credential node) keeps encrypted unauthenticated level
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::EncryptedUnauthenticated as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().peer_identity_type,
|
||||
PeerIdentityType::Admin as i32,
|
||||
);
|
||||
|
||||
// Verify credential ID matches
|
||||
let _ = cred_id; // just to use it
|
||||
}
|
||||
|
||||
/// Test: unknown credential node (not in trusted list) is rejected by admin.
|
||||
#[tokio::test]
|
||||
async fn peer_conn_unknown_credential_rejected() {
|
||||
let (c, s) = create_ring_tunnel_pair();
|
||||
|
||||
let c_peer_id = new_peer_id();
|
||||
let s_peer_id = new_peer_id();
|
||||
|
||||
// Admin node (server) with no credentials generated
|
||||
let s_ctx = get_mock_global_ctx();
|
||||
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||
"net1".to_string(),
|
||||
"secret".to_string(),
|
||||
));
|
||||
set_secure_mode_cfg(&s_ctx, true);
|
||||
|
||||
// Unknown credential node (client) with random key, not in admin's trusted list
|
||||
let c_ctx = get_mock_global_ctx();
|
||||
let random_private = x25519_dalek::StaticSecret::random_from_rng(OsRng);
|
||||
set_credential_mode_cfg(&c_ctx, "net1", &random_private);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
c_peer.do_handshake_as_client(),
|
||||
s_peer.do_handshake_as_server()
|
||||
);
|
||||
|
||||
// Server should reject the unknown credential
|
||||
assert!(s_ret.is_err(), "server should reject unknown credential");
|
||||
// Client may also fail due to connection being closed
|
||||
let _ = c_ret;
|
||||
}
|
||||
|
||||
/// Test: two admin nodes with same network_secret still get NetworkSecretConfirmed.
|
||||
/// (Regression test: credential system should not break normal admin-to-admin auth)
|
||||
#[tokio::test]
|
||||
async fn peer_conn_admin_to_admin_still_works() {
|
||||
let (c, s) = create_ring_tunnel_pair();
|
||||
|
||||
let c_peer_id = new_peer_id();
|
||||
let s_peer_id = new_peer_id();
|
||||
|
||||
let c_ctx = get_mock_global_ctx();
|
||||
let s_ctx = get_mock_global_ctx();
|
||||
|
||||
c_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||
"net1".to_string(),
|
||||
"secret".to_string(),
|
||||
));
|
||||
s_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||
"net1".to_string(),
|
||||
"secret".to_string(),
|
||||
));
|
||||
|
||||
set_secure_mode_cfg(&c_ctx, true);
|
||||
set_secure_mode_cfg(&s_ctx, true);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||
let mut s_peer = PeerConn::new(s_peer_id, s_ctx, Box::new(s), ps.clone());
|
||||
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
c_peer.do_handshake_as_client(),
|
||||
s_peer.do_handshake_as_server()
|
||||
);
|
||||
|
||||
c_ret.unwrap();
|
||||
s_ret.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
c_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
s_peer.get_conn_info().secure_auth_level,
|
||||
SecureAuthLevel::NetworkSecretConfirmed as i32,
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: revoked credential is rejected on new connection attempt.
|
||||
#[tokio::test]
|
||||
async fn peer_conn_revoked_credential_rejected() {
|
||||
// Admin generates credential, then revokes it
|
||||
let admin_ctx = get_mock_global_ctx();
|
||||
admin_ctx.config.set_network_identity(NetworkIdentity::new(
|
||||
"net1".to_string(),
|
||||
"secret".to_string(),
|
||||
));
|
||||
set_secure_mode_cfg(&admin_ctx, true);
|
||||
|
||||
let (cred_id, cred_secret) = admin_ctx.get_credential_manager().generate_credential(
|
||||
vec![],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
// Revoke the credential
|
||||
assert!(admin_ctx
|
||||
.get_credential_manager()
|
||||
.revoke_credential(&cred_id));
|
||||
|
||||
// Now try to connect with the revoked credential
|
||||
let (c, s) = create_ring_tunnel_pair();
|
||||
let c_peer_id = new_peer_id();
|
||||
let s_peer_id = new_peer_id();
|
||||
|
||||
let c_ctx = get_mock_global_ctx();
|
||||
let privkey_bytes: [u8; 32] = BASE64_STANDARD
|
||||
.decode(&cred_secret)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
|
||||
set_credential_mode_cfg(&c_ctx, "net1", &private);
|
||||
|
||||
let ps = Arc::new(PeerSessionStore::new());
|
||||
let mut c_peer = PeerConn::new(c_peer_id, c_ctx, Box::new(c), ps.clone());
|
||||
let mut s_peer = PeerConn::new(s_peer_id, admin_ctx, Box::new(s), ps.clone());
|
||||
|
||||
let (c_ret, s_ret) = tokio::join!(
|
||||
c_peer.do_handshake_as_client(),
|
||||
s_peer.do_handshake_as_server()
|
||||
);
|
||||
|
||||
// Server should reject the revoked credential
|
||||
assert!(s_ret.is_err(), "server should reject revoked credential");
|
||||
let _ = c_ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ use crate::{
|
||||
ListGlobalForeignNetworkResponse,
|
||||
},
|
||||
peer_rpc::{
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkSummary,
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||
RouteForeignNetworkSummary,
|
||||
},
|
||||
},
|
||||
tunnel::{
|
||||
@@ -374,12 +375,34 @@ impl PeerManager {
|
||||
}
|
||||
|
||||
async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||
if self.global_ctx.get_network_identity() != peer_conn.get_network_identity() {
|
||||
let my_identity = self.global_ctx.get_network_identity();
|
||||
let peer_identity = peer_conn.get_network_identity();
|
||||
|
||||
// For credential nodes, network_secret_digest is either None or all-zeros
|
||||
// (all-zeros when received over the wire via handshake).
|
||||
// In this case, only compare network_name.
|
||||
let my_digest_empty = my_identity
|
||||
.network_secret_digest
|
||||
.as_ref()
|
||||
.is_none_or(|d| d.iter().all(|b| *b == 0));
|
||||
let peer_digest_empty = peer_identity
|
||||
.network_secret_digest
|
||||
.as_ref()
|
||||
.is_none_or(|d| d.iter().all(|b| *b == 0));
|
||||
|
||||
let identity_ok = if my_digest_empty || peer_digest_empty {
|
||||
// Credential node: only check network_name
|
||||
my_identity.network_name == peer_identity.network_name
|
||||
} else {
|
||||
my_identity == peer_identity
|
||||
};
|
||||
|
||||
if !identity_ok {
|
||||
return Err(Error::SecretKeyError(
|
||||
"network identity not match".to_string(),
|
||||
));
|
||||
}
|
||||
self.peers.add_new_peer_conn(peer_conn).await;
|
||||
self.peers.add_new_peer_conn(peer_conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -414,7 +437,7 @@ impl PeerManager {
|
||||
{
|
||||
self.add_new_peer_conn(peer).await?;
|
||||
} else {
|
||||
self.foreign_network_client.add_new_peer_conn(peer).await;
|
||||
self.foreign_network_client.add_new_peer_conn(peer).await?;
|
||||
}
|
||||
Ok((peer_id, conn_id))
|
||||
}
|
||||
@@ -674,6 +697,12 @@ impl PeerManager {
|
||||
let secure_mode_enabled = self.is_secure_mode_enabled;
|
||||
let stats_mgr = self.global_ctx.stats_manager().clone();
|
||||
let route = self.get_route();
|
||||
let is_credential_node = self
|
||||
.global_ctx
|
||||
.get_network_identity()
|
||||
.network_secret
|
||||
.is_none()
|
||||
&& secure_mode_enabled;
|
||||
|
||||
let label_set =
|
||||
LabelSet::new().with_label_type(LabelType::NetworkName(global_ctx.get_network_name()));
|
||||
@@ -721,6 +750,17 @@ impl PeerManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 10b: credential nodes don't forward handshake packets
|
||||
if is_credential_node
|
||||
&& (hdr.packet_type == PacketType::HandShake as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg1 as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg2 as u8
|
||||
|| hdr.packet_type == PacketType::NoiseHandshakeMsg3 as u8)
|
||||
{
|
||||
tracing::debug!("credential node dropping forwarded handshake packet");
|
||||
continue;
|
||||
}
|
||||
|
||||
if hdr.forward_counter > 2 && hdr.is_latency_first() {
|
||||
tracing::trace!(?hdr, "set_latency_first false because too many hop");
|
||||
hdr.set_latency_first(false);
|
||||
@@ -934,6 +974,11 @@ impl PeerManager {
|
||||
self.my_peer_id
|
||||
}
|
||||
|
||||
async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||
let peer_map = self.peers.upgrade()?;
|
||||
peer_map.get_peer_identity_type(peer_id)
|
||||
}
|
||||
|
||||
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
||||
let ret = DashMap::new();
|
||||
let Some(foreign_mgr) = self.foreign_network_manager.upgrade() else {
|
||||
@@ -1965,7 +2010,7 @@ mod tests {
|
||||
return false;
|
||||
};
|
||||
conns.iter().any(|c| {
|
||||
c.secure_auth_level == SecureAuthLevel::SharedNodePubkeyVerified as i32
|
||||
c.secure_auth_level == SecureAuthLevel::PeerVerified as i32
|
||||
&& c.noise_local_static_pubkey.len() == 32
|
||||
&& c.noise_remote_static_pubkey.len() == 32
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
},
|
||||
proto::{
|
||||
api::instance::{self, PeerConnInfo},
|
||||
peer_rpc::RoutePeerInfo,
|
||||
peer_rpc::{PeerIdentityType, RoutePeerInfo},
|
||||
},
|
||||
tunnel::{packet_def::ZCPacket, TunnelError},
|
||||
};
|
||||
@@ -56,18 +56,19 @@ impl PeerMap {
|
||||
.issue_event(GlobalCtxEvent::PeerAdded(peer_id));
|
||||
}
|
||||
|
||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) {
|
||||
pub async fn add_new_peer_conn(&self, peer_conn: PeerConn) -> Result<(), Error> {
|
||||
let _ = self.maintain_alive_client_urls(&peer_conn);
|
||||
let peer_id = peer_conn.get_peer_id();
|
||||
let no_entry = self.peer_map.get(&peer_id).is_none();
|
||||
if no_entry {
|
||||
let new_peer = Peer::new(peer_id, self.packet_send.clone(), self.global_ctx.clone());
|
||||
new_peer.add_peer_conn(peer_conn).await;
|
||||
new_peer.add_peer_conn(peer_conn).await?;
|
||||
self.add_new_peer(new_peer).await;
|
||||
} else {
|
||||
let peer = self.peer_map.get(&peer_id).unwrap().clone();
|
||||
peer.add_peer_conn(peer_conn).await;
|
||||
peer.add_peer_conn(peer_conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maintain_alive_client_urls(&self, peer_conn: &PeerConn) -> Option<()> {
|
||||
@@ -302,6 +303,11 @@ impl PeerMap {
|
||||
.map(|p| p.get_default_conn_id())
|
||||
}
|
||||
|
||||
pub fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||
self.get_peer_by_id(peer_id)
|
||||
.and_then(|p| p.get_peer_identity_type())
|
||||
}
|
||||
|
||||
pub async fn close_peer_conn(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
|
||||
fmt::Debug,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||||
sync::{
|
||||
@@ -43,9 +43,10 @@ use crate::{
|
||||
route_foreign_network_infos, route_foreign_network_summary,
|
||||
sync_route_info_request::ConnInfo, ForeignNetworkRouteInfoEntry,
|
||||
ForeignNetworkRouteInfoKey, OspfRouteRpc, OspfRouteRpcClientFactory,
|
||||
OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, RouteForeignNetworkInfos,
|
||||
RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoError,
|
||||
SyncRouteInfoRequest, SyncRouteInfoResponse,
|
||||
OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion, PeerIdentityType,
|
||||
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo, RoutePeerInfos,
|
||||
SyncRouteInfoError, SyncRouteInfoRequest, SyncRouteInfoResponse,
|
||||
TrustedCredentialPubkey,
|
||||
},
|
||||
rpc_types::{
|
||||
self,
|
||||
@@ -80,6 +81,26 @@ static REMOVE_UNREACHABLE_PEER_INFO_AFTER: Duration = Duration::from_secs(90);
|
||||
|
||||
type Version = u32;
|
||||
|
||||
/// Check if `child` CIDR is a subset of `parent` CIDR (both as string representations).
|
||||
/// Returns true if child is contained within parent, or if they are equal.
|
||||
fn cidr_is_subset_str(child: &str, parent: &str) -> bool {
|
||||
let Ok(child_cidr) = child.parse::<IpCidr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(parent_cidr) = parent.parse::<IpCidr>() else {
|
||||
return false;
|
||||
};
|
||||
match (child_cidr, parent_cidr) {
|
||||
(IpCidr::V4(c), IpCidr::V4(p)) => {
|
||||
p.first_address() <= c.first_address() && c.last_address() <= p.last_address()
|
||||
}
|
||||
(IpCidr::V6(c), IpCidr::V6(p)) => {
|
||||
p.first_address() <= c.first_address() && c.last_address() <= p.last_address()
|
||||
}
|
||||
_ => false, // mixed v4/v6
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AtomicVersion(Arc<AtomicU32>);
|
||||
|
||||
@@ -147,6 +168,7 @@ impl RoutePeerInfo {
|
||||
|
||||
quic_port: None,
|
||||
noise_static_pubkey: Vec::new(),
|
||||
trusted_credential_pubkeys: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +228,17 @@ impl RoutePeerInfo {
|
||||
|
||||
noise_static_pubkey,
|
||||
|
||||
// Only admin nodes (holding network_secret) publish trusted credential pubkeys
|
||||
trusted_credential_pubkeys: if global_ctx
|
||||
.get_network_identity()
|
||||
.network_secret
|
||||
.is_some()
|
||||
{
|
||||
global_ctx.get_credential_manager().get_trusted_pubkeys()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -336,6 +369,10 @@ struct SyncedRouteInfo {
|
||||
group_trust_map: DashMap<PeerId, HashMap<String, Vec<u8>>>,
|
||||
group_trust_map_cache: DashMap<PeerId, Arc<Vec<String>>>, // cache for group trust map, should sync with group_trust_map
|
||||
|
||||
// Aggregated trusted credential pubkeys from all admin nodes
|
||||
// Maps pubkey bytes -> TrustedCredentialPubkey
|
||||
trusted_credential_pubkeys: DashMap<Vec<u8>, TrustedCredentialPubkey>,
|
||||
|
||||
version: AtomicVersion,
|
||||
}
|
||||
|
||||
@@ -352,6 +389,19 @@ impl Debug for SyncedRouteInfo {
|
||||
}
|
||||
|
||||
impl SyncedRouteInfo {
|
||||
fn mark_credential_peer(info: &mut RoutePeerInfo, is_credential_peer: bool) {
|
||||
let mut feature_flag = info.feature_flag.unwrap_or_default();
|
||||
feature_flag.is_credential_peer = is_credential_peer;
|
||||
info.feature_flag = Some(feature_flag);
|
||||
}
|
||||
|
||||
fn is_credential_peer_info(info: &RoutePeerInfo) -> bool {
|
||||
info.feature_flag
|
||||
.as_ref()
|
||||
.map(|x| x.is_credential_peer)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_connected_peers<T: FromIterator<PeerId>>(&self, peer_id: PeerId) -> Option<T> {
|
||||
self.conn_map
|
||||
.read()
|
||||
@@ -830,6 +880,160 @@ impl SyncedRouteInfo {
|
||||
self.group_trust_map_cache
|
||||
.insert(my_peer_id, Arc::new(my_group_names));
|
||||
}
|
||||
|
||||
/// Collect trusted credential pubkeys from admin nodes (network_secret holders)
|
||||
/// and verify credential peers. Returns set of peer_ids that should be removed.
|
||||
/// Also returns a HashMap of trusted keys for synchronization to GlobalCtx.
|
||||
fn verify_and_update_credential_trusts(
|
||||
&self,
|
||||
) -> (
|
||||
Vec<PeerId>,
|
||||
HashMap<Vec<u8>, crate::common::global_ctx::TrustedKeyMetadata>,
|
||||
) {
|
||||
use crate::common::global_ctx::{TrustedKeyMetadata, TrustedKeySource};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
// Step 1: Collect trusted credential pubkeys from admin nodes (take union)
|
||||
// Only trust nodes whose secret_digest matches ours (i.e. they hold network_secret)
|
||||
let mut all_trusted: HashMap<Vec<u8>, TrustedCredentialPubkey> = HashMap::new();
|
||||
// Also collect all peer pubkeys for GlobalCtx synchronization
|
||||
let mut global_trusted_keys: HashMap<Vec<u8>, TrustedKeyMetadata> = HashMap::new();
|
||||
|
||||
let peer_infos = self.peer_infos.read();
|
||||
|
||||
for (_, info) in peer_infos.iter() {
|
||||
if !self.is_admin_peer(info) {
|
||||
continue;
|
||||
}
|
||||
// Collect all peer noise_static_pubkeys as trusted keys
|
||||
if !info.noise_static_pubkey.is_empty() {
|
||||
global_trusted_keys.insert(
|
||||
info.noise_static_pubkey.clone(),
|
||||
TrustedKeyMetadata {
|
||||
source: TrustedKeySource::OspfNode,
|
||||
expiry_unix: None, // Peer pubkeys never expire
|
||||
},
|
||||
);
|
||||
}
|
||||
for tc in &info.trusted_credential_pubkeys {
|
||||
if tc.expiry_unix > now {
|
||||
all_trusted
|
||||
.entry(tc.pubkey.clone())
|
||||
.or_insert_with(|| tc.clone());
|
||||
// Also add to global trusted keys
|
||||
global_trusted_keys.insert(
|
||||
tc.pubkey.clone(),
|
||||
TrustedKeyMetadata {
|
||||
source: TrustedKeySource::OspfCredential,
|
||||
expiry_unix: Some(tc.expiry_unix),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the previous trusted set to detect revoked credentials
|
||||
let prev_trusted: HashSet<Vec<u8>> = self
|
||||
.trusted_credential_pubkeys
|
||||
.iter()
|
||||
.map(|r| r.key().clone())
|
||||
.collect();
|
||||
|
||||
// Update the trusted_credential_pubkeys map
|
||||
self.trusted_credential_pubkeys.clear();
|
||||
for (k, v) in &all_trusted {
|
||||
self.trusted_credential_pubkeys.insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
// Step 2: Update group trust map for credential peers
|
||||
// Credential peers get their groups from the TrustedCredentialPubkey declaration
|
||||
for (_, info) in peer_infos.iter() {
|
||||
if info.noise_static_pubkey.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(tc) = all_trusted.get(&info.noise_static_pubkey) {
|
||||
// This peer is a credential peer, assign groups from credential declaration
|
||||
if !tc.groups.is_empty() {
|
||||
let mut group_map = HashMap::new();
|
||||
let mut group_names = Vec::new();
|
||||
for g in &tc.groups {
|
||||
group_map.insert(g.clone(), Vec::new()); // no proof needed, admin-declared
|
||||
group_names.push(g.clone());
|
||||
}
|
||||
self.group_trust_map.insert(info.peer_id, group_map);
|
||||
self.group_trust_map_cache
|
||||
.insert(info.peer_id, Arc::new(group_names));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Find and remove peers with revoked/expired credentials.
|
||||
// A peer is untrusted if:
|
||||
// - Its noise_static_pubkey was in the PREVIOUS trusted set (it was a credential peer)
|
||||
// - Its noise_static_pubkey is NOT in the CURRENT trusted set (credential revoked/expired)
|
||||
let mut untrusted_peers = Vec::new();
|
||||
for (peer_id, info) in peer_infos.iter() {
|
||||
if info.noise_static_pubkey.is_empty() || info.version == 0 {
|
||||
continue;
|
||||
}
|
||||
// Only remove peers whose pubkey was previously trusted but no longer is
|
||||
if prev_trusted.contains(&info.noise_static_pubkey)
|
||||
&& !all_trusted.contains_key(&info.noise_static_pubkey)
|
||||
{
|
||||
untrusted_peers.push(*peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove untrusted peers from peer_infos so they won't appear in route graph
|
||||
if !untrusted_peers.is_empty() {
|
||||
drop(peer_infos); // release read lock before writing
|
||||
let mut peer_infos_write = self.peer_infos.write();
|
||||
for peer_id in &untrusted_peers {
|
||||
tracing::warn!(?peer_id, "removing untrusted peer from route info");
|
||||
peer_infos_write.remove(peer_id);
|
||||
self.raw_peer_infos.remove(peer_id);
|
||||
}
|
||||
drop(peer_infos_write);
|
||||
// Also remove from conn_map
|
||||
let mut conn_map = self.conn_map.write();
|
||||
for peer_id in &untrusted_peers {
|
||||
conn_map.remove(peer_id);
|
||||
}
|
||||
self.version.inc();
|
||||
}
|
||||
|
||||
(untrusted_peers, global_trusted_keys)
|
||||
}
|
||||
|
||||
fn is_admin_peer(&self, info: &RoutePeerInfo) -> bool {
|
||||
if info.version == 0 {
|
||||
return false;
|
||||
}
|
||||
!Self::is_credential_peer_info(info)
|
||||
}
|
||||
|
||||
fn is_credential_peer(&self, peer_id: PeerId) -> bool {
|
||||
let peer_infos = self.peer_infos.read();
|
||||
peer_infos
|
||||
.get(&peer_id)
|
||||
.map(Self::is_credential_peer_info)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_credential_info(&self, peer_id: PeerId) -> Option<TrustedCredentialPubkey> {
|
||||
let peer_infos = self.peer_infos.read();
|
||||
let info = peer_infos.get(&peer_id)?;
|
||||
if info.noise_static_pubkey.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.trusted_credential_pubkeys
|
||||
.get(&info.noise_static_pubkey)
|
||||
.map(|r| r.value().clone())
|
||||
}
|
||||
}
|
||||
|
||||
type PeerGraph = Graph<PeerId, usize, Directed>;
|
||||
@@ -977,6 +1181,14 @@ impl RouteTable {
|
||||
start_node: &NodeIndex,
|
||||
version: Version,
|
||||
) {
|
||||
if graph.node_weight(*start_node).is_none() {
|
||||
tracing::warn!(
|
||||
?start_node,
|
||||
version,
|
||||
"invalid start node for least-hop route rebuild"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let normalize_edge_cost = |e: petgraph::graph::EdgeReference<usize>| {
|
||||
if *e.weight() >= AVOID_RELAY_COST {
|
||||
AVOID_RELAY_COST + 1
|
||||
@@ -1020,6 +1232,14 @@ impl RouteTable {
|
||||
start_node: &NodeIndex,
|
||||
version: Version,
|
||||
) {
|
||||
if graph.node_weight(*start_node).is_none() {
|
||||
tracing::warn!(
|
||||
?start_node,
|
||||
version,
|
||||
"invalid start node for least-cost route rebuild"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (costs, next_hops) = dijkstra_with_first_hop(&graph, *start_node, |e| *e.weight());
|
||||
|
||||
for (dst, (next_hop, path_len)) in next_hops.iter() {
|
||||
@@ -1058,6 +1278,18 @@ impl RouteTable {
|
||||
|
||||
if graph.node_count() == 0 {
|
||||
tracing::warn!("no peer in graph, cannot build next hop map");
|
||||
self.next_hop_map_version.set_if_larger(version);
|
||||
self.clean_expired_route_info();
|
||||
return;
|
||||
}
|
||||
if start_node == NodeIndex::end() {
|
||||
tracing::warn!(
|
||||
?my_peer_id,
|
||||
version,
|
||||
"my peer id is missing in graph, skip next-hop rebuild this round"
|
||||
);
|
||||
self.next_hop_map_version.set_if_larger(version);
|
||||
self.clean_expired_route_info();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1596,6 +1828,7 @@ impl PeerRouteServiceImpl {
|
||||
foreign_network: DashMap::new(),
|
||||
group_trust_map: DashMap::new(),
|
||||
group_trust_map_cache: DashMap::new(),
|
||||
trusted_credential_pubkeys: DashMap::new(),
|
||||
version: AtomicVersion::new(),
|
||||
},
|
||||
cached_local_conn_map: std::sync::Mutex::new(RouteConnBitmap::default()),
|
||||
@@ -1607,6 +1840,24 @@ impl PeerRouteServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_my_secret_digest(&self) -> Option<Vec<u8>> {
|
||||
let ni = self.global_ctx.get_network_identity();
|
||||
ni.network_secret_digest.map(|d| d.to_vec())
|
||||
}
|
||||
|
||||
fn is_credential_node(&self) -> bool {
|
||||
self.global_ctx
|
||||
.get_network_identity()
|
||||
.network_secret
|
||||
.is_none()
|
||||
&& self
|
||||
.global_ctx
|
||||
.config
|
||||
.get_secure_mode()
|
||||
.map(|c| c.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn get_or_create_session(&self, dst_peer_id: PeerId) -> Arc<SyncRouteSession> {
|
||||
self.sessions
|
||||
.entry(dst_peer_id)
|
||||
@@ -1640,29 +1891,31 @@ impl PeerRouteServiceImpl {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_peer_identity_type_from_interface(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
) -> Option<PeerIdentityType> {
|
||||
self.interface
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_peer_identity_type(peer_id)
|
||||
.await
|
||||
}
|
||||
|
||||
fn update_my_peer_info(&self) -> bool {
|
||||
if self.synced_route_info.update_my_peer_info(
|
||||
self.synced_route_info.update_my_peer_info(
|
||||
self.my_peer_id,
|
||||
self.my_peer_route_id,
|
||||
&self.global_ctx,
|
||||
) {
|
||||
self.update_route_table_and_cached_local_conn_bitmap();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
async fn update_my_conn_info(&self) -> bool {
|
||||
let connected_peers: BTreeSet<PeerId> = self.list_peers_from_interface().await;
|
||||
let updated = self
|
||||
.synced_route_info
|
||||
.update_my_conn_info(self.my_peer_id, connected_peers);
|
||||
|
||||
if updated {
|
||||
self.update_route_table_and_cached_local_conn_bitmap();
|
||||
}
|
||||
|
||||
updated
|
||||
self.synced_route_info
|
||||
.update_my_conn_info(self.my_peer_id, connected_peers)
|
||||
}
|
||||
|
||||
async fn update_my_foreign_network(&self) -> bool {
|
||||
@@ -1921,15 +2174,6 @@ impl PeerRouteServiceImpl {
|
||||
// stop iter if last_update of conn info is older than session.last_sync_succ_timestamp
|
||||
let last_update = TryInto::<SystemTime>::try_into(conn_info.last_update).unwrap();
|
||||
if last_sync_succ_timestamp.is_some_and(|t| last_update < t) {
|
||||
tracing::debug!(
|
||||
"ignore conn info {:?} because last_update: {:?} is older than last_sync_succ_timestamp: {:?}, conn_map count: {}, my_peer_id: {:?}, session: {:?}",
|
||||
conn_info,
|
||||
last_update,
|
||||
last_sync_succ_timestamp,
|
||||
conn_map.len(),
|
||||
self.my_peer_id,
|
||||
session
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2012,7 +2256,16 @@ impl PeerRouteServiceImpl {
|
||||
let my_peer_info_updated = self.update_my_peer_info();
|
||||
let my_conn_info_updated = self.update_my_conn_info().await;
|
||||
let my_foreign_network_updated = self.update_my_foreign_network().await;
|
||||
if my_conn_info_updated || my_peer_info_updated {
|
||||
let mut untrusted_changed = false;
|
||||
if my_peer_info_updated {
|
||||
let (untrusted, global_trusted_keys) =
|
||||
self.synced_route_info.verify_and_update_credential_trusts();
|
||||
self.global_ctx.update_trusted_keys(global_trusted_keys);
|
||||
untrusted_changed = !untrusted.is_empty();
|
||||
}
|
||||
|
||||
if my_peer_info_updated || my_conn_info_updated || untrusted_changed {
|
||||
self.update_route_table_and_cached_local_conn_bitmap();
|
||||
self.update_foreign_network_owner_map();
|
||||
}
|
||||
if my_peer_info_updated {
|
||||
@@ -2168,7 +2421,7 @@ impl PeerRouteServiceImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, pper_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}",
|
||||
tracing::debug!(?foreign_network, "sync_route request need send to peer. my_id {:?}, dst_peer_id: {:?}, peer_infos: {:?}, conn_info: {:?}, synced_route_info: {:?} session: {:?}",
|
||||
my_peer_id, dst_peer_id, peer_infos, conn_info, self.synced_route_info, session);
|
||||
|
||||
session
|
||||
@@ -2504,16 +2757,28 @@ impl RouteSessionManager {
|
||||
}
|
||||
|
||||
// find peer_ids that are not initiators.
|
||||
let initiator_candidates = peers
|
||||
.iter()
|
||||
.filter(|x| {
|
||||
let Some(session) = service_impl.get_session(**x) else {
|
||||
return true;
|
||||
};
|
||||
!session.dst_is_initiator.load(Ordering::Relaxed)
|
||||
})
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let mut initiator_candidates = Vec::new();
|
||||
for peer_id in peers.iter().copied() {
|
||||
// Step 9a: Filter OSPF session candidates based on direct auth level.
|
||||
// - Credential nodes only initiate sessions to admin nodes (not other credential nodes)
|
||||
// - Admin nodes don't initiate sessions to credential nodes
|
||||
let identity_type = service_impl
|
||||
.get_peer_identity_type_from_interface(peer_id)
|
||||
.await
|
||||
.unwrap_or(PeerIdentityType::Admin);
|
||||
if matches!(identity_type, PeerIdentityType::Credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(session) = service_impl.get_session(peer_id) else {
|
||||
initiator_candidates.push(peer_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !session.dst_is_initiator.load(Ordering::Relaxed) {
|
||||
initiator_candidates.push(peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
if initiator_candidates.is_empty() {
|
||||
next_sleep_ms = 1000;
|
||||
@@ -2626,6 +2891,12 @@ impl RouteSessionManager {
|
||||
let my_peer_id = service_impl.my_peer_id;
|
||||
let session = self.get_or_start_session(from_peer_id)?;
|
||||
|
||||
let from_identity_type = service_impl
|
||||
.get_peer_identity_type_from_interface(from_peer_id)
|
||||
.await
|
||||
.unwrap_or(PeerIdentityType::Admin);
|
||||
let from_is_credential = matches!(from_identity_type, PeerIdentityType::Credential);
|
||||
|
||||
let _session_lock = session.lock.lock();
|
||||
|
||||
session.rpc_rx_count.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -2635,38 +2906,119 @@ impl RouteSessionManager {
|
||||
let mut need_update_route_table = false;
|
||||
|
||||
if let Some(peer_infos) = &peer_infos {
|
||||
// Step 9b: credential peers can only propagate their own route info
|
||||
let normalize_raw = |info: &RoutePeerInfo| {
|
||||
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||
raw.transcode_from(info).unwrap();
|
||||
raw
|
||||
};
|
||||
let normalized_peer_infos: Vec<RoutePeerInfo>;
|
||||
let normalized_raw_peer_infos: Vec<DynamicMessage>;
|
||||
let (pi, rpi) = if from_is_credential {
|
||||
let allowed_cidrs = service_impl
|
||||
.synced_route_info
|
||||
.get_credential_info(from_peer_id)
|
||||
.map(|tc| tc.allowed_proxy_cidrs.clone())
|
||||
.unwrap_or_default();
|
||||
normalized_peer_infos = peer_infos
|
||||
.iter()
|
||||
.filter(|info| info.peer_id == from_peer_id)
|
||||
.cloned()
|
||||
.map(|mut info| {
|
||||
// Filter proxy_cidrs to only those allowed by credential
|
||||
if !allowed_cidrs.is_empty() {
|
||||
info.proxy_cidrs.retain(|cidr| {
|
||||
allowed_cidrs
|
||||
.iter()
|
||||
.any(|allowed| cidr_is_subset_str(cidr, allowed))
|
||||
});
|
||||
} else {
|
||||
// No allowed_proxy_cidrs → no proxy_cidrs allowed
|
||||
info.proxy_cidrs.clear();
|
||||
}
|
||||
SyncedRouteInfo::mark_credential_peer(&mut info, true);
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
normalized_raw_peer_infos =
|
||||
normalized_peer_infos.iter().map(normalize_raw).collect();
|
||||
(&normalized_peer_infos, &normalized_raw_peer_infos)
|
||||
} else {
|
||||
let mut peer_infos_mut = peer_infos.clone();
|
||||
let mut raw_peer_infos_mut = raw_peer_infos
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| peer_infos_mut.iter().map(normalize_raw).collect());
|
||||
if let Some((idx, info)) = peer_infos_mut
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, info)| info.peer_id == from_peer_id)
|
||||
{
|
||||
let mut info = info.clone();
|
||||
SyncedRouteInfo::mark_credential_peer(&mut info, false);
|
||||
peer_infos_mut[idx] = info.clone();
|
||||
raw_peer_infos_mut[idx] = normalize_raw(&info);
|
||||
}
|
||||
normalized_peer_infos = peer_infos_mut;
|
||||
normalized_raw_peer_infos = raw_peer_infos_mut;
|
||||
(&normalized_peer_infos, &normalized_raw_peer_infos)
|
||||
};
|
||||
|
||||
service_impl.synced_route_info.update_peer_infos(
|
||||
my_peer_id,
|
||||
service_impl.my_peer_route_id,
|
||||
from_peer_id,
|
||||
peer_infos,
|
||||
raw_peer_infos.as_ref().unwrap(),
|
||||
pi,
|
||||
rpi,
|
||||
)?;
|
||||
service_impl
|
||||
.synced_route_info
|
||||
.verify_and_update_group_trusts(
|
||||
peer_infos,
|
||||
pi,
|
||||
&service_impl.global_ctx.get_acl_group_declarations(),
|
||||
);
|
||||
session.update_dst_saved_peer_info_version(peer_infos, from_peer_id);
|
||||
session.update_dst_saved_peer_info_version(pi, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
}
|
||||
|
||||
// Step 9b: credential peers' conn_info depends on allow_relay flag
|
||||
if let Some(conn_info) = &conn_info {
|
||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
let accept_conn_info = if from_is_credential {
|
||||
service_impl
|
||||
.synced_route_info
|
||||
.get_credential_info(from_peer_id)
|
||||
.map(|tc| tc.allow_relay)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if accept_conn_info {
|
||||
service_impl.synced_route_info.update_conn_info(conn_info);
|
||||
session.update_dst_saved_conn_info_version(conn_info, from_peer_id);
|
||||
need_update_route_table = true;
|
||||
}
|
||||
}
|
||||
|
||||
if need_update_route_table {
|
||||
// Run credential verification and update route table
|
||||
let (_untrusted, global_trusted_keys) = service_impl
|
||||
.synced_route_info
|
||||
.verify_and_update_credential_trusts();
|
||||
// Sync trusted keys to GlobalCtx for handshake verification
|
||||
service_impl
|
||||
.global_ctx
|
||||
.update_trusted_keys(global_trusted_keys);
|
||||
service_impl.update_route_table_and_cached_local_conn_bitmap();
|
||||
}
|
||||
|
||||
if let Some(foreign_network) = &foreign_network {
|
||||
service_impl
|
||||
.synced_route_info
|
||||
.update_foreign_network(foreign_network);
|
||||
session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id);
|
||||
// Step 9b: credential peers' foreign_network_infos are always ignored
|
||||
if !from_is_credential {
|
||||
service_impl
|
||||
.synced_route_info
|
||||
.update_foreign_network(foreign_network);
|
||||
session.update_dst_saved_foreign_network_version(foreign_network, from_peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
if need_update_route_table || foreign_network.is_some() {
|
||||
@@ -3041,12 +3393,15 @@ mod tests {
|
||||
create_packet_recv_chan,
|
||||
peer_manager::{PeerManager, RouteAlgoType},
|
||||
peer_ospf_route::{PeerIdVersion, PeerRouteServiceImpl, FORCE_USE_CONN_LIST},
|
||||
route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface},
|
||||
route_trait::{NextHopPolicy, Route, RouteCostCalculatorInterface, RouteInterface},
|
||||
tests::{connect_peer_manager, create_mock_peer_manager, wait_route_appear},
|
||||
},
|
||||
proto::{
|
||||
common::NatType,
|
||||
peer_rpc::{RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest},
|
||||
common::{NatType, PeerFeatureFlag},
|
||||
peer_rpc::{
|
||||
PeerIdentityType, RoutePeerInfo, RoutePeerInfos, SyncRouteInfoRequest,
|
||||
TrustedCredentialPubkey,
|
||||
},
|
||||
},
|
||||
tunnel::common::tests::wait_for_condition,
|
||||
};
|
||||
@@ -3054,6 +3409,26 @@ mod tests {
|
||||
|
||||
use super::PeerRoute;
|
||||
|
||||
struct AuthOnlyInterface {
|
||||
my_peer_id: PeerId,
|
||||
identity_type: DashMap<PeerId, PeerIdentityType>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RouteInterface for AuthOnlyInterface {
|
||||
async fn list_peers(&self) -> Vec<PeerId> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn my_peer_id(&self) -> PeerId {
|
||||
self.my_peer_id
|
||||
}
|
||||
|
||||
async fn get_peer_identity_type(&self, peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||
self.identity_type.get(&peer_id).map(|x| *x.value())
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_mock_route(peer_mgr: Arc<PeerManager>) -> Arc<PeerRoute> {
|
||||
let peer_route = PeerRoute::new(
|
||||
peer_mgr.my_peer_id(),
|
||||
@@ -3098,6 +3473,213 @@ mod tests {
|
||||
assert!(rx1 <= max_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_flag_controls_role_classification() {
|
||||
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
|
||||
|
||||
let mut admin_info = RoutePeerInfo::new();
|
||||
admin_info.peer_id = 10;
|
||||
admin_info.version = 1;
|
||||
admin_info.feature_flag = Some(PeerFeatureFlag {
|
||||
is_credential_peer: false,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut credential_info = RoutePeerInfo::new();
|
||||
credential_info.peer_id = 11;
|
||||
credential_info.version = 1;
|
||||
credential_info.feature_flag = Some(PeerFeatureFlag {
|
||||
is_credential_peer: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
{
|
||||
let mut guard = service_impl.synced_route_info.peer_infos.write();
|
||||
guard.insert(admin_info.peer_id, admin_info.clone());
|
||||
guard.insert(credential_info.peer_id, credential_info.clone());
|
||||
}
|
||||
|
||||
assert!(service_impl.synced_route_info.is_admin_peer(&admin_info));
|
||||
assert!(!service_impl
|
||||
.synced_route_info
|
||||
.is_admin_peer(&credential_info));
|
||||
assert!(service_impl
|
||||
.synced_route_info
|
||||
.is_credential_peer(credential_info.peer_id));
|
||||
assert!(!service_impl
|
||||
.synced_route_info
|
||||
.is_credential_peer(admin_info.peer_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_credentials_only_from_admin_publishers() {
|
||||
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
let admin_key = vec![1; 32];
|
||||
let credential_key = vec![2; 32];
|
||||
|
||||
let mut admin_info = RoutePeerInfo::new();
|
||||
admin_info.peer_id = 20;
|
||||
admin_info.version = 1;
|
||||
admin_info.feature_flag = Some(PeerFeatureFlag {
|
||||
is_credential_peer: false,
|
||||
..Default::default()
|
||||
});
|
||||
admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey {
|
||||
pubkey: admin_key.clone(),
|
||||
expiry_unix: now + 600,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
let mut credential_info = RoutePeerInfo::new();
|
||||
credential_info.peer_id = 21;
|
||||
credential_info.version = 1;
|
||||
credential_info.feature_flag = Some(PeerFeatureFlag {
|
||||
is_credential_peer: true,
|
||||
..Default::default()
|
||||
});
|
||||
credential_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkey {
|
||||
pubkey: credential_key.clone(),
|
||||
expiry_unix: now + 600,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
{
|
||||
let mut guard = service_impl.synced_route_info.peer_infos.write();
|
||||
guard.insert(admin_info.peer_id, admin_info);
|
||||
guard.insert(credential_info.peer_id, credential_info);
|
||||
}
|
||||
|
||||
service_impl
|
||||
.synced_route_info
|
||||
.verify_and_update_credential_trusts();
|
||||
|
||||
assert!(service_impl
|
||||
.synced_route_info
|
||||
.trusted_credential_pubkeys
|
||||
.contains_key(&admin_key));
|
||||
assert!(!service_impl
|
||||
.synced_route_info
|
||||
.trusted_credential_pubkeys
|
||||
.contains_key(&credential_key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_route_info_marks_credential_sender_and_filters_entries() {
|
||||
let peer_mgr = create_mock_pmgr().await;
|
||||
let route = create_mock_route(peer_mgr.clone()).await;
|
||||
let from_peer_id: PeerId = 10001;
|
||||
let forwarded_peer_id: PeerId = 10002;
|
||||
|
||||
let identity_type = DashMap::new();
|
||||
identity_type.insert(from_peer_id, PeerIdentityType::Credential);
|
||||
*route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface {
|
||||
my_peer_id: peer_mgr.my_peer_id(),
|
||||
identity_type,
|
||||
}));
|
||||
|
||||
let mut sender_info = RoutePeerInfo::new();
|
||||
sender_info.peer_id = from_peer_id;
|
||||
sender_info.version = 1;
|
||||
sender_info.proxy_cidrs = vec!["10.10.0.0/24".to_string()];
|
||||
|
||||
let mut forwarded_info = RoutePeerInfo::new();
|
||||
forwarded_info.peer_id = forwarded_peer_id;
|
||||
forwarded_info.version = 1;
|
||||
|
||||
let make_raw = |info: &RoutePeerInfo| {
|
||||
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||
raw.transcode_from(info).unwrap();
|
||||
raw
|
||||
};
|
||||
let raw_infos = vec![make_raw(&sender_info), make_raw(&forwarded_info)];
|
||||
|
||||
route
|
||||
.session_mgr
|
||||
.do_sync_route_info(
|
||||
from_peer_id,
|
||||
1,
|
||||
true,
|
||||
Some(vec![sender_info, forwarded_info]),
|
||||
Some(raw_infos),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let guard = route.service_impl.synced_route_info.peer_infos.read();
|
||||
let stored = guard.get(&from_peer_id).unwrap();
|
||||
assert!(stored
|
||||
.feature_flag
|
||||
.as_ref()
|
||||
.map(|x| x.is_credential_peer)
|
||||
.unwrap_or(false));
|
||||
assert!(stored.proxy_cidrs.is_empty());
|
||||
assert!(guard.get(&forwarded_peer_id).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_route_info_forces_non_credential_for_legacy_admin_sender() {
|
||||
let peer_mgr = create_mock_pmgr().await;
|
||||
let route = create_mock_route(peer_mgr.clone()).await;
|
||||
let from_peer_id: PeerId = 10011;
|
||||
let other_peer_id: PeerId = 10012;
|
||||
|
||||
let identity_type = DashMap::new();
|
||||
identity_type.insert(from_peer_id, PeerIdentityType::Admin);
|
||||
*route.service_impl.interface.lock().await = Some(Box::new(AuthOnlyInterface {
|
||||
my_peer_id: peer_mgr.my_peer_id(),
|
||||
identity_type,
|
||||
}));
|
||||
|
||||
let mut sender_info = RoutePeerInfo::new();
|
||||
sender_info.peer_id = from_peer_id;
|
||||
sender_info.version = 1;
|
||||
sender_info.feature_flag = Some(PeerFeatureFlag {
|
||||
is_credential_peer: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut other_info = RoutePeerInfo::new();
|
||||
other_info.peer_id = other_peer_id;
|
||||
other_info.version = 1;
|
||||
|
||||
let make_raw = |info: &RoutePeerInfo| {
|
||||
let mut raw = DynamicMessage::new(RoutePeerInfo::default().descriptor());
|
||||
raw.transcode_from(info).unwrap();
|
||||
raw
|
||||
};
|
||||
let raw_infos = vec![make_raw(&sender_info), make_raw(&other_info)];
|
||||
|
||||
route
|
||||
.session_mgr
|
||||
.do_sync_route_info(
|
||||
from_peer_id,
|
||||
1,
|
||||
true,
|
||||
Some(vec![sender_info, other_info]),
|
||||
Some(raw_infos),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let guard = route.service_impl.synced_route_info.peer_infos.read();
|
||||
let sender = guard.get(&from_peer_id).unwrap();
|
||||
assert!(!sender
|
||||
.feature_flag
|
||||
.as_ref()
|
||||
.map(|x| x.is_credential_peer)
|
||||
.unwrap_or(false));
|
||||
assert!(guard.get(&other_peer_id).is_some());
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[tokio::test]
|
||||
async fn ospf_route_2node(#[values(true, false)] enable_conn_list_sync: bool) {
|
||||
|
||||
@@ -787,7 +787,15 @@ impl PeerSession {
|
||||
let encryptor = self
|
||||
.get_encryptor(epoch, dir, true)
|
||||
.ok_or_else(|| anyhow!("no key for epoch"))?;
|
||||
let _ = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice()));
|
||||
if let Err(e) = encryptor.encrypt_with_nonce(pkt, Some(nonce_bytes.as_slice())) {
|
||||
tracing::warn!(
|
||||
peer_id = ?self.peer_id,
|
||||
?e,
|
||||
"session encrypt failed, invalidating"
|
||||
);
|
||||
self.invalidate();
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use dashmap::DashMap;
|
||||
use crate::{
|
||||
common::{global_ctx::NetworkIdentity, PeerId},
|
||||
proto::peer_rpc::{
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, RouteForeignNetworkInfos,
|
||||
RouteForeignNetworkSummary, RoutePeerInfo,
|
||||
ForeignNetworkRouteInfoEntry, ForeignNetworkRouteInfoKey, PeerIdentityType,
|
||||
RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,9 @@ pub type ForeignNetworkRouteInfoMap =
|
||||
pub trait RouteInterface {
|
||||
async fn list_peers(&self) -> Vec<PeerId>;
|
||||
fn my_peer_id(&self) -> PeerId;
|
||||
async fn get_peer_identity_type(&self, _peer_id: PeerId) -> Option<PeerIdentityType> {
|
||||
None
|
||||
}
|
||||
async fn list_foreign_networks(&self) -> ForeignNetworkRouteInfoMap {
|
||||
DashMap::new()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
use std::{
|
||||
ops::Deref,
|
||||
sync::{Arc, Weak},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
proto::{
|
||||
api::instance::{
|
||||
AclManageRpc, DumpRouteRequest, DumpRouteResponse, GetAclStatsRequest,
|
||||
AclManageRpc, CredentialManageRpc, DumpRouteRequest, DumpRouteResponse,
|
||||
GenerateCredentialRequest, GenerateCredentialResponse, GetAclStatsRequest,
|
||||
GetAclStatsResponse, GetForeignNetworkSummaryRequest, GetForeignNetworkSummaryResponse,
|
||||
GetWhitelistRequest, GetWhitelistResponse, ListForeignNetworkRequest,
|
||||
ListForeignNetworkResponse, ListGlobalForeignNetworkRequest,
|
||||
ListGlobalForeignNetworkResponse, ListPeerRequest, ListPeerResponse, ListRouteRequest,
|
||||
ListRouteResponse, PeerInfo, PeerManageRpc, ShowNodeInfoRequest, ShowNodeInfoResponse,
|
||||
GetWhitelistRequest, GetWhitelistResponse, ListCredentialsRequest,
|
||||
ListCredentialsResponse, ListForeignNetworkRequest, ListForeignNetworkResponse,
|
||||
ListGlobalForeignNetworkRequest, ListGlobalForeignNetworkResponse, ListPeerRequest,
|
||||
ListPeerResponse, ListRouteRequest, ListRouteResponse, PeerInfo, PeerManageRpc,
|
||||
RevokeCredentialRequest, RevokeCredentialResponse, ShowNodeInfoRequest,
|
||||
ShowNodeInfoResponse,
|
||||
},
|
||||
rpc_types::{self, controller::BaseController},
|
||||
},
|
||||
@@ -201,3 +205,77 @@ impl AclManageRpc for PeerManagerRpcService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CredentialManageRpc for PeerManagerRpcService {
|
||||
type Controller = BaseController;
|
||||
|
||||
async fn generate_credential(
|
||||
&self,
|
||||
_: BaseController,
|
||||
request: GenerateCredentialRequest,
|
||||
) -> Result<GenerateCredentialResponse, rpc_types::error::Error> {
|
||||
let pm = weak_upgrade(&self.peer_manager)?;
|
||||
let global_ctx = pm.get_global_ctx();
|
||||
|
||||
if global_ctx.get_network_identity().network_secret.is_none() {
|
||||
return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!(
|
||||
"only admin nodes (with network_secret) can generate credentials"
|
||||
)));
|
||||
}
|
||||
|
||||
let ttl = if request.ttl_seconds > 0 {
|
||||
Duration::from_secs(request.ttl_seconds as u64)
|
||||
} else {
|
||||
return Err(rpc_types::error::Error::ExecutionError(anyhow::anyhow!(
|
||||
"ttl_seconds must be positive"
|
||||
)));
|
||||
};
|
||||
|
||||
let (id, secret) = global_ctx.get_credential_manager().generate_credential(
|
||||
request.groups,
|
||||
request.allow_relay,
|
||||
request.allowed_proxy_cidrs,
|
||||
ttl,
|
||||
);
|
||||
|
||||
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||
|
||||
Ok(GenerateCredentialResponse {
|
||||
credential_id: id,
|
||||
credential_secret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
async fn revoke_credential(
|
||||
&self,
|
||||
_: BaseController,
|
||||
request: RevokeCredentialRequest,
|
||||
) -> Result<RevokeCredentialResponse, rpc_types::error::Error> {
|
||||
let pm = weak_upgrade(&self.peer_manager)?;
|
||||
let global_ctx = pm.get_global_ctx();
|
||||
|
||||
let success = global_ctx
|
||||
.get_credential_manager()
|
||||
.revoke_credential(&request.credential_id);
|
||||
|
||||
if success {
|
||||
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||
}
|
||||
|
||||
Ok(RevokeCredentialResponse { success })
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
_: BaseController,
|
||||
_request: ListCredentialsRequest,
|
||||
) -> Result<ListCredentialsResponse, rpc_types::error::Error> {
|
||||
let pm = weak_upgrade(&self.peer_manager)?;
|
||||
let global_ctx = pm.get_global_ctx();
|
||||
|
||||
Ok(ListCredentialsResponse {
|
||||
credentials: global_ctx.get_credential_manager().list_credentials(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use crate::{
|
||||
common::{
|
||||
error::Error,
|
||||
@@ -707,3 +709,467 @@ async fn relay_peer_map_bidirectional_handshake_race() {
|
||||
"peer_c should have session with peer_a"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a secure peer manager for a credential node.
|
||||
/// Uses the given X25519 private key as the Noise static key, with no network_secret.
|
||||
pub async fn create_mock_peer_manager_credential(
|
||||
network_name: String,
|
||||
private_key: &x25519_dalek::StaticSecret,
|
||||
) -> Arc<PeerManager> {
|
||||
use crate::common::config::NetworkIdentity;
|
||||
use crate::proto::common::SecureModeConfig;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
|
||||
let (s, _r) = create_packet_recv_chan();
|
||||
let g = get_mock_global_ctx_with_network(Some(NetworkIdentity::new_credential(network_name)));
|
||||
|
||||
let public = x25519_dalek::PublicKey::from(private_key);
|
||||
g.config.set_secure_mode(Some(SecureModeConfig {
|
||||
enabled: true,
|
||||
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
|
||||
local_public_key: Some(BASE64_STANDARD.encode(public.as_bytes())),
|
||||
}));
|
||||
|
||||
let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, g, s));
|
||||
peer_mgr.run().await.unwrap();
|
||||
peer_mgr
|
||||
}
|
||||
|
||||
/// Test: credential node joins a 2-admin network and routes appear.
|
||||
/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B
|
||||
/// Credential node connects to the admin that generated the credential.
|
||||
#[tokio::test]
|
||||
async fn credential_node_joins_network() {
|
||||
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;
|
||||
|
||||
// Generate credential on admin_a
|
||||
let (_cred_id, cred_secret) = admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.generate_credential(
|
||||
vec!["guest".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
// Create credential node using the generated key
|
||||
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;
|
||||
|
||||
// Connect admins first
|
||||
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||
|
||||
// Admin A and B should discover each other
|
||||
wait_route_appear(admin_a.clone(), admin_b.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now connect credential node to admin A (credential as client)
|
||||
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||
|
||||
// Credential node C should be reachable from admin B (via A)
|
||||
let cred_c_id = cred_c.my_peer_id();
|
||||
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;
|
||||
|
||||
// Credential node C should see admin B
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let cred_c = cred_c.clone();
|
||||
let admin_b_id = admin_b.my_peer_id();
|
||||
async move {
|
||||
cred_c
|
||||
.list_routes()
|
||||
.await
|
||||
.iter()
|
||||
.any(|r| r.peer_id == admin_b_id)
|
||||
}
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Test: credential node is rejected when its pubkey is not in any admin's trusted list.
|
||||
/// Topology: Admin_A -- Unknown_B (random key, not in trusted list)
|
||||
#[tokio::test]
|
||||
async fn unknown_credential_node_rejected() {
|
||||
let admin_a = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||
|
||||
// Create a credential node with a random key (NOT generated by admin)
|
||||
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let unknown_c = create_mock_peer_manager_credential("net1".to_string(), &random_private).await;
|
||||
|
||||
// Try to connect: C -> A (unknown credential as client, admin as server)
|
||||
connect_peer_manager(unknown_c.clone(), admin_a.clone()).await;
|
||||
|
||||
// The handshake should fail so the connection won't establish.
|
||||
// Wait a bit and verify no route appears.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let routes = admin_a.list_routes().await;
|
||||
assert!(
|
||||
!routes.iter().any(|r| r.peer_id == unknown_c.my_peer_id()),
|
||||
"unknown credential node should NOT appear in admin's routes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: after revocation, the credential node disappears from routes.
|
||||
/// Topology: Admin_A -- Credential_C, Admin_A -- Admin_B
|
||||
/// After revocation on A, C should be removed from B's route table.
|
||||
#[tokio::test]
|
||||
async fn credential_revocation_removes_from_routes() {
|
||||
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;
|
||||
|
||||
let (cred_id, cred_secret) = admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.generate_credential(vec![], false, vec![], std::time::Duration::from_secs(3600));
|
||||
|
||||
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;
|
||||
|
||||
// Connect: A -- B, C -> A (credential node as client, admin as server)
|
||||
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||
|
||||
// Wait for credential node to appear in admin_b's routes
|
||||
let cred_c_id = cred_c.my_peer_id();
|
||||
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;
|
||||
|
||||
// Now revoke the credential
|
||||
assert!(admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.revoke_credential(&cred_id));
|
||||
// Issue event to trigger OSPF sync
|
||||
admin_a
|
||||
.get_global_ctx()
|
||||
.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||
|
||||
// Wait for credential node to disappear from admin_b's routes
|
||||
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(15),
|
||||
)
|
||||
.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]
|
||||
async fn credential_node_group_assignment() {
|
||||
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;
|
||||
|
||||
let (_cred_id, cred_secret) = admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.generate_credential(
|
||||
vec!["guest".to_string(), "limited".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||
connect_peer_manager(cred_c.clone(), admin_a.clone()).await;
|
||||
|
||||
// Wait for credential node route to appear on admin_b (via OSPF through admin_a)
|
||||
let cred_c_id = cred_c.my_peer_id();
|
||||
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;
|
||||
|
||||
// Verify the credential node's groups are assigned via OSPF on admin_b
|
||||
// (admin_b gets the groups from admin_a's TrustedCredentialPubkey via OSPF sync)
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let admin_b = admin_b.clone();
|
||||
async move {
|
||||
let g = admin_b.get_route().get_peer_groups(cred_c_id);
|
||||
g.contains(&"guest".to_string()) && g.contains(&"limited".to_string())
|
||||
}
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Minimal test: two secure peers connect and discover each other's route.
|
||||
#[tokio::test]
|
||||
async fn two_secure_peers_route_appear() {
|
||||
let peer_a = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await;
|
||||
let peer_b = create_mock_peer_manager_secure("net1".to_string(), "sec1".to_string()).await;
|
||||
|
||||
connect_peer_manager(peer_a.clone(), peer_b.clone()).await;
|
||||
|
||||
wait_route_appear(peer_a.clone(), peer_b.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_admin_multi_credential_route_and_revocation_isolation() {
|
||||
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;
|
||||
let admin_d = create_mock_peer_manager_secure("net1".to_string(), "secret".to_string()).await;
|
||||
|
||||
connect_peer_manager(admin_a.clone(), admin_b.clone()).await;
|
||||
connect_peer_manager(admin_b.clone(), admin_d.clone()).await;
|
||||
connect_peer_manager(admin_a.clone(), admin_d.clone()).await;
|
||||
|
||||
wait_route_appear(admin_a.clone(), admin_b.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
wait_route_appear(admin_b.clone(), admin_d.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
wait_route_appear(admin_a.clone(), admin_d.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (cred1_id, cred1_secret) = admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.generate_credential(
|
||||
vec!["guest-a".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
let (_cred2_id, cred2_secret) = admin_b
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.generate_credential(
|
||||
vec!["guest-b".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
let cred1_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cred1_secret)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let cred2_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cred2_secret)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let cred_1 = create_mock_peer_manager_credential(
|
||||
"net1".to_string(),
|
||||
&x25519_dalek::StaticSecret::from(cred1_private),
|
||||
)
|
||||
.await;
|
||||
let cred_2 = create_mock_peer_manager_credential(
|
||||
"net1".to_string(),
|
||||
&x25519_dalek::StaticSecret::from(cred2_private),
|
||||
)
|
||||
.await;
|
||||
|
||||
connect_peer_manager(cred_1.clone(), admin_a.clone()).await;
|
||||
connect_peer_manager(cred_2.clone(), admin_b.clone()).await;
|
||||
|
||||
let cred_1_id = cred_1.my_peer_id();
|
||||
let cred_2_id = cred_2.my_peer_id();
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let admin_d = admin_d.clone();
|
||||
async move {
|
||||
let routes = admin_d.list_routes().await;
|
||||
routes.iter().any(|r| r.peer_id == cred_1_id)
|
||||
&& routes.iter().any(|r| r.peer_id == cred_2_id)
|
||||
}
|
||||
},
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await;
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let admin_d = admin_d.clone();
|
||||
async move {
|
||||
let g1 = admin_d.get_route().get_peer_groups(cred_1_id);
|
||||
let g2 = admin_d.get_route().get_peer_groups(cred_2_id);
|
||||
g1.contains(&"guest-a".to_string()) && g2.contains(&"guest-b".to_string())
|
||||
}
|
||||
},
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(admin_a
|
||||
.get_global_ctx()
|
||||
.get_credential_manager()
|
||||
.revoke_credential(&cred1_id));
|
||||
admin_a
|
||||
.get_global_ctx()
|
||||
.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let admin_d = admin_d.clone();
|
||||
async move {
|
||||
let routes = admin_d.list_routes().await;
|
||||
!routes.iter().any(|r| r.peer_id == cred_1_id)
|
||||
&& routes.iter().any(|r| r.peer_id == cred_2_id)
|
||||
}
|
||||
},
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_credential_rejected_while_valid_credential_survives() {
|
||||
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!["stable".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
std::time::Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
let valid_private: [u8; 32] = base64::engine::general_purpose::STANDARD
|
||||
.decode(&cred_secret)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let valid_cred = create_mock_peer_manager_credential(
|
||||
"net1".to_string(),
|
||||
&x25519_dalek::StaticSecret::from(valid_private),
|
||||
)
|
||||
.await;
|
||||
let unknown_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let unknown_cred =
|
||||
create_mock_peer_manager_credential("net1".to_string(), &unknown_private).await;
|
||||
|
||||
connect_peer_manager(valid_cred.clone(), admin_a.clone()).await;
|
||||
let (unknown_ring_client, unknown_ring_server) = create_ring_tunnel_pair();
|
||||
let unknown_connect_client = tokio::spawn({
|
||||
let unknown_cred = unknown_cred.clone();
|
||||
async move {
|
||||
unknown_cred
|
||||
.add_client_tunnel(unknown_ring_client, false)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let unknown_connect_server = tokio::spawn({
|
||||
let admin_a = admin_a.clone();
|
||||
async move {
|
||||
admin_a
|
||||
.add_tunnel_as_server(unknown_ring_server, true)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let (unknown_client_ret, unknown_server_ret) =
|
||||
tokio::join!(unknown_connect_client, unknown_connect_server);
|
||||
assert!(
|
||||
unknown_client_ret.unwrap().is_err() || unknown_server_ret.unwrap().is_err(),
|
||||
"unknown credential connection should fail on at least one side"
|
||||
);
|
||||
|
||||
let valid_id = valid_cred.my_peer_id();
|
||||
let unknown_id = unknown_cred.my_peer_id();
|
||||
|
||||
wait_for_condition(
|
||||
|| {
|
||||
let admin_b = admin_b.clone();
|
||||
async move {
|
||||
admin_b
|
||||
.list_routes()
|
||||
.await
|
||||
.iter()
|
||||
.any(|r| r.peer_id == valid_id)
|
||||
}
|
||||
},
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let routes = admin_b.list_routes().await;
|
||||
assert!(routes.iter().any(|r| r.peer_id == valid_id));
|
||||
assert!(!routes.iter().any(|r| r.peer_id == unknown_id));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user