1use affinidi_data_integrity::DataIntegrityProof;
5#[cfg(feature = "affinidi-signing")]
6use affinidi_data_integrity::{DataIntegrityError, SignOptions, VerifyOptions};
7#[cfg(feature = "affinidi-signing")]
8use affinidi_secrets_resolver::secrets::Secret;
9use chrono::{DateTime, Utc};
10use multibase::Base;
11use serde::{Deserialize, Serialize, Serializer};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14use std::fmt::Display;
15use thiserror::Error;
16
17pub mod create;
18
19#[derive(Clone, Copy, Debug)]
21pub enum W3CVCVersion {
22 V1_1,
24
25 V2_0,
27}
28
29impl TryFrom<&[String]> for W3CVCVersion {
30 type Error = DTGCredentialError;
31
32 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
34 if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
35 Ok(W3CVCVersion::V1_1)
36 } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
37 Ok(W3CVCVersion::V2_0)
38 } else {
39 Err(DTGCredentialError::UnknownVCVersion)
40 }
41 }
42}
43
44#[derive(Error, Debug)]
46pub enum DTGCredentialError {
47 #[error("Unknown credential type")]
48 UnknownCredential,
49
50 #[cfg(feature = "affinidi-signing")]
51 #[error("Data Integrity Error: {0}")]
52 DataIntegrity(#[from] DataIntegrityError),
53
54 #[error("Credential is not signed")]
55 NotSigned,
56
57 #[error("Unknown W3C VC Version")]
58 UnknownVCVersion,
59
60 #[error("WitnessCredential is missing the required taskContext property")]
62 MissingTaskContext,
63
64 #[error("Could not canonicalize credential: {0}")]
66 Canonicalization(String),
67
68 #[error("Expected a {expected}, got a {got}")]
70 WrongCredentialType { expected: String, got: String },
71
72 #[error("Not a community-issued membership grant: {0}")]
75 NotAMembershipGrant(String),
76}
77
78#[derive(Serialize, Deserialize, Debug, Clone)]
80#[serde(try_from = "DTGCommon")]
81pub struct DTGCredential {
82 #[serde(flatten)]
84 credential: DTGCommon,
85
86 #[serde(skip)]
88 type_: DTGCredentialType,
89
90 #[serde(skip)]
92 version: W3CVCVersion,
93}
94
95impl DTGCredential {
96 pub fn credential(&self) -> &DTGCommon {
98 &self.credential
99 }
100
101 pub fn credential_mut(&mut self) -> &mut DTGCommon {
103 &mut self.credential
104 }
105
106 pub fn signed(&self) -> bool {
108 self.credential.signed()
109 }
110
111 pub fn type_(&self) -> DTGCredentialType {
113 self.type_.clone()
114 }
115
116 pub fn id(&self) -> Option<&str> {
122 self.credential.id()
123 }
124
125 pub fn issuer(&self) -> &str {
127 self.credential.issuer()
128 }
129
130 pub fn subject(&self) -> &str {
132 self.credential.subject()
133 }
134
135 pub fn valid_from(&self) -> DateTime<Utc> {
137 self.credential.valid_from()
138 }
139
140 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
142 self.credential.valid_until()
143 }
144
145 pub fn task_context(&self) -> Option<&str> {
150 self.credential.task_context()
151 }
152
153 pub fn digest(&self) -> Result<String, DTGCredentialError> {
181 let unsigned = DTGCommon {
182 proof: None,
183 ..self.credential.clone()
184 };
185 let value = serde_json::to_value(&unsigned)
186 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
187 digest_json(&value)
188 }
189
190 pub fn subject_digest(&self) -> Option<&str> {
196 match &self.credential.credential_subject {
197 CredentialSubject::Membership(subject) => subject.digest.as_deref(),
198 CredentialSubject::Witness(subject) => subject.digest.as_deref(),
199 _ => None,
200 }
201 }
202
203 #[deprecated(
209 since = "0.4.0",
210 note = "This encoding is not what DTG Core Credentials specifies, so digests \
211 produced by it do not interoperate. Use DTGCredential::digest, which \
212 returns the conformant `sha256:<lowercase hex>` over the proofless JCS \
213 canonical form. This method will be removed in a future release."
214 )]
215 pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
216 let canonical = serde_json_canonicalizer::to_vec(&self.credential)
217 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
218
219 let mut multihash = Vec::with_capacity(34);
221 multihash.extend_from_slice(&[0x12, 0x20]);
222 multihash.extend_from_slice(&Sha256::digest(&canonical));
223
224 Ok(multibase::encode(Base::Base58Btc, &multihash))
225 }
226
227 pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
237 let Some(digest) = self.subject_digest() else {
238 return Ok(false);
239 };
240
241 Ok(digest == referenced.digest()?)
242 }
243
244 pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
268 if !matches!(self.type_, DTGCredentialType::Membership)
269 || !matches!(grant.type_, DTGCredentialType::Membership)
270 {
271 return Ok(false);
272 }
273
274 if grant.subject_digest().is_some() {
277 return Ok(false);
278 }
279
280 if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
281 return Ok(false);
282 }
283
284 self.verify_digest(grant)
285 }
286
287 pub fn proof_value(&self) -> Option<&str> {
289 if let Some(proof) = &self.credential.proof {
290 proof.proof_value.as_deref()
291 } else {
292 None
293 }
294 }
295
296 #[cfg(feature = "affinidi-signing")]
297 pub async fn sign(
301 &mut self,
302 signing_secret: &Secret,
303 create_time: Option<DateTime<Utc>>,
304 ) -> Result<DataIntegrityProof, DTGCredentialError> {
305 let mut options = SignOptions::new();
306 if let Some(ts) = create_time {
307 options = options.with_created(ts);
308 }
309
310 let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
311
312 self.credential.proof = Some(proof.clone());
313 Ok(proof)
314 }
315
316 #[cfg(feature = "affinidi-signing")]
317 pub fn verify_proof_with_public_key(
321 &self,
322 public_key_bytes: &[u8],
323 ) -> Result<(), DTGCredentialError> {
324 let proof = if let Some(proof) = &self.credential.proof {
325 proof.clone()
326 } else {
327 use tracing::warn;
328
329 warn!("Trying to verify a DTG Credential that has no proof");
330 return Err(DTGCredentialError::NotSigned);
331 };
332
333 let unsigned = DTGCommon {
334 proof: None,
335 ..self.credential.clone()
336 };
337
338 proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
339 Ok(())
340 }
341
342 pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
344 self.version
345 }
346
347 pub fn is_personhood_credential(&self) -> bool {
349 if let DTGCredentialType::Membership = self.type_ {
350 self.credential
351 .type_
352 .contains(&"PersonhoodCredential".to_string())
353 } else {
354 false
355 }
356 }
357}
358
359pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
382 let proofless = match doc {
383 Value::Object(members) => {
384 let mut members = members.clone();
385 members.remove("proof");
386 Value::Object(members)
387 }
388 other => other.clone(),
391 };
392
393 let canonical = serde_json_canonicalizer::to_vec(&proofless)
394 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
395
396 const HEX: &[u8; 16] = b"0123456789abcdef";
397 let mut out = String::with_capacity("sha256:".len() + 64);
398 out.push_str("sha256:");
399 for byte in Sha256::digest(&canonical) {
400 out.push(HEX[(byte >> 4) as usize] as char);
401 out.push(HEX[(byte & 0x0f) as usize] as char);
402 }
403 Ok(out)
404}
405
406#[derive(Debug, Clone)]
408#[non_exhaustive]
409pub enum DTGCredentialType {
410 Membership,
411 Relationship,
412 Invitation,
413 Persona,
414 Endorsement,
415 Witness,
416
417 #[deprecated(
419 since = "0.2.0",
420 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
421 It was removed from the DTG Core Credentials specification in Working Draft 01 \
422 and will be defined by the planned DTG Verifiable Data Structures specification. \
423 This variant will be removed in a future release."
424 )]
425 RCard,
426}
427
428impl Display for DTGCredentialType {
429 #[allow(deprecated)]
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 match self {
432 DTGCredentialType::Membership => write!(f, "MembershipCredential"),
433 DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
434 DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
435 DTGCredentialType::Persona => write!(f, "PersonaCredential"),
436 DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
437 DTGCredentialType::Witness => write!(f, "WitnessCredential"),
438 DTGCredentialType::RCard => write!(f, "RCardCredential"),
439 }
440 }
441}
442
443const DTG_TYPES: [&str; 7] = [
445 "MembershipCredential",
446 "RelationshipCredential",
447 "InvitationCredential",
448 "PersonaCredential",
449 "EndorsementCredential",
450 "WitnessCredential",
451 "RCardCredential",
452];
453
454impl TryFrom<&[String]> for DTGCredentialType {
455 type Error = DTGCredentialError;
456
457 #[allow(deprecated)]
458 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
459 if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
460 match *type_ {
461 "MembershipCredential" => Ok(DTGCredentialType::Membership),
462 "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
463 "InvitationCredential" => Ok(DTGCredentialType::Invitation),
464 "PersonaCredential" => Ok(DTGCredentialType::Persona),
465 "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
466 "WitnessCredential" => Ok(DTGCredentialType::Witness),
467 "RCardCredential" => Ok(DTGCredentialType::RCard),
468 _ => Err(DTGCredentialError::UnknownCredential),
469 }
470 } else {
471 Err(DTGCredentialError::UnknownCredential)
472 }
473 }
474}
475
476#[derive(Serialize, Deserialize, Debug, Clone)]
478#[serde(rename_all = "camelCase")]
479pub struct DTGCommon {
480 #[serde(rename = "@context")]
485 pub context: Vec<String>,
486
487 #[serde(rename = "type")]
492 pub type_: Vec<String>,
493
494 #[serde(skip_serializing_if = "Option::is_none", default)]
511 pub id: Option<String>,
512
513 pub issuer: String,
515
516 #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
518 pub valid_from: DateTime<Utc>,
519
520 #[serde(serialize_with = "iso8601_format_option")]
522 #[serde(
523 skip_serializing_if = "Option::is_none",
524 alias = "expirationDate",
525 default
526 )]
527 pub valid_until: Option<DateTime<Utc>>,
528
529 #[serde(skip_serializing_if = "Option::is_none", default)]
539 pub task_context: Option<String>,
540
541 pub credential_subject: CredentialSubject,
543
544 #[serde(skip_serializing_if = "Option::is_none", default)]
546 pub proof: Option<DataIntegrityProof>,
547}
548
549impl DTGCommon {
550 pub fn signed(&self) -> bool {
554 self.proof.is_some()
555 }
556
557 pub fn id(&self) -> Option<&str> {
559 self.id.as_deref()
560 }
561
562 pub fn issuer(&self) -> &str {
564 &self.issuer
565 }
566
567 #[allow(deprecated)]
569 pub fn subject(&self) -> &str {
570 match &self.credential_subject {
571 CredentialSubject::Basic(subject) => &subject.id,
572 CredentialSubject::Endorsement(subject) => &subject.id,
573 CredentialSubject::Witness(subject) => &subject.id,
574 CredentialSubject::Membership(subject) => &subject.id,
575 CredentialSubject::RCard(subject) => &subject.id,
576 }
577 }
578
579 pub fn valid_from(&self) -> DateTime<Utc> {
581 self.valid_from
582 }
583
584 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
586 self.valid_until
587 }
588
589 pub fn task_context(&self) -> Option<&str> {
591 self.task_context.as_deref()
592 }
593}
594
595impl Default for DTGCommon {
597 fn default() -> Self {
598 DTGCommon {
599 context: vec![
600 "https://www.w3.org/ns/credentials/v2".to_string(),
601 "https://firstperson.network/credentials/dtg/v1".to_string(),
602 ],
603 type_: vec![
604 "VerifiableCredential".to_string(),
605 "DTGCredential".to_string(),
606 ],
607 id: None,
608 issuer: String::new(),
609 valid_from: Utc::now(),
610 valid_until: None,
611 task_context: None,
612 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
613 id: String::new(),
614 }),
615 proof: None,
616 }
617 }
618}
619
620impl TryFrom<DTGCommon> for DTGCredential {
622 type Error = DTGCredentialError;
623
624 #[allow(deprecated)]
625 fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
626 match &value.type_.as_slice().try_into()? {
627 DTGCredentialType::Membership => {
628 let subject = match &value.credential_subject {
633 CredentialSubject::Membership(subject) => subject.clone(),
636
637 CredentialSubject::Basic(subject) => CredentialSubjectMembership {
639 id: subject.id.clone(),
640 digest: None,
641 },
642
643 CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
649 CredentialSubjectMembership {
650 id: subject.id.clone(),
651 digest: subject.digest.clone(),
652 }
653 }
654
655 _ => return Err(DTGCredentialError::UnknownCredential),
656 };
657
658 Ok(DTGCredential {
659 type_: DTGCredentialType::Membership,
660 version: value.context.as_slice().try_into()?,
661 credential: DTGCommon {
662 credential_subject: CredentialSubject::Membership(subject),
663 ..value
664 },
665 })
666 }
667 DTGCredentialType::Relationship => Ok(DTGCredential {
668 type_: DTGCredentialType::Relationship,
669 version: value.context.as_slice().try_into()?,
670 credential: value,
671 }),
672 DTGCredentialType::Invitation => Ok(DTGCredential {
673 type_: DTGCredentialType::Invitation,
674 version: value.context.as_slice().try_into()?,
675 credential: value,
676 }),
677 DTGCredentialType::Persona => Ok(DTGCredential {
678 type_: DTGCredentialType::Persona,
679 version: value.context.as_slice().try_into()?,
680 credential: value,
681 }),
682 DTGCredentialType::Endorsement => {
683 if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
684 Ok(DTGCredential {
685 type_: DTGCredentialType::Endorsement,
686 version: value.context.as_slice().try_into()?,
687 credential: value,
688 })
689 } else {
690 Err(DTGCredentialError::UnknownCredential)
691 }
692 }
693 DTGCredentialType::Witness => {
694 if value.task_context.is_none() {
698 return Err(DTGCredentialError::MissingTaskContext);
699 }
700
701 match &value.credential_subject {
702 CredentialSubject::Witness(_) => Ok(DTGCredential {
703 type_: DTGCredentialType::Witness,
704 version: value.context.as_slice().try_into()?,
705 credential: value,
706 }),
707 CredentialSubject::Basic(subject) => {
708 Ok(DTGCredential {
710 type_: DTGCredentialType::Witness,
711 version: value.context.as_slice().try_into()?,
712 credential: DTGCommon {
713 credential_subject: CredentialSubject::Witness(
714 CredentialSubjectWitness {
715 id: subject.id.clone(),
716 digest: None,
717 witness_context: None,
718 },
719 ),
720 ..value
721 },
722 })
723 }
724 _ => Err(DTGCredentialError::UnknownCredential),
725 }
726 }
727 DTGCredentialType::RCard => match &value.credential_subject {
728 CredentialSubject::RCard { .. } => Ok(DTGCredential {
729 type_: DTGCredentialType::RCard,
730 version: value.context.as_slice().try_into()?,
731 credential: value,
732 }),
733 _ => Err(DTGCredentialError::UnknownCredential),
734 },
735 }
736 }
737}
738
739fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
742where
743 S: Serializer,
744{
745 s.serialize_str(
746 timestamp
747 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
748 .as_str(),
749 )
750}
751
752fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
753where
754 S: Serializer,
755{
756 if let Some(timestamp) = timestamp {
757 s.serialize_str(
758 timestamp
759 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
760 .as_str(),
761 )
762 } else {
763 s.serialize_none()
764 }
765}
766
767#[allow(deprecated)]
776#[derive(Serialize, Deserialize, Debug, Clone)]
777#[serde(untagged)]
778pub enum CredentialSubject {
779 Endorsement(CredentialSubjectEndorsement),
781
782 #[deprecated(
784 since = "0.2.0",
785 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
786 See DTGCredentialType::RCard. This variant will be removed in a future release."
787 )]
788 RCard(CredentialSubjectRCard),
789
790 Basic(CredentialSubjectBasic),
793
794 Witness(CredentialSubjectWitness),
796
797 Membership(CredentialSubjectMembership),
815}
816
817#[derive(Serialize, Deserialize, Debug, Clone)]
819#[serde(deny_unknown_fields)]
820pub struct CredentialSubjectBasic {
821 pub id: String,
822}
823
824#[derive(Serialize, Deserialize, Debug, Clone)]
832#[serde(rename_all = "camelCase", deny_unknown_fields)]
833pub struct CredentialSubjectMembership {
834 pub id: String,
835
836 #[serde(skip_serializing_if = "Option::is_none", default)]
843 pub digest: Option<String>,
844}
845
846#[derive(Serialize, Deserialize, Debug, Clone)]
848#[serde(deny_unknown_fields)]
849pub struct CredentialSubjectEndorsement {
850 pub id: String,
851 pub endorsement: Value,
853}
854
855#[derive(Serialize, Deserialize, Debug, Clone)]
857#[serde(rename_all = "camelCase", deny_unknown_fields)]
858pub struct CredentialSubjectWitness {
859 pub id: String,
860
861 #[serde(skip_serializing_if = "Option::is_none")]
862 pub digest: Option<String>,
863
864 #[serde(skip_serializing_if = "Option::is_none")]
866 pub witness_context: Option<WitnessContext>,
867}
868
869#[derive(Serialize, Deserialize, Debug, Clone)]
871#[serde(rename_all = "camelCase", deny_unknown_fields)]
872pub struct WitnessContext {
873 pub event: Option<String>,
875
876 pub session_id: Option<String>,
878
879 pub method: Option<String>,
881}
882
883#[deprecated(
885 since = "0.2.0",
886 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
887 See DTGCredentialType::RCard. This struct will be removed in a future release."
888)]
889#[derive(Serialize, Deserialize, Debug, Clone)]
890#[serde(deny_unknown_fields)]
891pub struct CredentialSubjectRCard {
892 pub id: String,
893
894 pub card: Value,
896}
897
898#[cfg(test)]
899#[allow(deprecated)]
900mod tests {
901 use crate::{
902 CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
903 DTGCredentialType, W3CVCVersion, digest_json,
904 };
905 use chrono::{DateTime, Utc};
906 use serde_json::Value;
907
908 #[test]
909 fn test_vmc_vc_1_deserialize() {
910 let vmc: DTGCredential = match serde_json::from_str(
912 r#"{
913"@context": [
914 "https://www.w3.org/2018/credentials/v1",
915 "https://firstperson.network/credentials/dtg/v1",
916 "https://w3id.org/security/suites/ed25519-2020/v1"
917 ],
918 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
919 "issuer": "did:web:chess-club.example",
920 "issuanceDate": "2026-01-06T10:00:00Z",
921 "expirationDate": "2027-01-06T10:00:00Z",
922 "credentialSubject": {
923 "id": "did:key:z6MkpTHR8VNs..."
924 }
925 }"#,
926 ) {
927 Ok(vmc) => vmc,
928 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
929 };
930
931 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
932 assert!(matches!(
933 vmc.credential().credential_subject,
934 CredentialSubject::Membership(_)
935 ));
936 assert!(matches!(vmc.version, W3CVCVersion::V1_1));
937 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
938 }
939
940 #[test]
941 fn test_missing_w3c_context() {
942 assert!(
944 serde_json::from_str::<DTGCredential>(
945 r#"{
946"@context": [
947 "https://firstperson.network/credentials/dtg/v1",
948 "https://w3id.org/security/suites/ed25519-2020/v1"
949 ],
950 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
951 "issuer": "did:web:chess-club.example",
952 "issuanceDate": "2026-01-06T10:00:00Z",
953 "expirationDate": "2027-01-06T10:00:00Z",
954 "credentialSubject": {
955 "id": "did:key:z6MkpTHR8VNs..."
956 }
957 }"#,
958 )
959 .is_err()
960 );
961 }
962
963 #[test]
964 fn test_mutable_credential() {
965 let mut vmc = DTGCredential::new_vmc(
966 "did:example:issuer".to_string(),
967 "did:example:subject".to_string(),
968 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
969 .unwrap()
970 .with_timezone(&Utc),
971 None,
972 false,
973 );
974
975 let cred = vmc.credential_mut();
976 cred.type_.push("PersonhoodCredential".to_string());
977 assert!(vmc.is_personhood_credential());
978 }
979
980 #[test]
981 fn test_vmc_deserialize() {
982 let vmc: DTGCredential = match serde_json::from_str(
983 r#"{
984 "@context": ["https://www.w3.org/ns/credentials/v2"],
985 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
986 "issuer": "did:example:community",
987 "validFrom": "2024-06-18T10:00:00Z",
988 "credentialSubject": { "id": "did:example:rDid" }
989 }"#,
990 ) {
991 Ok(vmc) => vmc,
992 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
993 };
994
995 assert!(!vmc.is_personhood_credential());
996 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
997 assert!(matches!(
998 vmc.credential().credential_subject,
999 CredentialSubject::Membership(_)
1000 ));
1001 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1002 }
1003
1004 #[test]
1005 fn test_vmc_phc_deserialize() {
1006 let vmc: DTGCredential = match serde_json::from_str(
1007 r#"{
1008 "@context": ["https://www.w3.org/ns/credentials/v2"],
1009 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
1010 "issuer": "did:example:community",
1011 "validFrom": "2024-06-18T10:00:00Z",
1012 "credentialSubject": { "id": "did:example:rDid" }
1013 }"#,
1014 ) {
1015 Ok(vmc) => vmc,
1016 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1017 };
1018
1019 assert!(vmc.is_personhood_credential());
1020 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1021 assert!(matches!(
1022 vmc.credential().credential_subject,
1023 CredentialSubject::Membership(_)
1024 ));
1025 }
1026
1027 #[test]
1028 fn test_vrc_deserialize() {
1029 let vrc: DTGCredential = match serde_json::from_str(
1030 r#"{
1031 "@context": ["https://www.w3.org/ns/credentials/v2"],
1032 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1033 "issuer": "did:example:governmentAgencyDid",
1034 "validFrom": "2024-06-18T10:00:00Z",
1035 "credentialSubject": { "id": "did:example:citizenRDid" }
1036 }"#,
1037 ) {
1038 Ok(vrc) => vrc,
1039 Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1040 };
1041
1042 assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1043 assert!(matches!(
1044 vrc.credential().credential_subject,
1045 CredentialSubject::Basic(_)
1046 ));
1047 }
1048
1049 #[test]
1050 fn test_vic_deserialize() {
1051 let vic: DTGCredential = match serde_json::from_str(
1052 r#"{
1053 "@context": ["https://www.w3.org/ns/credentials/v2"],
1054 "type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
1055 "issuer": "did:example:governmentAgencyVicDid",
1056 "validFrom": "2024-06-18T10:00:00Z",
1057 "credentialSubject": { "id": "did:example:citizenRDid" }
1058 }"#,
1059 ) {
1060 Ok(vic) => vic,
1061 Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1062 };
1063
1064 assert!(!vic.is_personhood_credential());
1065 assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1066 assert!(matches!(
1067 vic.credential().credential_subject,
1068 CredentialSubject::Basic(_)
1069 ));
1070 }
1071
1072 #[test]
1073 fn test_vpc_deserialize() {
1074 let vpc: DTGCredential = match serde_json::from_str(
1075 r#"{
1076 "@context": ["https://www.w3.org/ns/credentials/v2"],
1077 "type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
1078 "issuer": "did:example:governmentAgencyDid",
1079 "validFrom": "2024-06-18T10:00:00Z",
1080 "credentialSubject": { "id": "did:example:citizenRDid" }
1081 }"#,
1082 ) {
1083 Ok(vpc) => vpc,
1084 Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1085 };
1086
1087 assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1088 assert!(matches!(
1089 vpc.credential().credential_subject,
1090 CredentialSubject::Basic(_)
1091 ));
1092 }
1093
1094 #[test]
1095 fn test_vec_deserialize() {
1096 let vec: DTGCredential = match serde_json::from_str(
1097 r#"{
1098 "@context": ["https://www.w3.org/ns/credentials/v2"],
1099 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1100 "issuer": "did:example:governmentAgencyDid",
1101 "validFrom": "2024-06-18T10:00:00Z",
1102 "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1103 }"#,
1104 ) {
1105 Ok(vec) => vec,
1106 Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1107 };
1108
1109 assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1110 assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1111 assert!(matches!(
1112 vec.credential().credential_subject,
1113 CredentialSubject::Endorsement(_)
1114 ));
1115 }
1116
1117 #[test]
1118 fn test_vec_bad_deserialize() {
1119 match serde_json::from_str::<DTGCredential>(
1120 r#"{
1121 "@context": ["https://www.w3.org/ns/credentials/v2"],
1122 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1123 "issuer": "did:example:governmentAgencyDid",
1124 "validFrom": "2024-06-18T10:00:00Z",
1125 "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1126 }"#,
1127 ) {
1128 Ok(_) => panic!("Expected Unknown Credential type"),
1129 Err(_) => {
1130 }
1132 };
1133 }
1134
1135 #[test]
1136 fn test_vwc_simple_deserialize() {
1137 let vwc: DTGCredential = match serde_json::from_str(
1138 r#"{
1139 "@context": ["https://www.w3.org/ns/credentials/v2"],
1140 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1141 "issuer": "did:example:governmentAgencyDid",
1142 "validFrom": "2024-06-18T10:00:00Z",
1143 "taskContext": "thread-abc-123",
1144 "credentialSubject": { "id": "did:example:citizenRDid" }
1145 }"#,
1146 ) {
1147 Ok(vwc) => vwc,
1148 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1149 };
1150
1151 assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1152 assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1153 assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1154 assert!(matches!(
1155 vwc.credential().credential_subject,
1156 CredentialSubject::Witness(_)
1157 ));
1158 }
1159
1160 #[test]
1161 fn test_vwc_full_deserialize() {
1162 let vwc: DTGCredential = match serde_json::from_str(
1163 r#"{
1164 "@context": ["https://www.w3.org/ns/credentials/v2"],
1165 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1166 "issuer": "did:example:governmentAgencyDid",
1167 "validFrom": "2024-06-18T10:00:00Z",
1168 "taskContext": "thread-abc-123",
1169 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} }
1170 }"#,
1171 ) {
1172 Ok(vwc) => vwc,
1173 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1174 };
1175
1176 assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1177 assert!(matches!(
1178 vwc.credential().credential_subject,
1179 CredentialSubject::Witness(_)
1180 ));
1181 }
1182
1183 #[test]
1184 fn test_vwc_bad_deserialize() {
1185 if serde_json::from_str::<DTGCredential>(
1186 r#"{
1187 "@context": ["https://www.w3.org/ns/credentials/v2"],
1188 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1189 "issuer": "did:example:governmentAgencyDid",
1190 "validFrom": "2024-06-18T10:00:00Z",
1191 "taskContext": "thread-abc-123",
1192 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {} }
1193 }"#,
1194 ).is_ok() {
1195 panic!("Should have failed due to wrong CredentialSubject!");
1196 }
1197 }
1198
1199 #[test]
1200 fn test_rcard_simple_deserialize() {
1201 let rcard: DTGCredential = match serde_json::from_str(
1202 r#"{
1203 "@context": ["https://www.w3.org/ns/credentials/v2"],
1204 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1205 "issuer": "did:example:governmentAgencyDid",
1206 "validFrom": "2024-06-18T10:00:00Z",
1207 "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1208 }"#,
1209 ) {
1210 Ok(rcard) => rcard,
1211 Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1212 };
1213
1214 assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1215 assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1216 assert!(matches!(
1217 rcard.credential().credential_subject,
1218 CredentialSubject::RCard(_)
1219 ));
1220 }
1221
1222 #[test]
1223 fn test_rcard_bad_deserialize() {
1224 if serde_json::from_str::<DTGCredential>(
1225 r#"{
1226 "@context": ["https://www.w3.org/ns/credentials/v2"],
1227 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1228 "issuer": "did:example:governmentAgencyDid",
1229 "validFrom": "2024-06-18T10:00:00Z",
1230 "credentialSubject": { "id": "did:example:citizenRDid" }
1231 }"#,
1232 )
1233 .is_ok()
1234 {
1235 panic!("Should have failed due to wrong CredentialSubject!");
1236 }
1237 }
1238 #[test]
1239 fn test_deserialize_unknown() {
1240 match serde_json::from_str::<DTGCredential>(
1241 r#"{
1242 "@context": ["https://www.w3.org/ns/credentials/v2"],
1243 "type": ["VerifiableCredential", "DTGCredential", "UnknownCredential"],
1244 "issuer": "did:example:governmentAgencyDid",
1245 "validFrom": "2024-06-18T10:00:00Z",
1246 "credentialSubject": { "id": "did:example:citizenRDid" }
1247 }"#,
1248 ) {
1249 Ok(_) => panic!("Expected Unknown Credential type"),
1250 Err(e) => {
1251 if e.to_string() == "Unknown credential type" {
1252 } else {
1254 panic!("Wrong error type returned");
1255 }
1256 }
1257 };
1258 }
1259
1260 #[test]
1261 fn test_deserialize_mismatched_credential_subject() {
1262 match serde_json::from_str::<DTGCredential>(
1263 r#"{
1264 "@context": ["https://www.w3.org/ns/credentials/v2"],
1265 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1266 "issuer": "did:example:governmentAgencyDid",
1267 "validFrom": "2024-06-18T10:00:00Z",
1268 "credentialSubject": { "id": "did:example:citizenRDid" }
1269 }"#,
1270 ) {
1271 Ok(_) => panic!("Expected Unknown Credential type"),
1272 Err(e) => {
1273 if e.to_string() == "Unknown credential type" {
1274 } else {
1276 panic!("Wrong error type returned");
1277 }
1278 }
1279 };
1280 }
1281
1282 #[test]
1283 fn test_proof_signed() {
1284 let cred: DTGCredential = match serde_json::from_str(
1285 r#"{
1286 "@context": ["https://www.w3.org/ns/credentials/v2"],
1287 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1288 "issuer": "did:example:community",
1289 "validFrom": "2024-06-18T10:00:00Z",
1290 "credentialSubject": { "id": "did:example:rDid" },
1291 "proof": {
1292 "type": "DataIntegrityProof",
1293 "cryptosuite": "eddsa-jcs-2022",
1294 "created": "2025-12-04T00:00:00",
1295 "verificationMethod": "did:example:test#key-1",
1296 "proofPurpose": "assertionMethod",
1297 "proofValue": "abcd"
1298 }
1299 }"#,
1300 ) {
1301 Ok(vmc) => vmc,
1302 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1303 };
1304
1305 assert!(cred.signed());
1306 assert!(cred.proof_value().is_some());
1307 }
1308
1309 #[test]
1310 fn test_proof_not_signed() {
1311 let cred: DTGCredential = match serde_json::from_str(
1312 r#"{
1313 "@context": ["https://www.w3.org/ns/credentials/v2"],
1314 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1315 "issuer": "did:example:community",
1316 "validFrom": "2024-06-18T10:00:00Z",
1317 "credentialSubject": { "id": "did:example:rDid" }
1318 }"#,
1319 ) {
1320 Ok(vmc) => vmc,
1321 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1322 };
1323
1324 assert!(!cred.signed());
1325 assert!(cred.proof_value().is_none());
1326 }
1327
1328 #[test]
1329 fn test_helpers() {
1330 let cred: DTGCredential = match serde_json::from_str(
1331 r#"{
1332 "@context": ["https://www.w3.org/ns/credentials/v2"],
1333 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1334 "issuer": "did:example:issuer",
1335 "validFrom": "2024-06-18T00:00:00Z",
1336 "credentialSubject": { "id": "did:example:subject" }
1337 }"#,
1338 ) {
1339 Ok(vmc) => vmc,
1340 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1341 };
1342
1343 assert_eq!(cred.issuer(), "did:example:issuer");
1344 assert_eq!(cred.subject(), "did:example:subject");
1345 assert_eq!(
1346 cred.valid_from()
1347 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1348 "2024-06-18T00:00:00Z"
1349 );
1350 assert_eq!(cred.valid_until(), None);
1351 }
1352
1353 #[test]
1354 fn test_valid_until() {
1355 let cred: DTGCredential = match serde_json::from_str(
1356 r#"{
1357 "@context": ["https://www.w3.org/ns/credentials/v2"],
1358 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1359 "issuer": "did:example:issuer",
1360 "validFrom": "2024-06-18T00:00:00Z",
1361 "validUntil": "2030-01-01T00:00:00Z",
1362 "credentialSubject": { "id": "did:example:subject" }
1363 }"#,
1364 ) {
1365 Ok(vmc) => vmc,
1366 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1367 };
1368
1369 assert_eq!(
1370 cred.valid_until()
1371 .unwrap()
1372 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1373 "2030-01-01T00:00:00Z"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_bad_type() {
1379 assert!(
1380 std::convert::TryInto::<DTGCredentialType>::try_into(
1381 vec!["bad_type".to_string()].as_slice(),
1382 )
1383 .is_err()
1384 );
1385 }
1386
1387 #[test]
1388 fn test_badly_constructed_vwc() {
1389 let mut cred = DTGCommon::default();
1390 cred.type_.push("WitnessCredential".to_string());
1391 cred.task_context = Some("thread-abc-123".to_string());
1394 cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1395 id: "did:example:bad".to_string(),
1396 card: Value::Null,
1397 });
1398
1399 assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1400 }
1401
1402 #[test]
1403 fn test_vwc_missing_task_context() {
1404 match serde_json::from_str::<DTGCredential>(
1406 r#"{
1407 "@context": ["https://www.w3.org/ns/credentials/v2"],
1408 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1409 "issuer": "did:example:witness",
1410 "validFrom": "2024-06-18T10:00:00Z",
1411 "credentialSubject": { "id": "did:example:observed" }
1412 }"#,
1413 ) {
1414 Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1415 Err(e) => assert_eq!(
1416 e.to_string(),
1417 "WitnessCredential is missing the required taskContext property"
1418 ),
1419 }
1420 }
1421
1422 #[test]
1423 fn test_task_context_round_trip() {
1424 let raw = r#"{
1427 "@context": ["https://www.w3.org/ns/credentials/v2"],
1428 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1429 "issuer": "did:example:witness",
1430 "validFrom": "2024-06-18T10:00:00Z",
1431 "taskContext": "thread-abc-123",
1432 "credentialSubject": { "id": "did:example:observed" }
1433 }"#;
1434
1435 let cred: DTGCredential = serde_json::from_str(raw).unwrap();
1436 let out = serde_json::to_string(&cred).unwrap();
1437
1438 assert!(out.contains(r#""taskContext":"thread-abc-123""#));
1439 }
1440
1441 #[test]
1442 fn test_task_context_optional_on_other_types() {
1443 let vrc: DTGCredential = serde_json::from_str(
1445 r#"{
1446 "@context": ["https://www.w3.org/ns/credentials/v2"],
1447 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1448 "issuer": "did:example:issuer",
1449 "validFrom": "2024-06-18T10:00:00Z",
1450 "credentialSubject": { "id": "did:example:subject" }
1451 }"#,
1452 )
1453 .unwrap();
1454
1455 assert_eq!(vrc.task_context(), None);
1456 assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
1458 }
1459
1460 #[test]
1461 fn test_digest_multibase() {
1462 let vrc = DTGCredential::new_vrc(
1463 "did:example:issuer".to_string(),
1464 "did:example:subject".to_string(),
1465 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1466 .unwrap()
1467 .with_timezone(&Utc),
1468 None,
1469 );
1470
1471 let digest = vrc.digest_multibase().unwrap();
1472
1473 assert!(digest.starts_with('z'));
1475
1476 let (base, bytes) = multibase::decode(&digest).unwrap();
1478 assert_eq!(base, multibase::Base::Base58Btc);
1479 assert_eq!(bytes.len(), 34);
1480 assert_eq!(&bytes[..2], &[0x12, 0x20]);
1481
1482 assert_eq!(digest, vrc.digest_multibase().unwrap());
1484
1485 let other = DTGCredential::new_vrc(
1487 "did:example:issuer".to_string(),
1488 "did:example:someone-else".to_string(),
1489 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1490 .unwrap()
1491 .with_timezone(&Utc),
1492 None,
1493 );
1494 assert_ne!(digest, other.digest_multibase().unwrap());
1495 }
1496
1497 #[test]
1498 fn test_verify_digest() {
1499 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1500 .unwrap()
1501 .with_timezone(&Utc);
1502
1503 let vrc = DTGCredential::new_vrc(
1504 "did:example:issuer".to_string(),
1505 "did:example:subject".to_string(),
1506 valid_from,
1507 None,
1508 );
1509
1510 let vwc = DTGCredential::new_vwc(
1511 "did:example:witness".to_string(),
1512 "did:example:issuer".to_string(),
1514 valid_from,
1515 None,
1516 "thread-abc-123".to_string(),
1517 Some(vrc.digest().unwrap()),
1518 None,
1519 );
1520
1521 assert!(vwc.verify_digest(&vrc).unwrap());
1522
1523 let other = DTGCredential::new_vrc(
1525 "did:example:issuer".to_string(),
1526 "did:example:someone-else".to_string(),
1527 valid_from,
1528 None,
1529 );
1530 assert!(!vwc.verify_digest(&other).unwrap());
1531 }
1532
1533 #[test]
1534 fn test_verify_digest_without_digest() {
1535 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1536 .unwrap()
1537 .with_timezone(&Utc);
1538
1539 let vrc = DTGCredential::new_vrc(
1540 "did:example:issuer".to_string(),
1541 "did:example:subject".to_string(),
1542 valid_from,
1543 None,
1544 );
1545
1546 let vwc = DTGCredential::new_vwc(
1548 "did:example:witness".to_string(),
1549 "did:example:issuer".to_string(),
1550 valid_from,
1551 None,
1552 "thread-abc-123".to_string(),
1553 None,
1554 None,
1555 );
1556
1557 assert!(!vwc.verify_digest(&vrc).unwrap());
1558 }
1559
1560 #[test]
1565 fn test_digest_is_sha256_hex_over_the_proofless_jcs_form() {
1566 let vmc = DTGCredential::new_vmc(
1567 "did:example:community".to_string(),
1568 "did:example:member".to_string(),
1569 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1570 .unwrap()
1571 .with_timezone(&Utc),
1572 None,
1573 false,
1574 )
1575 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1576
1577 let digest = vmc.digest().unwrap();
1578
1579 let (scheme, hex) = digest.split_once(':').expect("`sha256:` prefixed");
1580 assert_eq!(scheme, "sha256");
1581 assert_eq!(hex.len(), 64, "32 bytes, hex encoded");
1582 assert!(
1583 hex.chars()
1584 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1585 "lowercase hex only, got {hex}"
1586 );
1587
1588 assert_eq!(
1594 digest,
1595 "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
1596 );
1597
1598 assert_eq!(digest, vmc.digest().unwrap());
1600 }
1601
1602 #[cfg(feature = "affinidi-signing")]
1606 #[tokio::test]
1607 async fn test_digest_is_unchanged_by_signing() {
1608 use affinidi_secrets_resolver::secrets::Secret;
1609
1610 let secret = Secret::generate_ed25519(None, None);
1611
1612 let mut vmc = DTGCredential::new_vmc(
1613 "did:example:community".to_string(),
1614 "did:example:member".to_string(),
1615 Utc::now(),
1616 None,
1617 false,
1618 );
1619
1620 let before = vmc.digest().unwrap();
1621 vmc.sign(&secret, None).await.expect("signs");
1622 assert!(vmc.signed());
1623 assert_eq!(before, vmc.digest().unwrap());
1624 }
1625
1626 fn wire(c: &DTGCredential) -> Value {
1628 serde_json::to_value(c.credential()).expect("credential serialises")
1629 }
1630
1631 #[test]
1634 fn test_member_vmc_acknowledges_its_grant() {
1635 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1636 .unwrap()
1637 .with_timezone(&Utc);
1638
1639 let grant = DTGCredential::new_vmc(
1640 "did:example:community".to_string(),
1641 "did:example:member".to_string(),
1642 valid_from,
1643 None,
1644 false,
1645 );
1646
1647 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1648
1649 assert_eq!(ack.issuer(), "did:example:member");
1651 assert_eq!(ack.subject(), "did:example:community");
1652
1653 assert_eq!(grant.subject_digest(), None);
1655 assert_eq!(ack.subject_digest(), Some(grant.digest().unwrap().as_str()));
1656
1657 assert!(ack.acknowledges(&grant).unwrap());
1658 }
1659
1660 #[test]
1663 fn test_acknowledges_rejects_a_mismatched_pair() {
1664 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1665 .unwrap()
1666 .with_timezone(&Utc);
1667
1668 let grant = DTGCredential::new_vmc(
1669 "did:example:community".to_string(),
1670 "did:example:member".to_string(),
1671 valid_from,
1672 None,
1673 false,
1674 );
1675 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1676
1677 let other_member = DTGCredential::new_vmc(
1679 "did:example:community".to_string(),
1680 "did:example:someone-else".to_string(),
1681 valid_from,
1682 None,
1683 false,
1684 );
1685 assert!(!ack.acknowledges(&other_member).unwrap());
1686
1687 let other_community = DTGCredential::new_vmc(
1689 "did:example:other-community".to_string(),
1690 "did:example:member".to_string(),
1691 valid_from,
1692 None,
1693 false,
1694 );
1695 assert!(!ack.acknowledges(&other_community).unwrap());
1696
1697 let renewed = DTGCredential::new_vmc(
1701 "did:example:community".to_string(),
1702 "did:example:member".to_string(),
1703 valid_from + chrono::Duration::days(365),
1704 None,
1705 false,
1706 );
1707 assert!(!ack.acknowledges(&renewed).unwrap());
1708
1709 let ack_of_ack =
1711 DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1712 assert!(!ack_of_ack.acknowledges(&ack).unwrap());
1713
1714 assert!(!grant.acknowledges(&grant).unwrap());
1716 }
1717
1718 #[test]
1730 fn the_acknowledgement_digests_members_the_model_does_not_know() {
1731 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1732 .unwrap()
1733 .with_timezone(&Utc);
1734
1735 let mut grant = wire(&DTGCredential::new_vmc(
1736 "did:example:community".to_string(),
1737 "did:example:member".to_string(),
1738 valid_from,
1739 None,
1740 false,
1741 ));
1742 grant["credentialStatus"] = serde_json::json!({
1743 "id": "https://community.example/status#7",
1744 "type": "BitstringStatusListEntry",
1745 "statusPurpose": "revocation",
1746 "statusListIndex": "7"
1747 });
1748
1749 let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
1751 assert!(
1752 wire(&parsed).get("credentialStatus").is_none(),
1753 "the model is expected NOT to carry credentialStatus; if it now does, this \
1754 test has stopped guarding anything and the API can be simplified"
1755 );
1756
1757 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1758
1759 assert_eq!(
1760 ack.subject_digest(),
1761 Some(digest_json(&grant).unwrap().as_str()),
1762 "the acknowledgement must digest the grant as received"
1763 );
1764 assert_ne!(
1765 ack.subject_digest(),
1766 Some(parsed.digest().unwrap().as_str()),
1767 "digesting the parsed model would produce a digest the community cannot match"
1768 );
1769 }
1770
1771 #[test]
1775 fn digest_json_agrees_with_digest_where_the_model_is_complete() {
1776 let vmc = DTGCredential::new_vmc(
1777 "did:example:community".to_string(),
1778 "did:example:member".to_string(),
1779 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1780 .unwrap()
1781 .with_timezone(&Utc),
1782 None,
1783 false,
1784 )
1785 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1786
1787 assert_eq!(vmc.digest().unwrap(), digest_json(&wire(&vmc)).unwrap());
1788 }
1789
1790 #[test]
1793 fn test_acknowledges_is_membership_only() {
1794 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1795 .unwrap()
1796 .with_timezone(&Utc);
1797
1798 let grant = DTGCredential::new_vmc(
1799 "did:example:community".to_string(),
1800 "did:example:member".to_string(),
1801 valid_from,
1802 None,
1803 false,
1804 );
1805 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1806
1807 let vrc = DTGCredential::new_vrc(
1808 "did:example:member".to_string(),
1809 "did:example:community".to_string(),
1810 valid_from,
1811 None,
1812 );
1813 assert!(!ack.acknowledges(&vrc).unwrap());
1814
1815 let vwc = DTGCredential::new_vwc(
1817 "did:example:witness".to_string(),
1818 "did:example:community".to_string(),
1819 valid_from,
1820 None,
1821 "thread-abc-123".to_string(),
1822 Some(grant.digest().unwrap()),
1823 None,
1824 );
1825 assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
1826 assert!(
1827 !vwc.acknowledges(&grant).unwrap(),
1828 "but a VWC is not the member's acknowledgement"
1829 );
1830 }
1831
1832 #[test]
1836 fn test_new_member_vmc_refuses_a_non_grant() {
1837 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1838 .unwrap()
1839 .with_timezone(&Utc);
1840
1841 let vrc = DTGCredential::new_vrc(
1842 "did:example:a".to_string(),
1843 "did:example:b".to_string(),
1844 valid_from,
1845 None,
1846 );
1847 assert!(matches!(
1848 DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
1849 Err(DTGCredentialError::NotAMembershipGrant(_))
1850 ));
1851
1852 let grant = DTGCredential::new_vmc(
1853 "did:example:community".to_string(),
1854 "did:example:member".to_string(),
1855 valid_from,
1856 None,
1857 false,
1858 );
1859 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1860 assert!(matches!(
1861 DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
1862 Err(DTGCredentialError::NotAMembershipGrant(_))
1863 ));
1864 }
1865
1866 #[test]
1871 fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
1872 let vmc: DTGCredential = serde_json::from_str(
1873 r#"{
1874 "@context": ["https://www.w3.org/ns/credentials/v2"],
1875 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1876 "issuer": "did:example:member",
1877 "validFrom": "2024-06-18T10:00:00Z",
1878 "credentialSubject": {
1879 "id": "did:example:community",
1880 "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1881 }
1882 }"#,
1883 )
1884 .expect("deserializes");
1885
1886 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1887 assert!(matches!(
1888 vmc.credential().credential_subject,
1889 CredentialSubject::Membership(_)
1890 ));
1891 assert_eq!(
1892 vmc.subject_digest(),
1893 Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
1894 );
1895 assert_eq!(vmc.subject(), "did:example:community");
1896 }
1897
1898 #[test]
1901 fn test_membership_credential_rejects_a_witness_context() {
1902 let result: Result<DTGCredential, _> = serde_json::from_str(
1903 r#"{
1904 "@context": ["https://www.w3.org/ns/credentials/v2"],
1905 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1906 "issuer": "did:example:member",
1907 "validFrom": "2024-06-18T10:00:00Z",
1908 "credentialSubject": {
1909 "id": "did:example:community",
1910 "digest": "sha256:e3b0c4",
1911 "witnessContext": { "event": "not a membership property" }
1912 }
1913 }"#,
1914 );
1915 assert!(result.is_err());
1916 }
1917
1918 #[test]
1921 fn test_the_two_halves_round_trip_over_the_wire() {
1922 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1923 .unwrap()
1924 .with_timezone(&Utc);
1925
1926 let grant = DTGCredential::new_vmc(
1927 "did:example:community".to_string(),
1928 "did:example:member".to_string(),
1929 valid_from,
1930 None,
1931 false,
1932 );
1933 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1934
1935 let grant_json = serde_json::to_value(&grant).unwrap();
1936 assert!(
1937 grant_json["credentialSubject"].get("digest").is_none(),
1938 "the grant MUST omit `digest`: {grant_json}"
1939 );
1940
1941 let ack_json = serde_json::to_value(&ack).unwrap();
1942 assert_eq!(
1943 ack_json["credentialSubject"]["digest"],
1944 Value::String(grant.digest().unwrap()),
1945 );
1946
1947 let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
1950 let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
1951 assert!(ack.acknowledges(&grant).unwrap());
1952 }
1953
1954 #[test]
1955 fn test_iso8601_format_option() {
1956 let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
1957 &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1958 )
1959 .unwrap()
1960 .to_utc();
1961 let cred = DTGCommon {
1962 valid_until: Some(now),
1963 ..Default::default()
1964 };
1965
1966 let value = serde_json::to_value(&cred).unwrap();
1967 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1968 assert_eq!(cred2.valid_until, Some(now));
1969
1970 let cred = DTGCommon::default();
1971 let value = serde_json::to_value(&cred).unwrap();
1972 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1973 assert_eq!(cred2.valid_until, None);
1974 }
1975
1976 #[cfg(feature = "affinidi-signing")]
1977 #[tokio::test]
1978 async fn test_signing() {
1979 use affinidi_secrets_resolver::secrets::Secret;
1980
1981 let secret = Secret::generate_ed25519(None, None);
1982
1983 let mut cred = DTGCredential::new_vrc(
1984 "did:example:issuer".to_string(),
1985 "did:example:subject".to_string(),
1986 Utc::now(),
1987 None,
1988 );
1989
1990 assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
1991
1992 assert!(
1993 cred.verify_proof_with_public_key(secret.get_public_bytes())
1994 .is_ok()
1995 );
1996
1997 let secret2 = Secret::generate_ed25519(None, None);
1998 assert!(
1999 cred.verify_proof_with_public_key(secret2.get_public_bytes())
2000 .is_err()
2001 );
2002 }
2003
2004 #[cfg(feature = "affinidi-signing")]
2012 #[tokio::test]
2013 async fn test_id_is_covered_by_the_proof() {
2014 use affinidi_secrets_resolver::secrets::Secret;
2015
2016 let secret = Secret::generate_ed25519(None, None);
2017
2018 let mut cred = DTGCredential::new_vrc(
2019 "did:example:issuer".to_string(),
2020 "did:example:subject".to_string(),
2021 Utc::now(),
2022 None,
2023 )
2024 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2025
2026 cred.sign(&secret, Some(Utc::now()))
2027 .await
2028 .expect("signing a credential that carries an id");
2029 assert!(
2030 cred.verify_proof_with_public_key(secret.get_public_bytes())
2031 .is_ok(),
2032 "an id set before signing verifies"
2033 );
2034
2035 cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2038 assert!(
2039 cred.verify_proof_with_public_key(secret.get_public_bytes())
2040 .is_err(),
2041 "an id changed after signing must break the proof"
2042 );
2043 }
2044
2045 #[cfg(feature = "affinidi-signing")]
2046 #[tokio::test]
2047 async fn test_signing_error() {
2048 use affinidi_secrets_resolver::secrets::Secret;
2049
2050 let secret = Secret::generate_x25519(None, None).unwrap();
2051
2052 let mut cred = DTGCredential::new_vrc(
2053 "did:example:issuer".to_string(),
2054 "did:example:subject".to_string(),
2055 Utc::now(),
2056 None,
2057 );
2058
2059 assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2060 }
2061
2062 #[cfg(feature = "affinidi-signing")]
2063 #[test]
2064 fn test_signing_no_proof() {
2065 use crate::DTGCredentialError;
2066 use affinidi_secrets_resolver::secrets::Secret;
2067
2068 let cred = DTGCredential::new_vrc(
2069 "did:example:issuer".to_string(),
2070 "did:example:subject".to_string(),
2071 Utc::now(),
2072 None,
2073 );
2074
2075 let secret = Secret::generate_ed25519(None, None);
2076 match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2077 Err(DTGCredentialError::NotSigned) => {
2078 }
2080 _ => panic!("Expected NotSigned error!"),
2081 }
2082 }
2083}