1use affinidi_data_integrity::DataIntegrityProof;
5#[cfg(feature = "affinidi-signing")]
6use affinidi_data_integrity::{DataIntegrityError, SignOptions, VerifyOptions};
7#[cfg(feature = "affinidi-signing")]
8use affinidi_secrets_resolver::secrets::Secret;
9use chrono::{DateTime, Utc};
10use multibase::Base;
11use serde::{Deserialize, Serialize, Serializer};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14use std::fmt::Display;
15use thiserror::Error;
16
17pub mod create;
18
19#[derive(Clone, Copy, Debug)]
21pub enum W3CVCVersion {
22 V1_1,
24
25 V2_0,
27}
28
29impl TryFrom<&[String]> for W3CVCVersion {
30 type Error = DTGCredentialError;
31
32 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
34 if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
35 Ok(W3CVCVersion::V1_1)
36 } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
37 Ok(W3CVCVersion::V2_0)
38 } else {
39 Err(DTGCredentialError::UnknownVCVersion)
40 }
41 }
42}
43
44#[derive(Error, Debug)]
46pub enum DTGCredentialError {
47 #[error("Unknown credential type")]
48 UnknownCredential,
49
50 #[cfg(feature = "affinidi-signing")]
51 #[error("Data Integrity Error: {0}")]
52 DataIntegrity(#[from] DataIntegrityError),
53
54 #[error("Credential is not signed")]
55 NotSigned,
56
57 #[error("Unknown W3C VC Version")]
58 UnknownVCVersion,
59
60 #[error("WitnessCredential is missing the required taskContext property")]
62 MissingTaskContext,
63
64 #[error("Could not canonicalize credential: {0}")]
66 Canonicalization(String),
67
68 #[error("Expected a {expected}, got a {got}")]
70 WrongCredentialType { expected: String, got: String },
71
72 #[error("Not a community-issued membership grant: {0}")]
75 NotAMembershipGrant(String),
76}
77
78#[derive(Serialize, Deserialize, Debug, Clone)]
80#[serde(try_from = "DTGCommon")]
81pub struct DTGCredential {
82 #[serde(flatten)]
84 credential: DTGCommon,
85
86 #[serde(skip)]
88 type_: DTGCredentialType,
89
90 #[serde(skip)]
92 version: W3CVCVersion,
93}
94
95impl DTGCredential {
96 pub fn credential(&self) -> &DTGCommon {
98 &self.credential
99 }
100
101 pub fn credential_mut(&mut self) -> &mut DTGCommon {
103 &mut self.credential
104 }
105
106 pub fn signed(&self) -> bool {
108 self.credential.signed()
109 }
110
111 pub fn type_(&self) -> DTGCredentialType {
113 self.type_.clone()
114 }
115
116 pub fn id(&self) -> Option<&str> {
122 self.credential.id()
123 }
124
125 pub fn issuer(&self) -> &str {
127 self.credential.issuer()
128 }
129
130 pub fn subject(&self) -> &str {
132 self.credential.subject()
133 }
134
135 pub fn valid_from(&self) -> DateTime<Utc> {
137 self.credential.valid_from()
138 }
139
140 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
142 self.credential.valid_until()
143 }
144
145 pub fn task_context(&self) -> Option<&str> {
150 self.credential.task_context()
151 }
152
153 pub fn digest(&self) -> Result<String, DTGCredentialError> {
170 let unsigned = DTGCommon {
171 proof: None,
172 ..self.credential.clone()
173 };
174
175 let canonical = serde_json_canonicalizer::to_vec(&unsigned)
176 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
177
178 const HEX: &[u8; 16] = b"0123456789abcdef";
179 let mut out = String::with_capacity("sha256:".len() + 64);
180 out.push_str("sha256:");
181 for byte in Sha256::digest(&canonical) {
182 out.push(HEX[(byte >> 4) as usize] as char);
183 out.push(HEX[(byte & 0x0f) as usize] as char);
184 }
185 Ok(out)
186 }
187
188 pub fn subject_digest(&self) -> Option<&str> {
194 match &self.credential.credential_subject {
195 CredentialSubject::Membership(subject) => subject.digest.as_deref(),
196 CredentialSubject::Witness(subject) => subject.digest.as_deref(),
197 _ => None,
198 }
199 }
200
201 #[deprecated(
207 since = "0.4.0",
208 note = "This encoding is not what DTG Core Credentials specifies, so digests \
209 produced by it do not interoperate. Use DTGCredential::digest, which \
210 returns the conformant `sha256:<lowercase hex>` over the proofless JCS \
211 canonical form. This method will be removed in a future release."
212 )]
213 pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
214 let canonical = serde_json_canonicalizer::to_vec(&self.credential)
215 .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
216
217 let mut multihash = Vec::with_capacity(34);
219 multihash.extend_from_slice(&[0x12, 0x20]);
220 multihash.extend_from_slice(&Sha256::digest(&canonical));
221
222 Ok(multibase::encode(Base::Base58Btc, &multihash))
223 }
224
225 pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
235 let Some(digest) = self.subject_digest() else {
236 return Ok(false);
237 };
238
239 Ok(digest == referenced.digest()?)
240 }
241
242 pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
266 if !matches!(self.type_, DTGCredentialType::Membership)
267 || !matches!(grant.type_, DTGCredentialType::Membership)
268 {
269 return Ok(false);
270 }
271
272 if grant.subject_digest().is_some() {
275 return Ok(false);
276 }
277
278 if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
279 return Ok(false);
280 }
281
282 self.verify_digest(grant)
283 }
284
285 pub fn proof_value(&self) -> Option<&str> {
287 if let Some(proof) = &self.credential.proof {
288 proof.proof_value.as_deref()
289 } else {
290 None
291 }
292 }
293
294 #[cfg(feature = "affinidi-signing")]
295 pub async fn sign(
299 &mut self,
300 signing_secret: &Secret,
301 create_time: Option<DateTime<Utc>>,
302 ) -> Result<DataIntegrityProof, DTGCredentialError> {
303 let mut options = SignOptions::new();
304 if let Some(ts) = create_time {
305 options = options.with_created(ts);
306 }
307
308 let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
309
310 self.credential.proof = Some(proof.clone());
311 Ok(proof)
312 }
313
314 #[cfg(feature = "affinidi-signing")]
315 pub fn verify_proof_with_public_key(
319 &self,
320 public_key_bytes: &[u8],
321 ) -> Result<(), DTGCredentialError> {
322 let proof = if let Some(proof) = &self.credential.proof {
323 proof.clone()
324 } else {
325 use tracing::warn;
326
327 warn!("Trying to verify a DTG Credential that has no proof");
328 return Err(DTGCredentialError::NotSigned);
329 };
330
331 let unsigned = DTGCommon {
332 proof: None,
333 ..self.credential.clone()
334 };
335
336 proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
337 Ok(())
338 }
339
340 pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
342 self.version
343 }
344
345 pub fn is_personhood_credential(&self) -> bool {
347 if let DTGCredentialType::Membership = self.type_ {
348 self.credential
349 .type_
350 .contains(&"PersonhoodCredential".to_string())
351 } else {
352 false
353 }
354 }
355}
356
357#[derive(Debug, Clone)]
359#[non_exhaustive]
360pub enum DTGCredentialType {
361 Membership,
362 Relationship,
363 Invitation,
364 Persona,
365 Endorsement,
366 Witness,
367
368 #[deprecated(
370 since = "0.2.0",
371 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
372 It was removed from the DTG Core Credentials specification in Working Draft 01 \
373 and will be defined by the planned DTG Verifiable Data Structures specification. \
374 This variant will be removed in a future release."
375 )]
376 RCard,
377}
378
379impl Display for DTGCredentialType {
380 #[allow(deprecated)]
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 match self {
383 DTGCredentialType::Membership => write!(f, "MembershipCredential"),
384 DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
385 DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
386 DTGCredentialType::Persona => write!(f, "PersonaCredential"),
387 DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
388 DTGCredentialType::Witness => write!(f, "WitnessCredential"),
389 DTGCredentialType::RCard => write!(f, "RCardCredential"),
390 }
391 }
392}
393
394const DTG_TYPES: [&str; 7] = [
396 "MembershipCredential",
397 "RelationshipCredential",
398 "InvitationCredential",
399 "PersonaCredential",
400 "EndorsementCredential",
401 "WitnessCredential",
402 "RCardCredential",
403];
404
405impl TryFrom<&[String]> for DTGCredentialType {
406 type Error = DTGCredentialError;
407
408 #[allow(deprecated)]
409 fn try_from(types: &[String]) -> Result<Self, Self::Error> {
410 if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
411 match *type_ {
412 "MembershipCredential" => Ok(DTGCredentialType::Membership),
413 "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
414 "InvitationCredential" => Ok(DTGCredentialType::Invitation),
415 "PersonaCredential" => Ok(DTGCredentialType::Persona),
416 "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
417 "WitnessCredential" => Ok(DTGCredentialType::Witness),
418 "RCardCredential" => Ok(DTGCredentialType::RCard),
419 _ => Err(DTGCredentialError::UnknownCredential),
420 }
421 } else {
422 Err(DTGCredentialError::UnknownCredential)
423 }
424 }
425}
426
427#[derive(Serialize, Deserialize, Debug, Clone)]
429#[serde(rename_all = "camelCase")]
430pub struct DTGCommon {
431 #[serde(rename = "@context")]
436 pub context: Vec<String>,
437
438 #[serde(rename = "type")]
443 pub type_: Vec<String>,
444
445 #[serde(skip_serializing_if = "Option::is_none", default)]
462 pub id: Option<String>,
463
464 pub issuer: String,
466
467 #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
469 pub valid_from: DateTime<Utc>,
470
471 #[serde(serialize_with = "iso8601_format_option")]
473 #[serde(
474 skip_serializing_if = "Option::is_none",
475 alias = "expirationDate",
476 default
477 )]
478 pub valid_until: Option<DateTime<Utc>>,
479
480 #[serde(skip_serializing_if = "Option::is_none", default)]
490 pub task_context: Option<String>,
491
492 pub credential_subject: CredentialSubject,
494
495 #[serde(skip_serializing_if = "Option::is_none", default)]
497 pub proof: Option<DataIntegrityProof>,
498}
499
500impl DTGCommon {
501 pub fn signed(&self) -> bool {
505 self.proof.is_some()
506 }
507
508 pub fn id(&self) -> Option<&str> {
510 self.id.as_deref()
511 }
512
513 pub fn issuer(&self) -> &str {
515 &self.issuer
516 }
517
518 #[allow(deprecated)]
520 pub fn subject(&self) -> &str {
521 match &self.credential_subject {
522 CredentialSubject::Basic(subject) => &subject.id,
523 CredentialSubject::Endorsement(subject) => &subject.id,
524 CredentialSubject::Witness(subject) => &subject.id,
525 CredentialSubject::Membership(subject) => &subject.id,
526 CredentialSubject::RCard(subject) => &subject.id,
527 }
528 }
529
530 pub fn valid_from(&self) -> DateTime<Utc> {
532 self.valid_from
533 }
534
535 pub fn valid_until(&self) -> Option<DateTime<Utc>> {
537 self.valid_until
538 }
539
540 pub fn task_context(&self) -> Option<&str> {
542 self.task_context.as_deref()
543 }
544}
545
546impl Default for DTGCommon {
548 fn default() -> Self {
549 DTGCommon {
550 context: vec![
551 "https://www.w3.org/ns/credentials/v2".to_string(),
552 "https://firstperson.network/credentials/dtg/v1".to_string(),
553 ],
554 type_: vec![
555 "VerifiableCredential".to_string(),
556 "DTGCredential".to_string(),
557 ],
558 id: None,
559 issuer: String::new(),
560 valid_from: Utc::now(),
561 valid_until: None,
562 task_context: None,
563 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
564 id: String::new(),
565 }),
566 proof: None,
567 }
568 }
569}
570
571impl TryFrom<DTGCommon> for DTGCredential {
573 type Error = DTGCredentialError;
574
575 #[allow(deprecated)]
576 fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
577 match &value.type_.as_slice().try_into()? {
578 DTGCredentialType::Membership => {
579 let subject = match &value.credential_subject {
584 CredentialSubject::Membership(subject) => subject.clone(),
587
588 CredentialSubject::Basic(subject) => CredentialSubjectMembership {
590 id: subject.id.clone(),
591 digest: None,
592 },
593
594 CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
600 CredentialSubjectMembership {
601 id: subject.id.clone(),
602 digest: subject.digest.clone(),
603 }
604 }
605
606 _ => return Err(DTGCredentialError::UnknownCredential),
607 };
608
609 Ok(DTGCredential {
610 type_: DTGCredentialType::Membership,
611 version: value.context.as_slice().try_into()?,
612 credential: DTGCommon {
613 credential_subject: CredentialSubject::Membership(subject),
614 ..value
615 },
616 })
617 }
618 DTGCredentialType::Relationship => Ok(DTGCredential {
619 type_: DTGCredentialType::Relationship,
620 version: value.context.as_slice().try_into()?,
621 credential: value,
622 }),
623 DTGCredentialType::Invitation => Ok(DTGCredential {
624 type_: DTGCredentialType::Invitation,
625 version: value.context.as_slice().try_into()?,
626 credential: value,
627 }),
628 DTGCredentialType::Persona => Ok(DTGCredential {
629 type_: DTGCredentialType::Persona,
630 version: value.context.as_slice().try_into()?,
631 credential: value,
632 }),
633 DTGCredentialType::Endorsement => {
634 if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
635 Ok(DTGCredential {
636 type_: DTGCredentialType::Endorsement,
637 version: value.context.as_slice().try_into()?,
638 credential: value,
639 })
640 } else {
641 Err(DTGCredentialError::UnknownCredential)
642 }
643 }
644 DTGCredentialType::Witness => {
645 if value.task_context.is_none() {
649 return Err(DTGCredentialError::MissingTaskContext);
650 }
651
652 match &value.credential_subject {
653 CredentialSubject::Witness(_) => Ok(DTGCredential {
654 type_: DTGCredentialType::Witness,
655 version: value.context.as_slice().try_into()?,
656 credential: value,
657 }),
658 CredentialSubject::Basic(subject) => {
659 Ok(DTGCredential {
661 type_: DTGCredentialType::Witness,
662 version: value.context.as_slice().try_into()?,
663 credential: DTGCommon {
664 credential_subject: CredentialSubject::Witness(
665 CredentialSubjectWitness {
666 id: subject.id.clone(),
667 digest: None,
668 witness_context: None,
669 },
670 ),
671 ..value
672 },
673 })
674 }
675 _ => Err(DTGCredentialError::UnknownCredential),
676 }
677 }
678 DTGCredentialType::RCard => match &value.credential_subject {
679 CredentialSubject::RCard { .. } => Ok(DTGCredential {
680 type_: DTGCredentialType::RCard,
681 version: value.context.as_slice().try_into()?,
682 credential: value,
683 }),
684 _ => Err(DTGCredentialError::UnknownCredential),
685 },
686 }
687 }
688}
689
690fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
693where
694 S: Serializer,
695{
696 s.serialize_str(
697 timestamp
698 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
699 .as_str(),
700 )
701}
702
703fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
704where
705 S: Serializer,
706{
707 if let Some(timestamp) = timestamp {
708 s.serialize_str(
709 timestamp
710 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
711 .as_str(),
712 )
713 } else {
714 s.serialize_none()
715 }
716}
717
718#[allow(deprecated)]
727#[derive(Serialize, Deserialize, Debug, Clone)]
728#[serde(untagged)]
729pub enum CredentialSubject {
730 Endorsement(CredentialSubjectEndorsement),
732
733 #[deprecated(
735 since = "0.2.0",
736 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
737 See DTGCredentialType::RCard. This variant will be removed in a future release."
738 )]
739 RCard(CredentialSubjectRCard),
740
741 Basic(CredentialSubjectBasic),
744
745 Witness(CredentialSubjectWitness),
747
748 Membership(CredentialSubjectMembership),
766}
767
768#[derive(Serialize, Deserialize, Debug, Clone)]
770#[serde(deny_unknown_fields)]
771pub struct CredentialSubjectBasic {
772 pub id: String,
773}
774
775#[derive(Serialize, Deserialize, Debug, Clone)]
783#[serde(rename_all = "camelCase", deny_unknown_fields)]
784pub struct CredentialSubjectMembership {
785 pub id: String,
786
787 #[serde(skip_serializing_if = "Option::is_none", default)]
794 pub digest: Option<String>,
795}
796
797#[derive(Serialize, Deserialize, Debug, Clone)]
799#[serde(deny_unknown_fields)]
800pub struct CredentialSubjectEndorsement {
801 pub id: String,
802 pub endorsement: Value,
804}
805
806#[derive(Serialize, Deserialize, Debug, Clone)]
808#[serde(rename_all = "camelCase", deny_unknown_fields)]
809pub struct CredentialSubjectWitness {
810 pub id: String,
811
812 #[serde(skip_serializing_if = "Option::is_none")]
813 pub digest: Option<String>,
814
815 #[serde(skip_serializing_if = "Option::is_none")]
817 pub witness_context: Option<WitnessContext>,
818}
819
820#[derive(Serialize, Deserialize, Debug, Clone)]
822#[serde(rename_all = "camelCase", deny_unknown_fields)]
823pub struct WitnessContext {
824 pub event: Option<String>,
826
827 pub session_id: Option<String>,
829
830 pub method: Option<String>,
832}
833
834#[deprecated(
836 since = "0.2.0",
837 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
838 See DTGCredentialType::RCard. This struct will be removed in a future release."
839)]
840#[derive(Serialize, Deserialize, Debug, Clone)]
841#[serde(deny_unknown_fields)]
842pub struct CredentialSubjectRCard {
843 pub id: String,
844
845 pub card: Value,
847}
848
849#[cfg(test)]
850#[allow(deprecated)]
851mod tests {
852 use crate::{
853 CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
854 DTGCredentialType, W3CVCVersion,
855 };
856 use chrono::{DateTime, Utc};
857 use serde_json::Value;
858
859 #[test]
860 fn test_vmc_vc_1_deserialize() {
861 let vmc: DTGCredential = match serde_json::from_str(
863 r#"{
864"@context": [
865 "https://www.w3.org/2018/credentials/v1",
866 "https://firstperson.network/credentials/dtg/v1",
867 "https://w3id.org/security/suites/ed25519-2020/v1"
868 ],
869 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
870 "issuer": "did:web:chess-club.example",
871 "issuanceDate": "2026-01-06T10:00:00Z",
872 "expirationDate": "2027-01-06T10:00:00Z",
873 "credentialSubject": {
874 "id": "did:key:z6MkpTHR8VNs..."
875 }
876 }"#,
877 ) {
878 Ok(vmc) => vmc,
879 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
880 };
881
882 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
883 assert!(matches!(
884 vmc.credential().credential_subject,
885 CredentialSubject::Membership(_)
886 ));
887 assert!(matches!(vmc.version, W3CVCVersion::V1_1));
888 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
889 }
890
891 #[test]
892 fn test_missing_w3c_context() {
893 assert!(
895 serde_json::from_str::<DTGCredential>(
896 r#"{
897"@context": [
898 "https://firstperson.network/credentials/dtg/v1",
899 "https://w3id.org/security/suites/ed25519-2020/v1"
900 ],
901 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
902 "issuer": "did:web:chess-club.example",
903 "issuanceDate": "2026-01-06T10:00:00Z",
904 "expirationDate": "2027-01-06T10:00:00Z",
905 "credentialSubject": {
906 "id": "did:key:z6MkpTHR8VNs..."
907 }
908 }"#,
909 )
910 .is_err()
911 );
912 }
913
914 #[test]
915 fn test_mutable_credential() {
916 let mut vmc = DTGCredential::new_vmc(
917 "did:example:issuer".to_string(),
918 "did:example:subject".to_string(),
919 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
920 .unwrap()
921 .with_timezone(&Utc),
922 None,
923 false,
924 );
925
926 let cred = vmc.credential_mut();
927 cred.type_.push("PersonhoodCredential".to_string());
928 assert!(vmc.is_personhood_credential());
929 }
930
931 #[test]
932 fn test_vmc_deserialize() {
933 let vmc: DTGCredential = match serde_json::from_str(
934 r#"{
935 "@context": ["https://www.w3.org/ns/credentials/v2"],
936 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
937 "issuer": "did:example:community",
938 "validFrom": "2024-06-18T10:00:00Z",
939 "credentialSubject": { "id": "did:example:rDid" }
940 }"#,
941 ) {
942 Ok(vmc) => vmc,
943 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
944 };
945
946 assert!(!vmc.is_personhood_credential());
947 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
948 assert!(matches!(
949 vmc.credential().credential_subject,
950 CredentialSubject::Membership(_)
951 ));
952 assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
953 }
954
955 #[test]
956 fn test_vmc_phc_deserialize() {
957 let vmc: DTGCredential = match serde_json::from_str(
958 r#"{
959 "@context": ["https://www.w3.org/ns/credentials/v2"],
960 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
961 "issuer": "did:example:community",
962 "validFrom": "2024-06-18T10:00:00Z",
963 "credentialSubject": { "id": "did:example:rDid" }
964 }"#,
965 ) {
966 Ok(vmc) => vmc,
967 Err(e) => panic!("Couldn't deserialize VMC: {}", e),
968 };
969
970 assert!(vmc.is_personhood_credential());
971 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
972 assert!(matches!(
973 vmc.credential().credential_subject,
974 CredentialSubject::Membership(_)
975 ));
976 }
977
978 #[test]
979 fn test_vrc_deserialize() {
980 let vrc: DTGCredential = match serde_json::from_str(
981 r#"{
982 "@context": ["https://www.w3.org/ns/credentials/v2"],
983 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
984 "issuer": "did:example:governmentAgencyDid",
985 "validFrom": "2024-06-18T10:00:00Z",
986 "credentialSubject": { "id": "did:example:citizenRDid" }
987 }"#,
988 ) {
989 Ok(vrc) => vrc,
990 Err(e) => panic!("Couldn't deserialize VRC: {}", e),
991 };
992
993 assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
994 assert!(matches!(
995 vrc.credential().credential_subject,
996 CredentialSubject::Basic(_)
997 ));
998 }
999
1000 #[test]
1001 fn test_vic_deserialize() {
1002 let vic: DTGCredential = match serde_json::from_str(
1003 r#"{
1004 "@context": ["https://www.w3.org/ns/credentials/v2"],
1005 "type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
1006 "issuer": "did:example:governmentAgencyVicDid",
1007 "validFrom": "2024-06-18T10:00:00Z",
1008 "credentialSubject": { "id": "did:example:citizenRDid" }
1009 }"#,
1010 ) {
1011 Ok(vic) => vic,
1012 Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1013 };
1014
1015 assert!(!vic.is_personhood_credential());
1016 assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1017 assert!(matches!(
1018 vic.credential().credential_subject,
1019 CredentialSubject::Basic(_)
1020 ));
1021 }
1022
1023 #[test]
1024 fn test_vpc_deserialize() {
1025 let vpc: DTGCredential = match serde_json::from_str(
1026 r#"{
1027 "@context": ["https://www.w3.org/ns/credentials/v2"],
1028 "type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
1029 "issuer": "did:example:governmentAgencyDid",
1030 "validFrom": "2024-06-18T10:00:00Z",
1031 "credentialSubject": { "id": "did:example:citizenRDid" }
1032 }"#,
1033 ) {
1034 Ok(vpc) => vpc,
1035 Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1036 };
1037
1038 assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1039 assert!(matches!(
1040 vpc.credential().credential_subject,
1041 CredentialSubject::Basic(_)
1042 ));
1043 }
1044
1045 #[test]
1046 fn test_vec_deserialize() {
1047 let vec: DTGCredential = match serde_json::from_str(
1048 r#"{
1049 "@context": ["https://www.w3.org/ns/credentials/v2"],
1050 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1051 "issuer": "did:example:governmentAgencyDid",
1052 "validFrom": "2024-06-18T10:00:00Z",
1053 "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1054 }"#,
1055 ) {
1056 Ok(vec) => vec,
1057 Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1058 };
1059
1060 assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1061 assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1062 assert!(matches!(
1063 vec.credential().credential_subject,
1064 CredentialSubject::Endorsement(_)
1065 ));
1066 }
1067
1068 #[test]
1069 fn test_vec_bad_deserialize() {
1070 match serde_json::from_str::<DTGCredential>(
1071 r#"{
1072 "@context": ["https://www.w3.org/ns/credentials/v2"],
1073 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1074 "issuer": "did:example:governmentAgencyDid",
1075 "validFrom": "2024-06-18T10:00:00Z",
1076 "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1077 }"#,
1078 ) {
1079 Ok(_) => panic!("Expected Unknown Credential type"),
1080 Err(_) => {
1081 }
1083 };
1084 }
1085
1086 #[test]
1087 fn test_vwc_simple_deserialize() {
1088 let vwc: DTGCredential = match serde_json::from_str(
1089 r#"{
1090 "@context": ["https://www.w3.org/ns/credentials/v2"],
1091 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1092 "issuer": "did:example:governmentAgencyDid",
1093 "validFrom": "2024-06-18T10:00:00Z",
1094 "taskContext": "thread-abc-123",
1095 "credentialSubject": { "id": "did:example:citizenRDid" }
1096 }"#,
1097 ) {
1098 Ok(vwc) => vwc,
1099 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1100 };
1101
1102 assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1103 assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1104 assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1105 assert!(matches!(
1106 vwc.credential().credential_subject,
1107 CredentialSubject::Witness(_)
1108 ));
1109 }
1110
1111 #[test]
1112 fn test_vwc_full_deserialize() {
1113 let vwc: DTGCredential = match serde_json::from_str(
1114 r#"{
1115 "@context": ["https://www.w3.org/ns/credentials/v2"],
1116 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1117 "issuer": "did:example:governmentAgencyDid",
1118 "validFrom": "2024-06-18T10:00:00Z",
1119 "taskContext": "thread-abc-123",
1120 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} }
1121 }"#,
1122 ) {
1123 Ok(vwc) => vwc,
1124 Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1125 };
1126
1127 assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1128 assert!(matches!(
1129 vwc.credential().credential_subject,
1130 CredentialSubject::Witness(_)
1131 ));
1132 }
1133
1134 #[test]
1135 fn test_vwc_bad_deserialize() {
1136 if serde_json::from_str::<DTGCredential>(
1137 r#"{
1138 "@context": ["https://www.w3.org/ns/credentials/v2"],
1139 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1140 "issuer": "did:example:governmentAgencyDid",
1141 "validFrom": "2024-06-18T10:00:00Z",
1142 "taskContext": "thread-abc-123",
1143 "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {} }
1144 }"#,
1145 ).is_ok() {
1146 panic!("Should have failed due to wrong CredentialSubject!");
1147 }
1148 }
1149
1150 #[test]
1151 fn test_rcard_simple_deserialize() {
1152 let rcard: DTGCredential = match serde_json::from_str(
1153 r#"{
1154 "@context": ["https://www.w3.org/ns/credentials/v2"],
1155 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1156 "issuer": "did:example:governmentAgencyDid",
1157 "validFrom": "2024-06-18T10:00:00Z",
1158 "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1159 }"#,
1160 ) {
1161 Ok(rcard) => rcard,
1162 Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1163 };
1164
1165 assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1166 assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1167 assert!(matches!(
1168 rcard.credential().credential_subject,
1169 CredentialSubject::RCard(_)
1170 ));
1171 }
1172
1173 #[test]
1174 fn test_rcard_bad_deserialize() {
1175 if serde_json::from_str::<DTGCredential>(
1176 r#"{
1177 "@context": ["https://www.w3.org/ns/credentials/v2"],
1178 "type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
1179 "issuer": "did:example:governmentAgencyDid",
1180 "validFrom": "2024-06-18T10:00:00Z",
1181 "credentialSubject": { "id": "did:example:citizenRDid" }
1182 }"#,
1183 )
1184 .is_ok()
1185 {
1186 panic!("Should have failed due to wrong CredentialSubject!");
1187 }
1188 }
1189 #[test]
1190 fn test_deserialize_unknown() {
1191 match serde_json::from_str::<DTGCredential>(
1192 r#"{
1193 "@context": ["https://www.w3.org/ns/credentials/v2"],
1194 "type": ["VerifiableCredential", "DTGCredential", "UnknownCredential"],
1195 "issuer": "did:example:governmentAgencyDid",
1196 "validFrom": "2024-06-18T10:00:00Z",
1197 "credentialSubject": { "id": "did:example:citizenRDid" }
1198 }"#,
1199 ) {
1200 Ok(_) => panic!("Expected Unknown Credential type"),
1201 Err(e) => {
1202 if e.to_string() == "Unknown credential type" {
1203 } else {
1205 panic!("Wrong error type returned");
1206 }
1207 }
1208 };
1209 }
1210
1211 #[test]
1212 fn test_deserialize_mismatched_credential_subject() {
1213 match serde_json::from_str::<DTGCredential>(
1214 r#"{
1215 "@context": ["https://www.w3.org/ns/credentials/v2"],
1216 "type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
1217 "issuer": "did:example:governmentAgencyDid",
1218 "validFrom": "2024-06-18T10:00:00Z",
1219 "credentialSubject": { "id": "did:example:citizenRDid" }
1220 }"#,
1221 ) {
1222 Ok(_) => panic!("Expected Unknown Credential type"),
1223 Err(e) => {
1224 if e.to_string() == "Unknown credential type" {
1225 } else {
1227 panic!("Wrong error type returned");
1228 }
1229 }
1230 };
1231 }
1232
1233 #[test]
1234 fn test_proof_signed() {
1235 let cred: DTGCredential = match serde_json::from_str(
1236 r#"{
1237 "@context": ["https://www.w3.org/ns/credentials/v2"],
1238 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1239 "issuer": "did:example:community",
1240 "validFrom": "2024-06-18T10:00:00Z",
1241 "credentialSubject": { "id": "did:example:rDid" },
1242 "proof": {
1243 "type": "DataIntegrityProof",
1244 "cryptosuite": "eddsa-jcs-2022",
1245 "created": "2025-12-04T00:00:00",
1246 "verificationMethod": "did:example:test#key-1",
1247 "proofPurpose": "assertionMethod",
1248 "proofValue": "abcd"
1249 }
1250 }"#,
1251 ) {
1252 Ok(vmc) => vmc,
1253 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1254 };
1255
1256 assert!(cred.signed());
1257 assert!(cred.proof_value().is_some());
1258 }
1259
1260 #[test]
1261 fn test_proof_not_signed() {
1262 let cred: DTGCredential = match serde_json::from_str(
1263 r#"{
1264 "@context": ["https://www.w3.org/ns/credentials/v2"],
1265 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1266 "issuer": "did:example:community",
1267 "validFrom": "2024-06-18T10:00:00Z",
1268 "credentialSubject": { "id": "did:example:rDid" }
1269 }"#,
1270 ) {
1271 Ok(vmc) => vmc,
1272 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1273 };
1274
1275 assert!(!cred.signed());
1276 assert!(cred.proof_value().is_none());
1277 }
1278
1279 #[test]
1280 fn test_helpers() {
1281 let cred: DTGCredential = match serde_json::from_str(
1282 r#"{
1283 "@context": ["https://www.w3.org/ns/credentials/v2"],
1284 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1285 "issuer": "did:example:issuer",
1286 "validFrom": "2024-06-18T00:00:00Z",
1287 "credentialSubject": { "id": "did:example:subject" }
1288 }"#,
1289 ) {
1290 Ok(vmc) => vmc,
1291 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1292 };
1293
1294 assert_eq!(cred.issuer(), "did:example:issuer");
1295 assert_eq!(cred.subject(), "did:example:subject");
1296 assert_eq!(
1297 cred.valid_from()
1298 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1299 "2024-06-18T00:00:00Z"
1300 );
1301 assert_eq!(cred.valid_until(), None);
1302 }
1303
1304 #[test]
1305 fn test_valid_until() {
1306 let cred: DTGCredential = match serde_json::from_str(
1307 r#"{
1308 "@context": ["https://www.w3.org/ns/credentials/v2"],
1309 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1310 "issuer": "did:example:issuer",
1311 "validFrom": "2024-06-18T00:00:00Z",
1312 "validUntil": "2030-01-01T00:00:00Z",
1313 "credentialSubject": { "id": "did:example:subject" }
1314 }"#,
1315 ) {
1316 Ok(vmc) => vmc,
1317 Err(e) => panic!("Couldn't deserialize credential: {}", e),
1318 };
1319
1320 assert_eq!(
1321 cred.valid_until()
1322 .unwrap()
1323 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1324 "2030-01-01T00:00:00Z"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_bad_type() {
1330 assert!(
1331 std::convert::TryInto::<DTGCredentialType>::try_into(
1332 vec!["bad_type".to_string()].as_slice(),
1333 )
1334 .is_err()
1335 );
1336 }
1337
1338 #[test]
1339 fn test_badly_constructed_vwc() {
1340 let mut cred = DTGCommon::default();
1341 cred.type_.push("WitnessCredential".to_string());
1342 cred.task_context = Some("thread-abc-123".to_string());
1345 cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1346 id: "did:example:bad".to_string(),
1347 card: Value::Null,
1348 });
1349
1350 assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1351 }
1352
1353 #[test]
1354 fn test_vwc_missing_task_context() {
1355 match serde_json::from_str::<DTGCredential>(
1357 r#"{
1358 "@context": ["https://www.w3.org/ns/credentials/v2"],
1359 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1360 "issuer": "did:example:witness",
1361 "validFrom": "2024-06-18T10:00:00Z",
1362 "credentialSubject": { "id": "did:example:observed" }
1363 }"#,
1364 ) {
1365 Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1366 Err(e) => assert_eq!(
1367 e.to_string(),
1368 "WitnessCredential is missing the required taskContext property"
1369 ),
1370 }
1371 }
1372
1373 #[test]
1374 fn test_task_context_round_trip() {
1375 let raw = r#"{
1378 "@context": ["https://www.w3.org/ns/credentials/v2"],
1379 "type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
1380 "issuer": "did:example:witness",
1381 "validFrom": "2024-06-18T10:00:00Z",
1382 "taskContext": "thread-abc-123",
1383 "credentialSubject": { "id": "did:example:observed" }
1384 }"#;
1385
1386 let cred: DTGCredential = serde_json::from_str(raw).unwrap();
1387 let out = serde_json::to_string(&cred).unwrap();
1388
1389 assert!(out.contains(r#""taskContext":"thread-abc-123""#));
1390 }
1391
1392 #[test]
1393 fn test_task_context_optional_on_other_types() {
1394 let vrc: DTGCredential = serde_json::from_str(
1396 r#"{
1397 "@context": ["https://www.w3.org/ns/credentials/v2"],
1398 "type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
1399 "issuer": "did:example:issuer",
1400 "validFrom": "2024-06-18T10:00:00Z",
1401 "credentialSubject": { "id": "did:example:subject" }
1402 }"#,
1403 )
1404 .unwrap();
1405
1406 assert_eq!(vrc.task_context(), None);
1407 assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
1409 }
1410
1411 #[test]
1412 fn test_digest_multibase() {
1413 let vrc = DTGCredential::new_vrc(
1414 "did:example:issuer".to_string(),
1415 "did:example:subject".to_string(),
1416 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1417 .unwrap()
1418 .with_timezone(&Utc),
1419 None,
1420 );
1421
1422 let digest = vrc.digest_multibase().unwrap();
1423
1424 assert!(digest.starts_with('z'));
1426
1427 let (base, bytes) = multibase::decode(&digest).unwrap();
1429 assert_eq!(base, multibase::Base::Base58Btc);
1430 assert_eq!(bytes.len(), 34);
1431 assert_eq!(&bytes[..2], &[0x12, 0x20]);
1432
1433 assert_eq!(digest, vrc.digest_multibase().unwrap());
1435
1436 let other = DTGCredential::new_vrc(
1438 "did:example:issuer".to_string(),
1439 "did:example:someone-else".to_string(),
1440 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1441 .unwrap()
1442 .with_timezone(&Utc),
1443 None,
1444 );
1445 assert_ne!(digest, other.digest_multibase().unwrap());
1446 }
1447
1448 #[test]
1449 fn test_verify_digest() {
1450 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1451 .unwrap()
1452 .with_timezone(&Utc);
1453
1454 let vrc = DTGCredential::new_vrc(
1455 "did:example:issuer".to_string(),
1456 "did:example:subject".to_string(),
1457 valid_from,
1458 None,
1459 );
1460
1461 let vwc = DTGCredential::new_vwc(
1462 "did:example:witness".to_string(),
1463 "did:example:issuer".to_string(),
1465 valid_from,
1466 None,
1467 "thread-abc-123".to_string(),
1468 Some(vrc.digest().unwrap()),
1469 None,
1470 );
1471
1472 assert!(vwc.verify_digest(&vrc).unwrap());
1473
1474 let other = DTGCredential::new_vrc(
1476 "did:example:issuer".to_string(),
1477 "did:example:someone-else".to_string(),
1478 valid_from,
1479 None,
1480 );
1481 assert!(!vwc.verify_digest(&other).unwrap());
1482 }
1483
1484 #[test]
1485 fn test_verify_digest_without_digest() {
1486 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1487 .unwrap()
1488 .with_timezone(&Utc);
1489
1490 let vrc = DTGCredential::new_vrc(
1491 "did:example:issuer".to_string(),
1492 "did:example:subject".to_string(),
1493 valid_from,
1494 None,
1495 );
1496
1497 let vwc = DTGCredential::new_vwc(
1499 "did:example:witness".to_string(),
1500 "did:example:issuer".to_string(),
1501 valid_from,
1502 None,
1503 "thread-abc-123".to_string(),
1504 None,
1505 None,
1506 );
1507
1508 assert!(!vwc.verify_digest(&vrc).unwrap());
1509 }
1510
1511 #[test]
1516 fn test_digest_is_sha256_hex_over_the_proofless_jcs_form() {
1517 let vmc = DTGCredential::new_vmc(
1518 "did:example:community".to_string(),
1519 "did:example:member".to_string(),
1520 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1521 .unwrap()
1522 .with_timezone(&Utc),
1523 None,
1524 false,
1525 )
1526 .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1527
1528 let digest = vmc.digest().unwrap();
1529
1530 let (scheme, hex) = digest.split_once(':').expect("`sha256:` prefixed");
1531 assert_eq!(scheme, "sha256");
1532 assert_eq!(hex.len(), 64, "32 bytes, hex encoded");
1533 assert!(
1534 hex.chars()
1535 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1536 "lowercase hex only, got {hex}"
1537 );
1538
1539 assert_eq!(
1545 digest,
1546 "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
1547 );
1548
1549 assert_eq!(digest, vmc.digest().unwrap());
1551 }
1552
1553 #[cfg(feature = "affinidi-signing")]
1557 #[tokio::test]
1558 async fn test_digest_is_unchanged_by_signing() {
1559 use affinidi_secrets_resolver::secrets::Secret;
1560
1561 let secret = Secret::generate_ed25519(None, None);
1562
1563 let mut vmc = DTGCredential::new_vmc(
1564 "did:example:community".to_string(),
1565 "did:example:member".to_string(),
1566 Utc::now(),
1567 None,
1568 false,
1569 );
1570
1571 let before = vmc.digest().unwrap();
1572 vmc.sign(&secret, None).await.expect("signs");
1573 assert!(vmc.signed());
1574 assert_eq!(before, vmc.digest().unwrap());
1575 }
1576
1577 #[test]
1580 fn test_member_vmc_acknowledges_its_grant() {
1581 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1582 .unwrap()
1583 .with_timezone(&Utc);
1584
1585 let grant = DTGCredential::new_vmc(
1586 "did:example:community".to_string(),
1587 "did:example:member".to_string(),
1588 valid_from,
1589 None,
1590 false,
1591 );
1592
1593 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1594
1595 assert_eq!(ack.issuer(), "did:example:member");
1597 assert_eq!(ack.subject(), "did:example:community");
1598
1599 assert_eq!(grant.subject_digest(), None);
1601 assert_eq!(ack.subject_digest(), Some(grant.digest().unwrap().as_str()));
1602
1603 assert!(ack.acknowledges(&grant).unwrap());
1604 }
1605
1606 #[test]
1609 fn test_acknowledges_rejects_a_mismatched_pair() {
1610 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1611 .unwrap()
1612 .with_timezone(&Utc);
1613
1614 let grant = DTGCredential::new_vmc(
1615 "did:example:community".to_string(),
1616 "did:example:member".to_string(),
1617 valid_from,
1618 None,
1619 false,
1620 );
1621 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1622
1623 let other_member = DTGCredential::new_vmc(
1625 "did:example:community".to_string(),
1626 "did:example:someone-else".to_string(),
1627 valid_from,
1628 None,
1629 false,
1630 );
1631 assert!(!ack.acknowledges(&other_member).unwrap());
1632
1633 let other_community = DTGCredential::new_vmc(
1635 "did:example:other-community".to_string(),
1636 "did:example:member".to_string(),
1637 valid_from,
1638 None,
1639 false,
1640 );
1641 assert!(!ack.acknowledges(&other_community).unwrap());
1642
1643 let renewed = DTGCredential::new_vmc(
1647 "did:example:community".to_string(),
1648 "did:example:member".to_string(),
1649 valid_from + chrono::Duration::days(365),
1650 None,
1651 false,
1652 );
1653 assert!(!ack.acknowledges(&renewed).unwrap());
1654
1655 let ack_of_ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1657 assert!(!ack_of_ack.acknowledges(&ack).unwrap());
1658
1659 assert!(!grant.acknowledges(&grant).unwrap());
1661 }
1662
1663 #[test]
1666 fn test_acknowledges_is_membership_only() {
1667 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1668 .unwrap()
1669 .with_timezone(&Utc);
1670
1671 let grant = DTGCredential::new_vmc(
1672 "did:example:community".to_string(),
1673 "did:example:member".to_string(),
1674 valid_from,
1675 None,
1676 false,
1677 );
1678 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1679
1680 let vrc = DTGCredential::new_vrc(
1681 "did:example:member".to_string(),
1682 "did:example:community".to_string(),
1683 valid_from,
1684 None,
1685 );
1686 assert!(!ack.acknowledges(&vrc).unwrap());
1687
1688 let vwc = DTGCredential::new_vwc(
1690 "did:example:witness".to_string(),
1691 "did:example:community".to_string(),
1692 valid_from,
1693 None,
1694 "thread-abc-123".to_string(),
1695 Some(grant.digest().unwrap()),
1696 None,
1697 );
1698 assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
1699 assert!(
1700 !vwc.acknowledges(&grant).unwrap(),
1701 "but a VWC is not the member's acknowledgement"
1702 );
1703 }
1704
1705 #[test]
1709 fn test_new_member_vmc_refuses_a_non_grant() {
1710 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1711 .unwrap()
1712 .with_timezone(&Utc);
1713
1714 let vrc = DTGCredential::new_vrc(
1715 "did:example:a".to_string(),
1716 "did:example:b".to_string(),
1717 valid_from,
1718 None,
1719 );
1720 assert!(matches!(
1721 DTGCredential::new_member_vmc(&vrc, valid_from, None),
1722 Err(DTGCredentialError::WrongCredentialType { .. })
1723 ));
1724
1725 let grant = DTGCredential::new_vmc(
1726 "did:example:community".to_string(),
1727 "did:example:member".to_string(),
1728 valid_from,
1729 None,
1730 false,
1731 );
1732 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1733 assert!(matches!(
1734 DTGCredential::new_member_vmc(&ack, valid_from, None),
1735 Err(DTGCredentialError::NotAMembershipGrant(_))
1736 ));
1737 }
1738
1739 #[test]
1744 fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
1745 let vmc: DTGCredential = serde_json::from_str(
1746 r#"{
1747 "@context": ["https://www.w3.org/ns/credentials/v2"],
1748 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1749 "issuer": "did:example:member",
1750 "validFrom": "2024-06-18T10:00:00Z",
1751 "credentialSubject": {
1752 "id": "did:example:community",
1753 "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1754 }
1755 }"#,
1756 )
1757 .expect("deserializes");
1758
1759 assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1760 assert!(matches!(
1761 vmc.credential().credential_subject,
1762 CredentialSubject::Membership(_)
1763 ));
1764 assert_eq!(
1765 vmc.subject_digest(),
1766 Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
1767 );
1768 assert_eq!(vmc.subject(), "did:example:community");
1769 }
1770
1771 #[test]
1774 fn test_membership_credential_rejects_a_witness_context() {
1775 let result: Result<DTGCredential, _> = serde_json::from_str(
1776 r#"{
1777 "@context": ["https://www.w3.org/ns/credentials/v2"],
1778 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1779 "issuer": "did:example:member",
1780 "validFrom": "2024-06-18T10:00:00Z",
1781 "credentialSubject": {
1782 "id": "did:example:community",
1783 "digest": "sha256:e3b0c4",
1784 "witnessContext": { "event": "not a membership property" }
1785 }
1786 }"#,
1787 );
1788 assert!(result.is_err());
1789 }
1790
1791 #[test]
1794 fn test_the_two_halves_round_trip_over_the_wire() {
1795 let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1796 .unwrap()
1797 .with_timezone(&Utc);
1798
1799 let grant = DTGCredential::new_vmc(
1800 "did:example:community".to_string(),
1801 "did:example:member".to_string(),
1802 valid_from,
1803 None,
1804 false,
1805 );
1806 let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1807
1808 let grant_json = serde_json::to_value(&grant).unwrap();
1809 assert!(
1810 grant_json["credentialSubject"].get("digest").is_none(),
1811 "the grant MUST omit `digest`: {grant_json}"
1812 );
1813
1814 let ack_json = serde_json::to_value(&ack).unwrap();
1815 assert_eq!(
1816 ack_json["credentialSubject"]["digest"],
1817 Value::String(grant.digest().unwrap()),
1818 );
1819
1820 let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
1823 let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
1824 assert!(ack.acknowledges(&grant).unwrap());
1825 }
1826
1827 #[test]
1828 fn test_iso8601_format_option() {
1829 let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
1830 &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1831 )
1832 .unwrap()
1833 .to_utc();
1834 let cred = DTGCommon {
1835 valid_until: Some(now),
1836 ..Default::default()
1837 };
1838
1839 let value = serde_json::to_value(&cred).unwrap();
1840 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1841 assert_eq!(cred2.valid_until, Some(now));
1842
1843 let cred = DTGCommon::default();
1844 let value = serde_json::to_value(&cred).unwrap();
1845 let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1846 assert_eq!(cred2.valid_until, None);
1847 }
1848
1849 #[cfg(feature = "affinidi-signing")]
1850 #[tokio::test]
1851 async fn test_signing() {
1852 use affinidi_secrets_resolver::secrets::Secret;
1853
1854 let secret = Secret::generate_ed25519(None, None);
1855
1856 let mut cred = DTGCredential::new_vrc(
1857 "did:example:issuer".to_string(),
1858 "did:example:subject".to_string(),
1859 Utc::now(),
1860 None,
1861 );
1862
1863 assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
1864
1865 assert!(
1866 cred.verify_proof_with_public_key(secret.get_public_bytes())
1867 .is_ok()
1868 );
1869
1870 let secret2 = Secret::generate_ed25519(None, None);
1871 assert!(
1872 cred.verify_proof_with_public_key(secret2.get_public_bytes())
1873 .is_err()
1874 );
1875 }
1876
1877 #[cfg(feature = "affinidi-signing")]
1885 #[tokio::test]
1886 async fn test_id_is_covered_by_the_proof() {
1887 use affinidi_secrets_resolver::secrets::Secret;
1888
1889 let secret = Secret::generate_ed25519(None, None);
1890
1891 let mut cred = DTGCredential::new_vrc(
1892 "did:example:issuer".to_string(),
1893 "did:example:subject".to_string(),
1894 Utc::now(),
1895 None,
1896 )
1897 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1898
1899 cred.sign(&secret, Some(Utc::now()))
1900 .await
1901 .expect("signing a credential that carries an id");
1902 assert!(
1903 cred.verify_proof_with_public_key(secret.get_public_bytes())
1904 .is_ok(),
1905 "an id set before signing verifies"
1906 );
1907
1908 cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
1911 assert!(
1912 cred.verify_proof_with_public_key(secret.get_public_bytes())
1913 .is_err(),
1914 "an id changed after signing must break the proof"
1915 );
1916 }
1917
1918 #[cfg(feature = "affinidi-signing")]
1919 #[tokio::test]
1920 async fn test_signing_error() {
1921 use affinidi_secrets_resolver::secrets::Secret;
1922
1923 let secret = Secret::generate_x25519(None, None).unwrap();
1924
1925 let mut cred = DTGCredential::new_vrc(
1926 "did:example:issuer".to_string(),
1927 "did:example:subject".to_string(),
1928 Utc::now(),
1929 None,
1930 );
1931
1932 assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
1933 }
1934
1935 #[cfg(feature = "affinidi-signing")]
1936 #[test]
1937 fn test_signing_no_proof() {
1938 use crate::DTGCredentialError;
1939 use affinidi_secrets_resolver::secrets::Secret;
1940
1941 let cred = DTGCredential::new_vrc(
1942 "did:example:issuer".to_string(),
1943 "did:example:subject".to_string(),
1944 Utc::now(),
1945 None,
1946 );
1947
1948 let secret = Secret::generate_ed25519(None, None);
1949 match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
1950 Err(DTGCredentialError::NotSigned) => {
1951 }
1953 _ => panic!("Expected NotSigned error!"),
1954 }
1955 }
1956}