# 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 2–5; 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:** - Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) - Master plan (Phase 1 section): [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) --- ## 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: ```toml zvariant = "4" ``` (zbus 4 re-exports zvariant 4 — they must match major.) **Step 2:** Edit `common/Cargo.toml`. Under `[dependencies]`, add: ```toml 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: ```bash cargo check -p authforge-common ``` Expected: clean. **Step 4:** Commit. ```bash 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): ```rust #[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: ```bash 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`: ```rust #[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: ```bash cargo test -p authforge-common ``` Expected: PASS, 3 tests (mode + method + credential). **Step 5:** Commit. ```bash 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: ```toml [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: ```rust #[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: ```rust 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, } #[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, #[serde(default)] pub deadline_hours: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)] pub struct Policy { #[serde(default)] pub stacks: BTreeMap, #[serde(default)] pub storage: Storage, #[serde(default)] pub firstrun: Firstrun, } ``` **Step 4:** Run `cargo test -p authforge-common`. Expected: PASS. **Step 5:** Commit. ```bash 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: ```rust #[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): ```rust #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)] pub struct PendingFlag { pub required_methods: Vec, pub created_unix: u64, pub deadline_unix: Option, 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, } ``` **Step 4:** Test passes. **Step 5:** Commit. ```bash 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]`: ```toml zvariant = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } nix = { workspace = true } ``` Add a `[dev-dependencies]` section: ```toml [dev-dependencies] tempfile = "3" ``` **Step 2:** Add to root `Cargo.toml` `[workspace.dependencies]`: ```toml 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. ```bash 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` 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): ```rust // 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, } #[derive(Default)] struct StateInner { credentials: HashMap>, // user -> creds policy: Policy, pending: HashMap, } 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 { 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: ```bash cargo test -p authforge-daemon ``` Expected: PASS, 4 tests. **Step 4:** Commit. ```bash 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: ```rust // 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) = 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. ```bash 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: ```rust // 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, pub polkit: Arc, } #[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 { 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`: ```rust 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`: ```rust #[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) { 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 = 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: ```bash cargo test -p authforge-daemon dbus ``` Expected: 2 tests pass. **Step 6:** Commit. ```bash 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): ```rust #[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`): ```rust async fn enroll_own( &self, #[zbus(header)] hdr: zbus::message::Header<'_>, user: String, nickname: String, ) -> zbus::fdo::Result { 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): ```rust 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. ```bash 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): ```rust #[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: ```rust async fn enroll_other( &self, #[zbus(header)] hdr: zbus::message::Header<'_>, user: String, nickname: String, ) -> zbus::fdo::Result { 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 { 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 { 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`: ```rust use authforge_common::types::{Credential, PendingFlag, Policy, PolicyApplyResult}; ``` **Step 4:** `cargo test -p authforge-daemon dbus`. Expected: all 9 tests pass. **Step 5:** Commit. ```bash 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`: ```rust 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. ```bash 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`: ```ini [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): ```ini [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 ``` **Step 4:** Commit. ```bash 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 Dangerous Things https://dangerousthings.com Enroll a security key for your own account Authentication is required to enroll a security key. auth_self_keep auth_self_keep auth_self_keep Remove a security key from your own account Authentication is required to remove a security key. auth_self_keep auth_self_keep auth_self_keep Enroll a security key for another user Administrator authentication is required to enroll on behalf of another user. auth_admin_keep auth_admin_keep auth_admin_keep Change AuthForge MFA policy Administrator authentication is required to change MFA policy. auth_admin_keep auth_admin_keep auth_admin_keep Set a first-login enrollment flag for a user Administrator authentication is required to require enrollment for a user. auth_admin_keep auth_admin_keep auth_admin_keep Clear a user's first-login enrollment flag Administrator authentication is required to clear a pending-enrollment flag. auth_admin_keep auth_admin_keep auth_admin_keep Generate a one-time recovery code for a user Administrator authentication is required to generate a recovery code. auth_admin_keep auth_admin_keep auth_admin_keep ``` **Step 2:** Validate against the DTD if `xmllint` is available: ```bash 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. ```bash 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: ```bash #!/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. ```bash 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`: ```makefile # 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. ```bash 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. ```bash 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: ```yaml - 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. ```bash 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. ```bash # 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: ```bash 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?