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:
michael
2026-04-26 23:04:59 -07:00
parent df22358051
commit c26d6ae896
6 changed files with 430 additions and 68 deletions

266
daemon/src/dbus.rs Normal file
View 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()));
}
}

View File

@@ -1,15 +1,48 @@
use anyhow::Result;
use tracing::info;
use anyhow::{Context, Result};
use std::sync::Arc;
use tracing::{info, warn};
mod dbus;
mod polkit;
mod state;
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("authforged {} starting", env!("CARGO_PKG_VERSION"));
// D-Bus service registration in Phase 1.
let conn = zbus::Connection::system()
.await
.context("connecting to system D-Bus")?;
let polkit = if std::env::var("AUTHFORGE_POLKIT_BYPASS").is_ok() {
warn!(
"AUTHFORGE_POLKIT_BYPASS set — polkit checks are DISABLED. Do not use in production."
);
polkit::Polkit::permissive()
} else {
polkit::Polkit::system(conn.clone()).await
};
let state = Arc::new(state::AppState::with_fixtures());
let auth = dbus::AuthForge {
state: state.clone(),
polkit: Arc::new(polkit),
};
conn.object_server().at(OBJECT_PATH, auth).await?;
conn.request_name(BUS_NAME)
.await
.context("acquiring well-known bus name (another instance running?)")?;
info!("listening on D-Bus as {BUS_NAME} at {OBJECT_PATH}");
// Park forever. systemd will SIGTERM the process when stopping the unit.
std::future::pending::<()>().await;
Ok(())
}

View File

@@ -11,13 +11,11 @@ pub enum PolkitError {
Bus(#[from] zbus::Error),
}
#[allow(dead_code)] // wired up in Task 1.8.
pub enum Polkit {
Permissive,
System(Connection),
}
#[allow(dead_code)] // wired up in Task 1.8.
impl Polkit {
pub fn permissive() -> Self {
Self::Permissive
@@ -58,13 +56,18 @@ async fn system_check(conn: &Connection, action: &str, pid: u32) -> Result<(), P
.await?;
let (is_authorized, _is_challenge, _details): (bool, bool, HashMap<String, String>) = proxy
.call("CheckAuthorization", &(subject, action, details, flags, cancellation_id))
.call(
"CheckAuthorization",
&(subject, action, details, flags, cancellation_id),
)
.await?;
if is_authorized {
Ok(())
} else {
Err(PolkitError::NotAuthorized { action: action.to_string() })
Err(PolkitError::NotAuthorized {
action: action.to_string(),
})
}
}
@@ -75,6 +78,8 @@ mod tests {
#[tokio::test]
async fn permissive_always_allows() {
let p = Polkit::permissive();
p.check("io.dangerousthings.AuthForge.set-policy", 1234).await.unwrap();
p.check("io.dangerousthings.AuthForge.set-policy", 1234)
.await
.unwrap();
}
}

View File

@@ -14,8 +14,6 @@ struct StateInner {
pending: HashMap<String, PendingFlag>,
}
// `dead_code` allow lifts in Task 1.8 when dbus.rs starts calling these methods.
#[allow(dead_code)]
impl AppState {
pub fn with_fixtures() -> Self {
let mut inner = StateInner::default();
@@ -29,20 +27,36 @@ impl AppState {
created_unix: 1_700_000_000,
}],
);
Self { inner: RwLock::new(inner) }
Self {
inner: RwLock::new(inner),
}
}
pub async fn list_credentials(&self, user: &str) -> Vec<Credential> {
self.inner.read().await.credentials.get(user).cloned().unwrap_or_default()
self.inner
.read()
.await
.credentials
.get(user)
.cloned()
.unwrap_or_default()
}
pub async fn add_credential(&self, user: &str, c: Credential) {
self.inner.write().await.credentials.entry(user.to_string()).or_default().push(c);
self.inner
.write()
.await
.credentials
.entry(user.to_string())
.or_default()
.push(c);
}
pub async fn remove_credential(&self, user: &str, cred_id: &str) -> bool {
let mut g = self.inner.write().await;
let Some(list) = g.credentials.get_mut(user) else { return false };
let Some(list) = g.credentials.get_mut(user) else {
return false;
};
let before = list.len();
list.retain(|c| c.id != cred_id);
list.len() != before
@@ -64,6 +78,10 @@ impl AppState {
self.inner.write().await.pending.remove(user).is_some()
}
/// Tests assert pending flag round-trips through SetPendingFlag/ClearPendingFlag
/// via this read accessor. Production readers (PAM module, GUI status) come in
/// later phases and will read the on-disk file directly, not this in-memory cache.
#[cfg(test)]
pub async fn has_pending(&self, user: &str) -> bool {
self.inner.read().await.pending.contains_key(user)
}