1514 lines
44 KiB
Markdown
1514 lines
44 KiB
Markdown
# 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<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?
|