use std::collections::BTreeMap;
use acdp_did::web::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_primitives::revocation::{RevocationSource, SourcedRevocation};
use acdp_types::body::Body;
use acdp_types::change::{Change, ChangeEvidence, ChangeHint, ChangesResponse, WatchFilter};
use acdp_types::lifecycle::{retraction_state, LifecycleEvent, LifecycleEventType};
use acdp_types::primitives::CtxId;
use acdp_types::receipt::RegistryReceipt;
use acdp_types::revocation::RevocationTrustClass;
use acdp_types::EventReceipt;
use serde_json::Value;
use crate::receipt::verify_event_receipt_value;
use crate::revocation::verify_revocation_candidate;
use crate::verified::{ReceiptPolicy, VerificationPolicy, VerifiedContext};
use crate::RegistryClient;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Since {
Start,
Head,
Cursor(String),
}
impl Since {
pub fn as_query(&self) -> Option<&str> {
match self {
Since::Start => None,
Since::Head => Some("head"),
Since::Cursor(c) => Some(c),
}
}
}
#[derive(Clone)]
pub struct ChangeFeed<'a> {
client: &'a RegistryClient,
filter: WatchFilter,
limit: Option<u32>,
since: Since,
}
impl<'a> ChangeFeed<'a> {
pub fn new(client: &'a RegistryClient, filter: WatchFilter) -> Self {
Self {
client,
filter,
limit: None,
since: Since::Start,
}
}
pub fn since(mut self, since: Since) -> Self {
self.since = since;
self
}
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn position(&self) -> &Since {
&self.since
}
pub fn filter(&self) -> &WatchFilter {
&self.filter
}
pub async fn fetch(&self) -> Result<ChangesResponse, AcdpError> {
self.client
.changes(&self.since, &self.filter, self.limit)
.await
}
pub fn advance(&mut self, page: &ChangesResponse) {
self.since = Since::Cursor(page.next_cursor.clone());
}
pub fn reset_to_head(&mut self) {
self.since = Since::Head;
}
pub async fn on_hint(&self, hint: &ChangeHint) -> Result<ChangesResponse, AcdpError> {
if self.client.authority().as_deref() != Some(hint.registry_authority.as_str()) {
return Err(AcdpError::SchemaViolation(format!(
"change hint names registry '{}', not this feed's",
hint.registry_authority
)));
}
self.fetch().await
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SeenChanges {
high_water: BTreeMap<String, u64>,
}
impl SeenChanges {
pub fn new() -> Self {
Self::default()
}
pub fn is_new(&self, authority: &str, seq: u64) -> bool {
self.high_water.get(authority).is_none_or(|hw| seq > *hw)
}
pub fn mark(&mut self, authority: &str, seq: u64) {
let hw = self.high_water.entry(authority.to_owned()).or_insert(seq);
*hw = (*hw).max(seq);
}
pub fn first_time(&mut self, authority: &str, seq: u64) -> bool {
let new = self.is_new(authority, seq);
if new {
self.mark(authority, seq);
}
new
}
}
#[derive(Debug, Clone, Copy)]
pub struct HeldContext<'a> {
pub body: &'a Body,
pub events: &'a [Value],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LifecycleSource {
Held,
Retrieved,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ChangeEffect {
Lifecycle {
event: LifecycleEvent,
retracted: bool,
source: LifecycleSource,
},
Superseded {
new_version: Box<VerifiedContext>,
},
KeyRevocation {
revocation: SourcedRevocation,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ChangeVerdict {
Verified(ChangeEffect),
Rejected(AcdpError),
}
impl ChangeVerdict {
pub fn effect(&self) -> Option<&ChangeEffect> {
match self {
ChangeVerdict::Verified(e) => Some(e),
ChangeVerdict::Rejected(_) => None,
}
}
pub fn rejection(&self) -> Option<&AcdpError> {
match self {
ChangeVerdict::Verified(_) => None,
ChangeVerdict::Rejected(e) => Some(e),
}
}
}
pub async fn verify_change(
client: &RegistryClient,
resolver: &WebResolver,
change: &Change,
held: Option<HeldContext<'_>>,
policy: &VerificationPolicy,
) -> Result<ChangeVerdict, AcdpError> {
if let Some(h) = held {
if &h.body.ctx_id != change.ctx_id() {
return Err(AcdpError::SchemaViolation(format!(
"held context {} is not the entry's {}",
h.body.ctx_id,
change.ctx_id()
)));
}
}
let outcome = match change.check_consistency() {
Err(e) => Err(e),
Ok(()) => match change.evidence() {
ChangeEvidence::Lifecycle {
event,
event_receipt,
} => {
verify_lifecycle(client, resolver, change, event, event_receipt, held, policy).await
}
ChangeEvidence::Superseded {
superseded_by,
registry_receipt,
} => {
verify_superseded(
client,
resolver,
change,
superseded_by,
registry_receipt,
policy,
)
.await
}
ChangeEvidence::KeyRevocation { registry_receipt } => {
verify_key_revocation(client, resolver, change, registry_receipt).await
}
},
};
match outcome {
Ok(effect) => Ok(ChangeVerdict::Verified(effect)),
Err(e) if e.is_transient() => Err(e),
Err(e) => Ok(ChangeVerdict::Rejected(e)),
}
}
fn reject(change: &Change, why: impl std::fmt::Display) -> AcdpError {
AcdpError::SchemaViolation(format!(
"change {} ({} {}): {why} (RFC-ACDP-0018 §7)",
change.seq(),
change.kind(),
change.ctx_id()
))
}
fn serving_authority(client: &RegistryClient) -> Result<String, AcdpError> {
client
.authority()
.ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))
}
fn check_body_binding(change: &Change, body: &Body, authority: &str) -> Result<(), AcdpError> {
if &body.ctx_id != change.ctx_id() {
return Err(AcdpError::ContextIdMismatch {
requested: change.ctx_id().as_str().to_owned(),
served: body.ctx_id.as_str().to_owned(),
});
}
if &body.agent_id != change.agent_id() {
return Err(reject(
change,
format!("the context's producer is {}", body.agent_id),
));
}
if &body.lineage_id != change.lineage_id() {
return Err(reject(
change,
format!("the context's lineage is {}", body.lineage_id),
));
}
if body.origin_registry != authority {
return Err(reject(
change,
format!(
"the context originates at {}, not this registry",
body.origin_registry
),
));
}
Ok(())
}
fn jcs_equal(a: &Value, b: &Value) -> Result<bool, AcdpError> {
Ok(EventReceipt::event_hash_of_value(a)? == EventReceipt::event_hash_of_value(b)?)
}
async fn verify_lifecycle(
client: &RegistryClient,
resolver: &WebResolver,
change: &Change,
raw_event: &Value,
raw_receipt: &Value,
held: Option<HeldContext<'_>>,
policy: &VerificationPolicy,
) -> Result<ChangeEffect, AcdpError> {
let authority = serving_authority(client)?;
let index = EventReceipt::from_value(raw_receipt)?.index;
let index_usize = usize::try_from(index).map_err(|_| reject(change, "index out of range"))?;
let held = held.filter(|h| h.events.len() >= index_usize);
let retrieved;
let (body, list, source): (&Body, Vec<Value>, LifecycleSource) = match held {
Some(h) => {
let mut list = h.events.to_vec();
match list.get(index_usize) {
Some(have) if !jcs_equal(have, raw_event)? => {
return Err(reject(
change,
format!("the held event at index {index} differs from the entry's"),
));
}
Some(_) => {}
None => list.push(raw_event.clone()),
}
(h.body, list, LifecycleSource::Held)
}
None => {
retrieved =
VerifiedContext::fetch_with_policy(client, resolver, change.ctx_id(), policy)
.await?;
let list: Vec<Value> = retrieved
.registry_state()
.lifecycle_events
.iter()
.flatten()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
match list.get(index_usize) {
Some(served) if jcs_equal(served, raw_event)? => {}
_ => {
return Err(reject(
change,
format!(
"the re-retrieved lifecycle_events[{index}] is not the entry's event"
),
))
}
}
(retrieved.body(), list, LifecycleSource::Retrieved)
}
};
check_body_binding(change, body, &authority)?;
let registry_did = acdp_did::web::authority_to_did_web(&authority);
let event = acdp_verify::verify_lifecycle_event(
raw_event,
change.ctx_id(),
&body.agent_id,
Some(®istry_did),
resolver,
)
.await?;
verify_event_receipt_value(
raw_receipt,
&authority,
change.ctx_id(),
raw_event,
index,
body.created_at,
resolver,
std::time::Instant::now(),
)
.await?;
let mut events = Vec::with_capacity(list.len());
for (i, raw) in list.iter().enumerate() {
let parsed = if i == index_usize {
event.clone()
} else if source == LifecycleSource::Retrieved {
let parsed = LifecycleEvent::from_value(raw)?;
if matches!(
parsed.event_type,
LifecycleEventType::Retracted | LifecycleEventType::Republished
) {
acdp_verify::verify_lifecycle_event(
raw,
change.ctx_id(),
&body.agent_id,
Some(®istry_did),
resolver,
)
.await?
} else {
parsed
}
} else {
LifecycleEvent::from_value(raw)?
};
events.push(parsed);
}
Ok(ChangeEffect::Lifecycle {
retracted: retraction_state(&events),
event,
source,
})
}
async fn verify_superseded(
client: &RegistryClient,
resolver: &WebResolver,
change: &Change,
superseded_by: &CtxId,
raw_receipt: &Value,
policy: &VerificationPolicy,
) -> Result<ChangeEffect, AcdpError> {
let policy = VerificationPolicy {
receipts: ReceiptPolicy::Require,
..policy.clone()
};
let authority = serving_authority(client)?;
let new_version =
VerifiedContext::fetch_with_policy(client, resolver, superseded_by, &policy).await?;
let body = new_version.body();
if body.origin_registry != authority {
return Err(reject(
change,
format!(
"{superseded_by} originates at {}, not this registry",
body.origin_registry
),
));
}
if body.supersedes.as_ref() != Some(change.ctx_id()) {
return Err(reject(
change,
format!("{superseded_by} does not supersede it"),
));
}
if &body.lineage_id != change.lineage_id() {
return Err(reject(
change,
format!("{superseded_by} is in lineage {}", body.lineage_id),
));
}
if &body.agent_id != change.agent_id() {
return Err(reject(
change,
format!(
"{superseded_by} has producer {} (RFC-ACDP-0003 §3.1)",
body.agent_id
),
));
}
let matches_entry = match (new_version.verified_receipt(), new_version.receipt()) {
(Some(_), Some(served)) => jcs_equal(served, raw_receipt)?,
_ => false,
};
if !matches_entry {
return Err(reject(
change,
"the entry's registry_receipt is not the new version's verified receipt",
));
}
Ok(ChangeEffect::Superseded {
new_version: Box::new(new_version),
})
}
async fn verify_key_revocation(
client: &RegistryClient,
resolver: &WebResolver,
change: &Change,
raw_receipt: &Value,
) -> Result<ChangeEffect, AcdpError> {
let authority = serving_authority(client)?;
let ctx = client.retrieve(change.ctx_id()).await?;
if &ctx.body.ctx_id != change.ctx_id() {
return Err(AcdpError::ContextIdMismatch {
requested: change.ctx_id().as_str().to_owned(),
served: ctx.body.ctx_id.as_str().to_owned(),
});
}
if &ctx.body.lineage_id != change.lineage_id() {
return Err(reject(
change,
format!("the revocation is in lineage {}", ctx.body.lineage_id),
));
}
let since = std::time::Instant::now();
let revocation = verify_revocation_candidate(&ctx, client, resolver, since).await?;
if revocation.trust_class == RevocationTrustClass::RegistryAttested {
let caps = client.capabilities().await?;
revocation.cross_check_registry_binding(&authority, &caps.registry_did)?;
}
RegistryReceipt::from_value(raw_receipt)?;
let Some(served) = ctx.registry_receipt.as_ref() else {
return Err(reject(
change,
"the revocation is served without a registry_receipt",
));
};
if !jcs_equal(served, raw_receipt)? {
return Err(reject(
change,
"the entry's registry_receipt is not the receipt served with the revocation",
));
}
let body = &ctx.body;
let producer_fp = acdp_crypto::fingerprint::fingerprint_for_key_id_since(
&body.signature.key_id,
&body.signature.algorithm,
resolver,
since,
)
.await?;
crate::receipt::verify_receipt_value_since(
served,
&body.ctx_id,
body,
&body.content_hash,
&producer_fp,
&authority,
resolver,
since,
)
.await?;
let revocation = revocation.sourced(RevocationSource::Discovered { authority });
if !revocation.speaks_for(change.agent_id()) {
return Err(reject(
change,
format!(
"the revocation does not speak for {} (RFC-ACDP-0014 §7 producer scope)",
change.agent_id()
),
));
}
Ok(ChangeEffect::KeyRevocation { revocation })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn since_query_values() {
assert_eq!(Since::Start.as_query(), None);
assert_eq!(Since::Head.as_query(), Some("head"));
assert_eq!(Since::Cursor("abc".into()).as_query(), Some("abc"));
}
#[test]
fn seen_changes_dedups_by_authority_and_seq() {
let mut seen = SeenChanges::new();
assert!(seen.first_time("a.example.com", 5));
assert!(!seen.first_time("a.example.com", 5));
assert!(!seen.is_new("a.example.com", 4));
assert!(seen.is_new("b.example.com", 5));
assert!(seen.first_time("a.example.com", 6));
seen.mark("a.example.com", 2);
assert!(!seen.is_new("a.example.com", 6));
}
}