1use acdp_crypto::{verify_content_hash, verify_ecdsa_p256, verify_ed25519};
11use acdp_primitives::error::AcdpError;
12use acdp_types::body::{Body, Signature};
13use acdp_types::lifecycle::LifecycleEvent;
14use acdp_types::primitives::{AgentDid, ContentHash, CtxId};
15use acdp_types::publish::PublishRequest;
16
17#[cfg(feature = "client")]
18use acdp_did::web::WebResolver;
19
20#[cfg(feature = "client")]
22pub struct Verifier<'a> {
23 resolver: &'a WebResolver,
24}
25
26#[cfg(feature = "client")]
27impl<'a> Verifier<'a> {
28 pub fn new(resolver: &'a WebResolver) -> Self {
29 Self { resolver }
30 }
31
32 #[cfg_attr(
45 feature = "tracing",
46 tracing::instrument(
47 name = "acdp.verify_body",
48 skip_all,
49 fields(ctx_id = %body.ctx_id.0, agent_id = body.agent_id.as_str()),
50 err(Display)
51 )
52 )]
53 pub async fn verify_body(&self, body: &Body) -> Result<(), AcdpError> {
54 acdp_validation::validate_body(body)?;
59
60 self.verify_body_signed(body).await
61 }
62
63 #[cfg_attr(
71 feature = "tracing",
72 tracing::instrument(
73 name = "acdp.verify_body_signed",
74 skip_all,
75 fields(ctx_id = %body.ctx_id.0),
76 err(Display)
77 )
78 )]
79 pub async fn verify_body_signed(&self, body: &Body) -> Result<(), AcdpError> {
80 self.verify_body_hash(body)?;
81 #[cfg(feature = "tracing")]
82 tracing::debug!(
83 stage = "content_hash",
84 "content hash recomputed and matched"
85 );
86 self.verify_body_signature(body).await?;
87 #[cfg(feature = "tracing")]
88 tracing::debug!(stage = "signature", "producer signature verified");
89 Ok(())
90 }
91
92 pub fn verify_body_hash(&self, body: &Body) -> Result<(), AcdpError> {
97 let body_val = serde_json::to_value(body)?;
98 verify_content_hash(&body_val, &body.content_hash)
99 }
100
101 pub async fn verify_body_signature(&self, body: &Body) -> Result<(), AcdpError> {
106 verify_signature_envelope(
107 &body.agent_id,
108 &body.signature,
109 &body.content_hash,
110 self.resolver,
111 )
112 .await
113 }
114}
115
116#[cfg(feature = "client")]
129#[cfg_attr(
130 feature = "tracing",
131 tracing::instrument(
132 name = "acdp.verify_publish_request_signature",
133 skip_all,
134 fields(agent_id = req.agent_id.as_str(), key_id = %req.signature.key_id),
135 err(Display)
136 )
137)]
138pub async fn verify_publish_request_signature(
139 req: &PublishRequest,
140 resolver: &WebResolver,
141) -> Result<(), AcdpError> {
142 verify_signature_envelope(&req.agent_id, &req.signature, &req.content_hash, resolver).await
143}
144
145#[cfg(feature = "client")]
150async fn verify_signature_envelope(
151 agent_id: &AgentDid,
152 signature: &Signature,
153 content_hash: &ContentHash,
154 resolver: &WebResolver,
155) -> Result<(), AcdpError> {
156 let key_id = &signature.key_id;
160 let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
161 AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
162 })?;
163 if fragment.is_empty() {
164 return Err(AcdpError::KeyResolution(format!(
165 "signature.key_id '{key_id}' has an empty '#fragment'"
166 )));
167 }
168
169 if did_part != agent_id.as_str() {
171 return Err(AcdpError::KeyNotAuthorized(format!(
172 "key_id DID '{did_part}' ≠ agent_id '{agent_id}'"
173 )));
174 }
175
176 if did_part.starts_with("did:key:") {
181 return verify_did_key_envelope(signature, content_hash);
182 }
183 if !did_part.starts_with("did:web:") {
184 return Err(AcdpError::KeyNotAuthorized(format!(
185 "signatures require a did:web or did:key key_id; got '{did_part}'"
186 )));
187 }
188
189 let doc = resolver.resolve(did_part).await?;
191
192 let method = doc.find_by_fragment(fragment).ok_or_else(|| {
194 AcdpError::KeyResolution(format!(
195 "no verification method with fragment '#{fragment}'"
196 ))
197 })?;
198
199 if !doc.is_assertion_method(&method.id) {
201 return Err(AcdpError::KeyNotAuthorized(format!(
202 "'{}' is not in assertionMethod",
203 method.id
204 )));
205 }
206
207 if let Some(declared) = method.declared_algorithm() {
213 if declared != signature.algorithm {
214 return Err(AcdpError::InvalidSignature(format!(
215 "signature.algorithm '{}' does not match verification method type \
216 (resolved key declares '{declared}')",
217 signature.algorithm
218 )));
219 }
220 }
221
222 match signature.algorithm.as_str() {
224 "ed25519" => {
225 let pub_bytes = method.ed25519_public_key_bytes()?;
226 verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
227 }
228 "ecdsa-p256" => {
229 let pub_sec1 = method.ecdsa_p256_public_key_sec1()?;
230 verify_ecdsa_p256(&pub_sec1, &signature.value, content_hash.as_str())
231 }
232 other => Err(AcdpError::UnsupportedAlgorithm(format!(
233 "verifier does not support signature algorithm '{other}'"
234 ))),
235 }
236}
237
238pub fn verify_did_key_envelope(
254 signature: &Signature,
255 content_hash: &ContentHash,
256) -> Result<(), AcdpError> {
257 let material = acdp_did::key::resolve_did_key_url(&signature.key_id)?;
258
259 if material.algorithm() != signature.algorithm {
260 return Err(AcdpError::InvalidSignature(format!(
261 "signature.algorithm '{}' does not match the did:key multicodec \
262 (key implies '{}')",
263 signature.algorithm,
264 material.algorithm()
265 )));
266 }
267
268 match material {
269 acdp_did::key::DidKeyMaterial::Ed25519(pub_bytes) => {
270 verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
271 }
272 acdp_did::key::DidKeyMaterial::EcdsaP256(sec1_compressed) => {
273 verify_ecdsa_p256(&sec1_compressed, &signature.value, content_hash.as_str())
274 }
275 }
276}
277
278pub fn verify_body_offline(body: &Body) -> Result<(), AcdpError> {
292 acdp_validation::validate_body(body)?;
293
294 if !body.agent_id.as_str().starts_with("did:key:") {
295 return Err(AcdpError::KeyResolution(format!(
296 "verify_body_offline supports did:key producers only; '{}' requires \
297 the resolver-backed Verifier (client feature)",
298 body.agent_id
299 )));
300 }
301
302 let body_val = serde_json::to_value(body)?;
303 verify_content_hash(&body_val, &body.content_hash)?;
304
305 let did_part = body
306 .signature
307 .key_id
308 .split_once('#')
309 .map(|(d, _)| d)
310 .unwrap_or(body.signature.key_id.as_str());
311 if did_part != body.agent_id.as_str() {
312 return Err(AcdpError::KeyNotAuthorized(format!(
313 "key_id DID '{did_part}' ≠ agent_id '{}'",
314 body.agent_id
315 )));
316 }
317
318 verify_did_key_envelope(&body.signature, &body.content_hash)
319}
320
321pub fn verify_publish_request_signature_offline(req: &PublishRequest) -> Result<(), AcdpError> {
327 let key_id = req.signature.key_id.as_str();
328 let did_part = key_id.split_once('#').map(|(d, _)| d).unwrap_or(key_id);
329 if did_part != req.agent_id.as_str() {
330 return Err(AcdpError::KeyNotAuthorized(format!(
331 "key_id DID '{did_part}' ≠ agent_id '{}'",
332 req.agent_id
333 )));
334 }
335 if !did_part.starts_with("did:key:") {
336 return Err(AcdpError::KeyResolution(format!(
337 "offline verification supports did:key only; got '{did_part}'"
338 )));
339 }
340 verify_did_key_envelope(&req.signature, &req.content_hash)
341}
342
343#[cfg(feature = "client")]
355pub async fn verify_body_signature_historical(
356 body: &Body,
357 resolver: &WebResolver,
358) -> Result<(), AcdpError> {
359 let key_id = &body.signature.key_id;
360 let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
361 AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
362 })?;
363 if did_part != body.agent_id.as_str() {
364 return Err(AcdpError::KeyNotAuthorized(format!(
365 "key_id DID '{did_part}' ≠ agent_id '{}'",
366 body.agent_id
367 )));
368 }
369 if !did_part.starts_with("did:web:") {
370 return Err(AcdpError::KeyResolution(format!(
371 "historical-key verification applies to did:web only; got '{did_part}'"
372 )));
373 }
374 let doc = resolver.resolve(did_part).await?;
375 let method = doc.find_by_fragment(fragment).ok_or_else(|| {
380 AcdpError::KeyResolution(format!(
381 "no verification method with fragment '#{fragment}' — the key was \
382 removed from the DID document, not just rotated out of assertionMethod"
383 ))
384 })?;
385 if let Some(declared) = method.declared_algorithm() {
386 if declared != body.signature.algorithm {
387 return Err(AcdpError::InvalidSignature(format!(
388 "signature.algorithm '{}' does not match verification method type \
389 (resolved key declares '{declared}')",
390 body.signature.algorithm
391 )));
392 }
393 }
394 match body.signature.algorithm.as_str() {
395 "ed25519" => verify_ed25519(
396 &method.ed25519_public_key_bytes()?,
397 &body.signature.value,
398 body.content_hash.as_str(),
399 ),
400 "ecdsa-p256" => verify_ecdsa_p256(
401 &method.ecdsa_p256_public_key_sec1()?,
402 &body.signature.value,
403 body.content_hash.as_str(),
404 ),
405 other => Err(AcdpError::UnsupportedAlgorithm(format!(
406 "verifier does not support signature algorithm '{other}'"
407 ))),
408 }
409}
410
411pub fn verify_ctx_id_binding(served_ctx_id: &str, expected_ctx_id: &str) -> Result<(), AcdpError> {
445 let served = CtxId::parse(served_ctx_id)?;
446 let expected = CtxId::parse(expected_ctx_id)?;
447 if served != expected {
448 return Err(AcdpError::ContextIdMismatch {
449 requested: expected.as_str().to_string(),
450 served: served.as_str().to_string(),
451 });
452 }
453 Ok(())
454}
455
456fn lifecycle_event_prechecks(
478 raw_event: &serde_json::Value,
479 expected_ctx_id: &CtxId,
480 producer_did: &AgentDid,
481 registry_did: Option<&str>,
482) -> Result<(LifecycleEvent, ContentHash), AcdpError> {
483 let hash = LifecycleEvent::preimage_hash_of_value(raw_event)?;
484 let event = LifecycleEvent::from_value(raw_event)?;
485 if &event.ctx_id != expected_ctx_id {
486 return Err(AcdpError::SchemaViolation(format!(
487 "lifecycle event ctx_id '{}' ≠ the context's ctx_id '{expected_ctx_id}' \
488 (RFC-ACDP-0013 §4: an event binds to exactly one context)",
489 event.ctx_id
490 )));
491 }
492 let is_producer = event.actor.as_str() == producer_did.as_str();
493 let is_registry = registry_did.is_some_and(|did| event.actor.as_str() == did);
494 if !is_producer && !is_registry {
495 return Err(AcdpError::NotAuthorized(format!(
496 "lifecycle event actor '{}' is neither the producer '{producer_did}' nor the \
497 registry DID — only the producer and the serving registry can record \
498 lifecycle events (RFC-ACDP-0013 §4, §12)",
499 event.actor
500 )));
501 }
502 event.actor_bound_signature()?;
504 Ok((event, hash))
505}
506
507#[cfg(feature = "client")]
526pub async fn verify_lifecycle_event(
527 raw_event: &serde_json::Value,
528 expected_ctx_id: &CtxId,
529 producer_did: &AgentDid,
530 registry_did: Option<&str>,
531 resolver: &WebResolver,
532) -> Result<LifecycleEvent, AcdpError> {
533 let (event, hash) =
534 lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
535 let signature = event.actor_bound_signature()?.clone();
540 verify_signature_envelope(&event.actor, &signature, &hash, resolver).await?;
541 Ok(event)
542}
543
544pub fn verify_lifecycle_event_offline(
549 raw_event: &serde_json::Value,
550 expected_ctx_id: &CtxId,
551 producer_did: &AgentDid,
552 registry_did: Option<&str>,
553) -> Result<LifecycleEvent, AcdpError> {
554 let (event, hash) =
555 lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
556 if !event.actor.as_str().starts_with("did:key:") {
557 return Err(AcdpError::KeyResolution(format!(
558 "offline lifecycle-event verification supports did:key actors only; '{}' \
559 requires the resolver-backed verify_lifecycle_event (client feature)",
560 event.actor
561 )));
562 }
563 let signature = event.actor_bound_signature()?;
564 verify_did_key_envelope(signature, &hash)?;
565 Ok(event)
566}
567
568#[cfg(test)]
576mod offline_tests {
577 use super::*;
578 use acdp_crypto::{P256SigningKey, SigningKey};
579 use acdp_producer::Producer;
580 use acdp_types::body::{Body, DataPeriod};
581 use acdp_types::lifecycle::{LifecycleEvent, LifecycleEventType};
582 use acdp_types::{AgentDid, ContextType, CtxId, LineageId, Visibility};
583
584 const CTX: &str = "acdp://registry.example.com/00000000-0000-4000-8000-000000000000";
585 const LIN: &str = "lin:sha256:0000000000000000000000000000000000000000000000000000000000000000";
586 const EVENT_ID: &str = "00000000-0000-4000-8000-0000000000aa";
587
588 fn ts() -> chrono::DateTime<chrono::Utc> {
589 chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap()
590 }
591
592 fn body_of(producer: &Producer) -> Body {
593 let req = producer
594 .publish_request()
595 .title("offline verify test")
596 .context_type(ContextType::DataSnapshot)
597 .visibility(Visibility::Public)
598 .build()
599 .expect("valid request");
600 Body::from_publish_request(
601 &req,
602 CtxId(CTX.into()),
603 LineageId(LIN.into()),
604 "registry.example.com",
605 ts(),
606 )
607 }
608
609 fn ed25519_body() -> Body {
610 body_of(&Producer::new_did_key(SigningKey::from_bytes(&[1u8; 32])))
611 }
612
613 fn p256_body() -> Body {
614 let key = P256SigningKey::from_bytes(&[2u8; 32]).expect("valid p256 scalar");
615 body_of(&Producer::new_did_key_p256(key).expect("did:key p256 producer"))
616 }
617
618 fn didweb_body() -> Body {
619 body_of(&Producer::new(
620 SigningKey::from_bytes(&[3u8; 32]),
621 AgentDid::new("did:web:agents.example.com:p"),
622 "did:web:agents.example.com:p#key-1",
623 ))
624 }
625
626 #[test]
629 fn did_key_envelope_ed25519_and_p256_happy() {
630 let b = ed25519_body();
631 assert!(verify_did_key_envelope(&b.signature, &b.content_hash).is_ok());
632 let p = p256_body();
633 assert!(verify_did_key_envelope(&p.signature, &p.content_hash).is_ok());
634 }
635
636 #[test]
637 fn did_key_envelope_algorithm_downgrade_rejected() {
638 let b = ed25519_body();
641 let mut sig = b.signature.clone();
642 sig.algorithm = "ecdsa-p256".into();
643 assert!(matches!(
644 verify_did_key_envelope(&sig, &b.content_hash),
645 Err(AcdpError::InvalidSignature(_))
646 ));
647 }
648
649 #[test]
650 fn did_key_envelope_malformed_key_id_errors() {
651 let b = ed25519_body();
652 let mut sig = b.signature.clone();
653 sig.key_id = sig.key_id.split('#').next().unwrap().to_string();
655 assert!(verify_did_key_envelope(&sig, &b.content_hash).is_err());
656 }
657
658 #[test]
659 fn did_key_envelope_tampered_signature_rejected() {
660 let b = ed25519_body();
664 let other = body_of(&Producer::new_did_key(SigningKey::from_bytes(&[9u8; 32])));
665 let mut sig = b.signature.clone();
666 sig.value = other.signature.value.clone();
667 assert!(matches!(
668 verify_did_key_envelope(&sig, &b.content_hash),
669 Err(AcdpError::InvalidSignature(_))
670 ));
671 }
672
673 #[test]
676 fn body_offline_happy_ed25519_and_p256() {
677 assert!(verify_body_offline(&ed25519_body()).is_ok());
678 assert!(verify_body_offline(&p256_body()).is_ok());
679 }
680
681 #[test]
682 fn body_offline_structural_failure_precedes_hash() {
683 let mut b = ed25519_body();
687 b.data_period = Some(DataPeriod {
688 start: ts(),
689 end: ts() - chrono::Duration::days(1),
690 });
691 assert!(matches!(
692 verify_body_offline(&b),
693 Err(AcdpError::SchemaViolation(_))
694 ));
695 }
696
697 #[test]
698 fn body_offline_rejects_did_web_producer() {
699 assert!(matches!(
700 verify_body_offline(&didweb_body()),
701 Err(AcdpError::KeyResolution(_))
702 ));
703 }
704
705 #[test]
706 fn body_offline_tampered_field_fails_hash() {
707 let mut b = ed25519_body();
710 b.title = "tampered title".into();
711 assert!(verify_body_offline(&b).is_err());
712 }
713
714 #[test]
715 fn body_offline_key_id_did_must_equal_agent_id() {
716 let mut b = ed25519_body();
720 let other = p256_body();
721 b.signature.key_id = other.signature.key_id.clone();
722 assert!(matches!(
723 verify_body_offline(&b),
724 Err(AcdpError::KeyNotAuthorized(_))
725 ));
726 }
727
728 #[test]
729 fn body_offline_fragmentless_key_id_reaches_envelope_error() {
730 let mut b = ed25519_body();
734 b.signature.key_id = b.agent_id.as_str().to_string();
735 assert!(verify_body_offline(&b).is_err());
736 }
737
738 #[test]
741 fn publish_request_offline_happy() {
742 let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
743 let req = producer
744 .publish_request()
745 .title("pr offline")
746 .context_type(ContextType::DataSnapshot)
747 .visibility(Visibility::Public)
748 .build()
749 .expect("valid request");
750 assert!(verify_publish_request_signature_offline(&req).is_ok());
751 }
752
753 #[test]
754 fn publish_request_offline_did_mismatch_and_did_web() {
755 let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
756 let mut req = producer
757 .publish_request()
758 .title("pr offline")
759 .context_type(ContextType::DataSnapshot)
760 .visibility(Visibility::Public)
761 .build()
762 .expect("valid request");
763 let orig = req.signature.key_id.clone();
764 req.signature.key_id = p256_body().signature.key_id.clone();
766 assert!(matches!(
767 verify_publish_request_signature_offline(&req),
768 Err(AcdpError::KeyNotAuthorized(_))
769 ));
770 req.agent_id = AgentDid::new("did:web:agents.example.com:p");
772 req.signature.key_id = "did:web:agents.example.com:p#key-1".into();
773 let _ = orig;
774 assert!(matches!(
775 verify_publish_request_signature_offline(&req),
776 Err(AcdpError::KeyResolution(_))
777 ));
778 }
779
780 fn identity(seed: &[u8; 32]) -> (AgentDid, String) {
783 let key = SigningKey::from_bytes(seed);
784 let did = acdp_did::key::did_key_from_ed25519(&key.verifying_key_bytes());
785 let key_id = acdp_did::key::did_key_url(&did).expect("did:key url");
786 (AgentDid::new(did), key_id)
787 }
788
789 fn signed_event(seed: &[u8; 32], actor: AgentDid, key_id: String) -> serde_json::Value {
790 let event = LifecycleEvent::new(
791 EVENT_ID,
792 CtxId(CTX.into()),
793 LifecycleEventType::Retracted,
794 ts(),
795 actor,
796 Some("superseded".into()),
797 )
798 .expect("valid event")
799 .sign_with(SigningKey::from_bytes(seed), key_id)
800 .expect("signed event");
801 serde_json::to_value(&event).expect("event serializes")
802 }
803
804 #[test]
805 fn lifecycle_offline_happy_producer_actor() {
806 let (actor, key_id) = identity(&[5u8; 32]);
807 let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
808 let out = verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None);
809 assert!(out.is_ok());
810 }
811
812 #[test]
813 fn lifecycle_offline_registry_actor_accepted() {
814 let (actor, key_id) = identity(&[6u8; 32]);
815 let raw = signed_event(&[6u8; 32], actor.clone(), key_id);
816 let producer = AgentDid::new("did:key:z6MkOtherProducerDidThatIsNotTheActor");
818 let out = verify_lifecycle_event_offline(
819 &raw,
820 &CtxId(CTX.into()),
821 &producer,
822 Some(actor.as_str()),
823 );
824 assert!(out.is_ok());
825 }
826
827 #[test]
828 fn lifecycle_offline_unknown_member_rejected() {
829 let (actor, key_id) = identity(&[5u8; 32]);
830 let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
831 raw.as_object_mut()
832 .unwrap()
833 .insert("unexpected".into(), serde_json::json!(1));
834 assert!(matches!(
835 verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
836 Err(AcdpError::SchemaViolation(_))
837 ));
838 }
839
840 #[test]
841 fn lifecycle_offline_ctx_id_mismatch_rejected() {
842 let (actor, key_id) = identity(&[5u8; 32]);
843 let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
844 let other =
845 CtxId("acdp://registry.example.com/11111111-1111-4111-8111-111111111111".into());
846 assert!(matches!(
847 verify_lifecycle_event_offline(&raw, &other, &actor, None),
848 Err(AcdpError::SchemaViolation(_))
849 ));
850 }
851
852 #[test]
853 fn lifecycle_offline_unauthorized_actor_rejected() {
854 let (actor, key_id) = identity(&[5u8; 32]);
855 let raw = signed_event(&[5u8; 32], actor, key_id);
856 let stranger = AgentDid::new("did:key:z6MkStrangerNeitherProducerNorRegistry");
857 assert!(matches!(
859 verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &stranger, None),
860 Err(AcdpError::NotAuthorized(_))
861 ));
862 assert!(matches!(
864 verify_lifecycle_event_offline(
865 &raw,
866 &CtxId(CTX.into()),
867 &stranger,
868 Some("did:key:z6MkSomeOtherRegistry")
869 ),
870 Err(AcdpError::NotAuthorized(_))
871 ));
872 }
873
874 #[test]
875 fn lifecycle_offline_unsigned_event_rejected() {
876 let (actor, _key_id) = identity(&[5u8; 32]);
877 let event = LifecycleEvent::new(
878 EVENT_ID,
879 CtxId(CTX.into()),
880 LifecycleEventType::Retracted,
881 ts(),
882 actor.clone(),
883 Some("superseded".into()),
884 )
885 .expect("valid event");
886 let raw = serde_json::to_value(&event).expect("serializes");
887 assert!(verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None).is_err());
888 }
889
890 #[test]
891 fn lifecycle_offline_did_web_actor_rejected() {
892 let actor = AgentDid::new("did:web:agents.example.com:p");
893 let key_id = "did:web:agents.example.com:p#key-1".to_string();
894 let raw = signed_event(&[7u8; 32], actor.clone(), key_id);
895 assert!(matches!(
896 verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
897 Err(AcdpError::KeyResolution(_))
898 ));
899 }
900
901 #[test]
902 fn lifecycle_offline_mutated_raw_json_fails_signature() {
903 let (actor, key_id) = identity(&[5u8; 32]);
906 let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
907 raw.as_object_mut()
908 .unwrap()
909 .insert("reason".into(), serde_json::json!("changed after signing"));
910 assert!(matches!(
911 verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
912 Err(AcdpError::InvalidSignature(_))
913 ));
914 }
915
916 const OTHER_CTX: &str = "acdp://registry.example.com/11111111-1111-4111-8111-111111111111";
919
920 #[test]
921 fn ctx_id_binding_matching_ids_ok() {
922 assert!(verify_ctx_id_binding(CTX, CTX).is_ok());
924 }
925
926 #[test]
927 fn ctx_id_binding_mismatch_rejected_with_both_fields() {
928 match verify_ctx_id_binding(OTHER_CTX, CTX) {
929 Err(AcdpError::ContextIdMismatch { requested, served }) => {
930 assert_eq!(requested, CTX);
931 assert_eq!(served, OTHER_CTX);
932 }
933 other => panic!("expected ContextIdMismatch, got {other:?}"),
934 }
935 }
936
937 #[test]
938 fn ctx_id_binding_non_canonical_expected_is_schema_violation_not_silent_pass() {
939 let uppercase_authority =
944 "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
945 assert!(matches!(
946 verify_ctx_id_binding(CTX, uppercase_authority),
947 Err(AcdpError::SchemaViolation(_))
948 ));
949
950 let uppercase_uuid = "acdp://registry.example.com/00000000-0000-4000-8000-000000000AAA";
951 assert!(matches!(
952 verify_ctx_id_binding(CTX, uppercase_uuid),
953 Err(AcdpError::SchemaViolation(_))
954 ));
955
956 let missing_prefix = "registry.example.com/00000000-0000-4000-8000-000000000000";
957 assert!(matches!(
958 verify_ctx_id_binding(CTX, missing_prefix),
959 Err(AcdpError::SchemaViolation(_))
960 ));
961
962 let malformed_uuid = "acdp://registry.example.com/not-a-uuid";
963 assert!(matches!(
964 verify_ctx_id_binding(CTX, malformed_uuid),
965 Err(AcdpError::SchemaViolation(_))
966 ));
967 }
968
969 #[test]
970 fn ctx_id_binding_non_canonical_served_is_schema_violation() {
971 let uppercase_authority =
972 "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
973 assert!(matches!(
974 verify_ctx_id_binding(uppercase_authority, CTX),
975 Err(AcdpError::SchemaViolation(_))
976 ));
977 }
978
979 #[test]
980 fn ctx_id_binding_both_non_canonical_errors_without_panic() {
981 let bad_served = "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
982 let bad_expected = "not-acdp-at-all";
983 assert!(matches!(
984 verify_ctx_id_binding(bad_served, bad_expected),
985 Err(AcdpError::SchemaViolation(_))
986 ));
987 }
988
989 #[test]
990 fn ctx_id_binding_empty_string_either_side_errors_without_panic() {
991 assert!(matches!(
992 verify_ctx_id_binding("", CTX),
993 Err(AcdpError::SchemaViolation(_))
994 ));
995 assert!(matches!(
996 verify_ctx_id_binding(CTX, ""),
997 Err(AcdpError::SchemaViolation(_))
998 ));
999 assert!(matches!(
1000 verify_ctx_id_binding("", ""),
1001 Err(AcdpError::SchemaViolation(_))
1002 ));
1003 }
1004}