From 85f798f611abbad66b63d9efbad5beea7a68951b Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Sun, 7 Jun 2026 14:42:12 +0000 Subject: [PATCH] Implement AuthProtocol irpc service behind feature flag Add AuthProtocol enum (VerifyPubkey, VerifyToken, ReloadKeys, CheckAccess), AuthResult enum (Ok(Identity), Denied(String)), and AuthServiceImpl wrapping ConfigIdentityProvider via ArcSwap. All gated behind the irpc feature flag per ADR-028. --- crates/alknet-core/src/auth/auth_protocol.rs | 262 +++++++++++++++++++ crates/alknet-core/src/auth/mod.rs | 4 + crates/alknet-core/src/lib.rs | 3 + 3 files changed, 269 insertions(+) create mode 100644 crates/alknet-core/src/auth/auth_protocol.rs diff --git a/crates/alknet-core/src/auth/auth_protocol.rs b/crates/alknet-core/src/auth/auth_protocol.rs new file mode 100644 index 0000000..c1c6181 --- /dev/null +++ b/crates/alknet-core/src/auth/auth_protocol.rs @@ -0,0 +1,262 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use crate::auth::identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; +use crate::config::DynamicConfig; + +#[derive(Debug, Clone, PartialEq)] +pub enum AuthProtocol { + VerifyPubkey { + fingerprint: String, + key_data: Vec, + }, + VerifyToken { + token_bytes: Vec, + timestamp: u64, + }, + ReloadKeys, + CheckAccess { + identity: Identity, + operation: String, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AuthResult { + Ok(Identity), + Denied(String), +} + +pub struct AuthServiceImpl { + provider: ConfigIdentityProvider, + dynamic: Arc>, +} + +impl AuthServiceImpl { + pub fn new(dynamic: Arc>) -> Self { + let provider = ConfigIdentityProvider::new(Arc::clone(&dynamic)); + Self { provider, dynamic } + } + + pub fn verify_pubkey(&self, fingerprint: &str) -> AuthResult { + match self.provider.resolve_from_fingerprint(fingerprint) { + Some(identity) => AuthResult::Ok(identity), + None => AuthResult::Denied(format!("key not authorized: {}", fingerprint)), + } + } + + pub fn verify_token(&self, token: &AuthToken) -> AuthResult { + match self.provider.resolve_from_token(token) { + Some(identity) => AuthResult::Ok(identity), + None => AuthResult::Denied("token verification failed".to_string()), + } + } + + pub fn reload_keys(&self) { + self.dynamic.rcu(Arc::clone); + } + + pub fn check_access(&self, identity: &Identity, operation: &str) -> AuthResult { + if identity.scopes.iter().any(|s| s == operation) { + AuthResult::Ok(identity.clone()) + } else { + AuthResult::Denied(format!( + "identity {} lacks scope: {}", + identity.id, operation + )) + } + } +} + +impl std::fmt::Debug for AuthServiceImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuthServiceImpl").finish() + } +} + +#[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_service(keys_content: &str) -> (AuthServiceImpl, 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 service = AuthServiceImpl::new(Arc::clone(&arc_swap)); + (service, arc_swap) + } + + #[test] + fn auth_service_verify_pubkey_valid() { + let (service, _) = make_service(ED25519_PUBLIC_KEY); + let key = load_key().public_key().clone(); + let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256)); + let result = service.verify_pubkey(&fingerprint); + assert!(matches!(result, AuthResult::Ok(_))); + if let AuthResult::Ok(identity) = result { + assert_eq!(identity.id, fingerprint); + } + } + + #[test] + fn auth_service_verify_pubkey_invalid() { + let (service, _) = make_service(ED25519_PUBLIC_KEY); + let result = service.verify_pubkey("SHA256:invalid"); + assert!(matches!(result, AuthResult::Denied(_))); + } + + #[test] + fn auth_service_verify_pubkey_matches_identity_provider() { + let (service, arc_swap) = make_service(ED25519_PUBLIC_KEY); + let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap)); + let key = load_key().public_key().clone(); + let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256)); + + let service_result = service.verify_pubkey(&fingerprint); + let provider_result = provider.resolve_from_fingerprint(&fingerprint); + + match service_result { + AuthResult::Ok(identity) => { + assert_eq!(identity, provider_result.unwrap()); + } + AuthResult::Denied(_) => { + assert!(provider_result.is_none()); + } + } + } + + #[test] + fn auth_service_verify_token_returns_denied() { + let (service, _) = make_service(ED25519_PUBLIC_KEY); + let token = AuthToken { + raw: b"test-token".to_vec(), + }; + let result = service.verify_token(&token); + assert!(matches!(result, AuthResult::Denied(_))); + } + + #[test] + fn auth_service_check_access_granted() { + let (service, _) = make_service(ED25519_PUBLIC_KEY); + let key = load_key().public_key().clone(); + let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256)); + let identity = Identity { + id: fingerprint, + scopes: vec!["relay:connect".to_string()], + resources: std::collections::HashMap::new(), + }; + let result = service.check_access(&identity, "relay:connect"); + assert!(matches!(result, AuthResult::Ok(_))); + } + + #[test] + fn auth_service_check_access_denied() { + let (service, _) = make_service(ED25519_PUBLIC_KEY); + let identity = Identity { + id: "test".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: std::collections::HashMap::new(), + }; + let result = service.check_access(&identity, "admin:write"); + assert!(matches!(result, AuthResult::Denied(_))); + } + + #[test] + fn auth_protocol_variants() { + let identity = Identity { + id: "SHA256:abc".to_string(), + scopes: vec!["relay:connect".to_string()], + resources: std::collections::HashMap::new(), + }; + + let verify_pubkey = AuthProtocol::VerifyPubkey { + fingerprint: "SHA256:abc".to_string(), + key_data: vec![1, 2, 3], + }; + match &verify_pubkey { + AuthProtocol::VerifyPubkey { + fingerprint, + key_data, + } => { + assert_eq!(fingerprint, "SHA256:abc"); + assert_eq!(key_data, &vec![1, 2, 3]); + } + _ => panic!("expected VerifyPubkey variant"), + } + + let verify_token = AuthProtocol::VerifyToken { + token_bytes: vec![4, 5, 6], + timestamp: 12345, + }; + match &verify_token { + AuthProtocol::VerifyToken { + token_bytes, + timestamp, + } => { + assert_eq!(token_bytes, &vec![4, 5, 6]); + assert_eq!(*timestamp, 12345); + } + _ => panic!("expected VerifyToken variant"), + } + + assert!(matches!(AuthProtocol::ReloadKeys, AuthProtocol::ReloadKeys)); + + let check = AuthProtocol::CheckAccess { + identity: identity.clone(), + operation: "relay:connect".to_string(), + }; + match &check { + AuthProtocol::CheckAccess { + identity: id, + operation, + } => { + assert_eq!(id.id, "SHA256:abc"); + assert_eq!(operation, "relay:connect"); + } + _ => panic!("expected CheckAccess variant"), + } + } + + #[test] + fn auth_result_ok_identity() { + let identity = Identity { + id: "test".to_string(), + scopes: vec![], + resources: std::collections::HashMap::new(), + }; + let result = AuthResult::Ok(identity.clone()); + assert_eq!(result, AuthResult::Ok(identity)); + } + + #[test] + fn auth_result_denied_message() { + let result = AuthResult::Denied("access denied".to_string()); + assert_eq!(result, AuthResult::Denied("access denied".to_string())); + } +} diff --git a/crates/alknet-core/src/auth/mod.rs b/crates/alknet-core/src/auth/mod.rs index 7f9e28b..b716c6e 100644 --- a/crates/alknet-core/src/auth/mod.rs +++ b/crates/alknet-core/src/auth/mod.rs @@ -3,11 +3,15 @@ //! Supports file-path and in-memory key sources. No password authentication. //! See ADR-012 for the design rationale. +#[cfg(feature = "irpc")] +pub mod auth_protocol; pub mod client_auth; pub mod identity; pub mod keys; pub mod server_auth; +#[cfg(feature = "irpc")] +pub use auth_protocol::{AuthProtocol, AuthResult, AuthServiceImpl}; pub use client_auth::{ClientAuthConfig, ClientHandler}; pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource}; diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index d497f25..5b27570 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -27,6 +27,7 @@ //! | `tls` | yes | TLS transport via `tokio-rustls` | //! | `iroh` | yes | iroh QUIC P2P transport | //! | `acme` | no | ACME/Let's Encrypt auto-cert provisioning (implies `tls`) | +//! | `irpc` | no | irpc service layer (AuthProtocol, AuthServiceImpl) | //! | `testutil` | no | Test utilities (for internal use) | //! //! # Quick example @@ -61,6 +62,8 @@ pub mod transport; #[cfg(feature = "testutil")] pub mod testutil; +#[cfg(feature = "irpc")] +pub use auth::{AuthProtocol, AuthResult, AuthServiceImpl}; pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use client::channel_manager::{ChannelManager, ForwardRequest}; pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};