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 vm_controller = proof
.verification_method
.split('#')
.next()
.unwrap_or(&proof.verification_method);
if vm_controller != credential.issuer() {
tracing::warn!(
issuer = %credential.issuer(),
signer = %vm_controller,
"an invitation claiming a room as issuer was signed by another party"
);
return Err(AppError::Validation(format!(
"the invitation says room `{}` issued it but is signed by `{vm_controller}`",
credential.issuer()
)));
}
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())
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
struct DidKeyOnly;
#[async_trait::async_trait]
impl vti_rooms_dtg::VerificationKeys for DidKeyOnly {
async fn public_key(&self, verification_method: &str) -> Result<Vec<u8>, AppError> {
let did = verification_method
.split('#')
.next()
.unwrap_or(verification_method);
let mb = did
.strip_prefix("did:key:")
.ok_or_else(|| AppError::Validation(format!("not a did:key: {did}")))?;
let (_, bytes) = multibase::decode(mb)
.map_err(|e| AppError::Validation(format!("decode {did}: {e}")))?;
Ok(bytes[2..].to_vec())
}
}
fn a_party(seed: u8) -> (String, affinidi_secrets_resolver::secrets::Secret) {
let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
let pk = sk.verifying_key().to_bytes();
let mut mc = vec![0xed, 0x01];
mc.extend_from_slice(&pk);
let did = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &mc)
);
let secret = affinidi_secrets_resolver::secrets::Secret::from_str(
&format!("{did}#{}", did.trim_start_matches("did:key:")),
&serde_json::json!({
"crv": "Ed25519",
"d": B64.encode(sk.to_bytes()),
"kty": "OKP",
"x": B64.encode(pk),
}),
)
.expect("build a signing secret");
(did, secret)
}
async fn an_invitation(
issuer: &str,
signer: &affinidi_secrets_resolver::secrets::Secret,
subject: &str,
id: &str,
) -> String {
let now = Utc::now();
an_invitation_valid(
issuer,
signer,
subject,
id,
now - chrono::Duration::minutes(1),
Some(now + chrono::Duration::hours(1)),
)
.await
}
async fn an_invitation_valid(
issuer: &str,
signer: &affinidi_secrets_resolver::secrets::Secret,
subject: &str,
id: &str,
from: chrono::DateTime<Utc>,
until: Option<chrono::DateTime<Utc>>,
) -> String {
let mut vic = DTGCredential::new_vic(issuer.to_string(), subject.to_string(), from, until)
.with_id(id);
vic.sign(signer, None).await.expect("sign the invitation");
serde_json::to_string(vic.credential()).expect("serialise")
}
#[tokio::test]
async fn an_invitation_outside_its_window_is_refused() {
let (room, room_secret) = a_party(0x47);
let me = "did:key:zMember";
let now = Utc::now();
let expired = an_invitation_valid(
&room,
&room_secret,
me,
"urn:uuid:w-1",
now - chrono::Duration::hours(2),
Some(now - chrono::Duration::hours(1)),
)
.await;
let err = verify(&expired, &room, me, &DidKeyOnly)
.await
.expect_err("an invitation that has run out must not still admit");
assert!(format!("{err}").contains("expired"), "{err}");
let premature = an_invitation_valid(
&room,
&room_secret,
me,
"urn:uuid:w-2",
now + chrono::Duration::hours(1),
Some(now + chrono::Duration::hours(2)),
)
.await;
let err = verify(&premature, &room, me, &DidKeyOnly)
.await
.expect_err("nor one that has not started");
assert!(format!("{err}").contains("not valid yet"), "{err}");
let forever = an_invitation_valid(
&room,
&room_secret,
me,
"urn:uuid:w-3",
now - chrono::Duration::minutes(1),
None,
)
.await;
verify(&forever, &room, me, &DidKeyOnly)
.await
.expect("an open-ended invitation is the issuer's call, not this gate's");
}
#[tokio::test]
async fn a_genuine_invitation_verifies() {
let (room, room_secret) = a_party(0x41);
let vic = an_invitation(&room, &room_secret, "did:key:zMember", "urn:uuid:i-1").await;
let verified = verify(&vic, &room, "did:key:zMember", &DidKeyOnly)
.await
.expect("a genuine invitation must verify");
assert_eq!(verified.credential_id(), "urn:uuid:i-1");
}
#[tokio::test]
async fn an_invitation_signed_by_anyone_but_the_room_is_refused() {
let (room, _room_secret) = a_party(0x42);
let (_attacker, attacker_secret) = a_party(0x43);
let forged =
an_invitation(&room, &attacker_secret, "did:key:zMember", "urn:uuid:i-2").await;
let err = verify(&forged, &room, "did:key:zMember", &DidKeyOnly)
.await
.expect_err("a forged invitation must be refused");
assert!(
format!("{err}").contains("is signed by"),
"the refusal must name the mismatch rather than read as a bad signature: {err}"
);
}
#[tokio::test]
async fn an_invitation_to_another_room_is_refused() {
let (room, _) = a_party(0x44);
let (elsewhere, elsewhere_secret) = a_party(0x45);
let vic = an_invitation(
&elsewhere,
&elsewhere_secret,
"did:key:zMember",
"urn:uuid:i-3",
)
.await;
let err = verify(&vic, &room, "did:key:zMember", &DidKeyOnly)
.await
.expect_err("an invitation to another room is not one to this one");
assert!(format!("{err}").contains("issued by"), "{err}");
}
#[tokio::test]
async fn an_invitation_to_somebody_else_is_refused() {
let (room, room_secret) = a_party(0x46);
let vic = an_invitation(&room, &room_secret, "did:key:zSomeoneElse", "urn:uuid:i-4").await;
let err = verify(&vic, &room, "did:key:zMember", &DidKeyOnly)
.await
.expect_err("an invitation is not transferable");
assert!(format!("{err}").contains("not transferable"), "{err}");
}
}