diff --git a/cli/Cargo.toml b/cli/Cargo.toml index a702b38..1b9712e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,3 +14,6 @@ clap = { workspace = true } anyhow = { workspace = true } zbus = { workspace = true } tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/cli/src/bus.rs b/cli/src/bus.rs index e5d75f1..b12b65e 100644 --- a/cli/src/bus.rs +++ b/cli/src/bus.rs @@ -1,2 +1,59 @@ -// Phase 3+6+7 plan: D-Bus client wrapper. Stubbed in Task C1; real methods -// land in Task C2. +//! Thin async D-Bus wrapper around `io.dangerousthings.AuthForge1`. Each method +//! is one `Proxy::call` round-trip. Connection + proxy hold owned state so the +//! Daemon handle can be passed around freely. + +use anyhow::{Context, Result}; +use authforge_common::policy::Policy; +use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult}; + +const BUS_NAME: &str = "io.dangerousthings.AuthForge"; +const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; +const IFACE: &str = "io.dangerousthings.AuthForge1"; + +pub(crate) struct Daemon { + proxy: zbus::Proxy<'static>, +} + +impl Daemon { + pub async fn connect() -> Result { + let conn = zbus::Connection::system() + .await + .context("connecting to system D-Bus")?; + let proxy = zbus::Proxy::new_owned(conn, BUS_NAME, OBJECT_PATH, IFACE) + .await + .context("constructing AuthForge proxy")?; + Ok(Self { proxy }) + } + + pub async fn list_credentials(&self, user: &str) -> Result> { + Ok(self.proxy.call("ListCredentials", &(user,)).await?) + } + + pub async fn enroll_own(&self, user: &str, nickname: &str) -> Result { + Ok(self.proxy.call("EnrollOwn", &(user, nickname)).await?) + } + + pub async fn remove_own(&self, user: &str, cred_id: &str) -> Result<()> { + Ok(self.proxy.call("RemoveOwn", &(user, cred_id)).await?) + } + + pub async fn get_policy(&self) -> Result { + Ok(self.proxy.call("GetPolicy", &()).await?) + } + + pub async fn set_policy(&self, p: &Policy) -> Result { + Ok(self.proxy.call("SetPolicy", &(p,)).await?) + } + + pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> { + Ok(self.proxy.call("SetPendingFlag", &(user, flag)).await?) + } + + pub async fn clear_pending_flag(&self, user: &str) -> Result<()> { + Ok(self.proxy.call("ClearPendingFlag", &(user,)).await?) + } + + pub async fn generate_recovery_code(&self, user: &str) -> Result { + Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?) + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index a938077..3dce066 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -1,7 +1,208 @@ +use crate::bus::Daemon; use anyhow::Result; -pub(crate) async fn dispatch(_json: bool, cmd: super::Cmd) -> Result<()> { - eprintln!("authforgectl: subcommand dispatcher not yet wired in this build"); - let _ = cmd; +pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> { + let d = Daemon::connect().await?; + + match cmd { + super::Cmd::Status => { + let pol = d.get_policy().await?; + if json { + println!("{}", serde_json::to_string_pretty(&pol)?); + } else { + println!("authforge daemon: connected"); + println!("policy stacks configured: {}", pol.stacks.len()); + for (name, sp) in &pol.stacks { + println!(" {}: {:?} ({:?})", name, sp.mode, sp.methods); + } + } + } + + super::Cmd::List { user } => { + let user = user.unwrap_or_else(whoami_or_user); + let creds = d.list_credentials(&user).await?; + if json { + println!("{}", serde_json::to_string_pretty(&creds)?); + } else if creds.is_empty() { + println!("no credentials for {user}"); + } else { + for c in creds { + println!( + "{}\t{}\t{:?}\t{:?}", + c.id, c.nickname, c.method, c.transport + ); + } + } + } + + super::Cmd::Policy { + cmd: super::PolicyCmd::Show, + } => { + let pol = d.get_policy().await?; + if json { + println!("{}", serde_json::to_string_pretty(&pol)?); + } else { + println!("{}", toml::to_string_pretty(&pol).unwrap_or_default()); + } + } + + // Write-side subcommands (Task C4-C5). + super::Cmd::Enroll { user, nickname } => { + let user = user.unwrap_or_else(whoami_or_user); + let nickname = nickname.unwrap_or_else(|| "Security Key".to_string()); + let cred = d.enroll_own(&user, &nickname).await?; + if json { + println!("{}", serde_json::to_string_pretty(&cred)?); + } else { + println!( + "enrolled credential {} ({}) for {}", + cred.id, cred.nickname, user + ); + } + } + + super::Cmd::Remove { user, cred_id } => { + let user = user.unwrap_or_else(whoami_or_user); + d.remove_own(&user, &cred_id).await?; + if !json { + println!("removed credential {cred_id} for {user}"); + } + } + + super::Cmd::Policy { + cmd: + super::PolicyCmd::Set { + stack, + mode, + methods, + }, + } => { + use authforge_common::policy::StackPolicy; + use authforge_common::types::{Method, Mode}; + let m = match mode.as_str() { + "disabled" => Mode::Disabled, + "optional" => Mode::Optional, + "required" => Mode::Required, + other => anyhow::bail!("unknown mode: {other}"), + }; + let methods: Result> = methods + .into_iter() + .map(|s| match s.as_str() { + "fido2" => Ok(Method::Fido2), + "totp" => Ok(Method::Totp), + other => Err(anyhow::anyhow!("unknown method: {other}")), + }) + .collect(); + let mut pol = d.get_policy().await?; + pol.stacks.insert( + stack.clone(), + StackPolicy { + mode: m, + methods: methods?, + }, + ); + let _ = d.set_policy(&pol).await?; + if !json { + println!("policy updated for stack {stack}"); + } + } + + super::Cmd::Policy { + cmd: super::PolicyCmd::Apply { force: _ }, + } => { + // Phase 4 wires real pam-auth-update; for now we re-save current + // policy so the daemon emits PolicyChanged. + let pol = d.get_policy().await?; + let _ = d.set_policy(&pol).await?; + if !json { + println!("policy re-applied (Phase 4 wires real pam-auth-update)"); + } + } + + super::Cmd::Policy { + cmd: super::PolicyCmd::Validate, + } => { + // Phase 5 wires the lockout simulator. For now: syntax round-trip. + let pol = d.get_policy().await?; + let _ = toml::to_string_pretty(&pol)?; + if !json { + println!("policy is syntactically valid"); + } + } + + super::Cmd::Pending { + cmd: super::PendingCmd::Set { user, methods }, + } => { + use authforge_common::types::{Method, PendingFlag}; + let methods: Result> = methods + .into_iter() + .map(|s| match s.as_str() { + "fido2" => Ok(Method::Fido2), + "totp" => Ok(Method::Totp), + other => Err(anyhow::anyhow!("unknown method: {other}")), + }) + .collect(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let flag = PendingFlag { + required_methods: methods?, + created_unix: now, + deadline_unix: 0, + re_enroll: false, + }; + d.set_pending_flag(&user, &flag).await?; + if !json { + println!("pending flag set for {user}"); + } + } + + super::Cmd::Pending { + cmd: super::PendingCmd::Clear { user }, + } => { + d.clear_pending_flag(&user).await?; + if !json { + println!("pending flag cleared for {user}"); + } + } + + super::Cmd::Pending { + cmd: super::PendingCmd::List, + } => { + // ListPending lands when GUI needs it (Phase 8/12 adjustment). + if !json { + println!("(ListPending D-Bus method lands when GUI needs it)"); + } + } + + super::Cmd::Recovery { + cmd: super::RecoveryCmd::Generate { user }, + } => { + let code = d.generate_recovery_code(&user).await?; + if json { + println!( + "{}", + serde_json::json!({ "user": user, "code": code }) + ); + } else { + println!("{code}"); + } + } + + super::Cmd::Recovery { + cmd: super::RecoveryCmd::List { user: _ }, + } => { + if !json { + println!("(ListRecovery lands in Phase 12)"); + } + } + } Ok(()) } + +fn whoami_or_user() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "unknown".into()) +}