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:
michael
2026-04-27 09:39:16 -07:00
parent e9efde9077
commit a420aa5f75
7 changed files with 114 additions and 15 deletions

View File

@@ -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;