1use crate::lifecycle::LifecycleEvent;
21use crate::receipt::{is_canonical_ms_utc, RegistryReceipt};
22use crate::serde_helpers::de_present;
23use crate::EventReceipt;
24use acdp_primitives::error::AcdpError;
25use acdp_primitives::primitives::{is_valid_dns_authority, AgentDid, CtxId, LineageId};
26use chrono::{DateTime, Utc};
27use hmac::{Hmac, KeyInit, Mac};
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::Sha256;
31
32pub const CHANGE_VERSION: &str = "acdp-change/1";
34pub const CHANGE_HINT_VERSION: &str = "acdp-change-hint/1";
36pub const SIGNATURE_HEADER: &str = "ACDP-Signature";
38pub const HINT_MAX_SKEW_SECONDS: i64 = 300;
41pub const MAX_CURSOR_LEN: usize = 2048;
43pub const MIN_CHANGE_RETENTION_SECONDS: u64 = 2_592_000;
45
46fn bad(msg: impl Into<String>) -> AcdpError {
47 AcdpError::SchemaViolation(msg.into())
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ChangeKind {
57 Lifecycle,
59 Superseded,
61 KeyRevocation,
63}
64
65impl ChangeKind {
66 pub const ALL: [ChangeKind; 3] = [
68 ChangeKind::Lifecycle,
69 ChangeKind::Superseded,
70 ChangeKind::KeyRevocation,
71 ];
72
73 pub fn as_str(self) -> &'static str {
75 match self {
76 ChangeKind::Lifecycle => "lifecycle",
77 ChangeKind::Superseded => "superseded",
78 ChangeKind::KeyRevocation => "key_revocation",
79 }
80 }
81
82 pub fn parse(s: &str) -> Result<Self, AcdpError> {
85 Self::ALL
86 .into_iter()
87 .find(|k| k.as_str() == s)
88 .ok_or_else(|| bad(format!("unknown change kind '{s}' (RFC-ACDP-0018 §3)")))
89 }
90}
91
92impl std::fmt::Display for ChangeKind {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str(self.as_str())
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
101pub enum ChangeEvidence {
102 Lifecycle {
104 event: Value,
107 event_receipt: Value,
109 },
110 Superseded {
112 superseded_by: CtxId,
114 registry_receipt: Value,
116 },
117 KeyRevocation {
119 registry_receipt: Value,
121 },
122}
123
124impl ChangeEvidence {
125 pub fn kind(&self) -> ChangeKind {
127 match self {
128 ChangeEvidence::Lifecycle { .. } => ChangeKind::Lifecycle,
129 ChangeEvidence::Superseded { .. } => ChangeKind::Superseded,
130 ChangeEvidence::KeyRevocation { .. } => ChangeKind::KeyRevocation,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(try_from = "ChangeWire", into = "ChangeWire")]
149pub struct Change {
150 seq: u64,
151 ctx_id: CtxId,
152 lineage_id: LineageId,
153 agent_id: AgentDid,
154 recorded_at: DateTime<Utc>,
155 evidence: ChangeEvidence,
156}
157
158impl Change {
159 pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
162 Self::deserialize(value).map_err(|e| bad(format!("change entry does not parse: {e}")))
163 }
164
165 pub fn lifecycle(
169 seq: u64,
170 lineage_id: LineageId,
171 agent_id: AgentDid,
172 raw_event: Value,
173 event_receipt: &EventReceipt,
174 ) -> Result<Self, AcdpError> {
175 let change = Self {
176 seq,
177 ctx_id: event_receipt.ctx_id.clone(),
178 lineage_id,
179 agent_id,
180 recorded_at: event_receipt.accepted_at,
181 evidence: ChangeEvidence::Lifecycle {
182 event: raw_event,
183 event_receipt: serde_json::to_value(event_receipt)?,
184 },
185 };
186 change.validate()?;
187 Ok(change)
188 }
189
190 pub fn superseded(
194 seq: u64,
195 ctx_id: CtxId,
196 agent_id: AgentDid,
197 registry_receipt: &RegistryReceipt,
198 ) -> Result<Self, AcdpError> {
199 let change = Self {
200 seq,
201 ctx_id,
202 lineage_id: registry_receipt.lineage_id.clone(),
203 agent_id,
204 recorded_at: registry_receipt.created_at,
205 evidence: ChangeEvidence::Superseded {
206 superseded_by: registry_receipt.ctx_id.clone(),
207 registry_receipt: serde_json::to_value(registry_receipt)?,
208 },
209 };
210 change.validate()?;
211 Ok(change)
212 }
213
214 pub fn key_revocation(
219 seq: u64,
220 agent_id: AgentDid,
221 registry_receipt: &RegistryReceipt,
222 ) -> Result<Self, AcdpError> {
223 let change = Self {
224 seq,
225 ctx_id: registry_receipt.ctx_id.clone(),
226 lineage_id: registry_receipt.lineage_id.clone(),
227 agent_id,
228 recorded_at: registry_receipt.created_at,
229 evidence: ChangeEvidence::KeyRevocation {
230 registry_receipt: serde_json::to_value(registry_receipt)?,
231 },
232 };
233 change.validate()?;
234 Ok(change)
235 }
236
237 pub fn seq(&self) -> u64 {
239 self.seq
240 }
241 pub fn kind(&self) -> ChangeKind {
243 self.evidence.kind()
244 }
245 pub fn ctx_id(&self) -> &CtxId {
247 &self.ctx_id
248 }
249 pub fn lineage_id(&self) -> &LineageId {
251 &self.lineage_id
252 }
253 pub fn agent_id(&self) -> &AgentDid {
255 &self.agent_id
256 }
257 pub fn recorded_at(&self) -> DateTime<Utc> {
259 self.recorded_at
260 }
261 pub fn evidence(&self) -> &ChangeEvidence {
263 &self.evidence
264 }
265
266 fn validate(&self) -> Result<(), AcdpError> {
269 CtxId::parse(self.ctx_id.as_str())?;
270 LineageId::parse(self.lineage_id.as_str())?;
271 AgentDid::parse(self.agent_id.as_str())?;
272 match &self.evidence {
273 ChangeEvidence::Lifecycle {
274 event,
275 event_receipt,
276 } => {
277 LifecycleEvent::from_value(event)?;
278 EventReceipt::from_value(event_receipt)?;
279 }
280 ChangeEvidence::Superseded {
281 superseded_by,
282 registry_receipt,
283 } => {
284 CtxId::parse(superseded_by.as_str())?;
285 RegistryReceipt::from_value(registry_receipt)?;
286 }
287 ChangeEvidence::KeyRevocation { registry_receipt } => {
288 RegistryReceipt::from_value(registry_receipt)?;
289 }
290 }
291 Ok(())
292 }
293
294 pub fn check_consistency(&self) -> Result<(), AcdpError> {
308 let recorded = |at: DateTime<Utc>, what: &str| {
309 if at == self.recorded_at {
310 Ok(())
311 } else {
312 Err(bad(format!(
313 "change {} recorded_at {} ≠ {what} {} (RFC-ACDP-0018 §4)",
314 self.seq,
315 fmt_ms(self.recorded_at),
316 fmt_ms(at)
317 )))
318 }
319 };
320 match &self.evidence {
321 ChangeEvidence::Lifecycle {
322 event,
323 event_receipt,
324 } => {
325 let e = LifecycleEvent::from_value(event)?;
326 let r = EventReceipt::from_value(event_receipt)?;
327 if e.ctx_id != self.ctx_id || r.ctx_id != self.ctx_id {
328 return Err(bad(format!(
329 "change {} names ctx_id {} but its event or event receipt names another \
330 context",
331 self.seq, self.ctx_id
332 )));
333 }
334 recorded(r.accepted_at, "event_receipt.accepted_at")
335 }
336 ChangeEvidence::Superseded {
337 superseded_by,
338 registry_receipt,
339 } => {
340 let r = RegistryReceipt::from_value(registry_receipt)?;
341 if superseded_by == &self.ctx_id {
342 return Err(bad(format!(
343 "change {}: a context cannot supersede itself",
344 self.seq
345 )));
346 }
347 if &r.ctx_id != superseded_by || r.lineage_id != self.lineage_id {
348 return Err(bad(format!(
349 "change {}: registry_receipt is not for superseded_by {superseded_by} in \
350 lineage {}",
351 self.seq, self.lineage_id
352 )));
353 }
354 recorded(r.created_at, "registry_receipt.created_at")
355 }
356 ChangeEvidence::KeyRevocation { registry_receipt } => {
357 let r = RegistryReceipt::from_value(registry_receipt)?;
358 if r.ctx_id != self.ctx_id || r.lineage_id != self.lineage_id {
359 return Err(bad(format!(
360 "change {}: registry_receipt is not for the revocation context {} in \
361 lineage {}",
362 self.seq, self.ctx_id, self.lineage_id
363 )));
364 }
365 recorded(r.created_at, "registry_receipt.created_at")
366 }
367 }
368 }
369}
370
371fn fmt_ms(t: DateTime<Utc>) -> String {
372 t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
373}
374
375fn parse_ms(raw: &str, what: &str) -> Result<DateTime<Utc>, AcdpError> {
376 if !is_canonical_ms_utc(raw) {
377 return Err(bad(format!(
378 "{what} '{raw}' is not canonical millisecond-precision RFC 3339 UTC"
379 )));
380 }
381 Ok(DateTime::parse_from_rfc3339(raw)
382 .expect("is_canonical_ms_utc parsed it")
383 .with_timezone(&Utc))
384}
385
386#[derive(Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct ChangeWire {
390 change_version: String,
391 seq: u64,
392 kind: ChangeKind,
393 ctx_id: CtxId,
394 lineage_id: LineageId,
395 agent_id: AgentDid,
396 recorded_at: String,
397 #[serde(
398 default,
399 skip_serializing_if = "Option::is_none",
400 deserialize_with = "de_present"
401 )]
402 event: Option<Value>,
403 #[serde(
404 default,
405 skip_serializing_if = "Option::is_none",
406 deserialize_with = "de_present"
407 )]
408 event_receipt: Option<Value>,
409 #[serde(
410 default,
411 skip_serializing_if = "Option::is_none",
412 deserialize_with = "de_present"
413 )]
414 superseded_by: Option<CtxId>,
415 #[serde(
416 default,
417 skip_serializing_if = "Option::is_none",
418 deserialize_with = "de_present"
419 )]
420 registry_receipt: Option<Value>,
421}
422
423impl TryFrom<ChangeWire> for Change {
424 type Error = AcdpError;
425
426 fn try_from(w: ChangeWire) -> Result<Self, AcdpError> {
427 if w.change_version != CHANGE_VERSION {
428 return Err(bad(format!(
429 "change_version '{}' ≠ '{CHANGE_VERSION}'",
430 w.change_version
431 )));
432 }
433 let recorded_at = parse_ms(&w.recorded_at, "recorded_at")?;
434 let mismatch = |extra: &str| {
435 bad(format!(
436 "a '{}' change entry must not carry '{extra}' (RFC-ACDP-0018 §4)",
437 w.kind
438 ))
439 };
440 let missing = |member: &str| {
441 bad(format!(
442 "a '{}' change entry requires '{member}' (RFC-ACDP-0018 §4)",
443 w.kind
444 ))
445 };
446 let evidence = match w.kind {
447 ChangeKind::Lifecycle => {
448 if w.superseded_by.is_some() {
449 return Err(mismatch("superseded_by"));
450 }
451 if w.registry_receipt.is_some() {
452 return Err(mismatch("registry_receipt"));
453 }
454 ChangeEvidence::Lifecycle {
455 event: w.event.ok_or_else(|| missing("event"))?,
456 event_receipt: w.event_receipt.ok_or_else(|| missing("event_receipt"))?,
457 }
458 }
459 ChangeKind::Superseded => {
460 if w.event.is_some() {
461 return Err(mismatch("event"));
462 }
463 if w.event_receipt.is_some() {
464 return Err(mismatch("event_receipt"));
465 }
466 ChangeEvidence::Superseded {
467 superseded_by: w.superseded_by.ok_or_else(|| missing("superseded_by"))?,
468 registry_receipt: w
469 .registry_receipt
470 .ok_or_else(|| missing("registry_receipt"))?,
471 }
472 }
473 ChangeKind::KeyRevocation => {
474 for (present, name) in [
475 (w.event.is_some(), "event"),
476 (w.event_receipt.is_some(), "event_receipt"),
477 (w.superseded_by.is_some(), "superseded_by"),
478 ] {
479 if present {
480 return Err(mismatch(name));
481 }
482 }
483 ChangeEvidence::KeyRevocation {
484 registry_receipt: w
485 .registry_receipt
486 .ok_or_else(|| missing("registry_receipt"))?,
487 }
488 }
489 };
490 let change = Change {
491 seq: w.seq,
492 ctx_id: w.ctx_id,
493 lineage_id: w.lineage_id,
494 agent_id: w.agent_id,
495 recorded_at,
496 evidence,
497 };
498 change.validate()?;
499 Ok(change)
500 }
501}
502
503impl From<Change> for ChangeWire {
504 fn from(c: Change) -> Self {
505 let mut w = ChangeWire {
506 change_version: CHANGE_VERSION.into(),
507 seq: c.seq,
508 kind: c.evidence.kind(),
509 ctx_id: c.ctx_id,
510 lineage_id: c.lineage_id,
511 agent_id: c.agent_id,
512 recorded_at: fmt_ms(c.recorded_at),
513 event: None,
514 event_receipt: None,
515 superseded_by: None,
516 registry_receipt: None,
517 };
518 match c.evidence {
519 ChangeEvidence::Lifecycle {
520 event,
521 event_receipt,
522 } => {
523 w.event = Some(event);
524 w.event_receipt = Some(event_receipt);
525 }
526 ChangeEvidence::Superseded {
527 superseded_by,
528 registry_receipt,
529 } => {
530 w.superseded_by = Some(superseded_by);
531 w.registry_receipt = Some(registry_receipt);
532 }
533 ChangeEvidence::KeyRevocation { registry_receipt } => {
534 w.registry_receipt = Some(registry_receipt);
535 }
536 }
537 w
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub struct ChangesResponse {
552 pub changes: Vec<Change>,
554 pub next_cursor: String,
557 pub has_more: bool,
560}
561
562impl ChangesResponse {
563 pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
567 let r = Self::deserialize(value)
568 .map_err(|e| bad(format!("changes response does not parse: {e}")))?;
569 r.validate()?;
570 Ok(r)
571 }
572
573 pub fn validate(&self) -> Result<(), AcdpError> {
575 check_cursor(&self.next_cursor, "next_cursor")?;
576 if self.changes.windows(2).any(|w| w[0].seq >= w[1].seq) {
577 return Err(bad(
578 "changes are not in strictly ascending seq order (RFC-ACDP-0018 §5.2, §5.3)",
579 ));
580 }
581 Ok(())
582 }
583}
584
585fn check_cursor(c: &str, what: &str) -> Result<(), AcdpError> {
586 if c.is_empty() || c.chars().count() > MAX_CURSOR_LEN {
587 return Err(bad(format!(
588 "{what} must be 1 to {MAX_CURSOR_LEN} characters (RFC-ACDP-0018 §5.4)"
589 )));
590 }
591 Ok(())
592}
593
594#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
603#[serde(deny_unknown_fields)]
604pub struct WatchFilter {
605 #[serde(default, skip_serializing_if = "Vec::is_empty")]
607 pub ctx_id: Vec<CtxId>,
608 #[serde(default, skip_serializing_if = "Vec::is_empty")]
610 pub lineage_id: Vec<LineageId>,
611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
613 pub agent_id: Vec<AgentDid>,
614 #[serde(default, skip_serializing_if = "Vec::is_empty")]
616 pub kind: Vec<ChangeKind>,
617}
618
619impl WatchFilter {
620 pub fn new() -> Self {
622 Self::default()
623 }
624 pub fn ctx_id(mut self, c: CtxId) -> Self {
626 self.ctx_id.push(c);
627 self
628 }
629 pub fn lineage_id(mut self, l: LineageId) -> Self {
631 self.lineage_id.push(l);
632 self
633 }
634 pub fn agent_id(mut self, a: AgentDid) -> Self {
636 self.agent_id.push(a);
637 self
638 }
639 pub fn kind(mut self, k: ChangeKind) -> Self {
641 self.kind.push(k);
642 self
643 }
644
645 pub fn is_empty(&self) -> bool {
647 self.total_values() == 0
648 }
649
650 pub fn total_values(&self) -> usize {
653 self.ctx_id.len() + self.lineage_id.len() + self.agent_id.len() + self.kind.len()
654 }
655
656 pub fn validate(&self, max_change_filters: Option<u64>) -> Result<(), AcdpError> {
660 for c in &self.ctx_id {
661 CtxId::parse(c.as_str())?;
662 }
663 for l in &self.lineage_id {
664 LineageId::parse(l.as_str())?;
665 }
666 for a in &self.agent_id {
667 AgentDid::parse(a.as_str())?;
668 }
669 if let Some(max) = max_change_filters {
670 if self.total_values() as u64 > max {
671 return Err(bad(format!(
672 "{} filter values exceed limits.max_change_filters {max} (RFC-ACDP-0018 §5.1)",
673 self.total_values()
674 )));
675 }
676 }
677 Ok(())
678 }
679
680 pub fn validate_for_subscription(
683 &self,
684 max_change_filters: Option<u64>,
685 ) -> Result<(), AcdpError> {
686 self.validate(max_change_filters)?;
687 if self.is_empty() {
688 return Err(bad("a subscription filter needs at least one value"));
689 }
690 fn unique<T: Ord>(v: &[T]) -> bool {
691 let mut seen = std::collections::BTreeSet::new();
692 v.iter().all(|x| seen.insert(x))
693 }
694 if !unique(&self.ctx_id.iter().map(CtxId::as_str).collect::<Vec<_>>())
695 || !unique(
696 &self
697 .lineage_id
698 .iter()
699 .map(LineageId::as_str)
700 .collect::<Vec<_>>(),
701 )
702 || !unique(
703 &self
704 .agent_id
705 .iter()
706 .map(AgentDid::as_str)
707 .collect::<Vec<_>>(),
708 )
709 || !unique(&self.kind)
710 {
711 return Err(bad("a subscription filter member repeats a value"));
712 }
713 Ok(())
714 }
715
716 pub fn query_pairs(&self) -> Vec<(&'static str, String)> {
719 let mut q = Vec::with_capacity(self.total_values());
720 q.extend(
721 self.ctx_id
722 .iter()
723 .map(|c| ("ctx_id", c.as_str().to_owned())),
724 );
725 q.extend(
726 self.lineage_id
727 .iter()
728 .map(|l| ("lineage_id", l.as_str().to_owned())),
729 );
730 q.extend(
731 self.agent_id
732 .iter()
733 .map(|a| ("agent_id", a.as_str().to_owned())),
734 );
735 q.extend(self.kind.iter().map(|k| ("kind", k.as_str().to_owned())));
736 q
737 }
738
739 pub fn matches(
743 &self,
744 change: &Change,
745 producer_of: impl Fn(&CtxId) -> Option<AgentDid>,
746 ) -> bool {
747 if !self.kind.is_empty() && !self.kind.contains(&change.kind()) {
748 return false;
749 }
750 if self.ctx_id.is_empty() && self.lineage_id.is_empty() && self.agent_id.is_empty() {
751 return true;
752 }
753 self.ctx_id.contains(&change.ctx_id)
754 || self.lineage_id.contains(&change.lineage_id)
755 || self.agent_id.contains(&change.agent_id)
756 || (change.kind() == ChangeKind::KeyRevocation
757 && self
758 .ctx_id
759 .iter()
760 .any(|c| producer_of(c).as_ref() == Some(&change.agent_id)))
761 }
762}
763
764fn check_filter_arrays(filter: Option<&Value>) -> Result<(), AcdpError> {
766 if let Some(obj) = filter.and_then(Value::as_object) {
767 for (k, v) in obj {
768 if v.as_array().is_some_and(Vec::is_empty) {
769 return Err(bad(format!("filter.{k} is empty (minItems 1)")));
770 }
771 }
772 }
773 Ok(())
774}
775
776fn check_callback_url(url: &str) -> Result<(), AcdpError> {
777 let host = url.strip_prefix("https://").map(|r| {
778 r.split(['/', '?', '#'])
779 .next()
780 .unwrap_or("")
781 .rsplit('@')
782 .next()
783 .unwrap_or("")
784 });
785 if url.len() > 2048
786 || url.chars().any(|c| c.is_whitespace() || c.is_control())
787 || host.is_none_or(str::is_empty)
788 {
789 return Err(bad(format!(
790 "callback_url '{url}' is not an https URL of at most 2048 characters \
791 (RFC-ACDP-0018 §8.1)"
792 )));
793 }
794 Ok(())
795}
796
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802pub struct SubscriptionRequest {
803 pub filter: WatchFilter,
805 pub callback_url: String,
808}
809
810impl SubscriptionRequest {
811 pub fn new(filter: WatchFilter, callback_url: impl Into<String>) -> Self {
813 Self {
814 filter,
815 callback_url: callback_url.into(),
816 }
817 }
818
819 pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
822 check_filter_arrays(value.get("filter"))?;
823 let r = Self::deserialize(value)
824 .map_err(|e| bad(format!("subscription request does not parse: {e}")))?;
825 r.validate(None)?;
826 Ok(r)
827 }
828
829 pub fn validate(&self, max_change_filters: Option<u64>) -> Result<(), AcdpError> {
832 self.filter.validate_for_subscription(max_change_filters)?;
833 check_callback_url(&self.callback_url)
834 }
835}
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
839#[serde(rename_all = "snake_case")]
840pub enum SubscriptionStatus {
841 Active,
843 Disabled,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
850#[serde(deny_unknown_fields)]
851pub struct Subscription {
852 pub subscription_id: String,
854 pub filter: WatchFilter,
856 pub callback_url: String,
858 #[serde(with = "crate::receipt::ms_rfc3339")]
860 pub created_at: DateTime<Utc>,
861 pub status: SubscriptionStatus,
863 #[serde(
866 default,
867 skip_serializing_if = "Option::is_none",
868 deserialize_with = "de_present"
869 )]
870 pub secret: Option<String>,
871}
872
873impl Subscription {
874 pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
877 check_filter_arrays(value.get("filter"))?;
878 if let Some(raw) = value.get("created_at").and_then(Value::as_str) {
879 parse_ms(raw, "created_at")?;
880 }
881 let s = Self::deserialize(value)
882 .map_err(|e| bad(format!("subscription does not parse: {e}")))?;
883 s.validate()?;
884 Ok(s)
885 }
886
887 pub fn validate(&self) -> Result<(), AcdpError> {
889 if !is_uuid_v4(&self.subscription_id) {
890 return Err(bad(format!(
891 "subscription_id '{}' is not a canonical lowercase v4 UUID",
892 self.subscription_id
893 )));
894 }
895 self.filter.validate_for_subscription(None)?;
896 check_callback_url(&self.callback_url)?;
897 if let Some(secret) = &self.secret {
898 if !is_secret_form(secret) {
899 return Err(bad(
900 "subscription secret is not base64 of at least 32 bytes (RFC-ACDP-0018 §8.1)",
901 ));
902 }
903 }
904 Ok(())
905 }
906
907 pub fn without_secret(&self) -> Self {
910 Self {
911 secret: None,
912 ..self.clone()
913 }
914 }
915}
916
917fn is_secret_form(s: &str) -> bool {
919 let body = s.trim_end_matches('=');
920 s.len() - body.len() <= 2
921 && body.len() >= 43
922 && body
923 .bytes()
924 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
925}
926
927fn is_uuid_v4(s: &str) -> bool {
929 let b = s.as_bytes();
930 b.len() == 36
931 && b.iter().enumerate().all(|(i, &c)| match i {
932 8 | 13 | 18 | 23 => c == b'-',
933 14 => c == b'4',
934 19 => matches!(c, b'8' | b'9' | b'a' | b'b'),
935 _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
936 })
937}
938
939#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(deny_unknown_fields)]
948pub struct ChangeHint {
949 pub hint_version: String,
951 pub subscription_id: String,
953 pub registry_authority: String,
955 pub head_cursor: String,
957}
958
959impl ChangeHint {
960 pub fn new(
962 subscription_id: impl Into<String>,
963 registry_authority: impl Into<String>,
964 head_cursor: impl Into<String>,
965 ) -> Self {
966 Self {
967 hint_version: CHANGE_HINT_VERSION.into(),
968 subscription_id: subscription_id.into(),
969 registry_authority: registry_authority.into(),
970 head_cursor: head_cursor.into(),
971 }
972 }
973
974 pub fn from_value(value: &Value) -> Result<Self, AcdpError> {
978 let h = Self::deserialize(value)
979 .map_err(|e| bad(format!("change hint does not parse: {e}")))?;
980 h.validate()?;
981 Ok(h)
982 }
983
984 pub fn validate(&self) -> Result<(), AcdpError> {
986 if self.hint_version != CHANGE_HINT_VERSION {
987 return Err(bad(format!(
988 "hint_version '{}' ≠ '{CHANGE_HINT_VERSION}'",
989 self.hint_version
990 )));
991 }
992 if !is_uuid_v4(&self.subscription_id) {
993 return Err(bad(format!(
994 "subscription_id '{}' is not a canonical lowercase v4 UUID",
995 self.subscription_id
996 )));
997 }
998 if !is_valid_dns_authority(&self.registry_authority) {
999 return Err(bad(format!(
1000 "registry_authority '{}' is not a hostname",
1001 self.registry_authority
1002 )));
1003 }
1004 check_cursor(&self.head_cursor, "head_cursor")
1005 }
1006}
1007
1008fn hint_key(secret: &str) -> &[u8] {
1014 secret.as_bytes()
1015}
1016
1017fn hint_mac(secret: &str, t: i64, body: &[u8]) -> Hmac<Sha256> {
1018 let mut mac =
1019 Hmac::<Sha256>::new_from_slice(hint_key(secret)).expect("HMAC accepts any key length");
1020 mac.update(t.to_string().as_bytes());
1021 mac.update(b".");
1022 mac.update(body);
1023 mac
1024}
1025
1026pub fn sign_hint(secret: &str, t: i64, body: &[u8]) -> String {
1031 let tag = hint_mac(secret, t, body).finalize().into_bytes();
1032 format!("t={t},v1={}", hex::encode(tag))
1033}
1034
1035pub fn verify_hint_signature(
1049 secret: &str,
1050 header: &str,
1051 body: &[u8],
1052 now: DateTime<Utc>,
1053) -> Result<(), AcdpError> {
1054 let reject = |why: &str| {
1055 AcdpError::NotAuthorized(format!(
1056 "ACDP-Signature rejected: {why} (RFC-ACDP-0018 §8.2)"
1057 ))
1058 };
1059 let mut t: Option<i64> = None;
1060 let mut v1: Vec<Vec<u8>> = Vec::new();
1061 for part in header.split(',') {
1062 let Some((k, v)) = part.trim().split_once('=') else {
1063 return Err(reject("malformed header"));
1064 };
1065 match k {
1066 "t" => {
1067 if t.is_some() || v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) {
1068 return Err(reject("t is missing, repeated or not unix seconds"));
1069 }
1070 t = Some(v.parse().map_err(|_| reject("t is out of range"))?);
1071 }
1072 "v1" => v1.push(hex::decode(v).map_err(|_| reject("v1 is not hex"))?),
1073 _ => {}
1074 }
1075 }
1076 let t = t.ok_or_else(|| reject("no t"))?;
1077 if v1.is_empty() {
1078 return Err(reject("no v1"));
1079 }
1080 if (now.timestamp() - t).abs() > HINT_MAX_SKEW_SECONDS {
1081 return Err(reject(
1082 "t is more than 300 seconds from the receiver's clock",
1083 ));
1084 }
1085 let mac = hint_mac(secret, t, body);
1086 if v1.iter().any(|tag| mac.clone().verify_slice(tag).is_ok()) {
1087 Ok(())
1088 } else {
1089 Err(reject("v1 does not match"))
1090 }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use crate::receipt::ReceiptSigner;
1097 use acdp_crypto::SigningKey;
1098 use serde_json::json;
1099
1100 const CTX: &str = "acdp://registry.example.com/bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
1101 const NEW: &str = "acdp://registry.example.com/cccccccc-cccc-4ccc-8ccc-cccccccccccc";
1102 const LIN: &str = "lin:sha256:a3cb6a87c1601b9f6dddd8d54709b01df3e05f1095b43e6d58e3a862bf9906fa";
1103 const AGENT: &str = "did:web:agents.example.com:test-producer";
1104 const FP: &str = "sha256:6a3803d5f059902a1c6dafbc9ba4729212f7caac08634cc3ae76b27529f03827";
1105
1106 fn at(s: &str) -> DateTime<Utc> {
1107 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1108 }
1109
1110 fn registry() -> ReceiptSigner {
1111 ReceiptSigner::new(
1112 SigningKey::from_bytes(&[0x11; 32]),
1113 "did:web:registry.example.com",
1114 "did:web:registry.example.com#receipt-key-1",
1115 )
1116 .unwrap()
1117 }
1118
1119 fn event() -> Value {
1120 let e = LifecycleEvent::new(
1121 "00000000-0000-4000-8000-000000000098",
1122 CtxId::parse(CTX).unwrap(),
1123 crate::lifecycle::LifecycleEventType::Retracted,
1124 at("2026-10-03T00:00:00.000Z"),
1125 AgentDid::new(AGENT),
1126 None,
1127 )
1128 .unwrap()
1129 .sign_with(SigningKey::from_bytes(&[0; 32]), format!("{AGENT}#key-1"))
1130 .unwrap();
1131 serde_json::to_value(e).unwrap()
1132 }
1133
1134 fn lifecycle_change() -> Change {
1135 let r = registry()
1136 .mint_event_receipt(
1137 &CtxId::parse(CTX).unwrap(),
1138 &event(),
1139 0,
1140 at("2026-10-03T00:00:01.000Z"),
1141 )
1142 .unwrap();
1143 Change::lifecycle(
1144 1042,
1145 LineageId::parse(LIN).unwrap(),
1146 AgentDid::new(AGENT),
1147 event(),
1148 &r,
1149 )
1150 .unwrap()
1151 }
1152
1153 fn receipt_for(ctx: &str, created: &str) -> RegistryReceipt {
1154 registry()
1155 .mint(
1156 &CtxId::parse(ctx).unwrap(),
1157 &LineageId::parse(LIN).unwrap(),
1158 "registry.example.com",
1159 at(created),
1160 &acdp_primitives::primitives::ContentHash::parse(
1161 "sha256:1e5ec1be1bb3dc4ee2c9ec6eb4c6cc6422b4ee4a862b6e947aee8de772b6e879",
1162 )
1163 .unwrap(),
1164 FP,
1165 )
1166 .unwrap()
1167 }
1168
1169 #[test]
1170 fn constructors_round_trip_and_are_consistent() {
1171 let l = lifecycle_change();
1172 let s = Change::superseded(
1173 1050,
1174 CtxId::parse(CTX).unwrap(),
1175 AgentDid::new(AGENT),
1176 &receipt_for(NEW, "2026-10-04T00:00:00.000Z"),
1177 )
1178 .unwrap();
1179 let k = Change::key_revocation(
1180 1057,
1181 AgentDid::new(AGENT),
1182 &receipt_for(NEW, "2026-10-05T00:00:00.000Z"),
1183 )
1184 .unwrap();
1185 for c in [&l, &s, &k] {
1186 c.check_consistency().unwrap();
1187 let wire = serde_json::to_value(c).unwrap();
1188 assert_eq!(wire["change_version"], CHANGE_VERSION);
1189 assert_eq!(wire["kind"], c.kind().as_str());
1190 assert_eq!(&Change::from_value(&wire).unwrap(), c);
1191 }
1192 assert_eq!(l.recorded_at(), at("2026-10-03T00:00:01.000Z"));
1193 assert_eq!(
1194 serde_json::to_value(&s).unwrap()["superseded_by"],
1195 json!(NEW)
1196 );
1197 }
1198
1199 #[test]
1200 fn cross_kind_members_are_rejected() {
1201 let wire = serde_json::to_value(lifecycle_change()).unwrap();
1202 let receipt = serde_json::to_value(receipt_for(NEW, "2026-10-04T00:00:00.000Z")).unwrap();
1203 type Mutate = Box<dyn Fn(&mut Value)>;
1204 let cases: Vec<(&str, Mutate)> = vec![
1205 ("lifecycle + registry_receipt", {
1206 let r = receipt.clone();
1207 Box::new(move |v: &mut Value| v["registry_receipt"] = r.clone())
1208 }),
1209 (
1210 "lifecycle + superseded_by",
1211 Box::new(|v: &mut Value| v["superseded_by"] = json!(NEW)),
1212 ),
1213 (
1214 "lifecycle without event_receipt",
1215 Box::new(|v: &mut Value| {
1216 v.as_object_mut().unwrap().remove("event_receipt");
1217 }),
1218 ),
1219 (
1220 "superseded carrying event",
1221 Box::new(|v: &mut Value| v["kind"] = json!("superseded")),
1222 ),
1223 (
1224 "key_revocation carrying event",
1225 Box::new(|v: &mut Value| v["kind"] = json!("key_revocation")),
1226 ),
1227 (
1228 "unknown kind",
1229 Box::new(|v: &mut Value| v["kind"] = json!("deleted")),
1230 ),
1231 (
1232 "unknown member",
1233 Box::new(|v: &mut Value| v["x"] = json!(1)),
1234 ),
1235 (
1236 "wrong version",
1237 Box::new(|v: &mut Value| v["change_version"] = json!("acdp-change/2")),
1238 ),
1239 (
1240 "non-canonical recorded_at",
1241 Box::new(|v: &mut Value| v["recorded_at"] = json!("2026-10-03T00:00:01Z")),
1242 ),
1243 (
1244 "explicit null",
1245 Box::new(|v: &mut Value| v["superseded_by"] = Value::Null),
1246 ),
1247 (
1248 "event receipt with an unknown member",
1249 Box::new(|v: &mut Value| v["event_receipt"]["x"] = json!(1)),
1250 ),
1251 ];
1252 for (name, f) in cases {
1253 let mut v = wire.clone();
1254 f(&mut v);
1255 assert!(Change::from_value(&v).is_err(), "{name} parsed");
1256 }
1257 }
1258
1259 #[test]
1260 fn consistency_catches_envelope_disagreeing_with_evidence() {
1261 let mut v = serde_json::to_value(lifecycle_change()).unwrap();
1262 v["recorded_at"] = json!("2026-10-03T00:00:02.000Z");
1263 assert!(Change::from_value(&v).unwrap().check_consistency().is_err());
1264
1265 let mut v = serde_json::to_value(lifecycle_change()).unwrap();
1266 v["ctx_id"] = json!(NEW);
1267 assert!(Change::from_value(&v).unwrap().check_consistency().is_err());
1268
1269 let s = Change::superseded(
1271 1,
1272 CtxId::parse(CTX).unwrap(),
1273 AgentDid::new(AGENT),
1274 &receipt_for(CTX, "2026-10-04T00:00:00.000Z"),
1275 )
1276 .unwrap();
1277 assert!(s.check_consistency().is_err());
1278 }
1279
1280 #[test]
1281 fn changes_response_needs_cursor_and_ascending_seq() {
1282 let c = serde_json::to_value(lifecycle_change()).unwrap();
1283 let ok = json!({"changes": [c], "next_cursor": "c2VxOjEwNDI", "has_more": false});
1284 ChangesResponse::from_value(&ok).unwrap();
1285 for bad in [
1286 json!({"changes": [], "has_more": false}),
1287 json!({"changes": [], "next_cursor": "", "has_more": false}),
1288 json!({"changes": [], "next_cursor": "x".repeat(2049), "has_more": false}),
1289 json!({"changes": [c, c], "next_cursor": "c", "has_more": false}),
1290 json!({"changes": [], "next_cursor": "c", "has_more": false, "x": 1}),
1291 ] {
1292 assert!(ChangesResponse::from_value(&bad).is_err(), "{bad}");
1293 }
1294 }
1295
1296 #[test]
1297 fn watch_filter_semantics() {
1298 let l = lifecycle_change();
1299 let k = Change::key_revocation(
1300 1057,
1301 AgentDid::new(AGENT),
1302 &receipt_for(NEW, "2026-10-05T00:00:00.000Z"),
1303 )
1304 .unwrap();
1305 let none = |_: &CtxId| None;
1306 assert!(WatchFilter::new().matches(&l, none));
1307 let f = WatchFilter::new()
1309 .ctx_id(CtxId::parse(NEW).unwrap())
1310 .lineage_id(LineageId::parse(LIN).unwrap());
1311 assert!(f.matches(&l, none));
1312 assert!(!f.clone().kind(ChangeKind::Superseded).matches(&l, none));
1313 assert!(WatchFilter::new()
1314 .agent_id(AgentDid::new(AGENT))
1315 .matches(&l, none));
1316 let other_ctx = "acdp://registry.example.com/dddddddd-dddd-4ddd-8ddd-dddddddddddd";
1318 let watch = WatchFilter::new().ctx_id(CtxId::parse(other_ctx).unwrap());
1319 assert!(watch.matches(&k, |_| Some(AgentDid::new(AGENT))));
1320 assert!(!watch.matches(&k, |_| Some(AgentDid::new("did:web:other.example.com"))));
1321 assert!(!watch.matches(&l, |_| Some(AgentDid::new(AGENT))));
1323 assert_eq!(
1324 f.query_pairs(),
1325 vec![("ctx_id", NEW.to_string()), ("lineage_id", LIN.to_string())]
1326 );
1327 assert!(WatchFilter::new()
1328 .kind(ChangeKind::Lifecycle)
1329 .kind(ChangeKind::Superseded)
1330 .validate(Some(1))
1331 .is_err());
1332 }
1333
1334 #[test]
1335 fn subscription_objects_validate() {
1336 let req = json!({
1337 "filter": {"lineage_id": [LIN], "kind": ["lifecycle", "key_revocation"]},
1338 "callback_url": "https://consumer.example.com/acdp/hints"
1339 });
1340 let r = SubscriptionRequest::from_value(&req).unwrap();
1341 assert_eq!(serde_json::to_value(&r).unwrap(), req);
1342 for bad in [
1343 json!({"filter": {}, "callback_url": "https://c.example.com/"}),
1344 json!({"filter": {"kind": []}, "callback_url": "https://c.example.com/"}),
1345 json!({"filter": {"kind": ["lifecycle", "lifecycle"]}, "callback_url": "https://c.example.com/"}),
1346 json!({"filter": {"kind": ["lifecycle"]}, "callback_url": "http://c.example.com/"}),
1347 json!({"filter": {"kind": ["lifecycle"]}, "callback_url": "https:///path"}),
1348 json!({"filter": {"x": ["y"]}, "callback_url": "https://c.example.com/"}),
1349 ] {
1350 assert!(SubscriptionRequest::from_value(&bad).is_err(), "{bad}");
1351 }
1352
1353 let created = json!({
1354 "subscription_id": "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1355 "filter": {"lineage_id": [LIN]},
1356 "callback_url": "https://consumer.example.com/acdp/hints",
1357 "created_at": "2026-10-01T00:00:00.000Z",
1358 "status": "active",
1359 "secret": "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM="
1360 });
1361 let s = Subscription::from_value(&created).unwrap();
1362 assert_eq!(serde_json::to_value(&s).unwrap(), created);
1363 let served = serde_json::to_value(s.without_secret()).unwrap();
1364 assert!(served.get("secret").is_none());
1365 let mut short = created.clone();
1366 short["secret"] = json!("c2hvcnQ=");
1367 assert!(Subscription::from_value(&short).is_err());
1368 let mut upper = created.clone();
1369 upper["subscription_id"] = json!("6F1C2A9E-3B4D-4E5F-8A6B-7C8D9E0F1A2B");
1370 assert!(Subscription::from_value(&upper).is_err());
1371 }
1372
1373 #[test]
1374 fn hint_parses() {
1375 let h = json!({
1376 "hint_version": "acdp-change-hint/1",
1377 "subscription_id": "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1378 "registry_authority": "registry.example.com",
1379 "head_cursor": "c2VxOjEwNTc"
1380 });
1381 let parsed = ChangeHint::from_value(&h).unwrap();
1382 assert_eq!(
1383 parsed,
1384 ChangeHint::new(
1385 "6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b",
1386 "registry.example.com",
1387 "c2VxOjEwNTc"
1388 )
1389 );
1390 let mut content = h.clone();
1391 content["changes"] = json!([]);
1392 assert!(ChangeHint::from_value(&content).is_err());
1393 }
1394
1395 #[test]
1397 fn hmac_primitive_matches_rfc_4231() {
1398 let mut mac = Hmac::<Sha256>::new_from_slice(b"Jefe").unwrap();
1399 mac.update(b"what do ya want for nothing?");
1400 assert_eq!(
1401 hex::encode(mac.finalize().into_bytes()),
1402 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
1403 );
1404 }
1405
1406 #[test]
1409 fn hint_hmac_001() {
1410 let secret = "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM=";
1411 let body = br#"{"head_cursor":"c2VxOjEwNTc","hint_version":"acdp-change-hint/1","registry_authority":"registry.example.com","subscription_id":"6f1c2a9e-3b4d-4e5f-8a6b-7c8d9e0f1a2b"}"#;
1412 let t = 1_790_985_602;
1413 let expected =
1414 "t=1790985602,v1=a000c589a805e6a5ed4eeb7835a9e8051a879d067c17c86c3de28862f9e489de";
1415 let not =
1416 "t=1790985602,v1=3b096f13ceb0ff06e1f51542614551007d2a8547f8df1a6f5f38daa26d5f0aed";
1417 assert_eq!(sign_hint(secret, t, body), expected);
1418 let now = DateTime::from_timestamp(t, 0).unwrap();
1419 verify_hint_signature(secret, expected, body, now).unwrap();
1420 assert!(verify_hint_signature(secret, not, body, now).is_err());
1421 }
1422
1423 #[test]
1424 fn hint_signature_round_trip_and_rejections() {
1425 let secret = "q4C3i0gxkC8b7mSx2F5fW1dTnYp9Z0eLrJvH6uK3oAM=";
1426 let body = br#"{"hint_version":"acdp-change-hint/1"}"#;
1427 let t = 1_790_000_000_i64;
1428 let now = DateTime::from_timestamp(t, 0).unwrap();
1429 let header = sign_hint(secret, t, body);
1430 assert!(header.starts_with(&format!("t={t},v1=")));
1431 verify_hint_signature(secret, &header, body, now).unwrap();
1432 let v1 = header.split_once(",v1=").unwrap().1;
1434 verify_hint_signature(secret, &format!("v1={v1}, t={t},v2=zz"), body, now).unwrap();
1435 verify_hint_signature(
1436 secret,
1437 &format!("t={t},v1={},v1={v1}", "00".repeat(32)),
1438 body,
1439 now,
1440 )
1441 .unwrap();
1442 for (d, ok) in [(300, true), (-300, true), (301, false), (-301, false)] {
1444 let r =
1445 verify_hint_signature(secret, &header, body, now + chrono::Duration::seconds(d));
1446 assert_eq!(r.is_ok(), ok, "skew {d}");
1447 }
1448 let mut flipped = header.clone();
1450 let last = flipped.pop().unwrap();
1451 flipped.push(if last == '0' { '1' } else { '0' });
1452 for (s, h, b) in [
1453 (secret, flipped.as_str(), &body[..]),
1454 (
1455 "another-secret-another-secret-another-secret",
1456 header.as_str(),
1457 &body[..],
1458 ),
1459 (secret, header.as_str(), &b"{}"[..]),
1460 (secret, "v1=00", &body[..]),
1461 (secret, &format!("t={t}"), &body[..]),
1462 (secret, &format!("t=-{t},v1={v1}"), &body[..]),
1463 (secret, &format!("t={t},t={t},v1={v1}"), &body[..]),
1464 (secret, "garbage", &body[..]),
1465 ] {
1466 assert!(
1467 matches!(
1468 verify_hint_signature(s, h, b, now),
1469 Err(AcdpError::NotAuthorized(_))
1470 ),
1471 "{h} accepted"
1472 );
1473 }
1474 }
1475}