diff --git a/docs/plans/2026-04-26-phase-1-dbus.md b/docs/plans/2026-04-26-phase-1-dbus.md new file mode 100644 index 0000000..3211751 --- /dev/null +++ b/docs/plans/2026-04-26-phase-1-dbus.md @@ -0,0 +1,1513 @@ +# 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?