use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use tracing::info;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
pub use super::challenge_refusal::ChallengeRefusal;
const PREFIX: &str = "vetting-pcs-challenge:";
pub const DEFAULT_CHALLENGE_TTL: Duration = Duration::minutes(15);
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PcsChallenge {
challenge: String,
expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
spent_at: Option<DateTime<Utc>>,
}
fn key(applicant_did: &str) -> Vec<u8> {
format!("{PREFIX}{applicant_did}").into_bytes()
}
pub async fn issue(
ks: &KeyspaceHandle,
applicant_did: &str,
ttl: Duration,
now: DateTime<Utc>,
) -> Result<String, AppError> {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
let challenge: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
record(ks, applicant_did, &challenge, ttl, now).await?;
Ok(challenge)
}
pub async fn record(
ks: &KeyspaceHandle,
applicant_did: &str,
challenge: &str,
ttl: Duration,
now: DateTime<Utc>,
) -> Result<(), AppError> {
let rec = PcsChallenge {
challenge: challenge.to_string(),
expires_at: now + ttl,
spent_at: None,
};
ks.insert(key(applicant_did), &rec).await
}
#[derive(Debug)]
pub enum ConsumeError {
Refused(ChallengeRefusal),
Store(AppError),
}
impl From<AppError> for ConsumeError {
fn from(e: AppError) -> Self {
Self::Store(e)
}
}
impl std::fmt::Display for ConsumeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused(r) => r.fmt(f),
Self::Store(e) => e.fmt(f),
}
}
}
pub async fn consume(
ks: &KeyspaceHandle,
applicant_did: &str,
presented: &str,
now: DateTime<Utc>,
) -> Result<(), ConsumeError> {
let refused = |r: ChallengeRefusal| {
info!(
applicant = %applicant_did,
refusal = r.kind(),
"hidden-vetting challenge refused"
);
Err(ConsumeError::Refused(r))
};
let Some(raw) = ks.take_raw(key(applicant_did)).await? else {
return refused(ChallengeRefusal::NotIssued);
};
let rec: PcsChallenge = serde_json::from_slice(&raw)
.map_err(|e| AppError::Internal(format!("hidden-vetting challenge row: {e}")))?;
if rec.spent_at.is_some() {
ks.insert_if_absent(key(applicant_did), &rec).await?;
return refused(ChallengeRefusal::AlreadyUsed);
}
if now >= rec.expires_at {
ks.insert_if_absent(key(applicant_did), &rec).await?;
return refused(ChallengeRefusal::Expired {
expired_at: rec.expires_at,
});
}
let spent = PcsChallenge {
challenge: String::new(),
expires_at: rec.expires_at,
spent_at: Some(now),
};
ks.insert_if_absent(key(applicant_did), &spent).await?;
if rec.challenge != presented {
return refused(ChallengeRefusal::Mismatch);
}
Ok(())
}
pub async fn sweep_expired(ks: &KeyspaceHandle, now: DateTime<Utc>) -> Result<usize, AppError> {
let mut purged = 0usize;
for (k, raw) in ks.prefix_iter_raw(PREFIX.as_bytes().to_vec()).await? {
if let Ok(rec) = serde_json::from_slice::<PcsChallenge>(&raw)
&& now >= rec.expires_at
{
ks.remove(k).await?;
purged += 1;
}
}
Ok(purged)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::build_test_vtc;
const BOB: &str = "did:key:z6MkBob";
async fn refusal(
ks: &KeyspaceHandle,
did: &str,
presented: &str,
now: DateTime<Utc>,
) -> ChallengeRefusal {
match consume(ks, did, presented, now).await {
Err(ConsumeError::Refused(r)) => r,
other => panic!("expected a refusal, got {other:?}"),
}
}
#[tokio::test]
async fn a_challenge_is_single_use() {
let state = build_test_vtc().await.state;
let now = Utc::now();
let c = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
consume(&state.join_requests_ks, BOB, &c, now)
.await
.unwrap();
assert_eq!(
refusal(&state.join_requests_ks, BOB, &c, now).await,
ChallengeRefusal::AlreadyUsed
);
assert_eq!(
refusal(&state.join_requests_ks, BOB, &c, now).await,
ChallengeRefusal::AlreadyUsed
);
}
#[tokio::test]
async fn no_challenge_asked_for_is_not_issued() {
let state = build_test_vtc().await.state;
assert_eq!(
refusal(&state.join_requests_ks, BOB, "deadbeef", Utc::now()).await,
ChallengeRefusal::NotIssued
);
}
#[tokio::test]
async fn a_challenge_this_community_never_issued_is_refused() {
let state = build_test_vtc().await.state;
let now = Utc::now();
let c = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
assert_eq!(
refusal(&state.join_requests_ks, BOB, "deadbeef", now).await,
ChallengeRefusal::Mismatch
);
assert_eq!(
refusal(&state.join_requests_ks, BOB, &c, now).await,
ChallengeRefusal::AlreadyUsed
);
}
#[tokio::test]
async fn a_proof_over_a_replaced_challenge_is_a_mismatch() {
let state = build_test_vtc().await.state;
let now = Utc::now();
let first = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
let _second = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
assert_eq!(
refusal(&state.join_requests_ks, BOB, &first, now).await,
ChallengeRefusal::Mismatch
);
}
#[tokio::test]
async fn a_new_challenge_replaces_the_spent_marker() {
let state = build_test_vtc().await.state;
let now = Utc::now();
let c = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
consume(&state.join_requests_ks, BOB, &c, now)
.await
.unwrap();
let fresh = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
consume(&state.join_requests_ks, BOB, &fresh, now)
.await
.expect("a fresh challenge is accepted once");
}
#[tokio::test]
async fn an_expired_challenge_says_so_until_it_is_swept() {
let state = build_test_vtc().await.state;
let issued_at = Utc::now() - Duration::hours(1);
let c = issue(
&state.join_requests_ks,
BOB,
DEFAULT_CHALLENGE_TTL,
issued_at,
)
.await
.unwrap();
let expired_at = issued_at + DEFAULT_CHALLENGE_TTL;
for _ in 0..2 {
assert_eq!(
refusal(&state.join_requests_ks, BOB, &c, Utc::now()).await,
ChallengeRefusal::Expired { expired_at }
);
}
assert_eq!(
sweep_expired(&state.join_requests_ks, Utc::now())
.await
.unwrap(),
1,
"an abandoned challenge is swept"
);
assert_eq!(
refusal(&state.join_requests_ks, BOB, &c, Utc::now()).await,
ChallengeRefusal::NotIssued
);
}
#[tokio::test]
async fn the_spent_marker_holds_no_challenge_and_is_swept() {
let state = build_test_vtc().await.state;
let now = Utc::now();
let c = issue(&state.join_requests_ks, BOB, DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
consume(&state.join_requests_ks, BOB, &c, now)
.await
.unwrap();
let raw = state
.join_requests_ks
.get_raw(key(BOB))
.await
.unwrap()
.expect("a spent marker");
assert!(
!String::from_utf8_lossy(&raw).contains(&c),
"the marker keeps no challenge"
);
assert_eq!(
sweep_expired(&state.join_requests_ks, now).await.unwrap(),
0,
"not before its expiry"
);
assert_eq!(
sweep_expired(&state.join_requests_ks, now + DEFAULT_CHALLENGE_TTL)
.await
.unwrap(),
1
);
}
}