use affinidi_data_integrity::DataIntegrityProof;
#[cfg(feature = "affinidi-signing")]
use affinidi_data_integrity::{DataIntegrityError, SignOptions, VerifyOptions};
#[cfg(feature = "affinidi-signing")]
use affinidi_secrets_resolver::secrets::Secret;
use chrono::{DateTime, Utc};
use multibase::Base;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fmt::Display;
use thiserror::Error;
pub mod authority;
pub mod create;
pub mod delegation;
#[derive(Clone, Copy, Debug)]
pub enum W3CVCVersion {
V1_1,
V2_0,
}
impl TryFrom<&[String]> for W3CVCVersion {
type Error = DTGCredentialError;
fn try_from(types: &[String]) -> Result<Self, Self::Error> {
if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
Ok(W3CVCVersion::V1_1)
} else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
Ok(W3CVCVersion::V2_0)
} else {
Err(DTGCredentialError::UnknownVCVersion)
}
}
}
#[derive(Error, Debug)]
pub enum DTGCredentialError {
#[error("Unknown credential type")]
UnknownCredential,
#[cfg(feature = "affinidi-signing")]
#[error("Data Integrity Error: {0}")]
DataIntegrity(#[from] DataIntegrityError),
#[error("Credential is not signed")]
NotSigned,
#[error("Unknown W3C VC Version")]
UnknownVCVersion,
#[error("AuthorityCredential carries an empty actions list, which confers nothing")]
EmptyAuthorityActions,
#[error("not an AuthorityCredential, so there is no authority to attenuate")]
NotAnAuthorityCredential,
#[deprecated(
since = "0.7.0",
note = "Never returned. `authority.parent` is a digest as of Working Draft 02, so a \
parent VAC no longer needs an `id` to be attenuated. This variant will be \
removed in a future release."
)]
#[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
AttenuationParentHasNoId,
#[error("not a well-formed digestMultibase value: {0}")]
InvalidDigest(String),
#[error("digest uses multihash algorithm 0x{0:x}, which this library does not accept")]
UnsupportedDigestAlgorithm(u64),
#[error("malformed DelegationCredential: {0}")]
MalformedDelegation(String),
#[error("Not a delegation grant: {0}")]
NotADelegationGrant(String),
#[error("attenuation would widen the parent grant: {0}")]
AttenuationWidens(String),
#[error("WitnessCredential is missing the required taskContext property")]
MissingTaskContext,
#[error("Could not canonicalize credential: {0}")]
Canonicalization(String),
#[error("Expected a {expected}, got a {got}")]
WrongCredentialType { expected: String, got: String },
#[error("Not a community-issued membership grant: {0}")]
NotAMembershipGrant(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(try_from = "DTGCommon")]
pub struct DTGCredential {
#[serde(flatten)]
credential: DTGCommon,
#[serde(skip)]
type_: DTGCredentialType,
#[serde(skip)]
version: W3CVCVersion,
}
impl DTGCredential {
pub fn credential(&self) -> &DTGCommon {
&self.credential
}
pub fn credential_mut(&mut self) -> &mut DTGCommon {
&mut self.credential
}
pub fn signed(&self) -> bool {
self.credential.signed()
}
pub fn type_(&self) -> DTGCredentialType {
self.type_.clone()
}
pub fn id(&self) -> Option<&str> {
self.credential.id()
}
pub fn issuer(&self) -> &str {
self.credential.issuer()
}
pub fn subject(&self) -> &str {
self.credential.subject()
}
pub fn valid_from(&self) -> DateTime<Utc> {
self.credential.valid_from()
}
pub fn valid_until(&self) -> Option<DateTime<Utc>> {
self.credential.valid_until()
}
pub fn task_context(&self) -> Option<&str> {
self.credential.task_context()
}
pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
let unsigned = DTGCommon {
proof: None,
..self.credential.clone()
};
let value = serde_json::to_value(&unsigned)
.map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
digest_multibase_json(&value)
}
#[deprecated(
since = "0.7.0",
note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc \
multibase multihash under the property name `digestMultibase`. Use \
DTGCredential::digest_multibase. This method will be removed in a future \
release."
)]
pub fn digest(&self) -> Result<String, DTGCredentialError> {
let unsigned = DTGCommon {
proof: None,
..self.credential.clone()
};
let value = serde_json::to_value(&unsigned)
.map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
#[allow(deprecated)]
digest_json(&value)
}
pub fn subject_digest(&self) -> Option<&str> {
match &self.credential.credential_subject {
CredentialSubject::Membership(subject) => subject.digest_multibase.as_deref(),
CredentialSubject::Witness(subject) => subject.digest_multibase.as_deref(),
CredentialSubject::Authority(subject) => subject.authority.parent.as_deref(),
CredentialSubject::Delegation(subject) => subject
.delegation
.accepts
.as_deref()
.or(subject.delegation.parent.as_deref()),
_ => None,
}
}
pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
let Some(carried) = self.subject_digest() else {
return Ok(false);
};
digests_match(carried, &referenced.digest_multibase()?)
}
pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
if !matches!(self.type_, DTGCredentialType::Membership)
|| !matches!(grant.type_, DTGCredentialType::Membership)
{
return Ok(false);
}
if grant.subject_digest().is_some() {
return Ok(false);
}
if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
return Ok(false);
}
self.verify_digest(grant)
}
pub fn accepts(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
if !matches!(self.type_, DTGCredentialType::Delegation)
|| !matches!(grant.type_, DTGCredentialType::Delegation)
{
return Ok(false);
}
let (Some(acceptance), Some(appointment)) =
(self.credential.delegation(), grant.credential.delegation())
else {
return Ok(false);
};
if appointment.accepts.is_some() || appointment.scope.is_none() {
return Ok(false);
}
let Some(carried) = &acceptance.accepts else {
return Ok(false);
};
if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
return Ok(false);
}
digests_match(carried, &grant.digest_multibase()?)
}
pub fn proof_value(&self) -> Option<&str> {
if let Some(proof) = &self.credential.proof {
proof.proof_value.as_deref()
} else {
None
}
}
#[cfg(feature = "affinidi-signing")]
pub async fn sign(
&mut self,
signing_secret: &Secret,
create_time: Option<DateTime<Utc>>,
) -> Result<DataIntegrityProof, DTGCredentialError> {
let mut options = SignOptions::new();
if let Some(ts) = create_time {
options = options.with_created(ts);
}
let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
self.credential.proof = Some(proof.clone());
Ok(proof)
}
#[cfg(feature = "affinidi-signing")]
pub fn verify_proof_with_public_key(
&self,
public_key_bytes: &[u8],
) -> Result<(), DTGCredentialError> {
let proof = if let Some(proof) = &self.credential.proof {
proof.clone()
} else {
use tracing::warn;
warn!("Trying to verify a DTG Credential that has no proof");
return Err(DTGCredentialError::NotSigned);
};
let unsigned = DTGCommon {
proof: None,
..self.credential.clone()
};
proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
Ok(())
}
pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
self.version
}
pub fn is_personhood_credential(&self) -> bool {
if let DTGCredentialType::Membership = self.type_ {
self.credential
.type_
.contains(&"PersonhoodCredential".to_string())
} else {
false
}
}
}
const MULTIHASH_SHA2_256: u64 = 0x12;
fn proofless(doc: &Value) -> Value {
match doc {
Value::Object(members) => {
let mut members = members.clone();
members.remove("proof");
Value::Object(members)
}
other => other.clone(),
}
}
pub fn digest_multibase_json(doc: &Value) -> Result<String, DTGCredentialError> {
let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
.map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
let digest = Sha256::digest(&canonical);
let mut multihash = Vec::with_capacity(2 + digest.len());
multihash.push(MULTIHASH_SHA2_256 as u8);
multihash.push(digest.len() as u8);
multihash.extend_from_slice(&digest);
Ok(multibase::encode(Base::Base58Btc, &multihash))
}
pub fn decode_digest_multibase(digest: &str) -> Result<(u64, Vec<u8>), DTGCredentialError> {
let (_, bytes) = multibase::decode(digest)
.map_err(|e| DTGCredentialError::InvalidDigest(format!("multibase: {e}")))?;
let (&code, rest) = bytes
.split_first()
.ok_or_else(|| DTGCredentialError::InvalidDigest("empty multihash".into()))?;
if code & 0x80 != 0 {
return Err(DTGCredentialError::InvalidDigest(
"multi-byte multihash code, which names no algorithm this library accepts".into(),
));
}
let (&length, raw) = rest
.split_first()
.ok_or_else(|| DTGCredentialError::InvalidDigest("multihash has no length".into()))?;
if code as u64 != MULTIHASH_SHA2_256 {
return Err(DTGCredentialError::UnsupportedDigestAlgorithm(code as u64));
}
if length as usize != raw.len() {
return Err(DTGCredentialError::InvalidDigest(format!(
"multihash declares {length} bytes but carries {}",
raw.len()
)));
}
Ok((code as u64, raw.to_vec()))
}
pub fn digests_match(left: &str, right: &str) -> Result<bool, DTGCredentialError> {
Ok(decode_digest_multibase(left)? == decode_digest_multibase(right)?)
}
#[deprecated(
since = "0.7.0",
note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc multibase \
multihash under the property name `digestMultibase`. Use \
digest_multibase_json. This function will be removed in a future release."
)]
pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
.map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity("sha256:".len() + 64);
out.push_str("sha256:");
for byte in Sha256::digest(&canonical) {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DTGCredentialType {
Membership,
Relationship,
Invitation,
Persona,
Endorsement,
Witness,
Authority,
Delegation,
#[deprecated(
since = "0.2.0",
note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
It was removed from the DTG Core Credentials specification in Working Draft 01 \
and will be defined by the planned DTG Verifiable Data Structures specification. \
This variant will be removed in a future release."
)]
RCard,
}
impl Display for DTGCredentialType {
#[allow(deprecated)]
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DTGCredentialType::Membership => write!(f, "MembershipCredential"),
DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
DTGCredentialType::Persona => write!(f, "PersonaCredential"),
DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
DTGCredentialType::Witness => write!(f, "WitnessCredential"),
DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
DTGCredentialType::RCard => write!(f, "RCardCredential"),
}
}
}
const DTG_TYPES: [&str; 9] = [
"MembershipCredential",
"RelationshipCredential",
"InvitationCredential",
"PersonaCredential",
"EndorsementCredential",
"WitnessCredential",
"AuthorityCredential",
"DelegationCredential",
"RCardCredential",
];
impl TryFrom<&[String]> for DTGCredentialType {
type Error = DTGCredentialError;
#[allow(deprecated)]
fn try_from(types: &[String]) -> Result<Self, Self::Error> {
if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
match *type_ {
"MembershipCredential" => Ok(DTGCredentialType::Membership),
"RelationshipCredential" => Ok(DTGCredentialType::Relationship),
"InvitationCredential" => Ok(DTGCredentialType::Invitation),
"PersonaCredential" => Ok(DTGCredentialType::Persona),
"EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
"WitnessCredential" => Ok(DTGCredentialType::Witness),
"AuthorityCredential" => Ok(DTGCredentialType::Authority),
"DelegationCredential" => Ok(DTGCredentialType::Delegation),
"RCardCredential" => Ok(DTGCredentialType::RCard),
_ => Err(DTGCredentialError::UnknownCredential),
}
} else {
Err(DTGCredentialError::UnknownCredential)
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DTGCommon {
#[serde(rename = "@context")]
pub context: Vec<String>,
#[serde(rename = "type")]
pub type_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub id: Option<String>,
pub issuer: String,
#[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
pub valid_from: DateTime<Utc>,
#[serde(serialize_with = "iso8601_format_option")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "expirationDate",
default
)]
pub valid_until: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub task_context: Option<String>,
pub credential_subject: CredentialSubject,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub credential_status: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub proof: Option<DataIntegrityProof>,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
impl DTGCommon {
pub fn signed(&self) -> bool {
self.proof.is_some()
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn issuer(&self) -> &str {
&self.issuer
}
#[allow(deprecated)]
pub fn subject(&self) -> &str {
match &self.credential_subject {
CredentialSubject::Basic(subject) => &subject.id,
CredentialSubject::Endorsement(subject) => &subject.id,
CredentialSubject::Witness(subject) => &subject.id,
CredentialSubject::Membership(subject) => &subject.id,
CredentialSubject::Authority(subject) => &subject.id,
CredentialSubject::Delegation(subject) => &subject.id,
CredentialSubject::RCard(subject) => &subject.id,
}
}
pub fn authority(&self) -> Option<&AuthorityGrant> {
match &self.credential_subject {
CredentialSubject::Authority(subject) => Some(&subject.authority),
_ => None,
}
}
pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
match &mut self.credential_subject {
CredentialSubject::Authority(subject) => Some(&mut subject.authority),
_ => None,
}
}
pub fn delegation(&self) -> Option<&DelegationGrant> {
match &self.credential_subject {
CredentialSubject::Delegation(subject) => Some(&subject.delegation),
_ => None,
}
}
pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> {
match &mut self.credential_subject {
CredentialSubject::Delegation(subject) => Some(&mut subject.delegation),
_ => None,
}
}
pub fn valid_from(&self) -> DateTime<Utc> {
self.valid_from
}
pub fn valid_until(&self) -> Option<DateTime<Utc>> {
self.valid_until
}
pub fn task_context(&self) -> Option<&str> {
self.task_context.as_deref()
}
}
impl Default for DTGCommon {
fn default() -> Self {
DTGCommon {
context: vec![
"https://www.w3.org/ns/credentials/v2".to_string(),
"https://firstperson.network/credentials/dtg/v1".to_string(),
],
type_: vec![
"VerifiableCredential".to_string(),
"DTGCredential".to_string(),
],
id: None,
issuer: String::new(),
valid_from: Utc::now(),
valid_until: None,
task_context: None,
credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
id: String::new(),
}),
credential_status: None,
proof: None,
extra: serde_json::Map::new(),
}
}
}
impl TryFrom<DTGCommon> for DTGCredential {
type Error = DTGCredentialError;
#[allow(deprecated)]
fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
match &value.type_.as_slice().try_into()? {
DTGCredentialType::Membership => {
let subject = match &value.credential_subject {
CredentialSubject::Membership(subject) => subject.clone(),
CredentialSubject::Basic(subject) => CredentialSubjectMembership {
id: subject.id.clone(),
digest_multibase: None,
},
CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
CredentialSubjectMembership {
id: subject.id.clone(),
digest_multibase: subject.digest_multibase.clone(),
}
}
_ => return Err(DTGCredentialError::UnknownCredential),
};
Ok(DTGCredential {
type_: DTGCredentialType::Membership,
version: value.context.as_slice().try_into()?,
credential: DTGCommon {
credential_subject: CredentialSubject::Membership(subject),
..value
},
})
}
DTGCredentialType::Relationship => Ok(DTGCredential {
type_: DTGCredentialType::Relationship,
version: value.context.as_slice().try_into()?,
credential: value,
}),
DTGCredentialType::Invitation => Ok(DTGCredential {
type_: DTGCredentialType::Invitation,
version: value.context.as_slice().try_into()?,
credential: value,
}),
DTGCredentialType::Persona => Ok(DTGCredential {
type_: DTGCredentialType::Persona,
version: value.context.as_slice().try_into()?,
credential: value,
}),
DTGCredentialType::Endorsement => {
if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
Ok(DTGCredential {
type_: DTGCredentialType::Endorsement,
version: value.context.as_slice().try_into()?,
credential: value,
})
} else {
Err(DTGCredentialError::UnknownCredential)
}
}
DTGCredentialType::Witness => {
if value.task_context.is_none() {
return Err(DTGCredentialError::MissingTaskContext);
}
match &value.credential_subject {
CredentialSubject::Witness(_) => Ok(DTGCredential {
type_: DTGCredentialType::Witness,
version: value.context.as_slice().try_into()?,
credential: value,
}),
CredentialSubject::Basic(subject) => {
Ok(DTGCredential {
type_: DTGCredentialType::Witness,
version: value.context.as_slice().try_into()?,
credential: DTGCommon {
credential_subject: CredentialSubject::Witness(
CredentialSubjectWitness {
id: subject.id.clone(),
digest_multibase: None,
witness_context: None,
},
),
..value
},
})
}
_ => Err(DTGCredentialError::UnknownCredential),
}
}
DTGCredentialType::Authority => {
match &value.credential_subject {
CredentialSubject::Authority(subject) => {
if subject.authority.actions.is_empty() {
return Err(DTGCredentialError::EmptyAuthorityActions);
}
Ok(DTGCredential {
type_: DTGCredentialType::Authority,
version: value.context.as_slice().try_into()?,
credential: value,
})
}
_ => Err(DTGCredentialError::UnknownCredential),
}
}
DTGCredentialType::Delegation => {
match &value.credential_subject {
CredentialSubject::Delegation(subject) => {
let d = &subject.delegation;
match (&d.accepts, &d.scope) {
(Some(_), Some(_)) => {
return Err(DTGCredentialError::MalformedDelegation(
"carries both `accepts` and `scope`: an acceptance \
consents to the scope of the grant it names rather \
than restating it"
.into(),
));
}
(Some(_), None) => {
if d.parent.is_some() || d.max_depth.is_some() {
return Err(DTGCredentialError::MalformedDelegation(
"an acceptance carries `accepts` and nothing else".into(),
));
}
}
(None, Some(scope)) => {
if scope.is_empty() {
return Err(DTGCredentialError::MalformedDelegation(
"a grant's `scope` MUST contain at least one \
entry — emptying it is not how an unbounded \
appointment is expressed, because there is no \
way to express one"
.into(),
));
}
}
(None, None) => {
return Err(DTGCredentialError::MalformedDelegation(
"carries neither `scope` nor `accepts`, so it is \
neither a grant nor an acceptance"
.into(),
));
}
}
Ok(DTGCredential {
type_: DTGCredentialType::Delegation,
version: value.context.as_slice().try_into()?,
credential: value,
})
}
_ => Err(DTGCredentialError::UnknownCredential),
}
}
DTGCredentialType::RCard => match &value.credential_subject {
CredentialSubject::RCard { .. } => Ok(DTGCredential {
type_: DTGCredentialType::RCard,
version: value.context.as_slice().try_into()?,
credential: value,
}),
_ => Err(DTGCredentialError::UnknownCredential),
},
}
}
}
fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_str(
timestamp
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.as_str(),
)
}
fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(timestamp) = timestamp {
s.serialize_str(
timestamp
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.as_str(),
)
} else {
s.serialize_none()
}
}
#[allow(deprecated)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum CredentialSubject {
Endorsement(CredentialSubjectEndorsement),
#[deprecated(
since = "0.2.0",
note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
See DTGCredentialType::RCard. This variant will be removed in a future release."
)]
RCard(CredentialSubjectRCard),
Basic(CredentialSubjectBasic),
Witness(CredentialSubjectWitness),
Authority(CredentialSubjectAuthority),
Delegation(CredentialSubjectDelegation),
Membership(CredentialSubjectMembership),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct CredentialSubjectBasic {
pub id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthorityGrant {
pub scope: String,
pub actions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DelegationGrant {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub scope: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub max_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub accepts: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialSubjectDelegation {
pub id: String,
pub delegation: DelegationGrant,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialSubjectAuthority {
pub id: String,
pub authority: AuthorityGrant,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialSubjectMembership {
pub id: String,
#[serde(
rename = "digestMultibase",
alias = "digest",
skip_serializing_if = "Option::is_none",
default
)]
pub digest_multibase: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct CredentialSubjectEndorsement {
pub id: String,
pub endorsement: Value,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CredentialSubjectWitness {
pub id: String,
#[serde(
rename = "digestMultibase",
alias = "digest",
skip_serializing_if = "Option::is_none",
default
)]
pub digest_multibase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub witness_context: Option<WitnessContext>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WitnessContext {
pub event: Option<String>,
pub session_id: Option<String>,
pub method: Option<String>,
}
#[deprecated(
since = "0.2.0",
note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
See DTGCredentialType::RCard. This struct will be removed in a future release."
)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct CredentialSubjectRCard {
pub id: String,
pub card: Value,
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use crate::{
CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
DTGCredentialType, W3CVCVersion, decode_digest_multibase, digest_multibase_json,
digests_match,
};
use chrono::{DateTime, Utc};
use multibase::Base;
use serde_json::Value;
use sha2::{Digest, Sha256};
#[test]
fn test_vmc_vc_1_deserialize() {
let vmc: DTGCredential = match serde_json::from_str(
r#"{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://firstperson.network/credentials/dtg/v1",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:web:chess-club.example",
"issuanceDate": "2026-01-06T10:00:00Z",
"expirationDate": "2027-01-06T10:00:00Z",
"credentialSubject": {
"id": "did:key:z6MkpTHR8VNs..."
}
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize VMC: {}", e),
};
assert!(matches!(vmc.type_, DTGCredentialType::Membership));
assert!(matches!(
vmc.credential().credential_subject,
CredentialSubject::Membership(_)
));
assert!(matches!(vmc.version, W3CVCVersion::V1_1));
assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
}
#[test]
fn test_missing_w3c_context() {
assert!(
serde_json::from_str::<DTGCredential>(
r#"{
"@context": [
"https://firstperson.network/credentials/dtg/v1",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:web:chess-club.example",
"issuanceDate": "2026-01-06T10:00:00Z",
"expirationDate": "2027-01-06T10:00:00Z",
"credentialSubject": {
"id": "did:key:z6MkpTHR8VNs..."
}
}"#,
)
.is_err()
);
}
#[test]
fn test_mutable_credential() {
let mut vmc = DTGCredential::new_vmc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
false,
);
let cred = vmc.credential_mut();
cred.type_.push("PersonhoodCredential".to_string());
assert!(vmc.is_personhood_credential());
}
#[test]
fn test_vmc_deserialize() {
let vmc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:rDid" }
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize VMC: {}", e),
};
assert!(!vmc.is_personhood_credential());
assert!(matches!(vmc.type_, DTGCredentialType::Membership));
assert!(matches!(
vmc.credential().credential_subject,
CredentialSubject::Membership(_)
));
assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
}
#[test]
fn test_vmc_phc_deserialize() {
let vmc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
"issuer": "did:example:community",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:rDid" }
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize VMC: {}", e),
};
assert!(vmc.is_personhood_credential());
assert!(matches!(vmc.type_, DTGCredentialType::Membership));
assert!(matches!(
vmc.credential().credential_subject,
CredentialSubject::Membership(_)
));
}
#[test]
fn test_vrc_deserialize() {
let vrc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(vrc) => vrc,
Err(e) => panic!("Couldn't deserialize VRC: {}", e),
};
assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
assert!(matches!(
vrc.credential().credential_subject,
CredentialSubject::Basic(_)
));
}
#[test]
fn test_vic_deserialize() {
let vic: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
"issuer": "did:example:governmentAgencyVicDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(vic) => vic,
Err(e) => panic!("Couldn't deserialize VIC: {}", e),
};
assert!(!vic.is_personhood_credential());
assert!(matches!(vic.type_, DTGCredentialType::Invitation));
assert!(matches!(
vic.credential().credential_subject,
CredentialSubject::Basic(_)
));
}
#[test]
fn test_vpc_deserialize() {
let vpc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(vpc) => vpc,
Err(e) => panic!("Couldn't deserialize VPC: {}", e),
};
assert!(matches!(vpc.type_, DTGCredentialType::Persona));
assert!(matches!(
vpc.credential().credential_subject,
CredentialSubject::Basic(_)
));
}
#[test]
fn test_vec_deserialize() {
let vec: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
}"#,
) {
Ok(vec) => vec,
Err(e) => panic!("Couldn't deserialize VEC: {}", e),
};
assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
assert!(matches!(vec.subject(), "did:example:citizenRDid"));
assert!(matches!(
vec.credential().credential_subject,
CredentialSubject::Endorsement(_)
));
}
#[test]
fn test_vec_bad_deserialize() {
match serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
}"#,
) {
Ok(_) => panic!("Expected Unknown Credential type"),
Err(_) => {
}
};
}
#[test]
fn test_vwc_simple_deserialize() {
let vwc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"taskContext": "thread-abc-123",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(vwc) => vwc,
Err(e) => panic!("Couldn't deserialize VWC: {}", e),
};
assert!(matches!(vwc.type_, DTGCredentialType::Witness));
assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
assert_eq!(vwc.task_context(), Some("thread-abc-123"));
assert!(matches!(
vwc.credential().credential_subject,
CredentialSubject::Witness(_)
));
}
#[test]
fn test_vwc_full_deserialize() {
let vwc: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"taskContext": "thread-abc-123",
"credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "witnessContext": {} }
}"#,
) {
Ok(vwc) => vwc,
Err(e) => panic!("Couldn't deserialize VWC: {}", e),
};
assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
assert!(matches!(
vwc.credential().credential_subject,
CredentialSubject::Witness(_)
));
}
#[test]
fn test_vwc_bad_deserialize() {
if serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"taskContext": "thread-abc-123",
"credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "wrongContext": {} }
}"#,
).is_ok() {
panic!("Should have failed due to wrong CredentialSubject!");
}
}
#[test]
fn test_rcard_simple_deserialize() {
let rcard: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
}"#,
) {
Ok(rcard) => rcard,
Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
};
assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
assert!(matches!(
rcard.credential().credential_subject,
CredentialSubject::RCard(_)
));
}
#[test]
fn test_rcard_bad_deserialize() {
if serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RCardCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
)
.is_ok()
{
panic!("Should have failed due to wrong CredentialSubject!");
}
}
#[test]
fn test_deserialize_unknown() {
match serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "UnknownCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(_) => panic!("Expected Unknown Credential type"),
Err(e) => {
if e.to_string() == "Unknown credential type" {
} else {
panic!("Wrong error type returned");
}
}
};
}
#[test]
fn test_deserialize_mismatched_credential_subject() {
match serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "EndorsementCredential"],
"issuer": "did:example:governmentAgencyDid",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(_) => panic!("Expected Unknown Credential type"),
Err(e) => {
if e.to_string() == "Unknown credential type" {
} else {
panic!("Wrong error type returned");
}
}
};
}
#[test]
fn test_proof_signed() {
let cred: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:rDid" },
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2025-12-04T00:00:00",
"verificationMethod": "did:example:test#key-1",
"proofPurpose": "assertionMethod",
"proofValue": "abcd"
}
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize credential: {}", e),
};
assert!(cred.signed());
assert!(cred.proof_value().is_some());
}
#[test]
fn test_proof_not_signed() {
let cred: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:rDid" }
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize credential: {}", e),
};
assert!(!cred.signed());
assert!(cred.proof_value().is_none());
}
#[test]
fn test_helpers() {
let cred: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:issuer",
"validFrom": "2024-06-18T00:00:00Z",
"credentialSubject": { "id": "did:example:subject" }
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize credential: {}", e),
};
assert_eq!(cred.issuer(), "did:example:issuer");
assert_eq!(cred.subject(), "did:example:subject");
assert_eq!(
cred.valid_from()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"2024-06-18T00:00:00Z"
);
assert_eq!(cred.valid_until(), None);
}
#[test]
fn test_valid_until() {
let cred: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:issuer",
"validFrom": "2024-06-18T00:00:00Z",
"validUntil": "2030-01-01T00:00:00Z",
"credentialSubject": { "id": "did:example:subject" }
}"#,
) {
Ok(vmc) => vmc,
Err(e) => panic!("Couldn't deserialize credential: {}", e),
};
assert_eq!(
cred.valid_until()
.unwrap()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"2030-01-01T00:00:00Z"
);
}
#[test]
fn test_bad_type() {
assert!(
std::convert::TryInto::<DTGCredentialType>::try_into(
vec!["bad_type".to_string()].as_slice(),
)
.is_err()
);
}
#[test]
fn test_badly_constructed_vwc() {
let mut cred = DTGCommon::default();
cred.type_.push("WitnessCredential".to_string());
cred.task_context = Some("thread-abc-123".to_string());
cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
id: "did:example:bad".to_string(),
card: Value::Null,
});
assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
}
#[test]
fn test_vwc_missing_task_context() {
match serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
"issuer": "did:example:witness",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:observed" }
}"#,
) {
Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
Err(e) => assert_eq!(
e.to_string(),
"WitnessCredential is missing the required taskContext property"
),
}
}
#[test]
fn test_task_context_round_trip() {
let raw = r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "WitnessCredential"],
"issuer": "did:example:witness",
"validFrom": "2024-06-18T10:00:00Z",
"taskContext": "thread-abc-123",
"credentialSubject": { "id": "did:example:observed" }
}"#;
let cred: DTGCredential = serde_json::from_str(raw).unwrap();
let out = serde_json::to_string(&cred).unwrap();
assert!(out.contains(r#""taskContext":"thread-abc-123""#));
}
#[test]
fn test_task_context_optional_on_other_types() {
let vrc: DTGCredential = serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": "did:example:issuer",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:subject" }
}"#,
)
.unwrap();
assert_eq!(vrc.task_context(), None);
assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
}
#[test]
fn test_digest_multibase() {
let vrc = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
);
let digest = vrc.digest_multibase().unwrap();
assert!(digest.starts_with('z'));
let (base, bytes) = multibase::decode(&digest).unwrap();
assert_eq!(base, multibase::Base::Base58Btc);
assert_eq!(bytes.len(), 34);
assert_eq!(&bytes[..2], &[0x12, 0x20]);
assert_eq!(digest, vrc.digest_multibase().unwrap());
let other = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:someone-else".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
);
assert_ne!(digest, other.digest_multibase().unwrap());
}
#[test]
fn test_verify_digest() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let vrc = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
valid_from,
None,
);
let vwc = DTGCredential::new_vwc(
"did:example:witness".to_string(),
"did:example:issuer".to_string(),
valid_from,
None,
"thread-abc-123".to_string(),
Some(vrc.digest_multibase().unwrap()),
None,
);
assert!(vwc.verify_digest(&vrc).unwrap());
let other = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:someone-else".to_string(),
valid_from,
None,
);
assert!(!vwc.verify_digest(&other).unwrap());
}
#[test]
fn test_verify_digest_without_digest() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let vrc = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
valid_from,
None,
);
let vwc = DTGCredential::new_vwc(
"did:example:witness".to_string(),
"did:example:issuer".to_string(),
valid_from,
None,
"thread-abc-123".to_string(),
None,
None,
);
assert!(!vwc.verify_digest(&vrc).unwrap());
}
#[test]
fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() {
let vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
false,
)
.with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
let digest = vmc.digest_multibase().unwrap();
assert!(digest.starts_with('z'), "multibase base58btc prefix");
let (base, bytes) = multibase::decode(&digest).unwrap();
assert_eq!(base, Base::Base58Btc);
assert_eq!(bytes.len(), 34);
assert_eq!(&bytes[..2], &[0x12, 0x20]);
assert_eq!(digest, "zQmTJgyPT2ShMQ2AvCHGDoPGjEWyRC7ZNT3MBpe5PP6Vpvu");
assert_eq!(digest, vmc.digest_multibase().unwrap());
}
#[test]
#[allow(deprecated)]
fn the_superseded_hex_digest_is_unchanged() {
let vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
false,
)
.with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
assert_eq!(
vmc.digest().unwrap(),
"sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
);
}
#[test]
fn a_superseded_digest_value_is_rejected_as_malformed() {
let err = decode_digest_multibase(
"sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2",
)
.unwrap_err();
assert!(
matches!(err, DTGCredentialError::InvalidDigest(_)),
"expected InvalidDigest, got {err:?}"
);
}
#[test]
fn digests_are_compared_by_bytes_not_by_string() {
let multihash = {
let mut v = vec![0x12u8, 0x20];
v.extend_from_slice(&Sha256::digest(b"an edge credential"));
v
};
let b58 = multibase::encode(Base::Base58Btc, &multihash);
let b16 = multibase::encode(Base::Base16Lower, &multihash);
assert_ne!(b58, b16, "the two spellings differ as strings");
assert!(
digests_match(&b58, &b16).unwrap(),
"but name the same digest"
);
}
#[test]
fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() {
let mut multihash = vec![0x13u8, 0x40];
multihash.extend_from_slice(&[0u8; 64]);
let encoded = multibase::encode(Base::Base58Btc, &multihash);
assert!(matches!(
decode_digest_multibase(&encoded),
Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13))
));
}
#[cfg(feature = "affinidi-signing")]
#[tokio::test]
async fn test_digest_is_unchanged_by_signing() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_ed25519(None, None);
let mut vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
Utc::now(),
None,
false,
);
let before = vmc.digest_multibase().unwrap();
vmc.sign(&secret, None).await.expect("signs");
assert!(vmc.signed());
assert_eq!(before, vmc.digest_multibase().unwrap());
}
fn wire(c: &DTGCredential) -> Value {
serde_json::to_value(c.credential()).expect("credential serialises")
}
#[test]
fn test_member_vmc_acknowledges_its_grant() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let grant = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
assert_eq!(ack.issuer(), "did:example:member");
assert_eq!(ack.subject(), "did:example:community");
assert_eq!(grant.subject_digest(), None);
assert_eq!(
ack.subject_digest(),
Some(grant.digest_multibase().unwrap().as_str())
);
assert!(ack.acknowledges(&grant).unwrap());
}
#[test]
fn test_acknowledges_rejects_a_mismatched_pair() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let grant = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
let other_member = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:someone-else".to_string(),
valid_from,
None,
false,
);
assert!(!ack.acknowledges(&other_member).unwrap());
let other_community = DTGCredential::new_vmc(
"did:example:other-community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
assert!(!ack.acknowledges(&other_community).unwrap());
let renewed = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from + chrono::Duration::days(365),
None,
false,
);
assert!(!ack.acknowledges(&renewed).unwrap());
let ack_of_ack =
DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
assert!(!ack_of_ack.acknowledges(&ack).unwrap());
assert!(!grant.acknowledges(&grant).unwrap());
}
#[test]
fn a_vdc_carries_the_credential_status_it_is_given() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let valid_until = DateTime::parse_from_rfc3339("2026-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let status = serde_json::json!({
"id": "https://delegator.example/status#12",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "12"
});
let vdc = DTGCredential::new_vdc(
"did:example:delegator".to_string(),
"did:example:delegate".to_string(),
valid_from,
valid_until,
vec!["sign:invoices".to_string()],
None,
)
.expect("a bounded grant is well formed");
assert!(
vdc.credential().credential_status.is_none(),
"a VDC MAY omit `credentialStatus`, so the constructor must not supply one"
);
let vdc = vdc.with_credential_status(status.clone());
assert_eq!(vdc.credential().credential_status.as_ref(), Some(&status));
assert_eq!(wire(&vdc).get("credentialStatus"), Some(&status));
let parsed: DTGCredential = serde_json::from_value(wire(&vdc)).expect("parses");
assert_eq!(
parsed.credential().credential_status.as_ref(),
Some(&status)
);
}
#[test]
fn set_credential_status_matches_the_builder() {
let status = serde_json::json!({ "type": "BitstringStatusListEntry" });
let mut vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
Utc::now(),
None,
false,
);
vmc.set_credential_status(status.clone());
assert_eq!(vmc.credential().credential_status.as_ref(), Some(&status));
}
#[test]
fn credential_types_compare_by_equality() {
let vdc = DTGCredential::new_vdc(
"did:example:delegator".to_string(),
"did:example:delegate".to_string(),
Utc::now(),
Utc::now() + chrono::Duration::days(1),
vec!["sign:invoices".to_string()],
None,
)
.expect("a bounded grant is well formed");
assert_eq!(vdc.type_(), DTGCredentialType::Delegation);
assert_ne!(vdc.type_(), DTGCredentialType::Membership);
}
#[test]
fn credential_status_survives_a_round_trip() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut grant = wire(&DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
));
let status = serde_json::json!({
"id": "https://community.example/status#7",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "7"
});
grant["credentialStatus"] = status.clone();
let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
assert_eq!(
parsed.credential().credential_status.as_ref(),
Some(&status)
);
assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status));
assert_eq!(
parsed.digest_multibase().unwrap(),
digest_multibase_json(&grant).unwrap(),
"the digest must not change under a round trip that preserves every member"
);
}
#[test]
fn unmodelled_top_level_members_survive_a_round_trip() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut grant = wire(&DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
));
let schema = serde_json::json!({
"id": "https://community.example/schemas/vmc",
"type": "JsonSchema"
});
grant["credentialSchema"] = schema.clone();
let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
assert_eq!(
parsed.credential().extra.get("credentialSchema"),
Some(&schema)
);
assert_eq!(
parsed.digest_multibase().unwrap(),
digest_multibase_json(&grant).unwrap()
);
}
#[test]
fn the_acknowledgement_digests_the_grant_as_it_arrived() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let mut grant = wire(&DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
));
grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string());
let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
assert_ne!(
wire(&parsed).get("validFrom"),
grant.get("validFrom"),
"the model is expected to normalize the timestamp; if it now round-trips \
verbatim, this test has stopped guarding anything"
);
let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
assert_eq!(
ack.subject_digest(),
Some(digest_multibase_json(&grant).unwrap().as_str()),
"the acknowledgement must digest the grant as received"
);
assert_ne!(
ack.subject_digest(),
Some(parsed.digest_multibase().unwrap().as_str()),
"digesting the parsed model would produce a digest the community cannot match"
);
}
#[test]
fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() {
let vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
None,
false,
)
.with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
assert_eq!(
vmc.digest_multibase().unwrap(),
digest_multibase_json(&wire(&vmc)).unwrap()
);
}
#[test]
fn test_acknowledges_is_membership_only() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let grant = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
let vrc = DTGCredential::new_vrc(
"did:example:member".to_string(),
"did:example:community".to_string(),
valid_from,
None,
);
assert!(!ack.acknowledges(&vrc).unwrap());
let vwc = DTGCredential::new_vwc(
"did:example:witness".to_string(),
"did:example:community".to_string(),
valid_from,
None,
"thread-abc-123".to_string(),
Some(grant.digest_multibase().unwrap()),
None,
);
assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
assert!(
!vwc.acknowledges(&grant).unwrap(),
"but a VWC is not the member's acknowledgement"
);
}
#[test]
fn test_new_member_vmc_refuses_a_non_grant() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let vrc = DTGCredential::new_vrc(
"did:example:a".to_string(),
"did:example:b".to_string(),
valid_from,
None,
);
assert!(matches!(
DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
Err(DTGCredentialError::NotAMembershipGrant(_))
));
let grant = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
assert!(matches!(
DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
Err(DTGCredentialError::NotAMembershipGrant(_))
));
}
#[test]
fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
let vmc: DTGCredential = serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:member",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": {
"id": "did:example:community",
"digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}"#,
)
.expect("deserializes");
assert!(matches!(vmc.type_, DTGCredentialType::Membership));
assert!(matches!(
vmc.credential().credential_subject,
CredentialSubject::Membership(_)
));
assert_eq!(
vmc.subject_digest(),
Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
);
assert_eq!(vmc.subject(), "did:example:community");
}
#[test]
fn test_membership_credential_rejects_a_witness_context() {
let result: Result<DTGCredential, _> = serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:member",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": {
"id": "did:example:community",
"digestMultibase": "sha256:e3b0c4",
"witnessContext": { "event": "not a membership property" }
}
}"#,
);
assert!(result.is_err());
}
#[test]
fn test_the_two_halves_round_trip_over_the_wire() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let grant = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
None,
false,
);
let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
let grant_json = serde_json::to_value(&grant).unwrap();
assert!(
grant_json["credentialSubject"]
.get("digestMultibase")
.is_none(),
"the grant MUST omit `digestMultibase`: {grant_json}"
);
let ack_json = serde_json::to_value(&ack).unwrap();
assert_eq!(
ack_json["credentialSubject"]["digestMultibase"],
Value::String(grant.digest_multibase().unwrap()),
);
let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
assert!(ack.acknowledges(&grant).unwrap());
}
#[test]
fn test_iso8601_format_option() {
let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
&Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
.unwrap()
.to_utc();
let cred = DTGCommon {
valid_until: Some(now),
..Default::default()
};
let value = serde_json::to_value(&cred).unwrap();
let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
assert_eq!(cred2.valid_until, Some(now));
let cred = DTGCommon::default();
let value = serde_json::to_value(&cred).unwrap();
let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
assert_eq!(cred2.valid_until, None);
}
#[cfg(feature = "affinidi-signing")]
#[tokio::test]
async fn test_signing() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_ed25519(None, None);
let mut cred = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
Utc::now(),
None,
);
assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
assert!(
cred.verify_proof_with_public_key(secret.get_public_bytes())
.is_ok()
);
let secret2 = Secret::generate_ed25519(None, None);
assert!(
cred.verify_proof_with_public_key(secret2.get_public_bytes())
.is_err()
);
}
#[cfg(feature = "affinidi-signing")]
#[tokio::test]
async fn test_id_is_covered_by_the_proof() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_ed25519(None, None);
let mut cred = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
Utc::now(),
None,
)
.with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
cred.sign(&secret, Some(Utc::now()))
.await
.expect("signing a credential that carries an id");
assert!(
cred.verify_proof_with_public_key(secret.get_public_bytes())
.is_ok(),
"an id set before signing verifies"
);
cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
assert!(
cred.verify_proof_with_public_key(secret.get_public_bytes())
.is_err(),
"an id changed after signing must break the proof"
);
}
#[cfg(feature = "affinidi-signing")]
#[tokio::test]
async fn test_signing_error() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_x25519(None, None).unwrap();
let mut cred = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
Utc::now(),
None,
);
assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
}
#[cfg(feature = "affinidi-signing")]
#[test]
fn test_signing_no_proof() {
use crate::DTGCredentialError;
use affinidi_secrets_resolver::secrets::Secret;
let cred = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
"did:example:subject".to_string(),
Utc::now(),
None,
);
let secret = Secret::generate_ed25519(None, None);
match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
Err(DTGCredentialError::NotSigned) => {
}
_ => panic!("Expected NotSigned error!"),
}
}
}