use chrono::Utc;
use dtg_credentials::{DTGCredential, DTGCredentialType};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use vti_rooms_dtg::VerificationKeys;
#[derive(Debug)]
pub struct VerifiedInvitation {
credential_id: String,
subject: String,
}
impl VerifiedInvitation {
pub fn credential_id(&self) -> &str {
&self.credential_id
}
pub fn subject(&self) -> &str {
&self.subject
}
}
pub async fn verify(
encoded: &str,
room_id: &str,
expected_subject: &str,
keys: &dyn VerificationKeys,
) -> Result<VerifiedInvitation, AppError> {
let credential: DTGCredential = decode(encoded)?;
if !matches!(credential.type_(), DTGCredentialType::Invitation) {
return Err(AppError::Validation(format!(
"the presented credential is a {}, not an invitation",
credential.type_()
)));
}
if credential.issuer() != room_id {
return Err(AppError::Validation(format!(
"the invitation was issued by `{}`, not by room `{room_id}`",
credential.issuer()
)));
}
if credential.subject() != expected_subject {
return Err(AppError::Validation(format!(
"the invitation names `{}`, not this member; an invitation is not transferable",
credential.subject()
)));
}
let now = Utc::now();
let common = credential.credential();
if common.valid_from > now {
return Err(AppError::Validation(
"the invitation is not valid yet".into(),
));
}
if let Some(until) = common.valid_until
&& until < now
{
return Err(AppError::Validation("the invitation has expired".into()));
}
let proof = common
.proof
.as_ref()
.ok_or_else(|| AppError::Validation("the invitation carries no proof".into()))?;
let key = keys
.public_key(&proof.verification_method)
.await
.map_err(|e| {
tracing::warn!(
verification_method = %proof.verification_method,
error = %e,
"could not resolve an invitation's verification method"
);
AppError::Validation("the invitation could not be verified".into())
})?;
credential.verify_proof_with_public_key(&key).map_err(|e| {
tracing::warn!(error = %e, "an invitation's proof did not verify");
AppError::Validation("the invitation could not be verified".into())
})?;
Ok(VerifiedInvitation {
credential_id: credential
.id()
.ok_or_else(|| {
AppError::Validation(
"the invitation carries no id, so it cannot be recorded as used".into(),
)
})?
.to_string(),
subject: credential.subject().to_string(),
})
}
fn decode(encoded: &str) -> Result<DTGCredential, AppError> {
use base64::Engine as _;
let bytes = if encoded.trim_start().starts_with('{') {
encoded.as_bytes().to_vec()
} else {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(encoded.trim())
.map_err(|_| {
AppError::Validation(
"the invitation is neither base64url nor JSON; one that cannot be read \
cannot be verified"
.into(),
)
})?
};
serde_json::from_slice(&bytes)
.map_err(|e| AppError::Validation(format!("the invitation is not a DTG credential: {e}")))
}
pub async fn is_consumed(
invitations: &KeyspaceHandle,
credential_id: &str,
) -> Result<bool, AppError> {
Ok(invitations
.get_raw(super::room_groups::invitation_key(credential_id))
.await
.map_err(|e| AppError::Internal(format!("read the invitation record: {e}")))?
.is_some())
}