mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-07 18:24:36 +00:00
feat(credential): implement credential peer auth and trust propagation (#1968)
- add credential manager and RPC/CLI for generate/list/revoke - support credential-based Noise authentication and revocation handling - propagate trusted credential metadata through OSPF route sync - classify direct peers by auth level in session maintenance - normalize sender credential flag for legacy non-secure compatibility - add unit/integration tests for credential join, relay and revocation
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
use crate::proto::peer_rpc::TrustedCredentialPubkey;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CredentialEntry {
|
||||
pubkey_bytes: Vec<u8>,
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
expiry_unix: i64,
|
||||
created_at_unix: i64,
|
||||
}
|
||||
|
||||
pub struct CredentialManager {
|
||||
credentials: Mutex<HashMap<String, CredentialEntry>>,
|
||||
storage_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CredentialManager {
|
||||
pub fn new(storage_path: Option<PathBuf>) -> Self {
|
||||
let mgr = CredentialManager {
|
||||
credentials: Mutex::new(HashMap::new()),
|
||||
storage_path,
|
||||
};
|
||||
mgr.load_from_disk();
|
||||
mgr
|
||||
}
|
||||
|
||||
pub fn generate_credential(
|
||||
&self,
|
||||
groups: Vec<String>,
|
||||
allow_relay: bool,
|
||||
allowed_proxy_cidrs: Vec<String>,
|
||||
ttl: Duration,
|
||||
) -> (String, String) {
|
||||
let private = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
||||
let public = PublicKey::from(&private);
|
||||
let id = BASE64_STANDARD.encode(public.as_bytes());
|
||||
let secret = BASE64_STANDARD.encode(private.as_bytes());
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let expiry_unix = now + ttl.as_secs() as i64;
|
||||
|
||||
let entry = CredentialEntry {
|
||||
pubkey_bytes: public.as_bytes().to_vec(),
|
||||
groups,
|
||||
allow_relay,
|
||||
allowed_proxy_cidrs,
|
||||
expiry_unix,
|
||||
created_at_unix: now,
|
||||
};
|
||||
|
||||
self.credentials.lock().unwrap().insert(id.clone(), entry);
|
||||
self.save_to_disk();
|
||||
(id, secret)
|
||||
}
|
||||
|
||||
pub fn revoke_credential(&self, credential_id: &str) -> bool {
|
||||
let removed = self
|
||||
.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(credential_id)
|
||||
.is_some();
|
||||
if removed {
|
||||
self.save_to_disk();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn get_trusted_pubkeys(&self) -> Vec<TrustedCredentialPubkey> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.expiry_unix > now)
|
||||
.map(|e| TrustedCredentialPubkey {
|
||||
pubkey: e.pubkey_bytes.clone(),
|
||||
groups: e.groups.clone(),
|
||||
allow_relay: e.allow_relay,
|
||||
expiry_unix: e.expiry_unix,
|
||||
allowed_proxy_cidrs: e.allowed_proxy_cidrs.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_pubkey_trusted(&self, pubkey: &[u8]) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
self.credentials
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|e| e.pubkey_bytes == pubkey && e.expiry_unix > 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;
|
||||
|
||||
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(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_to_disk(&self) {
|
||||
let Some(path) = &self.storage_path else {
|
||||
return;
|
||||
};
|
||||
let creds = self.credentials.lock().unwrap();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&*creds) {
|
||||
if let Err(e) = std::fs::write(path, json) {
|
||||
tracing::warn!(?e, "failed to save credentials to disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_disk(&self) {
|
||||
let Some(path) = &self.storage_path else {
|
||||
return;
|
||||
};
|
||||
let Ok(data) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
match serde_json::from_str::<HashMap<String, CredentialEntry>>(&data) {
|
||||
Ok(loaded) => {
|
||||
*self.credentials.lock().unwrap() = loaded;
|
||||
tracing::info!("loaded credentials from {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "failed to parse credentials file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_and_revoke() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, secret) = mgr.generate_credential(
|
||||
vec!["guest".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
assert!(!id.is_empty());
|
||||
assert!(!secret.is_empty());
|
||||
|
||||
let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert!(mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(trusted[0].groups, vec!["guest".to_string()]);
|
||||
|
||||
assert!(mgr.revoke_credential(&id));
|
||||
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_credential() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
// TTL of 0 seconds - immediately expired
|
||||
let (id, _) = mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0));
|
||||
|
||||
let pubkey_bytes = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert!(!mgr.is_pubkey_trusted(&pubkey_bytes));
|
||||
assert!(mgr.get_trusted_pubkeys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_credentials() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(
|
||||
vec!["a".to_string()],
|
||||
true,
|
||||
vec!["10.0.0.0/24".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keypair_validity() {
|
||||
// Verify the generated private key can derive the same public key
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, secret) =
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
let privkey_bytes: [u8; 32] = BASE64_STANDARD.decode(&secret).unwrap().try_into().unwrap();
|
||||
let private = StaticSecret::from(privkey_bytes);
|
||||
let derived_public = PublicKey::from(&private);
|
||||
let derived_id = BASE64_STANDARD.encode(derived_public.as_bytes());
|
||||
|
||||
assert_eq!(id, derived_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revoke_nonexistent() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
assert!(!mgr.revoke_credential("nonexistent_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_credentials_independent() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id1, _) = mgr.generate_credential(
|
||||
vec!["group1".to_string()],
|
||||
false,
|
||||
vec![],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
let (id2, _) = mgr.generate_credential(
|
||||
vec!["group2".to_string()],
|
||||
true,
|
||||
vec!["10.0.0.0/8".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
|
||||
let pk1 = BASE64_STANDARD.decode(&id1).unwrap();
|
||||
let pk2 = BASE64_STANDARD.decode(&id2).unwrap();
|
||||
|
||||
assert!(mgr.is_pubkey_trusted(&pk1));
|
||||
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||
|
||||
// Revoke first, second should still be trusted
|
||||
mgr.revoke_credential(&id1);
|
||||
assert!(!mgr.is_pubkey_trusted(&pk1));
|
||||
assert!(mgr.is_pubkey_trusted(&pk2));
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
assert_eq!(trusted[0].groups, vec!["group2".to_string()]);
|
||||
assert!(trusted[0].allow_relay);
|
||||
assert_eq!(
|
||||
trusted[0].allowed_proxy_cidrs,
|
||||
vec!["10.0.0.0/8".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_pubkeys_include_metadata() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
let (id, _) = mgr.generate_credential(
|
||||
vec!["admin".to_string(), "ops".to_string()],
|
||||
true,
|
||||
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()],
|
||||
Duration::from_secs(7200),
|
||||
);
|
||||
|
||||
let trusted = mgr.get_trusted_pubkeys();
|
||||
assert_eq!(trusted.len(), 1);
|
||||
let tc = &trusted[0];
|
||||
assert_eq!(tc.groups, vec!["admin".to_string(), "ops".to_string()]);
|
||||
assert!(tc.allow_relay);
|
||||
assert_eq!(
|
||||
tc.allowed_proxy_cidrs,
|
||||
vec!["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()]
|
||||
);
|
||||
assert!(tc.expiry_unix > 0);
|
||||
|
||||
let pk = BASE64_STANDARD.decode(&id).unwrap();
|
||||
assert_eq!(tc.pubkey, pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_pubkey_not_trusted() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
|
||||
let random_key = [42u8; 32];
|
||||
assert!(!mgr.is_pubkey_trusted(&random_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("creds.json");
|
||||
|
||||
// Create and save
|
||||
{
|
||||
let mgr = CredentialManager::new(Some(path.clone()));
|
||||
mgr.generate_credential(
|
||||
vec!["persist_group".to_string()],
|
||||
true,
|
||||
vec!["10.0.0.0/24".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
assert_eq!(mgr.list_credentials().len(), 1);
|
||||
}
|
||||
|
||||
// Load from disk
|
||||
{
|
||||
let mgr = CredentialManager::new(Some(path));
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].groups, vec!["persist_group".to_string()]);
|
||||
assert!(list[0].allow_relay);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_credentials_filters_expired() {
|
||||
let mgr = CredentialManager::new(None);
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(3600));
|
||||
mgr.generate_credential(vec![], false, vec![], Duration::from_secs(0)); // expired
|
||||
|
||||
let list = mgr.list_credentials();
|
||||
assert_eq!(list.len(), 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user