593 lines
22 KiB
Rust
593 lines
22 KiB
Rust
use crate::polkit::Polkit;
|
|
use crate::state::AppState;
|
|
use authforge_common::policy::Policy;
|
|
#[cfg(feature = "totp")]
|
|
use authforge_common::types::TotpEnrollment;
|
|
use authforge_common::types::{
|
|
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
|
|
};
|
|
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 get_pending_status(&self, user: String) -> zbus::fdo::Result<PendingStatus> {
|
|
// No polkit gate — pure read; daemon's pattern is gates on writes only.
|
|
self.state
|
|
.get_pending_status(&user)
|
|
.await
|
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
|
}
|
|
|
|
async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> {
|
|
self.authz("io.dangerousthings.AuthForge.generate-recovery")
|
|
.await?;
|
|
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()))
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
async fn enroll_totp(&self, user: String) -> zbus::fdo::Result<TotpEnrollment> {
|
|
self.authz("io.dangerousthings.AuthForge.enroll-totp")
|
|
.await?;
|
|
self.state
|
|
.enroll_totp(&user)
|
|
.await
|
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
async fn is_totp_enrolled(&self, user: String) -> zbus::fdo::Result<bool> {
|
|
// No polkit gate — pure read; daemon's pattern is gates on writes only.
|
|
self.state
|
|
.is_totp_enrolled(&user)
|
|
.await
|
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
async fn revoke_totp(&self, user: String) -> zbus::fdo::Result<bool> {
|
|
self.authz("io.dangerousthings.AuthForge.revoke-totp")
|
|
.await?;
|
|
self.state
|
|
.revoke_totp(&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
|
|
/// 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"),
|
|
recovery_dir: tmp.path().join("recovery"),
|
|
#[cfg(feature = "totp")]
|
|
totp_dir: tmp.path().join("totp"),
|
|
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 pending_status_is_absent_for_fresh_user() {
|
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
|
let p = proxy(&client).await;
|
|
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
|
|
assert!(!s.present);
|
|
assert!(s.flag.required_methods.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pending_status_round_trips_set_flag() {
|
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
|
let p = proxy(&client).await;
|
|
let flag = PendingFlag {
|
|
required_methods: vec![Method::Fido2],
|
|
created_unix: 100,
|
|
deadline_unix: 200,
|
|
re_enroll: true,
|
|
};
|
|
let _: () = p
|
|
.call("SetPendingFlag", &("alice", flag.clone()))
|
|
.await
|
|
.unwrap();
|
|
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
|
|
assert!(s.present);
|
|
assert_eq!(s.flag, flag);
|
|
}
|
|
|
|
#[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 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;
|
|
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");
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
#[tokio::test]
|
|
async fn enroll_totp_returns_otpauth_uri() {
|
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
|
let p = proxy(&client).await;
|
|
let e: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap();
|
|
assert_eq!(e.user, "alice");
|
|
assert!(e.otpauth_uri.starts_with("otpauth://totp/AuthForge:alice?"));
|
|
assert!(e
|
|
.secret_b32
|
|
.chars()
|
|
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
#[tokio::test]
|
|
async fn is_totp_enrolled_round_trips() {
|
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
|
let p = proxy(&client).await;
|
|
let before: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
|
|
assert!(!before);
|
|
let _: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap();
|
|
let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
|
|
assert!(after);
|
|
}
|
|
|
|
#[cfg(feature = "totp")]
|
|
#[tokio::test]
|
|
async fn revoke_totp_removes_enrollment() {
|
|
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
|
let p = proxy(&client).await;
|
|
let _: TotpEnrollment = p.call("EnrollTotp", &("alice",)).await.unwrap();
|
|
let removed: bool = p.call("RevokeTotp", &("alice",)).await.unwrap();
|
|
assert!(removed);
|
|
let after: bool = p.call("IsTotpEnrolled", &("alice",)).await.unwrap();
|
|
assert!(!after);
|
|
}
|
|
}
|