1use crate::body::Body;
19use crate::publish::PublishRequest;
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{AgentDid, ContextType, CtxId, Visibility};
22pub use acdp_primitives::revocation::{
23 RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
24};
25use acdp_primitives::time::fmt_rfc3339_ms;
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29pub const MAX_REASON_CHARS: usize = 1024;
31
32pub use acdp_primitives::revocation::RevocationTrustClass;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct KeyRevocation {
50 pub revoked_key_fingerprint: String,
54 pub compromised_since: DateTime<Utc>,
57 pub reason: Option<String>,
61 pub revoked_key_id: Option<String>,
64 pub revoked_key_controller: AgentDid,
69 pub publisher: AgentDid,
73 pub trust_class: RevocationTrustClass,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub ctx_id: Option<CtxId>,
88}
89
90impl KeyRevocation {
91 pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
118 let mut revocation = Self::from_parts(
119 &body.context_type,
120 &body.visibility,
121 body.metadata.as_ref(),
122 &body.agent_id,
123 &body.signature.key_id,
124 )?;
125 revocation.ctx_id = Some(body.ctx_id.clone());
126 Ok(revocation)
127 }
128
129 #[must_use]
132 pub fn sourced(&self, source: RevocationSource) -> SourcedRevocation {
133 SourcedRevocation::new(
134 self.revoked_key_fingerprint.clone(),
135 self.compromised_since,
136 self.ctx_id.clone(),
137 self.publisher.clone(),
138 self.revoked_key_controller.clone(),
139 self.trust_class,
140 source,
141 )
142 }
143
144 pub fn from_publish_request(req: &PublishRequest) -> Result<Self, AcdpError> {
155 Self::from_parts(
156 &req.context_type,
157 &req.visibility,
158 req.metadata.as_ref(),
159 &req.agent_id,
160 &req.signature.key_id,
161 )
162 }
163
164 fn from_parts(
171 context_type: &ContextType,
172 visibility: &Visibility,
173 metadata: Option<&serde_json::Value>,
174 agent_id: &AgentDid,
175 signing_key_id: &str,
176 ) -> Result<Self, AcdpError> {
177 if !context_type.is_key_revocation() {
178 return Err(AcdpError::SchemaViolation(format!(
179 "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
180 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
181 serde_json::to_value(context_type)
182 .ok()
183 .and_then(|v| v.as_str().map(str::to_owned))
184 .unwrap_or_default()
185 )));
186 }
187 if *visibility != Visibility::Public {
188 return Err(AcdpError::SchemaViolation(
189 "a key-revocation context MUST be visibility 'public' — it is a safety \
190 broadcast; an audience-restricted revocation protects nobody outside the \
191 audience (RFC-ACDP-0014 §4)"
192 .into(),
193 ));
194 }
195
196 let meta = metadata.and_then(|m| m.as_object()).ok_or_else(|| {
197 AcdpError::SchemaViolation(
198 "key-revocation body has no metadata object; \
199 metadata.revoked_key_fingerprint and metadata.compromised_since are \
200 REQUIRED (RFC-ACDP-0014 §4)"
201 .into(),
202 )
203 })?;
204
205 let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
206 if !is_sha256_fingerprint(fingerprint) {
207 return Err(AcdpError::SchemaViolation(format!(
208 "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
209 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
210 )));
211 }
212
213 let since_raw = required_str(meta, "compromised_since")?;
214 let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
215 AcdpError::SchemaViolation(format!(
216 "metadata.compromised_since '{since_raw}' is not canonical \
217 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
218 ))
219 })?;
220
221 let reason = optional_str(meta, "reason")?;
222 if let Some(r) = &reason {
223 if r.chars().count() > MAX_REASON_CHARS {
224 return Err(AcdpError::SchemaViolation(format!(
225 "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
226 )));
227 }
228 }
229 let revoked_key_id = optional_str(meta, "revoked_key_id")?;
230
231 let (revoked_key_controller, trust_class) =
232 match optional_str(meta, "revoked_key_controller")? {
233 None => (agent_id.clone(), RevocationTrustClass::ProducerSigned),
234 Some(c) => {
235 let controller = AgentDid::parse(&c)?;
236 if controller == *agent_id {
237 (controller, RevocationTrustClass::ProducerSigned)
240 } else {
241 (controller, RevocationTrustClass::RegistryAttested)
244 }
245 }
246 };
247
248 let revocation = KeyRevocation {
249 revoked_key_fingerprint: fingerprint.to_string(),
250 compromised_since,
251 reason,
252 revoked_key_id,
253 revoked_key_controller,
254 publisher: agent_id.clone(),
255 trust_class,
256 ctx_id: None,
257 };
258
259 if signing_key_id.starts_with("did:key:") {
264 if let Ok(material) = acdp_did::key::resolve_did_key_url(signing_key_id) {
265 if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
266 revocation.check_not_self_signed(&fp)?;
267 }
268 }
269 }
270
271 Ok(revocation)
272 }
273
274 pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
285 if signing_key_fingerprint == self.revoked_key_fingerprint {
286 return Err(AcdpError::KeyNotAuthorized(format!(
287 "revocation of key {} is signed by that same key — a key is not \
288 authorized to attest its own compromise; treat as unverified \
289 (RFC-ACDP-0014 §5 step 2)",
290 self.revoked_key_fingerprint
291 )));
292 }
293 Ok(())
294 }
295
296 pub fn revokes(&self, key_fingerprint: &str) -> bool {
299 self.revoked_key_fingerprint == key_fingerprint
300 }
301
302 pub fn check_not_self_signed_lenient(
320 req: &PublishRequest,
321 signing_key_fingerprint: &str,
322 ) -> Result<(), AcdpError> {
323 let Some(fingerprint) = req
324 .metadata
325 .as_ref()
326 .and_then(|m| m.as_object())
327 .and_then(|m| m.get("revoked_key_fingerprint"))
328 .and_then(|v| v.as_str())
329 else {
330 return Ok(());
331 };
332 if fingerprint == signing_key_fingerprint {
333 return Err(AcdpError::KeyNotAuthorized(format!(
334 "revocation of key {fingerprint} is signed by that same key — a key is not \
335 authorized to attest its own compromise; treat as unverified \
336 (RFC-ACDP-0014 §5 step 2)"
337 )));
338 }
339 Ok(())
340 }
341
342 pub fn check_not_self_signed_did_key_lenient(req: &PublishRequest) -> Result<(), AcdpError> {
355 if !req.signature.key_id.starts_with("did:key:") {
356 return Ok(());
357 }
358 let Ok(material) = acdp_did::key::resolve_did_key_url(&req.signature.key_id) else {
359 return Ok(());
360 };
361 let Ok(signer_fingerprint) =
362 acdp_crypto::fingerprint::fingerprint_did_key_material(&material)
363 else {
364 return Ok(());
365 };
366 Self::check_not_self_signed_lenient(req, &signer_fingerprint)
367 }
368
369 pub fn cross_check_registry_binding(
396 &self,
397 serving_authority: &str,
398 capabilities_registry_did: &str,
399 ) -> Result<(), AcdpError> {
400 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
401 if self.publisher.as_str() != expected_did {
402 return Err(AcdpError::KeyNotAuthorized(format!(
403 "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
404 (RFC-ACDP-0014 §6 steps 2–3)",
405 self.publisher
406 )));
407 }
408 if self.publisher.as_str() != capabilities_registry_did {
409 return Err(AcdpError::KeyNotAuthorized(format!(
410 "key-revocation publisher '{}' ≠ capabilities.registry_did \
411 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
412 self.publisher
413 )));
414 }
415 Ok(())
416 }
417}
418
419pub fn effective_boundary<'a>(
428 revocations: impl IntoIterator<Item = &'a KeyRevocation>,
429 key_fingerprint: &str,
430) -> Option<DateTime<Utc>> {
431 revocations
432 .into_iter()
433 .filter(|r| r.revokes(key_fingerprint))
434 .map(|r| r.compromised_since)
435 .min()
436}
437
438fn required_str<'m>(
439 meta: &'m serde_json::Map<String, serde_json::Value>,
440 key: &str,
441) -> Result<&'m str, AcdpError> {
442 meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
443 AcdpError::SchemaViolation(format!(
444 "key-revocation metadata.{key} is REQUIRED and must be a string \
445 (RFC-ACDP-0014 §4)"
446 ))
447 })
448}
449
450fn optional_str(
451 meta: &serde_json::Map<String, serde_json::Value>,
452 key: &str,
453) -> Result<Option<String>, AcdpError> {
454 match meta.get(key) {
455 None => Ok(None),
456 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
457 Some(_) => Err(AcdpError::SchemaViolation(format!(
458 "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
459 ))),
460 }
461}
462
463fn is_sha256_fingerprint(s: &str) -> bool {
465 match s.strip_prefix("sha256:") {
466 Some(hex) => {
467 hex.len() == 64
468 && hex
469 .chars()
470 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
471 }
472 None => false,
473 }
474}
475
476fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
480 let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
481 (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::body::Signature;
488 use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
489
490 const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
495 const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
496
497 fn pr_valid_metadata() -> serde_json::Value {
498 serde_json::json!({
499 "revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
500 "compromised_since": PR_COMPROMISED_SINCE,
501 })
502 }
503
504 fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
505 PublishRequest {
506 version: 1,
507 supersedes: None,
508 agent_id: AgentDid::new(PR_PRODUCER_DID),
509 contributors: vec![],
510 title: "Key revocation — key-1 compromised".into(),
511 context_type: ContextType::KeyRevocation,
512 data_refs: vec![],
513 derived_from: vec![],
514 visibility: Visibility::Public,
515 content_hash: ContentHash("sha256:0".into()),
516 signature: Signature {
517 algorithm: "ed25519".into(),
518 key_id: format!("{PR_PRODUCER_DID}#key-1"),
519 value: "A".repeat(88),
520 },
521 audience: None,
522 acdp_version: Some("0.3.0".into()),
523 description: None,
524 summary: None,
525 lineage_id: None,
526 tags: None,
527 domain: None,
528 expires_at: None,
529 data_period: None,
530 metadata,
531 schema_uri: None,
532 anchors: None,
533 }
534 }
535
536 #[test]
540 fn from_publish_request_valid_case_is_accepted() {
541 let req = publish_request_with_metadata(Some(pr_valid_metadata()));
542 let rev =
543 KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
544 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
545 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
546 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
547 }
548
549 #[test]
550 fn from_publish_request_wrong_context_type_rejected() {
551 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
552 req.context_type = ContextType::Analysis;
553 assert!(matches!(
554 KeyRevocation::from_publish_request(&req),
555 Err(AcdpError::SchemaViolation(_))
556 ));
557 }
558
559 #[test]
560 fn from_publish_request_non_public_visibility_rejected() {
561 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
562 req.visibility = Visibility::Restricted;
563 assert!(matches!(
564 KeyRevocation::from_publish_request(&req),
565 Err(AcdpError::SchemaViolation(_))
566 ));
567 }
568
569 #[test]
570 fn from_publish_request_missing_metadata_rejected() {
571 let req = publish_request_with_metadata(None);
572 assert!(matches!(
573 KeyRevocation::from_publish_request(&req),
574 Err(AcdpError::SchemaViolation(_))
575 ));
576 }
577
578 #[test]
579 fn from_publish_request_missing_fingerprint_rejected() {
580 let mut meta = pr_valid_metadata();
581 meta.as_object_mut()
582 .unwrap()
583 .remove("revoked_key_fingerprint");
584 let req = publish_request_with_metadata(Some(meta));
585 assert!(matches!(
586 KeyRevocation::from_publish_request(&req),
587 Err(AcdpError::SchemaViolation(_))
588 ));
589 }
590
591 #[test]
592 fn from_publish_request_malformed_fingerprint_rejected() {
593 let mut meta = pr_valid_metadata();
594 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
595 let req = publish_request_with_metadata(Some(meta));
596 assert!(matches!(
597 KeyRevocation::from_publish_request(&req),
598 Err(AcdpError::SchemaViolation(_))
599 ));
600 }
601
602 #[test]
603 fn from_publish_request_missing_compromised_since_rejected() {
604 let mut meta = pr_valid_metadata();
605 meta.as_object_mut().unwrap().remove("compromised_since");
606 let req = publish_request_with_metadata(Some(meta));
607 assert!(matches!(
608 KeyRevocation::from_publish_request(&req),
609 Err(AcdpError::SchemaViolation(_))
610 ));
611 }
612
613 #[test]
614 fn from_publish_request_non_canonical_compromised_since_rejected() {
615 let mut meta = pr_valid_metadata();
616 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
619 let req = publish_request_with_metadata(Some(meta));
620 assert!(matches!(
621 KeyRevocation::from_publish_request(&req),
622 Err(AcdpError::SchemaViolation(_))
623 ));
624 }
625
626 #[test]
627 fn from_publish_request_reason_over_limit_rejected() {
628 let mut meta = pr_valid_metadata();
629 meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
630 let req = publish_request_with_metadata(Some(meta));
631 assert!(matches!(
632 KeyRevocation::from_publish_request(&req),
633 Err(AcdpError::SchemaViolation(_))
634 ));
635 }
636
637 #[test]
642 fn from_body_and_from_publish_request_agree_on_equivalent_input() {
643 let metadata = Some(pr_valid_metadata());
644 let req = publish_request_with_metadata(metadata.clone());
645 let body = body_from_pr_request(&req);
646
647 let from_body = KeyRevocation::from_body(&body).unwrap();
648 assert_eq!(from_body.ctx_id.as_ref(), Some(&body.ctx_id));
650 let mut from_request = KeyRevocation::from_publish_request(&req).unwrap();
651 assert_eq!(from_request.ctx_id, None);
652 from_request.ctx_id = Some(body.ctx_id.clone());
653 assert_eq!(from_request, from_body);
654 }
655
656 fn body_from_pr_request(req: &PublishRequest) -> Body {
660 Body::from_publish_request(
661 req,
662 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
663 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
664 "registry.example.com",
665 DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
666 .unwrap()
667 .with_timezone(&Utc),
668 )
669 }
670
671 #[test]
679 fn from_body_and_from_publish_request_agree_on_error_message() {
680 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
682 req.visibility = Visibility::Restricted;
683 let body = body_from_pr_request(&req);
684 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
685 let body_err = KeyRevocation::from_body(&body).unwrap_err();
686 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
687 assert_eq!(pr_err.to_string(), body_err.to_string());
688
689 let mut meta = pr_valid_metadata();
691 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
692 let req = publish_request_with_metadata(Some(meta));
693 let body = body_from_pr_request(&req);
694 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
695 let body_err = KeyRevocation::from_body(&body).unwrap_err();
696 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
697 assert_eq!(pr_err.to_string(), body_err.to_string());
698 }
699
700 #[test]
707 fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
708 let mut meta = pr_valid_metadata();
709 meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
710 let req = publish_request_with_metadata(Some(meta));
711 let rev = KeyRevocation::from_publish_request(&req).unwrap();
712 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
713 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
714 }
715
716 #[test]
720 fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
721 const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
722 let mut meta = pr_valid_metadata();
723 meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
724 let req = publish_request_with_metadata(Some(meta));
725 let rev = KeyRevocation::from_publish_request(&req).unwrap();
726 assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
727 assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
728 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
729 }
730
731 #[test]
732 fn from_publish_request_controller_not_a_string_rejected() {
733 let mut meta = pr_valid_metadata();
734 meta["revoked_key_controller"] = serde_json::json!(42);
735 let req = publish_request_with_metadata(Some(meta));
736 assert!(matches!(
737 KeyRevocation::from_publish_request(&req),
738 Err(AcdpError::SchemaViolation(_))
739 ));
740 }
741
742 #[test]
743 fn from_publish_request_controller_invalid_did_rejected() {
744 let mut meta = pr_valid_metadata();
745 meta["revoked_key_controller"] = serde_json::json!("not-a-did");
746 let req = publish_request_with_metadata(Some(meta));
747 assert!(matches!(
748 KeyRevocation::from_publish_request(&req),
749 Err(AcdpError::SchemaViolation(_))
750 ));
751 }
752
753 fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
769 let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
770 let public_key = signing_key.verifying_key_bytes();
771 let did = acdp_did::key::did_key_from_ed25519(&public_key);
772 let key_id = acdp_did::key::did_key_url(&did).unwrap();
773 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
774 (key_id, fingerprint)
775 }
776
777 #[test]
783 fn from_publish_request_did_key_self_revocation_rejected() {
784 let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
785 let mut meta = pr_valid_metadata();
786 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
787 let mut req = publish_request_with_metadata(Some(meta));
788 req.signature.key_id = key_id;
789 assert!(matches!(
790 KeyRevocation::from_publish_request(&req),
791 Err(AcdpError::KeyNotAuthorized(_))
792 ));
793 }
794
795 #[test]
801 fn from_publish_request_did_key_different_key_accepted() {
802 let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
803 let meta = pr_valid_metadata(); let mut req = publish_request_with_metadata(Some(meta));
805 req.signature.key_id = key_id;
806 let rev = KeyRevocation::from_publish_request(&req)
807 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
808 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
809 }
810
811 #[test]
818 fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
819 let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
820 let malformed_key_id = format!("{}-not-the-msi", key_id); let mut meta = pr_valid_metadata();
822 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
823 let mut req = publish_request_with_metadata(Some(meta));
824 req.signature.key_id = malformed_key_id;
825 let rev = KeyRevocation::from_publish_request(&req).expect(
826 "malformed did:key key_id is left for signature verification, not rejected here",
827 );
828 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
829 }
830
831 fn registry_attested_rev(publisher: &str) -> KeyRevocation {
832 KeyRevocation {
833 revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
834 compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
835 reason: None,
836 revoked_key_id: None,
837 revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
838 publisher: AgentDid::new(publisher),
839 trust_class: RevocationTrustClass::RegistryAttested,
840 ctx_id: None,
841 }
842 }
843
844 #[test]
848 fn cross_check_registry_binding_success_and_both_failure_directions() {
849 let rev = registry_attested_rev("did:web:registry.example.com");
850
851 rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
852 .expect("serving authority and capabilities.registry_did both match publisher");
853
854 assert!(matches!(
856 rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
857 Err(AcdpError::KeyNotAuthorized(_))
858 ));
859
860 assert!(matches!(
862 rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
863 Err(AcdpError::KeyNotAuthorized(_))
864 ));
865 }
866
867 #[test]
872 fn cross_check_registry_binding_percent_encoded_port_authority() {
873 let rev = registry_attested_rev("did:web:localhost%3A8443");
874
875 rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
876 .expect("host:port authority round-trips through authority_to_did_web");
877
878 assert!(matches!(
881 rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
882 Err(AcdpError::KeyNotAuthorized(_))
883 ));
884 }
885
886 #[test]
887 fn fingerprint_form_edges() {
888 assert!(is_sha256_fingerprint(&format!(
889 "sha256:{}",
890 "a1".repeat(32)
891 )));
892 assert!(!is_sha256_fingerprint(&format!(
893 "sha256:{}",
894 "A1".repeat(32)
895 ))); assert!(!is_sha256_fingerprint(&format!(
897 "sha512:{}",
898 "a1".repeat(32)
899 ))); assert!(!is_sha256_fingerprint(&format!(
901 "sha256:{}",
902 "a1".repeat(31)
903 ))); assert!(!is_sha256_fingerprint("sha256:")); assert!(!is_sha256_fingerprint(&"a1".repeat(32))); }
907
908 #[test]
909 fn canonical_ms_timestamp_edges() {
910 assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
911 for bad in [
913 "2026-05-01T00:00:00Z", "2026-05-01T00:00:00.0Z", "2026-05-01T00:00:00.000000Z", "2026-05-01T00:00:00.000+00:00", "2026-05-01 00:00:00.000Z", "not-a-time",
919 ] {
920 assert!(
921 parse_canonical_ms(bad).is_none(),
922 "{bad:?} must be rejected"
923 );
924 }
925 }
926
927 const EB_FP: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
934 const EB_OTHER_FP: &str =
935 "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
936
937 fn eb_rev(fp: &str, t: &str) -> KeyRevocation {
938 KeyRevocation {
939 revoked_key_fingerprint: fp.into(),
940 compromised_since: DateTime::parse_from_rfc3339(t).unwrap().with_timezone(&Utc),
941 reason: None,
942 revoked_key_id: None,
943 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
944 publisher: AgentDid::new("did:web:agents.example.com:p"),
945 trust_class: RevocationTrustClass::ProducerSigned,
946 ctx_id: None,
947 }
948 }
949
950 #[test]
951 fn effective_boundary_empty_slice_is_none() {
952 let revs: [KeyRevocation; 0] = [];
953 assert_eq!(effective_boundary(&revs, EB_FP), None);
954 }
955
956 #[test]
957 fn effective_boundary_single_match() {
958 let revs = [eb_rev(EB_FP, "2026-05-01T00:00:00.000Z")];
959 assert_eq!(
960 effective_boundary(&revs, EB_FP),
961 Some(
962 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
963 .unwrap()
964 .with_timezone(&Utc)
965 )
966 );
967 }
968
969 #[test]
973 fn effective_boundary_min_folds_across_multiple_entries_same_fingerprint() {
974 let revs = [
975 eb_rev(EB_FP, "2026-06-01T00:00:00.000Z"),
976 eb_rev(EB_FP, "2026-04-01T00:00:00.000Z"), eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
978 ];
979 assert_eq!(
980 effective_boundary(&revs, EB_FP),
981 Some(
982 DateTime::parse_from_rfc3339("2026-04-01T00:00:00.000Z")
983 .unwrap()
984 .with_timezone(&Utc)
985 )
986 );
987 }
988
989 #[test]
993 fn effective_boundary_ignores_non_matching_fingerprints() {
994 let revs = [
995 eb_rev(EB_OTHER_FP, "2026-01-01T00:00:00.000Z"),
996 eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
997 eb_rev(EB_OTHER_FP, "2026-02-01T00:00:00.000Z"),
998 ];
999 assert_eq!(
1000 effective_boundary(&revs, EB_FP),
1001 Some(
1002 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1003 .unwrap()
1004 .with_timezone(&Utc)
1005 )
1006 );
1007 const UNRELATED_FP: &str =
1011 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1012 assert_eq!(effective_boundary(&revs, UNRELATED_FP), None);
1013 }
1014}