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 authority;
18pub mod create;
19pub mod delegation;
20
21#[derive(Clone, Copy, Debug)]
23pub enum W3CVCVersion {
24 V1_1,
26
27 V2_0,
29}
30
31impl TryFrom<&[String]> for W3CVCVersion {
32 type Error = DTGCredentialError;
33
34 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
36 if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
37 Ok(W3CVCVersion::V1_1)
38 } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
39 Ok(W3CVCVersion::V2_0)
40 } else {
41 Err(DTGCredentialError::UnknownVCVersion)
42 }
43 }
44}
45
46#[derive(Error, Debug)]
48pub enum DTGCredentialError {
49 #[error("Unknown credential type")]
50 UnknownCredential,
51
52 #[cfg(feature = "affinidi-signing")]
53 #[error("Data Integrity Error: {0}")]
54 DataIntegrity(#[from] DataIntegrityError),
55
56 #[error("Credential is not signed")]
57 NotSigned,
58
59 #[error("Unknown W3C VC Version")]
60 UnknownVCVersion,
61
62 #[error("AuthorityCredential carries an empty actions list, which confers nothing")]
67 EmptyAuthorityActions,
68
69 #[error("not an AuthorityCredential, so there is no authority to attenuate")]
71 NotAnAuthorityCredential,
72
73 #[deprecated(
79 since = "0.7.0",
80 note = "Never returned. `authority.parent` is a digest as of Working Draft 02, so a \
81 parent VAC no longer needs an `id` to be attenuated. This variant will be \
82 removed in a future release."
83 )]
84 #[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
85 AttenuationParentHasNoId,
86
87 #[error("not a well-formed digestMultibase value: {0}")]
93 InvalidDigest(String),
94
95 #[error("digest uses multihash algorithm 0x{0:x}, which this library does not accept")]
101 UnsupportedDigestAlgorithm(u64),
102
103 #[error("malformed DelegationCredential: {0}")]
105 MalformedDelegation(String),
106
107 #[error("Not a delegation grant: {0}")]
110 NotADelegationGrant(String),
111
112 #[error("attenuation would widen the parent grant: {0}")]
114 AttenuationWidens(String),
115
116 #[error("WitnessCredential is missing the required taskContext property")]
118 MissingTaskContext,
119
120 #[error("Could not canonicalize credential: {0}")]
122 Canonicalization(String),
123
124 #[error("Expected a {expected}, got a {got}")]
126 WrongCredentialType { expected: String, got: String },
127
128 #[error("Not a community-issued membership grant: {0}")]
131 NotAMembershipGrant(String),
132}
133
134#[derive(Serialize, Deserialize, Debug, Clone)]
136#[serde(try_from = "DTGCommon")]
137pub struct DTGCredential {
138 #[serde(flatten)]
140 credential: DTGCommon,
141
142 #[serde(skip)]
144 type_: DTGCredentialType,
145
146 #[serde(skip)]
148 version: W3CVCVersion,
149}
150
151impl DTGCredential {
152 pub fn credential(&self) -> &DTGCommon {
154 &self.credential
155 }
156
157 pub fn credential_mut(&mut self) -> &mut DTGCommon {
159 &mut self.credential
160 }
161
162 pub fn signed(&self) -> bool {
164 self.credential.signed()
165 }
166
167 pub fn type_(&self) -> DTGCredentialType {
169 self.type_.clone()
170 }
171
172 pub fn id(&self) -> Option<&str> {
178 self.credential.id()
179 }
180
181 pub fn issuer(&self) -> &str {
183 self.credential.issuer()
184 }
185
186 pub fn subject(&self) -> &str {
188 self.credential.subject()
189 }
190
191 pub fn valid_from(&self) -> DateTime<Utc> {
193 self.credential.valid_from()
194 }
195
196 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
198 self.credential.valid_until()
199 }
200
201 pub fn task_context(&self) -> Option<&str> {
206 self.credential.task_context()
207 }
208
209 pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
237 let unsigned = DTGCommon {
238 proof: None,
239 ..self.credential.clone()
240 };
241 let value = serde_json::to_value(&unsigned)
242 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
243 digest_multibase_json(&value)
244 }
245
246 #[deprecated(
248 since = "0.7.0",
249 note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc \
250 multibase multihash under the property name `digestMultibase`. Use \
251 DTGCredential::digest_multibase. This method will be removed in a future \
252 release."
253 )]
254 pub fn digest(&self) -> Result<String, DTGCredentialError> {
255 let unsigned = DTGCommon {
256 proof: None,
257 ..self.credential.clone()
258 };
259 let value = serde_json::to_value(&unsigned)
260 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
261 #[allow(deprecated)]
262 digest_json(&value)
263 }
264
265 pub fn subject_digest(&self) -> Option<&str> {
273 match &self.credential.credential_subject {
274 CredentialSubject::Membership(subject) => subject.digest_multibase.as_deref(),
275 CredentialSubject::Witness(subject) => subject.digest_multibase.as_deref(),
276 CredentialSubject::Authority(subject) => subject.authority.parent.as_deref(),
277 CredentialSubject::Delegation(subject) => subject
278 .delegation
279 .accepts
280 .as_deref()
281 .or(subject.delegation.parent.as_deref()),
282 _ => None,
283 }
284 }
285
286 pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
312 let Some(carried) = self.subject_digest() else {
313 return Ok(false);
314 };
315
316 digests_match(carried, &referenced.digest_multibase()?)
317 }
318
319 pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
343 if !matches!(self.type_, DTGCredentialType::Membership)
344 || !matches!(grant.type_, DTGCredentialType::Membership)
345 {
346 return Ok(false);
347 }
348
349 if grant.subject_digest().is_some() {
352 return Ok(false);
353 }
354
355 if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
356 return Ok(false);
357 }
358
359 self.verify_digest(grant)
360 }
361
362 pub fn accepts(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
385 if !matches!(self.type_, DTGCredentialType::Delegation)
386 || !matches!(grant.type_, DTGCredentialType::Delegation)
387 {
388 return Ok(false);
389 }
390
391 let (Some(acceptance), Some(appointment)) =
392 (self.credential.delegation(), grant.credential.delegation())
393 else {
394 return Ok(false);
395 };
396
397 if appointment.accepts.is_some() || appointment.scope.is_none() {
400 return Ok(false);
401 }
402 let Some(carried) = &acceptance.accepts else {
403 return Ok(false);
404 };
405
406 if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
407 return Ok(false);
408 }
409
410 digests_match(carried, &grant.digest_multibase()?)
411 }
412
413 pub fn proof_value(&self) -> Option<&str> {
415 if let Some(proof) = &self.credential.proof {
416 proof.proof_value.as_deref()
417 } else {
418 None
419 }
420 }
421
422 #[cfg(feature = "affinidi-signing")]
423 pub async fn sign(
427 &mut self,
428 signing_secret: &Secret,
429 create_time: Option<DateTime<Utc>>,
430 ) -> Result<DataIntegrityProof, DTGCredentialError> {
431 let mut options = SignOptions::new();
432 if let Some(ts) = create_time {
433 options = options.with_created(ts);
434 }
435
436 let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
437
438 self.credential.proof = Some(proof.clone());
439 Ok(proof)
440 }
441
442 #[cfg(feature = "affinidi-signing")]
443 pub fn verify_proof_with_public_key(
447 &self,
448 public_key_bytes: &[u8],
449 ) -> Result<(), DTGCredentialError> {
450 let proof = if let Some(proof) = &self.credential.proof {
451 proof.clone()
452 } else {
453 use tracing::warn;
454
455 warn!("Trying to verify a DTG Credential that has no proof");
456 return Err(DTGCredentialError::NotSigned);
457 };
458
459 let unsigned = DTGCommon {
460 proof: None,
461 ..self.credential.clone()
462 };
463
464 proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
465 Ok(())
466 }
467
468 pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
470 self.version
471 }
472
473 pub fn is_personhood_credential(&self) -> bool {
475 if let DTGCredentialType::Membership = self.type_ {
476 self.credential
477 .type_
478 .contains(&"PersonhoodCredential".to_string())
479 } else {
480 false
481 }
482 }
483}
484
485const MULTIHASH_SHA2_256: u64 = 0x12;
489
490fn proofless(doc: &Value) -> Value {
492 match doc {
493 Value::Object(members) => {
494 let mut members = members.clone();
495 members.remove("proof");
496 Value::Object(members)
497 }
498 other => other.clone(),
501 }
502}
503
504pub fn digest_multibase_json(doc: &Value) -> Result<String, DTGCredentialError> {
535 let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
536 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
537
538 let digest = Sha256::digest(&canonical);
539
540 let mut multihash = Vec::with_capacity(2 + digest.len());
543 multihash.push(MULTIHASH_SHA2_256 as u8);
544 multihash.push(digest.len() as u8);
545 multihash.extend_from_slice(&digest);
546
547 Ok(multibase::encode(Base::Base58Btc, &multihash))
548}
549
550pub fn decode_digest_multibase(digest: &str) -> Result<(u64, Vec<u8>), DTGCredentialError> {
564 let (_, bytes) = multibase::decode(digest)
565 .map_err(|e| DTGCredentialError::InvalidDigest(format!("multibase: {e}")))?;
566
567 let (&code, rest) = bytes
571 .split_first()
572 .ok_or_else(|| DTGCredentialError::InvalidDigest("empty multihash".into()))?;
573 if code & 0x80 != 0 {
574 return Err(DTGCredentialError::InvalidDigest(
575 "multi-byte multihash code, which names no algorithm this library accepts".into(),
576 ));
577 }
578 let (&length, raw) = rest
579 .split_first()
580 .ok_or_else(|| DTGCredentialError::InvalidDigest("multihash has no length".into()))?;
581
582 if code as u64 != MULTIHASH_SHA2_256 {
583 return Err(DTGCredentialError::UnsupportedDigestAlgorithm(code as u64));
584 }
585 if length as usize != raw.len() {
586 return Err(DTGCredentialError::InvalidDigest(format!(
587 "multihash declares {length} bytes but carries {}",
588 raw.len()
589 )));
590 }
591
592 Ok((code as u64, raw.to_vec()))
593}
594
595pub fn digests_match(left: &str, right: &str) -> Result<bool, DTGCredentialError> {
600 Ok(decode_digest_multibase(left)? == decode_digest_multibase(right)?)
601}
602
603#[deprecated(
605 since = "0.7.0",
606 note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc multibase \
607 multihash under the property name `digestMultibase`. Use \
608 digest_multibase_json. This function will be removed in a future release."
609)]
610pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
611 let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
612 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
613
614 const HEX: &[u8; 16] = b"0123456789abcdef";
615 let mut out = String::with_capacity("sha256:".len() + 64);
616 out.push_str("sha256:");
617 for byte in Sha256::digest(&canonical) {
618 out.push(HEX[(byte >> 4) as usize] as char);
619 out.push(HEX[(byte & 0x0f) as usize] as char);
620 }
621 Ok(out)
622}
623
624#[derive(Debug, Clone, PartialEq, Eq)]
630#[non_exhaustive]
631pub enum DTGCredentialType {
632 Membership,
633 Relationship,
634 Invitation,
635 Persona,
636 Endorsement,
637 Witness,
638
639 Authority,
649
650 Delegation,
656
657 #[deprecated(
659 since = "0.2.0",
660 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
661 It was removed from the DTG Core Credentials specification in Working Draft 01 \
662 and will be defined by the planned DTG Verifiable Data Structures specification. \
663 This variant will be removed in a future release."
664 )]
665 RCard,
666}
667
668impl Display for DTGCredentialType {
669 #[allow(deprecated)]
670 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
671 match self {
672 DTGCredentialType::Membership => write!(f, "MembershipCredential"),
673 DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
674 DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
675 DTGCredentialType::Persona => write!(f, "PersonaCredential"),
676 DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
677 DTGCredentialType::Witness => write!(f, "WitnessCredential"),
678 DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
679 DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
680 DTGCredentialType::RCard => write!(f, "RCardCredential"),
681 }
682 }
683}
684
685const DTG_TYPES: [&str; 9] = [
687 "MembershipCredential",
688 "RelationshipCredential",
689 "InvitationCredential",
690 "PersonaCredential",
691 "EndorsementCredential",
692 "WitnessCredential",
693 "AuthorityCredential",
694 "DelegationCredential",
695 "RCardCredential",
696];
697
698impl TryFrom<&[String]> for DTGCredentialType {
699 type Error = DTGCredentialError;
700
701 #[allow(deprecated)]
702 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
703 if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
704 match *type_ {
705 "MembershipCredential" => Ok(DTGCredentialType::Membership),
706 "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
707 "InvitationCredential" => Ok(DTGCredentialType::Invitation),
708 "PersonaCredential" => Ok(DTGCredentialType::Persona),
709 "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
710 "WitnessCredential" => Ok(DTGCredentialType::Witness),
711 "AuthorityCredential" => Ok(DTGCredentialType::Authority),
712 "DelegationCredential" => Ok(DTGCredentialType::Delegation),
713 "RCardCredential" => Ok(DTGCredentialType::RCard),
714 _ => Err(DTGCredentialError::UnknownCredential),
715 }
716 } else {
717 Err(DTGCredentialError::UnknownCredential)
718 }
719 }
720}
721
722#[derive(Serialize, Deserialize, Debug, Clone)]
724#[serde(rename_all = "camelCase")]
725pub struct DTGCommon {
726 #[serde(rename = "@context")]
731 pub context: Vec<String>,
732
733 #[serde(rename = "type")]
738 pub type_: Vec<String>,
739
740 #[serde(skip_serializing_if = "Option::is_none", default)]
757 pub id: Option<String>,
758
759 pub issuer: String,
761
762 #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
764 pub valid_from: DateTime<Utc>,
765
766 #[serde(serialize_with = "iso8601_format_option")]
768 #[serde(
769 skip_serializing_if = "Option::is_none",
770 alias = "expirationDate",
771 default
772 )]
773 pub valid_until: Option<DateTime<Utc>>,
774
775 #[serde(skip_serializing_if = "Option::is_none", default)]
785 pub task_context: Option<String>,
786
787 pub credential_subject: CredentialSubject,
789
790 #[serde(skip_serializing_if = "Option::is_none", default)]
810 pub credential_status: Option<Value>,
811
812 #[serde(skip_serializing_if = "Option::is_none", default)]
814 pub proof: Option<DataIntegrityProof>,
815
816 #[serde(flatten)]
829 pub extra: serde_json::Map<String, Value>,
830}
831
832impl DTGCommon {
833 pub fn signed(&self) -> bool {
837 self.proof.is_some()
838 }
839
840 pub fn id(&self) -> Option<&str> {
842 self.id.as_deref()
843 }
844
845 pub fn issuer(&self) -> &str {
847 &self.issuer
848 }
849
850 #[allow(deprecated)]
852 pub fn subject(&self) -> &str {
853 match &self.credential_subject {
854 CredentialSubject::Basic(subject) => &subject.id,
855 CredentialSubject::Endorsement(subject) => &subject.id,
856 CredentialSubject::Witness(subject) => &subject.id,
857 CredentialSubject::Membership(subject) => &subject.id,
858 CredentialSubject::Authority(subject) => &subject.id,
859 CredentialSubject::Delegation(subject) => &subject.id,
860 CredentialSubject::RCard(subject) => &subject.id,
861 }
862 }
863
864 pub fn authority(&self) -> Option<&AuthorityGrant> {
870 match &self.credential_subject {
871 CredentialSubject::Authority(subject) => Some(&subject.authority),
872 _ => None,
873 }
874 }
875
876 pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
882 match &mut self.credential_subject {
883 CredentialSubject::Authority(subject) => Some(&mut subject.authority),
884 _ => None,
885 }
886 }
887
888 pub fn delegation(&self) -> Option<&DelegationGrant> {
894 match &self.credential_subject {
895 CredentialSubject::Delegation(subject) => Some(&subject.delegation),
896 _ => None,
897 }
898 }
899
900 pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> {
906 match &mut self.credential_subject {
907 CredentialSubject::Delegation(subject) => Some(&mut subject.delegation),
908 _ => None,
909 }
910 }
911
912 pub fn valid_from(&self) -> DateTime<Utc> {
914 self.valid_from
915 }
916
917 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
919 self.valid_until
920 }
921
922 pub fn task_context(&self) -> Option<&str> {
924 self.task_context.as_deref()
925 }
926}
927
928impl Default for DTGCommon {
930 fn default() -> Self {
931 DTGCommon {
932 context: vec![
933 "https://www.w3.org/ns/credentials/v2".to_string(),
934 "https://firstperson.network/credentials/dtg/v1".to_string(),
935 ],
936 type_: vec![
937 "VerifiableCredential".to_string(),
938 "DTGCredential".to_string(),
939 ],
940 id: None,
941 issuer: String::new(),
942 valid_from: Utc::now(),
943 valid_until: None,
944 task_context: None,
945 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
946 id: String::new(),
947 }),
948 credential_status: None,
949 proof: None,
950 extra: serde_json::Map::new(),
951 }
952 }
953}
954
955impl TryFrom<DTGCommon> for DTGCredential {
957 type Error = DTGCredentialError;
958
959 #[allow(deprecated)]
960 fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
961 match &value.type_.as_slice().try_into()? {
962 DTGCredentialType::Membership => {
963 let subject = match &value.credential_subject {
968 CredentialSubject::Membership(subject) => subject.clone(),
971
972 CredentialSubject::Basic(subject) => CredentialSubjectMembership {
974 id: subject.id.clone(),
975 digest_multibase: None,
976 },
977
978 CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
984 CredentialSubjectMembership {
985 id: subject.id.clone(),
986 digest_multibase: subject.digest_multibase.clone(),
987 }
988 }
989
990 _ => return Err(DTGCredentialError::UnknownCredential),
991 };
992
993 Ok(DTGCredential {
994 type_: DTGCredentialType::Membership,
995 version: value.context.as_slice().try_into()?,
996 credential: DTGCommon {
997 credential_subject: CredentialSubject::Membership(subject),
998 ..value
999 },
1000 })
1001 }
1002 DTGCredentialType::Relationship => Ok(DTGCredential {
1003 type_: DTGCredentialType::Relationship,
1004 version: value.context.as_slice().try_into()?,
1005 credential: value,
1006 }),
1007 DTGCredentialType::Invitation => Ok(DTGCredential {
1008 type_: DTGCredentialType::Invitation,
1009 version: value.context.as_slice().try_into()?,
1010 credential: value,
1011 }),
1012 DTGCredentialType::Persona => Ok(DTGCredential {
1013 type_: DTGCredentialType::Persona,
1014 version: value.context.as_slice().try_into()?,
1015 credential: value,
1016 }),
1017 DTGCredentialType::Endorsement => {
1018 if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
1019 Ok(DTGCredential {
1020 type_: DTGCredentialType::Endorsement,
1021 version: value.context.as_slice().try_into()?,
1022 credential: value,
1023 })
1024 } else {
1025 Err(DTGCredentialError::UnknownCredential)
1026 }
1027 }
1028 DTGCredentialType::Witness => {
1029 if value.task_context.is_none() {
1033 return Err(DTGCredentialError::MissingTaskContext);
1034 }
1035
1036 match &value.credential_subject {
1037 CredentialSubject::Witness(_) => Ok(DTGCredential {
1038 type_: DTGCredentialType::Witness,
1039 version: value.context.as_slice().try_into()?,
1040 credential: value,
1041 }),
1042 CredentialSubject::Basic(subject) => {
1043 Ok(DTGCredential {
1045 type_: DTGCredentialType::Witness,
1046 version: value.context.as_slice().try_into()?,
1047 credential: DTGCommon {
1048 credential_subject: CredentialSubject::Witness(
1049 CredentialSubjectWitness {
1050 id: subject.id.clone(),
1051 digest_multibase: None,
1052 witness_context: None,
1053 },
1054 ),
1055 ..value
1056 },
1057 })
1058 }
1059 _ => Err(DTGCredentialError::UnknownCredential),
1060 }
1061 }
1062 DTGCredentialType::Authority => {
1063 match &value.credential_subject {
1069 CredentialSubject::Authority(subject) => {
1070 if subject.authority.actions.is_empty() {
1071 return Err(DTGCredentialError::EmptyAuthorityActions);
1074 }
1075 Ok(DTGCredential {
1076 type_: DTGCredentialType::Authority,
1077 version: value.context.as_slice().try_into()?,
1078 credential: value,
1079 })
1080 }
1081 _ => Err(DTGCredentialError::UnknownCredential),
1082 }
1083 }
1084 DTGCredentialType::Delegation => {
1085 match &value.credential_subject {
1090 CredentialSubject::Delegation(subject) => {
1091 let d = &subject.delegation;
1092
1093 match (&d.accepts, &d.scope) {
1097 (Some(_), Some(_)) => {
1098 return Err(DTGCredentialError::MalformedDelegation(
1099 "carries both `accepts` and `scope`: an acceptance \
1100 consents to the scope of the grant it names rather \
1101 than restating it"
1102 .into(),
1103 ));
1104 }
1105 (Some(_), None) => {
1106 if d.parent.is_some() || d.max_depth.is_some() {
1107 return Err(DTGCredentialError::MalformedDelegation(
1108 "an acceptance carries `accepts` and nothing else".into(),
1109 ));
1110 }
1111 }
1112 (None, Some(scope)) => {
1113 if scope.is_empty() {
1114 return Err(DTGCredentialError::MalformedDelegation(
1115 "a grant's `scope` MUST contain at least one \
1116 entry — emptying it is not how an unbounded \
1117 appointment is expressed, because there is no \
1118 way to express one"
1119 .into(),
1120 ));
1121 }
1122 }
1123 (None, None) => {
1124 return Err(DTGCredentialError::MalformedDelegation(
1125 "carries neither `scope` nor `accepts`, so it is \
1126 neither a grant nor an acceptance"
1127 .into(),
1128 ));
1129 }
1130 }
1131
1132 Ok(DTGCredential {
1133 type_: DTGCredentialType::Delegation,
1134 version: value.context.as_slice().try_into()?,
1135 credential: value,
1136 })
1137 }
1138 _ => Err(DTGCredentialError::UnknownCredential),
1139 }
1140 }
1141 DTGCredentialType::RCard => match &value.credential_subject {
1142 CredentialSubject::RCard { .. } => Ok(DTGCredential {
1143 type_: DTGCredentialType::RCard,
1144 version: value.context.as_slice().try_into()?,
1145 credential: value,
1146 }),
1147 _ => Err(DTGCredentialError::UnknownCredential),
1148 },
1149 }
1150 }
1151}
1152
1153fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
1156where
1157 S: Serializer,
1158{
1159 s.serialize_str(
1160 timestamp
1161 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1162 .as_str(),
1163 )
1164}
1165
1166fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
1167where
1168 S: Serializer,
1169{
1170 if let Some(timestamp) = timestamp {
1171 s.serialize_str(
1172 timestamp
1173 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1174 .as_str(),
1175 )
1176 } else {
1177 s.serialize_none()
1178 }
1179}
1180
1181#[allow(deprecated)]
1190#[derive(Serialize, Deserialize, Debug, Clone)]
1191#[serde(untagged)]
1192pub enum CredentialSubject {
1193 Endorsement(CredentialSubjectEndorsement),
1195
1196 #[deprecated(
1198 since = "0.2.0",
1199 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1200 See DTGCredentialType::RCard. This variant will be removed in a future release."
1201 )]
1202 RCard(CredentialSubjectRCard),
1203
1204 Basic(CredentialSubjectBasic),
1207
1208 Witness(CredentialSubjectWitness),
1210
1211 Authority(CredentialSubjectAuthority),
1217
1218 Delegation(CredentialSubjectDelegation),
1223
1224 Membership(CredentialSubjectMembership),
1242}
1243
1244#[derive(Serialize, Deserialize, Debug, Clone)]
1246#[serde(deny_unknown_fields)]
1247pub struct CredentialSubjectBasic {
1248 pub id: String,
1249}
1250
1251#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1263#[serde(rename_all = "camelCase", deny_unknown_fields)]
1264pub struct AuthorityGrant {
1265 pub scope: String,
1270
1271 pub actions: Vec<String>,
1278
1279 #[serde(skip_serializing_if = "Option::is_none")]
1296 pub parent: Option<String>,
1297}
1298
1299#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
1316#[serde(rename_all = "camelCase", deny_unknown_fields)]
1317pub struct DelegationGrant {
1318 #[serde(skip_serializing_if = "Option::is_none", default)]
1329 pub scope: Option<Vec<String>>,
1330
1331 #[serde(skip_serializing_if = "Option::is_none", default)]
1334 pub parent: Option<String>,
1335
1336 #[serde(skip_serializing_if = "Option::is_none", default)]
1344 pub max_depth: Option<u32>,
1345
1346 #[serde(skip_serializing_if = "Option::is_none", default)]
1351 pub accepts: Option<String>,
1352}
1353
1354#[derive(Serialize, Deserialize, Debug, Clone)]
1356#[serde(rename_all = "camelCase", deny_unknown_fields)]
1357pub struct CredentialSubjectDelegation {
1358 pub id: String,
1360
1361 pub delegation: DelegationGrant,
1363}
1364
1365#[derive(Serialize, Deserialize, Debug, Clone)]
1367#[serde(rename_all = "camelCase", deny_unknown_fields)]
1368pub struct CredentialSubjectAuthority {
1369 pub id: String,
1371
1372 pub authority: AuthorityGrant,
1374}
1375
1376#[derive(Serialize, Deserialize, Debug, Clone)]
1384#[serde(rename_all = "camelCase", deny_unknown_fields)]
1385pub struct CredentialSubjectMembership {
1386 pub id: String,
1387
1388 #[serde(
1400 rename = "digestMultibase",
1401 alias = "digest",
1402 skip_serializing_if = "Option::is_none",
1403 default
1404 )]
1405 pub digest_multibase: Option<String>,
1406}
1407
1408#[derive(Serialize, Deserialize, Debug, Clone)]
1410#[serde(deny_unknown_fields)]
1411pub struct CredentialSubjectEndorsement {
1412 pub id: String,
1413 pub endorsement: Value,
1415}
1416
1417#[derive(Serialize, Deserialize, Debug, Clone)]
1419#[serde(rename_all = "camelCase", deny_unknown_fields)]
1420pub struct CredentialSubjectWitness {
1421 pub id: String,
1422
1423 #[serde(
1430 rename = "digestMultibase",
1431 alias = "digest",
1432 skip_serializing_if = "Option::is_none",
1433 default
1434 )]
1435 pub digest_multibase: Option<String>,
1436
1437 #[serde(skip_serializing_if = "Option::is_none")]
1439 pub witness_context: Option<WitnessContext>,
1440}
1441
1442#[derive(Serialize, Deserialize, Debug, Clone)]
1444#[serde(rename_all = "camelCase", deny_unknown_fields)]
1445pub struct WitnessContext {
1446 pub event: Option<String>,
1448
1449 pub session_id: Option<String>,
1451
1452 pub method: Option<String>,
1454}
1455
1456#[deprecated(
1458 since = "0.2.0",
1459 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1460 See DTGCredentialType::RCard. This struct will be removed in a future release."
1461)]
1462#[derive(Serialize, Deserialize, Debug, Clone)]
1463#[serde(deny_unknown_fields)]
1464pub struct CredentialSubjectRCard {
1465 pub id: String,
1466
1467 pub card: Value,
1469}
1470
1471#[cfg(test)]
1472#[allow(deprecated)]
1473mod tests {
1474 use crate::{
1475 CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
1476 DTGCredentialType, W3CVCVersion, decode_digest_multibase, digest_multibase_json,
1477 digests_match,
1478 };
1479 use chrono::{DateTime, Utc};
1480 use multibase::Base;
1481 use serde_json::Value;
1482 use sha2::{Digest, Sha256};
1483
1484 #[test]
1485 fn test_vmc_vc_1_deserialize() {
1486 let vmc: DTGCredential = match serde_json::from_str(
1488 r#"{
1489"@context": [
1490 "https://www.w3.org/2018/credentials/v1",
1491 "https://firstperson.network/credentials/dtg/v1",
1492 "https://w3id.org/security/suites/ed25519-2020/v1"
1493 ],
1494 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1495 "issuer": "did:web:chess-club.example",
1496 "issuanceDate": "2026-01-06T10:00:00Z",
1497 "expirationDate": "2027-01-06T10:00:00Z",
1498 "credentialSubject": {
1499 "id": "did:key:z6MkpTHR8VNs..."
1500 }
1501 }"#,
1502 ) {
1503 Ok(vmc) => vmc,
1504 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1505 };
1506
1507 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1508 assert!(matches!(
1509 vmc.credential().credential_subject,
1510 CredentialSubject::Membership(_)
1511 ));
1512 assert!(matches!(vmc.version, W3CVCVersion::V1_1));
1513 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
1514 }
1515
1516 #[test]
1517 fn test_missing_w3c_context() {
1518 assert!(
1520 serde_json::from_str::<DTGCredential>(
1521 r#"{
1522"@context": [
1523 "https://firstperson.network/credentials/dtg/v1",
1524 "https://w3id.org/security/suites/ed25519-2020/v1"
1525 ],
1526 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1527 "issuer": "did:web:chess-club.example",
1528 "issuanceDate": "2026-01-06T10:00:00Z",
1529 "expirationDate": "2027-01-06T10:00:00Z",
1530 "credentialSubject": {
1531 "id": "did:key:z6MkpTHR8VNs..."
1532 }
1533 }"#,
1534 )
1535 .is_err()
1536 );
1537 }
1538
1539 #[test]
1540 fn test_mutable_credential() {
1541 let mut vmc = DTGCredential::new_vmc(
1542 "did:example:issuer".to_string(),
1543 "did:example:subject".to_string(),
1544 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1545 .unwrap()
1546 .with_timezone(&Utc),
1547 None,
1548 false,
1549 );
1550
1551 let cred = vmc.credential_mut();
1552 cred.type_.push("PersonhoodCredential".to_string());
1553 assert!(vmc.is_personhood_credential());
1554 }
1555
1556 #[test]
1557 fn test_vmc_deserialize() {
1558 let vmc: DTGCredential = match serde_json::from_str(
1559 r#"{
1560 "@context": ["https://www.w3.org/ns/credentials/v2"],
1561 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1562 "issuer": "did:example:community",
1563 "validFrom": "2024-06-18T10:00:00Z",
1564 "credentialSubject": { "id": "did:example:rDid" }
1565 }"#,
1566 ) {
1567 Ok(vmc) => vmc,
1568 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1569 };
1570
1571 assert!(!vmc.is_personhood_credential());
1572 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1573 assert!(matches!(
1574 vmc.credential().credential_subject,
1575 CredentialSubject::Membership(_)
1576 ));
1577 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1578 }
1579
1580 #[test]
1581 fn test_vmc_phc_deserialize() {
1582 let vmc: DTGCredential = match serde_json::from_str(
1583 r#"{
1584 "@context": ["https://www.w3.org/ns/credentials/v2"],
1585 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
1586 "issuer": "did:example:community",
1587 "validFrom": "2024-06-18T10:00:00Z",
1588 "credentialSubject": { "id": "did:example:rDid" }
1589 }"#,
1590 ) {
1591 Ok(vmc) => vmc,
1592 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1593 };
1594
1595 assert!(vmc.is_personhood_credential());
1596 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1597 assert!(matches!(
1598 vmc.credential().credential_subject,
1599 CredentialSubject::Membership(_)
1600 ));
1601 }
1602
1603 #[test]
1604 fn test_vrc_deserialize() {
1605 let vrc: DTGCredential = match serde_json::from_str(
1606 r#"{
1607 "@context": ["https://www.w3.org/ns/credentials/v2"],
1608 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1609 "issuer": "did:example:governmentAgencyDid",
1610 "validFrom": "2024-06-18T10:00:00Z",
1611 "credentialSubject": { "id": "did:example:citizenRDid" }
1612 }"#,
1613 ) {
1614 Ok(vrc) => vrc,
1615 Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1616 };
1617
1618 assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1619 assert!(matches!(
1620 vrc.credential().credential_subject,
1621 CredentialSubject::Basic(_)
1622 ));
1623 }
1624
1625 #[test]
1626 fn test_vic_deserialize() {
1627 let vic: DTGCredential = match serde_json::from_str(
1628 r#"{
1629 "@context": ["https://www.w3.org/ns/credentials/v2"],
1630 "type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
1631 "issuer": "did:example:governmentAgencyVicDid",
1632 "validFrom": "2024-06-18T10:00:00Z",
1633 "credentialSubject": { "id": "did:example:citizenRDid" }
1634 }"#,
1635 ) {
1636 Ok(vic) => vic,
1637 Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1638 };
1639
1640 assert!(!vic.is_personhood_credential());
1641 assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1642 assert!(matches!(
1643 vic.credential().credential_subject,
1644 CredentialSubject::Basic(_)
1645 ));
1646 }
1647
1648 #[test]
1649 fn test_vpc_deserialize() {
1650 let vpc: DTGCredential = match serde_json::from_str(
1651 r#"{
1652 "@context": ["https://www.w3.org/ns/credentials/v2"],
1653 "type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
1654 "issuer": "did:example:governmentAgencyDid",
1655 "validFrom": "2024-06-18T10:00:00Z",
1656 "credentialSubject": { "id": "did:example:citizenRDid" }
1657 }"#,
1658 ) {
1659 Ok(vpc) => vpc,
1660 Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1661 };
1662
1663 assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1664 assert!(matches!(
1665 vpc.credential().credential_subject,
1666 CredentialSubject::Basic(_)
1667 ));
1668 }
1669
1670 #[test]
1671 fn test_vec_deserialize() {
1672 let vec: DTGCredential = match serde_json::from_str(
1673 r#"{
1674 "@context": ["https://www.w3.org/ns/credentials/v2"],
1675 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1676 "issuer": "did:example:governmentAgencyDid",
1677 "validFrom": "2024-06-18T10:00:00Z",
1678 "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1679 }"#,
1680 ) {
1681 Ok(vec) => vec,
1682 Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1683 };
1684
1685 assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1686 assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1687 assert!(matches!(
1688 vec.credential().credential_subject,
1689 CredentialSubject::Endorsement(_)
1690 ));
1691 }
1692
1693 #[test]
1694 fn test_vec_bad_deserialize() {
1695 match serde_json::from_str::<DTGCredential>(
1696 r#"{
1697 "@context": ["https://www.w3.org/ns/credentials/v2"],
1698 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1699 "issuer": "did:example:governmentAgencyDid",
1700 "validFrom": "2024-06-18T10:00:00Z",
1701 "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1702 }"#,
1703 ) {
1704 Ok(_) => panic!("Expected Unknown Credential type"),
1705 Err(_) => {
1706 }
1708 };
1709 }
1710
1711 #[test]
1712 fn test_vwc_simple_deserialize() {
1713 let vwc: DTGCredential = match serde_json::from_str(
1714 r#"{
1715 "@context": ["https://www.w3.org/ns/credentials/v2"],
1716 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1717 "issuer": "did:example:governmentAgencyDid",
1718 "validFrom": "2024-06-18T10:00:00Z",
1719 "taskContext": "thread-abc-123",
1720 "credentialSubject": { "id": "did:example:citizenRDid" }
1721 }"#,
1722 ) {
1723 Ok(vwc) => vwc,
1724 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1725 };
1726
1727 assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1728 assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1729 assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1730 assert!(matches!(
1731 vwc.credential().credential_subject,
1732 CredentialSubject::Witness(_)
1733 ));
1734 }
1735
1736 #[test]
1737 fn test_vwc_full_deserialize() {
1738 let vwc: DTGCredential = match serde_json::from_str(
1739 r#"{
1740 "@context": ["https://www.w3.org/ns/credentials/v2"],
1741 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1742 "issuer": "did:example:governmentAgencyDid",
1743 "validFrom": "2024-06-18T10:00:00Z",
1744 "taskContext": "thread-abc-123",
1745 "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "witnessContext": {} }
1746 }"#,
1747 ) {
1748 Ok(vwc) => vwc,
1749 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1750 };
1751
1752 assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1753 assert!(matches!(
1754 vwc.credential().credential_subject,
1755 CredentialSubject::Witness(_)
1756 ));
1757 }
1758
1759 #[test]
1760 fn test_vwc_bad_deserialize() {
1761 if serde_json::from_str::<DTGCredential>(
1762 r#"{
1763 "@context": ["https://www.w3.org/ns/credentials/v2"],
1764 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1765 "issuer": "did:example:governmentAgencyDid",
1766 "validFrom": "2024-06-18T10:00:00Z",
1767 "taskContext": "thread-abc-123",
1768 "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "wrongContext": {} }
1769 }"#,
1770 ).is_ok() {
1771 panic!("Should have failed due to wrong CredentialSubject!");
1772 }
1773 }
1774
1775 #[test]
1776 fn test_rcard_simple_deserialize() {
1777 let rcard: DTGCredential = match serde_json::from_str(
1778 r#"{
1779 "@context": ["https://www.w3.org/ns/credentials/v2"],
1780 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1781 "issuer": "did:example:governmentAgencyDid",
1782 "validFrom": "2024-06-18T10:00:00Z",
1783 "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1784 }"#,
1785 ) {
1786 Ok(rcard) => rcard,
1787 Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1788 };
1789
1790 assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1791 assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1792 assert!(matches!(
1793 rcard.credential().credential_subject,
1794 CredentialSubject::RCard(_)
1795 ));
1796 }
1797
1798 #[test]
1799 fn test_rcard_bad_deserialize() {
1800 if serde_json::from_str::<DTGCredential>(
1801 r#"{
1802 "@context": ["https://www.w3.org/ns/credentials/v2"],
1803 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1804 "issuer": "did:example:governmentAgencyDid",
1805 "validFrom": "2024-06-18T10:00:00Z",
1806 "credentialSubject": { "id": "did:example:citizenRDid" }
1807 }"#,
1808 )
1809 .is_ok()
1810 {
1811 panic!("Should have failed due to wrong CredentialSubject!");
1812 }
1813 }
1814 #[test]
1815 fn test_deserialize_unknown() {
1816 match serde_json::from_str::<DTGCredential>(
1817 r#"{
1818 "@context": ["https://www.w3.org/ns/credentials/v2"],
1819 "type": ["VerifiableCredential", "DTGCredential", "UnknownCredential"],
1820 "issuer": "did:example:governmentAgencyDid",
1821 "validFrom": "2024-06-18T10:00:00Z",
1822 "credentialSubject": { "id": "did:example:citizenRDid" }
1823 }"#,
1824 ) {
1825 Ok(_) => panic!("Expected Unknown Credential type"),
1826 Err(e) => {
1827 if e.to_string() == "Unknown credential type" {
1828 } else {
1830 panic!("Wrong error type returned");
1831 }
1832 }
1833 };
1834 }
1835
1836 #[test]
1837 fn test_deserialize_mismatched_credential_subject() {
1838 match serde_json::from_str::<DTGCredential>(
1839 r#"{
1840 "@context": ["https://www.w3.org/ns/credentials/v2"],
1841 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1842 "issuer": "did:example:governmentAgencyDid",
1843 "validFrom": "2024-06-18T10:00:00Z",
1844 "credentialSubject": { "id": "did:example:citizenRDid" }
1845 }"#,
1846 ) {
1847 Ok(_) => panic!("Expected Unknown Credential type"),
1848 Err(e) => {
1849 if e.to_string() == "Unknown credential type" {
1850 } else {
1852 panic!("Wrong error type returned");
1853 }
1854 }
1855 };
1856 }
1857
1858 #[test]
1859 fn test_proof_signed() {
1860 let cred: DTGCredential = match serde_json::from_str(
1861 r#"{
1862 "@context": ["https://www.w3.org/ns/credentials/v2"],
1863 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1864 "issuer": "did:example:community",
1865 "validFrom": "2024-06-18T10:00:00Z",
1866 "credentialSubject": { "id": "did:example:rDid" },
1867 "proof": {
1868 "type": "DataIntegrityProof",
1869 "cryptosuite": "eddsa-jcs-2022",
1870 "created": "2025-12-04T00:00:00",
1871 "verificationMethod": "did:example:test#key-1",
1872 "proofPurpose": "assertionMethod",
1873 "proofValue": "abcd"
1874 }
1875 }"#,
1876 ) {
1877 Ok(vmc) => vmc,
1878 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1879 };
1880
1881 assert!(cred.signed());
1882 assert!(cred.proof_value().is_some());
1883 }
1884
1885 #[test]
1886 fn test_proof_not_signed() {
1887 let cred: DTGCredential = match serde_json::from_str(
1888 r#"{
1889 "@context": ["https://www.w3.org/ns/credentials/v2"],
1890 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1891 "issuer": "did:example:community",
1892 "validFrom": "2024-06-18T10:00:00Z",
1893 "credentialSubject": { "id": "did:example:rDid" }
1894 }"#,
1895 ) {
1896 Ok(vmc) => vmc,
1897 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1898 };
1899
1900 assert!(!cred.signed());
1901 assert!(cred.proof_value().is_none());
1902 }
1903
1904 #[test]
1905 fn test_helpers() {
1906 let cred: DTGCredential = match serde_json::from_str(
1907 r#"{
1908 "@context": ["https://www.w3.org/ns/credentials/v2"],
1909 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1910 "issuer": "did:example:issuer",
1911 "validFrom": "2024-06-18T00:00:00Z",
1912 "credentialSubject": { "id": "did:example:subject" }
1913 }"#,
1914 ) {
1915 Ok(vmc) => vmc,
1916 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1917 };
1918
1919 assert_eq!(cred.issuer(), "did:example:issuer");
1920 assert_eq!(cred.subject(), "did:example:subject");
1921 assert_eq!(
1922 cred.valid_from()
1923 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1924 "2024-06-18T00:00:00Z"
1925 );
1926 assert_eq!(cred.valid_until(), None);
1927 }
1928
1929 #[test]
1930 fn test_valid_until() {
1931 let cred: DTGCredential = match serde_json::from_str(
1932 r#"{
1933 "@context": ["https://www.w3.org/ns/credentials/v2"],
1934 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1935 "issuer": "did:example:issuer",
1936 "validFrom": "2024-06-18T00:00:00Z",
1937 "validUntil": "2030-01-01T00:00:00Z",
1938 "credentialSubject": { "id": "did:example:subject" }
1939 }"#,
1940 ) {
1941 Ok(vmc) => vmc,
1942 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1943 };
1944
1945 assert_eq!(
1946 cred.valid_until()
1947 .unwrap()
1948 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1949 "2030-01-01T00:00:00Z"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_bad_type() {
1955 assert!(
1956 std::convert::TryInto::<DTGCredentialType>::try_into(
1957 vec!["bad_type".to_string()].as_slice(),
1958 )
1959 .is_err()
1960 );
1961 }
1962
1963 #[test]
1964 fn test_badly_constructed_vwc() {
1965 let mut cred = DTGCommon::default();
1966 cred.type_.push("WitnessCredential".to_string());
1967 cred.task_context = Some("thread-abc-123".to_string());
1970 cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1971 id: "did:example:bad".to_string(),
1972 card: Value::Null,
1973 });
1974
1975 assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1976 }
1977
1978 #[test]
1979 fn test_vwc_missing_task_context() {
1980 match serde_json::from_str::<DTGCredential>(
1982 r#"{
1983 "@context": ["https://www.w3.org/ns/credentials/v2"],
1984 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1985 "issuer": "did:example:witness",
1986 "validFrom": "2024-06-18T10:00:00Z",
1987 "credentialSubject": { "id": "did:example:observed" }
1988 }"#,
1989 ) {
1990 Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1991 Err(e) => assert_eq!(
1992 e.to_string(),
1993 "WitnessCredential is missing the required taskContext property"
1994 ),
1995 }
1996 }
1997
1998 #[test]
1999 fn test_task_context_round_trip() {
2000 let raw = r#"{
2003 "@context": ["https://www.w3.org/ns/credentials/v2"],
2004 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
2005 "issuer": "did:example:witness",
2006 "validFrom": "2024-06-18T10:00:00Z",
2007 "taskContext": "thread-abc-123",
2008 "credentialSubject": { "id": "did:example:observed" }
2009 }"#;
2010
2011 let cred: DTGCredential = serde_json::from_str(raw).unwrap();
2012 let out = serde_json::to_string(&cred).unwrap();
2013
2014 assert!(out.contains(r#""taskContext":"thread-abc-123""#));
2015 }
2016
2017 #[test]
2018 fn test_task_context_optional_on_other_types() {
2019 let vrc: DTGCredential = serde_json::from_str(
2021 r#"{
2022 "@context": ["https://www.w3.org/ns/credentials/v2"],
2023 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
2024 "issuer": "did:example:issuer",
2025 "validFrom": "2024-06-18T10:00:00Z",
2026 "credentialSubject": { "id": "did:example:subject" }
2027 }"#,
2028 )
2029 .unwrap();
2030
2031 assert_eq!(vrc.task_context(), None);
2032 assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
2034 }
2035
2036 #[test]
2037 fn test_digest_multibase() {
2038 let vrc = DTGCredential::new_vrc(
2039 "did:example:issuer".to_string(),
2040 "did:example:subject".to_string(),
2041 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2042 .unwrap()
2043 .with_timezone(&Utc),
2044 None,
2045 );
2046
2047 let digest = vrc.digest_multibase().unwrap();
2048
2049 assert!(digest.starts_with('z'));
2051
2052 let (base, bytes) = multibase::decode(&digest).unwrap();
2054 assert_eq!(base, multibase::Base::Base58Btc);
2055 assert_eq!(bytes.len(), 34);
2056 assert_eq!(&bytes[..2], &[0x12, 0x20]);
2057
2058 assert_eq!(digest, vrc.digest_multibase().unwrap());
2060
2061 let other = DTGCredential::new_vrc(
2063 "did:example:issuer".to_string(),
2064 "did:example:someone-else".to_string(),
2065 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2066 .unwrap()
2067 .with_timezone(&Utc),
2068 None,
2069 );
2070 assert_ne!(digest, other.digest_multibase().unwrap());
2071 }
2072
2073 #[test]
2074 fn test_verify_digest() {
2075 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2076 .unwrap()
2077 .with_timezone(&Utc);
2078
2079 let vrc = DTGCredential::new_vrc(
2080 "did:example:issuer".to_string(),
2081 "did:example:subject".to_string(),
2082 valid_from,
2083 None,
2084 );
2085
2086 let vwc = DTGCredential::new_vwc(
2087 "did:example:witness".to_string(),
2088 "did:example:issuer".to_string(),
2090 valid_from,
2091 None,
2092 "thread-abc-123".to_string(),
2093 Some(vrc.digest_multibase().unwrap()),
2094 None,
2095 );
2096
2097 assert!(vwc.verify_digest(&vrc).unwrap());
2098
2099 let other = DTGCredential::new_vrc(
2101 "did:example:issuer".to_string(),
2102 "did:example:someone-else".to_string(),
2103 valid_from,
2104 None,
2105 );
2106 assert!(!vwc.verify_digest(&other).unwrap());
2107 }
2108
2109 #[test]
2110 fn test_verify_digest_without_digest() {
2111 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2112 .unwrap()
2113 .with_timezone(&Utc);
2114
2115 let vrc = DTGCredential::new_vrc(
2116 "did:example:issuer".to_string(),
2117 "did:example:subject".to_string(),
2118 valid_from,
2119 None,
2120 );
2121
2122 let vwc = DTGCredential::new_vwc(
2124 "did:example:witness".to_string(),
2125 "did:example:issuer".to_string(),
2126 valid_from,
2127 None,
2128 "thread-abc-123".to_string(),
2129 None,
2130 None,
2131 );
2132
2133 assert!(!vwc.verify_digest(&vrc).unwrap());
2134 }
2135
2136 #[test]
2141 fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() {
2142 let vmc = DTGCredential::new_vmc(
2143 "did:example:community".to_string(),
2144 "did:example:member".to_string(),
2145 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2146 .unwrap()
2147 .with_timezone(&Utc),
2148 None,
2149 false,
2150 )
2151 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2152
2153 let digest = vmc.digest_multibase().unwrap();
2154
2155 assert!(digest.starts_with('z'), "multibase base58btc prefix");
2157
2158 let (base, bytes) = multibase::decode(&digest).unwrap();
2160 assert_eq!(base, Base::Base58Btc);
2161 assert_eq!(bytes.len(), 34);
2162 assert_eq!(&bytes[..2], &[0x12, 0x20]);
2163
2164 assert_eq!(digest, "zQmTJgyPT2ShMQ2AvCHGDoPGjEWyRC7ZNT3MBpe5PP6Vpvu");
2171
2172 assert_eq!(digest, vmc.digest_multibase().unwrap());
2174 }
2175
2176 #[test]
2179 #[allow(deprecated)]
2180 fn the_superseded_hex_digest_is_unchanged() {
2181 let vmc = DTGCredential::new_vmc(
2182 "did:example:community".to_string(),
2183 "did:example:member".to_string(),
2184 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2185 .unwrap()
2186 .with_timezone(&Utc),
2187 None,
2188 false,
2189 )
2190 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2191
2192 assert_eq!(
2193 vmc.digest().unwrap(),
2194 "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
2195 );
2196 }
2197
2198 #[test]
2202 fn a_superseded_digest_value_is_rejected_as_malformed() {
2203 let err = decode_digest_multibase(
2204 "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2",
2205 )
2206 .unwrap_err();
2207
2208 assert!(
2209 matches!(err, DTGCredentialError::InvalidDigest(_)),
2210 "expected InvalidDigest, got {err:?}"
2211 );
2212 }
2213
2214 #[test]
2217 fn digests_are_compared_by_bytes_not_by_string() {
2218 let multihash = {
2221 let mut v = vec![0x12u8, 0x20];
2222 v.extend_from_slice(&Sha256::digest(b"an edge credential"));
2223 v
2224 };
2225 let b58 = multibase::encode(Base::Base58Btc, &multihash);
2226 let b16 = multibase::encode(Base::Base16Lower, &multihash);
2227
2228 assert_ne!(b58, b16, "the two spellings differ as strings");
2229 assert!(
2230 digests_match(&b58, &b16).unwrap(),
2231 "but name the same digest"
2232 );
2233 }
2234
2235 #[test]
2239 fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() {
2240 let mut multihash = vec![0x13u8, 0x40];
2242 multihash.extend_from_slice(&[0u8; 64]);
2243 let encoded = multibase::encode(Base::Base58Btc, &multihash);
2244
2245 assert!(matches!(
2246 decode_digest_multibase(&encoded),
2247 Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13))
2248 ));
2249 }
2250
2251 #[cfg(feature = "affinidi-signing")]
2255 #[tokio::test]
2256 async fn test_digest_is_unchanged_by_signing() {
2257 use affinidi_secrets_resolver::secrets::Secret;
2258
2259 let secret = Secret::generate_ed25519(None, None);
2260
2261 let mut vmc = DTGCredential::new_vmc(
2262 "did:example:community".to_string(),
2263 "did:example:member".to_string(),
2264 Utc::now(),
2265 None,
2266 false,
2267 );
2268
2269 let before = vmc.digest_multibase().unwrap();
2270 vmc.sign(&secret, None).await.expect("signs");
2271 assert!(vmc.signed());
2272 assert_eq!(before, vmc.digest_multibase().unwrap());
2273 }
2274
2275 fn wire(c: &DTGCredential) -> Value {
2277 serde_json::to_value(c.credential()).expect("credential serialises")
2278 }
2279
2280 #[test]
2283 fn test_member_vmc_acknowledges_its_grant() {
2284 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2285 .unwrap()
2286 .with_timezone(&Utc);
2287
2288 let grant = DTGCredential::new_vmc(
2289 "did:example:community".to_string(),
2290 "did:example:member".to_string(),
2291 valid_from,
2292 None,
2293 false,
2294 );
2295
2296 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2297
2298 assert_eq!(ack.issuer(), "did:example:member");
2300 assert_eq!(ack.subject(), "did:example:community");
2301
2302 assert_eq!(grant.subject_digest(), None);
2304 assert_eq!(
2305 ack.subject_digest(),
2306 Some(grant.digest_multibase().unwrap().as_str())
2307 );
2308
2309 assert!(ack.acknowledges(&grant).unwrap());
2310 }
2311
2312 #[test]
2315 fn test_acknowledges_rejects_a_mismatched_pair() {
2316 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2317 .unwrap()
2318 .with_timezone(&Utc);
2319
2320 let grant = DTGCredential::new_vmc(
2321 "did:example:community".to_string(),
2322 "did:example:member".to_string(),
2323 valid_from,
2324 None,
2325 false,
2326 );
2327 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2328
2329 let other_member = DTGCredential::new_vmc(
2331 "did:example:community".to_string(),
2332 "did:example:someone-else".to_string(),
2333 valid_from,
2334 None,
2335 false,
2336 );
2337 assert!(!ack.acknowledges(&other_member).unwrap());
2338
2339 let other_community = DTGCredential::new_vmc(
2341 "did:example:other-community".to_string(),
2342 "did:example:member".to_string(),
2343 valid_from,
2344 None,
2345 false,
2346 );
2347 assert!(!ack.acknowledges(&other_community).unwrap());
2348
2349 let renewed = DTGCredential::new_vmc(
2353 "did:example:community".to_string(),
2354 "did:example:member".to_string(),
2355 valid_from + chrono::Duration::days(365),
2356 None,
2357 false,
2358 );
2359 assert!(!ack.acknowledges(&renewed).unwrap());
2360
2361 let ack_of_ack =
2363 DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2364 assert!(!ack_of_ack.acknowledges(&ack).unwrap());
2365
2366 assert!(!grant.acknowledges(&grant).unwrap());
2368 }
2369
2370 #[test]
2376 fn a_vdc_carries_the_credential_status_it_is_given() {
2377 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2378 .unwrap()
2379 .with_timezone(&Utc);
2380 let valid_until = DateTime::parse_from_rfc3339("2026-12-11T00:00:00Z")
2381 .unwrap()
2382 .with_timezone(&Utc);
2383
2384 let status = serde_json::json!({
2385 "id": "https://delegator.example/status#12",
2386 "type": "BitstringStatusListEntry",
2387 "statusPurpose": "revocation",
2388 "statusListIndex": "12"
2389 });
2390
2391 let vdc = DTGCredential::new_vdc(
2392 "did:example:delegator".to_string(),
2393 "did:example:delegate".to_string(),
2394 valid_from,
2395 valid_until,
2396 vec!["sign:invoices".to_string()],
2397 None,
2398 )
2399 .expect("a bounded grant is well formed");
2400
2401 assert!(
2403 vdc.credential().credential_status.is_none(),
2404 "a VDC MAY omit `credentialStatus`, so the constructor must not supply one"
2405 );
2406
2407 let vdc = vdc.with_credential_status(status.clone());
2408 assert_eq!(vdc.credential().credential_status.as_ref(), Some(&status));
2409 assert_eq!(wire(&vdc).get("credentialStatus"), Some(&status));
2410
2411 let parsed: DTGCredential = serde_json::from_value(wire(&vdc)).expect("parses");
2414 assert_eq!(
2415 parsed.credential().credential_status.as_ref(),
2416 Some(&status)
2417 );
2418 }
2419
2420 #[test]
2422 fn set_credential_status_matches_the_builder() {
2423 let status = serde_json::json!({ "type": "BitstringStatusListEntry" });
2424
2425 let mut vmc = DTGCredential::new_vmc(
2426 "did:example:community".to_string(),
2427 "did:example:member".to_string(),
2428 Utc::now(),
2429 None,
2430 false,
2431 );
2432 vmc.set_credential_status(status.clone());
2433
2434 assert_eq!(vmc.credential().credential_status.as_ref(), Some(&status));
2435 }
2436
2437 #[test]
2440 fn credential_types_compare_by_equality() {
2441 let vdc = DTGCredential::new_vdc(
2442 "did:example:delegator".to_string(),
2443 "did:example:delegate".to_string(),
2444 Utc::now(),
2445 Utc::now() + chrono::Duration::days(1),
2446 vec!["sign:invoices".to_string()],
2447 None,
2448 )
2449 .expect("a bounded grant is well formed");
2450
2451 assert_eq!(vdc.type_(), DTGCredentialType::Delegation);
2452 assert_ne!(vdc.type_(), DTGCredentialType::Membership);
2453 }
2454
2455 #[test]
2459 fn credential_status_survives_a_round_trip() {
2460 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2461 .unwrap()
2462 .with_timezone(&Utc);
2463
2464 let mut grant = wire(&DTGCredential::new_vmc(
2465 "did:example:community".to_string(),
2466 "did:example:member".to_string(),
2467 valid_from,
2468 None,
2469 false,
2470 ));
2471 let status = serde_json::json!({
2472 "id": "https://community.example/status#7",
2473 "type": "BitstringStatusListEntry",
2474 "statusPurpose": "revocation",
2475 "statusListIndex": "7"
2476 });
2477 grant["credentialStatus"] = status.clone();
2478
2479 let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2480 assert_eq!(
2481 parsed.credential().credential_status.as_ref(),
2482 Some(&status)
2483 );
2484 assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status));
2485 assert_eq!(
2486 parsed.digest_multibase().unwrap(),
2487 digest_multibase_json(&grant).unwrap(),
2488 "the digest must not change under a round trip that preserves every member"
2489 );
2490 }
2491
2492 #[test]
2495 fn unmodelled_top_level_members_survive_a_round_trip() {
2496 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2497 .unwrap()
2498 .with_timezone(&Utc);
2499
2500 let mut grant = wire(&DTGCredential::new_vmc(
2501 "did:example:community".to_string(),
2502 "did:example:member".to_string(),
2503 valid_from,
2504 None,
2505 false,
2506 ));
2507 let schema = serde_json::json!({
2508 "id": "https://community.example/schemas/vmc",
2509 "type": "JsonSchema"
2510 });
2511 grant["credentialSchema"] = schema.clone();
2512
2513 let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2514 assert_eq!(
2515 parsed.credential().extra.get("credentialSchema"),
2516 Some(&schema)
2517 );
2518 assert_eq!(
2519 parsed.digest_multibase().unwrap(),
2520 digest_multibase_json(&grant).unwrap()
2521 );
2522 }
2523
2524 #[test]
2541 fn the_acknowledgement_digests_the_grant_as_it_arrived() {
2542 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2543 .unwrap()
2544 .with_timezone(&Utc);
2545
2546 let mut grant = wire(&DTGCredential::new_vmc(
2547 "did:example:community".to_string(),
2548 "did:example:member".to_string(),
2549 valid_from,
2550 None,
2551 false,
2552 ));
2553 grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string());
2555
2556 let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2558 assert_ne!(
2559 wire(&parsed).get("validFrom"),
2560 grant.get("validFrom"),
2561 "the model is expected to normalize the timestamp; if it now round-trips \
2562 verbatim, this test has stopped guarding anything"
2563 );
2564
2565 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
2566
2567 assert_eq!(
2568 ack.subject_digest(),
2569 Some(digest_multibase_json(&grant).unwrap().as_str()),
2570 "the acknowledgement must digest the grant as received"
2571 );
2572 assert_ne!(
2573 ack.subject_digest(),
2574 Some(parsed.digest_multibase().unwrap().as_str()),
2575 "digesting the parsed model would produce a digest the community cannot match"
2576 );
2577 }
2578
2579 #[test]
2580 fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() {
2581 let vmc = DTGCredential::new_vmc(
2582 "did:example:community".to_string(),
2583 "did:example:member".to_string(),
2584 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2585 .unwrap()
2586 .with_timezone(&Utc),
2587 None,
2588 false,
2589 )
2590 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2591
2592 assert_eq!(
2593 vmc.digest_multibase().unwrap(),
2594 digest_multibase_json(&wire(&vmc)).unwrap()
2595 );
2596 }
2597
2598 #[test]
2601 fn test_acknowledges_is_membership_only() {
2602 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2603 .unwrap()
2604 .with_timezone(&Utc);
2605
2606 let grant = DTGCredential::new_vmc(
2607 "did:example:community".to_string(),
2608 "did:example:member".to_string(),
2609 valid_from,
2610 None,
2611 false,
2612 );
2613 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2614
2615 let vrc = DTGCredential::new_vrc(
2616 "did:example:member".to_string(),
2617 "did:example:community".to_string(),
2618 valid_from,
2619 None,
2620 );
2621 assert!(!ack.acknowledges(&vrc).unwrap());
2622
2623 let vwc = DTGCredential::new_vwc(
2625 "did:example:witness".to_string(),
2626 "did:example:community".to_string(),
2627 valid_from,
2628 None,
2629 "thread-abc-123".to_string(),
2630 Some(grant.digest_multibase().unwrap()),
2631 None,
2632 );
2633 assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
2634 assert!(
2635 !vwc.acknowledges(&grant).unwrap(),
2636 "but a VWC is not the member's acknowledgement"
2637 );
2638 }
2639
2640 #[test]
2644 fn test_new_member_vmc_refuses_a_non_grant() {
2645 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2646 .unwrap()
2647 .with_timezone(&Utc);
2648
2649 let vrc = DTGCredential::new_vrc(
2650 "did:example:a".to_string(),
2651 "did:example:b".to_string(),
2652 valid_from,
2653 None,
2654 );
2655 assert!(matches!(
2656 DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
2657 Err(DTGCredentialError::NotAMembershipGrant(_))
2658 ));
2659
2660 let grant = DTGCredential::new_vmc(
2661 "did:example:community".to_string(),
2662 "did:example:member".to_string(),
2663 valid_from,
2664 None,
2665 false,
2666 );
2667 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2668 assert!(matches!(
2669 DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
2670 Err(DTGCredentialError::NotAMembershipGrant(_))
2671 ));
2672 }
2673
2674 #[test]
2679 fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
2680 let vmc: DTGCredential = serde_json::from_str(
2681 r#"{
2682 "@context": ["https://www.w3.org/ns/credentials/v2"],
2683 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2684 "issuer": "did:example:member",
2685 "validFrom": "2024-06-18T10:00:00Z",
2686 "credentialSubject": {
2687 "id": "did:example:community",
2688 "digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2689 }
2690 }"#,
2691 )
2692 .expect("deserializes");
2693
2694 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2695 assert!(matches!(
2696 vmc.credential().credential_subject,
2697 CredentialSubject::Membership(_)
2698 ));
2699 assert_eq!(
2700 vmc.subject_digest(),
2701 Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2702 );
2703 assert_eq!(vmc.subject(), "did:example:community");
2704 }
2705
2706 #[test]
2709 fn test_membership_credential_rejects_a_witness_context() {
2710 let result: Result<DTGCredential, _> = serde_json::from_str(
2711 r#"{
2712 "@context": ["https://www.w3.org/ns/credentials/v2"],
2713 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2714 "issuer": "did:example:member",
2715 "validFrom": "2024-06-18T10:00:00Z",
2716 "credentialSubject": {
2717 "id": "did:example:community",
2718 "digestMultibase": "sha256:e3b0c4",
2719 "witnessContext": { "event": "not a membership property" }
2720 }
2721 }"#,
2722 );
2723 assert!(result.is_err());
2724 }
2725
2726 #[test]
2729 fn test_the_two_halves_round_trip_over_the_wire() {
2730 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2731 .unwrap()
2732 .with_timezone(&Utc);
2733
2734 let grant = DTGCredential::new_vmc(
2735 "did:example:community".to_string(),
2736 "did:example:member".to_string(),
2737 valid_from,
2738 None,
2739 false,
2740 );
2741 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2742
2743 let grant_json = serde_json::to_value(&grant).unwrap();
2744 assert!(
2745 grant_json["credentialSubject"]
2746 .get("digestMultibase")
2747 .is_none(),
2748 "the grant MUST omit `digestMultibase`: {grant_json}"
2749 );
2750
2751 let ack_json = serde_json::to_value(&ack).unwrap();
2752 assert_eq!(
2753 ack_json["credentialSubject"]["digestMultibase"],
2754 Value::String(grant.digest_multibase().unwrap()),
2755 );
2756
2757 let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
2760 let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
2761 assert!(ack.acknowledges(&grant).unwrap());
2762 }
2763
2764 #[test]
2765 fn test_iso8601_format_option() {
2766 let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
2767 &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2768 )
2769 .unwrap()
2770 .to_utc();
2771 let cred = DTGCommon {
2772 valid_until: Some(now),
2773 ..Default::default()
2774 };
2775
2776 let value = serde_json::to_value(&cred).unwrap();
2777 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2778 assert_eq!(cred2.valid_until, Some(now));
2779
2780 let cred = DTGCommon::default();
2781 let value = serde_json::to_value(&cred).unwrap();
2782 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2783 assert_eq!(cred2.valid_until, None);
2784 }
2785
2786 #[cfg(feature = "affinidi-signing")]
2787 #[tokio::test]
2788 async fn test_signing() {
2789 use affinidi_secrets_resolver::secrets::Secret;
2790
2791 let secret = Secret::generate_ed25519(None, None);
2792
2793 let mut cred = DTGCredential::new_vrc(
2794 "did:example:issuer".to_string(),
2795 "did:example:subject".to_string(),
2796 Utc::now(),
2797 None,
2798 );
2799
2800 assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
2801
2802 assert!(
2803 cred.verify_proof_with_public_key(secret.get_public_bytes())
2804 .is_ok()
2805 );
2806
2807 let secret2 = Secret::generate_ed25519(None, None);
2808 assert!(
2809 cred.verify_proof_with_public_key(secret2.get_public_bytes())
2810 .is_err()
2811 );
2812 }
2813
2814 #[cfg(feature = "affinidi-signing")]
2822 #[tokio::test]
2823 async fn test_id_is_covered_by_the_proof() {
2824 use affinidi_secrets_resolver::secrets::Secret;
2825
2826 let secret = Secret::generate_ed25519(None, None);
2827
2828 let mut cred = DTGCredential::new_vrc(
2829 "did:example:issuer".to_string(),
2830 "did:example:subject".to_string(),
2831 Utc::now(),
2832 None,
2833 )
2834 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2835
2836 cred.sign(&secret, Some(Utc::now()))
2837 .await
2838 .expect("signing a credential that carries an id");
2839 assert!(
2840 cred.verify_proof_with_public_key(secret.get_public_bytes())
2841 .is_ok(),
2842 "an id set before signing verifies"
2843 );
2844
2845 cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2848 assert!(
2849 cred.verify_proof_with_public_key(secret.get_public_bytes())
2850 .is_err(),
2851 "an id changed after signing must break the proof"
2852 );
2853 }
2854
2855 #[cfg(feature = "affinidi-signing")]
2856 #[tokio::test]
2857 async fn test_signing_error() {
2858 use affinidi_secrets_resolver::secrets::Secret;
2859
2860 let secret = Secret::generate_x25519(None, None).unwrap();
2861
2862 let mut cred = DTGCredential::new_vrc(
2863 "did:example:issuer".to_string(),
2864 "did:example:subject".to_string(),
2865 Utc::now(),
2866 None,
2867 );
2868
2869 assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2870 }
2871
2872 #[cfg(feature = "affinidi-signing")]
2873 #[test]
2874 fn test_signing_no_proof() {
2875 use crate::DTGCredentialError;
2876 use affinidi_secrets_resolver::secrets::Secret;
2877
2878 let cred = DTGCredential::new_vrc(
2879 "did:example:issuer".to_string(),
2880 "did:example:subject".to_string(),
2881 Utc::now(),
2882 None,
2883 );
2884
2885 let secret = Secret::generate_ed25519(None, None);
2886 match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2887 Err(DTGCredentialError::NotSigned) => {
2888 }
2890 _ => panic!("Expected NotSigned error!"),
2891 }
2892 }
2893}