use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use affinidi_did_resolver_cache_sdk::{ResolveResponse, errors::DIDCacheError};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub const FRESH_RESOLVE_MIN_INTERVAL: Duration = Duration::from_secs(5);
const FRESH_RESOLVE_TRACKED_MAX: usize = 4096;
pub const FRESH_RESOLVE_DID_MAX_LEN: usize = 1_000;
struct RefreshFloor {
last: Mutex<HashMap<String, Instant>>,
capacity: usize,
}
impl RefreshFloor {
fn new(capacity: usize) -> Self {
Self {
last: Mutex::new(HashMap::new()),
capacity,
}
}
fn admit(&self, did: &str, now: Instant) -> bool {
let mut last = self
.last
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if last
.get(did)
.is_some_and(|t| now.duration_since(*t) < FRESH_RESOLVE_MIN_INTERVAL)
{
return false;
}
if !last.contains_key(did) && last.len() >= self.capacity {
last.retain(|_, t| now.duration_since(*t) < FRESH_RESOLVE_MIN_INTERVAL);
if last.len() >= self.capacity
&& let Some(oldest) = last.iter().min_by_key(|(_, t)| **t).map(|(d, _)| d.clone())
{
last.remove(&oldest);
}
}
last.insert(did.to_string(), now);
true
}
#[cfg(test)]
fn tracks(&self, did: &str) -> bool {
self.last
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(did)
}
}
static LAST_FRESH_RESOLVE: LazyLock<RefreshFloor> =
LazyLock::new(|| RefreshFloor::new(FRESH_RESOLVE_TRACKED_MAX));
fn is_cached(resolver: &DIDCacheClient, did: &str) -> bool {
did.len() <= FRESH_RESOLVE_DID_MAX_LEN
&& resolver
.get_cache()
.contains_key(&DIDCacheClient::hash_did(did))
}
pub async fn evict_for_fresh_resolve(resolver: &DIDCacheClient, did: &str) -> bool {
if !is_cached(resolver, did) || !LAST_FRESH_RESOLVE.admit(did, Instant::now()) {
return false;
}
resolver.remove(did).await;
true
}
fn lists_vm(doc: &affinidi_did_common::Document, vm: &str) -> bool {
let relative = vm
.split_once('#')
.map(|(_, fragment)| format!("#{fragment}"))
.unwrap_or_default();
doc.verification_method
.iter()
.any(|m| m.id.as_str() == vm || m.id.as_str() == relative)
}
pub async fn resolve_for_vm(
resolver: &DIDCacheClient,
did: &str,
vm: &str,
) -> Result<ResolveResponse, DIDCacheError> {
let resolved = resolver.resolve(did).await?;
if resolved.cache_hit
&& !lists_vm(&resolved.doc, vm)
&& evict_for_fresh_resolve(resolver, did).await
{
return resolver.resolve(did).await;
}
Ok(resolved)
}
#[cfg(feature = "didcomm")]
pub fn authcrypt_claimed_sender(jwe: &str) -> Option<String> {
use base64::Engine;
let envelope: serde_json::Value = serde_json::from_str(jwe).ok()?;
let protected = envelope.get("protected")?.as_str()?;
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(protected.trim_end_matches('='))
.ok()?;
let header: serde_json::Value = serde_json::from_slice(&header).ok()?;
let skid = header.get("skid")?.as_str()?;
let did = skid.split('#').next()?;
did.starts_with("did:").then(|| did.to_string())
}
#[cfg(feature = "didcomm")]
pub async fn unpack_refreshing_sender(
atm: &affinidi_tdk::messaging::ATM,
resolver: Option<&DIDCacheClient>,
body: &str,
) -> Result<
(
affinidi_tdk::didcomm::Message,
affinidi_tdk::messaging::messages::compat::UnpackMetadata,
),
affinidi_tdk::messaging::errors::ATMError,
> {
let target = resolver.and_then(|r| refresh_target(r, body).map(|did| (r, did)));
let first = match atm.unpack(body).await {
Ok(unpacked) => return Ok(unpacked),
Err(e) => e,
};
let Some((resolver, sender)) = target else {
return Err(first);
};
if !evict_for_fresh_resolve(resolver, &sender).await {
return Err(first);
}
atm.unpack(body).await
}
#[cfg(feature = "didcomm")]
fn refresh_target(resolver: &DIDCacheClient, body: &str) -> Option<String> {
authcrypt_claimed_sender(body).filter(|did| is_cached(resolver, did))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "didcomm")]
#[test]
fn the_claimed_sender_is_read_from_the_protected_header() {
use base64::Engine;
let header = serde_json::json!({ "alg": "ECDH-1PU+A256KW", "skid": "did:webvh:abc:example.com#key-1" });
let protected = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&header).unwrap());
let jwe = serde_json::json!({ "protected": protected, "ciphertext": "x" }).to_string();
assert_eq!(
authcrypt_claimed_sender(&jwe).as_deref(),
Some("did:webvh:abc:example.com")
);
assert_eq!(authcrypt_claimed_sender("not json"), None);
let anon = serde_json::json!({ "protected": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"alg\":\"ECDH-ES+A256KW\"}") }).to_string();
assert_eq!(
authcrypt_claimed_sender(&anon),
None,
"anoncrypt names no sender"
);
}
async fn resolver_caching(dids: &[&str]) -> DIDCacheClient {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
let mut resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
for did in dids {
resolver
.add_did_document(did, placeholder_doc().await)
.await;
}
resolver
}
async fn placeholder_doc() -> affinidi_did_common::Document {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap()
.resolve("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
.await
.unwrap()
.doc
}
fn unique_did(tag: &str) -> String {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("did:web:{tag}-{}-{n}.example", std::process::id())
}
#[tokio::test]
async fn a_forced_refresh_is_rate_limited_per_did() {
let did = unique_did("refresh-floor");
let other = unique_did("refresh-floor-other");
let mut resolver = resolver_caching(&[&did, &other]).await;
assert!(
evict_for_fresh_resolve(&resolver, &did).await,
"first refresh"
);
resolver
.add_did_document(&did, placeholder_doc().await)
.await;
assert!(
!evict_for_fresh_resolve(&resolver, &did).await,
"a second refresh inside the window is refused"
);
assert!(
evict_for_fresh_resolve(&resolver, &other).await,
"the floor is per DID"
);
}
#[tokio::test]
async fn an_uncached_did_takes_no_refresh_slot() {
let resolver = resolver_caching(&[]).await;
let did = unique_did("uncached");
assert!(!evict_for_fresh_resolve(&resolver, &did).await);
assert!(!LAST_FRESH_RESOLVE.tracks(&did), "no slot taken");
let resolver = resolver_caching(&[&did]).await;
assert!(
evict_for_fresh_resolve(&resolver, &did).await,
"cached now, so it refreshes"
);
}
#[tokio::test]
async fn an_over_long_did_is_not_tracked() {
let did = format!("did:web:{}.example", "a".repeat(FRESH_RESOLVE_DID_MAX_LEN));
let resolver = resolver_caching(&[&did]).await;
assert!(!evict_for_fresh_resolve(&resolver, &did).await);
assert!(!LAST_FRESH_RESOLVE.tracks(&did));
}
#[tokio::test]
async fn uncached_dids_cannot_exhaust_the_floor() {
let resolver = resolver_caching(&[]).await;
for _ in 0..(FRESH_RESOLVE_TRACKED_MAX + 100) {
let junk = unique_did("junk");
assert!(!evict_for_fresh_resolve(&resolver, &junk).await);
assert!(!LAST_FRESH_RESOLVE.tracks(&junk));
}
let rotated = unique_did("rotated");
let resolver = resolver_caching(&[&rotated]).await;
assert!(evict_for_fresh_resolve(&resolver, &rotated).await);
}
#[test]
fn a_full_floor_never_refuses_a_new_did() {
let floor = RefreshFloor::new(3);
let t0 = Instant::now();
assert!(floor.admit("did:web:a", t0));
assert!(floor.admit("did:web:b", t0 + Duration::from_millis(1)));
assert!(floor.admit("did:web:c", t0 + Duration::from_millis(2)));
let t1 = t0 + Duration::from_millis(3);
assert!(floor.admit("did:web:d", t1));
assert!(!floor.tracks("did:web:a"));
assert!(!floor.admit("did:web:b", t1), "b's window still holds");
assert!(!floor.admit("did:web:d", t1), "d's window still holds");
let t2 = t0 + FRESH_RESOLVE_MIN_INTERVAL + Duration::from_millis(2);
assert!(floor.admit("did:web:e", t2));
assert!(floor.tracks("did:web:d"), "d was live, so it was kept");
assert!(floor.admit("did:web:b", t2), "b's window has passed");
}
#[cfg(feature = "didcomm")]
#[tokio::test]
async fn only_a_cached_sender_is_a_retry_target() {
use base64::Engine;
let jwe_from = |did: &str| {
let header =
serde_json::json!({ "alg": "ECDH-1PU+A256KW", "skid": format!("{did}#key-1") });
let protected = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&header).unwrap());
serde_json::json!({ "protected": protected, "ciphertext": "x" }).to_string()
};
let cached = unique_did("cached-sender");
let fresh = unique_did("fresh-sender");
let resolver = resolver_caching(&[&cached]).await;
assert_eq!(
refresh_target(&resolver, &jwe_from(&cached)).as_deref(),
Some(cached.as_str())
);
assert_eq!(refresh_target(&resolver, &jwe_from(&fresh)), None);
}
#[tokio::test]
async fn a_cached_document_missing_the_method_is_re_resolved() {
use affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder;
let mut resolver = DIDCacheClient::new(DIDCacheConfigBuilder::default().build())
.await
.unwrap();
let did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
let fresh = resolver.resolve(did).await.unwrap().doc;
let vm = fresh.verification_method[0].id.to_string();
let mut stale = fresh.clone();
stale.verification_method.clear();
resolver.add_did_document(did, stale).await;
let before = resolver.resolve(did).await.unwrap();
assert!(before.cache_hit && before.doc.verification_method.is_empty());
let resolved = resolve_for_vm(&resolver, did, &vm).await.unwrap();
assert!(!resolved.cache_hit, "re-resolved rather than served stale");
assert!(
resolved
.doc
.verification_method
.iter()
.any(|m| m.id.as_str() == vm)
);
}
}