use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use affinidi_data_integrity::{DataIntegrityError, ResolvedKey, SignatureFailure, VerifyOptions};
use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use async_trait::async_trait;
use didwebvh_rs::host_policy::HostPolicy;
use didwebvh_rs::resolve::ResolveOptions;
use serde::Serialize;
use trust_tasks_proof::affinidi::{
CachedDidResolver, ProofPurpose, ProofPurposeResolver, PurposeBound,
};
use trust_tasks_rs::{ProofVerifier, TrustTask, VerificationError};
pub const OPERATIONAL_PROOF_PURPOSE: &str = "authentication";
pub const APPROVAL_PROOF_PURPOSE: &str = "assertionMethod";
const UNREACHABLE: &str = "signer DID unreachable";
const DEACTIVATED: &str = "is deactivated; its keys are no longer valid";
const DEACTIVATION_CACHE_CAPACITY: usize = 4096;
pub fn is_unreachable(err: &VerificationError) -> bool {
matches!(err, VerificationError::Other(m) if m.contains(UNREACHABLE))
}
pub const FORCED_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
struct SessionDelegate {
principal: String,
verification_method: String,
}
#[async_trait]
trait StaleKeyRefresh: Send + Sync {
async fn refresh(&self, did: &str) -> bool;
}
#[derive(Default)]
struct RefreshLimiter {
last: Mutex<HashMap<String, Instant>>,
}
impl RefreshLimiter {
fn allow(&self, did: &str, now: Instant) -> bool {
let mut last = self.last.lock().unwrap_or_else(|p| p.into_inner());
if last
.get(did)
.is_some_and(|t| now.duration_since(*t) < FORCED_REFRESH_INTERVAL)
{
return false;
}
last.retain(|_, t| now.duration_since(*t) < FORCED_REFRESH_INTERVAL);
last.insert(did.to_string(), now);
true
}
}
struct CacheRefresher {
client: DIDCacheClient,
limiter: RefreshLimiter,
deactivation: Arc<DeactivationCache>,
}
#[async_trait]
impl StaleKeyRefresh for CacheRefresher {
async fn refresh(&self, did: &str) -> bool {
if !self.limiter.allow(did, Instant::now()) {
return false;
}
self.client.remove(did).await;
self.deactivation.forget(did);
true
}
}
#[derive(Default)]
struct DeactivationCache {
verdicts: Mutex<HashMap<String, (Instant, bool)>>,
host_policy: HostPolicy,
}
impl DeactivationCache {
fn ttl() -> Duration {
Duration::from_secs(u64::from(crate::server::identity::DID_CACHE_TTL_SECS))
}
fn forget(&self, did: &str) {
self.verdicts
.lock()
.unwrap_or_else(|p| p.into_inner())
.remove(did);
}
fn cached(&self, did: &str, document_cached: bool) -> Option<bool> {
if !document_cached {
return None;
}
self.verdicts
.lock()
.unwrap_or_else(|p| p.into_inner())
.get(did)
.filter(|(at, _)| at.elapsed() < Self::ttl())
.map(|(_, v)| *v)
}
fn remember(&self, did: &str, deactivated: bool) {
let mut verdicts = self.verdicts.lock().unwrap_or_else(|p| p.into_inner());
if verdicts.len() >= DEACTIVATION_CACHE_CAPACITY && !verdicts.contains_key(did) {
let ttl = Self::ttl();
verdicts.retain(|_, (at, _)| at.elapsed() < ttl);
if verdicts.len() >= DEACTIVATION_CACHE_CAPACITY
&& let Some(oldest) = verdicts
.iter()
.min_by_key(|(_, (at, _))| *at)
.map(|(k, _)| k.clone())
{
verdicts.remove(&oldest);
}
}
verdicts.insert(did.to_string(), (Instant::now(), deactivated));
}
async fn is_deactivated(
&self,
did: &str,
document_cached: bool,
) -> Result<bool, DataIntegrityError> {
if let Some(verdict) = self.cached(did, document_cached) {
return Ok(verdict);
}
let mut state = didwebvh_rs::DIDWebVHState::default();
let options = ResolveOptions::default().with_host_policy(self.host_policy);
let deactivated = state
.resolve(did, options)
.await
.map(|(_, meta)| meta.deactivated)
.map_err(|e| {
DataIntegrityError::Resolver(format!(
"{UNREACHABLE}: cannot read the DID log of {did} to establish whether it is \
deactivated ({e})"
))
})?;
self.remember(did, deactivated);
Ok(deactivated)
}
}
struct SignerKeyResolver {
client: DIDCacheClient,
decode: CachedDidResolver,
deactivation: Option<Arc<DeactivationCache>>,
}
#[async_trait]
impl ProofPurposeResolver for SignerKeyResolver {
async fn resolve_vm_for_purpose(
&self,
vm: &str,
purpose: ProofPurpose,
) -> Result<ResolvedKey, DataIntegrityError> {
let did = controller_did(vm);
let resolved = match self.client.resolve(did).await {
Ok(resolved) => resolved,
Err(e) => {
if did.starts_with("did:webvh:")
&& let Some(deactivation) = &self.deactivation
&& let Ok(true) = deactivation.is_deactivated(did, false).await
{
return Err(DataIntegrityError::Resolver(format!("{did} {DEACTIVATED}")));
}
return Err(DataIntegrityError::Resolver(format!(
"{UNREACHABLE}: cannot resolve {did} (is its DID document reachable from \
this service?): {e}"
)));
}
};
let key = self.decode.resolve_vm_for_purpose(vm, purpose).await?;
if did.starts_with("did:webvh:")
&& let Some(deactivation) = &self.deactivation
&& deactivation.is_deactivated(did, resolved.cache_hit).await?
{
return Err(DataIntegrityError::Resolver(format!("{did} {DEACTIVATED}")));
}
Ok(key)
}
}
#[derive(Clone)]
pub struct TransportBoundVerifier {
resolver: Arc<dyn ProofPurposeResolver>,
signer_resolver: Arc<dyn ProofPurposeResolver>,
refresher: Option<Arc<dyn StaleKeyRefresh>>,
options: VerifyOptions,
delegate: Option<SessionDelegate>,
#[cfg_attr(not(any(test, feature = "test-support")), allow(dead_code))]
deactivation: Option<Arc<DeactivationCache>>,
}
pub fn build_verifier(
did_resolver: Option<&DIDCacheClient>,
) -> Option<Arc<TransportBoundVerifier>> {
did_resolver
.cloned()
.map(|client| Arc::new(TransportBoundVerifier::with_did_cache(client)))
}
impl TransportBoundVerifier {
pub fn with_resolver(resolver: Arc<dyn ProofPurposeResolver>) -> Self {
Self {
signer_resolver: resolver.clone(),
resolver,
refresher: None,
options: VerifyOptions::default(),
delegate: None,
deactivation: None,
}
}
pub fn with_did_cache(client: DIDCacheClient) -> Self {
Self::with_did_cache_and_host_policy(client, HostPolicy::PublicOnly)
}
pub fn with_did_cache_and_host_policy(client: DIDCacheClient, host_policy: HostPolicy) -> Self {
let decode = CachedDidResolver::new(Arc::new(client.clone()));
let deactivation = Arc::new(DeactivationCache {
host_policy,
..DeactivationCache::default()
});
Self {
resolver: Arc::new(decode.clone()),
signer_resolver: Arc::new(SignerKeyResolver {
client: client.clone(),
decode,
deactivation: Some(deactivation.clone()),
}),
refresher: Some(Arc::new(CacheRefresher {
client,
limiter: RefreshLimiter::default(),
deactivation: deactivation.clone(),
})),
options: VerifyOptions::default(),
delegate: None,
deactivation: Some(deactivation),
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn record_deactivation_verdict(&self, did: &str, deactivated: bool) {
if let Some(deactivation) = &self.deactivation {
deactivation.remember(did, deactivated);
}
}
pub fn with_options(mut self, options: VerifyOptions) -> Self {
self.options = options;
self
}
pub fn with_session_delegate(
mut self,
principal: impl Into<String>,
verification_method: impl Into<String>,
) -> Self {
self.delegate = Some(SessionDelegate {
principal: principal.into(),
verification_method: verification_method.into(),
});
self
}
pub async fn verify_operational<P>(&self, doc: &TrustTask<P>) -> Result<(), VerificationError>
where
P: Serialize + Send + Sync,
{
if let Some(proof) = &doc.proof
&& proof.proof_purpose != OPERATIONAL_PROOF_PURPOSE
{
return Err(VerificationError::MalformedProof(format!(
"proofPurpose must be `{OPERATIONAL_PROOF_PURPOSE}` for this message, not `{}`",
proof.proof_purpose
)));
}
self.verify_with(
doc,
&*self.signer_resolver,
ProofPurpose::Authentication,
Delegation::Allowed,
)
.await
}
pub async fn verify_approval<P>(&self, doc: &TrustTask<P>) -> Result<(), VerificationError>
where
P: Serialize + Send + Sync,
{
if let Some(proof) = &doc.proof
&& proof.proof_purpose != APPROVAL_PROOF_PURPOSE
{
return Err(VerificationError::MalformedProof(format!(
"proofPurpose must be `{APPROVAL_PROOF_PURPOSE}` for an approver's decision, not `{}`",
proof.proof_purpose
)));
}
self.verify_with(
doc,
&*self.signer_resolver,
ProofPurpose::AssertionMethod,
Delegation::Refused,
)
.await
}
async fn verify_with<P>(
&self,
doc: &TrustTask<P>,
resolver: &dyn ProofPurposeResolver,
purpose: ProofPurpose,
delegation: Delegation,
) -> Result<(), VerificationError>
where
P: Serialize + Send + Sync,
{
let (parsed_proof, doc_value) = self.prepare(doc, delegation)?;
let resolver = PurposeBound::new(resolver, purpose);
let attempt = parsed_proof
.verify(&doc_value, &resolver, self.options.clone())
.await;
let err = match attempt {
Ok(_) => return Ok(()),
Err(e) => e,
};
let retryable = match &err {
DataIntegrityError::Resolver(m) => !m.contains(DEACTIVATED),
DataIntegrityError::InvalidSignature { .. } => true,
_ => false,
};
let vm = doc
.proof
.as_ref()
.map(|p| p.verification_method.clone())
.unwrap_or_default();
if retryable
&& let Some(refresher) = &self.refresher
&& refresher.refresh(controller_did(&vm)).await
{
tracing::debug!(vm, error = %err, "proof failed against cached DID document; retrying fresh");
return parsed_proof
.verify(&doc_value, &resolver, self.options.clone())
.await
.map(|_| ())
.map_err(map_error);
}
Err(map_error(err))
}
fn prepare<P>(
&self,
doc: &TrustTask<P>,
delegation: Delegation,
) -> Result<
(
affinidi_data_integrity::DataIntegrityProof,
serde_json::Value,
),
VerificationError,
>
where
P: Serialize + Send + Sync,
{
let Some(proof) = &doc.proof else {
return Err(VerificationError::MalformedProof(
"document carries no proof member".to_string(),
));
};
let proof_value = serde_json::to_value(proof)
.map_err(|e| VerificationError::MalformedProof(format!("serialise proof: {e}")))?;
let parsed_proof = trust_tasks_proof::affinidi::parse_data_integrity_proof(&proof_value)?;
let mut doc_value = serde_json::to_value(doc).map_err(|e| {
VerificationError::Other(format!("serialise TrustTask for verification: {e}"))
})?;
if let Some(obj) = doc_value.as_object_mut() {
obj.remove("proof");
}
let Some(issuer) = doc_value.get("issuer").and_then(|v| v.as_str()) else {
return Err(VerificationError::IssuerMismatch(
"document carries a proof but no in-band issuer to bind it to".to_string(),
));
};
let vm = proof.verification_method.as_str();
let self_signed = controller_did(vm) == issuer;
let delegated = delegation == Delegation::Allowed
&& self
.delegate
.as_ref()
.is_some_and(|d| d.verification_method == vm && d.principal == issuer);
if !self_signed && !delegated {
return Err(VerificationError::IssuerMismatch(format!(
"verificationMethod is controlled by {}, not the document issuer {issuer}",
controller_did(vm)
)));
}
Ok((parsed_proof, doc_value))
}
}
pub fn controller_did(verification_method: &str) -> &str {
verification_method
.split('#')
.next()
.unwrap_or(verification_method)
}
#[async_trait]
impl ProofVerifier for TransportBoundVerifier {
async fn verify<P>(&self, doc: &TrustTask<P>) -> Result<(), VerificationError>
where
P: Serialize + Send + Sync,
{
let purpose = doc
.proof
.as_ref()
.map(|p| ProofPurpose::parse(&p.proof_purpose))
.transpose()
.map_err(map_error)?
.unwrap_or(ProofPurpose::Authentication);
let delegation = if matches!(purpose, ProofPurpose::AssertionMethod) {
Delegation::Refused
} else {
Delegation::Allowed
};
self.verify_with(doc, &*self.resolver, purpose, delegation)
.await
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Delegation {
Allowed,
Refused,
}
fn map_error(err: DataIntegrityError) -> VerificationError {
match err {
DataIntegrityError::UnsupportedCryptoSuite { name } => {
VerificationError::UnsupportedCryptosuite(name)
}
DataIntegrityError::KeyTypeMismatch {
expected,
actual,
suite,
} => VerificationError::IssuerMismatch(format!(
"key type {actual:?} does not match cryptosuite {suite:?} (expected {expected:?})"
)),
DataIntegrityError::InvalidSignature { reason, .. } => match reason {
SignatureFailure::Malformed | SignatureFailure::Invalid => {
VerificationError::SignatureInvalid
}
_ => VerificationError::SignatureInvalid,
},
DataIntegrityError::InvalidPublicKey { reason, .. } => {
VerificationError::MalformedProof(format!("public key: {reason}"))
}
DataIntegrityError::Canonicalization(reason) => {
VerificationError::Other(format!("canonicalisation: {reason}"))
}
DataIntegrityError::MalformedProof(reason) => VerificationError::MalformedProof(reason),
other => VerificationError::Other(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::{
DataIntegrityProof, DidKeyResolver, SignOptions, VerificationMethodResolver,
};
use affinidi_secrets_resolver::secrets::Secret;
use serde_json::{Value, json};
use trust_tasks_rs::TrustTask;
fn verifier() -> TransportBoundVerifier {
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
}
fn base_body() -> Value {
json!({
"id": "urn:uuid:24f1b27f-36d9-49af-9a20-751def9000aa",
"type": "https://trusttasks.org/spec/acl/grant/0.1",
"recipient": "did:web:server.example",
"issuedAt": "2026-06-09T08:51:03Z",
"payload": { "entry": { "subject": "did:web:alice.example", "role": "owner" } },
})
}
async fn sign(body: Value) -> (TrustTask<Value>, String) {
sign_with(body, SignOptions::new()).await
}
async fn sign_with(body: Value, options: SignOptions) -> (TrustTask<Value>, String) {
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let pk_mb = secret.get_public_keymultibase().expect("multibase pubkey");
let did_key = format!("did:key:{pk_mb}");
let mut signer = secret.clone();
signer.id = format!("{did_key}#{pk_mb}");
let doc_noproof: TrustTask<Value> =
serde_json::from_value(body).expect("body parses as TrustTask");
let signing_value = serde_json::to_value(&doc_noproof).expect("serialise doc");
let di_proof = DataIntegrityProof::sign(&signing_value, &signer, options)
.await
.expect("sign");
let mut full = signing_value;
full.as_object_mut().unwrap().insert(
"proof".to_string(),
serde_json::to_value(&di_proof).unwrap(),
);
let doc: TrustTask<Value> = serde_json::from_value(full).expect("proofed doc parses");
(doc, did_key)
}
#[tokio::test]
async fn issuer_absent_rejected() {
let (doc, _did) = sign(base_body()).await;
assert!(doc.issuer.is_none(), "test fixture must omit issuer");
let err = verifier().verify(&doc).await.expect_err("must reject");
assert!(
matches!(err, VerificationError::IssuerMismatch(_)),
"expected IssuerMismatch, got {err:?}"
);
}
#[tokio::test]
async fn session_delegate_accepts_its_key_for_its_principal() {
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!("did:web:alice.example"));
let (doc, signer_did) = sign_with(
body,
SignOptions::new().with_proof_purpose(OPERATIONAL_PROOF_PURPOSE),
)
.await;
let vm = doc.proof.as_ref().unwrap().verification_method.clone();
assert!(vm.starts_with(&signer_did));
verifier()
.with_session_delegate("did:web:alice.example", vm)
.verify(&doc)
.await
.expect("delegated proof verifies");
}
#[tokio::test]
async fn session_delegate_rejects_other_principal() {
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!("did:web:admin.example"));
let (doc, _) = sign(body).await;
let vm = doc.proof.as_ref().unwrap().verification_method.clone();
let err = verifier()
.with_session_delegate("did:web:alice.example", vm)
.verify(&doc)
.await
.expect_err("must reject");
assert!(matches!(err, VerificationError::IssuerMismatch(_)));
}
#[tokio::test]
async fn session_delegate_never_signs_an_approval() {
let approval = SignOptions::new().with_proof_purpose(APPROVAL_PROOF_PURPOSE);
let (as_itself, signer_did) = sign_with(base_body(), approval.clone()).await;
let mut as_itself_body = serde_json::to_value(&as_itself).unwrap();
as_itself_body["issuer"] = json!(signer_did);
as_itself_body.as_object_mut().unwrap().remove("proof");
let (as_itself, _) = sign_with(as_itself_body, approval.clone()).await;
verifier()
.verify_approval(&as_itself)
.await
.expect("a did:key's own assertionMethod proof verifies");
let mut body = base_body();
body["issuer"] = json!("did:web:alice.example");
let (doc, _) = sign_with(body, approval).await;
let vm = doc.proof.as_ref().unwrap().verification_method.clone();
let delegated = verifier().with_session_delegate("did:web:alice.example", vm);
let err = delegated
.verify_approval(&doc)
.await
.expect_err("a delegate must not approve");
assert!(
matches!(err, VerificationError::IssuerMismatch(_)),
"{err:?}"
);
let err = delegated
.verify(&doc)
.await
.expect_err("nor pass an assertionMethod proof through the generic check");
assert!(
matches!(err, VerificationError::IssuerMismatch(_)),
"{err:?}"
);
}
#[tokio::test]
async fn session_delegate_rejects_other_key() {
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!("did:web:alice.example"));
let (doc, _) = sign(body).await;
let err = verifier()
.with_session_delegate("did:web:alice.example", "did:key:z6MkOther#z6MkOther")
.verify(&doc)
.await
.expect_err("must reject");
assert!(matches!(err, VerificationError::IssuerMismatch(_)));
}
#[tokio::test]
async fn issuer_present_matching_verifies() {
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let did_key = format!("did:key:{pk_mb}");
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!(did_key));
let (doc, signer_did) = sign(body).await;
assert_eq!(doc.issuer.as_deref(), Some(signer_did.as_str()));
verifier()
.verify(&doc)
.await
.expect("issuer-matched proof verifies");
}
#[tokio::test]
async fn issuer_present_mismatch_rejected() {
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!("did:web:evil.example"));
let (doc, signer_did) = sign(body).await;
assert_ne!(signer_did, "did:web:evil.example");
let err = verifier().verify(&doc).await.expect_err("must reject");
assert!(
matches!(err, VerificationError::IssuerMismatch(_)),
"expected IssuerMismatch, got {err:?}"
);
}
#[tokio::test]
async fn tampered_payload_rejected() {
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!(format!("did:key:{pk_mb}")));
let (mut doc, _did) = sign(body).await;
doc.payload = json!({ "entry": { "subject": "did:web:mallory.example", "role": "admin" } });
let err = verifier()
.verify(&doc)
.await
.expect_err("must reject tampered doc");
assert!(
matches!(err, VerificationError::SignatureInvalid),
"expected SignatureInvalid, got {err:?}"
);
}
#[tokio::test]
async fn missing_proof_rejected() {
let doc: TrustTask<Value> = serde_json::from_value(base_body()).expect("body parses");
let err = verifier().verify(&doc).await.expect_err("must reject");
assert!(
matches!(err, VerificationError::MalformedProof(_)),
"expected MalformedProof, got {err:?}"
);
}
struct RotatedResolver {
refreshed: Arc<std::sync::atomic::AtomicBool>,
}
#[async_trait]
impl ProofPurposeResolver for RotatedResolver {
async fn resolve_vm_for_purpose(
&self,
vm: &str,
purpose: ProofPurpose,
) -> Result<ResolvedKey, DataIntegrityError> {
if self.refreshed.load(std::sync::atomic::Ordering::SeqCst) {
DidKeyResolver.resolve_vm_for_purpose(vm, purpose).await
} else {
Err(DataIntegrityError::Resolver(format!(
"{vm} not in cached document"
)))
}
}
}
struct FlagRefresher {
refreshed: Arc<std::sync::atomic::AtomicBool>,
calls: std::sync::atomic::AtomicUsize,
allow: bool,
}
#[async_trait]
impl StaleKeyRefresh for FlagRefresher {
async fn refresh(&self, _did: &str) -> bool {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.allow {
self.refreshed
.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.allow
}
}
fn rotated_verifier(allow: bool) -> (TransportBoundVerifier, Arc<FlagRefresher>) {
let refreshed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let refresher = Arc::new(FlagRefresher {
refreshed: refreshed.clone(),
calls: Default::default(),
allow,
});
let mut v = TransportBoundVerifier::with_resolver(Arc::new(RotatedResolver { refreshed }));
v.refresher = Some(refresher.clone());
(v, refresher)
}
async fn self_issued_doc() -> TrustTask<Value> {
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!(format!("did:key:{pk_mb}")));
sign(body).await.0
}
#[tokio::test]
async fn a_stale_cached_document_is_re_resolved_once_before_refusing() {
let (v, refresher) = rotated_verifier(true);
v.verify(&self_issued_doc().await)
.await
.expect("verifies after refresh");
assert_eq!(refresher.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn without_a_refresh_the_stale_failure_stands() {
let (v, refresher) = rotated_verifier(false);
assert!(v.verify(&self_issued_doc().await).await.is_err());
assert_eq!(refresher.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_binding_failure_does_not_trigger_a_refresh() {
let (v, refresher) = rotated_verifier(true);
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!("did:web:evil.example"));
let (doc, _) = sign(body).await;
assert!(matches!(
v.verify(&doc).await,
Err(VerificationError::IssuerMismatch(_))
));
assert_eq!(refresher.calls.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[test]
fn forced_refreshes_are_rate_limited_per_did() {
let limiter = RefreshLimiter::default();
let t0 = Instant::now();
assert!(limiter.allow("did:example:a", t0));
assert!(!limiter.allow("did:example:a", t0 + Duration::from_secs(1)));
assert!(limiter.allow("did:example:b", t0 + Duration::from_secs(1)));
assert!(limiter.allow("did:example:a", t0 + FORCED_REFRESH_INTERVAL));
}
#[tokio::test]
async fn operational_verification_requires_authentication_purpose() {
let doc = self_issued_doc().await;
assert_eq!(doc.proof.as_ref().unwrap().proof_purpose, "assertionMethod");
let err = verifier().verify_operational(&doc).await.unwrap_err();
assert!(
matches!(err, VerificationError::MalformedProof(_)),
"{err:?}"
);
}
#[tokio::test]
async fn operational_keys_must_be_authentication_keys() {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
let secret = Secret::generate_ed25519(None, Some(&[5u8; 32]));
let pk = secret.get_public_keymultibase().unwrap();
let mut client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
for (did, rel) in [
("did:web:auth.example", "authentication"),
("did:web:assert.example", "assertionMethod"),
] {
let doc: affinidi_tdk::did_common::Document = serde_json::from_value(json!({
"id": did,
"verificationMethod": [{
"id": format!("{did}#key-1"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": pk,
}],
rel: [format!("{did}#key-1")],
}))
.unwrap();
client.add_did_document(did, doc).await;
}
let signers = SignerKeyResolver {
client: client.clone(),
decode: CachedDidResolver::new(Arc::new(client)),
deactivation: None,
};
let resolver = PurposeBound::new(&signers, ProofPurpose::Authentication);
resolver
.resolve_vm("did:web:auth.example#key-1")
.await
.expect("authentication key resolves");
let err = resolver
.resolve_vm("did:web:assert.example#key-1")
.await
.unwrap_err();
assert!(
err.to_string().contains("not listed under authentication"),
"{err}"
);
let approvals = PurposeBound::new(&signers, ProofPurpose::AssertionMethod);
approvals
.resolve_vm("did:web:assert.example#key-1")
.await
.expect("assertionMethod key resolves for an approval");
let err = approvals
.resolve_vm("did:web:auth.example#key-1")
.await
.unwrap_err();
assert!(
err.to_string().contains("not listed under assertionMethod"),
"{err}"
);
}
#[tokio::test]
async fn approval_verification_requires_assertion_method_purpose() {
let doc = self_issued_doc().await;
verifier()
.verify_approval(&doc)
.await
.expect("an assertionMethod-purpose decision verifies");
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let did = format!("did:key:{pk_mb}");
let mut signer = secret;
signer.id = format!("{did}#{pk_mb}");
let mut body = base_body();
body.as_object_mut()
.unwrap()
.insert("issuer".to_string(), json!(did));
let unsigned: TrustTask<Value> = serde_json::from_value(body).unwrap();
let signed = super::super::bound::sign_document(&unsigned, &signer)
.await
.unwrap();
assert_eq!(
signed.proof.as_ref().unwrap().proof_purpose,
"authentication"
);
let err = verifier().verify_approval(&signed).await.unwrap_err();
assert!(
matches!(err, VerificationError::MalformedProof(ref m) if m.contains("assertionMethod")),
"{err:?}"
);
}
#[test]
fn unreachable_signers_are_distinguished_from_bad_proofs() {
let unreachable = map_error(DataIntegrityError::Resolver(format!(
"{UNREACHABLE}: cannot resolve did:webvh:QmX:gone.example"
)));
assert!(is_unreachable(&unreachable), "{unreachable:?}");
assert!(unreachable.to_string().contains("gone.example"));
let not_listed = map_error(DataIntegrityError::Resolver(
"verificationMethod is not listed under authentication in its DID document".into(),
));
assert!(!is_unreachable(¬_listed));
assert!(!is_unreachable(&VerificationError::SignatureInvalid));
}
#[test]
fn a_deactivation_verdict_is_reused_only_for_a_cached_document() {
let cache = DeactivationCache::default();
cache.remember("did:webvh:QmA:a.example", false);
assert_eq!(cache.cached("did:webvh:QmA:a.example", true), Some(false));
assert_eq!(cache.cached("did:webvh:QmA:a.example", false), None);
assert_eq!(cache.cached("did:webvh:QmB:b.example", true), None);
}
#[test]
fn the_deactivation_cache_is_bounded() {
let cache = DeactivationCache::default();
for i in 0..DEACTIVATION_CACHE_CAPACITY + 10 {
cache.remember(&format!("did:webvh:Qm{i}:x.example"), false);
}
let len = cache.verdicts.lock().unwrap().len();
assert!(len <= DEACTIVATION_CACHE_CAPACITY, "{len}");
let newest = format!("did:webvh:Qm{}:x.example", DEACTIVATION_CACHE_CAPACITY + 9);
assert_eq!(cache.cached(&newest, true), Some(false));
}
#[tokio::test]
async fn a_deactivated_webvh_signer_is_refused() {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
let secret = Secret::generate_ed25519(None, Some(&[6u8; 32]));
let pk = secret.get_public_keymultibase().unwrap();
let mut client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
let live = "did:webvh:QmLive:live.example";
let dead = "did:webvh:QmDead:dead.example";
for did in [live, dead] {
let doc: affinidi_tdk::did_common::Document = serde_json::from_value(json!({
"id": did,
"verificationMethod": [{
"id": format!("{did}#key-1"),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": pk,
}],
"authentication": [format!("{did}#key-1")],
}))
.unwrap();
client.add_did_document(did, doc).await;
}
let deactivation = Arc::new(DeactivationCache::default());
{
let mut v = deactivation.verdicts.lock().unwrap();
v.insert(live.into(), (Instant::now(), false));
v.insert(dead.into(), (Instant::now(), true));
}
let signers = SignerKeyResolver {
client: client.clone(),
decode: CachedDidResolver::new(Arc::new(client)),
deactivation: Some(deactivation),
};
let resolver = PurposeBound::new(&signers, ProofPurpose::Authentication);
resolver
.resolve_vm(&format!("{live}#key-1"))
.await
.expect("a live signer resolves");
let err = resolver
.resolve_vm(&format!("{dead}#key-1"))
.await
.unwrap_err();
assert!(err.to_string().contains("deactivated"), "{err}");
}
async fn webvh_log(port: u16, secret: &Secret, deactivate: bool) -> (String, String) {
use didwebvh_rs::{
DIDWebVHState, Multibase, log_entry::LogEntryMethods, parameters::Parameters,
};
let mut update_key = Secret::generate_ed25519(None, None);
let update_pk = update_key.get_public_keymultibase().unwrap();
update_key.id = format!("did:key:{update_pk}#{update_pk}");
let pk = secret.get_public_keymultibase().unwrap();
let template = format!("did:webvh:{{SCID}}:localhost%3A{port}");
let doc = json!({
"id": template,
"@context": ["https://www.w3.org/ns/did/v1"],
"verificationMethod": [{
"id": format!("{template}#key-1"),
"type": "Multikey",
"controller": template,
"publicKeyMultibase": pk,
}],
"authentication": [format!("{template}#key-1")],
});
let params = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(update_pk)])),
portable: Some(false),
..Default::default()
};
let genesis = (chrono::Utc::now() - chrono::Duration::seconds(100)).fixed_offset();
let mut state = DIDWebVHState::default();
state
.create_log_entry(Some(genesis), &doc, ¶ms, &update_key)
.await
.unwrap();
if deactivate {
state.deactivate(&update_key).await.unwrap();
}
let did = state
.log_entries()
.last()
.unwrap()
.log_entry
.get_did_document()
.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let jsonl = state
.log_entries()
.iter()
.map(|e| serde_json::to_string(&e.log_entry).unwrap())
.collect::<Vec<_>>()
.join("\n");
(did, jsonl)
}
fn serve(listener: tokio::net::TcpListener, body: String) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let body = body.clone();
tokio::spawn(async move {
let mut request = [0u8; 4096];
let _ = socket.read(&mut request).await;
let reply = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/jsonl\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(reply.as_bytes()).await;
});
}
});
}
#[tokio::test]
async fn an_uncached_deactivated_webvh_signer_is_refused_as_final() {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
use affinidi_did_resolver_cache_sdk::network_resolvers::HostPolicy as CacheHostPolicy;
let secret = Secret::generate_ed25519(None, Some(&[7u8; 32]));
let live_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let dead_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let (live, live_log) =
webvh_log(live_listener.local_addr().unwrap().port(), &secret, false).await;
let (dead, dead_log) =
webvh_log(dead_listener.local_addr().unwrap().port(), &secret, true).await;
serve(live_listener, live_log);
serve(dead_listener, dead_log);
let client = DIDCacheClient::new(
DIDCacheConfigBuilder::default()
.with_host_policy(CacheHostPolicy::AllowPrivate)
.build(),
)
.await
.unwrap();
let signers = SignerKeyResolver {
client: client.clone(),
decode: CachedDidResolver::new(Arc::new(client)),
deactivation: Some(Arc::new(DeactivationCache {
host_policy: HostPolicy::AllowPrivate,
..DeactivationCache::default()
})),
};
let resolver = PurposeBound::new(&signers, ProofPurpose::Authentication);
resolver
.resolve_vm(&format!("{live}#key-1"))
.await
.expect("a live signer resolves");
let err = resolver
.resolve_vm(&format!("{dead}#key-1"))
.await
.unwrap_err()
.to_string();
assert!(err.contains(DEACTIVATED), "{err}");
assert!(
!err.contains(UNREACHABLE),
"a deactivated signer is final: {err}"
);
assert!(
!is_unreachable(&map_error(DataIntegrityError::Resolver(err.clone()))),
"{err}"
);
}
const STEP_UP_FIXTURE: &str = include_str!("testdata/stepup-approval-fixture.json");
#[tokio::test]
async fn wallet_signed_step_up_approval_verifies() {
let fixture: Value = serde_json::from_str(STEP_UP_FIXTURE).expect("fixture parses");
let approved: TrustTask<Value> =
serde_json::from_value(fixture["approved"].clone()).expect("approved doc parses");
verifier()
.verify(&approved)
.await
.expect("wallet-signed approved response verifies");
verifier()
.verify_approval(&approved)
.await
.expect("wallet-signed approval passes the approval check");
let denied: TrustTask<Value> =
serde_json::from_value(fixture["denied"].clone()).expect("denied doc parses");
verifier()
.verify(&denied)
.await
.expect("wallet-signed denied response verifies");
}
#[tokio::test]
async fn wallet_signed_step_up_tampered_decision_rejected() {
let fixture: Value = serde_json::from_str(STEP_UP_FIXTURE).expect("fixture parses");
let mut doc: TrustTask<Value> =
serde_json::from_value(fixture["approved"].clone()).expect("approved doc parses");
doc.payload
.as_object_mut()
.expect("payload is an object")
.insert("decision".to_string(), json!("denied"));
let err = verifier()
.verify(&doc)
.await
.expect_err("tampered decision must be rejected");
assert!(
!matches!(err, VerificationError::IssuerMismatch(_)),
"expected a signature failure, got {err:?}"
);
}
}