feat(daemon): D-Bus interface with all 9 stub methods + system-bus wiring
Bundles plan tasks 1.8 (read methods), 1.9 (EnrollOwn/RemoveOwn with polkit gate), 1.10 (EnrollOther, SetPolicy, pending, recovery-code), and 1.11 (main.rs system-bus registration) — they land together because Polkit::System is only constructed by main.rs, so splitting them mid-implementation would require dead_code allows that immediately reverse. Adds: - daemon/src/dbus.rs — AuthForge struct + #[zbus::interface] impl with all 9 methods. Reads (ListCredentials, GetPolicy) are unauthenticated; writes call authz() which dispatches to polkit. Includes 9 integration tests via a tokio::net::UnixStream::pair p2p connection — no system bus needed for tests. - daemon/src/main.rs — connects to system bus, picks Polkit::system or Polkit::permissive based on AUTHFORGE_POLKIT_BYPASS env var, registers the AuthForge interface at /io/dangerousthings/AuthForge, requests well-known name io.dangerousthings.AuthForge, then parks forever. - daemon/src/polkit.rs — drop dead_code allows now that System is wired. - daemon/src/state.rs — gate has_pending() behind cfg(test); production reads go through the on-disk file in later phases, not this in-memory cache. - common/src/types.rs (formatting only via rustfmt). Tests: 14/14 daemon tests pass (4 state, 1 polkit, 9 dbus). 7/7 common tests pass. cargo clippy --workspace --all-targets -D warnings clean. cargo fmt clean.
This commit is contained in:
266
daemon/src/dbus.rs
Normal file
266
daemon/src/dbus.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use crate::polkit::Polkit;
|
||||
use crate::state::AppState;
|
||||
use authforge_common::types::{
|
||||
Credential, Method, PendingFlag, Policy, PolicyApplyResult, Transport,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AuthForge {
|
||||
pub state: Arc<AppState>,
|
||||
pub polkit: Arc<Polkit>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "io.dangerousthings.AuthForge1")]
|
||||
impl AuthForge {
|
||||
async fn list_credentials(&self, user: String) -> zbus::fdo::Result<Vec<Credential>> {
|
||||
Ok(self.state.list_credentials(&user).await)
|
||||
}
|
||||
|
||||
async fn get_policy(&self) -> zbus::fdo::Result<Policy> {
|
||||
Ok(self.state.get_policy().await)
|
||||
}
|
||||
|
||||
async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> {
|
||||
self.authz("io.dangerousthings.AuthForge.enroll-own")
|
||||
.await?;
|
||||
let now = Self::now();
|
||||
let cred = Credential {
|
||||
id: format!("stub-{user}-{now}"),
|
||||
nickname,
|
||||
method: Method::Fido2,
|
||||
transport: Transport::Usb,
|
||||
created_unix: now,
|
||||
};
|
||||
self.state.add_credential(&user, cred.clone()).await;
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> {
|
||||
self.authz("io.dangerousthings.AuthForge.remove-own")
|
||||
.await?;
|
||||
if self.state.remove_credential(&user, &cred_id).await {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(zbus::fdo::Error::Failed(format!(
|
||||
"no credential {cred_id} for {user}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn enroll_other(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> {
|
||||
self.authz("io.dangerousthings.AuthForge.enroll-other")
|
||||
.await?;
|
||||
let now = Self::now();
|
||||
let cred = Credential {
|
||||
id: format!("stub-{user}-{now}"),
|
||||
nickname,
|
||||
method: Method::Fido2,
|
||||
transport: Transport::Usb,
|
||||
created_unix: now,
|
||||
};
|
||||
self.state.add_credential(&user, cred.clone()).await;
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> {
|
||||
self.authz("io.dangerousthings.AuthForge.set-policy")
|
||||
.await?;
|
||||
self.state.set_policy(p).await;
|
||||
Ok(PolicyApplyResult {
|
||||
applied: true,
|
||||
violations: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_recovery_code(&self, _user: String) -> zbus::fdo::Result<String> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use authforge_common::types::{Method, StackPolicy};
|
||||
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.
|
||||
async fn p2p_pair() -> (Connection, Connection, Arc<AppState>) {
|
||||
let state = Arc::new(AppState::with_fixtures());
|
||||
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)
|
||||
}
|
||||
|
||||
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_returns_fixture_for_alice() {
|
||||
let (_srv, client, _state) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let creds: Vec<Credential> = 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) = 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) = 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) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let _: () = p
|
||||
.call("RemoveOwn", &("alice", "fixture-cred-1"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(state.list_credentials("alice").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_own_unknown_id_errors() {
|
||||
let (_srv, client, _state) = 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) = 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_replaces_state() {
|
||||
let (_srv, client, state) = 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,
|
||||
..Default::default()
|
||||
};
|
||||
let r: PolicyApplyResult = p.call("SetPolicy", &(pol.clone(),)).await.unwrap();
|
||||
assert!(r.applied);
|
||||
assert_eq!(state.get_policy().await, pol);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_and_clear_pending_flag() {
|
||||
let (_srv, client, state) = 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);
|
||||
let _: () = p.call("ClearPendingFlag", &("alice",)).await.unwrap();
|
||||
assert!(!state.has_pending("alice").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_recovery_code_returns_8_digits() {
|
||||
let (_srv, client, _state) = 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user