use crate::polkit::Polkit; use crate::state::AppState; use authforge_common::policy::Policy; use authforge_common::types::{ Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary, }; #[cfg(feature = "totp")] use authforge_common::types::TotpEnrollment; use std::sync::Arc; pub struct AuthForge { pub state: Arc, pub polkit: Arc, } impl AuthForge { async fn authz(&self, action: &str) -> zbus::fdo::Result<()> { // Phase 1: pid 0 — permissive mode ignores it; system mode running on the // real system bus needs sender → unique-name → pid resolution, which lands // when CLI starts making real calls (Phase 2 / Phase 7). Until then, // operate the daemon under AUTHFORGE_POLKIT_BYPASS=1 for smoke tests. let pid = 0u32; self.polkit .check(action, pid) .await .map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?; Ok(()) } } #[zbus::interface(name = "io.dangerousthings.AuthForge1")] impl AuthForge { async fn list_credentials(&self, user: String) -> zbus::fdo::Result> { Ok(self.state.list_credentials(&user).await) } async fn get_policy(&self) -> zbus::fdo::Result { self.state .get_policy() .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn enroll_own( &self, #[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>, user: String, nickname: String, ) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.enroll-own") .await?; // Phase 3 emits a synthetic "touch required" before the real call so // GUIs (Phase 8) can surface a prompt. busctl monitor sees it too. let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await; match self.state.enroll(&user, &nickname).await { Ok(c) => { let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await; Ok(c) } Err(e) => { let msg = e.to_string(); let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await; Err(zbus::fdo::Error::Failed(msg)) } } } async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> { self.authz("io.dangerousthings.AuthForge.remove-own") .await?; let removed = self .state .remove_credential(&user, &cred_id) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; if removed { Ok(()) } else { Err(zbus::fdo::Error::Failed(format!( "no credential {cred_id} for {user}" ))) } } async fn enroll_other( &self, #[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>, user: String, nickname: String, ) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.enroll-other") .await?; let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await; match self.state.enroll(&user, &nickname).await { Ok(c) => { let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await; Ok(c) } Err(e) => { let msg = e.to_string(); let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await; Err(zbus::fdo::Error::Failed(msg)) } } } async fn set_policy(&self, p: Policy, force: bool) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.set-policy") .await?; self.state .set_policy(p, force) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> { self.authz("io.dangerousthings.AuthForge.set-pending") .await?; self.state .set_pending(&user, flag) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; Ok(()) } async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> { self.authz("io.dangerousthings.AuthForge.clear-pending") .await?; self.state .clear_pending(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; Ok(()) } async fn get_pending_status(&self, user: String) -> zbus::fdo::Result { // No polkit gate — pure read; daemon's pattern is gates on writes only. self.state .get_pending_status(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.generate-recovery") .await?; self.state .issue_recovery(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn list_recovery_codes(&self) -> zbus::fdo::Result> { self.authz("io.dangerousthings.AuthForge.list-recovery") .await?; let entries = self .state .list_recovery() .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; Ok(entries .into_iter() .map(|e| RecoveryCodeSummary { user: e.user, expires_unix: e.expires_unix, }) .collect()) } async fn revoke_recovery_code(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.revoke-recovery") .await?; self.state .revoke_recovery(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } #[cfg(feature = "totp")] async fn enroll_totp(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.enroll-totp") .await?; self.state .enroll_totp(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } #[cfg(feature = "totp")] async fn is_totp_enrolled(&self, user: String) -> zbus::fdo::Result { // No polkit gate — pure read; daemon's pattern is gates on writes only. self.state .is_totp_enrolled(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } #[cfg(feature = "totp")] async fn revoke_totp(&self, user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.revoke-totp") .await?; self.state .revoke_totp(&user) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } /// Emitted by main.rs whenever the inotify watcher on the policy.d/ dir /// reports any change. Subscribers (GUI) reload via `GetPolicy`. #[zbus(signal)] pub async fn policy_changed( signal_ctxt: &zbus::object_server::SignalContext<'_>, ) -> zbus::Result<()>; /// Emitted by enrollment hot-plug discovery (Phase 3 stub: synthetic). #[zbus(signal)] pub async fn device_found( ctx: &zbus::object_server::SignalContext<'_>, name: String, transport: String, ) -> zbus::Result<()>; /// Emitted right before the daemon waits for the user to touch the device. #[zbus(signal)] pub async fn touch_required( ctx: &zbus::object_server::SignalContext<'_>, device: String, ) -> zbus::Result<()>; /// Fired after a successful EnrollOwn / EnrollOther so the GUI can dismiss /// its "touch your key" overlay without polling. #[zbus(signal)] pub async fn enrollment_succeeded( ctx: &zbus::object_server::SignalContext<'_>, cred_id: String, ) -> zbus::Result<()>; /// Fired when enrollment fails — payload is the StateError message. #[zbus(signal)] pub async fn enrollment_failed( ctx: &zbus::object_server::SignalContext<'_>, reason: String, ) -> zbus::Result<()>; } #[cfg(test)] mod tests { use super::*; use authforge_common::policy::StackPolicy; use authforge_common::types::Method; use std::collections::BTreeMap; use zbus::connection::Builder; use zbus::Connection; /// Build a peer-to-peer pair: server side has the AuthForge interface /// registered at /io/dangerousthings/AuthForge, client side is a bare /// connection. No system bus is involved. The TempDir must be returned — /// dropping it before the test ends would delete the storage directories. /// /// Storage is seeded with a central-mode policy pointing at a file inside /// the tempdir so credential writes don't try to touch /home//... /// (test users like alice/bob/carol aren't real accounts). async fn p2p_pair() -> (Connection, Connection, Arc, tempfile::TempDir) { let tmp = tempfile::tempdir().unwrap(); let policy_dir = tmp.path().join("policy.d"); std::fs::create_dir_all(&policy_dir).unwrap(); let central = tmp.path().join("u2f_keys"); std::fs::write( policy_dir.join("00-test.conf"), format!( r#"[storage] backend = "central" central_path = "{}" "#, central.display() ), ) .unwrap(); let state = Arc::new({ let shim = tmp.path().join("fake-pau.sh"); std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap(); let mut perms = std::fs::metadata(&shim).unwrap().permissions(); std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); std::fs::set_permissions(&shim, perms).unwrap(); AppState::open( crate::state::StorageConfig { policy_dir, pending_dir: tmp.path().join("pending"), recovery_dir: tmp.path().join("recovery"), #[cfg(feature = "totp")] totp_dir: tmp.path().join("totp"), userdb_path: tmp.path().join("users.db"), pam_profile_path: tmp.path().join("authforge-pamconf"), pam_auth_update: shim, }, Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()), ) .unwrap() }); let auth = AuthForge { state: state.clone(), polkit: Arc::new(Polkit::permissive()), }; let guid = zbus::Guid::generate(); let (server_sock, client_sock) = tokio::net::UnixStream::pair().unwrap(); // Both sides must build concurrently — `build()` completes the handshake, // and one side waiting on the other deadlocks. tokio::join drives them in // parallel on this runtime. let server_build = Builder::socket(server_sock) .p2p() .server(guid) .unwrap() .serve_at("/io/dangerousthings/AuthForge", auth) .unwrap() .build(); let client_build = Builder::socket(client_sock).p2p().build(); let (server, client) = tokio::join!(server_build, client_build); (server.unwrap(), client.unwrap(), state, tmp) } async fn proxy(client: &Connection) -> zbus::Proxy<'_> { zbus::Proxy::new( client, "io.dangerousthings.AuthForge", "/io/dangerousthings/AuthForge", "io.dangerousthings.AuthForge1", ) .await .unwrap() } #[tokio::test] async fn list_credentials_after_enroll() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; // Seed via the same EnrollOwn path the GUI would use; previously this // was a Phase 1 fixture in AppState, but file-backed storage starts empty. let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap(); let creds: Vec = p.call("ListCredentials", &("alice",)).await.unwrap(); assert_eq!(creds.len(), 1); assert_eq!(creds[0].method, Method::Fido2); } #[tokio::test] async fn get_policy_returns_default() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let pol: Policy = p.call("GetPolicy", &()).await.unwrap(); assert!(pol.stacks.is_empty()); } #[tokio::test] async fn enroll_own_appends_credential() { let (_srv, client, state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let _: Credential = p.call("EnrollOwn", &("bob", "Bob's Key")).await.unwrap(); assert_eq!(state.list_credentials("bob").await.len(), 1); } #[tokio::test] async fn remove_own_drops_credential() { let (_srv, client, state, _tmp) = p2p_pair().await; let p = proxy(&client).await; // Enroll, then remove by the cred id we just got back. let cred: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap(); let _: () = p .call("RemoveOwn", &("alice", cred.id.as_str())) .await .unwrap(); assert!(state.list_credentials("alice").await.is_empty()); } #[tokio::test] async fn remove_own_unknown_id_errors() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let r: Result<(), zbus::Error> = p.call("RemoveOwn", &("alice", "no-such-id")).await; assert!(r.is_err()); } #[tokio::test] async fn enroll_other_creates_credential() { let (_srv, client, state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let _: Credential = p.call("EnrollOther", &("carol", "Hers")).await.unwrap(); assert_eq!(state.list_credentials("carol").await.len(), 1); } #[tokio::test] async fn set_policy_persists_stacks() { let (_srv, client, state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let mut stacks = BTreeMap::new(); stacks.insert( "sudo".to_string(), StackPolicy { mode: authforge_common::types::Mode::Required, methods: vec![Method::Fido2], }, ); let pol = Policy { stacks: stacks.clone(), ..Default::default() }; let r: PolicyApplyResult = p.call("SetPolicy", &(pol, true)).await.unwrap(); assert!(r.applied); // p2p_pair seeds 00-test.conf with [storage]; SetPolicy writes 50-local.conf. // The merged read shows our new stack alongside the seeded storage block. let merged = state.get_policy().await.unwrap(); assert_eq!(merged.stacks, stacks); } #[tokio::test] async fn set_and_clear_pending_flag() { let (_srv, client, state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let flag = PendingFlag { required_methods: vec![Method::Fido2], created_unix: 0, deadline_unix: 0, re_enroll: false, }; let _: () = p.call("SetPendingFlag", &("alice", flag)).await.unwrap(); assert!(state.has_pending("alice").await.unwrap()); let _: () = p.call("ClearPendingFlag", &("alice",)).await.unwrap(); assert!(!state.has_pending("alice").await.unwrap()); } #[tokio::test] async fn pending_status_is_absent_for_fresh_user() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); assert!(!s.present); assert!(s.flag.required_methods.is_empty()); } #[tokio::test] async fn pending_status_round_trips_set_flag() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let flag = PendingFlag { required_methods: vec![Method::Fido2], created_unix: 100, deadline_unix: 200, re_enroll: true, }; let _: () = p .call("SetPendingFlag", &("alice", flag.clone())) .await .unwrap(); let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap(); assert!(s.present); assert_eq!(s.flag, flag); } #[tokio::test] async fn generate_recovery_code_returns_8_digits() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); assert_eq!(code.len(), 8); assert!(code.chars().all(|c| c.is_ascii_digit())); } #[tokio::test] async fn list_recovery_returns_issued_users() { use authforge_common::types::RecoveryCodeSummary; let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); let _: String = p.call("GenerateRecoveryCode", &("bob",)).await.unwrap(); let entries: Vec = p.call("ListRecoveryCodes", &()).await.unwrap(); let users: Vec<_> = entries.into_iter().map(|e| e.user).collect(); assert_eq!(users, vec!["alice".to_string(), "bob".to_string()]); } #[tokio::test] async fn revoke_recovery_removes_entry() { use authforge_common::types::RecoveryCodeSummary; let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); let removed: bool = p.call("RevokeRecoveryCode", &("alice",)).await.unwrap(); assert!(removed); let entries: Vec = p.call("ListRecoveryCodes", &()).await.unwrap(); assert!(entries.is_empty()); } #[tokio::test] async fn revoke_recovery_returns_false_on_missing_user() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let removed: bool = p.call("RevokeRecoveryCode", &("ghost",)).await.unwrap(); assert!(!removed); } #[tokio::test] async fn generate_recovery_code_persists_argon2id_hash_on_disk() { let (_srv, client, _state, tmp) = p2p_pair().await; let p = proxy(&client).await; let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap(); let body = std::fs::read_to_string(tmp.path().join("recovery").join("alice")).unwrap(); let mut lines = body.lines(); // Line 1: expires_unix (numeric). let expires: u64 = lines.next().unwrap().parse().unwrap(); assert!(expires > 0); // Line 2: argon2id PHC string. let phc = lines.next().unwrap(); assert!(phc.starts_with("$argon2id$"), "phc was {phc}"); } #[tokio::test] async fn enrollment_succeeded_signal_fires_on_enroll_own() { use futures_util::stream::StreamExt; let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let mut stream = p.receive_signal("EnrollmentSucceeded").await.unwrap(); let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap(); let _msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) .await .expect("EnrollmentSucceeded signal not received within 2s") .expect("stream closed before signal"); } #[tokio::test] async fn policy_changed_signal_fires_on_emit() { use futures_util::stream::StreamExt; let (server, client, _state, _tmp) = p2p_pair().await; let proxy = proxy(&client).await; let mut stream = proxy.receive_signal("PolicyChanged").await.unwrap(); let iface_ref: zbus::object_server::InterfaceRef = server .object_server() .interface("/io/dangerousthings/AuthForge") .await .unwrap(); AuthForge::policy_changed(iface_ref.signal_context()) .await .unwrap(); let _msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) .await .expect("signal not received within 2s") .expect("stream closed before signal"); } #[cfg(feature = "totp")] #[tokio::test] async fn enroll_totp_returns_otpauth_uri() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let e: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap(); assert_eq!(e.user, "alice"); assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?")); assert!(e .secret_b32 .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); } #[cfg(feature = "totp")] #[tokio::test] async fn is_totp_enrolled_round_trips() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let before: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); assert!(!before); let _: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap(); let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); assert!(after); } #[cfg(feature = "totp")] #[tokio::test] async fn revoke_totp_removes_enrollment() { let (_srv, client, _state, _tmp) = p2p_pair().await; let p = proxy(&client).await; let _: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap(); let removed: bool = p.call("RevokeTotp", &("alice",)).await.unwrap(); assert!(removed); let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap(); assert!(!after); } }