use crate::polkit::Polkit; use crate::state::AppState; use authforge_common::policy::Policy; use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult}; 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 generate_recovery_code(&self, _user: String) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.generate-recovery") .await?; // Stub: 8 random digits. Phase 12 replaces with Argon2id-backed real flow. use rand::Rng; let n: u32 = rand::rng().random_range(0..100_000_000); Ok(format!("{n:08}")) } /// 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"), 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 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 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"); } }