Files
authforge/docs/plans/2026-04-26-phase-1-dbus.md

44 KiB
Raw Blame History

Phase 1: D-Bus Interface, systemd, polkit — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: authforged registers on the system bus as io.dangerousthings.AuthForge, exposes 9 stub methods that return fixture data, is started by systemd, and gates writes through polkit. Real backing (storage, FIDO2, policy apply) lands in Phases 25; this phase is wiring + types + IPC contract.

Architecture: Shared wire types in common (with zvariant::Type derives so Policy, Credential, etc. cross D-Bus cleanly). Daemon owns an AppState with in-memory fixtures, and a zbus::interface impl that calls the state. A small polkit helper authorizes each write method against the calling subject. systemd Type=dbus unit auto-starts the daemon when something pokes the bus name. polkit XML declares the 7 actions referenced in the design doc.

Tech Stack: Rust 2021, zbus = "4" (already in workspace deps), zvariant = "4" (added in Task 1.1), tokio runtime, tracing. polkit is queried via raw D-Bus (the org.freedesktop.PolicyKit1.Authority interface) — no extra crate needed.

Reference:


Conventions

  • One logical change per commit. Conventional prefixes (feat:, test:, chore:, docs:, refactor:).
  • TDD for everything that has a return value or branches. Test names describe behavior.
  • No system-bus tests in Phase 1 (would need root). All D-Bus interface tests use a peer-to-peer zbus::Connection::new_unix_pair() or unit-test the underlying state directly.
  • After each task, run cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace. If any fails, fix before commit.
  • Build the daemon only (-p authforge-daemon) when iterating. The full workspace cargo build excludes GUI by default (default-members in root Cargo.toml).

Task 1.1: Add zvariant to workspace deps

Files:

  • Modify: Cargo.toml (workspace root)
  • Modify: common/Cargo.toml

Why: Wire types live in common and need #[derive(Type)] for D-Bus marshaling. Adding zvariant once at workspace level keeps the version consistent across daemon, cli, and common.

Step 1: Edit root Cargo.toml. In [workspace.dependencies], add:

zvariant = "4"

(zbus 4 re-exports zvariant 4 — they must match major.)

Step 2: Edit common/Cargo.toml. Under [dependencies], add:

zvariant = { workspace = true }
serde_json = { workspace = true }

(serde_json was already a dev-dep; promote to a real dep so types can be JSON-serialized in tests and inspection paths.)

Step 3: Verify it compiles:

cargo check -p authforge-common

Expected: clean.

Step 4: Commit.

git add Cargo.toml common/Cargo.toml
git commit -m "chore: add zvariant to workspace deps"

Task 1.2: Define Credential wire type

Files:

  • Modify: common/src/types.rs

Step 1: Append the failing test (do not implement yet):

    #[test]
    fn credential_roundtrips_via_serde() {
        let c = Credential {
            id: "abc123".to_string(),
            nickname: "Yellow Yubikey".to_string(),
            method: Method::Fido2,
            transport: Transport::Usb,
            created_unix: 1_700_000_000,
        };
        let json = serde_json::to_string(&c).unwrap();
        let back: Credential = serde_json::from_str(&json).unwrap();
        assert_eq!(c, back);
    }

Step 2: Run test, confirm failure:

cargo test -p authforge-common credential_roundtrips_via_serde

Expected: compile error — Credential and Transport undefined.

Step 3: Implement. Append to common/src/types.rs:

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type,
)]
#[serde(rename_all = "lowercase")]
#[zvariant(signature = "s")]
pub enum Transport {
    Usb,
    Nfc,
    Internal,
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Credential {
    pub id: String,
    pub nickname: String,
    pub method: Method,
    pub transport: Transport,
    pub created_unix: u64,
}

Also add zvariant::Type and #[zvariant(signature = "s")] to the existing Mode and Method enums (they cross D-Bus too).

Step 4: Run:

cargo test -p authforge-common

Expected: PASS, 3 tests (mode + method + credential).

Step 5: Commit.

git add common/src/types.rs
git commit -m "feat: add Credential and Transport wire types"

Task 1.3: Define Policy, StackPolicy, Storage, Firstrun types

Files:

  • Modify: common/src/types.rs

These mirror the TOML format from the design doc:

[stacks.gdm-password]
mode = "required"
methods = ["fido2", "totp"]

[storage]
backend = "central"
central_path = "/etc/u2f_mappings"

[firstrun]
default_required_methods = ["fido2"]
deadline_hours = 0

Step 1: Failing test:

    #[test]
    fn policy_default_is_all_disabled() {
        let p = Policy::default();
        assert!(p.stacks.is_empty());
        assert_eq!(p.storage.backend, StorageBackend::PerUser);
        assert!(p.firstrun.default_required_methods.is_empty());
        assert_eq!(p.firstrun.deadline_hours, 0);
    }

    #[test]
    fn policy_serde_via_json() {
        let mut p = Policy::default();
        p.stacks.insert(
            "sudo".to_string(),
            StackPolicy { mode: Mode::Required, methods: vec![Method::Fido2] },
        );
        let json = serde_json::to_string(&p).unwrap();
        let back: Policy = serde_json::from_str(&json).unwrap();
        assert_eq!(p, back);
    }

Step 2: Run, confirm fail (types undefined).

Step 3: Implement:

use std::collections::BTreeMap;

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default,
)]
#[serde(rename_all = "kebab-case")]
#[zvariant(signature = "s")]
pub enum StorageBackend {
    #[default]
    PerUser,
    Central,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct StackPolicy {
    pub mode: Mode,
    pub methods: Vec<Method>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Storage {
    #[serde(default)]
    pub backend: StorageBackend,
    #[serde(default)]
    pub central_path: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Firstrun {
    #[serde(default)]
    pub default_required_methods: Vec<Method>,
    #[serde(default)]
    pub deadline_hours: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Policy {
    #[serde(default)]
    pub stacks: BTreeMap<String, StackPolicy>,
    #[serde(default)]
    pub storage: Storage,
    #[serde(default)]
    pub firstrun: Firstrun,
}

Step 4: Run cargo test -p authforge-common. Expected: PASS.

Step 5: Commit.

git add common/src/types.rs
git commit -m "feat: add Policy, StackPolicy, Storage, Firstrun types"

Task 1.4: Define PendingFlag, PolicyApplyResult, Violation

Files:

  • Modify: common/src/types.rs

Step 1: Failing test:

    #[test]
    fn pending_flag_serde() {
        let f = PendingFlag {
            required_methods: vec![Method::Fido2],
            created_unix: 1_700_000_000,
            deadline_unix: None,
            re_enroll: false,
        };
        let json = serde_json::to_string(&f).unwrap();
        let back: PendingFlag = serde_json::from_str(&json).unwrap();
        assert_eq!(f, back);
    }

    #[test]
    fn policy_apply_result_ok_no_violations() {
        let r = PolicyApplyResult { applied: true, violations: vec![] };
        assert!(r.applied);
        assert!(r.violations.is_empty());
    }

Step 2: Run, confirm fail.

Step 3: Implement (append):

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingFlag {
    pub required_methods: Vec<Method>,
    pub created_unix: u64,
    pub deadline_unix: Option<u64>,
    pub re_enroll: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Violation {
    pub user: String,
    pub stack: String,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PolicyApplyResult {
    pub applied: bool,
    pub violations: Vec<Violation>,
}

Step 4: Test passes.

Step 5: Commit.

git add common/src/types.rs
git commit -m "feat: add PendingFlag, PolicyApplyResult, Violation types"

Task 1.5: Add daemon dependencies

Files:

  • Modify: daemon/Cargo.toml

Step 1: Add to [dependencies]:

zvariant = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
nix = { workspace = true }

Add a [dev-dependencies] section:

[dev-dependencies]
tempfile = "3"

Step 2: Add to root Cargo.toml [workspace.dependencies]:

tempfile = "3"

(Then convert the daemon dev-dep line to tempfile = { workspace = true }.)

Step 3: cargo check -p authforge-daemon. Expected: clean.

Step 4: Commit.

git add Cargo.toml daemon/Cargo.toml
git commit -m "chore: add daemon deps for D-Bus interface and tests"

Task 1.6: Daemon state module — fixture-backed app state

Files:

  • Create: daemon/src/state.rs
  • Modify: daemon/src/main.rs (add mod state;)

The AppState is a tokio::sync::RwLock<StateInner> so the interface impl can take &self and still mutate. Phase 1 fixtures live in StateInner; Phase 2 swaps them for real storage.

Step 1: Write the failing test in a new module at the bottom of daemon/src/state.rs (created with skeleton):

// daemon/src/state.rs

use authforge_common::types::{Credential, Method, PendingFlag, Policy, Transport};
use std::collections::HashMap;
use tokio::sync::RwLock;

#[derive(Default)]
pub struct AppState {
    inner: RwLock<StateInner>,
}

#[derive(Default)]
struct StateInner {
    credentials: HashMap<String, Vec<Credential>>, // user -> creds
    policy: Policy,
    pending: HashMap<String, PendingFlag>,
}

impl AppState {
    pub fn with_fixtures() -> Self {
        let mut inner = StateInner::default();
        inner.credentials.insert(
            "alice".to_string(),
            vec![Credential {
                id: "fixture-cred-1".to_string(),
                nickname: "Alice's Yubikey".to_string(),
                method: Method::Fido2,
                transport: Transport::Usb,
                created_unix: 1_700_000_000,
            }],
        );
        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()
    }

    pub async fn add_credential(&self, user: &str, c: Credential) {
        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 before = list.len();
        list.retain(|c| c.id != cred_id);
        list.len() != before
    }

    pub async fn get_policy(&self) -> Policy {
        self.inner.read().await.policy.clone()
    }

    pub async fn set_policy(&self, p: Policy) {
        self.inner.write().await.policy = p;
    }

    pub async fn set_pending(&self, user: &str, f: PendingFlag) {
        self.inner.write().await.pending.insert(user.to_string(), f);
    }

    pub async fn clear_pending(&self, user: &str) -> bool {
        self.inner.write().await.pending.remove(user).is_some()
    }

    pub async fn has_pending(&self, user: &str) -> bool {
        self.inner.read().await.pending.contains_key(user)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn fixtures_seeded_for_alice() {
        let s = AppState::with_fixtures();
        let creds = s.list_credentials("alice").await;
        assert_eq!(creds.len(), 1);
        assert_eq!(creds[0].nickname, "Alice's Yubikey");
    }

    #[tokio::test]
    async fn unknown_user_has_no_creds() {
        let s = AppState::with_fixtures();
        assert!(s.list_credentials("nobody").await.is_empty());
    }

    #[tokio::test]
    async fn add_then_remove_credential() {
        let s = AppState::default();
        let c = Credential {
            id: "id1".to_string(),
            nickname: "x".to_string(),
            method: Method::Fido2,
            transport: Transport::Usb,
            created_unix: 0,
        };
        s.add_credential("bob", c).await;
        assert_eq!(s.list_credentials("bob").await.len(), 1);
        assert!(s.remove_credential("bob", "id1").await);
        assert!(s.list_credentials("bob").await.is_empty());
    }

    #[tokio::test]
    async fn pending_set_clear_roundtrip() {
        let s = AppState::default();
        assert!(!s.has_pending("alice").await);
        s.set_pending(
            "alice",
            PendingFlag {
                required_methods: vec![Method::Fido2],
                created_unix: 1,
                deadline_unix: None,
                re_enroll: false,
            },
        )
        .await;
        assert!(s.has_pending("alice").await);
        assert!(s.clear_pending("alice").await);
        assert!(!s.has_pending("alice").await);
    }
}

Step 2: Add mod state; to daemon/src/main.rs.

Step 3: Run:

cargo test -p authforge-daemon

Expected: PASS, 4 tests.

Step 4: Commit.

git add daemon/src/state.rs daemon/src/main.rs
git commit -m "feat(daemon): add AppState with fixture-backed in-memory store"

Task 1.7: Daemon polkit helper — pluggable authorizer

Files:

  • Create: daemon/src/polkit.rs
  • Modify: daemon/src/main.rs (add mod polkit;)

Why now: the interface impl will call authz.check(&action, sender_pid) for each write method. Phase 1 ships a Polkit::permissive() mode for tests + initial debug, plus a Polkit::system() constructor that dispatches to the real org.freedesktop.PolicyKit1.Authority. The system mode is implemented now (small) so the daemon is auth-real from day one; bypass is opt-in via env var only.

Step 1: Failing test:

// daemon/src/polkit.rs

use thiserror::Error;
use zbus::Connection;

#[derive(Debug, Error)]
pub enum PolkitError {
    #[error("not authorized: {action}")]
    NotAuthorized { action: String },
    #[error(transparent)]
    Bus(#[from] zbus::Error),
}

pub enum Polkit {
    Permissive,
    System(Connection),
}

impl Polkit {
    pub fn permissive() -> Self {
        Self::Permissive
    }

    pub async fn system(conn: Connection) -> Self {
        Self::System(conn)
    }

    /// Check whether `pid` may invoke `action`. Phase 1 stub: permissive mode
    /// always allows; system mode implemented in Step 3 of this task.
    pub async fn check(&self, action: &str, pid: u32) -> Result<(), PolkitError> {
        match self {
            Self::Permissive => Ok(()),
            Self::System(conn) => system_check(conn, action, pid).await,
        }
    }
}

async fn system_check(conn: &Connection, action: &str, pid: u32) -> Result<(), PolkitError> {
    use zbus::zvariant::Value;
    use std::collections::HashMap;

    // Subject = ("unix-process", { "pid": u32, "start-time": u64 })
    // start-time 0 is accepted by polkit; it'll fetch from /proc itself.
    let mut subject_details: HashMap<&str, Value> = HashMap::new();
    subject_details.insert("pid", Value::U32(pid));
    subject_details.insert("start-time", Value::U64(0));

    let subject = ("unix-process", subject_details);
    let details: HashMap<&str, &str> = HashMap::new();
    let flags: u32 = 0; // no interaction; daemon does not block on auth prompt
    let cancellation_id = "";

    let proxy = zbus::Proxy::new(
        conn,
        "org.freedesktop.PolicyKit1",
        "/org/freedesktop/PolicyKit1/Authority",
        "org.freedesktop.PolicyKit1.Authority",
    )
    .await?;

    let (is_authorized, _is_challenge, _details): (bool, bool, HashMap<String, String>) = proxy
        .call(
            "CheckAuthorization",
            &(subject, action, details, flags, cancellation_id),
        )
        .await?;

    if is_authorized {
        Ok(())
    } else {
        Err(PolkitError::NotAuthorized { action: action.to_string() })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn permissive_always_allows() {
        let p = Polkit::permissive();
        p.check("io.dangerousthings.AuthForge.set-policy", 1234).await.unwrap();
    }
}

Step 2: cargo test -p authforge-daemon polkit. Expected: PASS.

Step 3: Add mod polkit; to daemon/src/main.rs.

Step 4: cargo clippy -p authforge-daemon --all-targets -- -D warnings. Expected: clean.

Step 5: Commit.

git add daemon/src/polkit.rs daemon/src/main.rs
git commit -m "feat(daemon): add polkit authorizer with permissive + system modes"

Task 1.8: D-Bus interface — read methods (ListCredentials, GetPolicy)

Files:

  • Create: daemon/src/dbus.rs
  • Modify: daemon/src/main.rs (add mod dbus;)

These two methods are unauthenticated per design (read is free), so they don't call polkit.

Step 1: Skeleton:

// daemon/src/dbus.rs

use crate::{polkit::Polkit, state::AppState};
use authforge_common::types::{Credential, Policy};
use std::sync::Arc;

pub struct AuthForge {
    pub state: Arc<AppState>,
    pub polkit: Arc<Polkit>,
}

#[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)
    }
}

Step 2: Add mod dbus; to daemon/src/main.rs.

Step 3: cargo build -p authforge-daemon. Expected: clean.

Step 4: Add an integration test using a P2P connection. Create daemon/tests/dbus_p2p.rs:

use authforge_common::types::Method;
use std::sync::Arc;

// Re-export internals via a tiny helper so the integration test can construct them.
// We'll add this helper in the daemon main lib in a follow-up — for now, copy the
// minimal types here would require a lib target. Instead, run this as a unit test
// inside the daemon crate.

Actually the daemon is a binary crate, not a library. Either (a) add lib.rs or (b) keep tests as #[cfg(test)] mod tests inside dbus.rs. Take option (b) — simpler.

Append to daemon/src/dbus.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use authforge_common::types::Method;
    use zbus::Connection;

    /// Spin up a peer-to-peer zbus connection (no system bus needed),
    /// register the interface on one side, and hit it from the other.
    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_stream, client_stream) = tokio::io::duplex(8192);

        let server = zbus::ConnectionBuilder::tokio_socket(server_stream)
            .unwrap()
            .server(&guid)
            .unwrap()
            .p2p()
            .serve_at("/io/dangerousthings/AuthForge", auth)
            .unwrap()
            .build()
            .await
            .unwrap();

        let client = zbus::ConnectionBuilder::tokio_socket(client_stream)
            .unwrap()
            .p2p()
            .build()
            .await
            .unwrap();

        (server, client, state)
    }

    #[tokio::test]
    async fn list_credentials_returns_fixture_for_alice() {
        let (_srv, client, _state) = p2p_pair().await;
        let proxy = zbus::Proxy::new(
            &client,
            "io.dangerousthings.AuthForge",
            "/io/dangerousthings/AuthForge",
            "io.dangerousthings.AuthForge1",
        )
        .await
        .unwrap();

        let creds: Vec<Credential> = proxy
            .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 proxy = zbus::Proxy::new(
            &client,
            "io.dangerousthings.AuthForge",
            "/io/dangerousthings/AuthForge",
            "io.dangerousthings.AuthForge1",
        )
        .await
        .unwrap();

        let p: Policy = proxy.call("GetPolicy", &()).await.unwrap();
        assert!(p.stacks.is_empty());
    }
}

Note: zbus 4 uses connection::Builder from zbus::connection; the import paths may shift slightly between minor versions. If the above paths are stale at implementation time, run cargo doc --open -p zbus and verify against the local docs. Adapt — do not guess.

Step 5: Run:

cargo test -p authforge-daemon dbus

Expected: 2 tests pass.

Step 6: Commit.

git add daemon/src/dbus.rs daemon/src/main.rs
git commit -m "feat(daemon): D-Bus interface with ListCredentials and GetPolicy"

Task 1.9: D-Bus interface — EnrollOwn / RemoveOwn with polkit

Files:

  • Modify: daemon/src/dbus.rs

Step 1: Failing tests (append to the tests module):

    #[tokio::test]
    async fn enroll_own_appends_credential() {
        let (_srv, client, state) = p2p_pair().await;
        let proxy = zbus::Proxy::new(
            &client,
            "io.dangerousthings.AuthForge",
            "/io/dangerousthings/AuthForge",
            "io.dangerousthings.AuthForge1",
        )
        .await
        .unwrap();

        let _: Credential = proxy
            .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 proxy = zbus::Proxy::new(
            &client,
            "io.dangerousthings.AuthForge",
            "/io/dangerousthings/AuthForge",
            "io.dangerousthings.AuthForge1",
        )
        .await
        .unwrap();

        // Alice has fixture-cred-1 from fixtures.
        let _: () = proxy
            .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 proxy = zbus::Proxy::new(
            &client,
            "io.dangerousthings.AuthForge",
            "/io/dangerousthings/AuthForge",
            "io.dangerousthings.AuthForge1",
        )
        .await
        .unwrap();

        let r: Result<(), zbus::Error> = proxy
            .call("RemoveOwn", &("alice", "no-such-id"))
            .await;
        assert!(r.is_err());
    }

Step 2: Run, confirm 3 fails (methods missing).

Step 3: Implement (extend impl AuthForge):

    async fn enroll_own(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        user: String,
        nickname: String,
    ) -> zbus::fdo::Result<Credential> {
        self.authz(&hdr, "io.dangerousthings.AuthForge.enroll-own").await?;

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let cred = Credential {
            id: format!("stub-{user}-{now}"),
            nickname,
            method: authforge_common::types::Method::Fido2,
            transport: authforge_common::types::Transport::Usb,
            created_unix: now,
        };
        self.state.add_credential(&user, cred.clone()).await;
        Ok(cred)
    }

    async fn remove_own(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        user: String,
        cred_id: String,
    ) -> zbus::fdo::Result<()> {
        self.authz(&hdr, "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}")))
        }
    }

Step 4: Add the authz helper (private, not part of the interface):

impl AuthForge {
    async fn authz(
        &self,
        hdr: &zbus::message::Header<'_>,
        action: &str,
    ) -> zbus::fdo::Result<()> {
        // Phase 1: we don't have access to caller pid from the header alone in p2p
        // mode. On the system bus we'd resolve sender → unique name → pid via
        // org.freedesktop.DBus.GetConnectionUnixProcessID. For now, pass 0 — the
        // permissive authorizer ignores it, and the system authorizer running on
        // the real bus will be wired in Task 1.13 with the resolver.
        let pid = 0u32;
        self.polkit
            .check(action, pid)
            .await
            .map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?;
        Ok(())
    }
}

Step 5: cargo test -p authforge-daemon dbus. Expected: 5 tests pass.

Step 6: Commit.

git add daemon/src/dbus.rs
git commit -m "feat(daemon): EnrollOwn and RemoveOwn with polkit gate"

Task 1.10: D-Bus interface — EnrollOther, SetPolicy, pending + recovery

Files:

  • Modify: daemon/src/dbus.rs

Step 1: Failing tests (add four):

    #[tokio::test]
    async fn enroll_other_creates_credential() { /* call EnrollOther("carol","Hers"), assert state */ }

    #[tokio::test]
    async fn set_policy_replaces_state() {
        let (_srv, client, state) = p2p_pair().await;
        // build a Policy with sudo=Required(fido2), call SetPolicy, assert state.get_policy()
    }

    #[tokio::test]
    async fn set_and_clear_pending_flag() { /* SetPendingFlag/ClearPendingFlag, assert has_pending */ }

    #[tokio::test]
    async fn generate_recovery_code_returns_8_digits() { /* call, assert .len() == 8 + all digits */ }

(Fill in the bodies following the same proxy pattern as Task 1.9.)

Step 2: Confirm fails.

Step 3: Implement on impl AuthForge block:

    async fn enroll_other(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        user: String,
        nickname: String,
    ) -> zbus::fdo::Result<Credential> {
        self.authz(&hdr, "io.dangerousthings.AuthForge.enroll-other").await?;
        // Same body as enroll_own — different polkit action.
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let cred = Credential {
            id: format!("stub-{user}-{now}"),
            nickname,
            method: authforge_common::types::Method::Fido2,
            transport: authforge_common::types::Transport::Usb,
            created_unix: now,
        };
        self.state.add_credential(&user, cred.clone()).await;
        Ok(cred)
    }

    async fn set_policy(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        p: Policy,
    ) -> zbus::fdo::Result<PolicyApplyResult> {
        self.authz(&hdr, "io.dangerousthings.AuthForge.set-policy").await?;
        self.state.set_policy(p).await;
        Ok(PolicyApplyResult { applied: true, violations: vec![] })
    }

    async fn set_pending_flag(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        user: String,
        flag: PendingFlag,
    ) -> zbus::fdo::Result<()> {
        self.authz(&hdr, "io.dangerousthings.AuthForge.set-pending").await?;
        self.state.set_pending(&user, flag).await;
        Ok(())
    }

    async fn clear_pending_flag(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        user: String,
    ) -> zbus::fdo::Result<()> {
        self.authz(&hdr, "io.dangerousthings.AuthForge.clear-pending").await?;
        self.state.clear_pending(&user).await;
        Ok(())
    }

    async fn generate_recovery_code(
        &self,
        #[zbus(header)] hdr: zbus::message::Header<'_>,
        _user: String,
    ) -> zbus::fdo::Result<String> {
        self.authz(&hdr, "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}"))
    }

The rand crate isn't in deps yet; add rand = "0.9" to workspace deps and rand = { workspace = true } to daemon deps.

Update imports at top of dbus.rs:

use authforge_common::types::{Credential, PendingFlag, Policy, PolicyApplyResult};

Step 4: cargo test -p authforge-daemon dbus. Expected: all 9 tests pass.

Step 5: Commit.

git add daemon/src/dbus.rs daemon/Cargo.toml Cargo.toml
git commit -m "feat(daemon): EnrollOther, SetPolicy, pending flag, recovery-code stubs"

Task 1.11: Wire up daemon main.rs to register on the system bus

Files:

  • Modify: daemon/src/main.rs

Step 1: Replace main.rs:

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"));

    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 us when stopping the unit.
    std::future::pending::<()>().await;
    Ok(())
}

Step 2: cargo build -p authforge-daemon. Expected: clean.

Step 3: cargo test -p authforge-daemon. Expected: all prior tests still pass (we didn't touch interface code, only main).

Step 4: Commit.

git add daemon/src/main.rs
git commit -m "feat(daemon): register on system bus and serve interface at /io/dangerousthings/AuthForge"

Task 1.12: systemd unit, D-Bus service file, D-Bus policy

Files:

  • Create: debian/authforge-daemon.service (systemd unit)
  • Create: debian/io.dangerousthings.AuthForge.service (D-Bus activation file)
  • Create: debian/io.dangerousthings.AuthForge.conf (D-Bus system policy)

Step 1: debian/authforge-daemon.service:

[Unit]
Description=AuthForge MFA daemon
Documentation=https://github.com/dangerousthings/authforge
After=dbus.service
Requires=dbus.service

[Service]
Type=dbus
BusName=io.dangerousthings.AuthForge
ExecStart=/usr/sbin/authforged
Restart=on-failure
RestartSec=2
# Hardening — daemon needs to read /etc/authforge and /var/lib/authforge,
# write to /etc/pam.d via pam-auth-update, and call libfido2 (USB hid).
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/etc/authforge /var/lib/authforge /etc/pam.d /usr/share/pam-configs /etc/u2f_mappings

[Install]
WantedBy=multi-user.target

Step 2: debian/io.dangerousthings.AuthForge.service (D-Bus activation):

[D-BUS Service]
Name=io.dangerousthings.AuthForge
Exec=/bin/false
User=root
SystemdService=authforge-daemon.service

(Exec=/bin/false because activation goes through systemd. SystemdService line is the magic.)

Step 3: debian/io.dangerousthings.AuthForge.conf (D-Bus policy):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC
 "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>

  <!-- Only root can own the well-known name. -->
  <policy user="root">
    <allow own="io.dangerousthings.AuthForge"/>
  </policy>

  <!-- Any local user may call the interface; method-level authz is enforced
       by polkit inside the daemon. -->
  <policy context="default">
    <allow send_destination="io.dangerousthings.AuthForge"
           send_interface="io.dangerousthings.AuthForge1"/>
    <allow send_destination="io.dangerousthings.AuthForge"
           send_interface="org.freedesktop.DBus.Introspectable"/>
    <allow send_destination="io.dangerousthings.AuthForge"
           send_interface="org.freedesktop.DBus.Properties"/>
  </policy>

</busconfig>

Step 4: Commit.

git add debian/authforge-daemon.service \
        debian/io.dangerousthings.AuthForge.service \
        debian/io.dangerousthings.AuthForge.conf
git commit -m "feat(packaging): systemd unit + D-Bus activation/policy files"

Task 1.13: polkit policy XML

Files:

  • Create: debian/io.dangerousthings.AuthForge.policy

Step 1: Write the XML:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
 "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>

  <vendor>Dangerous Things</vendor>
  <vendor_url>https://dangerousthings.com</vendor_url>

  <action id="io.dangerousthings.AuthForge.enroll-own">
    <description>Enroll a security key for your own account</description>
    <message>Authentication is required to enroll a security key.</message>
    <defaults>
      <allow_any>auth_self_keep</allow_any>
      <allow_inactive>auth_self_keep</allow_inactive>
      <allow_active>auth_self_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.remove-own">
    <description>Remove a security key from your own account</description>
    <message>Authentication is required to remove a security key.</message>
    <defaults>
      <allow_any>auth_self_keep</allow_any>
      <allow_inactive>auth_self_keep</allow_inactive>
      <allow_active>auth_self_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.enroll-other">
    <description>Enroll a security key for another user</description>
    <message>Administrator authentication is required to enroll on behalf of another user.</message>
    <defaults>
      <allow_any>auth_admin_keep</allow_any>
      <allow_inactive>auth_admin_keep</allow_inactive>
      <allow_active>auth_admin_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.set-policy">
    <description>Change AuthForge MFA policy</description>
    <message>Administrator authentication is required to change MFA policy.</message>
    <defaults>
      <allow_any>auth_admin_keep</allow_any>
      <allow_inactive>auth_admin_keep</allow_inactive>
      <allow_active>auth_admin_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.set-pending">
    <description>Set a first-login enrollment flag for a user</description>
    <message>Administrator authentication is required to require enrollment for a user.</message>
    <defaults>
      <allow_any>auth_admin_keep</allow_any>
      <allow_inactive>auth_admin_keep</allow_inactive>
      <allow_active>auth_admin_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.clear-pending">
    <description>Clear a user's first-login enrollment flag</description>
    <message>Administrator authentication is required to clear a pending-enrollment flag.</message>
    <defaults>
      <allow_any>auth_admin_keep</allow_any>
      <allow_inactive>auth_admin_keep</allow_inactive>
      <allow_active>auth_admin_keep</allow_active>
    </defaults>
  </action>

  <action id="io.dangerousthings.AuthForge.generate-recovery">
    <description>Generate a one-time recovery code for a user</description>
    <message>Administrator authentication is required to generate a recovery code.</message>
    <defaults>
      <allow_any>auth_admin_keep</allow_any>
      <allow_inactive>auth_admin_keep</allow_inactive>
      <allow_active>auth_admin_keep</allow_active>
    </defaults>
  </action>

</policyconfig>

Step 2: Validate against the DTD if xmllint is available:

xmllint --noout --valid debian/io.dangerousthings.AuthForge.policy 2>&1 || true

(May fail to fetch the DTD offline — non-fatal. polkit itself parses leniently.)

Step 3: Commit.

git add debian/io.dangerousthings.AuthForge.policy
git commit -m "feat(packaging): polkit policy with 7 AuthForge actions"

Task 1.14: postinst — enable + start daemon

Files:

  • Create: debian/authforge-daemon.postinst

Step 1: Write it:

#!/bin/sh
set -e

#DEBHELPER#

case "$1" in
    configure)
        # Reload systemd to pick up our new unit.
        if [ -d /run/systemd/system ]; then
            systemctl daemon-reload || true
            # Don't enable on the build chroot.
            if [ "$2" = "" ] || [ -z "${DPKG_ROOT:-}" ]; then
                deb-systemd-helper enable authforge-daemon.service >/dev/null || true
                deb-systemd-invoke start authforge-daemon.service >/dev/null || true
            fi
        fi
        ;;
esac

exit 0

#DEBHELPER# is replaced at build time with dh-generated snippets (e.g., dh_installsystemd).

chmod 0755.

Step 2: Commit.

chmod 0755 debian/authforge-daemon.postinst
git add debian/authforge-daemon.postinst
git commit -m "feat(packaging): postinst that enables + starts authforge-daemon"

Task 1.15: Wire packaging install rules

Files:

  • Modify: debian/rules

Step 1: Append new install lines to override_dh_auto_install:

	# D-Bus activation file
	install -D -m 0644 debian/io.dangerousthings.AuthForge.service \
	    debian/authforge-daemon/usr/share/dbus-1/system-services/io.dangerousthings.AuthForge.service
	# D-Bus system policy
	install -D -m 0644 debian/io.dangerousthings.AuthForge.conf \
	    debian/authforge-daemon/usr/share/dbus-1/system.d/io.dangerousthings.AuthForge.conf
	# polkit actions
	install -D -m 0644 debian/io.dangerousthings.AuthForge.policy \
	    debian/authforge-daemon/usr/share/polkit-1/actions/io.dangerousthings.AuthForge.policy
	# systemd unit (dh_installsystemd will enable it)
	install -D -m 0644 debian/authforge-daemon.service \
	    debian/authforge-daemon/lib/systemd/system/authforge-daemon.service

Step 2: Commit.

git add debian/rules
git commit -m "feat(packaging): install dbus, polkit, systemd assets into authforge-daemon"

Task 1.16: Update debian/control deps

Files:

  • Modify: debian/control

Step 1: Add to authforge-daemon Depends::

dbus, policykit-1

(Many distros call this polkit now; policykit-1 is the apt name on Ubuntu 22.04 LTS through at least 24.04. Recheck on 26.04.)

Final Depends: line:

Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1, dbus, policykit-1

Step 2: Commit.

git add debian/control
git commit -m "chore(packaging): authforge-daemon depends on dbus + policykit"

Task 1.17: CI — install dbus dev headers, run new tests

Files:

  • Modify: .github/workflows/ci.yml

Step 1: Update the rust: job's apt step to include the libs we now use at runtime in tests:

      - run: sudo apt-get update && sudo apt-get install -y \
            libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config \
            dbus

(zbus pure-Rust needs no headers; we add dbus for the busctl binary if we add a smoke step later.)

Step 2: Commit.

git add .github/workflows/ci.yml
git commit -m "ci: ensure dbus is installed for daemon tests"

Task 1.18: Manual smoke test (no CI; run locally if possible)

These are manual verification steps. Skip if no system bus / not on Linux desktop.

# Build release.
cargo build --release -p authforge-daemon

# Drop the assets in place (or sudo dpkg -i the deb once Task 1.20 lands).
sudo cp target/release/authforged /usr/sbin/authforged
sudo cp debian/io.dangerousthings.AuthForge.service /usr/share/dbus-1/system-services/
sudo cp debian/io.dangerousthings.AuthForge.conf    /usr/share/dbus-1/system.d/
sudo cp debian/io.dangerousthings.AuthForge.policy  /usr/share/polkit-1/actions/
sudo cp debian/authforge-daemon.service             /lib/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start authforge-daemon
sudo systemctl status authforge-daemon

# Hit the interface as a regular user.
busctl introspect io.dangerousthings.AuthForge /io/dangerousthings/AuthForge
busctl call io.dangerousthings.AuthForge \
    /io/dangerousthings/AuthForge \
    io.dangerousthings.AuthForge1 \
    ListCredentials s "$USER"
# Expected: empty array (or the alice fixture if you happen to be alice).

# Try a write — expect a polkit prompt.
busctl --user 0 call ...  # placeholder; real call comes from CLI in Phase 7.

If the polkit prompt does not appear, check /var/log/auth.log (or journalctl -u polkit) for parse errors in the policy XML.

No commit for this task — it's verification.


Task 1.19: Phase 1 acceptance gate

Verify before declaring Phase 1 complete:

  • cargo build --workspace --release succeeds.
  • cargo test --workspace succeeds and includes the new daemon dbus + state + polkit tests.
  • cargo clippy --workspace -- -D warnings is clean.
  • debuild -us -uc -b produces 5 debs (no regression from Phase 0).
  • busctl introspect ... lists all 9 methods.
  • As non-root: busctl call ... SetPolicy ... triggers a polkit auth prompt.
  • systemctl status authforge-daemon shows active after install.
  • CI green on a PR branch.

Tag the milestone:

git tag -a v0.1.0-phase1 -m "Phase 1: D-Bus interface + systemd + polkit"

Risks / known unknowns going into Phase 1

Risk Mitigation
zbus 4 API renames between minor versions (ConnectionBuilder vs connection::Builder). Adapt at implementation time; check cargo doc --open -p zbus. The plan's snippets are zbus 4.x at the time of writing.
Polkit subject resolution from a P2P / sender unique name needs the org.freedesktop.DBus.GetConnectionUnixProcessID round-trip. Phase 1 punts on this — authz() passes pid 0 and we run polkit in permissive-by-env mode for the smoke test. The full subject resolver lands in Phase 1.5 or early Phase 2 when CLI starts making real calls.
systemd hardening (ProtectSystem=full) may block writes the daemon needs. We list ReadWritePaths covering /etc/authforge, /var/lib/authforge, /etc/pam.d, /usr/share/pam-configs, /etc/u2f_mappings. Adjust if Phase 4 finds gaps.
policykit-1 may have been renamed to polkit on the next Ubuntu LTS. Recheck before Phase 14 (PPA setup). Easy fix in control.

Execution Handoff

Plan complete and saved to docs/plans/2026-04-26-phase-1-dbus.md. Phase 1 is broken into 19 bite-sized tasks. Each writes a failing test first (where mechanical), implements, runs the test, commits. The final acceptance gate verifies the full pipeline.

Two execution options:

1. Subagent-Driven (this session) — I dispatch a fresh subagent per task, review between tasks, fast iteration.

2. Parallel Session (separate) — Open a new session in a worktree using superpowers:executing-plans, batch execution with checkpoints.

Which approach?