feat(daemon): real GenerateRecoveryCode + ListRecoveryCodes + RevokeRecoveryCode
Replace the random-number stub at dbus.rs:132 with state.issue_recovery, add ListRecoveryCodes (returns Vec<RecoveryCodeSummary>) and RevokeRecoveryCode. New polkit actions list-recovery / revoke-recovery (auth_admin_keep). Adds 4 D-Bus integration tests; the previously-stub generate-code test now exercises the real Argon2id-backed write path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,12 @@ pub struct PolicyApplyResult {
|
||||
pub violations: Vec<Violation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
pub struct RecoveryCodeSummary {
|
||||
pub user: String,
|
||||
pub expires_unix: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::polkit::Polkit;
|
||||
use crate::state::AppState;
|
||||
use authforge_common::policy::Policy;
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AuthForge {
|
||||
@@ -129,13 +129,39 @@ impl AuthForge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_recovery_code(&self, _user: String) -> zbus::fdo::Result<String> {
|
||||
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}"))
|
||||
self.state
|
||||
.issue_recovery(&user)
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn list_recovery_codes(&self) -> zbus::fdo::Result<Vec<RecoveryCodeSummary>> {
|
||||
self.authz("io.dangerousthings.AuthForge.list-recovery")
|
||||
.await?;
|
||||
let entries = self
|
||||
.state
|
||||
.list_recovery()
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| RecoveryCodeSummary {
|
||||
user: e.user,
|
||||
expires_unix: e.expires_unix,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn revoke_recovery_code(&self, user: String) -> zbus::fdo::Result<bool> {
|
||||
self.authz("io.dangerousthings.AuthForge.revoke-recovery")
|
||||
.await?;
|
||||
self.state
|
||||
.revoke_recovery(&user)
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Emitted by main.rs whenever the inotify watcher on the policy.d/ dir
|
||||
@@ -370,6 +396,53 @@ central_path = "{}"
|
||||
assert!(code.chars().all(|c| c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_recovery_returns_issued_users() {
|
||||
use authforge_common::types::RecoveryCodeSummary;
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||
let _: String = p.call("GenerateRecoveryCode", &("bob",)).await.unwrap();
|
||||
let entries: Vec<RecoveryCodeSummary> = p.call("ListRecoveryCodes", &()).await.unwrap();
|
||||
let users: Vec<_> = entries.into_iter().map(|e| e.user).collect();
|
||||
assert_eq!(users, vec!["alice".to_string(), "bob".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_recovery_removes_entry() {
|
||||
use authforge_common::types::RecoveryCodeSummary;
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||
let removed: bool = p.call("RevokeRecoveryCode", &("alice",)).await.unwrap();
|
||||
assert!(removed);
|
||||
let entries: Vec<RecoveryCodeSummary> = p.call("ListRecoveryCodes", &()).await.unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoke_recovery_returns_false_on_missing_user() {
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let removed: bool = p.call("RevokeRecoveryCode", &("ghost",)).await.unwrap();
|
||||
assert!(!removed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_recovery_code_persists_argon2id_hash_on_disk() {
|
||||
let (_srv, client, _state, tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let _: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
|
||||
let body = std::fs::read_to_string(tmp.path().join("recovery").join("alice")).unwrap();
|
||||
let mut lines = body.lines();
|
||||
// Line 1: expires_unix (numeric).
|
||||
let expires: u64 = lines.next().unwrap().parse().unwrap();
|
||||
assert!(expires > 0);
|
||||
// Line 2: argon2id PHC string.
|
||||
let phc = lines.next().unwrap();
|
||||
assert!(phc.starts_with("$argon2id$"), "phc was {phc}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enrollment_succeeded_signal_fires_on_enroll_own() {
|
||||
use futures_util::stream::StreamExt;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
#![allow(dead_code)] // wired through RecoveryStore (Task 4) and AppState (Task 5).
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
password_hash::{
|
||||
rand_core::OsRng as PhcOsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
||||
},
|
||||
Argon2,
|
||||
};
|
||||
use rand::Rng;
|
||||
|
||||
@@ -233,7 +233,9 @@ impl AppState {
|
||||
|
||||
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||
pub async fn issue_recovery(&self, user: &str) -> Result<String, StateError> {
|
||||
Ok(self.recovery.issue(user, crate::recovery::DEFAULT_TTL_SECS)?)
|
||||
Ok(self
|
||||
.recovery
|
||||
.issue(user, crate::recovery::DEFAULT_TTL_SECS)?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired through D-Bus in Task 6.
|
||||
|
||||
@@ -161,7 +161,8 @@ impl RecoveryStore {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ok = verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?;
|
||||
let ok =
|
||||
verify_code(candidate, phc).map_err(|e| RecoveryStoreError::Hash(e.to_string()))?;
|
||||
if ok {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,7 @@ pub(crate) enum SegmentError {
|
||||
/// Reject any username that could escape a single path segment. Centralized
|
||||
/// so pending / recovery / future stores don't drift on what's "safe."
|
||||
pub(crate) fn join_user_segment(dir: &Path, user: &str) -> Result<PathBuf, SegmentError> {
|
||||
if user.is_empty()
|
||||
|| user == "."
|
||||
|| user == ".."
|
||||
|| user.contains('/')
|
||||
|| user.contains('\0')
|
||||
{
|
||||
if user.is_empty() || user == "." || user == ".." || user.contains('/') || user.contains('\0') {
|
||||
return Err(SegmentError::Invalid(user.to_string()));
|
||||
}
|
||||
Ok(dir.join(user))
|
||||
|
||||
20
debian/io.dangerousthings.AuthForge.policy
vendored
20
debian/io.dangerousthings.AuthForge.policy
vendored
@@ -77,4 +77,24 @@
|
||||
</defaults>
|
||||
</action>
|
||||
|
||||
<action id="io.dangerousthings.AuthForge.list-recovery">
|
||||
<description>List active recovery codes</description>
|
||||
<message>Administrator authentication is required to list recovery codes.</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.revoke-recovery">
|
||||
<description>Revoke a recovery code</description>
|
||||
<message>Administrator authentication is required to revoke 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>
|
||||
|
||||
Reference in New Issue
Block a user