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<DynamicConfig>. All gated
behind the irpc feature flag per ADR-028.
This commit is contained in:
2026-06-07 14:42:12 +00:00
parent 92a307fd03
commit 85f798f611
3 changed files with 269 additions and 0 deletions

View File

@@ -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<u8>,
},
VerifyToken {
token_bytes: Vec<u8>,
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<ArcSwap<DynamicConfig>>,
}
impl AuthServiceImpl {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> 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<ArcSwap<DynamicConfig>>) {
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()));
}
}

View File

@@ -3,11 +3,15 @@
//! Supports file-path and in-memory key sources. No password authentication. //! Supports file-path and in-memory key sources. No password authentication.
//! See ADR-012 for the design rationale. //! See ADR-012 for the design rationale.
#[cfg(feature = "irpc")]
pub mod auth_protocol;
pub mod client_auth; pub mod client_auth;
pub mod identity; pub mod identity;
pub mod keys; pub mod keys;
pub mod server_auth; pub mod server_auth;
#[cfg(feature = "irpc")]
pub use auth_protocol::{AuthProtocol, AuthResult, AuthServiceImpl};
pub use client_auth::{ClientAuthConfig, ClientHandler}; pub use client_auth::{ClientAuthConfig, ClientHandler};
pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource}; pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource};

View File

@@ -27,6 +27,7 @@
//! | `tls` | yes | TLS transport via `tokio-rustls` | //! | `tls` | yes | TLS transport via `tokio-rustls` |
//! | `iroh` | yes | iroh QUIC P2P transport | //! | `iroh` | yes | iroh QUIC P2P transport |
//! | `acme` | no | ACME/Let's Encrypt auto-cert provisioning (implies `tls`) | //! | `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) | //! | `testutil` | no | Test utilities (for internal use) |
//! //!
//! # Quick example //! # Quick example
@@ -61,6 +62,8 @@ pub mod transport;
#[cfg(feature = "testutil")] #[cfg(feature = "testutil")]
pub mod testutil; pub mod testutil;
#[cfg(feature = "irpc")]
pub use auth::{AuthProtocol, AuthResult, AuthServiceImpl};
pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider}; pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
pub use client::channel_manager::{ChannelManager, ForwardRequest}; pub use client::channel_manager::{ChannelManager, ForwardRequest};
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode}; pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};