Files
authforge/daemon/src/dbus.rs
michael 5c94319bf0 feat(daemon): policy apply via pam-auth-update + lockout simulator (Phase 4+5)
Lands Lane 1 of the Phase 4+5+8+15+16 parallel cycle. Phase 4 + Phase 5 must
land together because both modify the SetPolicy code path.

- daemon/src/lockout.rs — pure simulate(new_policy, registry) -> Vec<Violation>.
  Iterates Required stacks, flags users with no enrolled credential of any
  required method. 5 unit tests cover: optional-mode skipped, required-with-
  unenrolled flagged, any-method-satisfies, empty registry, multi-stack.
- daemon/src/policy_apply.rs — PolicyApplier renders the pam-configs profile
  (Default: yes when any stack requires fido2; pam_u2f.so + pam_authforge_pending
  when fido2 required, only pam_authforge_pending otherwise) and runs
  pam-auth-update --package. Stash-and-restore on failure: prior profile
  contents are restored and pam-auth-update re-run, so a failed apply leaves
  the system in its previous PAM state. 4 unit tests including a real-process
  rollback test against a failing /bin/sh shim.
- daemon/src/state.rs — AppState::set_policy(p, force) returns
  PolicyApplyResult. Always runs the simulator first; if violations and !force,
  returns { applied: false, violations } without writing. Otherwise persists
  via PolicyStore::save and invokes PolicyApplier::apply. StorageConfig grows
  pam_profile_path + pam_auth_update fields (env-var driven, tests inject a
  no-op /bin/sh shim into a tempdir).
- daemon/src/dbus.rs — SetPolicy signature is now (Policy, bool) -> Result.
  Wire-breaking pre-alpha; CLI updated in this commit.
- cli/src/{bus,commands}.rs — set_policy takes force flag. policy set runs
  with force=false and surfaces violations as a non-zero exit + stderr list
  pointing the user at policy apply --force-i-know-what-im-doing. policy
  apply now actually invokes pam-auth-update via the daemon.

Test count: 42 daemon (was 33; adds 5 lockout + 4 policy_apply). 13 common.
5 cli. cargo clippy --workspace --all-targets -D warnings clean.

Plan deviation: PolicyApplier::from_env() became PolicyApplier::new(profile_path,
pam_auth_update) with the env defaults moved into StorageConfig::from_env_or_defaults.
Cleaner: state owns one source of truth for env-driven path config.
2026-04-27 08:41:00 -07:00

410 lines
15 KiB
Rust

use crate::polkit::Polkit;
use crate::state::AppState;
use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
use std::sync::Arc;
pub struct AuthForge {
pub state: Arc<AppState>,
pub polkit: Arc<Polkit>,
}
impl AuthForge {
async fn authz(&self, action: &str) -> zbus::fdo::Result<()> {
// Phase 1: pid 0 — permissive mode ignores it; system mode running on the
// real system bus needs sender → unique-name → pid resolution, which lands
// when CLI starts making real calls (Phase 2 / Phase 7). Until then,
// operate the daemon under AUTHFORGE_POLKIT_BYPASS=1 for smoke tests.
let pid = 0u32;
self.polkit
.check(action, pid)
.await
.map_err(|e| zbus::fdo::Error::AccessDenied(e.to_string()))?;
Ok(())
}
}
#[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> {
self.state
.get_policy()
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
async fn enroll_own(
&self,
#[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
self.authz("io.dangerousthings.AuthForge.enroll-own")
.await?;
// Phase 3 emits a synthetic "touch required" before the real call so
// GUIs (Phase 8) can surface a prompt. busctl monitor sees it too.
let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await;
match self.state.enroll(&user, &nickname).await {
Ok(c) => {
let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await;
Ok(c)
}
Err(e) => {
let msg = e.to_string();
let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await;
Err(zbus::fdo::Error::Failed(msg))
}
}
}
async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.remove-own")
.await?;
let removed = self
.state
.remove_credential(&user, &cred_id)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
if removed {
Ok(())
} else {
Err(zbus::fdo::Error::Failed(format!(
"no credential {cred_id} for {user}"
)))
}
}
async fn enroll_other(
&self,
#[zbus(signal_context)] ctx: zbus::object_server::SignalContext<'_>,
user: String,
nickname: String,
) -> zbus::fdo::Result<Credential> {
self.authz("io.dangerousthings.AuthForge.enroll-other")
.await?;
let _ = AuthForge::touch_required(&ctx, "primary".to_string()).await;
match self.state.enroll(&user, &nickname).await {
Ok(c) => {
let _ = AuthForge::enrollment_succeeded(&ctx, c.id.clone()).await;
Ok(c)
}
Err(e) => {
let msg = e.to_string();
let _ = AuthForge::enrollment_failed(&ctx, msg.clone()).await;
Err(zbus::fdo::Error::Failed(msg))
}
}
}
async fn set_policy(&self, p: Policy, force: bool) -> zbus::fdo::Result<PolicyApplyResult> {
self.authz("io.dangerousthings.AuthForge.set-policy")
.await?;
self.state
.set_policy(p, force)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.set-pending")
.await?;
self.state
.set_pending(&user, flag)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.clear-pending")
.await?;
self.state
.clear_pending(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
async fn generate_recovery_code(&self, _user: String) -> zbus::fdo::Result<String> {
self.authz("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}"))
}
/// Emitted by main.rs whenever the inotify watcher on the policy.d/ dir
/// reports any change. Subscribers (GUI) reload via `GetPolicy`.
#[zbus(signal)]
pub async fn policy_changed(
signal_ctxt: &zbus::object_server::SignalContext<'_>,
) -> zbus::Result<()>;
/// Emitted by enrollment hot-plug discovery (Phase 3 stub: synthetic).
#[zbus(signal)]
pub async fn device_found(
ctx: &zbus::object_server::SignalContext<'_>,
name: String,
transport: String,
) -> zbus::Result<()>;
/// Emitted right before the daemon waits for the user to touch the device.
#[zbus(signal)]
pub async fn touch_required(
ctx: &zbus::object_server::SignalContext<'_>,
device: String,
) -> zbus::Result<()>;
/// Fired after a successful EnrollOwn / EnrollOther so the GUI can dismiss
/// its "touch your key" overlay without polling.
#[zbus(signal)]
pub async fn enrollment_succeeded(
ctx: &zbus::object_server::SignalContext<'_>,
cred_id: String,
) -> zbus::Result<()>;
/// Fired when enrollment fails — payload is the StateError message.
#[zbus(signal)]
pub async fn enrollment_failed(
ctx: &zbus::object_server::SignalContext<'_>,
reason: String,
) -> zbus::Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use authforge_common::policy::StackPolicy;
use authforge_common::types::Method;
use std::collections::BTreeMap;
use zbus::connection::Builder;
use zbus::Connection;
/// Build a peer-to-peer pair: server side has the AuthForge interface
/// registered at /io/dangerousthings/AuthForge, client side is a bare
/// connection. No system bus is involved. The TempDir must be returned —
/// dropping it before the test ends would delete the storage directories.
///
/// Storage is seeded with a central-mode policy pointing at a file inside
/// the tempdir so credential writes don't try to touch /home/<user>/...
/// (test users like alice/bob/carol aren't real accounts).
async fn p2p_pair() -> (Connection, Connection, Arc<AppState>, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let policy_dir = tmp.path().join("policy.d");
std::fs::create_dir_all(&policy_dir).unwrap();
let central = tmp.path().join("u2f_keys");
std::fs::write(
policy_dir.join("00-test.conf"),
format!(
r#"[storage]
backend = "central"
central_path = "{}"
"#,
central.display()
),
)
.unwrap();
let state = Arc::new({
let shim = tmp.path().join("fake-pau.sh");
std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap();
let mut perms = std::fs::metadata(&shim).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&shim, perms).unwrap();
AppState::open(
crate::state::StorageConfig {
policy_dir,
pending_dir: tmp.path().join("pending"),
userdb_path: tmp.path().join("users.db"),
pam_profile_path: tmp.path().join("authforge-pamconf"),
pam_auth_update: shim,
},
Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()),
)
.unwrap()
});
let auth = AuthForge {
state: state.clone(),
polkit: Arc::new(Polkit::permissive()),
};
let guid = zbus::Guid::generate();
let (server_sock, client_sock) = tokio::net::UnixStream::pair().unwrap();
// Both sides must build concurrently — `build()` completes the handshake,
// and one side waiting on the other deadlocks. tokio::join drives them in
// parallel on this runtime.
let server_build = Builder::socket(server_sock)
.p2p()
.server(guid)
.unwrap()
.serve_at("/io/dangerousthings/AuthForge", auth)
.unwrap()
.build();
let client_build = Builder::socket(client_sock).p2p().build();
let (server, client) = tokio::join!(server_build, client_build);
(server.unwrap(), client.unwrap(), state, tmp)
}
async fn proxy(client: &Connection) -> zbus::Proxy<'_> {
zbus::Proxy::new(
client,
"io.dangerousthings.AuthForge",
"/io/dangerousthings/AuthForge",
"io.dangerousthings.AuthForge1",
)
.await
.unwrap()
}
#[tokio::test]
async fn list_credentials_after_enroll() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
// Seed via the same EnrollOwn path the GUI would use; previously this
// was a Phase 1 fixture in AppState, but file-backed storage starts empty.
let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap();
let creds: Vec<Credential> = p.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, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let pol: Policy = p.call("GetPolicy", &()).await.unwrap();
assert!(pol.stacks.is_empty());
}
#[tokio::test]
async fn enroll_own_appends_credential() {
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let _: Credential = p.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, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
// Enroll, then remove by the cred id we just got back.
let cred: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap();
let _: () = p
.call("RemoveOwn", &("alice", cred.id.as_str()))
.await
.unwrap();
assert!(state.list_credentials("alice").await.is_empty());
}
#[tokio::test]
async fn remove_own_unknown_id_errors() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let r: Result<(), zbus::Error> = p.call("RemoveOwn", &("alice", "no-such-id")).await;
assert!(r.is_err());
}
#[tokio::test]
async fn enroll_other_creates_credential() {
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let _: Credential = p.call("EnrollOther", &("carol", "Hers")).await.unwrap();
assert_eq!(state.list_credentials("carol").await.len(), 1);
}
#[tokio::test]
async fn set_policy_persists_stacks() {
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let mut stacks = BTreeMap::new();
stacks.insert(
"sudo".to_string(),
StackPolicy {
mode: authforge_common::types::Mode::Required,
methods: vec![Method::Fido2],
},
);
let pol = Policy {
stacks: stacks.clone(),
..Default::default()
};
let r: PolicyApplyResult = p.call("SetPolicy", &(pol, true)).await.unwrap();
assert!(r.applied);
// p2p_pair seeds 00-test.conf with [storage]; SetPolicy writes 50-local.conf.
// The merged read shows our new stack alongside the seeded storage block.
let merged = state.get_policy().await.unwrap();
assert_eq!(merged.stacks, stacks);
}
#[tokio::test]
async fn set_and_clear_pending_flag() {
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let flag = PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 0,
deadline_unix: 0,
re_enroll: false,
};
let _: () = p.call("SetPendingFlag", &("alice", flag)).await.unwrap();
assert!(state.has_pending("alice").await.unwrap());
let _: () = p.call("ClearPendingFlag", &("alice",)).await.unwrap();
assert!(!state.has_pending("alice").await.unwrap());
}
#[tokio::test]
async fn generate_recovery_code_returns_8_digits() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
assert_eq!(code.len(), 8);
assert!(code.chars().all(|c| c.is_ascii_digit()));
}
#[tokio::test]
async fn enrollment_succeeded_signal_fires_on_enroll_own() {
use futures_util::stream::StreamExt;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let mut stream = p.receive_signal("EnrollmentSucceeded").await.unwrap();
let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap();
let _msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
.await
.expect("EnrollmentSucceeded signal not received within 2s")
.expect("stream closed before signal");
}
#[tokio::test]
async fn policy_changed_signal_fires_on_emit() {
use futures_util::stream::StreamExt;
let (server, client, _state, _tmp) = p2p_pair().await;
let proxy = proxy(&client).await;
let mut stream = proxy.receive_signal("PolicyChanged").await.unwrap();
let iface_ref: zbus::object_server::InterfaceRef<AuthForge> = server
.object_server()
.interface("/io/dangerousthings/AuthForge")
.await
.unwrap();
AuthForge::policy_changed(iface_ref.signal_context())
.await
.unwrap();
let _msg = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next())
.await
.expect("signal not received within 2s")
.expect("stream closed before signal");
}
}