44 KiB
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
- Master plan (Phase 1 section): 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 workspacecargo buildexcludes GUI by default (default-membersin rootCargo.toml).
Task 1.1: Add zvariant to workspace deps
Files:
- Modify:
Cargo.toml(workspace root) - Modify:
common/Cargo.toml
Why: Wire types live in common and need #[derive(Type)] for D-Bus marshaling. Adding zvariant once at workspace level keeps the version consistent across daemon, cli, and common.
Step 1: Edit root Cargo.toml. In [workspace.dependencies], add:
zvariant = "4"
(zbus 4 re-exports zvariant 4 — they must match major.)
Step 2: Edit common/Cargo.toml. Under [dependencies], add:
zvariant = { workspace = true }
serde_json = { workspace = true }
(serde_json was already a dev-dep; promote to a real dep so types can be JSON-serialized in tests and inspection paths.)
Step 3: Verify it compiles:
cargo check -p authforge-common
Expected: clean.
Step 4: Commit.
git add Cargo.toml common/Cargo.toml
git commit -m "chore: add zvariant to workspace deps"
Task 1.2: Define Credential wire type
Files:
- Modify:
common/src/types.rs
Step 1: Append the failing test (do not implement yet):
#[test]
fn credential_roundtrips_via_serde() {
let c = Credential {
id: "abc123".to_string(),
nickname: "Yellow Yubikey".to_string(),
method: Method::Fido2,
transport: Transport::Usb,
created_unix: 1_700_000_000,
};
let json = serde_json::to_string(&c).unwrap();
let back: Credential = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
Step 2: Run test, confirm failure:
cargo test -p authforge-common credential_roundtrips_via_serde
Expected: compile error — Credential and Transport undefined.
Step 3: Implement. Append to common/src/types.rs:
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, zvariant::Type,
)]
#[serde(rename_all = "lowercase")]
#[zvariant(signature = "s")]
pub enum Transport {
Usb,
Nfc,
Internal,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Credential {
pub id: String,
pub nickname: String,
pub method: Method,
pub transport: Transport,
pub created_unix: u64,
}
Also add zvariant::Type and #[zvariant(signature = "s")] to the existing Mode and Method enums (they cross D-Bus too).
Step 4: Run:
cargo test -p authforge-common
Expected: PASS, 3 tests (mode + method + credential).
Step 5: Commit.
git add common/src/types.rs
git commit -m "feat: add Credential and Transport wire types"
Task 1.3: Define Policy, StackPolicy, Storage, Firstrun types
Files:
- Modify:
common/src/types.rs
These mirror the TOML format from the design doc:
[stacks.gdm-password]
mode = "required"
methods = ["fido2", "totp"]
[storage]
backend = "central"
central_path = "/etc/u2f_mappings"
[firstrun]
default_required_methods = ["fido2"]
deadline_hours = 0
Step 1: Failing test:
#[test]
fn policy_default_is_all_disabled() {
let p = Policy::default();
assert!(p.stacks.is_empty());
assert_eq!(p.storage.backend, StorageBackend::PerUser);
assert!(p.firstrun.default_required_methods.is_empty());
assert_eq!(p.firstrun.deadline_hours, 0);
}
#[test]
fn policy_serde_via_json() {
let mut p = Policy::default();
p.stacks.insert(
"sudo".to_string(),
StackPolicy { mode: Mode::Required, methods: vec![Method::Fido2] },
);
let json = serde_json::to_string(&p).unwrap();
let back: Policy = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
Step 2: Run, confirm fail (types undefined).
Step 3: Implement:
use std::collections::BTreeMap;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default,
)]
#[serde(rename_all = "kebab-case")]
#[zvariant(signature = "s")]
pub enum StorageBackend {
#[default]
PerUser,
Central,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct StackPolicy {
pub mode: Mode,
pub methods: Vec<Method>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Storage {
#[serde(default)]
pub backend: StorageBackend,
#[serde(default)]
pub central_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Firstrun {
#[serde(default)]
pub default_required_methods: Vec<Method>,
#[serde(default)]
pub deadline_hours: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type, Default)]
pub struct Policy {
#[serde(default)]
pub stacks: BTreeMap<String, StackPolicy>,
#[serde(default)]
pub storage: Storage,
#[serde(default)]
pub firstrun: Firstrun,
}
Step 4: Run cargo test -p authforge-common. Expected: PASS.
Step 5: Commit.
git add common/src/types.rs
git commit -m "feat: add Policy, StackPolicy, Storage, Firstrun types"
Task 1.4: Define PendingFlag, PolicyApplyResult, Violation
Files:
- Modify:
common/src/types.rs
Step 1: Failing test:
#[test]
fn pending_flag_serde() {
let f = PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 1_700_000_000,
deadline_unix: None,
re_enroll: false,
};
let json = serde_json::to_string(&f).unwrap();
let back: PendingFlag = serde_json::from_str(&json).unwrap();
assert_eq!(f, back);
}
#[test]
fn policy_apply_result_ok_no_violations() {
let r = PolicyApplyResult { applied: true, violations: vec![] };
assert!(r.applied);
assert!(r.violations.is_empty());
}
Step 2: Run, confirm fail.
Step 3: Implement (append):
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingFlag {
pub required_methods: Vec<Method>,
pub created_unix: u64,
pub deadline_unix: Option<u64>,
pub re_enroll: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct Violation {
pub user: String,
pub stack: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PolicyApplyResult {
pub applied: bool,
pub violations: Vec<Violation>,
}
Step 4: Test passes.
Step 5: Commit.
git add common/src/types.rs
git commit -m "feat: add PendingFlag, PolicyApplyResult, Violation types"
Task 1.5: Add daemon dependencies
Files:
- Modify:
daemon/Cargo.toml
Step 1: Add to [dependencies]:
zvariant = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
nix = { workspace = true }
Add a [dev-dependencies] section:
[dev-dependencies]
tempfile = "3"
Step 2: Add to root Cargo.toml [workspace.dependencies]:
tempfile = "3"
(Then convert the daemon dev-dep line to tempfile = { workspace = true }.)
Step 3: cargo check -p authforge-daemon. Expected: clean.
Step 4: Commit.
git add Cargo.toml daemon/Cargo.toml
git commit -m "chore: add daemon deps for D-Bus interface and tests"
Task 1.6: Daemon state module — fixture-backed app state
Files:
- Create:
daemon/src/state.rs - Modify:
daemon/src/main.rs(addmod state;)
The AppState is a tokio::sync::RwLock<StateInner> so the interface impl can take &self and still mutate. Phase 1 fixtures live in StateInner; Phase 2 swaps them for real storage.
Step 1: Write the failing test in a new module at the bottom of daemon/src/state.rs (created with skeleton):
// daemon/src/state.rs
use authforge_common::types::{Credential, Method, PendingFlag, Policy, Transport};
use std::collections::HashMap;
use tokio::sync::RwLock;
#[derive(Default)]
pub struct AppState {
inner: RwLock<StateInner>,
}
#[derive(Default)]
struct StateInner {
credentials: HashMap<String, Vec<Credential>>, // user -> creds
policy: Policy,
pending: HashMap<String, PendingFlag>,
}
impl AppState {
pub fn with_fixtures() -> Self {
let mut inner = StateInner::default();
inner.credentials.insert(
"alice".to_string(),
vec![Credential {
id: "fixture-cred-1".to_string(),
nickname: "Alice's Yubikey".to_string(),
method: Method::Fido2,
transport: Transport::Usb,
created_unix: 1_700_000_000,
}],
);
Self { inner: RwLock::new(inner) }
}
pub async fn list_credentials(&self, user: &str) -> Vec<Credential> {
self.inner.read().await.credentials.get(user).cloned().unwrap_or_default()
}
pub async fn add_credential(&self, user: &str, c: Credential) {
self.inner.write().await.credentials.entry(user.to_string()).or_default().push(c);
}
pub async fn remove_credential(&self, user: &str, cred_id: &str) -> bool {
let mut g = self.inner.write().await;
let Some(list) = g.credentials.get_mut(user) else { return false };
let before = list.len();
list.retain(|c| c.id != cred_id);
list.len() != before
}
pub async fn get_policy(&self) -> Policy {
self.inner.read().await.policy.clone()
}
pub async fn set_policy(&self, p: Policy) {
self.inner.write().await.policy = p;
}
pub async fn set_pending(&self, user: &str, f: PendingFlag) {
self.inner.write().await.pending.insert(user.to_string(), f);
}
pub async fn clear_pending(&self, user: &str) -> bool {
self.inner.write().await.pending.remove(user).is_some()
}
pub async fn has_pending(&self, user: &str) -> bool {
self.inner.read().await.pending.contains_key(user)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fixtures_seeded_for_alice() {
let s = AppState::with_fixtures();
let creds = s.list_credentials("alice").await;
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].nickname, "Alice's Yubikey");
}
#[tokio::test]
async fn unknown_user_has_no_creds() {
let s = AppState::with_fixtures();
assert!(s.list_credentials("nobody").await.is_empty());
}
#[tokio::test]
async fn add_then_remove_credential() {
let s = AppState::default();
let c = Credential {
id: "id1".to_string(),
nickname: "x".to_string(),
method: Method::Fido2,
transport: Transport::Usb,
created_unix: 0,
};
s.add_credential("bob", c).await;
assert_eq!(s.list_credentials("bob").await.len(), 1);
assert!(s.remove_credential("bob", "id1").await);
assert!(s.list_credentials("bob").await.is_empty());
}
#[tokio::test]
async fn pending_set_clear_roundtrip() {
let s = AppState::default();
assert!(!s.has_pending("alice").await);
s.set_pending(
"alice",
PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 1,
deadline_unix: None,
re_enroll: false,
},
)
.await;
assert!(s.has_pending("alice").await);
assert!(s.clear_pending("alice").await);
assert!(!s.has_pending("alice").await);
}
}
Step 2: Add mod state; to daemon/src/main.rs.
Step 3: Run:
cargo test -p authforge-daemon
Expected: PASS, 4 tests.
Step 4: Commit.
git add daemon/src/state.rs daemon/src/main.rs
git commit -m "feat(daemon): add AppState with fixture-backed in-memory store"
Task 1.7: Daemon polkit helper — pluggable authorizer
Files:
- Create:
daemon/src/polkit.rs - Modify:
daemon/src/main.rs(addmod polkit;)
Why now: the interface impl will call authz.check(&action, sender_pid) for each write method. Phase 1 ships a Polkit::permissive() mode for tests + initial debug, plus a Polkit::system() constructor that dispatches to the real org.freedesktop.PolicyKit1.Authority. The system mode is implemented now (small) so the daemon is auth-real from day one; bypass is opt-in via env var only.
Step 1: Failing test:
// daemon/src/polkit.rs
use thiserror::Error;
use zbus::Connection;
#[derive(Debug, Error)]
pub enum PolkitError {
#[error("not authorized: {action}")]
NotAuthorized { action: String },
#[error(transparent)]
Bus(#[from] zbus::Error),
}
pub enum Polkit {
Permissive,
System(Connection),
}
impl Polkit {
pub fn permissive() -> Self {
Self::Permissive
}
pub async fn system(conn: Connection) -> Self {
Self::System(conn)
}
/// Check whether `pid` may invoke `action`. Phase 1 stub: permissive mode
/// always allows; system mode implemented in Step 3 of this task.
pub async fn check(&self, action: &str, pid: u32) -> Result<(), PolkitError> {
match self {
Self::Permissive => Ok(()),
Self::System(conn) => system_check(conn, action, pid).await,
}
}
}
async fn system_check(conn: &Connection, action: &str, pid: u32) -> Result<(), PolkitError> {
use zbus::zvariant::Value;
use std::collections::HashMap;
// Subject = ("unix-process", { "pid": u32, "start-time": u64 })
// start-time 0 is accepted by polkit; it'll fetch from /proc itself.
let mut subject_details: HashMap<&str, Value> = HashMap::new();
subject_details.insert("pid", Value::U32(pid));
subject_details.insert("start-time", Value::U64(0));
let subject = ("unix-process", subject_details);
let details: HashMap<&str, &str> = HashMap::new();
let flags: u32 = 0; // no interaction; daemon does not block on auth prompt
let cancellation_id = "";
let proxy = zbus::Proxy::new(
conn,
"org.freedesktop.PolicyKit1",
"/org/freedesktop/PolicyKit1/Authority",
"org.freedesktop.PolicyKit1.Authority",
)
.await?;
let (is_authorized, _is_challenge, _details): (bool, bool, HashMap<String, String>) = proxy
.call(
"CheckAuthorization",
&(subject, action, details, flags, cancellation_id),
)
.await?;
if is_authorized {
Ok(())
} else {
Err(PolkitError::NotAuthorized { action: action.to_string() })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn permissive_always_allows() {
let p = Polkit::permissive();
p.check("io.dangerousthings.AuthForge.set-policy", 1234).await.unwrap();
}
}
Step 2: cargo test -p authforge-daemon polkit. Expected: PASS.
Step 3: Add mod polkit; to daemon/src/main.rs.
Step 4: cargo clippy -p authforge-daemon --all-targets -- -D warnings. Expected: clean.
Step 5: Commit.
git add daemon/src/polkit.rs daemon/src/main.rs
git commit -m "feat(daemon): add polkit authorizer with permissive + system modes"
Task 1.8: D-Bus interface — read methods (ListCredentials, GetPolicy)
Files:
- Create:
daemon/src/dbus.rs - Modify:
daemon/src/main.rs(addmod dbus;)
These two methods are unauthenticated per design (read is free), so they don't call polkit.
Step 1: Skeleton:
// daemon/src/dbus.rs
use crate::{polkit::Polkit, state::AppState};
use authforge_common::types::{Credential, Policy};
use std::sync::Arc;
pub struct AuthForge {
pub state: Arc<AppState>,
pub polkit: Arc<Polkit>,
}
#[zbus::interface(name = "io.dangerousthings.AuthForge1")]
impl AuthForge {
async fn list_credentials(&self, user: String) -> zbus::fdo::Result<Vec<Credential>> {
Ok(self.state.list_credentials(&user).await)
}
async fn get_policy(&self) -> zbus::fdo::Result<Policy> {
Ok(self.state.get_policy().await)
}
}
Step 2: Add mod dbus; to daemon/src/main.rs.
Step 3: cargo build -p authforge-daemon. Expected: clean.
Step 4: Add an integration test using a P2P connection. Create daemon/tests/dbus_p2p.rs:
use authforge_common::types::Method;
use std::sync::Arc;
// Re-export internals via a tiny helper so the integration test can construct them.
// We'll add this helper in the daemon main lib in a follow-up — for now, copy the
// minimal types here would require a lib target. Instead, run this as a unit test
// inside the daemon crate.
Actually the daemon is a binary crate, not a library. Either (a) add lib.rs or (b) keep tests as #[cfg(test)] mod tests inside dbus.rs. Take option (b) — simpler.
Append to daemon/src/dbus.rs:
#[cfg(test)]
mod tests {
use super::*;
use authforge_common::types::Method;
use zbus::Connection;
/// Spin up a peer-to-peer zbus connection (no system bus needed),
/// register the interface on one side, and hit it from the other.
async fn p2p_pair() -> (Connection, Connection, Arc<AppState>) {
let state = Arc::new(AppState::with_fixtures());
let auth = AuthForge {
state: state.clone(),
polkit: Arc::new(Polkit::permissive()),
};
let guid = zbus::Guid::generate();
let (server_stream, client_stream) = tokio::io::duplex(8192);
let server = zbus::ConnectionBuilder::tokio_socket(server_stream)
.unwrap()
.server(&guid)
.unwrap()
.p2p()
.serve_at("/io/dangerousthings/AuthForge", auth)
.unwrap()
.build()
.await
.unwrap();
let client = zbus::ConnectionBuilder::tokio_socket(client_stream)
.unwrap()
.p2p()
.build()
.await
.unwrap();
(server, client, state)
}
#[tokio::test]
async fn list_credentials_returns_fixture_for_alice() {
let (_srv, client, _state) = p2p_pair().await;
let proxy = zbus::Proxy::new(
&client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap();
let creds: Vec<Credential> = proxy
.call("ListCredentials", &("alice",))
.await
.unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].method, Method::Fido2);
}
#[tokio::test]
async fn get_policy_returns_default() {
let (_srv, client, _state) = p2p_pair().await;
let proxy = zbus::Proxy::new(
&client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap();
let p: Policy = proxy.call("GetPolicy", &()).await.unwrap();
assert!(p.stacks.is_empty());
}
}
Note: zbus 4 uses
connection::Builderfromzbus::connection; the import paths may shift slightly between minor versions. If the above paths are stale at implementation time, runcargo doc --open -p zbusand verify against the local docs. Adapt — do not guess.
Step 5: Run:
cargo test -p authforge-daemon dbus
Expected: 2 tests pass.
Step 6: Commit.
git add daemon/src/dbus.rs daemon/src/main.rs
git commit -m "feat(daemon): D-Bus interface with ListCredentials and GetPolicy"
Task 1.9: D-Bus interface — EnrollOwn / RemoveOwn with polkit
Files:
- Modify:
daemon/src/dbus.rs
Step 1: Failing tests (append to the tests module):
#[tokio::test]
async fn enroll_own_appends_credential() {
let (_srv, client, state) = p2p_pair().await;
let proxy = zbus::Proxy::new(
&client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap();
let _: Credential = proxy
.call("EnrollOwn", &("bob", "Bob's Key"))
.await
.unwrap();
assert_eq!(state.list_credentials("bob").await.len(), 1);
}
#[tokio::test]
async fn remove_own_drops_credential() {
let (_srv, client, state) = p2p_pair().await;
let proxy = zbus::Proxy::new(
&client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap();
// Alice has fixture-cred-1 from fixtures.
let _: () = proxy
.call("RemoveOwn", &("alice", "fixture-cred-1"))
.await
.unwrap();
assert!(state.list_credentials("alice").await.is_empty());
}
#[tokio::test]
async fn remove_own_unknown_id_errors() {
let (_srv, client, _state) = p2p_pair().await;
let proxy = zbus::Proxy::new(
&client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap();
let r: Result<(), zbus::Error> = proxy
.call("RemoveOwn", &("alice", "no-such-id"))
.await;
assert!(r.is_err());
}
Step 2: Run, confirm 3 fails (methods missing).
Step 3: Implement (extend impl AuthForge):
async fn enroll_own(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
self.authz(&hdr, "io.dangerousthings.AuthForge.enroll-own").await?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cred = Credential {
id: format!("stub-{user}-{now}"),
nickname,
method: authforge_common::types::Method::Fido2,
transport: authforge_common::types::Transport::Usb,
created_unix: now,
};
self.state.add_credential(&user, cred.clone()).await;
Ok(cred)
}
async fn remove_own(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
user: String,
cred_id: String,
) -> zbus::fdo::Result<()> {
self.authz(&hdr, "io.dangerousthings.AuthForge.remove-own").await?;
if self.state.remove_credential(&user, &cred_id).await {
Ok(())
} else {
Err(zbus::fdo::Error::Failed(format!("no credential {cred_id} for {user}")))
}
}
Step 4: Add the authz helper (private, not part of the interface):
impl AuthForge {
async fn authz(
&self,
hdr: &zbus::message::Header<'_>,
action: &str,
) -> zbus::fdo::Result<()> {
// Phase 1: we don't have access to caller pid from the header alone in p2p
// mode. On the system bus we'd resolve sender → unique name → pid via
// org.freedesktop.DBus.GetConnectionUnixProcessID. For now, pass 0 — the
// permissive authorizer ignores it, and the system authorizer running on
// the real bus will be wired in Task 1.13 with the resolver.
let pid = 0u32;
self.polkit
.check(action, pid)
.await
.map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?;
Ok(())
}
}
Step 5: cargo test -p authforge-daemon dbus. Expected: 5 tests pass.
Step 6: Commit.
git add daemon/src/dbus.rs
git commit -m "feat(daemon): EnrollOwn and RemoveOwn with polkit gate"
Task 1.10: D-Bus interface — EnrollOther, SetPolicy, pending + recovery
Files:
- Modify:
daemon/src/dbus.rs
Step 1: Failing tests (add four):
#[tokio::test]
async fn enroll_other_creates_credential() { /* call EnrollOther("carol","Hers"), assert state */ }
#[tokio::test]
async fn set_policy_replaces_state() {
let (_srv, client, state) = p2p_pair().await;
// build a Policy with sudo=Required(fido2), call SetPolicy, assert state.get_policy()
}
#[tokio::test]
async fn set_and_clear_pending_flag() { /* SetPendingFlag/ClearPendingFlag, assert has_pending */ }
#[tokio::test]
async fn generate_recovery_code_returns_8_digits() { /* call, assert .len() == 8 + all digits */ }
(Fill in the bodies following the same proxy pattern as Task 1.9.)
Step 2: Confirm fails.
Step 3: Implement on impl AuthForge block:
async fn enroll_other(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
self.authz(&hdr, "io.dangerousthings.AuthForge.enroll-other").await?;
// Same body as enroll_own — different polkit action.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cred = Credential {
id: format!("stub-{user}-{now}"),
nickname,
method: authforge_common::types::Method::Fido2,
transport: authforge_common::types::Transport::Usb,
created_unix: now,
};
self.state.add_credential(&user, cred.clone()).await;
Ok(cred)
}
async fn set_policy(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
p: Policy,
) -> zbus::fdo::Result<PolicyApplyResult> {
self.authz(&hdr, "io.dangerousthings.AuthForge.set-policy").await?;
self.state.set_policy(p).await;
Ok(PolicyApplyResult { applied: true, violations: vec![] })
}
async fn set_pending_flag(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
user: String,
flag: PendingFlag,
) -> zbus::fdo::Result<()> {
self.authz(&hdr, "io.dangerousthings.AuthForge.set-pending").await?;
self.state.set_pending(&user, flag).await;
Ok(())
}
async fn clear_pending_flag(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
user: String,
) -> zbus::fdo::Result<()> {
self.authz(&hdr, "io.dangerousthings.AuthForge.clear-pending").await?;
self.state.clear_pending(&user).await;
Ok(())
}
async fn generate_recovery_code(
&self,
#[zbus(header)] hdr: zbus::message::Header<'_>,
_user: String,
) -> zbus::fdo::Result<String> {
self.authz(&hdr, "io.dangerousthings.AuthForge.generate-recovery").await?;
// Stub: 8 random digits. Phase 12 replaces with Argon2id-backed real flow.
use rand::Rng;
let n: u32 = rand::rng().random_range(0..100_000_000);
Ok(format!("{n:08}"))
}
The rand crate isn't in deps yet; add rand = "0.9" to workspace deps and rand = { workspace = true } to daemon deps.
Update imports at top of dbus.rs:
use authforge_common::types::{Credential, PendingFlag, Policy, PolicyApplyResult};
Step 4: cargo test -p authforge-daemon dbus. Expected: all 9 tests pass.
Step 5: Commit.
git add daemon/src/dbus.rs daemon/Cargo.toml Cargo.toml
git commit -m "feat(daemon): EnrollOther, SetPolicy, pending flag, recovery-code stubs"
Task 1.11: Wire up daemon main.rs to register on the system bus
Files:
- Modify:
daemon/src/main.rs
Step 1: Replace main.rs:
use anyhow::{Context, Result};
use std::sync::Arc;
use tracing::{info, warn};
mod dbus;
mod polkit;
mod state;
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
info!("authforged {} starting", env!("CARGO_PKG_VERSION"));
let conn = zbus::Connection::system()
.await
.context("connecting to system D-Bus")?;
let polkit = if std::env::var("AUTHFORGE_POLKIT_BYPASS").is_ok() {
warn!("AUTHFORGE_POLKIT_BYPASS set — polkit checks are DISABLED. Do not use in production.");
polkit::Polkit::permissive()
} else {
polkit::Polkit::system(conn.clone()).await
};
let state = Arc::new(state::AppState::with_fixtures());
let auth = dbus::AuthForge {
state: state.clone(),
polkit: Arc::new(polkit),
};
conn.object_server().at(OBJECT_PATH, auth).await?;
conn.request_name(BUS_NAME)
.await
.context("acquiring well-known bus name (another instance running?)")?;
info!("listening on D-Bus as {BUS_NAME} at {OBJECT_PATH}");
// Park forever. systemd will SIGTERM us when stopping the unit.
std::future::pending::<()>().await;
Ok(())
}
Step 2: cargo build -p authforge-daemon. Expected: clean.
Step 3: cargo test -p authforge-daemon. Expected: all prior tests still pass (we didn't touch interface code, only main).
Step 4: Commit.
git add daemon/src/main.rs
git commit -m "feat(daemon): register on system bus and serve interface at /io/dangerousthings/AuthForge"
Task 1.12: systemd unit, D-Bus service file, D-Bus policy
Files:
- Create:
debian/authforge-daemon.service(systemd unit) - Create:
debian/io.dangerousthings.AuthForge.service(D-Bus activation file) - Create:
debian/io.dangerousthings.AuthForge.conf(D-Bus system policy)
Step 1: debian/authforge-daemon.service:
[Unit]
Description=AuthForge MFA daemon
Documentation=https://github.com/dangerousthings/authforge
After=dbus.service
Requires=dbus.service
[Service]
Type=dbus
BusName=io.dangerousthings.AuthForge
ExecStart=/usr/sbin/authforged
Restart=on-failure
RestartSec=2
# Hardening — daemon needs to read /etc/authforge and /var/lib/authforge,
# write to /etc/pam.d via pam-auth-update, and call libfido2 (USB hid).
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/etc/authforge /var/lib/authforge /etc/pam.d /usr/share/pam-configs /etc/u2f_mappings
[Install]
WantedBy=multi-user.target
Step 2: debian/io.dangerousthings.AuthForge.service (D-Bus activation):
[D-BUS Service]
Name=io.dangerousthings.AuthForge
Exec=/bin/false
User=root
SystemdService=authforge-daemon.service
(Exec=/bin/false because activation goes through systemd. SystemdService line is the magic.)
Step 3: debian/io.dangerousthings.AuthForge.conf (D-Bus policy):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC
"-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<!-- Only root can own the well-known name. -->
<policy user="root">
<allow own="io.dangerousthings.AuthForge"/>
</policy>
<!-- Any local user may call the interface; method-level authz is enforced
by polkit inside the daemon. -->
<policy context="default">
<allow send_destination="io.dangerousthings.AuthForge"
send_interface="io.dangerousthings.AuthForge1"/>
<allow send_destination="io.dangerousthings.AuthForge"
send_interface="org.freedesktop.DBus.Introspectable"/>
<allow send_destination="io.dangerousthings.AuthForge"
send_interface="org.freedesktop.DBus.Properties"/>
</policy>
</busconfig>
Step 4: Commit.
git add debian/authforge-daemon.service \
debian/io.dangerousthings.AuthForge.service \
debian/io.dangerousthings.AuthForge.conf
git commit -m "feat(packaging): systemd unit + D-Bus activation/policy files"
Task 1.13: polkit policy XML
Files:
- Create:
debian/io.dangerousthings.AuthForge.policy
Step 1: Write the XML:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>Dangerous Things</vendor>
<vendor_url>https://dangerousthings.com</vendor_url>
<action id="io.dangerousthings.AuthForge.enroll-own">
<description>Enroll a security key for your own account</description>
<message>Authentication is required to enroll a security key.</message>
<defaults>
<allow_any>auth_self_keep</allow_any>
<allow_inactive>auth_self_keep</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.remove-own">
<description>Remove a security key from your own account</description>
<message>Authentication is required to remove a security key.</message>
<defaults>
<allow_any>auth_self_keep</allow_any>
<allow_inactive>auth_self_keep</allow_inactive>
<allow_active>auth_self_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.enroll-other">
<description>Enroll a security key for another user</description>
<message>Administrator authentication is required to enroll on behalf of another user.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.set-policy">
<description>Change AuthForge MFA policy</description>
<message>Administrator authentication is required to change MFA policy.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.set-pending">
<description>Set a first-login enrollment flag for a user</description>
<message>Administrator authentication is required to require enrollment for a user.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.clear-pending">
<description>Clear a user's first-login enrollment flag</description>
<message>Administrator authentication is required to clear a pending-enrollment flag.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
<action id="io.dangerousthings.AuthForge.generate-recovery">
<description>Generate a one-time recovery code for a user</description>
<message>Administrator authentication is required to generate a recovery code.</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
</action>
</policyconfig>
Step 2: Validate against the DTD if xmllint is available:
xmllint --noout --valid debian/io.dangerousthings.AuthForge.policy 2>&1 || true
(May fail to fetch the DTD offline — non-fatal. polkit itself parses leniently.)
Step 3: Commit.
git add debian/io.dangerousthings.AuthForge.policy
git commit -m "feat(packaging): polkit policy with 7 AuthForge actions"
Task 1.14: postinst — enable + start daemon
Files:
- Create:
debian/authforge-daemon.postinst
Step 1: Write it:
#!/bin/sh
set -e
#DEBHELPER#
case "$1" in
configure)
# Reload systemd to pick up our new unit.
if [ -d /run/systemd/system ]; then
systemctl daemon-reload || true
# Don't enable on the build chroot.
if [ "$2" = "" ] || [ -z "${DPKG_ROOT:-}" ]; then
deb-systemd-helper enable authforge-daemon.service >/dev/null || true
deb-systemd-invoke start authforge-daemon.service >/dev/null || true
fi
fi
;;
esac
exit 0
#DEBHELPER# is replaced at build time with dh-generated snippets (e.g., dh_installsystemd).
chmod 0755.
Step 2: Commit.
chmod 0755 debian/authforge-daemon.postinst
git add debian/authforge-daemon.postinst
git commit -m "feat(packaging): postinst that enables + starts authforge-daemon"
Task 1.15: Wire packaging install rules
Files:
- Modify:
debian/rules
Step 1: Append new install lines to override_dh_auto_install:
# D-Bus activation file
install -D -m 0644 debian/io.dangerousthings.AuthForge.service \
debian/authforge-daemon/usr/share/dbus-1/system-services/io.dangerousthings.AuthForge.service
# D-Bus system policy
install -D -m 0644 debian/io.dangerousthings.AuthForge.conf \
debian/authforge-daemon/usr/share/dbus-1/system.d/io.dangerousthings.AuthForge.conf
# polkit actions
install -D -m 0644 debian/io.dangerousthings.AuthForge.policy \
debian/authforge-daemon/usr/share/polkit-1/actions/io.dangerousthings.AuthForge.policy
# systemd unit (dh_installsystemd will enable it)
install -D -m 0644 debian/authforge-daemon.service \
debian/authforge-daemon/lib/systemd/system/authforge-daemon.service
Step 2: Commit.
git add debian/rules
git commit -m "feat(packaging): install dbus, polkit, systemd assets into authforge-daemon"
Task 1.16: Update debian/control deps
Files:
- Modify:
debian/control
Step 1: Add to authforge-daemon Depends::
dbus, policykit-1
(Many distros call this polkit now; policykit-1 is the apt name on Ubuntu 22.04 LTS through at least 24.04. Recheck on 26.04.)
Final Depends: line:
Depends: ${shlibs:Depends}, ${misc:Depends}, libpam-u2f, libfido2-1, dbus, policykit-1
Step 2: Commit.
git add debian/control
git commit -m "chore(packaging): authforge-daemon depends on dbus + policykit"
Task 1.17: CI — install dbus dev headers, run new tests
Files:
- Modify:
.github/workflows/ci.yml
Step 1: Update the rust: job's apt step to include the libs we now use at runtime in tests:
- run: sudo apt-get update && sudo apt-get install -y \
libpam0g-dev libgtk-4-dev libadwaita-1-dev libfido2-dev pkg-config \
dbus
(zbus pure-Rust needs no headers; we add dbus for the busctl binary if we add a smoke step later.)
Step 2: Commit.
git add .github/workflows/ci.yml
git commit -m "ci: ensure dbus is installed for daemon tests"
Task 1.18: Manual smoke test (no CI; run locally if possible)
These are manual verification steps. Skip if no system bus / not on Linux desktop.
# Build release.
cargo build --release -p authforge-daemon
# Drop the assets in place (or sudo dpkg -i the deb once Task 1.20 lands).
sudo cp target/release/authforged /usr/sbin/authforged
sudo cp debian/io.dangerousthings.AuthForge.service /usr/share/dbus-1/system-services/
sudo cp debian/io.dangerousthings.AuthForge.conf /usr/share/dbus-1/system.d/
sudo cp debian/io.dangerousthings.AuthForge.policy /usr/share/polkit-1/actions/
sudo cp debian/authforge-daemon.service /lib/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start authforge-daemon
sudo systemctl status authforge-daemon
# Hit the interface as a regular user.
busctl introspect io.dangerousthings.AuthForge /io/dangerousthings/AuthForge
busctl call io.dangerousthings.AuthForge \
/io/dangerousthings/AuthForge \
io.dangerousthings.AuthForge1 \
ListCredentials s "$USER"
# Expected: empty array (or the alice fixture if you happen to be alice).
# Try a write — expect a polkit prompt.
busctl --user 0 call ... # placeholder; real call comes from CLI in Phase 7.
If the polkit prompt does not appear, check /var/log/auth.log (or journalctl -u polkit) for parse errors in the policy XML.
No commit for this task — it's verification.
Task 1.19: Phase 1 acceptance gate
Verify before declaring Phase 1 complete:
cargo build --workspace --releasesucceeds.cargo test --workspacesucceeds and includes the new daemon dbus + state + polkit tests.cargo clippy --workspace -- -D warningsis clean.debuild -us -uc -bproduces 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-daemonshows active after install.- CI green on a PR branch.
Tag the milestone:
git tag -a v0.1.0-phase1 -m "Phase 1: D-Bus interface + systemd + polkit"
Risks / known unknowns going into Phase 1
| Risk | Mitigation |
|---|---|
zbus 4 API renames between minor versions (ConnectionBuilder vs connection::Builder). |
Adapt at implementation time; check cargo doc --open -p zbus. The plan's snippets are zbus 4.x at the time of writing. |
Polkit subject resolution from a P2P / sender unique name needs the org.freedesktop.DBus.GetConnectionUnixProcessID round-trip. |
Phase 1 punts on this — authz() passes pid 0 and we run polkit in permissive-by-env mode for the smoke test. The full subject resolver lands in Phase 1.5 or early Phase 2 when CLI starts making real calls. |
systemd hardening (ProtectSystem=full) may block writes the daemon needs. |
We list ReadWritePaths covering /etc/authforge, /var/lib/authforge, /etc/pam.d, /usr/share/pam-configs, /etc/u2f_mappings. Adjust if Phase 4 finds gaps. |
policykit-1 may have been renamed to polkit on the next Ubuntu LTS. |
Recheck before Phase 14 (PPA setup). Easy fix in control. |
Execution Handoff
Plan complete and saved to docs/plans/2026-04-26-phase-1-dbus.md. Phase 1 is broken into 19 bite-sized tasks. Each writes a failing test first (where mechanical), implements, runs the test, commits. The final acceptance gate verifies the full pipeline.
Two execution options:
1. Subagent-Driven (this session) — I dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Parallel Session (separate) — Open a new session in a worktree using superpowers:executing-plans, batch execution with checkpoints.
Which approach?