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

1514 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:**
- 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<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.
```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<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.
```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<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):
```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<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:
```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<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.
```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<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`:
```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<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:
```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<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):
```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<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`:
```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
<?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.
```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
<?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:
```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?