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:
KKRainbow
2026-03-07 22:58:15 +08:00
committed by GitHub
parent 59d4475743
commit c4eacf4591
31 changed files with 4289 additions and 163 deletions
+466
View File
@@ -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));
}