mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-06 17:59:11 +00:00
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:
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user