use std::sync::LazyLock;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use vti_common::error::AppError;
use crate::schemas::accepts::CredentialIssuers;
use crate::store::KeyspaceHandle;
pub const CLOSED_RECORD_RETENTION: Duration = Duration::hours(1);
const KEY_PREFIX: &str = "session:";
static SESSION_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SessionPhase {
AwaitingWallet,
RequestRetrieved,
Verifying,
Presented,
Refused,
Expired,
}
impl SessionPhase {
pub fn is_open(self) -> bool {
matches!(self, Self::AwaitingWallet | Self::RequestRetrieved)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RefusalReason {
PresentationInvalid,
QueryNotSatisfied,
IssuerNotTrusted,
StatusNotValid,
WalletDeclined,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EvidenceCredential {
pub credential_query_id: String,
pub format: String,
pub issuer: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vct: Option<String>,
pub claims: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issuer_authority_key_ids: Vec<String>,
}
impl std::fmt::Debug for EvidenceCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EvidenceCredential")
.field("credential_query_id", &self.credential_query_id)
.field("format", &self.format)
.field("issuer", &self.issuer)
.field("vct", &self.vct)
.field("claims", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalletEvidence {
pub satisfied: bool,
pub holder: String,
pub credentials: Vec<EvidenceCredential>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalletSession {
pub id: String,
pub applicant_did: String,
pub criterion_id: String,
pub requirements_digest: String,
pub credential_issuers: CredentialIssuers,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<Uuid>,
pub identity_id: String,
pub client_id: String,
pub dcql_query: Value,
pub nonce: String,
pub state: String,
pub enc_values: Vec<String>,
pub response_key_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_object: Option<String>,
pub phase: SessionPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<RefusalReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence: Option<WalletEvidence>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub held_until: Option<DateTime<Utc>>,
}
impl std::fmt::Debug for WalletSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WalletSession")
.field("id", &self.id)
.field("applicant_did", &self.applicant_did)
.field("criterion_id", &self.criterion_id)
.field("request_id", &self.request_id)
.field("identity_id", &self.identity_id)
.field("phase", &self.phase)
.field("reason", &self.reason)
.field(
"response_key",
&self.response_key.as_ref().map(|_| "<redacted>"),
)
.field("expires_at", &self.expires_at)
.finish()
}
}
impl WalletSession {
pub fn evidence_item(&self) -> Option<crate::join::storage::WalletEvidenceItem> {
let evidence = self.evidence.as_ref()?;
Some(crate::join::storage::WalletEvidenceItem {
session_id: self.id.clone(),
requirements_digest: self.requirements_digest.clone(),
holder: evidence.holder.clone(),
credentials: evidence.credentials.clone(),
presented_at: self.closed_at.unwrap_or(self.created_at),
})
}
pub fn open_at(&self, now: DateTime<Utc>) -> bool {
self.phase.is_open() && now < self.expires_at
}
fn close(&mut self, phase: SessionPhase, now: DateTime<Utc>) {
self.phase = phase;
self.response_key = None;
self.request_object = None;
self.closed_at = Some(now);
}
fn drop_after(&self) -> Option<DateTime<Utc>> {
let closed = self.closed_at? + CLOSED_RECORD_RETENTION;
Some(match self.held_until {
Some(held) if held > closed => held,
_ => closed,
})
}
}
pub fn random_token(n: usize) -> String {
let mut bytes = vec![0u8; n];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn storage_key(id: &str) -> String {
format!("{KEY_PREFIX}{id}")
}
pub async fn insert(ks: &KeyspaceHandle, session: &WalletSession) -> Result<(), AppError> {
let _guard = SESSION_LOCK.lock().await;
ks.insert(storage_key(&session.id), session).await
}
pub async fn get(ks: &KeyspaceHandle, id: &str) -> Result<Option<WalletSession>, AppError> {
ks.get(storage_key(id)).await
}
pub async fn list(ks: &KeyspaceHandle) -> Result<Vec<WalletSession>, AppError> {
let rows = ks.prefix_iter_raw(KEY_PREFIX).await?;
let mut out = Vec::with_capacity(rows.len());
for (_, value) in rows {
out.push(serde_json::from_slice(&value).map_err(|e| {
AppError::Internal(format!("an oid4vp_sessions record does not parse: {e}"))
})?);
}
Ok(out)
}
pub async fn open_count(
ks: &KeyspaceHandle,
applicant_did: &str,
now: DateTime<Utc>,
) -> Result<usize, AppError> {
Ok(list(ks)
.await?
.iter()
.filter(|s| s.applicant_did == applicant_did && s.open_at(now))
.count())
}
pub async fn take_request_object(
ks: &KeyspaceHandle,
id: &str,
now: DateTime<Utc>,
) -> Result<Option<String>, AppError> {
let _guard = SESSION_LOCK.lock().await;
let Some(mut session) = get(ks, id).await? else {
return Ok(None);
};
if !session.open_at(now) {
return Ok(None);
}
let Some(jar) = session.request_object.take() else {
return Ok(None);
};
session.phase = SessionPhase::RequestRetrieved;
ks.insert(storage_key(id), &session).await?;
Ok(Some(jar))
}
pub async fn claim_for_response(
ks: &KeyspaceHandle,
id: &str,
now: DateTime<Utc>,
) -> Result<Option<WalletSession>, AppError> {
let _guard = SESSION_LOCK.lock().await;
let Some(mut session) = get(ks, id).await? else {
return Ok(None);
};
if !session.open_at(now) || session.response_key.is_none() {
return Ok(None);
}
let claimed = session.clone();
session.phase = SessionPhase::Verifying;
session.response_key = None;
session.request_object = None;
ks.insert(storage_key(id), &session).await?;
Ok(Some(claimed))
}
pub async fn finish(
ks: &KeyspaceHandle,
id: &str,
outcome: Result<WalletEvidence, RefusalReason>,
hold: Duration,
now: DateTime<Utc>,
) -> Result<WalletSession, AppError> {
let _guard = SESSION_LOCK.lock().await;
let mut session = get(ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("wallet session {id} vanished")))?;
match outcome {
Ok(evidence) => {
session.close(SessionPhase::Presented, now);
if session.request_id.is_none() {
session.held_until = Some(now + hold);
}
session.evidence = Some(evidence);
}
Err(reason) => {
session.close(SessionPhase::Refused, now);
session.reason = Some(reason);
}
}
ks.insert(storage_key(id), &session).await?;
Ok(session)
}
pub async fn held_for(
ks: &KeyspaceHandle,
applicant_did: &str,
now: DateTime<Utc>,
) -> Result<Vec<WalletSession>, AppError> {
Ok(list(ks)
.await?
.into_iter()
.filter(|s| {
s.applicant_did == applicant_did
&& s.phase == SessionPhase::Presented
&& s.request_id.is_none()
&& s.evidence.is_some()
&& s.held_until.is_some_and(|t| now < t)
})
.collect())
}
pub async fn bind_to_request(
ks: &KeyspaceHandle,
ids: &[String],
request_id: Uuid,
now: DateTime<Utc>,
) -> Result<Vec<WalletSession>, AppError> {
let _guard = SESSION_LOCK.lock().await;
let mut bound = Vec::new();
for id in ids {
let Some(mut session) = get(ks, id).await? else {
continue;
};
if session.request_id.is_some() || !session.held_until.is_some_and(|t| now < t) {
continue;
}
session.request_id = Some(request_id);
session.held_until = None;
ks.insert(storage_key(id), &session).await?;
bound.push(session);
}
Ok(bound)
}
pub async fn sweep(ks: &KeyspaceHandle, now: DateTime<Utc>) -> Result<(usize, usize), AppError> {
let _guard = SESSION_LOCK.lock().await;
let (mut expired, mut dropped) = (0, 0);
for mut session in list(ks).await? {
if session.phase.is_open() && now >= session.expires_at {
session.close(SessionPhase::Expired, now);
ks.insert(storage_key(&session.id), &session).await?;
expired += 1;
} else if session.phase == SessionPhase::Verifying && now >= session.expires_at {
session.close(SessionPhase::Refused, now);
session.reason = Some(RefusalReason::PresentationInvalid);
ks.insert(storage_key(&session.id), &session).await?;
expired += 1;
} else if session.drop_after().is_some_and(|t| now >= t) {
ks.remove(storage_key(&session.id)).await?;
dropped += 1;
}
}
Ok((expired, dropped))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session(id: &str, applicant: &str, now: DateTime<Utc>) -> WalletSession {
WalletSession {
id: id.into(),
applicant_did: applicant.into(),
criterion_id: "residence".into(),
requirements_digest: "zQmDigest".into(),
credential_issuers: CredentialIssuers::Any,
request_id: None,
identity_id: "swiyu".into(),
client_id: "decentralized_identifier:did:webvh:Qm:verifier.example".into(),
dcql_query: json!({ "credentials": [] }),
nonce: random_token(16),
state: random_token(16),
enc_values: vec!["A256GCM".into()],
response_key_id: "response-key".into(),
response_key: Some("scalar".into()),
request_object: Some("jar".into()),
phase: SessionPhase::AwaitingWallet,
reason: None,
evidence: None,
created_at: now,
expires_at: now + Duration::minutes(10),
closed_at: None,
held_until: None,
}
}
fn evidence() -> WalletEvidence {
WalletEvidence {
satisfied: true,
holder: "did:key:zDnHolder".into(),
credentials: vec![],
}
}
async fn ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = vti_common::store::Store::open(&vti_common::config::StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store
.keyspace(crate::store::keyspaces::OID4VP_SESSIONS)
.unwrap();
(dir, ks)
}
#[tokio::test]
async fn the_request_object_is_handed_out_once() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("s1", "did:key:a", now)).await.unwrap();
assert_eq!(
take_request_object(&ks, "s1", now)
.await
.unwrap()
.as_deref(),
Some("jar")
);
assert_eq!(take_request_object(&ks, "s1", now).await.unwrap(), None);
let s = get(&ks, "s1").await.unwrap().unwrap();
assert_eq!(s.phase, SessionPhase::RequestRetrieved);
}
#[tokio::test]
async fn only_one_response_can_claim_a_session() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("s1", "did:key:a", now)).await.unwrap();
let claimed = claim_for_response(&ks, "s1", now).await.unwrap().unwrap();
assert_eq!(claimed.response_key.as_deref(), Some("scalar"));
assert!(claim_for_response(&ks, "s1", now).await.unwrap().is_none());
let stored = get(&ks, "s1").await.unwrap().unwrap();
assert_eq!(stored.phase, SessionPhase::Verifying);
assert!(
stored.response_key.is_none(),
"the key leaves with the claim"
);
}
#[tokio::test]
async fn an_expired_session_answers_nothing() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("s1", "did:key:a", now)).await.unwrap();
let later = now + Duration::minutes(11);
assert_eq!(take_request_object(&ks, "s1", later).await.unwrap(), None);
assert!(
claim_for_response(&ks, "s1", later)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn only_unbound_evidence_is_held() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("s1", "did:key:a", now)).await.unwrap();
let mut bound = session("s2", "did:key:a", now);
bound.request_id = Some(Uuid::new_v4());
insert(&ks, &bound).await.unwrap();
for id in ["s1", "s2"] {
claim_for_response(&ks, id, now).await.unwrap().unwrap();
}
let hold = Duration::hours(6);
let s1 = finish(&ks, "s1", Ok(evidence()), hold, now).await.unwrap();
assert_eq!(s1.held_until, Some(now + hold));
assert_eq!(s1.phase, SessionPhase::Presented);
let s2 = finish(&ks, "s2", Ok(evidence()), hold, now).await.unwrap();
assert_eq!(s2.held_until, None);
}
#[tokio::test]
async fn the_sweeper_closes_and_then_drops() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("open", "did:key:a", now))
.await
.unwrap();
insert(&ks, &session("stuck", "did:key:a", now))
.await
.unwrap();
claim_for_response(&ks, "stuck", now)
.await
.unwrap()
.unwrap();
assert_eq!(sweep(&ks, now).await.unwrap(), (0, 0));
let past = now + Duration::minutes(11);
assert_eq!(sweep(&ks, past).await.unwrap(), (2, 0));
let open = get(&ks, "open").await.unwrap().unwrap();
assert_eq!(open.phase, SessionPhase::Expired);
assert!(open.response_key.is_none() && open.request_object.is_none());
let stuck = get(&ks, "stuck").await.unwrap().unwrap();
assert_eq!(stuck.phase, SessionPhase::Refused);
assert_eq!(stuck.reason, Some(RefusalReason::PresentationInvalid));
let much_later = past + CLOSED_RECORD_RETENTION;
assert_eq!(sweep(&ks, much_later).await.unwrap(), (0, 2));
assert!(list(&ks).await.unwrap().is_empty());
}
#[tokio::test]
async fn held_evidence_outlives_the_closed_record_retention() {
let (_dir, ks) = ks().await;
let now = Utc::now();
insert(&ks, &session("s1", "did:key:a", now)).await.unwrap();
claim_for_response(&ks, "s1", now).await.unwrap().unwrap();
finish(&ks, "s1", Ok(evidence()), Duration::hours(6), now)
.await
.unwrap();
assert_eq!(sweep(&ks, now + Duration::hours(2)).await.unwrap(), (0, 0));
assert_eq!(sweep(&ks, now + Duration::hours(6)).await.unwrap(), (0, 1));
}
#[test]
fn open_counts_only_this_applicants_open_sessions() {
let now = Utc::now();
let mut closed = session("c", "did:key:a", now);
closed.close(SessionPhase::Expired, now);
assert!(!closed.open_at(now));
assert!(session("o", "did:key:a", now).open_at(now));
}
}