use std::collections::HashMap;
use std::sync::{Arc, PoisonError, Weak};
use uuid::Uuid;
use vta_sdk::protocols::auth::{ChallengeResponse, epoch_to_rfc3339};
use crate::auth::AuthError;
use crate::auth::backend::{AuthAuditEvent, AuthBackend, ChallengeInput, SessionStore};
use crate::auth::session::{Session, SessionState, now_epoch};
pub async fn handle_challenge<B: AuthBackend>(
backend: &B,
input: ChallengeInput,
) -> Result<ChallengeResponse, B::Error> {
backend.validate_did(&input.did).await?;
let enrolled = backend.has_effective_entry(&input.did).await;
let mut bytes = [0u8; 32];
rand::fill(&mut bytes);
let challenge = hex::encode(bytes);
let session_id = Uuid::new_v4().to_string();
let attestation = backend.attest_challenge(&bytes).await?;
let created_at = now_epoch();
let session = Session {
session_id: session_id.clone(),
did: input.did,
challenge: challenge.clone(),
state: SessionState::ChallengeSent,
created_at,
last_seen: created_at,
refresh_token: None,
refresh_expires_at: None,
tee_attested: attestation.attested,
amr: Vec::new(),
acr: String::new(),
acr_expires_at: None,
token_id: None,
session_pubkey_b58btc: input.session_pubkey_b58btc,
not_after: None,
};
if enrolled {
let limit = backend.max_pending_challenges_per_did();
let cap = cap_lock(&session.did);
let _cap = tokio::time::timeout(CAP_LOCK_WAIT, cap.lock())
.await
.map_err(|_| {
AuthError::Internal(format!(
"the per-DID challenge cap stayed busy for {CAP_LOCK_WAIT:?}"
))
})?;
if limit > 0 {
make_room_for_challenge(backend, &session.did, limit).await?;
}
backend
.sessions()
.store_session(&session)
.await
.map_err(|e| AuthError::Internal(format!("store_session failed: {e:?}")))?;
backend.audit(AuthAuditEvent::ChallengeIssued {
did: &session.did,
session_id: &session_id,
});
} else {
tracing::warn!(
did = %session.did,
"auth challenge requested for a subject with no effective ACL entry — \
answered with an unusable challenge, nothing persisted"
);
}
Ok(ChallengeResponse {
challenge,
session_id,
expires_at: epoch_to_rfc3339(created_at.saturating_add(backend.challenge_ttl())),
tee_attestation: attestation.report,
})
}
static CAP_LOCKS: std::sync::LazyLock<
std::sync::Mutex<HashMap<String, Weak<tokio::sync::Mutex<()>>>>,
> = std::sync::LazyLock::new(Default::default);
const CAP_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
fn cap_lock(did: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = CAP_LOCKS.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(lock) = locks.get(did).and_then(Weak::upgrade) {
return lock;
}
locks.retain(|_, lock| lock.strong_count() > 0);
let lock = Arc::new(tokio::sync::Mutex::new(()));
locks.insert(did.to_owned(), Arc::downgrade(&lock));
lock
}
async fn make_room_for_challenge<B: AuthBackend>(
backend: &B,
did: &str,
limit: usize,
) -> Result<(), B::Error> {
let sessions = backend.sessions();
let Some(pending) = sessions
.pending_challenges(did)
.await
.map_err(|e| AuthError::Internal(format!("pending_challenges failed: {e:?}")))?
else {
let pending = sessions
.count_pending_challenges(did)
.await
.map_err(|e| AuthError::Internal(format!("count_pending_challenges failed: {e:?}")))?;
if pending >= limit {
tracing::warn!(did, pending, limit, "auth challenge rate limited per-DID");
return Err(AuthError::PendingChallengeLimitReached.into());
}
return Ok(());
};
let now = now_epoch();
let ttl = backend.challenge_ttl();
let (expired, live): (Vec<_>, Vec<_>) = pending
.into_iter()
.partition(|s| now.saturating_sub(s.created_at) > ttl);
let excess = (live.len() + 1).saturating_sub(limit);
if excess > 0 {
tracing::warn!(
did,
outstanding = live.len(),
limit,
evicted = excess,
"auth challenge cap reached per-DID — evicting the oldest outstanding challenges"
);
}
for s in expired.iter().chain(live.iter().take(excess)) {
sessions
.delete_session(&s.session_id)
.await
.map_err(|e| AuthError::Internal(format!("delete_session failed: {e:?}")))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::backend::{RoleResolution, SessionStore};
use crate::auth::handlers::store::KeyspaceSessionStore;
use crate::error::AppError;
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct RecordingStore {
stored: Mutex<Vec<Session>>,
}
#[async_trait]
impl SessionStore for RecordingStore {
type Error = AppError;
async fn store_session(&self, s: &Session) -> Result<(), AppError> {
self.stored.lock().unwrap().push(s.clone());
Ok(())
}
async fn get_session(&self, _: &str) -> Result<Option<Session>, AppError> {
Ok(None)
}
async fn delete_session(&self, _: &str) -> Result<(), AppError> {
Ok(())
}
async fn store_refresh_index(&self, _: &str, _: &str) -> Result<(), AppError> {
unreachable!()
}
async fn take_session_id_by_refresh(&self, _: &str) -> Result<Option<String>, AppError> {
unreachable!()
}
async fn count_pending_challenges(&self, _: &str) -> Result<usize, AppError> {
Ok(self.stored.lock().unwrap().len())
}
}
#[derive(Clone, Copy)]
enum Acl {
Present,
Absent,
Broken,
}
struct MockBackend<S = RecordingStore> {
store: S,
acl: Acl,
audited: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl<S: SessionStore<Error = AppError> + Send + Sync + 'static> AuthBackend for MockBackend<S> {
type Store = S;
type Error = AppError;
type Role = String;
fn sessions(&self) -> &S {
&self.store
}
#[allow(clippy::too_many_arguments)]
async fn mint_access_token(
&self,
_subject: &str,
_session_id: &str,
_role: &String,
_contexts: &[String],
_amr: &[String],
_acr: &str,
_tee_attested: bool,
_ttl_secs: u64,
_jti: &str,
) -> Result<String, AppError> {
unreachable!("challenge never mints a token")
}
async fn check_acl(&self, _did: &str) -> Result<RoleResolution<String>, AppError> {
match self.acl {
Acl::Present => Ok(RoleResolution {
role: "reader".to_string(),
contexts: vec![],
}),
Acl::Absent => Err(AuthError::Forbidden.into()),
Acl::Broken => Err(AppError::Internal("acl keyspace unavailable".into())),
}
}
fn challenge_ttl(&self) -> u64 {
60
}
fn access_token_ttl(&self) -> u64 {
900
}
fn refresh_token_ttl(&self) -> u64 {
86_400
}
fn audit(&self, event: AuthAuditEvent<'_>) {
if let AuthAuditEvent::ChallengeIssued { did, .. } = event {
self.audited.lock().unwrap().push(did.to_string());
}
}
}
fn backend(acl: Acl) -> MockBackend {
MockBackend {
store: RecordingStore::default(),
acl,
audited: Arc::new(Mutex::new(Vec::new())),
}
}
fn input(did: &str) -> ChallengeInput {
ChallengeInput {
did: did.to_string(),
session_pubkey_b58btc: None,
}
}
#[tokio::test]
async fn an_enrolled_subject_gets_a_persisted_session() {
let b = backend(Acl::Present);
let resp = handle_challenge(&b, input("did:key:zEnrolled"))
.await
.unwrap();
assert_eq!(b.store.stored.lock().unwrap().len(), 1);
assert_eq!(b.audited.lock().unwrap().as_slice(), ["did:key:zEnrolled"]);
assert_eq!(resp.challenge.len(), 64, "32 bytes, hex-encoded");
}
#[tokio::test]
async fn an_unenrolled_subject_gets_a_challenge_and_nothing_is_persisted() {
let b = backend(Acl::Absent);
let resp = handle_challenge(&b, input("did:key:zStranger"))
.await
.expect("an unenrolled subject is answered, not refused");
assert!(
b.store.stored.lock().unwrap().is_empty(),
"a subject with no entry must not be able to fill the session store"
);
assert!(
b.audited.lock().unwrap().is_empty(),
"nothing was granted, so nothing belongs in the challenge-issued trail"
);
assert_eq!(resp.challenge.len(), 64);
}
#[tokio::test]
async fn the_two_answers_are_indistinguishable() {
let enrolled = handle_challenge(&backend(Acl::Present), input("did:key:zEnrolled"))
.await
.unwrap();
let stranger = handle_challenge(&backend(Acl::Absent), input("did:key:zStranger"))
.await
.unwrap();
assert_eq!(enrolled.challenge.len(), stranger.challenge.len());
assert_eq!(
enrolled.session_id.len(),
stranger.session_id.len(),
"both are UUIDs"
);
assert_eq!(
enrolled.tee_attestation.is_some(),
stranger.tee_attestation.is_some(),
"attestation is performed for both, or the answer says which is which"
);
assert!(!enrolled.expires_at.is_empty() && !stranger.expires_at.is_empty());
assert_ne!(
enrolled.challenge, stranger.challenge,
"each challenge is freshly minted"
);
}
#[tokio::test]
async fn a_broken_acl_store_does_not_disclose_itself() {
let b = backend(Acl::Broken);
let resp = handle_challenge(&b, input("did:key:zEnrolled"))
.await
.unwrap();
assert_eq!(resp.challenge.len(), 64);
assert!(b.store.stored.lock().unwrap().is_empty());
}
fn keyspace_backend(acl: Acl) -> (MockBackend<KeyspaceSessionStore>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = crate::store::Store::open(&crate::config::StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
let sessions = KeyspaceSessionStore::new(store.keyspace("sessions").expect("keyspace"));
let b = MockBackend {
store: sessions,
acl,
audited: Arc::new(Mutex::new(Vec::new())),
};
(b, dir)
}
fn challenge_row(session_id: &str, did: &str, created_at: u64) -> Session {
Session {
session_id: session_id.into(),
did: did.into(),
challenge: "00".into(),
state: SessionState::ChallengeSent,
created_at,
last_seen: created_at,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: vec![],
acr: String::new(),
acr_expires_at: None,
token_id: None,
session_pubkey_b58btc: None,
not_after: None,
}
}
async fn outstanding(b: &MockBackend<KeyspaceSessionStore>, did: &str) -> Vec<String> {
b.store
.pending_challenges(did)
.await
.unwrap()
.expect("the keyspace store lists challenges")
.into_iter()
.map(|s| s.session_id)
.collect()
}
#[tokio::test]
async fn at_the_cap_a_challenge_is_evicted_not_refused() {
let (b, _dir) = keyspace_backend(Acl::Present);
let did = "did:key:zRetrying";
let limit = b.max_pending_challenges_per_did();
let mut issued = Vec::new();
for _ in 0..=limit {
let resp = handle_challenge(&b, input(did))
.await
.expect("a challenge is never refused at the cap");
issued.push(resp.session_id);
}
let left = outstanding(&b, did).await;
assert_eq!(left.len(), limit, "the cap still holds (VTI-SES-005)");
assert!(
left.contains(&issued[limit]),
"the new challenge is never the one evicted"
);
let evicted: Vec<_> = issued.iter().filter(|id| !left.contains(id)).collect();
assert_eq!(evicted.len(), 1, "exactly one was evicted");
assert!(
b.store.get_session(evicted[0]).await.unwrap().is_none(),
"an evicted challenge can no longer be redeemed"
);
}
#[tokio::test]
async fn eviction_takes_the_oldest_outstanding_challenge() {
let (b, _dir) = keyspace_backend(Acl::Present);
let did = "did:key:zOrdered";
let now = now_epoch();
let limit = b.max_pending_challenges_per_did();
for i in 0..limit {
let at = now - (limit - i) as u64;
b.store
.store_session(&challenge_row(&format!("c-{i}"), did, at))
.await
.unwrap();
}
handle_challenge(&b, input(did)).await.unwrap();
let left = outstanding(&b, did).await;
assert!(!left.contains(&"c-0".to_string()), "{left:?}");
assert!(left.contains(&"c-1".to_string()), "{left:?}");
}
#[tokio::test]
async fn expired_challenges_are_discarded_and_do_not_count() {
let (b, _dir) = keyspace_backend(Acl::Present);
let did = "did:key:zStale";
let stale_at = now_epoch() - b.challenge_ttl() - 1;
for i in 0..b.max_pending_challenges_per_did() {
b.store
.store_session(&challenge_row(&format!("stale-{i}"), did, stale_at))
.await
.unwrap();
}
let resp = handle_challenge(&b, input(did)).await.unwrap();
assert_eq!(outstanding(&b, did).await, [resp.session_id]);
}
#[tokio::test]
async fn the_cap_does_not_disclose_enrolment() {
for acl in [Acl::Present, Acl::Absent] {
let (b, _dir) = keyspace_backend(acl);
for _ in 0..=b.max_pending_challenges_per_did() {
let resp = handle_challenge(&b, input("did:key:zProbed")).await;
assert!(resp.is_ok(), "never refused, enrolled or not");
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_requests_cannot_overshoot_the_cap() {
let (b, _dir) = keyspace_backend(Acl::Present);
let b = Arc::new(b);
let did = "did:key:zConcurrent";
let limit = b.max_pending_challenges_per_did();
let now = now_epoch();
for i in 0..limit - 1 {
b.store
.store_session(&challenge_row(&format!("seed-{i}"), did, now))
.await
.unwrap();
}
let requests: Vec<_> = (0..8)
.map(|_| {
let b = Arc::clone(&b);
tokio::spawn(async move { handle_challenge(&*b, input(did)).await })
})
.collect();
for r in requests {
r.await.unwrap().expect("never refused at the cap");
}
assert_eq!(outstanding(&b, did).await.len(), limit);
}
#[tokio::test]
async fn a_store_that_cannot_list_challenges_still_refuses_at_the_cap() {
let b = backend(Acl::Present);
for _ in 0..b.max_pending_challenges_per_did() {
handle_challenge(&b, input("did:key:zLegacy"))
.await
.unwrap();
}
let err = handle_challenge(&b, input("did:key:zLegacy"))
.await
.expect_err("refused at the cap");
assert!(
matches!(&err, AppError::Validation(m) if m.contains("too many pending challenges")),
"{err:?}"
);
}
}