use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const PREFIX: &str = "present-challenge:";
pub const DEFAULT_CHALLENGE_TTL: Duration = Duration::minutes(5);
fn key(thread_id: &str) -> Vec<u8> {
format!("{PREFIX}{thread_id}").into_bytes()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PresentChallenge {
nonce: String,
aud: String,
expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumedChallenge {
pub nonce: String,
pub aud: String,
}
pub async fn issue(
ks: &KeyspaceHandle,
thread_id: &str,
aud: &str,
ttl: Duration,
now: DateTime<Utc>,
) -> Result<String, AppError> {
let nonce = Uuid::new_v4().to_string();
let rec = PresentChallenge {
nonce: nonce.clone(),
aud: aud.to_string(),
expires_at: now + ttl,
};
ks.insert(key(thread_id), &rec).await?;
Ok(nonce)
}
pub async fn consume(
ks: &KeyspaceHandle,
thread_id: &str,
now: DateTime<Utc>,
) -> Result<ConsumedChallenge, AppError> {
let rec: PresentChallenge = ks.get(key(thread_id)).await?.ok_or_else(|| {
AppError::Validation(format!(
"no presentation challenge for thread `{thread_id}` (unknown or already consumed)"
))
})?;
ks.remove(key(thread_id)).await?;
if now >= rec.expires_at {
return Err(AppError::Validation(format!(
"presentation challenge for thread `{thread_id}` expired at {}",
rec.expires_at
)));
}
Ok(ConsumedChallenge {
nonce: rec.nonce,
aud: rec.aud,
})
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn fresh_ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("join_requests").unwrap();
(dir, ks)
}
#[tokio::test]
async fn issue_then_consume_round_trips() {
let (_dir, ks) = fresh_ks();
let now = Utc::now();
let nonce = issue(
&ks,
"thread-1",
"did:web:vtc.example",
DEFAULT_CHALLENGE_TTL,
now,
)
.await
.unwrap();
let consumed = consume(&ks, "thread-1", now).await.unwrap();
assert_eq!(consumed.nonce, nonce);
assert_eq!(consumed.aud, "did:web:vtc.example");
}
#[tokio::test]
async fn consume_is_single_use() {
let (_dir, ks) = fresh_ks();
let now = Utc::now();
issue(
&ks,
"thread-1",
"did:web:vtc.example",
DEFAULT_CHALLENGE_TTL,
now,
)
.await
.unwrap();
consume(&ks, "thread-1", now).await.expect("first consume");
let err = consume(&ks, "thread-1", now).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("already consumed")),
"{err:?}"
);
}
#[tokio::test]
async fn consume_rejects_an_expired_challenge() {
let (_dir, ks) = fresh_ks();
let issued_at = Utc::now() - Duration::minutes(10);
issue(
&ks,
"thread-1",
"did:web:vtc.example",
DEFAULT_CHALLENGE_TTL,
issued_at,
)
.await
.unwrap();
let err = consume(&ks, "thread-1", Utc::now()).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("expired")),
"{err:?}"
);
let again = consume(&ks, "thread-1", Utc::now()).await.unwrap_err();
assert!(matches!(&again, AppError::Validation(m) if m.contains("already consumed")));
}
#[tokio::test]
async fn consume_rejects_an_unknown_thread() {
let (_dir, ks) = fresh_ks();
let err = consume(&ks, "never-issued", Utc::now()).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("unknown")),
"{err:?}"
);
}
}