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;
19
20#[derive(Clone, Copy, Debug)]
22pub enum W3CVCVersion {
23 V1_1,
25
26 V2_0,
28}
29
30impl TryFrom<&[String]> for W3CVCVersion {
31 type Error = DTGCredentialError;
32
33 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
35 if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
36 Ok(W3CVCVersion::V1_1)
37 } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
38 Ok(W3CVCVersion::V2_0)
39 } else {
40 Err(DTGCredentialError::UnknownVCVersion)
41 }
42 }
43}
44
45#[derive(Error, Debug)]
47pub enum DTGCredentialError {
48 #[error("Unknown credential type")]
49 UnknownCredential,
50
51 #[cfg(feature = "affinidi-signing")]
52 #[error("Data Integrity Error: {0}")]
53 DataIntegrity(#[from] DataIntegrityError),
54
55 #[error("Credential is not signed")]
56 NotSigned,
57
58 #[error("Unknown W3C VC Version")]
59 UnknownVCVersion,
60
61 #[error("AuthorityCredential carries an empty actions list, which confers nothing")]
66 EmptyAuthorityActions,
67
68 #[error("not an AuthorityCredential, so there is no authority to attenuate")]
70 NotAnAuthorityCredential,
71
72 #[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
78 AttenuationParentHasNoId,
79
80 #[error("attenuation would widen the parent grant: {0}")]
82 AttenuationWidens(String),
83
84 #[error("WitnessCredential is missing the required taskContext property")]
86 MissingTaskContext,
87
88 #[error("Could not canonicalize credential: {0}")]
90 Canonicalization(String),
91
92 #[error("Expected a {expected}, got a {got}")]
94 WrongCredentialType { expected: String, got: String },
95
96 #[error("Not a community-issued membership grant: {0}")]
99 NotAMembershipGrant(String),
100}
101
102#[derive(Serialize, Deserialize, Debug, Clone)]
104#[serde(try_from = "DTGCommon")]
105pub struct DTGCredential {
106 #[serde(flatten)]
108 credential: DTGCommon,
109
110 #[serde(skip)]
112 type_: DTGCredentialType,
113
114 #[serde(skip)]
116 version: W3CVCVersion,
117}
118
119impl DTGCredential {
120 pub fn credential(&self) -> &DTGCommon {
122 &self.credential
123 }
124
125 pub fn credential_mut(&mut self) -> &mut DTGCommon {
127 &mut self.credential
128 }
129
130 pub fn signed(&self) -> bool {
132 self.credential.signed()
133 }
134
135 pub fn type_(&self) -> DTGCredentialType {
137 self.type_.clone()
138 }
139
140 pub fn id(&self) -> Option<&str> {
146 self.credential.id()
147 }
148
149 pub fn issuer(&self) -> &str {
151 self.credential.issuer()
152 }
153
154 pub fn subject(&self) -> &str {
156 self.credential.subject()
157 }
158
159 pub fn valid_from(&self) -> DateTime<Utc> {
161 self.credential.valid_from()
162 }
163
164 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
166 self.credential.valid_until()
167 }
168
169 pub fn task_context(&self) -> Option<&str> {
174 self.credential.task_context()
175 }
176
177 pub fn digest(&self) -> Result<String, DTGCredentialError> {
205 let unsigned = DTGCommon {
206 proof: None,
207 ..self.credential.clone()
208 };
209 let value = serde_json::to_value(&unsigned)
210 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
211 digest_json(&value)
212 }
213
214 pub fn subject_digest(&self) -> Option<&str> {
220 match &self.credential.credential_subject {
221 CredentialSubject::Membership(subject) => subject.digest.as_deref(),
222 CredentialSubject::Witness(subject) => subject.digest.as_deref(),
223 _ => None,
224 }
225 }
226
227 #[deprecated(
233 since = "0.4.0",
234 note = "This encoding is not what DTG Core Credentials specifies, so digests \
235 produced by it do not interoperate. Use DTGCredential::digest, which \
236 returns the conformant `sha256:<lowercase hex>` over the proofless JCS \
237 canonical form. This method will be removed in a future release."
238 )]
239 pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
240 let canonical = serde_json_canonicalizer::to_vec(&self.credential)
241 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
242
243 let mut multihash = Vec::with_capacity(34);
245 multihash.extend_from_slice(&[0x12, 0x20]);
246 multihash.extend_from_slice(&Sha256::digest(&canonical));
247
248 Ok(multibase::encode(Base::Base58Btc, &multihash))
249 }
250
251 pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
261 let Some(digest) = self.subject_digest() else {
262 return Ok(false);
263 };
264
265 Ok(digest == referenced.digest()?)
266 }
267
268 pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
292 if !matches!(self.type_, DTGCredentialType::Membership)
293 || !matches!(grant.type_, DTGCredentialType::Membership)
294 {
295 return Ok(false);
296 }
297
298 if grant.subject_digest().is_some() {
301 return Ok(false);
302 }
303
304 if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
305 return Ok(false);
306 }
307
308 self.verify_digest(grant)
309 }
310
311 pub fn proof_value(&self) -> Option<&str> {
313 if let Some(proof) = &self.credential.proof {
314 proof.proof_value.as_deref()
315 } else {
316 None
317 }
318 }
319
320 #[cfg(feature = "affinidi-signing")]
321 pub async fn sign(
325 &mut self,
326 signing_secret: &Secret,
327 create_time: Option<DateTime<Utc>>,
328 ) -> Result<DataIntegrityProof, DTGCredentialError> {
329 let mut options = SignOptions::new();
330 if let Some(ts) = create_time {
331 options = options.with_created(ts);
332 }
333
334 let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
335
336 self.credential.proof = Some(proof.clone());
337 Ok(proof)
338 }
339
340 #[cfg(feature = "affinidi-signing")]
341 pub fn verify_proof_with_public_key(
345 &self,
346 public_key_bytes: &[u8],
347 ) -> Result<(), DTGCredentialError> {
348 let proof = if let Some(proof) = &self.credential.proof {
349 proof.clone()
350 } else {
351 use tracing::warn;
352
353 warn!("Trying to verify a DTG Credential that has no proof");
354 return Err(DTGCredentialError::NotSigned);
355 };
356
357 let unsigned = DTGCommon {
358 proof: None,
359 ..self.credential.clone()
360 };
361
362 proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
363 Ok(())
364 }
365
366 pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
368 self.version
369 }
370
371 pub fn is_personhood_credential(&self) -> bool {
373 if let DTGCredentialType::Membership = self.type_ {
374 self.credential
375 .type_
376 .contains(&"PersonhoodCredential".to_string())
377 } else {
378 false
379 }
380 }
381}
382
383pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
406 let proofless = match doc {
407 Value::Object(members) => {
408 let mut members = members.clone();
409 members.remove("proof");
410 Value::Object(members)
411 }
412 other => other.clone(),
415 };
416
417 let canonical = serde_json_canonicalizer::to_vec(&proofless)
418 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
419
420 const HEX: &[u8; 16] = b"0123456789abcdef";
421 let mut out = String::with_capacity("sha256:".len() + 64);
422 out.push_str("sha256:");
423 for byte in Sha256::digest(&canonical) {
424 out.push(HEX[(byte >> 4) as usize] as char);
425 out.push(HEX[(byte & 0x0f) as usize] as char);
426 }
427 Ok(out)
428}
429
430#[derive(Debug, Clone)]
432#[non_exhaustive]
433pub enum DTGCredentialType {
434 Membership,
435 Relationship,
436 Invitation,
437 Persona,
438 Endorsement,
439 Witness,
440
441 Authority,
447
448 Delegation,
454
455 #[deprecated(
457 since = "0.2.0",
458 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
459 It was removed from the DTG Core Credentials specification in Working Draft 01 \
460 and will be defined by the planned DTG Verifiable Data Structures specification. \
461 This variant will be removed in a future release."
462 )]
463 RCard,
464}
465
466impl Display for DTGCredentialType {
467 #[allow(deprecated)]
468 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469 match self {
470 DTGCredentialType::Membership => write!(f, "MembershipCredential"),
471 DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
472 DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
473 DTGCredentialType::Persona => write!(f, "PersonaCredential"),
474 DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
475 DTGCredentialType::Witness => write!(f, "WitnessCredential"),
476 DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
477 DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
478 DTGCredentialType::RCard => write!(f, "RCardCredential"),
479 }
480 }
481}
482
483const DTG_TYPES: [&str; 9] = [
485 "MembershipCredential",
486 "RelationshipCredential",
487 "InvitationCredential",
488 "PersonaCredential",
489 "EndorsementCredential",
490 "WitnessCredential",
491 "AuthorityCredential",
492 "DelegationCredential",
493 "RCardCredential",
494];
495
496impl TryFrom<&[String]> for DTGCredentialType {
497 type Error = DTGCredentialError;
498
499 #[allow(deprecated)]
500 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
501 if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
502 match *type_ {
503 "MembershipCredential" => Ok(DTGCredentialType::Membership),
504 "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
505 "InvitationCredential" => Ok(DTGCredentialType::Invitation),
506 "PersonaCredential" => Ok(DTGCredentialType::Persona),
507 "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
508 "WitnessCredential" => Ok(DTGCredentialType::Witness),
509 "AuthorityCredential" => Ok(DTGCredentialType::Authority),
510 "DelegationCredential" => Ok(DTGCredentialType::Delegation),
511 "RCardCredential" => Ok(DTGCredentialType::RCard),
512 _ => Err(DTGCredentialError::UnknownCredential),
513 }
514 } else {
515 Err(DTGCredentialError::UnknownCredential)
516 }
517 }
518}
519
520#[derive(Serialize, Deserialize, Debug, Clone)]
522#[serde(rename_all = "camelCase")]
523pub struct DTGCommon {
524 #[serde(rename = "@context")]
529 pub context: Vec<String>,
530
531 #[serde(rename = "type")]
536 pub type_: Vec<String>,
537
538 #[serde(skip_serializing_if = "Option::is_none", default)]
555 pub id: Option<String>,
556
557 pub issuer: String,
559
560 #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
562 pub valid_from: DateTime<Utc>,
563
564 #[serde(serialize_with = "iso8601_format_option")]
566 #[serde(
567 skip_serializing_if = "Option::is_none",
568 alias = "expirationDate",
569 default
570 )]
571 pub valid_until: Option<DateTime<Utc>>,
572
573 #[serde(skip_serializing_if = "Option::is_none", default)]
583 pub task_context: Option<String>,
584
585 pub credential_subject: CredentialSubject,
587
588 #[serde(skip_serializing_if = "Option::is_none", default)]
590 pub proof: Option<DataIntegrityProof>,
591}
592
593impl DTGCommon {
594 pub fn signed(&self) -> bool {
598 self.proof.is_some()
599 }
600
601 pub fn id(&self) -> Option<&str> {
603 self.id.as_deref()
604 }
605
606 pub fn issuer(&self) -> &str {
608 &self.issuer
609 }
610
611 #[allow(deprecated)]
613 pub fn subject(&self) -> &str {
614 match &self.credential_subject {
615 CredentialSubject::Basic(subject) => &subject.id,
616 CredentialSubject::Endorsement(subject) => &subject.id,
617 CredentialSubject::Witness(subject) => &subject.id,
618 CredentialSubject::Membership(subject) => &subject.id,
619 CredentialSubject::Authority(subject) => &subject.id,
620 CredentialSubject::RCard(subject) => &subject.id,
621 }
622 }
623
624 pub fn authority(&self) -> Option<&AuthorityGrant> {
630 match &self.credential_subject {
631 CredentialSubject::Authority(subject) => Some(&subject.authority),
632 _ => None,
633 }
634 }
635
636 pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
642 match &mut self.credential_subject {
643 CredentialSubject::Authority(subject) => Some(&mut subject.authority),
644 _ => None,
645 }
646 }
647
648 pub fn valid_from(&self) -> DateTime<Utc> {
650 self.valid_from
651 }
652
653 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
655 self.valid_until
656 }
657
658 pub fn task_context(&self) -> Option<&str> {
660 self.task_context.as_deref()
661 }
662}
663
664impl Default for DTGCommon {
666 fn default() -> Self {
667 DTGCommon {
668 context: vec![
669 "https://www.w3.org/ns/credentials/v2".to_string(),
670 "https://firstperson.network/credentials/dtg/v1".to_string(),
671 ],
672 type_: vec![
673 "VerifiableCredential".to_string(),
674 "DTGCredential".to_string(),
675 ],
676 id: None,
677 issuer: String::new(),
678 valid_from: Utc::now(),
679 valid_until: None,
680 task_context: None,
681 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
682 id: String::new(),
683 }),
684 proof: None,
685 }
686 }
687}
688
689impl TryFrom<DTGCommon> for DTGCredential {
691 type Error = DTGCredentialError;
692
693 #[allow(deprecated)]
694 fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
695 match &value.type_.as_slice().try_into()? {
696 DTGCredentialType::Membership => {
697 let subject = match &value.credential_subject {
702 CredentialSubject::Membership(subject) => subject.clone(),
705
706 CredentialSubject::Basic(subject) => CredentialSubjectMembership {
708 id: subject.id.clone(),
709 digest: None,
710 },
711
712 CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
718 CredentialSubjectMembership {
719 id: subject.id.clone(),
720 digest: subject.digest.clone(),
721 }
722 }
723
724 _ => return Err(DTGCredentialError::UnknownCredential),
725 };
726
727 Ok(DTGCredential {
728 type_: DTGCredentialType::Membership,
729 version: value.context.as_slice().try_into()?,
730 credential: DTGCommon {
731 credential_subject: CredentialSubject::Membership(subject),
732 ..value
733 },
734 })
735 }
736 DTGCredentialType::Relationship => Ok(DTGCredential {
737 type_: DTGCredentialType::Relationship,
738 version: value.context.as_slice().try_into()?,
739 credential: value,
740 }),
741 DTGCredentialType::Invitation => Ok(DTGCredential {
742 type_: DTGCredentialType::Invitation,
743 version: value.context.as_slice().try_into()?,
744 credential: value,
745 }),
746 DTGCredentialType::Persona => Ok(DTGCredential {
747 type_: DTGCredentialType::Persona,
748 version: value.context.as_slice().try_into()?,
749 credential: value,
750 }),
751 DTGCredentialType::Endorsement => {
752 if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
753 Ok(DTGCredential {
754 type_: DTGCredentialType::Endorsement,
755 version: value.context.as_slice().try_into()?,
756 credential: value,
757 })
758 } else {
759 Err(DTGCredentialError::UnknownCredential)
760 }
761 }
762 DTGCredentialType::Witness => {
763 if value.task_context.is_none() {
767 return Err(DTGCredentialError::MissingTaskContext);
768 }
769
770 match &value.credential_subject {
771 CredentialSubject::Witness(_) => Ok(DTGCredential {
772 type_: DTGCredentialType::Witness,
773 version: value.context.as_slice().try_into()?,
774 credential: value,
775 }),
776 CredentialSubject::Basic(subject) => {
777 Ok(DTGCredential {
779 type_: DTGCredentialType::Witness,
780 version: value.context.as_slice().try_into()?,
781 credential: DTGCommon {
782 credential_subject: CredentialSubject::Witness(
783 CredentialSubjectWitness {
784 id: subject.id.clone(),
785 digest: None,
786 witness_context: None,
787 },
788 ),
789 ..value
790 },
791 })
792 }
793 _ => Err(DTGCredentialError::UnknownCredential),
794 }
795 }
796 DTGCredentialType::Authority => {
797 match &value.credential_subject {
803 CredentialSubject::Authority(subject) => {
804 if subject.authority.actions.is_empty() {
805 return Err(DTGCredentialError::EmptyAuthorityActions);
808 }
809 Ok(DTGCredential {
810 type_: DTGCredentialType::Authority,
811 version: value.context.as_slice().try_into()?,
812 credential: value,
813 })
814 }
815 _ => Err(DTGCredentialError::UnknownCredential),
816 }
817 }
818 DTGCredentialType::Delegation => Ok(DTGCredential {
819 type_: DTGCredentialType::Delegation,
820 version: value.context.as_slice().try_into()?,
821 credential: value,
822 }),
823 DTGCredentialType::RCard => match &value.credential_subject {
824 CredentialSubject::RCard { .. } => Ok(DTGCredential {
825 type_: DTGCredentialType::RCard,
826 version: value.context.as_slice().try_into()?,
827 credential: value,
828 }),
829 _ => Err(DTGCredentialError::UnknownCredential),
830 },
831 }
832 }
833}
834
835fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
838where
839 S: Serializer,
840{
841 s.serialize_str(
842 timestamp
843 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
844 .as_str(),
845 )
846}
847
848fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
849where
850 S: Serializer,
851{
852 if let Some(timestamp) = timestamp {
853 s.serialize_str(
854 timestamp
855 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
856 .as_str(),
857 )
858 } else {
859 s.serialize_none()
860 }
861}
862
863#[allow(deprecated)]
872#[derive(Serialize, Deserialize, Debug, Clone)]
873#[serde(untagged)]
874pub enum CredentialSubject {
875 Endorsement(CredentialSubjectEndorsement),
877
878 #[deprecated(
880 since = "0.2.0",
881 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
882 See DTGCredentialType::RCard. This variant will be removed in a future release."
883 )]
884 RCard(CredentialSubjectRCard),
885
886 Basic(CredentialSubjectBasic),
889
890 Witness(CredentialSubjectWitness),
892
893 Authority(CredentialSubjectAuthority),
899
900 Membership(CredentialSubjectMembership),
918}
919
920#[derive(Serialize, Deserialize, Debug, Clone)]
922#[serde(deny_unknown_fields)]
923pub struct CredentialSubjectBasic {
924 pub id: String,
925}
926
927#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
939#[serde(rename_all = "camelCase", deny_unknown_fields)]
940pub struct AuthorityGrant {
941 pub scope: String,
946
947 pub actions: Vec<String>,
954
955 #[serde(skip_serializing_if = "Option::is_none")]
960 pub parent: Option<String>,
961
962 #[serde(skip_serializing_if = "Option::is_none")]
967 pub audience: Option<String>,
968}
969
970#[derive(Serialize, Deserialize, Debug, Clone)]
972#[serde(rename_all = "camelCase", deny_unknown_fields)]
973pub struct CredentialSubjectAuthority {
974 pub id: String,
976
977 pub authority: AuthorityGrant,
979}
980
981#[derive(Serialize, Deserialize, Debug, Clone)]
989#[serde(rename_all = "camelCase", deny_unknown_fields)]
990pub struct CredentialSubjectMembership {
991 pub id: String,
992
993 #[serde(skip_serializing_if = "Option::is_none", default)]
1000 pub digest: Option<String>,
1001}
1002
1003#[derive(Serialize, Deserialize, Debug, Clone)]
1005#[serde(deny_unknown_fields)]
1006pub struct CredentialSubjectEndorsement {
1007 pub id: String,
1008 pub endorsement: Value,
1010}
1011
1012#[derive(Serialize, Deserialize, Debug, Clone)]
1014#[serde(rename_all = "camelCase", deny_unknown_fields)]
1015pub struct CredentialSubjectWitness {
1016 pub id: String,
1017
1018 #[serde(skip_serializing_if = "Option::is_none")]
1019 pub digest: Option<String>,
1020
1021 #[serde(skip_serializing_if = "Option::is_none")]
1023 pub witness_context: Option<WitnessContext>,
1024}
1025
1026#[derive(Serialize, Deserialize, Debug, Clone)]
1028#[serde(rename_all = "camelCase", deny_unknown_fields)]
1029pub struct WitnessContext {
1030 pub event: Option<String>,
1032
1033 pub session_id: Option<String>,
1035
1036 pub method: Option<String>,
1038}
1039
1040#[deprecated(
1042 since = "0.2.0",
1043 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1044 See DTGCredentialType::RCard. This struct will be removed in a future release."
1045)]
1046#[derive(Serialize, Deserialize, Debug, Clone)]
1047#[serde(deny_unknown_fields)]
1048pub struct CredentialSubjectRCard {
1049 pub id: String,
1050
1051 pub card: Value,
1053}
1054
1055#[cfg(test)]
1056#[allow(deprecated)]
1057mod tests {
1058 use crate::{
1059 CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
1060 DTGCredentialType, W3CVCVersion, digest_json,
1061 };
1062 use chrono::{DateTime, Utc};
1063 use serde_json::Value;
1064
1065 #[test]
1066 fn test_vmc_vc_1_deserialize() {
1067 let vmc: DTGCredential = match serde_json::from_str(
1069 r#"{
1070"@context": [
1071 "https://www.w3.org/2018/credentials/v1",
1072 "https://firstperson.network/credentials/dtg/v1",
1073 "https://w3id.org/security/suites/ed25519-2020/v1"
1074 ],
1075 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1076 "issuer": "did:web:chess-club.example",
1077 "issuanceDate": "2026-01-06T10:00:00Z",
1078 "expirationDate": "2027-01-06T10:00:00Z",
1079 "credentialSubject": {
1080 "id": "did:key:z6MkpTHR8VNs..."
1081 }
1082 }"#,
1083 ) {
1084 Ok(vmc) => vmc,
1085 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1086 };
1087
1088 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1089 assert!(matches!(
1090 vmc.credential().credential_subject,
1091 CredentialSubject::Membership(_)
1092 ));
1093 assert!(matches!(vmc.version, W3CVCVersion::V1_1));
1094 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
1095 }
1096
1097 #[test]
1098 fn test_missing_w3c_context() {
1099 assert!(
1101 serde_json::from_str::<DTGCredential>(
1102 r#"{
1103"@context": [
1104 "https://firstperson.network/credentials/dtg/v1",
1105 "https://w3id.org/security/suites/ed25519-2020/v1"
1106 ],
1107 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1108 "issuer": "did:web:chess-club.example",
1109 "issuanceDate": "2026-01-06T10:00:00Z",
1110 "expirationDate": "2027-01-06T10:00:00Z",
1111 "credentialSubject": {
1112 "id": "did:key:z6MkpTHR8VNs..."
1113 }
1114 }"#,
1115 )
1116 .is_err()
1117 );
1118 }
1119
1120 #[test]
1121 fn test_mutable_credential() {
1122 let mut vmc = DTGCredential::new_vmc(
1123 "did:example:issuer".to_string(),
1124 "did:example:subject".to_string(),
1125 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1126 .unwrap()
1127 .with_timezone(&Utc),
1128 None,
1129 false,
1130 );
1131
1132 let cred = vmc.credential_mut();
1133 cred.type_.push("PersonhoodCredential".to_string());
1134 assert!(vmc.is_personhood_credential());
1135 }
1136
1137 #[test]
1138 fn test_vmc_deserialize() {
1139 let vmc: DTGCredential = match serde_json::from_str(
1140 r#"{
1141 "@context": ["https://www.w3.org/ns/credentials/v2"],
1142 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1143 "issuer": "did:example:community",
1144 "validFrom": "2024-06-18T10:00:00Z",
1145 "credentialSubject": { "id": "did:example:rDid" }
1146 }"#,
1147 ) {
1148 Ok(vmc) => vmc,
1149 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1150 };
1151
1152 assert!(!vmc.is_personhood_credential());
1153 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1154 assert!(matches!(
1155 vmc.credential().credential_subject,
1156 CredentialSubject::Membership(_)
1157 ));
1158 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1159 }
1160
1161 #[test]
1162 fn test_vmc_phc_deserialize() {
1163 let vmc: DTGCredential = match serde_json::from_str(
1164 r#"{
1165 "@context": ["https://www.w3.org/ns/credentials/v2"],
1166 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
1167 "issuer": "did:example:community",
1168 "validFrom": "2024-06-18T10:00:00Z",
1169 "credentialSubject": { "id": "did:example:rDid" }
1170 }"#,
1171 ) {
1172 Ok(vmc) => vmc,
1173 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1174 };
1175
1176 assert!(vmc.is_personhood_credential());
1177 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1178 assert!(matches!(
1179 vmc.credential().credential_subject,
1180 CredentialSubject::Membership(_)
1181 ));
1182 }
1183
1184 #[test]
1185 fn test_vrc_deserialize() {
1186 let vrc: DTGCredential = match serde_json::from_str(
1187 r#"{
1188 "@context": ["https://www.w3.org/ns/credentials/v2"],
1189 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1190 "issuer": "did:example:governmentAgencyDid",
1191 "validFrom": "2024-06-18T10:00:00Z",
1192 "credentialSubject": { "id": "did:example:citizenRDid" }
1193 }"#,
1194 ) {
1195 Ok(vrc) => vrc,
1196 Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1197 };
1198
1199 assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1200 assert!(matches!(
1201 vrc.credential().credential_subject,
1202 CredentialSubject::Basic(_)
1203 ));
1204 }
1205
1206 #[test]
1207 fn test_vic_deserialize() {
1208 let vic: DTGCredential = match serde_json::from_str(
1209 r#"{
1210 "@context": ["https://www.w3.org/ns/credentials/v2"],
1211 "type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
1212 "issuer": "did:example:governmentAgencyVicDid",
1213 "validFrom": "2024-06-18T10:00:00Z",
1214 "credentialSubject": { "id": "did:example:citizenRDid" }
1215 }"#,
1216 ) {
1217 Ok(vic) => vic,
1218 Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1219 };
1220
1221 assert!(!vic.is_personhood_credential());
1222 assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1223 assert!(matches!(
1224 vic.credential().credential_subject,
1225 CredentialSubject::Basic(_)
1226 ));
1227 }
1228
1229 #[test]
1230 fn test_vpc_deserialize() {
1231 let vpc: DTGCredential = match serde_json::from_str(
1232 r#"{
1233 "@context": ["https://www.w3.org/ns/credentials/v2"],
1234 "type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
1235 "issuer": "did:example:governmentAgencyDid",
1236 "validFrom": "2024-06-18T10:00:00Z",
1237 "credentialSubject": { "id": "did:example:citizenRDid" }
1238 }"#,
1239 ) {
1240 Ok(vpc) => vpc,
1241 Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1242 };
1243
1244 assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1245 assert!(matches!(
1246 vpc.credential().credential_subject,
1247 CredentialSubject::Basic(_)
1248 ));
1249 }
1250
1251 #[test]
1252 fn test_vec_deserialize() {
1253 let vec: DTGCredential = match serde_json::from_str(
1254 r#"{
1255 "@context": ["https://www.w3.org/ns/credentials/v2"],
1256 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1257 "issuer": "did:example:governmentAgencyDid",
1258 "validFrom": "2024-06-18T10:00:00Z",
1259 "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1260 }"#,
1261 ) {
1262 Ok(vec) => vec,
1263 Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1264 };
1265
1266 assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1267 assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1268 assert!(matches!(
1269 vec.credential().credential_subject,
1270 CredentialSubject::Endorsement(_)
1271 ));
1272 }
1273
1274 #[test]
1275 fn test_vec_bad_deserialize() {
1276 match serde_json::from_str::<DTGCredential>(
1277 r#"{
1278 "@context": ["https://www.w3.org/ns/credentials/v2"],
1279 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1280 "issuer": "did:example:governmentAgencyDid",
1281 "validFrom": "2024-06-18T10:00:00Z",
1282 "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1283 }"#,
1284 ) {
1285 Ok(_) => panic!("Expected Unknown Credential type"),
1286 Err(_) => {
1287 }
1289 };
1290 }
1291
1292 #[test]
1293 fn test_vwc_simple_deserialize() {
1294 let vwc: DTGCredential = match serde_json::from_str(
1295 r#"{
1296 "@context": ["https://www.w3.org/ns/credentials/v2"],
1297 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1298 "issuer": "did:example:governmentAgencyDid",
1299 "validFrom": "2024-06-18T10:00:00Z",
1300 "taskContext": "thread-abc-123",
1301 "credentialSubject": { "id": "did:example:citizenRDid" }
1302 }"#,
1303 ) {
1304 Ok(vwc) => vwc,
1305 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1306 };
1307
1308 assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1309 assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1310 assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1311 assert!(matches!(
1312 vwc.credential().credential_subject,
1313 CredentialSubject::Witness(_)
1314 ));
1315 }
1316
1317 #[test]
1318 fn test_vwc_full_deserialize() {
1319 let vwc: DTGCredential = match serde_json::from_str(
1320 r#"{
1321 "@context": ["https://www.w3.org/ns/credentials/v2"],
1322 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1323 "issuer": "did:example:governmentAgencyDid",
1324 "validFrom": "2024-06-18T10:00:00Z",
1325 "taskContext": "thread-abc-123",
1326 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} }
1327 }"#,
1328 ) {
1329 Ok(vwc) => vwc,
1330 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1331 };
1332
1333 assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1334 assert!(matches!(
1335 vwc.credential().credential_subject,
1336 CredentialSubject::Witness(_)
1337 ));
1338 }
1339
1340 #[test]
1341 fn test_vwc_bad_deserialize() {
1342 if serde_json::from_str::<DTGCredential>(
1343 r#"{
1344 "@context": ["https://www.w3.org/ns/credentials/v2"],
1345 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1346 "issuer": "did:example:governmentAgencyDid",
1347 "validFrom": "2024-06-18T10:00:00Z",
1348 "taskContext": "thread-abc-123",
1349 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {} }
1350 }"#,
1351 ).is_ok() {
1352 panic!("Should have failed due to wrong CredentialSubject!");
1353 }
1354 }
1355
1356 #[test]
1357 fn test_rcard_simple_deserialize() {
1358 let rcard: DTGCredential = match serde_json::from_str(
1359 r#"{
1360 "@context": ["https://www.w3.org/ns/credentials/v2"],
1361 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1362 "issuer": "did:example:governmentAgencyDid",
1363 "validFrom": "2024-06-18T10:00:00Z",
1364 "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1365 }"#,
1366 ) {
1367 Ok(rcard) => rcard,
1368 Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1369 };
1370
1371 assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1372 assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1373 assert!(matches!(
1374 rcard.credential().credential_subject,
1375 CredentialSubject::RCard(_)
1376 ));
1377 }
1378
1379 #[test]
1380 fn test_rcard_bad_deserialize() {
1381 if serde_json::from_str::<DTGCredential>(
1382 r#"{
1383 "@context": ["https://www.w3.org/ns/credentials/v2"],
1384 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1385 "issuer": "did:example:governmentAgencyDid",
1386 "validFrom": "2024-06-18T10:00:00Z",
1387 "credentialSubject": { "id": "did:example:citizenRDid" }
1388 }"#,
1389 )
1390 .is_ok()
1391 {
1392 panic!("Should have failed due to wrong CredentialSubject!");
1393 }
1394 }
1395 #[test]
1396 fn test_deserialize_unknown() {
1397 match serde_json::from_str::<DTGCredential>(
1398 r#"{
1399 "@context": ["https://www.w3.org/ns/credentials/v2"],
1400 "type": ["VerifiableCredential", "DTGCredential", "UnknownCredential"],
1401 "issuer": "did:example:governmentAgencyDid",
1402 "validFrom": "2024-06-18T10:00:00Z",
1403 "credentialSubject": { "id": "did:example:citizenRDid" }
1404 }"#,
1405 ) {
1406 Ok(_) => panic!("Expected Unknown Credential type"),
1407 Err(e) => {
1408 if e.to_string() == "Unknown credential type" {
1409 } else {
1411 panic!("Wrong error type returned");
1412 }
1413 }
1414 };
1415 }
1416
1417 #[test]
1418 fn test_deserialize_mismatched_credential_subject() {
1419 match serde_json::from_str::<DTGCredential>(
1420 r#"{
1421 "@context": ["https://www.w3.org/ns/credentials/v2"],
1422 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1423 "issuer": "did:example:governmentAgencyDid",
1424 "validFrom": "2024-06-18T10:00:00Z",
1425 "credentialSubject": { "id": "did:example:citizenRDid" }
1426 }"#,
1427 ) {
1428 Ok(_) => panic!("Expected Unknown Credential type"),
1429 Err(e) => {
1430 if e.to_string() == "Unknown credential type" {
1431 } else {
1433 panic!("Wrong error type returned");
1434 }
1435 }
1436 };
1437 }
1438
1439 #[test]
1440 fn test_proof_signed() {
1441 let cred: DTGCredential = match serde_json::from_str(
1442 r#"{
1443 "@context": ["https://www.w3.org/ns/credentials/v2"],
1444 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1445 "issuer": "did:example:community",
1446 "validFrom": "2024-06-18T10:00:00Z",
1447 "credentialSubject": { "id": "did:example:rDid" },
1448 "proof": {
1449 "type": "DataIntegrityProof",
1450 "cryptosuite": "eddsa-jcs-2022",
1451 "created": "2025-12-04T00:00:00",
1452 "verificationMethod": "did:example:test#key-1",
1453 "proofPurpose": "assertionMethod",
1454 "proofValue": "abcd"
1455 }
1456 }"#,
1457 ) {
1458 Ok(vmc) => vmc,
1459 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1460 };
1461
1462 assert!(cred.signed());
1463 assert!(cred.proof_value().is_some());
1464 }
1465
1466 #[test]
1467 fn test_proof_not_signed() {
1468 let cred: DTGCredential = match serde_json::from_str(
1469 r#"{
1470 "@context": ["https://www.w3.org/ns/credentials/v2"],
1471 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1472 "issuer": "did:example:community",
1473 "validFrom": "2024-06-18T10:00:00Z",
1474 "credentialSubject": { "id": "did:example:rDid" }
1475 }"#,
1476 ) {
1477 Ok(vmc) => vmc,
1478 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1479 };
1480
1481 assert!(!cred.signed());
1482 assert!(cred.proof_value().is_none());
1483 }
1484
1485 #[test]
1486 fn test_helpers() {
1487 let cred: DTGCredential = match serde_json::from_str(
1488 r#"{
1489 "@context": ["https://www.w3.org/ns/credentials/v2"],
1490 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1491 "issuer": "did:example:issuer",
1492 "validFrom": "2024-06-18T00:00:00Z",
1493 "credentialSubject": { "id": "did:example:subject" }
1494 }"#,
1495 ) {
1496 Ok(vmc) => vmc,
1497 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1498 };
1499
1500 assert_eq!(cred.issuer(), "did:example:issuer");
1501 assert_eq!(cred.subject(), "did:example:subject");
1502 assert_eq!(
1503 cred.valid_from()
1504 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1505 "2024-06-18T00:00:00Z"
1506 );
1507 assert_eq!(cred.valid_until(), None);
1508 }
1509
1510 #[test]
1511 fn test_valid_until() {
1512 let cred: DTGCredential = match serde_json::from_str(
1513 r#"{
1514 "@context": ["https://www.w3.org/ns/credentials/v2"],
1515 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1516 "issuer": "did:example:issuer",
1517 "validFrom": "2024-06-18T00:00:00Z",
1518 "validUntil": "2030-01-01T00:00:00Z",
1519 "credentialSubject": { "id": "did:example:subject" }
1520 }"#,
1521 ) {
1522 Ok(vmc) => vmc,
1523 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1524 };
1525
1526 assert_eq!(
1527 cred.valid_until()
1528 .unwrap()
1529 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1530 "2030-01-01T00:00:00Z"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_bad_type() {
1536 assert!(
1537 std::convert::TryInto::<DTGCredentialType>::try_into(
1538 vec!["bad_type".to_string()].as_slice(),
1539 )
1540 .is_err()
1541 );
1542 }
1543
1544 #[test]
1545 fn test_badly_constructed_vwc() {
1546 let mut cred = DTGCommon::default();
1547 cred.type_.push("WitnessCredential".to_string());
1548 cred.task_context = Some("thread-abc-123".to_string());
1551 cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1552 id: "did:example:bad".to_string(),
1553 card: Value::Null,
1554 });
1555
1556 assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1557 }
1558
1559 #[test]
1560 fn test_vwc_missing_task_context() {
1561 match serde_json::from_str::<DTGCredential>(
1563 r#"{
1564 "@context": ["https://www.w3.org/ns/credentials/v2"],
1565 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1566 "issuer": "did:example:witness",
1567 "validFrom": "2024-06-18T10:00:00Z",
1568 "credentialSubject": { "id": "did:example:observed" }
1569 }"#,
1570 ) {
1571 Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1572 Err(e) => assert_eq!(
1573 e.to_string(),
1574 "WitnessCredential is missing the required taskContext property"
1575 ),
1576 }
1577 }
1578
1579 #[test]
1580 fn test_task_context_round_trip() {
1581 let raw = r#"{
1584 "@context": ["https://www.w3.org/ns/credentials/v2"],
1585 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1586 "issuer": "did:example:witness",
1587 "validFrom": "2024-06-18T10:00:00Z",
1588 "taskContext": "thread-abc-123",
1589 "credentialSubject": { "id": "did:example:observed" }
1590 }"#;
1591
1592 let cred: DTGCredential = serde_json::from_str(raw).unwrap();
1593 let out = serde_json::to_string(&cred).unwrap();
1594
1595 assert!(out.contains(r#""taskContext":"thread-abc-123""#));
1596 }
1597
1598 #[test]
1599 fn test_task_context_optional_on_other_types() {
1600 let vrc: DTGCredential = serde_json::from_str(
1602 r#"{
1603 "@context": ["https://www.w3.org/ns/credentials/v2"],
1604 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1605 "issuer": "did:example:issuer",
1606 "validFrom": "2024-06-18T10:00:00Z",
1607 "credentialSubject": { "id": "did:example:subject" }
1608 }"#,
1609 )
1610 .unwrap();
1611
1612 assert_eq!(vrc.task_context(), None);
1613 assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
1615 }
1616
1617 #[test]
1618 fn test_digest_multibase() {
1619 let vrc = DTGCredential::new_vrc(
1620 "did:example:issuer".to_string(),
1621 "did:example:subject".to_string(),
1622 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1623 .unwrap()
1624 .with_timezone(&Utc),
1625 None,
1626 );
1627
1628 let digest = vrc.digest_multibase().unwrap();
1629
1630 assert!(digest.starts_with('z'));
1632
1633 let (base, bytes) = multibase::decode(&digest).unwrap();
1635 assert_eq!(base, multibase::Base::Base58Btc);
1636 assert_eq!(bytes.len(), 34);
1637 assert_eq!(&bytes[..2], &[0x12, 0x20]);
1638
1639 assert_eq!(digest, vrc.digest_multibase().unwrap());
1641
1642 let other = DTGCredential::new_vrc(
1644 "did:example:issuer".to_string(),
1645 "did:example:someone-else".to_string(),
1646 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1647 .unwrap()
1648 .with_timezone(&Utc),
1649 None,
1650 );
1651 assert_ne!(digest, other.digest_multibase().unwrap());
1652 }
1653
1654 #[test]
1655 fn test_verify_digest() {
1656 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1657 .unwrap()
1658 .with_timezone(&Utc);
1659
1660 let vrc = DTGCredential::new_vrc(
1661 "did:example:issuer".to_string(),
1662 "did:example:subject".to_string(),
1663 valid_from,
1664 None,
1665 );
1666
1667 let vwc = DTGCredential::new_vwc(
1668 "did:example:witness".to_string(),
1669 "did:example:issuer".to_string(),
1671 valid_from,
1672 None,
1673 "thread-abc-123".to_string(),
1674 Some(vrc.digest().unwrap()),
1675 None,
1676 );
1677
1678 assert!(vwc.verify_digest(&vrc).unwrap());
1679
1680 let other = DTGCredential::new_vrc(
1682 "did:example:issuer".to_string(),
1683 "did:example:someone-else".to_string(),
1684 valid_from,
1685 None,
1686 );
1687 assert!(!vwc.verify_digest(&other).unwrap());
1688 }
1689
1690 #[test]
1691 fn test_verify_digest_without_digest() {
1692 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1693 .unwrap()
1694 .with_timezone(&Utc);
1695
1696 let vrc = DTGCredential::new_vrc(
1697 "did:example:issuer".to_string(),
1698 "did:example:subject".to_string(),
1699 valid_from,
1700 None,
1701 );
1702
1703 let vwc = DTGCredential::new_vwc(
1705 "did:example:witness".to_string(),
1706 "did:example:issuer".to_string(),
1707 valid_from,
1708 None,
1709 "thread-abc-123".to_string(),
1710 None,
1711 None,
1712 );
1713
1714 assert!(!vwc.verify_digest(&vrc).unwrap());
1715 }
1716
1717 #[test]
1722 fn test_digest_is_sha256_hex_over_the_proofless_jcs_form() {
1723 let vmc = DTGCredential::new_vmc(
1724 "did:example:community".to_string(),
1725 "did:example:member".to_string(),
1726 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1727 .unwrap()
1728 .with_timezone(&Utc),
1729 None,
1730 false,
1731 )
1732 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1733
1734 let digest = vmc.digest().unwrap();
1735
1736 let (scheme, hex) = digest.split_once(':').expect("`sha256:` prefixed");
1737 assert_eq!(scheme, "sha256");
1738 assert_eq!(hex.len(), 64, "32 bytes, hex encoded");
1739 assert!(
1740 hex.chars()
1741 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1742 "lowercase hex only, got {hex}"
1743 );
1744
1745 assert_eq!(
1751 digest,
1752 "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
1753 );
1754
1755 assert_eq!(digest, vmc.digest().unwrap());
1757 }
1758
1759 #[cfg(feature = "affinidi-signing")]
1763 #[tokio::test]
1764 async fn test_digest_is_unchanged_by_signing() {
1765 use affinidi_secrets_resolver::secrets::Secret;
1766
1767 let secret = Secret::generate_ed25519(None, None);
1768
1769 let mut vmc = DTGCredential::new_vmc(
1770 "did:example:community".to_string(),
1771 "did:example:member".to_string(),
1772 Utc::now(),
1773 None,
1774 false,
1775 );
1776
1777 let before = vmc.digest().unwrap();
1778 vmc.sign(&secret, None).await.expect("signs");
1779 assert!(vmc.signed());
1780 assert_eq!(before, vmc.digest().unwrap());
1781 }
1782
1783 fn wire(c: &DTGCredential) -> Value {
1785 serde_json::to_value(c.credential()).expect("credential serialises")
1786 }
1787
1788 #[test]
1791 fn test_member_vmc_acknowledges_its_grant() {
1792 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1793 .unwrap()
1794 .with_timezone(&Utc);
1795
1796 let grant = DTGCredential::new_vmc(
1797 "did:example:community".to_string(),
1798 "did:example:member".to_string(),
1799 valid_from,
1800 None,
1801 false,
1802 );
1803
1804 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1805
1806 assert_eq!(ack.issuer(), "did:example:member");
1808 assert_eq!(ack.subject(), "did:example:community");
1809
1810 assert_eq!(grant.subject_digest(), None);
1812 assert_eq!(ack.subject_digest(), Some(grant.digest().unwrap().as_str()));
1813
1814 assert!(ack.acknowledges(&grant).unwrap());
1815 }
1816
1817 #[test]
1820 fn test_acknowledges_rejects_a_mismatched_pair() {
1821 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1822 .unwrap()
1823 .with_timezone(&Utc);
1824
1825 let grant = DTGCredential::new_vmc(
1826 "did:example:community".to_string(),
1827 "did:example:member".to_string(),
1828 valid_from,
1829 None,
1830 false,
1831 );
1832 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1833
1834 let other_member = DTGCredential::new_vmc(
1836 "did:example:community".to_string(),
1837 "did:example:someone-else".to_string(),
1838 valid_from,
1839 None,
1840 false,
1841 );
1842 assert!(!ack.acknowledges(&other_member).unwrap());
1843
1844 let other_community = DTGCredential::new_vmc(
1846 "did:example:other-community".to_string(),
1847 "did:example:member".to_string(),
1848 valid_from,
1849 None,
1850 false,
1851 );
1852 assert!(!ack.acknowledges(&other_community).unwrap());
1853
1854 let renewed = DTGCredential::new_vmc(
1858 "did:example:community".to_string(),
1859 "did:example:member".to_string(),
1860 valid_from + chrono::Duration::days(365),
1861 None,
1862 false,
1863 );
1864 assert!(!ack.acknowledges(&renewed).unwrap());
1865
1866 let ack_of_ack =
1868 DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1869 assert!(!ack_of_ack.acknowledges(&ack).unwrap());
1870
1871 assert!(!grant.acknowledges(&grant).unwrap());
1873 }
1874
1875 #[test]
1887 fn the_acknowledgement_digests_members_the_model_does_not_know() {
1888 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1889 .unwrap()
1890 .with_timezone(&Utc);
1891
1892 let mut grant = wire(&DTGCredential::new_vmc(
1893 "did:example:community".to_string(),
1894 "did:example:member".to_string(),
1895 valid_from,
1896 None,
1897 false,
1898 ));
1899 grant["credentialStatus"] = serde_json::json!({
1900 "id": "https://community.example/status#7",
1901 "type": "BitstringStatusListEntry",
1902 "statusPurpose": "revocation",
1903 "statusListIndex": "7"
1904 });
1905
1906 let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
1908 assert!(
1909 wire(&parsed).get("credentialStatus").is_none(),
1910 "the model is expected NOT to carry credentialStatus; if it now does, this \
1911 test has stopped guarding anything and the API can be simplified"
1912 );
1913
1914 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1915
1916 assert_eq!(
1917 ack.subject_digest(),
1918 Some(digest_json(&grant).unwrap().as_str()),
1919 "the acknowledgement must digest the grant as received"
1920 );
1921 assert_ne!(
1922 ack.subject_digest(),
1923 Some(parsed.digest().unwrap().as_str()),
1924 "digesting the parsed model would produce a digest the community cannot match"
1925 );
1926 }
1927
1928 #[test]
1932 fn digest_json_agrees_with_digest_where_the_model_is_complete() {
1933 let vmc = DTGCredential::new_vmc(
1934 "did:example:community".to_string(),
1935 "did:example:member".to_string(),
1936 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1937 .unwrap()
1938 .with_timezone(&Utc),
1939 None,
1940 false,
1941 )
1942 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1943
1944 assert_eq!(vmc.digest().unwrap(), digest_json(&wire(&vmc)).unwrap());
1945 }
1946
1947 #[test]
1950 fn test_acknowledges_is_membership_only() {
1951 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1952 .unwrap()
1953 .with_timezone(&Utc);
1954
1955 let grant = DTGCredential::new_vmc(
1956 "did:example:community".to_string(),
1957 "did:example:member".to_string(),
1958 valid_from,
1959 None,
1960 false,
1961 );
1962 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1963
1964 let vrc = DTGCredential::new_vrc(
1965 "did:example:member".to_string(),
1966 "did:example:community".to_string(),
1967 valid_from,
1968 None,
1969 );
1970 assert!(!ack.acknowledges(&vrc).unwrap());
1971
1972 let vwc = DTGCredential::new_vwc(
1974 "did:example:witness".to_string(),
1975 "did:example:community".to_string(),
1976 valid_from,
1977 None,
1978 "thread-abc-123".to_string(),
1979 Some(grant.digest().unwrap()),
1980 None,
1981 );
1982 assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
1983 assert!(
1984 !vwc.acknowledges(&grant).unwrap(),
1985 "but a VWC is not the member's acknowledgement"
1986 );
1987 }
1988
1989 #[test]
1993 fn test_new_member_vmc_refuses_a_non_grant() {
1994 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1995 .unwrap()
1996 .with_timezone(&Utc);
1997
1998 let vrc = DTGCredential::new_vrc(
1999 "did:example:a".to_string(),
2000 "did:example:b".to_string(),
2001 valid_from,
2002 None,
2003 );
2004 assert!(matches!(
2005 DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
2006 Err(DTGCredentialError::NotAMembershipGrant(_))
2007 ));
2008
2009 let grant = DTGCredential::new_vmc(
2010 "did:example:community".to_string(),
2011 "did:example:member".to_string(),
2012 valid_from,
2013 None,
2014 false,
2015 );
2016 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2017 assert!(matches!(
2018 DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
2019 Err(DTGCredentialError::NotAMembershipGrant(_))
2020 ));
2021 }
2022
2023 #[test]
2028 fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
2029 let vmc: DTGCredential = serde_json::from_str(
2030 r#"{
2031 "@context": ["https://www.w3.org/ns/credentials/v2"],
2032 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2033 "issuer": "did:example:member",
2034 "validFrom": "2024-06-18T10:00:00Z",
2035 "credentialSubject": {
2036 "id": "did:example:community",
2037 "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2038 }
2039 }"#,
2040 )
2041 .expect("deserializes");
2042
2043 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2044 assert!(matches!(
2045 vmc.credential().credential_subject,
2046 CredentialSubject::Membership(_)
2047 ));
2048 assert_eq!(
2049 vmc.subject_digest(),
2050 Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2051 );
2052 assert_eq!(vmc.subject(), "did:example:community");
2053 }
2054
2055 #[test]
2058 fn test_membership_credential_rejects_a_witness_context() {
2059 let result: Result<DTGCredential, _> = serde_json::from_str(
2060 r#"{
2061 "@context": ["https://www.w3.org/ns/credentials/v2"],
2062 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2063 "issuer": "did:example:member",
2064 "validFrom": "2024-06-18T10:00:00Z",
2065 "credentialSubject": {
2066 "id": "did:example:community",
2067 "digest": "sha256:e3b0c4",
2068 "witnessContext": { "event": "not a membership property" }
2069 }
2070 }"#,
2071 );
2072 assert!(result.is_err());
2073 }
2074
2075 #[test]
2078 fn test_the_two_halves_round_trip_over_the_wire() {
2079 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2080 .unwrap()
2081 .with_timezone(&Utc);
2082
2083 let grant = DTGCredential::new_vmc(
2084 "did:example:community".to_string(),
2085 "did:example:member".to_string(),
2086 valid_from,
2087 None,
2088 false,
2089 );
2090 let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2091
2092 let grant_json = serde_json::to_value(&grant).unwrap();
2093 assert!(
2094 grant_json["credentialSubject"].get("digest").is_none(),
2095 "the grant MUST omit `digest`: {grant_json}"
2096 );
2097
2098 let ack_json = serde_json::to_value(&ack).unwrap();
2099 assert_eq!(
2100 ack_json["credentialSubject"]["digest"],
2101 Value::String(grant.digest().unwrap()),
2102 );
2103
2104 let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
2107 let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
2108 assert!(ack.acknowledges(&grant).unwrap());
2109 }
2110
2111 #[test]
2112 fn test_iso8601_format_option() {
2113 let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
2114 &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2115 )
2116 .unwrap()
2117 .to_utc();
2118 let cred = DTGCommon {
2119 valid_until: Some(now),
2120 ..Default::default()
2121 };
2122
2123 let value = serde_json::to_value(&cred).unwrap();
2124 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2125 assert_eq!(cred2.valid_until, Some(now));
2126
2127 let cred = DTGCommon::default();
2128 let value = serde_json::to_value(&cred).unwrap();
2129 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2130 assert_eq!(cred2.valid_until, None);
2131 }
2132
2133 #[cfg(feature = "affinidi-signing")]
2134 #[tokio::test]
2135 async fn test_signing() {
2136 use affinidi_secrets_resolver::secrets::Secret;
2137
2138 let secret = Secret::generate_ed25519(None, None);
2139
2140 let mut cred = DTGCredential::new_vrc(
2141 "did:example:issuer".to_string(),
2142 "did:example:subject".to_string(),
2143 Utc::now(),
2144 None,
2145 );
2146
2147 assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
2148
2149 assert!(
2150 cred.verify_proof_with_public_key(secret.get_public_bytes())
2151 .is_ok()
2152 );
2153
2154 let secret2 = Secret::generate_ed25519(None, None);
2155 assert!(
2156 cred.verify_proof_with_public_key(secret2.get_public_bytes())
2157 .is_err()
2158 );
2159 }
2160
2161 #[cfg(feature = "affinidi-signing")]
2169 #[tokio::test]
2170 async fn test_id_is_covered_by_the_proof() {
2171 use affinidi_secrets_resolver::secrets::Secret;
2172
2173 let secret = Secret::generate_ed25519(None, None);
2174
2175 let mut cred = DTGCredential::new_vrc(
2176 "did:example:issuer".to_string(),
2177 "did:example:subject".to_string(),
2178 Utc::now(),
2179 None,
2180 )
2181 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2182
2183 cred.sign(&secret, Some(Utc::now()))
2184 .await
2185 .expect("signing a credential that carries an id");
2186 assert!(
2187 cred.verify_proof_with_public_key(secret.get_public_bytes())
2188 .is_ok(),
2189 "an id set before signing verifies"
2190 );
2191
2192 cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2195 assert!(
2196 cred.verify_proof_with_public_key(secret.get_public_bytes())
2197 .is_err(),
2198 "an id changed after signing must break the proof"
2199 );
2200 }
2201
2202 #[cfg(feature = "affinidi-signing")]
2203 #[tokio::test]
2204 async fn test_signing_error() {
2205 use affinidi_secrets_resolver::secrets::Secret;
2206
2207 let secret = Secret::generate_x25519(None, None).unwrap();
2208
2209 let mut cred = DTGCredential::new_vrc(
2210 "did:example:issuer".to_string(),
2211 "did:example:subject".to_string(),
2212 Utc::now(),
2213 None,
2214 );
2215
2216 assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2217 }
2218
2219 #[cfg(feature = "affinidi-signing")]
2220 #[test]
2221 fn test_signing_no_proof() {
2222 use crate::DTGCredentialError;
2223 use affinidi_secrets_resolver::secrets::Secret;
2224
2225 let cred = DTGCredential::new_vrc(
2226 "did:example:issuer".to_string(),
2227 "did:example:subject".to_string(),
2228 Utc::now(),
2229 None,
2230 );
2231
2232 let secret = Secret::generate_ed25519(None, None);
2233 match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2234 Err(DTGCredentialError::NotSigned) => {
2235 }
2237 _ => panic!("Expected NotSigned error!"),
2238 }
2239 }
2240}