use std::collections::BTreeMap;
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::proof_check::{self, ProofError, Purpose};
pub const ISSUED_AT_SKEW: TimeDelta = TimeDelta::minutes(5);
pub const MAX_DOCUMENT_ID_CHARS: usize = 256;
pub const MAX_SEEN_PER_ISSUER: usize = 1024;
pub const MAX_SEEN_ISSUERS: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationalKind {
RemovalNotice,
CommunityAnswer,
}
impl OperationalKind {
#[must_use]
pub fn of(typ: &str) -> Self {
if typ == vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE {
OperationalKind::RemovalNotice
} else {
OperationalKind::CommunityAnswer
}
}
#[must_use]
pub fn max_age(self) -> TimeDelta {
match self {
OperationalKind::RemovalNotice => TimeDelta::days(30) + TimeDelta::days(1),
OperationalKind::CommunityAnswer => TimeDelta::days(1),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OperationalError {
#[error("it is not a Trust Task document")]
NotADocument,
#[error("it has no id")]
NoId,
#[error("its signed type is not the one this message was handled as")]
WrongType,
#[error("its id is too long")]
IdTooLong,
#[error("too many recent documents from this community to take another yet")]
QuotaExceeded,
#[error("it is not issued by the community it came from")]
IssuerNotSender,
#[error("it names no recipient")]
NoRecipient,
#[error("it is addressed to someone else")]
WrongRecipient,
#[error("it has no valid issuedAt")]
NoIssuedAt,
#[error("it is dated in the future")]
FromTheFuture,
#[error("it is too old to act on")]
TooOld,
#[error("it has expired")]
Expired,
#[error("it was already acted on (replay)")]
Replayed,
#[error("it is from a community we hold no membership with")]
NoStanding,
#[error("its proof: {0}")]
Proof(#[from] ProofError),
#[error("it was not checked")]
NotChecked,
#[error("its check did not finish (timed out or failed)")]
CheckUnfinished,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SeenDocuments {
#[serde(default)]
entries: BTreeMap<String, BTreeMap<String, DateTime<Utc>>>,
#[serde(skip)]
revision: u64,
}
impl SeenDocuments {
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub fn contains(&self, issuer: &str, id: &str, now: DateTime<Utc>) -> bool {
self.entries
.get(issuer)
.and_then(|ids| ids.get(id))
.is_some_and(|until| *until > now)
}
fn prune(&mut self, now: DateTime<Utc>) {
let before: usize = self.entries.values().map(BTreeMap::len).sum();
for ids in self.entries.values_mut() {
ids.retain(|_, until| *until > now);
}
self.entries.retain(|_, ids| !ids.is_empty());
let after: usize = self.entries.values().map(BTreeMap::len).sum();
if after != before {
self.revision += 1;
}
}
pub fn check(
&self,
issuer: &str,
id: &str,
now: DateTime<Utc>,
) -> Result<(), OperationalError> {
if self.contains(issuer, id, now) {
return Err(OperationalError::Replayed);
}
let live =
|ids: &BTreeMap<String, DateTime<Utc>>| ids.values().filter(|u| **u > now).count();
match self.entries.get(issuer) {
Some(ids) if live(ids) >= MAX_SEEN_PER_ISSUER => Err(OperationalError::QuotaExceeded),
None if self.entries.values().filter(|ids| live(ids) > 0).count()
>= MAX_SEEN_ISSUERS =>
{
Err(OperationalError::QuotaExceeded)
}
_ => Ok(()),
}
}
pub fn record(
&mut self,
issuer: &str,
id: &str,
forget_after: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<(), OperationalError> {
self.prune(now);
self.check(issuer, id, now)?;
self.entries
.entry(issuer.to_string())
.or_default()
.insert(id.to_string(), forget_after);
self.revision += 1;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "a verified document must be committed once bound, or it can be replayed"]
pub struct VerifiedOperational {
issuer: String,
id: String,
recipient: String,
forget_after: DateTime<Utc>,
}
impl VerifiedOperational {
#[must_use]
pub fn recipient(&self) -> &str {
&self.recipient
}
pub fn check(&self, seen: &SeenDocuments, now: DateTime<Utc>) -> Result<(), OperationalError> {
seen.check(&self.issuer, &self.id, now)
}
pub fn commit(
self,
seen: &mut SeenDocuments,
now: DateTime<Utc>,
) -> Result<(), OperationalError> {
seen.record(&self.issuer, &self.id, self.forget_after, now)
}
}
pub async fn verify_operational(
document: &Value,
sender: &str,
our_dids: &[&str],
typ: &str,
resolver: &DIDCacheClient,
seen: &SeenDocuments,
now: DateTime<Utc>,
) -> Result<VerifiedOperational, OperationalError> {
let verified = check_envelope(document, sender, our_dids, typ, seen, now)?;
proof_check::verify_signed(document, sender, resolver, &[Purpose::Authentication]).await?;
Ok(verified)
}
pub fn verify_operational_with(
document: &Value,
sender: &str,
sender_doc: &affinidi_tdk::did_common::Document,
our_dids: &[&str],
typ: &str,
seen: &SeenDocuments,
now: DateTime<Utc>,
) -> Result<VerifiedOperational, OperationalError> {
let verified = check_envelope(document, sender, our_dids, typ, seen, now)?;
proof_check::verify_proofs(document, sender, sender_doc, &[Purpose::Authentication])?;
Ok(verified)
}
fn check_envelope(
document: &Value,
sender: &str,
our_dids: &[&str],
typ: &str,
seen: &SeenDocuments,
now: DateTime<Utc>,
) -> Result<VerifiedOperational, OperationalError> {
let obj = document.as_object().ok_or(OperationalError::NotADocument)?;
if !obj.contains_key("payload") {
return Err(OperationalError::NotADocument);
}
if obj.get("type").and_then(Value::as_str) != Some(typ) {
return Err(OperationalError::WrongType);
}
let kind = OperationalKind::of(typ);
let id = obj
.get("id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or(OperationalError::NoId)?;
if id.chars().count() > MAX_DOCUMENT_ID_CHARS {
return Err(OperationalError::IdTooLong);
}
if obj.get("issuer").and_then(Value::as_str) != Some(sender) {
return Err(OperationalError::IssuerNotSender);
}
let recipient = obj
.get("recipient")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or(OperationalError::NoRecipient)?;
if !our_dids.contains(&recipient) {
return Err(OperationalError::WrongRecipient);
}
let issued_at = timestamp(obj.get("issuedAt")).ok_or(OperationalError::NoIssuedAt)?;
if issued_at > now + ISSUED_AT_SKEW {
return Err(OperationalError::FromTheFuture);
}
if issued_at + kind.max_age() < now {
return Err(OperationalError::TooOld);
}
match obj.get("expiresAt") {
None | Some(Value::Null) => {}
Some(v) => {
let expires = timestamp(Some(v)).ok_or(OperationalError::Expired)?;
if expires <= now {
return Err(OperationalError::Expired);
}
}
}
if seen.contains(sender, id, now) {
return Err(OperationalError::Replayed);
}
Ok(VerifiedOperational {
issuer: sender.to_string(),
id: id.to_string(),
recipient: recipient.to_string(),
forget_after: issued_at + kind.max_age() + ISSUED_AT_SKEW,
})
}
fn timestamp(value: Option<&Value>) -> Option<DateTime<Utc>> {
value
.and_then(Value::as_str)
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|t| t.with_timezone(&Utc))
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use affinidi_tdk::secrets_resolver::secrets::Secret;
use serde_json::json;
pub(crate) const TEST_TYPE: &str = "https://trusttasks.org/spec/vtc/test/0.1";
pub(crate) fn document(issuer: &str, recipient: &str, payload: Value) -> Value {
json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TEST_TYPE,
"issuer": issuer,
"recipient": recipient,
"issuedAt": Utc::now().to_rfc3339(),
"payload": payload,
})
}
pub(crate) async fn sign(doc: Value, signer: &Secret) -> Value {
proof_check::test_support::sign_for(doc, &[signer], Purpose::Authentication).await
}
}
#[cfg(test)]
mod tests {
use super::test_support::{TEST_TYPE, document, sign};
use super::*;
use crate::proof_check::test_support::{document as did_document, ed_key};
use serde_json::json;
const VTC: &str = "did:webvh:QmScid:vtc.example.com";
const ME: &str = "did:webvh:QmP:example.com:alice";
fn vtc() -> (
affinidi_tdk::secrets_resolver::secrets::Secret,
affinidi_tdk::secrets_resolver::secrets::Secret,
affinidi_tdk::did_common::Document,
) {
let assertion = ed_key(VTC, "key-0", 1);
let op = ed_key(VTC, "key-op", 2);
let doc = did_document(VTC, &[("key-0", &assertion)], &[("key-op", &op)]);
(assertion, op, doc)
}
fn verify(
d: &Value,
doc: &affinidi_tdk::did_common::Document,
seen: &SeenDocuments,
) -> Result<VerifiedOperational, OperationalError> {
verify_operational_with(d, VTC, doc, &[ME], TEST_TYPE, seen, Utc::now())
}
#[tokio::test]
async fn a_committed_document_is_not_taken_again() {
let (_, op, doc) = vtc();
let mut seen = SeenDocuments::default();
let d = sign(document(VTC, ME, json!({})), &op).await;
let v = verify(&d, &doc, &seen).unwrap();
assert_eq!(v.recipient(), ME);
assert!(seen.is_empty());
assert!(verify(&d, &doc, &seen).is_ok());
let rev = seen.revision();
v.commit(&mut seen, Utc::now()).unwrap();
assert_ne!(seen.revision(), rev, "a commit is a change to persist");
assert_eq!(verify(&d, &doc, &seen), Err(OperationalError::Replayed));
}
#[tokio::test]
async fn a_document_is_taken_only_as_its_signed_type() {
let (_, op, doc) = vtc();
let seen = SeenDocuments::default();
let d = sign(document(VTC, ME, json!({})), &op).await;
assert!(verify(&d, &doc, &seen).is_ok());
let as_notice = verify_operational_with(
&d,
VTC,
&doc,
&[ME],
vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE,
&seen,
Utc::now(),
);
assert_eq!(as_notice, Err(OperationalError::WrongType));
let mut untyped = document(VTC, ME, json!({}));
untyped.as_object_mut().unwrap().remove("type");
let untyped = sign(untyped, &op).await;
assert_eq!(
verify(&untyped, &doc, &seen),
Err(OperationalError::WrongType)
);
assert_eq!(
OperationalKind::of(vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE),
OperationalKind::RemovalNotice
);
assert_eq!(
OperationalKind::of(TEST_TYPE),
OperationalKind::CommunityAnswer
);
}
#[tokio::test]
async fn an_assertion_method_proof_is_not_operational() {
let (assertion, _, doc) = vtc();
let seen = SeenDocuments::default();
let d = proof_check::test_support::sign(document(VTC, ME, json!({})), &[&assertion]).await;
assert!(matches!(
verify(&d, &doc, &seen),
Err(OperationalError::Proof(ProofError::WrongPurpose(0)))
));
let d = sign(document(VTC, ME, json!({})), &assertion).await;
assert!(matches!(
verify(&d, &doc, &seen),
Err(OperationalError::Proof(ProofError::NotInRelationship(0)))
));
}
#[tokio::test]
async fn recipient_window_issuer_and_id_are_required() {
let (_, op, doc) = vtc();
let seen = SeenDocuments::default();
let mut no_recipient = document(VTC, ME, json!({}));
no_recipient.as_object_mut().unwrap().remove("recipient");
let no_recipient = sign(no_recipient, &op).await;
assert_eq!(
verify(&no_recipient, &doc, &seen),
Err(OperationalError::NoRecipient)
);
let other = sign(
document(VTC, "did:webvh:QmP:example.com:bob", json!({})),
&op,
)
.await;
assert_eq!(
verify(&other, &doc, &seen),
Err(OperationalError::WrongRecipient)
);
let mut undated = document(VTC, ME, json!({}));
undated.as_object_mut().unwrap().remove("issuedAt");
let undated = sign(undated, &op).await;
assert_eq!(
verify(&undated, &doc, &seen),
Err(OperationalError::NoIssuedAt)
);
let mut old = document(VTC, ME, json!({}));
old["issuedAt"] = json!((Utc::now() - TimeDelta::days(2)).to_rfc3339());
let old = sign(old, &op).await;
assert_eq!(verify(&old, &doc, &seen), Err(OperationalError::TooOld));
let mut future = document(VTC, ME, json!({}));
future["issuedAt"] = json!((Utc::now() + TimeDelta::hours(1)).to_rfc3339());
let future = sign(future, &op).await;
assert_eq!(
verify(&future, &doc, &seen),
Err(OperationalError::FromTheFuture)
);
let mut expired = document(VTC, ME, json!({}));
expired["expiresAt"] = json!((Utc::now() - TimeDelta::minutes(1)).to_rfc3339());
let expired = sign(expired, &op).await;
assert_eq!(
verify(&expired, &doc, &seen),
Err(OperationalError::Expired)
);
let mut foreign = document("did:webvh:QmOther:evil.example.com", ME, json!({}));
foreign["issuer"] = json!("did:webvh:QmOther:evil.example.com");
let foreign = sign(foreign, &op).await;
assert_eq!(
verify(&foreign, &doc, &seen),
Err(OperationalError::IssuerNotSender)
);
let mut long = document(VTC, ME, json!({}));
long["id"] = json!("x".repeat(MAX_DOCUMENT_ID_CHARS + 1));
let long = sign(long, &op).await;
assert_eq!(verify(&long, &doc, &seen), Err(OperationalError::IdTooLong));
}
#[test]
fn ids_are_per_issuer_and_quotas_refuse_rather_than_evict() {
let now = Utc::now();
let later = now + TimeDelta::days(30);
let mut seen = SeenDocuments::default();
seen.record(VTC, "genuine", later, now).unwrap();
assert!(seen.record("did:web:other", "genuine", later, now).is_ok());
let flood = "did:web:flood.example";
for i in 0..MAX_SEEN_PER_ISSUER {
seen.record(flood, &format!("f{i}"), now + TimeDelta::minutes(1), now)
.unwrap();
}
assert_eq!(
seen.record(flood, "one-more", later, now),
Err(OperationalError::QuotaExceeded)
);
assert!(
seen.contains(VTC, "genuine", now),
"another issuer is untouched"
);
assert!(seen.contains(flood, "f0", now), "nothing was evicted");
assert_eq!(
seen.record(VTC, "genuine", later, now),
Err(OperationalError::Replayed)
);
let after = now + TimeDelta::minutes(2);
assert!(seen.record(flood, "one-more", later, after).is_ok());
assert!(seen.contains(VTC, "genuine", after));
}
#[test]
fn remembered_ids_are_forgotten_after_their_window() {
let mut seen = SeenDocuments::default();
let now = Utc::now();
seen.record(VTC, "a", now + TimeDelta::hours(1), now)
.unwrap();
assert_eq!(
seen.record(VTC, "a", now + TimeDelta::hours(1), now),
Err(OperationalError::Replayed)
);
let later = now + TimeDelta::hours(2);
assert!(!seen.contains(VTC, "a", later));
assert!(
seen.record(VTC, "a", later + TimeDelta::hours(1), later)
.is_ok()
);
}
}