Honor credential reusable flag (#2157)

- propagate reusable through credential storage, CLI, RPC, routing, and tests
- enforce reusable=false owner election with current topology
- preserve proof-backed groups when refreshing credential ACL groups
This commit is contained in:
KKRainbow
2026-04-25 00:22:40 +08:00
committed by GitHub
parent f7ea78d4f0
commit 4688ad74ad
8 changed files with 1461 additions and 205 deletions
+26 -2
View File
@@ -15,7 +15,7 @@ use anyhow::Context;
use base64::Engine as _;
use base64::prelude::BASE64_STANDARD;
use cidr::Ipv4Inet;
use clap::{Args, CommandFactory, Parser, Subcommand};
use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, builder::BoolishValueParser};
use dashmap::DashMap;
use easytier::ShellType;
use humansize::format_size;
@@ -402,6 +402,14 @@ enum CredentialSubCommand {
help = "allowed proxy CIDRs (comma-separated)"
)]
allowed_proxy_cidrs: Option<Vec<String>>,
#[arg(
long,
action = ArgAction::Set,
default_value = "true",
value_parser = BoolishValueParser::new(),
help = "whether this credential may be reused by multiple peers concurrently"
)]
reusable: bool,
},
/// Revoke a credential by its ID
Revoke {
@@ -2008,6 +2016,7 @@ impl<'a> CommandHandler<'a> {
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
reusable: bool,
) -> Result<(), Error> {
let results = self
.collect_instance_results(|handler| {
@@ -2027,6 +2036,7 @@ impl<'a> CommandHandler<'a> {
allowed_proxy_cidrs,
ttl_seconds: ttl,
instance: Some(handler.instance_selector.clone()),
reusable: Some(reusable),
},
)
.await
@@ -2104,7 +2114,14 @@ impl<'a> CommandHandler<'a> {
} else {
use tabled::{builder::Builder, settings::Style};
let mut builder = Builder::default();
builder.push_record(["ID", "Groups", "Relay", "Expiry", "Allowed CIDRs"]);
builder.push_record([
"ID",
"Groups",
"Relay",
"Reusable",
"Expiry",
"Allowed CIDRs",
]);
for cred in &response.credentials {
let expiry = {
let secs = cred.expiry_unix;
@@ -2123,6 +2140,11 @@ impl<'a> CommandHandler<'a> {
&cred.credential_id[..],
&cred.groups.join(","),
if cred.allow_relay { "yes" } else { "no" },
if cred.reusable.unwrap_or(true) {
"yes"
} else {
"no"
},
&expiry,
&cred.allowed_proxy_cidrs.join(","),
]);
@@ -2921,6 +2943,7 @@ async fn main() -> Result<(), Error> {
groups,
allow_relay,
allowed_proxy_cidrs,
reusable,
} => {
handler
.handle_credential_generate(
@@ -2929,6 +2952,7 @@ async fn main() -> Result<(), Error> {
groups.clone().unwrap_or_default(),
*allow_relay,
allowed_proxy_cidrs.clone().unwrap_or_default(),
*reusable,
)
.await?;
}
+148 -39
View File
@@ -12,6 +12,17 @@ use x25519_dalek::{PublicKey, StaticSecret};
use crate::proto::peer_rpc::{TrustedCredentialPubkey, TrustedCredentialPubkeyProof};
fn default_true() -> bool {
true
}
fn current_unix_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CredentialEntry {
pubkey: String,
@@ -20,10 +31,43 @@ struct CredentialEntry {
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
#[serde(default = "default_true")]
reusable: bool,
expiry_unix: i64,
created_at_unix: i64,
}
impl CredentialEntry {
fn is_active_at(&self, now: i64) -> bool {
self.expiry_unix > now
}
fn to_trusted_credential(&self) -> Option<TrustedCredentialPubkey> {
Some(TrustedCredentialPubkey {
pubkey: CredentialManager::decode_pubkey_b64(&self.pubkey)?,
groups: self.groups.clone(),
allow_relay: self.allow_relay,
expiry_unix: self.expiry_unix,
allowed_proxy_cidrs: self.allowed_proxy_cidrs.clone(),
reusable: Some(self.reusable),
})
}
fn to_api_credential_info(
&self,
credential_id: &str,
) -> crate::proto::api::instance::CredentialInfo {
crate::proto::api::instance::CredentialInfo {
credential_id: credential_id.to_string(),
groups: self.groups.clone(),
allow_relay: self.allow_relay,
expiry_unix: self.expiry_unix,
allowed_proxy_cidrs: self.allowed_proxy_cidrs.clone(),
reusable: Some(self.reusable),
}
}
}
pub struct CredentialManager {
credentials: Mutex<HashMap<String, CredentialEntry>>,
storage_path: Option<PathBuf>,
@@ -46,7 +90,14 @@ impl CredentialManager {
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
) -> (String, String) {
self.generate_credential_with_id(groups, allow_relay, allowed_proxy_cidrs, ttl, None)
self.generate_credential_with_options(
groups,
allow_relay,
allowed_proxy_cidrs,
ttl,
None,
true,
)
}
pub fn generate_credential_with_id(
@@ -56,6 +107,25 @@ impl CredentialManager {
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
credential_id: Option<String>,
) -> (String, String) {
self.generate_credential_with_options(
groups,
allow_relay,
allowed_proxy_cidrs,
ttl,
credential_id,
true,
)
}
pub fn generate_credential_with_options(
&self,
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
ttl: Duration,
credential_id: Option<String>,
reusable: bool,
) -> (String, String) {
let mut credentials = self.credentials.lock().unwrap();
let id = if let Some(id) = credential_id
@@ -72,7 +142,8 @@ impl CredentialManager {
uuid::Uuid::new_v4().to_string()
};
let (entry, secret) = Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, ttl);
let (entry, secret) =
Self::build_entry(groups, allow_relay, allowed_proxy_cidrs, reusable, ttl);
credentials.insert(id.clone(), entry);
drop(credentials);
self.save_to_disk();
@@ -83,6 +154,7 @@ impl CredentialManager {
groups: Vec<String>,
allow_relay: bool,
allowed_proxy_cidrs: Vec<String>,
reusable: bool,
ttl: Duration,
) -> (CredentialEntry, String) {
let private = StaticSecret::random_from_rng(rand::rngs::OsRng);
@@ -102,6 +174,7 @@ impl CredentialManager {
groups,
allow_relay,
allowed_proxy_cidrs,
reusable,
expiry_unix,
created_at_unix: now,
};
@@ -122,67 +195,41 @@ impl CredentialManager {
}
pub fn get_trusted_pubkeys(&self, network_secret: &str) -> Vec<TrustedCredentialPubkeyProof> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let now = current_unix_timestamp();
self.credentials
.lock()
.unwrap()
.values()
.filter(|e| e.expiry_unix > now)
.map(|e| {
let credential = TrustedCredentialPubkey {
pubkey: Self::decode_pubkey_b64(&e.pubkey).unwrap_or_default(),
groups: e.groups.clone(),
allow_relay: e.allow_relay,
expiry_unix: e.expiry_unix,
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
};
TrustedCredentialPubkeyProof::new_signed(credential, network_secret)
})
.filter(|e| {
e.credential
.as_ref()
.map(|x| !x.pubkey.is_empty())
.unwrap_or(false)
.filter(|entry| entry.is_active_at(now))
.filter_map(|entry| {
entry.to_trusted_credential().map(|credential| {
TrustedCredentialPubkeyProof::new_signed(credential, network_secret)
})
})
.collect()
}
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let now = current_unix_timestamp();
let encoded = BASE64_STANDARD.encode(pubkey);
self.credentials
.lock()
.unwrap()
.values()
.any(|e| e.pubkey == encoded && e.expiry_unix > now)
.any(|entry| entry.pubkey == encoded && entry.is_active_at(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;
let now = current_unix_timestamp();
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(),
})
.filter(|(_, entry)| entry.is_active_at(now))
.map(|(id, entry)| entry.to_api_credential_info(id))
.collect()
}
@@ -254,6 +301,7 @@ mod tests {
trusted[0].credential.as_ref().unwrap().groups,
vec!["guest".to_string()]
);
assert_eq!(trusted[0].credential.as_ref().unwrap().reusable, Some(true));
assert!(mgr.revoke_credential(&id));
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
@@ -286,6 +334,7 @@ mod tests {
let list = mgr.list_credentials();
assert_eq!(list.len(), 2);
assert!(list.iter().all(|item| item.reusable == Some(true)));
}
#[test]
@@ -360,6 +409,7 @@ mod tests {
trusted[0].credential.as_ref().unwrap().allowed_proxy_cidrs,
vec!["10.0.0.0/8".to_string()]
);
assert_eq!(trusted[0].credential.as_ref().unwrap().reusable, Some(true));
}
#[test]
@@ -384,6 +434,7 @@ mod tests {
tc.credential.as_ref().unwrap().allowed_proxy_cidrs,
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
);
assert_eq!(tc.credential.as_ref().unwrap().reusable, Some(true));
assert!(tc.credential.as_ref().unwrap().expiry_unix > 0);
assert!(tc.verify_credential_hmac("sec"));
assert!(
@@ -431,6 +482,7 @@ mod tests {
assert_eq!(list.len(), 1);
assert_eq!(list[0].groups, vec!["persist_group".to_string()]);
assert!(list[0].allow_relay);
assert_eq!(list[0].reusable, Some(true));
}
}
@@ -473,5 +525,62 @@ mod tests {
assert_eq!(list[0].groups, vec!["group-a".to_string()]);
assert!(!list[0].allow_relay);
assert_eq!(list[0].allowed_proxy_cidrs, vec!["10.0.0.0/24".to_string()]);
assert_eq!(list[0].reusable, Some(true));
}
#[test]
fn test_generate_non_reusable_credential() {
let mgr = CredentialManager::new(None);
let (_id, secret) = mgr.generate_credential_with_options(
vec!["single".to_string()],
false,
vec![],
Duration::from_secs(3600),
None,
false,
);
let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap();
let private = StaticSecret::from(privkey_bytes);
let pubkey_bytes = PublicKey::from(&private).as_bytes().to_vec();
let listed = mgr.list_credentials();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].reusable, Some(false));
assert!(mgr.is_pubkey_trusted(&pubkey_bytes));
let trusted = mgr.get_trusted_pubkeys("sec");
assert_eq!(trusted.len(), 1);
assert_eq!(
trusted[0].credential.as_ref().unwrap().reusable,
Some(false)
);
}
#[test]
fn test_load_old_credentials_default_to_reusable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy-creds.json");
std::fs::write(
&path,
r#"{
"legacy-id": {
"pubkey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"secret": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
"groups": ["legacy"],
"allow_relay": false,
"allowed_proxy_cidrs": [],
"expiry_unix": 4102444800,
"created_at_unix": 1700000000
}
}"#,
)
.unwrap();
let mgr = CredentialManager::new(Some(path));
let list = mgr.list_credentials();
assert_eq!(list.len(), 1);
assert_eq!(list[0].credential_id, "legacy-id");
assert_eq!(list[0].reusable, Some(true));
}
}
+729 -106
View File
@@ -48,8 +48,8 @@ use crate::{
OspfRouteRpcClientFactory, OspfRouteRpcServer, PeerGroupInfo, PeerIdVersion,
PeerIdentityType, RouteForeignNetworkInfos, RouteForeignNetworkSummary, RoutePeerInfo,
RoutePeerInfos, SyncRouteInfoError, SyncRouteInfoRequest, SyncRouteInfoResponse,
TrustedCredentialPubkey, route_foreign_network_infos, route_foreign_network_summary,
sync_route_info_request::ConnInfo,
TrustedCredentialPubkey, TrustedCredentialPubkeyProof, route_foreign_network_infos,
route_foreign_network_summary, sync_route_info_request::ConnInfo,
},
rpc_types::{
self,
@@ -123,6 +123,20 @@ fn patch_raw_from_info(raw: &mut DynamicMessage, info: &RoutePeerInfo, fields: &
}
}
fn raw_credential_bytes_from_route_info(
raw_route_info: &DynamicMessage,
proof_idx: usize,
) -> Option<Vec<u8>> {
raw_route_info
.get_field_by_name("trusted_credential_pubkeys")?
.as_list()?
.get(proof_idx)?
.as_message()?
.get_field_by_name("credential")?
.as_message()
.map(|credential| credential.encode_to_vec())
}
#[derive(Debug, Clone)]
struct AtomicVersion(Arc<AtomicU32>);
@@ -401,6 +415,9 @@ struct SyncedRouteInfo {
// Aggregated trusted credential pubkeys from all admin nodes
// Maps pubkey bytes -> TrustedCredentialPubkey
trusted_credential_pubkeys: DashMap<Vec<u8>, TrustedCredentialPubkey>,
// Tracks the currently accepted peer for non-reusable credentials.
// Maps credential pubkey bytes -> peer_id.
non_reusable_credential_owners: DashMap<Vec<u8>, PeerId>,
version: AtomicVersion,
}
@@ -457,6 +474,212 @@ impl SyncedRouteInfo {
.unwrap_or(false)
}
fn credential_is_reusable(info: &TrustedCredentialPubkey) -> bool {
info.reusable.unwrap_or(true)
}
fn credential_proof_is_valid(
&self,
raw_route_info: Option<&DynamicMessage>,
proof_idx: usize,
proof: &TrustedCredentialPubkeyProof,
network_secret: Option<&str>,
) -> bool {
network_secret
.map(|secret| {
raw_route_info
.and_then(|raw| raw_credential_bytes_from_route_info(raw, proof_idx))
.map(|raw_credential_bytes| {
proof.verify_credential_hmac_with_bytes(&raw_credential_bytes, secret)
})
.unwrap_or_else(|| proof.verify_credential_hmac(secret))
})
.unwrap_or(true)
}
fn collect_trusted_credentials(
&self,
peer_infos: &OrderedHashMap<PeerId, RoutePeerInfo>,
network_secret: Option<&str>,
now: i64,
) -> (
HashMap<Vec<u8>, TrustedCredentialPubkey>,
HashMap<Vec<u8>, crate::common::global_ctx::TrustedKeyMetadata>,
) {
use crate::common::global_ctx::{TrustedKeyMetadata, TrustedKeySource};
let mut all_trusted = HashMap::new();
let mut global_trusted_keys = HashMap::new();
for (peer_id, info) in peer_infos.iter() {
if !self.is_admin_peer(info) {
continue;
}
if !info.noise_static_pubkey.is_empty() {
global_trusted_keys.insert(
info.noise_static_pubkey.clone(),
TrustedKeyMetadata {
source: TrustedKeySource::OspfNode,
expiry_unix: None,
},
);
}
let raw_route_info = self.raw_peer_infos.get(peer_id);
let raw_route_info = raw_route_info.as_deref();
for (proof_idx, proof) in info.trusted_credential_pubkeys.iter().enumerate() {
if !self.credential_proof_is_valid(raw_route_info, proof_idx, proof, network_secret)
{
continue;
}
let Some(credential) = proof.credential.as_ref() else {
continue;
};
if credential.expiry_unix <= now {
continue;
}
all_trusted
.entry(credential.pubkey.clone())
.or_insert_with(|| credential.clone());
global_trusted_keys.insert(
credential.pubkey.clone(),
TrustedKeyMetadata {
source: TrustedKeySource::OspfCredential,
expiry_unix: Some(credential.expiry_unix),
},
);
}
}
(all_trusted, global_trusted_keys)
}
fn replace_trusted_credential_pubkeys(
&self,
all_trusted: &HashMap<Vec<u8>, TrustedCredentialPubkey>,
) -> HashSet<Vec<u8>> {
let prev_trusted = self
.trusted_credential_pubkeys
.iter()
.map(|entry| entry.key().clone())
.collect();
self.trusted_credential_pubkeys.clear();
for (pubkey, credential) in all_trusted {
self.trusted_credential_pubkeys
.insert(pubkey.clone(), credential.clone());
}
prev_trusted
}
fn collect_non_reusable_credential_owners<F>(
&self,
peer_infos: &OrderedHashMap<PeerId, RoutePeerInfo>,
all_trusted: &HashMap<Vec<u8>, TrustedCredentialPubkey>,
mut is_peer_active: F,
) -> (HashMap<Vec<u8>, PeerId>, BTreeSet<PeerId>)
where
F: FnMut(PeerId) -> bool,
{
let mut candidates: BTreeMap<Vec<u8>, BTreeSet<PeerId>> = BTreeMap::new();
for (peer_id, info) in peer_infos.iter() {
if info.noise_static_pubkey.is_empty() {
continue;
}
let Some(credential) = all_trusted.get(&info.noise_static_pubkey) else {
continue;
};
if Self::credential_is_reusable(credential) {
continue;
}
if !is_peer_active(*peer_id) {
continue;
}
candidates
.entry(info.noise_static_pubkey.clone())
.or_default()
.insert(*peer_id);
}
let mut active_owners = HashMap::new();
let mut duplicate_untrusted_peers = BTreeSet::new();
for (pubkey, candidate_peer_ids) in candidates {
let Some(owner_peer_id) = candidate_peer_ids.iter().next().copied() else {
continue;
};
active_owners.insert(pubkey, owner_peer_id);
duplicate_untrusted_peers.extend(
candidate_peer_ids
.into_iter()
.filter(|peer_id| *peer_id != owner_peer_id),
);
}
(active_owners, duplicate_untrusted_peers)
}
fn replace_non_reusable_credential_owners(&self, active_owners: HashMap<Vec<u8>, PeerId>) {
self.non_reusable_credential_owners
.retain(|pubkey, _| active_owners.contains_key(pubkey));
for (pubkey, peer_id) in active_owners {
self.non_reusable_credential_owners.insert(pubkey, peer_id);
}
}
fn update_credential_groups(
&self,
peer_infos: &OrderedHashMap<PeerId, RoutePeerInfo>,
all_trusted: &HashMap<Vec<u8>, TrustedCredentialPubkey>,
) {
for (_, info) in peer_infos.iter() {
if info.noise_static_pubkey.is_empty() {
continue;
}
let Some(credential) = all_trusted.get(&info.noise_static_pubkey) else {
continue;
};
let mut group_map = self.get_proof_groups(info.peer_id);
for group in &credential.groups {
group_map.entry(group.clone()).or_default();
}
self.set_peer_groups(info.peer_id, group_map);
}
}
fn collect_revoked_credential_peers(
peer_infos: &OrderedHashMap<PeerId, RoutePeerInfo>,
prev_trusted: &HashSet<Vec<u8>>,
all_trusted: &HashMap<Vec<u8>, TrustedCredentialPubkey>,
) -> BTreeSet<PeerId> {
let mut untrusted_peers = BTreeSet::new();
for (peer_id, info) in peer_infos.iter() {
if info.noise_static_pubkey.is_empty() || info.version == 0 {
continue;
}
if prev_trusted.contains(&info.noise_static_pubkey)
&& !all_trusted.contains_key(&info.noise_static_pubkey)
{
untrusted_peers.insert(*peer_id);
}
}
untrusted_peers
}
fn get_connected_peers<T: FromIterator<PeerId>>(&self, peer_id: PeerId) -> Option<T> {
self.conn_map
.read()
@@ -967,110 +1190,37 @@ impl SyncedRouteInfo {
Vec<PeerId>,
HashMap<Vec<u8>, crate::common::global_ctx::TrustedKeyMetadata>,
) {
use crate::common::global_ctx::{TrustedKeyMetadata, TrustedKeySource};
self.verify_and_update_credential_trusts_with_active_peers(network_secret, |_| true)
}
fn verify_and_update_credential_trusts_with_active_peers<F>(
&self,
network_secret: Option<&str>,
is_peer_active: F,
) -> (
Vec<PeerId>,
HashMap<Vec<u8>, crate::common::global_ctx::TrustedKeyMetadata>,
)
where
F: FnMut(PeerId) -> bool,
{
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();
let (all_trusted, global_trusted_keys) =
self.collect_trusted_credentials(&peer_infos, network_secret, now);
let prev_trusted = self.replace_trusted_credential_pubkeys(&all_trusted);
let (active_non_reusable_owners, duplicate_untrusted_peers) =
self.collect_non_reusable_credential_owners(&peer_infos, &all_trusted, is_peer_active);
self.replace_non_reusable_credential_owners(active_non_reusable_owners);
self.update_credential_groups(&peer_infos, &all_trusted);
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 proof in &info.trusted_credential_pubkeys {
// If we have a network_secret, verify the HMAC as before.
// If we don't (e.g. credential nodes), accept proofs from admin peers
// based on the authenticated channel instead of local HMAC verification.
let hmac_valid = network_secret
.map(|secret| proof.verify_credential_hmac(secret))
.unwrap_or(true);
if !hmac_valid {
continue;
}
let Some(tc) = proof.credential.as_ref() else {
continue;
};
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) {
// Start from proof-backed groups so credential-declared groups can coexist
// without leaving stale credential-only entries behind after refreshes.
let mut group_map = self.get_proof_groups(info.peer_id);
for g in &tc.groups {
group_map.entry(g.clone()).or_default();
}
self.set_peer_groups(info.peer_id, group_map);
}
}
// 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);
}
}
let mut untrusted_peers =
Self::collect_revoked_credential_peers(&peer_infos, &prev_trusted, &all_trusted);
untrusted_peers.extend(duplicate_untrusted_peers);
// Remove untrusted peers from peer_infos so they won't appear in route graph
if !untrusted_peers.is_empty() {
@@ -1081,7 +1231,7 @@ impl SyncedRouteInfo {
self.remove_peers(untrusted_peers.iter().copied());
}
(untrusted_peers, global_trusted_keys)
(untrusted_peers.into_iter().collect(), global_trusted_keys)
}
fn is_admin_peer(&self, info: &RoutePeerInfo) -> bool {
@@ -1928,6 +2078,7 @@ impl PeerRouteServiceImpl {
group_trust_map: DashMap::new(),
group_trust_map_cache: DashMap::new(),
trusted_credential_pubkeys: DashMap::new(),
non_reusable_credential_owners: DashMap::new(),
version: AtomicVersion::new(),
},
cached_local_conn_map: std::sync::Mutex::new(RouteConnBitmap::default()),
@@ -1949,6 +2100,12 @@ impl PeerRouteServiceImpl {
ni.network_secret_digest.map(|d| d.to_vec())
}
fn is_active_non_reusable_credential_peer(&self, peer_id: PeerId) -> bool {
peer_id == self.my_peer_id
|| self.sessions.contains_key(&peer_id)
|| self.route_table.peer_reachable(peer_id)
}
fn is_credential_node(&self) -> bool {
self.global_ctx
.get_network_identity()
@@ -2492,7 +2649,7 @@ impl PeerRouteServiceImpl {
trust_admin_groups_without_proof,
);
let untrusted = self.refresh_credential_trusts();
let untrusted = self.refresh_credential_trusts_with_current_topology();
self.disconnect_untrusted_peers(&untrusted).await;
if my_peer_info_updated || !untrusted.is_empty() {
@@ -2513,11 +2670,34 @@ impl PeerRouteServiceImpl {
.verify_and_update_credential_trusts(network_identity.network_secret.as_deref());
self.global_ctx
.update_trusted_keys(global_trusted_keys, &network_identity.network_name);
untrusted
}
fn refresh_credential_trusts_with_current_topology(&self) -> Vec<PeerId> {
let network_identity = self.global_ctx.get_network_identity();
// Non-reusable credential owner election depends on reachability, so rebuild the
// route table from the latest synced peer/conn state before checking active peers.
self.update_route_table_and_cached_local_conn_bitmap();
let (untrusted, global_trusted_keys) = self
.synced_route_info
.verify_and_update_credential_trusts_with_active_peers(
network_identity.network_secret.as_deref(),
|peer_id| self.is_active_non_reusable_credential_peer(peer_id),
);
self.global_ctx
.update_trusted_keys(global_trusted_keys, &network_identity.network_name);
if !untrusted.is_empty() {
self.update_route_table_and_cached_local_conn_bitmap();
}
untrusted
}
async fn refresh_credential_trusts_and_disconnect(&self) -> bool {
let untrusted = self.refresh_credential_trusts();
let untrusted = self.refresh_credential_trusts_with_current_topology();
self.disconnect_untrusted_peers(&untrusted).await;
!untrusted.is_empty()
}
@@ -3298,9 +3478,7 @@ impl RouteSessionManager {
}
if need_update_route_table {
// Run credential verification and update route table
untrusted_peers = service_impl.refresh_credential_trusts();
service_impl.update_route_table_and_cached_local_conn_bitmap();
untrusted_peers = service_impl.refresh_credential_trusts_with_current_topology();
}
let mut foreign_network_changed = false;
@@ -3738,10 +3916,11 @@ mod tests {
time::{Duration, SystemTime},
};
use super::{PeerRoute, REMOVE_DEAD_PEER_INFO_AFTER, RouteConnInfo};
use super::{NextHopInfo, PeerRoute, REMOVE_DEAD_PEER_INFO_AFTER, RouteConnInfo};
use crate::{
common::{
PeerId,
config::NetworkIdentity,
global_ctx::{
GlobalCtxEvent, TrustedKeySource,
tests::{get_mock_global_ctx, get_mock_global_ctx_with_network},
@@ -3767,7 +3946,6 @@ mod tests {
tunnel::common::tests::wait_for_condition,
};
use prost::Message;
struct AuthOnlyInterface {
my_peer_id: PeerId,
identity_type: DashMap<PeerId, PeerIdentityType>,
@@ -3987,6 +4165,32 @@ mod tests {
)
}
fn make_credential_route_peer_info(
peer_id: PeerId,
noise_static_pubkey: &[u8],
) -> RoutePeerInfo {
let mut peer_info = RoutePeerInfo::new();
peer_info.peer_id = peer_id;
peer_info.version = 1;
peer_info.noise_static_pubkey = noise_static_pubkey.to_vec();
peer_info.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: true,
..Default::default()
});
peer_info
}
fn make_route_conn_info<I>(connected_peers: I, last_update: SystemTime) -> RouteConnInfo
where
I: IntoIterator<Item = PeerId>,
{
RouteConnInfo {
connected_peers: connected_peers.into_iter().collect(),
version: 1.into(),
last_update,
}
}
async fn create_mock_pmgr() -> Arc<PeerManager> {
let (s, _r) = create_packet_recv_chan();
let peer_mgr = Arc::new(PeerManager::new(
@@ -4331,6 +4535,393 @@ mod tests {
));
}
#[tokio::test]
async fn verify_trusted_credential_hmac_with_raw_payload_bytes() {
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
let network_secret = "sec1";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let credential_key = vec![7; 32];
let mut admin_info = RoutePeerInfo::new();
admin_info.peer_id = 30;
admin_info.version = 1;
let credential = TrustedCredentialPubkey {
pubkey: credential_key.clone(),
expiry_unix: now + 600,
reusable: Some(true),
..Default::default()
};
let mut raw_credential_bytes = credential.encode_to_vec();
prost::encoding::encode_key(
9999,
prost::encoding::WireType::Varint,
&mut raw_credential_bytes,
);
prost::encoding::encode_varint(42, &mut raw_credential_bytes);
let (admin_info, raw_admin_info) = make_route_info_with_raw_trusted_credential_proof(
&admin_info,
&raw_credential_bytes,
&TrustedCredentialPubkeyProof::generate_credential_hmac_from_bytes(
&raw_credential_bytes,
network_secret,
),
);
assert_eq!(admin_info.trusted_credential_pubkeys.len(), 1);
assert!(
!admin_info.trusted_credential_pubkeys[0].verify_credential_hmac(network_secret),
"typed verification should fail after nested unknown fields are dropped"
);
let mut credential_info = RoutePeerInfo::new();
credential_info.peer_id = 41;
credential_info.version = 1;
credential_info.noise_static_pubkey = credential_key.clone();
credential_info.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: true,
..Default::default()
});
let mut raw_credential_info = DynamicMessage::new(RoutePeerInfo::default().descriptor());
raw_credential_info
.transcode_from(&credential_info)
.unwrap();
{
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
.raw_peer_infos
.insert(30, raw_admin_info);
service_impl
.synced_route_info
.raw_peer_infos
.insert(41, raw_credential_info);
let (untrusted_peers, _) = service_impl
.synced_route_info
.verify_and_update_credential_trusts(Some(network_secret));
assert!(untrusted_peers.is_empty());
assert!(
service_impl
.synced_route_info
.trusted_credential_pubkeys
.contains_key(&credential_key)
);
}
#[tokio::test]
async fn non_reusable_credential_elects_lowest_peer_id() {
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
let network_secret = "sec1";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let credential_key = vec![7; 32];
let mut admin_info = RoutePeerInfo::new();
admin_info.peer_id = 30;
admin_info.version = 1;
admin_info.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: false,
..Default::default()
});
admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkeyProof::new_signed(
TrustedCredentialPubkey {
pubkey: credential_key.clone(),
expiry_unix: now + 600,
reusable: Some(false),
..Default::default()
},
network_secret,
)];
let mut original_peer = RoutePeerInfo::new();
original_peer.peer_id = 41;
original_peer.version = 1;
original_peer.noise_static_pubkey = credential_key.clone();
original_peer.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(original_peer.peer_id, original_peer);
}
let (first_untrusted, _) = service_impl
.synced_route_info
.verify_and_update_credential_trusts(Some(network_secret));
assert!(first_untrusted.is_empty());
assert_eq!(
service_impl
.synced_route_info
.non_reusable_credential_owners
.get(&credential_key)
.map(|entry| *entry.value()),
Some(41)
);
let mut new_peer = RoutePeerInfo::new();
new_peer.peer_id = 39;
new_peer.version = 1;
new_peer.noise_static_pubkey = credential_key.clone();
new_peer.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: true,
..Default::default()
});
service_impl
.synced_route_info
.peer_infos
.write()
.insert(new_peer.peer_id, new_peer);
service_impl
.synced_route_info
.non_reusable_credential_owners
.insert(credential_key.clone(), 41);
let (second_untrusted, _) = service_impl
.synced_route_info
.verify_and_update_credential_trusts(Some(network_secret));
assert_eq!(second_untrusted, vec![41]);
assert!(
!service_impl
.synced_route_info
.peer_infos
.read()
.contains_key(&41)
);
assert!(
service_impl
.synced_route_info
.peer_infos
.read()
.contains_key(&39)
);
assert_eq!(
service_impl
.synced_route_info
.non_reusable_credential_owners
.get(&credential_key)
.map(|entry| *entry.value()),
Some(39)
);
}
#[tokio::test]
async fn non_reusable_credential_ignores_unreachable_stale_owner() {
let service_impl = PeerRouteServiceImpl::new(1, get_mock_global_ctx());
let network_secret = "sec1";
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let credential_key = vec![8; 32];
let stale_peer_id = 41;
let replacement_peer_id = 39;
let mut admin_info = RoutePeerInfo::new();
admin_info.peer_id = 30;
admin_info.version = 1;
admin_info.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: false,
..Default::default()
});
admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkeyProof::new_signed(
TrustedCredentialPubkey {
pubkey: credential_key.clone(),
expiry_unix: now + 600,
reusable: Some(false),
..Default::default()
},
network_secret,
)];
let mut stale_peer = RoutePeerInfo::new();
stale_peer.peer_id = stale_peer_id;
stale_peer.version = 1;
stale_peer.noise_static_pubkey = credential_key.clone();
stale_peer.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: true,
..Default::default()
});
let mut replacement_peer = RoutePeerInfo::new();
replacement_peer.peer_id = replacement_peer_id;
replacement_peer.version = 1;
replacement_peer.noise_static_pubkey = credential_key.clone();
replacement_peer.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);
guard.insert(stale_peer.peer_id, stale_peer);
guard.insert(replacement_peer.peer_id, replacement_peer);
}
service_impl
.synced_route_info
.non_reusable_credential_owners
.insert(credential_key.clone(), stale_peer_id);
service_impl.route_table.next_hop_map.insert(
replacement_peer_id,
NextHopInfo {
next_hop_peer_id: replacement_peer_id,
path_latency: 0,
path_len: 1,
version: 1,
},
);
service_impl.route_table.next_hop_map_version.set(1);
let (untrusted_peers, _) = service_impl
.synced_route_info
.verify_and_update_credential_trusts_with_active_peers(
Some(network_secret),
|peer_id| service_impl.is_active_non_reusable_credential_peer(peer_id),
);
assert!(untrusted_peers.is_empty());
assert!(
service_impl
.synced_route_info
.peer_infos
.read()
.contains_key(&stale_peer_id)
);
assert!(
service_impl
.synced_route_info
.peer_infos
.read()
.contains_key(&replacement_peer_id)
);
assert_eq!(
service_impl
.synced_route_info
.non_reusable_credential_owners
.get(&credential_key)
.map(|entry| *entry.value()),
Some(replacement_peer_id)
);
}
#[tokio::test]
async fn credential_refresh_rebuilds_reachability_before_owner_election() {
const NETWORK_SECRET: &str = "sec1";
const SELF_PEER_ID: PeerId = 1;
let service_impl = PeerRouteServiceImpl::new(
SELF_PEER_ID,
get_mock_global_ctx_with_network(Some(NetworkIdentity::new(
"test-net".to_string(),
NETWORK_SECRET.to_string(),
))),
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let credential_key = vec![9; 32];
let admin_peer_id = 30;
let stale_peer_id = 41;
let replacement_peer_id = 39;
let mut self_info = RoutePeerInfo::new();
self_info.peer_id = SELF_PEER_ID;
self_info.version = 1;
let mut admin_info = RoutePeerInfo::new();
admin_info.peer_id = admin_peer_id;
admin_info.version = 1;
admin_info.feature_flag = Some(PeerFeatureFlag {
is_credential_peer: false,
..Default::default()
});
admin_info.trusted_credential_pubkeys = vec![TrustedCredentialPubkeyProof::new_signed(
TrustedCredentialPubkey {
pubkey: credential_key.clone(),
expiry_unix: now + 600,
reusable: Some(false),
..Default::default()
},
NETWORK_SECRET,
)];
let stale_peer = make_credential_route_peer_info(stale_peer_id, &credential_key);
let replacement_peer =
make_credential_route_peer_info(replacement_peer_id, &credential_key);
{
let mut guard = service_impl.synced_route_info.peer_infos.write();
guard.insert(self_info.peer_id, self_info);
guard.insert(admin_info.peer_id, admin_info);
guard.insert(stale_peer.peer_id, stale_peer);
guard.insert(replacement_peer.peer_id, replacement_peer);
}
let now = std::time::SystemTime::now();
{
let mut guard = service_impl.synced_route_info.conn_map.write();
guard.insert(SELF_PEER_ID, make_route_conn_info([admin_peer_id], now));
guard.insert(
admin_peer_id,
make_route_conn_info([SELF_PEER_ID, replacement_peer_id], now),
);
guard.insert(
replacement_peer_id,
make_route_conn_info([admin_peer_id], now),
);
guard.insert(stale_peer_id, make_route_conn_info([], now));
}
service_impl.synced_route_info.version.set(2);
service_impl.update_route_table_and_cached_local_conn_bitmap();
assert!(!service_impl.is_active_non_reusable_credential_peer(stale_peer_id));
assert!(service_impl.is_active_non_reusable_credential_peer(replacement_peer_id));
service_impl.route_table.next_hop_map.clear();
service_impl.route_table.next_hop_map.insert(
stale_peer_id,
NextHopInfo {
next_hop_peer_id: stale_peer_id,
path_latency: 0,
path_len: 1,
version: 1,
},
);
service_impl.route_table.next_hop_map_version.set(1);
let untrusted = service_impl.refresh_credential_trusts_with_current_topology();
assert!(untrusted.is_empty());
assert!(!service_impl.is_active_non_reusable_credential_peer(stale_peer_id));
assert!(service_impl.is_active_non_reusable_credential_peer(replacement_peer_id));
assert_eq!(
service_impl
.synced_route_info
.non_reusable_credential_owners
.get(&credential_key)
.map(|entry| *entry.value()),
Some(replacement_peer_id)
);
}
#[tokio::test]
async fn sync_route_info_marks_credential_sender_and_filters_entries() {
let peer_mgr = create_mock_pmgr().await;
@@ -5474,6 +6065,38 @@ mod tests {
.any(|w| w == unknown_bytes)
}
fn encode_length_delimited_field(field_number: u32, payload: &[u8], dst: &mut Vec<u8>) {
prost::encoding::encode_key(
field_number,
prost::encoding::WireType::LengthDelimited,
dst,
);
prost::encoding::encode_varint(payload.len() as u64, dst);
dst.extend_from_slice(payload);
}
fn make_route_info_with_raw_trusted_credential_proof(
info: &RoutePeerInfo,
raw_credential_bytes: &[u8],
credential_hmac: &[u8],
) -> (RoutePeerInfo, DynamicMessage) {
let mut proof_bytes = Vec::new();
encode_length_delimited_field(1, raw_credential_bytes, &mut proof_bytes);
encode_length_delimited_field(2, credential_hmac, &mut proof_bytes);
let mut route_info_bytes = info.encode_to_vec();
encode_length_delimited_field(19, &proof_bytes, &mut route_info_bytes);
let typed_info = RoutePeerInfo::decode(route_info_bytes.as_slice()).unwrap();
let raw_info = DynamicMessage::decode(
RoutePeerInfo::default().descriptor(),
route_info_bytes.as_slice(),
)
.unwrap();
(typed_info, raw_info)
}
#[tokio::test]
async fn sync_route_preserves_unknown_fields_for_credential_sender() {
let peer_mgr = create_mock_pmgr().await;
+2 -1
View File
@@ -234,12 +234,13 @@ impl CredentialManageRpc for PeerManagerRpcService {
let (id, secret) = global_ctx
.get_credential_manager()
.generate_credential_with_id(
.generate_credential_with_options(
request.groups,
request.allow_relay,
request.allowed_proxy_cidrs,
ttl,
request.credential_id,
request.reusable.unwrap_or(true),
);
global_ctx.issue_event(crate::common::global_ctx::GlobalCtxEvent::CredentialChanged);
+2
View File
@@ -318,6 +318,7 @@ message GenerateCredentialRequest {
int64 ttl_seconds = 4; // must be > 0: credential TTL in seconds (0 / omitted is invalid)
optional string credential_id = 5; // optional: user-specified credential id, reused if already exists
InstanceIdentifier instance = 6; // target network instance
optional bool reusable = 7; // default true: allow multiple peers to reuse this credential
}
message GenerateCredentialResponse {
@@ -344,6 +345,7 @@ message CredentialInfo {
bool allow_relay = 3;
int64 expiry_unix = 4;
repeated string allowed_proxy_cidrs = 5;
optional bool reusable = 6;
}
message ListCredentialsResponse {
+1
View File
@@ -11,6 +11,7 @@ message TrustedCredentialPubkey {
bool allow_relay = 3; // whether this credential node can relay data
int64 expiry_unix = 4; // expiry time (Unix timestamp)
repeated string allowed_proxy_cidrs = 5; // allowed proxy_cidrs ranges
optional bool reusable = 6; // whether multiple peers may use the same credential concurrently
}
message TrustedCredentialPubkeyProof {
+52 -4
View File
@@ -40,17 +40,24 @@ impl PeerGroupInfo {
}
impl TrustedCredentialPubkeyProof {
pub fn generate_credential_hmac(
credential: &TrustedCredentialPubkey,
pub fn generate_credential_hmac_from_bytes(
credential_bytes: &[u8],
network_secret: &str,
) -> Vec<u8> {
let mut mac = Hmac::<Sha256>::new_from_slice(network_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(b"easytier credential proof");
mac.update(&credential.encode_to_vec());
mac.update(credential_bytes);
mac.finalize().into_bytes().to_vec()
}
pub fn generate_credential_hmac(
credential: &TrustedCredentialPubkey,
network_secret: &str,
) -> Vec<u8> {
Self::generate_credential_hmac_from_bytes(&credential.encode_to_vec(), network_secret)
}
pub fn new_signed(credential: TrustedCredentialPubkey, network_secret: &str) -> Self {
let credential_hmac = Self::generate_credential_hmac(&credential, network_secret);
Self {
@@ -63,6 +70,14 @@ impl TrustedCredentialPubkeyProof {
let Some(credential) = self.credential.as_ref() else {
return false;
};
self.verify_credential_hmac_with_bytes(&credential.encode_to_vec(), network_secret)
}
pub fn verify_credential_hmac_with_bytes(
&self,
credential_bytes: &[u8],
network_secret: &str,
) -> bool {
if self.credential_hmac.is_empty() {
return false;
}
@@ -70,7 +85,7 @@ impl TrustedCredentialPubkeyProof {
let mut mac = Hmac::<Sha256>::new_from_slice(network_secret.as_bytes())
.expect("HMAC can take key of any size");
mac.update(b"easytier credential proof");
mac.update(&credential.encode_to_vec());
mac.update(credential_bytes);
mac.verify_slice(&self.credential_hmac).is_ok()
}
}
@@ -300,6 +315,7 @@ mod tests {
allow_relay: true,
expiry_unix: 123456,
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_string()],
reusable: Some(true),
};
let tc = TrustedCredentialPubkeyProof::new_signed(credential, "sec-1");
@@ -315,6 +331,7 @@ mod tests {
allow_relay: false,
expiry_unix: 1,
allowed_proxy_cidrs: vec![],
reusable: Some(true),
};
let tc = TrustedCredentialPubkeyProof::new_signed(credential, "sec-1");
@@ -322,4 +339,35 @@ mod tests {
tampered.credential.as_mut().unwrap().allow_relay = true;
assert!(!tampered.verify_credential_hmac("sec-1"));
}
#[test]
fn test_trusted_credential_pubkey_hmac_with_raw_bytes() {
let credential = TrustedCredentialPubkey {
pubkey: vec![9u8; 32],
groups: vec!["raw".to_string()],
allow_relay: true,
expiry_unix: 123456,
allowed_proxy_cidrs: vec![],
reusable: Some(true),
};
let mut raw_credential_bytes = credential.encode_to_vec();
prost::encoding::encode_key(
9999,
prost::encoding::WireType::Varint,
&mut raw_credential_bytes,
);
prost::encoding::encode_varint(42, &mut raw_credential_bytes);
let proof = TrustedCredentialPubkeyProof {
credential: Some(credential),
credential_hmac: TrustedCredentialPubkeyProof::generate_credential_hmac_from_bytes(
&raw_credential_bytes,
"sec-1",
),
};
assert!(proof.verify_credential_hmac_with_bytes(&raw_credential_bytes, "sec-1"));
assert!(!proof.verify_credential_hmac("sec-1"));
}
}
+501 -53
View File
@@ -23,12 +23,12 @@ use rstest::rstest;
/// Prepare network namespaces for credential tests
/// Topology:
/// br_a (10.1.1.0/24): ns_adm (10.1.1.1), ns_c1 (10.1.1.2), ns_c2 (10.1.1.3), ns_c3 (10.1.1.4)
/// br_a (10.1.1.0/24): ns_adm (10.1.1.1), ns_c1 (10.1.1.2), ns_c2 (10.1.1.3), ns_c3 (10.1.1.4), ns_c4 (10.1.1.5)
/// br_b (10.1.2.0/24): ns_adm2 (10.1.2.1) - for multi-admin tests
/// Note: Using short names (max 15 chars for veth interfaces)
pub fn prepare_credential_network() {
// Clean up any existing namespaces
for ns in ["ns_adm", "ns_c1", "ns_c2", "ns_c3", "ns_adm2"] {
for ns in ["ns_adm", "ns_c1", "ns_c2", "ns_c3", "ns_c4", "ns_adm2"] {
del_netns(ns);
}
@@ -61,6 +61,10 @@ pub fn prepare_credential_network() {
create_netns("ns_c3", "10.1.1.4/24", "fd11::4/64");
add_ns_to_bridge("br_a", "ns_c3");
// Create ns_c4 for multi-admin credential tests (needs 5 nodes)
create_netns("ns_c4", "10.1.1.5/24", "fd11::5/64");
add_ns_to_bridge("br_a", "ns_c4");
// Create bridge br_b for second admin (multi-admin tests)
let _ = std::process::Command::new("ip")
.args(["link", "del", "br_b"])
@@ -80,6 +84,38 @@ pub fn prepare_credential_network() {
add_ns_to_bridge("br_b", "ns_adm2");
}
fn credential_private_key_from_secret(credential_secret: &str) -> x25519_dalek::StaticSecret {
use base64::Engine as _;
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(credential_secret)
.unwrap()
.try_into()
.unwrap();
x25519_dalek::StaticSecret::from(privkey_bytes)
}
fn build_credential_config(
network_name: String,
private_key: &x25519_dalek::StaticSecret,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(network_name));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(private_key)));
config
}
/// Helper: Create credential node config with generated credential
async fn create_credential_config(
admin_inst: &Instance,
@@ -88,39 +124,41 @@ async fn create_credential_config(
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
use base64::Engine as _;
// Generate credential on admin
let (_cred_id, cred_secret) = admin_inst
.get_global_ctx()
.get_credential_manager()
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
// Decode private key
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
// Create config
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(
build_credential_config(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
&credential_private_key_from_secret(&cred_secret),
inst_name,
ns,
ipv4,
ipv6,
)
}
config
fn create_credential_config_from_secret(
network_name: String,
credential_secret: &str,
inst_name: &str,
ns: Option<&str>,
ipv4: &str,
ipv6: &str,
) -> TomlConfigLoader {
build_credential_config(
network_name,
&credential_private_key_from_secret(credential_secret),
inst_name,
ns,
ipv4,
ipv6,
)
}
/// Helper: Create credential node config with a random, unknown key
@@ -132,17 +170,7 @@ fn create_unknown_credential_config(
ipv6: &str,
) -> TomlConfigLoader {
let random_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(network_name));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&random_private)));
config
build_credential_config(network_name, &random_private, inst_name, ns, ipv4, ipv6)
}
/// Helper: Create admin node config
@@ -200,33 +228,22 @@ fn create_generated_credential_config(
ipv4: &str,
ipv6: &str,
) -> (TomlConfigLoader, String) {
use base64::Engine as _;
let (cred_id, cred_secret) = admin_inst
.get_global_ctx()
.get_credential_manager()
.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
let privkey_bytes: [u8; 32] = base64::prelude::BASE64_STANDARD
.decode(&cred_secret)
.unwrap()
.try_into()
.unwrap();
let private = x25519_dalek::StaticSecret::from(privkey_bytes);
let config = TomlConfigLoader::default();
config.set_inst_name(inst_name.to_owned());
config.set_netns(ns.map(|s| s.to_owned()));
config.set_ipv4(Some(ipv4.parse().unwrap()));
config.set_ipv6(Some(ipv6.parse().unwrap()));
config.set_listeners(vec![]);
config.set_network_identity(NetworkIdentity::new_credential(
let config = build_credential_config(
admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone(),
));
config.set_secure_mode(Some(generate_secure_mode_config_with_key(&private)));
&credential_private_key_from_secret(&cred_secret),
inst_name,
ns,
ipv4,
ipv6,
);
(config, cred_id)
}
@@ -311,6 +328,68 @@ async fn assert_shared_visibility_stable(
}
}
async fn wait_stable_single_visible_peer_on_admins(
admin_a_inst: &Instance,
admin_c_inst: &Instance,
peer_a_id: u32,
peer_b_id: u32,
timeout: Duration,
) -> u32 {
let start = std::time::Instant::now();
let mut stable_winner = None;
let mut stable_samples = 0;
loop {
let admin_a_routes = admin_a_inst.get_peer_manager().list_routes().await;
let admin_c_routes = admin_c_inst.get_peer_manager().list_routes().await;
let admin_a_has_a = admin_a_routes.iter().any(|r| r.peer_id == peer_a_id);
let admin_a_has_b = admin_a_routes.iter().any(|r| r.peer_id == peer_b_id);
let admin_c_has_a = admin_c_routes.iter().any(|r| r.peer_id == peer_a_id);
let admin_c_has_b = admin_c_routes.iter().any(|r| r.peer_id == peer_b_id);
let current_winner = if admin_a_has_a && admin_c_has_a && !admin_a_has_b && !admin_c_has_b {
Some(peer_a_id)
} else if admin_a_has_b && admin_c_has_b && !admin_a_has_a && !admin_c_has_a {
Some(peer_b_id)
} else {
None
};
println!(
"single-visible routes: a={:?} c={:?} winner={:?}",
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
current_winner
);
match current_winner {
Some(winner) if stable_winner == Some(winner) => stable_samples += 1,
Some(winner) => {
stable_winner = Some(winner);
stable_samples = 1;
}
None => {
stable_winner = None;
stable_samples = 0;
}
}
if stable_samples >= 3 {
return stable_winner.unwrap();
}
assert!(
start.elapsed() < timeout,
"timed out waiting for a stable single visible peer on both admins: a={:?} c={:?}",
admin_a_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>(),
admin_c_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
/// Test 1: Basic credential node connectivity
/// Topology: Admin ← Credential
/// Verifies that a credential node can connect to an admin node and appears in routes
@@ -884,6 +963,161 @@ async fn credential_revocation_propagates() {
drop_insts(vec![admin_inst, cred_inst]).await;
}
/// Test: A non-reusable credential only allows one peer at a time.
#[tokio::test]
#[serial_test::serial]
async fn credential_non_reusable_allows_only_one_peer() {
prepare_credential_network();
let admin_config = create_admin_config("admin", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
let mut admin_inst = Instance::new(admin_config);
admin_inst.run().await.unwrap();
let (_cred_id, cred_secret) = admin_inst
.get_global_ctx()
.get_credential_manager()
.generate_credential_with_options(
vec![],
false,
vec![],
Duration::from_secs(3600),
None,
false,
);
let network_name = admin_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone();
let cred1_config = create_credential_config_from_secret(
network_name.clone(),
&cred_secret,
"cred1_single",
Some("ns_c1"),
"10.144.144.2",
"fd00::2/64",
);
let cred2_config = create_credential_config_from_secret(
network_name,
&cred_secret,
"cred2_single",
Some("ns_c2"),
"10.144.144.3",
"fd00::3/64",
);
let mut cred1_inst = Some(Instance::new(cred1_config));
cred1_inst.as_mut().unwrap().run().await.unwrap();
cred1_inst
.as_ref()
.unwrap()
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.1:11010".parse().unwrap(),
));
let cred1_peer_id = cred1_inst.as_ref().unwrap().peer_id();
wait_for_condition(
|| async {
admin_inst
.get_peer_manager()
.list_routes()
.await
.iter()
.any(|r| r.peer_id == cred1_peer_id)
},
Duration::from_secs(10),
)
.await;
wait_ping_reachability("ns_adm", "10.144.144.2", true, Duration::from_secs(10)).await;
let mut cred2_inst = Some(Instance::new(cred2_config));
cred2_inst.as_mut().unwrap().run().await.unwrap();
cred2_inst
.as_ref()
.unwrap()
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.1:11010".parse().unwrap(),
));
let cred2_peer_id = cred2_inst.as_ref().unwrap().peer_id();
tokio::time::sleep(Duration::from_secs(3)).await;
// The non-reusable credential owner is elected by lowest peer_id, so either cred1 or cred2
// may win. Determine the winner and loser dynamically.
let admin_routes = admin_inst.get_peer_manager().list_routes().await;
let (winner_peer_id, winner_ip, winner_inst, loser_peer_id, loser_ip, loser_inst) =
if admin_routes.iter().any(|r| r.peer_id == cred1_peer_id) {
(
cred1_peer_id,
"10.144.144.2",
&mut cred1_inst,
cred2_peer_id,
"10.144.144.3",
&mut cred2_inst,
)
} else if admin_routes.iter().any(|r| r.peer_id == cred2_peer_id) {
(
cred2_peer_id,
"10.144.144.3",
&mut cred2_inst,
cred1_peer_id,
"10.144.144.2",
&mut cred1_inst,
)
} else {
panic!(
"neither credential peer is present in routes: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
};
for _ in 0..5 {
let admin_routes = admin_inst.get_peer_manager().list_routes().await;
assert!(
admin_routes.iter().any(|r| r.peer_id == winner_peer_id),
"winning credential peer should remain present: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
assert!(
ping_test("ns_adm", winner_ip, None).await,
"admin should still reach the winning credential peer"
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
for _ in 0..5 {
let admin_routes = admin_inst.get_peer_manager().list_routes().await;
assert!(
!admin_routes.iter().any(|r| r.peer_id == loser_peer_id),
"losing credential peer should not appear in routes: {:?}",
admin_routes.iter().map(|r| r.peer_id).collect::<Vec<_>>()
);
assert!(
!ping_test("ns_adm", loser_ip, None).await,
"admin should not reach the losing credential peer"
);
tokio::time::sleep(Duration::from_secs(1)).await;
}
drop_insts(vec![winner_inst.take().unwrap()]).await;
wait_for_condition(
|| async {
let routes = admin_inst.get_peer_manager().list_routes().await;
!routes.iter().any(|r| r.peer_id == winner_peer_id)
&& routes.iter().any(|r| r.peer_id == loser_peer_id)
},
Duration::from_secs(20),
)
.await;
wait_ping_reachability("ns_adm", loser_ip, true, Duration::from_secs(20)).await;
drop_insts(vec![admin_inst, loser_inst.take().unwrap()]).await;
}
/// Test 4: Unknown credential (not in trusted list) is rejected
/// Topology: Admin
/// Verifies that credential nodes with unknown/random keys cannot connect
@@ -1226,3 +1460,217 @@ async fn credential_admin_shared_admin_credential_connectivity(
drop_insts(vec![admin_a_inst, shared_b_inst, admin_c_inst, cred_d_inst]).await;
}
#[tokio::test]
#[serial_test::serial]
async fn credential_non_reusable_across_two_admins_allows_only_one_peer() {
prepare_credential_network();
let admin_a_config =
create_admin_config("admin_a", Some("ns_adm"), "10.144.144.1", "fd00::1/64");
let mut admin_a_inst = Instance::new(admin_a_config);
admin_a_inst.run().await.unwrap();
let shared_b_config =
create_shared_config("shared_b", Some("ns_c1"), "10.144.144.2", "fd00::2/64");
let mut shared_b_inst = Instance::new(shared_b_config);
shared_b_inst.run().await.unwrap();
let admin_c_config =
create_admin_config("admin_c", Some("ns_c3"), "10.144.144.4", "fd00::4/64");
let mut admin_c_inst = Instance::new(admin_c_config);
admin_c_inst.run().await.unwrap();
admin_a_inst
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.2:11010".parse().unwrap(),
));
admin_c_inst
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.2:11010".parse().unwrap(),
));
let admin_c_peer_id = admin_c_inst.peer_id();
wait_for_condition(
|| async {
let a_routes = admin_a_inst.get_peer_manager().list_routes().await;
let c_routes = admin_c_inst.get_peer_manager().list_routes().await;
a_routes.iter().any(|r| r.peer_id == admin_c_peer_id)
|| c_routes.iter().any(|r| r.peer_id == admin_a_inst.peer_id())
},
Duration::from_secs(10),
)
.await;
let (_cred_id, cred_secret) = admin_a_inst
.get_global_ctx()
.get_credential_manager()
.generate_credential_with_options(
vec![],
false,
vec![],
Duration::from_secs(3600),
None,
false,
);
admin_a_inst
.get_global_ctx()
.issue_event(GlobalCtxEvent::CredentialChanged);
let network_name = admin_a_inst
.get_global_ctx()
.get_network_identity()
.network_name
.clone();
let cred_left_config = create_credential_config_from_secret(
network_name.clone(),
&cred_secret,
"cred_left",
Some("ns_c2"),
"10.144.144.5",
"fd00::5/64",
);
let cred_right_config = create_credential_config_from_secret(
network_name,
&cred_secret,
"cred_right",
Some("ns_c4"),
"10.144.144.6",
"fd00::6/64",
);
let mut cred_left_inst = Some(Instance::new(cred_left_config));
cred_left_inst.as_mut().unwrap().run().await.unwrap();
cred_left_inst
.as_ref()
.unwrap()
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.1:11010".parse().unwrap(),
));
let mut cred_right_inst = Some(Instance::new(cred_right_config));
cred_right_inst.as_mut().unwrap().run().await.unwrap();
cred_right_inst
.as_ref()
.unwrap()
.get_conn_manager()
.add_connector(TcpTunnelConnector::new(
"tcp://10.1.1.4:11010".parse().unwrap(),
));
let cred_left_peer_id = cred_left_inst.as_ref().unwrap().peer_id();
let cred_right_peer_id = cred_right_inst.as_ref().unwrap().peer_id();
let winner_peer_id = wait_stable_single_visible_peer_on_admins(
&admin_a_inst,
&admin_c_inst,
cred_left_peer_id,
cred_right_peer_id,
Duration::from_secs(60),
)
.await;
let (winner_peer_id, winner_ip, loser_peer_id, loser_ip) =
if winner_peer_id == cred_left_peer_id {
(
cred_left_peer_id,
"10.144.144.5",
cred_right_peer_id,
"10.144.144.6",
)
} else {
(
cred_right_peer_id,
"10.144.144.6",
cred_left_peer_id,
"10.144.144.5",
)
};
wait_ping_reachability("ns_adm", winner_ip, true, Duration::from_secs(20)).await;
wait_ping_reachability("ns_c3", winner_ip, true, Duration::from_secs(20)).await;
wait_ping_reachability("ns_adm", loser_ip, false, Duration::from_secs(5)).await;
wait_ping_reachability("ns_c3", loser_ip, false, Duration::from_secs(5)).await;
assert_shared_visibility_stable(
&admin_a_inst,
&admin_c_inst,
winner_peer_id,
winner_ip,
true,
"winning credential",
)
.await;
assert_shared_visibility_stable(
&admin_a_inst,
&admin_c_inst,
loser_peer_id,
loser_ip,
false,
"losing credential",
)
.await;
if winner_peer_id == cred_left_peer_id {
drop_insts(vec![cred_left_inst.take().unwrap()]).await;
} else {
drop_insts(vec![cred_right_inst.take().unwrap()]).await;
}
wait_for_condition(
|| async {
let admin_a_routes = admin_a_inst.get_peer_manager().list_routes().await;
let admin_c_routes = admin_c_inst.get_peer_manager().list_routes().await;
admin_a_routes.iter().any(|r| r.peer_id == loser_peer_id)
&& admin_c_routes.iter().any(|r| r.peer_id == loser_peer_id)
&& !admin_a_routes.iter().any(|r| r.peer_id == winner_peer_id)
&& !admin_c_routes.iter().any(|r| r.peer_id == winner_peer_id)
},
Duration::from_secs(60),
)
.await;
wait_ping_reachability("ns_adm", loser_ip, true, Duration::from_secs(20)).await;
wait_ping_reachability("ns_c3", loser_ip, true, Duration::from_secs(20)).await;
wait_ping_reachability("ns_adm", winner_ip, false, Duration::from_secs(5)).await;
wait_ping_reachability("ns_c3", winner_ip, false, Duration::from_secs(5)).await;
assert_shared_visibility_stable(
&admin_a_inst,
&admin_c_inst,
loser_peer_id,
loser_ip,
true,
"failover credential",
)
.await;
assert_shared_visibility_stable(
&admin_a_inst,
&admin_c_inst,
winner_peer_id,
winner_ip,
false,
"removed winning credential",
)
.await;
if winner_peer_id == cred_left_peer_id {
drop_insts(vec![
admin_a_inst,
shared_b_inst,
admin_c_inst,
cred_right_inst.take().unwrap(),
])
.await;
} else {
drop_insts(vec![
admin_a_inst,
shared_b_inst,
admin_c_inst,
cred_left_inst.take().unwrap(),
])
.await;
}
}