From c64dbd19d560b588c6b28c3b1876a0195951b4ff Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Sun, 7 Jun 2026 14:21:14 +0000 Subject: [PATCH] feat(core): implement Identity, IdentityProvider trait, and ConfigIdentityProvider Add Identity struct with id/scopes/resources fields and IdentityProvider trait with resolve_from_fingerprint/resolve_from_token methods. Implement ConfigIdentityProvider reading from ArcSwap for fingerprint-based key lookups. Delegate ServerHandler::auth_publickey() through IdentityProvider instead of direct AuthPolicy access. Store authenticated Identity in the handler for use by ForwardingPolicy. --- crates/alknet-core/src/auth/identity.rs | 196 ++++++++++++++++++ crates/alknet-core/src/auth/mod.rs | 2 + .../alknet-core/src/config/dynamic_config.rs | 22 ++ crates/alknet-core/src/lib.rs | 1 + crates/alknet-core/src/server/handler.rs | 32 ++- 5 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 crates/alknet-core/src/auth/identity.rs diff --git a/crates/alknet-core/src/auth/identity.rs b/crates/alknet-core/src/auth/identity.rs new file mode 100644 index 0000000..4f39997 --- /dev/null +++ b/crates/alknet-core/src/auth/identity.rs @@ -0,0 +1,196 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::config::DynamicConfig; + +#[derive(Debug, Clone, PartialEq)] +pub struct Identity { + pub id: String, + pub scopes: Vec, + pub resources: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct AuthToken { + pub raw: Vec, +} + +pub trait IdentityProvider: Send + Sync + 'static { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option; + + fn resolve_from_token(&self, token: &AuthToken) -> Option; +} + +pub struct ConfigIdentityProvider { + dynamic: Arc>, +} + +impl ConfigIdentityProvider { + pub fn new(dynamic: Arc>) -> Self { + Self { dynamic } + } +} + +impl IdentityProvider for ConfigIdentityProvider { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option { + let config = self.dynamic.load(); + let auth = &config.auth; + auth.resolve_identity_from_fingerprint(fingerprint) + } + + fn resolve_from_token(&self, _token: &AuthToken) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::keys::KeySource; + use crate::auth::ServerAuthConfig; + use crate::config::AuthPolicy; + use russh::keys::ssh_key::HashAlg; + use russh::keys::PrivateKey; + use std::io::Write; + + const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n"; + + const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096"; + + fn load_key() -> PrivateKey { + russh::keys::decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap() + } + + fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(keys_content.as_bytes()).unwrap(); + f.flush().unwrap(); + f + } + + fn make_provider(keys_content: &str) -> (ConfigIdentityProvider, Arc>) { + let f = make_authorized_keys_file(keys_content); + let server_auth = + ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None) + .unwrap(); + let auth_policy = AuthPolicy::from_server_auth_config(server_auth); + let dynamic = DynamicConfig::new(auth_policy); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap)); + (provider, arc_swap) + } + + #[test] + fn identity_fields() { + let mut resources = HashMap::new(); + resources.insert( + "service".to_string(), + vec!["gitea".to_string(), "registry".to_string()], + ); + let identity = Identity { + id: "SHA256:abc123".to_string(), + scopes: vec![ + "relay:connect".to_string(), + "service:gitea:read".to_string(), + ], + resources, + }; + assert_eq!(identity.id, "SHA256:abc123"); + assert_eq!(identity.scopes, vec!["relay:connect", "service:gitea:read"]); + assert_eq!( + identity.resources.get("service").unwrap(), + &vec!["gitea".to_string(), "registry".to_string()] + ); + } + + #[test] + fn identity_equality() { + let id1 = Identity { + id: "test".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: HashMap::new(), + }; + let id2 = Identity { + id: "test".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: HashMap::new(), + }; + assert_eq!(id1, id2); + } + + #[test] + fn identity_inequality_different_id() { + let id1 = Identity { + id: "a".to_string(), + scopes: vec![], + resources: HashMap::new(), + }; + let id2 = Identity { + id: "b".to_string(), + scopes: vec![], + resources: HashMap::new(), + }; + assert_ne!(id1, id2); + } + + #[test] + fn config_identity_provider_resolves_valid_fingerprint() { + let (provider, _) = make_provider(ED25519_PUBLIC_KEY); + let key = load_key().public_key().clone(); + let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256)); + let identity = provider.resolve_from_fingerprint(&fingerprint); + assert!(identity.is_some()); + let identity = identity.unwrap(); + assert_eq!(identity.id, fingerprint); + assert!(!identity.scopes.is_empty()); + } + + #[test] + fn config_identity_provider_rejects_invalid_fingerprint() { + let (provider, _) = make_provider(ED25519_PUBLIC_KEY); + let identity = provider.resolve_from_fingerprint("SHA256:invalid"); + assert!(identity.is_none()); + } + + #[test] + fn config_identity_provider_empty_config_rejects_all() { + let dynamic = DynamicConfig::default(); + let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic))); + let provider = ConfigIdentityProvider::new(arc_swap); + let identity = provider.resolve_from_fingerprint("SHA256:anything"); + assert!(identity.is_none()); + } + + #[test] + fn config_identity_provider_resolve_from_token_returns_none() { + let (provider, _) = make_provider(ED25519_PUBLIC_KEY); + let token = AuthToken { + raw: b"test-token".to_vec(), + }; + assert!(provider.resolve_from_token(&token).is_none()); + } + + #[test] + fn auth_token_holds_raw_bytes() { + let token = AuthToken { raw: vec![1, 2, 3] }; + assert_eq!(token.raw, vec![1, 2, 3]); + } + + #[test] + fn config_identity_provider_reflects_config_reload() { + let (provider, arc_swap) = make_provider(ED25519_PUBLIC_KEY); + let key = load_key().public_key().clone(); + let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256)); + + let identity = provider.resolve_from_fingerprint(&fingerprint); + assert!(identity.is_some()); + + let new_dynamic = DynamicConfig::default(); + arc_swap.store(Arc::new(new_dynamic)); + + let identity = provider.resolve_from_fingerprint(&fingerprint); + assert!(identity.is_none()); + } +} diff --git a/crates/alknet-core/src/auth/mod.rs b/crates/alknet-core/src/auth/mod.rs index 19680dd..7f9e28b 100644 --- a/crates/alknet-core/src/auth/mod.rs +++ b/crates/alknet-core/src/auth/mod.rs @@ -4,9 +4,11 @@ //! See ADR-012 for the design rationale. pub mod client_auth; +pub mod identity; pub mod keys; pub mod server_auth; pub use client_auth::{ClientAuthConfig, ClientHandler}; +pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource}; pub use server_auth::ServerAuthConfig; diff --git a/crates/alknet-core/src/config/dynamic_config.rs b/crates/alknet-core/src/config/dynamic_config.rs index bdb8130..0a67236 100644 --- a/crates/alknet-core/src/config/dynamic_config.rs +++ b/crates/alknet-core/src/config/dynamic_config.rs @@ -1,13 +1,17 @@ +use std::collections::HashMap; use std::sync::Arc; use arc_swap::ArcSwap; +use russh::keys::ssh_key::HashAlg; +use crate::auth::identity::Identity; use crate::auth::ServerAuthConfig; pub struct AuthPolicy { pub authorized_keys: std::collections::HashSet, pub cert_authorities: Vec, encoded_keys: std::collections::HashSet>, + fingerprint_to_key: HashMap, } fn encode_key_data(key: &russh::keys::PublicKey) -> Vec { @@ -21,11 +25,16 @@ impl AuthPolicy { cert_authorities: Vec, ) -> Self { let encoded_keys = authorized_keys.iter().map(encode_key_data).collect(); + let fingerprint_to_key = authorized_keys + .iter() + .map(|k| (format!("{}", k.fingerprint(HashAlg::Sha256)), k.clone())) + .collect(); Self { authorized_keys, cert_authorities, encoded_keys, + fingerprint_to_key, } } @@ -37,6 +46,18 @@ impl AuthPolicy { Self::new(std::collections::HashSet::new(), Vec::new()) } + pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option { + if self.fingerprint_to_key.contains_key(fingerprint) { + Some(Identity { + id: fingerprint.to_string(), + scopes: vec!["relay:connect".to_string()], + resources: HashMap::new(), + }) + } else { + None + } + } + pub fn authenticate_publickey( &self, key: &russh::keys::PublicKey, @@ -186,6 +207,7 @@ impl Clone for AuthPolicy { authorized_keys: self.authorized_keys.clone(), cert_authorities: self.cert_authorities.clone(), encoded_keys: self.encoded_keys.clone(), + fingerprint_to_key: self.fingerprint_to_key.clone(), } } } diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 6566828..948ac20 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -61,6 +61,7 @@ pub mod transport; #[cfg(feature = "testutil")] pub mod testutil; +pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use client::channel_manager::{ChannelManager, ForwardRequest}; pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode}; pub use config::{ diff --git a/crates/alknet-core/src/server/handler.rs b/crates/alknet-core/src/server/handler.rs index 23ce48c..5b5e311 100644 --- a/crates/alknet-core/src/server/handler.rs +++ b/crates/alknet-core/src/server/handler.rs @@ -9,6 +9,7 @@ use russh::server::{Auth, Handler, Msg, Session}; use russh::Channel; use russh::ChannelId; +use crate::auth::identity::{ConfigIdentityProvider, Identity, IdentityProvider}; use crate::config::DynamicConfig; use crate::server::control_channel::{ControlChannelHandler, ControlChannelRouter, ALKNET_PREFIX}; use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter}; @@ -43,7 +44,7 @@ impl std::fmt::Display for TransportKind { } pub struct ServerHandler { - dynamic: Arc>, + identity_provider: Box, #[allow(dead_code)] outbound_proxy: Option, remote_addr: Option, @@ -54,6 +55,7 @@ pub struct ServerHandler { connection_allowed: bool, auth_limiter: AuthAttemptLimiter, connected_at: Instant, + authenticated_identity: Option, } impl ServerHandler { @@ -65,6 +67,9 @@ impl ServerHandler { connection_limiter: Arc, max_auth_attempts: usize, ) -> Self { + let identity_provider: Box = + Box::new(ConfigIdentityProvider::new(Arc::clone(&dynamic))); + let allowed = if let Some(addr) = remote_addr { let ip = addr.ip(); if connection_limiter.check(ip) { @@ -88,7 +93,7 @@ impl ServerHandler { }; Self { - dynamic, + identity_provider, outbound_proxy, remote_addr, control_channel_router: ControlChannelRouter::without_handler(), @@ -97,9 +102,19 @@ impl ServerHandler { connection_allowed: allowed, auth_limiter: AuthAttemptLimiter::new(max_auth_attempts), connected_at: Instant::now(), + authenticated_identity: None, } } + pub fn with_identity_provider(mut self, provider: Box) -> Self { + self.identity_provider = provider; + self + } + + pub fn authenticated_identity(&self) -> Option<&Identity> { + self.authenticated_identity.as_ref() + } + pub fn is_connection_allowed(&self) -> bool { self.connection_allowed } @@ -167,12 +182,13 @@ impl Handler for ServerHandler { .remote_addr .map_or("unknown".to_string(), |a| a.to_string()); - let russh_pub = russh::keys::PublicKey::new(public_key.key_data().clone(), user); - let auth_config = self.dynamic.load(); - let result = auth_config.auth.authenticate_publickey(&russh_pub); + let identity = self + .identity_provider + .resolve_from_fingerprint(&fingerprint); - match result { - Ok(()) => { + match identity { + Some(id) => { + self.authenticated_identity = Some(id); tracing::info!( remote_addr = %remote_addr_display, user = user, @@ -182,7 +198,7 @@ impl Handler for ServerHandler { ); Ok(Auth::Accept) } - Err(_) => { + None => { self.auth_limiter.on_failure(); tracing::info!( remote_addr = %remote_addr_display,