mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-02 09:09:17 +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:
@@ -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