diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index f18a131e..14357802 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -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>, + #[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, allow_relay: bool, allowed_proxy_cidrs: Vec, + 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?; } diff --git a/easytier/src/peers/credential_manager.rs b/easytier/src/peers/credential_manager.rs index c003711e..bc5988dd 100644 --- a/easytier/src/peers/credential_manager.rs +++ b/easytier/src/peers/credential_manager.rs @@ -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, allow_relay: bool, allowed_proxy_cidrs: Vec, + #[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 { + 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>, storage_path: Option, @@ -46,7 +90,14 @@ impl CredentialManager { allowed_proxy_cidrs: Vec, 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, ttl: Duration, credential_id: Option, + ) -> (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, + allow_relay: bool, + allowed_proxy_cidrs: Vec, + ttl: Duration, + credential_id: Option, + 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, allow_relay: bool, allowed_proxy_cidrs: Vec, + 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 { - 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 { - 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)); } } diff --git a/easytier/src/peers/peer_ospf_route.rs b/easytier/src/peers/peer_ospf_route.rs index 9a71fa90..e857f0e3 100644 --- a/easytier/src/peers/peer_ospf_route.rs +++ b/easytier/src/peers/peer_ospf_route.rs @@ -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> { + 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); @@ -401,6 +415,9 @@ struct SyncedRouteInfo { // Aggregated trusted credential pubkeys from all admin nodes // Maps pubkey bytes -> TrustedCredentialPubkey trusted_credential_pubkeys: DashMap, TrustedCredentialPubkey>, + // Tracks the currently accepted peer for non-reusable credentials. + // Maps credential pubkey bytes -> peer_id. + non_reusable_credential_owners: DashMap, 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, + network_secret: Option<&str>, + now: i64, + ) -> ( + HashMap, TrustedCredentialPubkey>, + HashMap, 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, TrustedCredentialPubkey>, + ) -> HashSet> { + 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( + &self, + peer_infos: &OrderedHashMap, + all_trusted: &HashMap, TrustedCredentialPubkey>, + mut is_peer_active: F, + ) -> (HashMap, PeerId>, BTreeSet) + where + F: FnMut(PeerId) -> bool, + { + let mut candidates: BTreeMap, BTreeSet> = 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, 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, + all_trusted: &HashMap, 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, + prev_trusted: &HashSet>, + all_trusted: &HashMap, TrustedCredentialPubkey>, + ) -> BTreeSet { + 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>(&self, peer_id: PeerId) -> Option { self.conn_map .read() @@ -967,110 +1190,37 @@ impl SyncedRouteInfo { Vec, HashMap, 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( + &self, + network_secret: Option<&str>, + is_peer_active: F, + ) -> ( + Vec, + HashMap, 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, TrustedCredentialPubkey> = HashMap::new(); - // Also collect all peer pubkeys for GlobalCtx synchronization - let mut global_trusted_keys: HashMap, 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> = 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 { + 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, @@ -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(connected_peers: I, last_update: SystemTime) -> RouteConnInfo + where + I: IntoIterator, + { + RouteConnInfo { + connected_peers: connected_peers.into_iter().collect(), + version: 1.into(), + last_update, + } + } + async fn create_mock_pmgr() -> Arc { 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) { + 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; diff --git a/easytier/src/peers/rpc_service.rs b/easytier/src/peers/rpc_service.rs index 7a95cfa8..44e3dd36 100644 --- a/easytier/src/peers/rpc_service.rs +++ b/easytier/src/peers/rpc_service.rs @@ -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); diff --git a/easytier/src/proto/api_instance.proto b/easytier/src/proto/api_instance.proto index 5f5f2460..41d6ec80 100644 --- a/easytier/src/proto/api_instance.proto +++ b/easytier/src/proto/api_instance.proto @@ -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 { diff --git a/easytier/src/proto/peer_rpc.proto b/easytier/src/proto/peer_rpc.proto index f3dfde6c..79ea35e6 100644 --- a/easytier/src/proto/peer_rpc.proto +++ b/easytier/src/proto/peer_rpc.proto @@ -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 { diff --git a/easytier/src/proto/peer_rpc.rs b/easytier/src/proto/peer_rpc.rs index f60a72a4..aa3d091e 100644 --- a/easytier/src/proto/peer_rpc.rs +++ b/easytier/src/proto/peer_rpc.rs @@ -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 { let mut mac = Hmac::::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 { + 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::::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")); + } } diff --git a/easytier/src/tests/credential_tests.rs b/easytier/src/tests/credential_tests.rs index 989e2aa6..235b0732 100644 --- a/easytier/src/tests/credential_tests.rs +++ b/easytier/src/tests/credential_tests.rs @@ -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::>(), + admin_c_routes.iter().map(|r| r.peer_id).collect::>(), + 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::>(), + admin_c_routes.iter().map(|r| r.peer_id).collect::>() + ); + + 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::>() + ); + }; + + 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::>() + ); + 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::>() + ); + 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; + } +}