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 limit = backend.max_pending_challenges_per_did();
if enrolled && limit > 0 {
let pending = backend
.sessions()
.count_pending_challenges(&input.did)
.await
.map_err(|e| AuthError::Internal(format!("count_pending_challenges failed: {e:?}")))?;
if pending >= limit {
tracing::warn!(
did = %input.did,
pending,
limit,
"auth challenge rate limited per-DID"
);
return Err(AuthError::PendingChallengeLimitReached.into());
}
}
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,
};
if enrolled {
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,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::backend::{RoleResolution, SessionStore};
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(0)
}
}
#[derive(Clone, Copy)]
enum Acl {
Present,
Absent,
Broken,
}
struct MockBackend {
store: RecordingStore,
acl: Acl,
audited: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl AuthBackend for MockBackend {
type Store = RecordingStore;
type Error = AppError;
type Role = String;
fn sessions(&self) -> &RecordingStore {
&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());
}
}