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 std::str::FromStr;
use thiserror::Error;
pub mod accept;
pub mod authority;
pub mod create;
pub mod delegation;
pub mod statement;
pub use accept::{
AcceptListEntry, AdditionalMember, PredicateAcceptList, PredicateStatus, RegistryAcceptList,
};
pub use statement::{
CredentialSubjectStatement, ENDORSES_V1, ObjectKind, PRESENTED_V1, PredicateProfile,
StatementObject, VETTED_V1, WITNESSED_V1, check_predicate_iri,
};
pub const W3C_VC_V2_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2";
pub const W3C_VC_V1_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1";
pub const DTG_CONTEXT_V1: &str = "https://registry.trustoverip.org/dtg/context/v1";
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")]
pub enum IssuerScope {
Pairwise,
Directed,
Public,
}
impl IssuerScope {
pub fn as_str(&self) -> &'static str {
match self {
IssuerScope::Pairwise => "pairwise",
IssuerScope::Directed => "directed",
IssuerScope::Public => "public",
}
}
pub fn satisfies(&self, minimum: IssuerScope) -> bool {
*self >= minimum
}
}
impl Display for IssuerScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for IssuerScope {
type Err = DTGCredentialError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pairwise" => Ok(IssuerScope::Pairwise),
"directed" => Ok(IssuerScope::Directed),
"public" => Ok(IssuerScope::Public),
other => Err(DTGCredentialError::MalformedCredential(format!(
"`{other}` is not an issuerScope; expected `pairwise`, `directed` or `public`"
))),
}
}
}
#[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)]
#[non_exhaustive]
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("the statement's predicate profile requires taskContext, and it has none")]
MissingTaskContext,
#[error("taskContext is required here, and so is taskDigestMultibase, which is absent")]
MissingTaskDigest,
#[error("malformed @context: {0}")]
InvalidContext(String),
#[error("malformed type: {0}")]
InvalidType(String),
#[error("issuerScope `{declared}` is narrower than the required minimum `{minimum}`")]
IssuerScopeTooNarrow {
declared: IssuerScope,
minimum: IssuerScope,
},
#[error("invalid predicate: {0}")]
InvalidPredicate(String),
#[error("the statement does not meet its predicate profile: {0}")]
ProfileViolation(String),
#[error("predicate `{0}` is not accepted by this verifier")]
PredicateNotAccepted(String),
#[error("malformed accept-list: {0}")]
MalformedAcceptList(String),
#[error("cannot cite this document as a taskContext: {0}")]
MalformedTaskDocument(String),
#[error("not the witness/session document that opened the session: {0}")]
NotAWitnessSession(String),
#[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),
#[error("validUntil {valid_until} is not after validFrom {valid_from}")]
InvalidValidityWindow {
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
},
#[error("JSON is nested more than {max} levels deep")]
JsonTooDeep { max: usize },
#[error("the grant names `{found}` as its subject, not `{expected}`")]
NotTheGrantSubject { expected: String, found: String },
#[error("would remain valid after the grant it answers, which expires at {grant_valid_until}")]
OutlivesGrant {
valid_until: Option<DateTime<Utc>>,
grant_valid_until: DateTime<Utc>,
},
#[error("the proof was made by `{verification_method}`, which is not the issuer `{issuer}`")]
ProofNotFromIssuer {
issuer: String,
verification_method: String,
},
#[error("the credential is not valid at {at}")]
NotValidAt { at: DateTime<Utc> },
#[error("malformed credential: {0}")]
MalformedCredential(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(try_from = "Value")]
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 issuer_scope(&self) -> IssuerScope {
self.credential.issuer_scope
}
pub fn statement(&self) -> Option<&CredentialSubjectStatement> {
self.credential.statement()
}
pub fn predicate(&self) -> Option<&str> {
self.statement().map(|s| s.predicate.as_str())
}
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 task_digest_multibase(&self) -> Option<&str> {
self.credential.task_digest_multibase()
}
pub fn cites_task(&self, document: &Value) -> Result<bool, DTGCredentialError> {
let (Some(task_context), Some(carried)) =
(self.task_context(), self.task_digest_multibase())
else {
return Ok(false);
};
if document.get("id").and_then(Value::as_str) != Some(task_context) {
return Ok(false);
}
digests_match(carried, &task_digest_multibase_json(document)?)
}
pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
self.credential.check_depth()?;
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> {
self.credential.check_depth()?;
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::Statement(subject) => subject.object.digest_multibase(),
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
}
}
pub fn validate(&self) -> Result<(), DTGCredentialError> {
crate::create::check_window(self.valid_from(), self.valid_until())?;
self.credential.check_depth()?;
self.check_conformance()
}
fn check_conformance(&self) -> Result<(), DTGCredentialError> {
let c = &self.credential;
check_context(&c.context)?;
if check_type(&c.type_)? != self.type_ {
return Err(DTGCredentialError::InvalidType(format!(
"the type array no longer names a {}",
self.type_
)));
}
match (&self.type_, &c.credential_subject) {
(DTGCredentialType::Membership, CredentialSubject::Membership(subject)) => {
if subject.digest_multibase.is_none() && c.issuer_scope != IssuerScope::Public {
return Err(DTGCredentialError::IssuerScopeTooNarrow {
declared: c.issuer_scope,
minimum: IssuerScope::Public,
});
}
}
(
DTGCredentialType::Relationship
| DTGCredentialType::Invitation
| DTGCredentialType::Persona,
CredentialSubject::Basic(_),
) => {}
(DTGCredentialType::Statement, CredentialSubject::Statement(subject)) => {
statement::check_statement(c, subject)?;
}
(DTGCredentialType::Authority, CredentialSubject::Authority(subject)) => {
if subject.authority.actions.is_empty() {
return Err(DTGCredentialError::EmptyAuthorityActions);
}
}
(DTGCredentialType::Delegation, CredentialSubject::Delegation(subject)) => {
check_delegation_shape(&subject.delegation)?;
}
_ => return Err(DTGCredentialError::UnknownCredential),
}
Ok(())
}
#[cfg(feature = "affinidi-signing")]
pub async fn sign(
&mut self,
signing_secret: &Secret,
create_time: Option<DateTime<Utc>>,
) -> Result<DataIntegrityProof, DTGCredentialError> {
self.validate()?;
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> {
self.validate()?;
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;
pub const MAX_JSON_DEPTH: usize = 64;
fn exceeds_max_depth<'a>(roots: impl IntoIterator<Item = (&'a Value, usize)>) -> bool {
let mut pending: Vec<(&Value, usize)> = roots.into_iter().collect();
while let Some((value, depth)) = pending.pop() {
if depth > MAX_JSON_DEPTH {
return true;
}
match value {
Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
Value::Object(members) => {
pending.extend(members.values().map(|member| (member, depth + 1)))
}
_ => {}
}
}
false
}
pub(crate) fn check_json_depth(doc: &Value) -> Result<(), DTGCredentialError> {
if exceeds_max_depth([(doc, 1)]) {
Err(DTGCredentialError::JsonTooDeep {
max: MAX_JSON_DEPTH,
})
} else {
Ok(())
}
}
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> {
check_json_depth(doc)?;
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 task_digest_multibase_json(document: &Value) -> Result<String, DTGCredentialError> {
digest_multibase_json(document)
}
#[cfg(feature = "affinidi-signing")]
pub fn verify_grant_with_public_key(
grant: &Value,
public_key: &[u8],
at: DateTime<Utc>,
) -> Result<(), DTGCredentialError> {
check_json_depth(grant)?;
let object = grant
.as_object()
.ok_or_else(|| DTGCredentialError::MalformedCredential("not a JSON object".into()))?;
let Some(proof) = object.get("proof") else {
return Err(DTGCredentialError::NotSigned);
};
let proof: DataIntegrityProof = serde_json::from_value(proof.clone())
.map_err(|e| DTGCredentialError::MalformedCredential(format!("unreadable `proof`: {e}")))?;
proof.verify_with_public_key(&proofless(grant), public_key, VerifyOptions::new())?;
let issuer = create::issuer_of(object)
.ok_or_else(|| DTGCredentialError::MalformedCredential("no `issuer`".into()))?;
let method_did = proof
.verification_method
.split_once('#')
.map_or(proof.verification_method.as_str(), |(did, _)| did);
if method_did != issuer {
return Err(DTGCredentialError::ProofNotFromIssuer {
issuer,
verification_method: proof.verification_method,
});
}
let valid_from = create::read_timestamp(object, "validFrom", "issuanceDate")
.map_err(DTGCredentialError::MalformedCredential)?
.ok_or_else(|| DTGCredentialError::MalformedCredential("no `validFrom`".into()))?;
let valid_until = create::read_timestamp(object, "validUntil", "expirationDate")
.map_err(DTGCredentialError::MalformedCredential)?;
create::check_window(valid_from, valid_until)?;
if valid_from > at || valid_until.is_some_and(|until| until < at) {
return Err(DTGCredentialError::NotValidAt { at });
}
Ok(())
}
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> {
check_json_depth(doc)?;
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,
Statement,
Authority,
Delegation,
}
impl DTGCredentialType {
pub fn as_str(&self) -> &'static str {
match self {
DTGCredentialType::Membership => "MembershipCredential",
DTGCredentialType::Relationship => "RelationshipCredential",
DTGCredentialType::Invitation => "InvitationCredential",
DTGCredentialType::Persona => "PersonaCredential",
DTGCredentialType::Statement => "StatementCredential",
DTGCredentialType::Authority => "AuthorityCredential",
DTGCredentialType::Delegation => "DelegationCredential",
}
}
fn from_type_str(type_: &str) -> Option<Self> {
DTG_TYPES.iter().find(|t| t.as_str() == type_).cloned()
}
}
impl Display for DTGCredentialType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl TryFrom<&[String]> for DTGCredentialType {
type Error = DTGCredentialError;
fn try_from(types: &[String]) -> Result<Self, Self::Error> {
check_type(types)
}
}
const DTG_TYPES: [DTGCredentialType; 7] = [
DTGCredentialType::Membership,
DTGCredentialType::Relationship,
DTGCredentialType::Delegation,
DTGCredentialType::Invitation,
DTGCredentialType::Persona,
DTGCredentialType::Statement,
DTGCredentialType::Authority,
];
const RETIRED_TYPES: [(&str, &str); 3] = [
(
"EndorsementCredential",
"replaced by StatementCredential under the endorses/1 predicate",
),
(
"WitnessCredential",
"replaced by StatementCredential under the witnessed/1 predicate",
),
(
"RCardCredential",
"the r-card is a verifiable data structure, not a DTG credential",
),
];
const PERSONHOOD_HINT: &str = "PersonhoodCredential";
fn check_type(types: &[String]) -> Result<DTGCredentialType, DTGCredentialError> {
let (mut vc, mut dtg, mut personhood) = (false, false, false);
let mut concrete: Option<DTGCredentialType> = None;
for type_ in types {
let seen = match type_.as_str() {
"VerifiableCredential" => std::mem::replace(&mut vc, true),
"DTGCredential" => std::mem::replace(&mut dtg, true),
PERSONHOOD_HINT => std::mem::replace(&mut personhood, true),
other => match DTGCredentialType::from_type_str(other) {
Some(found) => match &concrete {
Some(already) if *already == found => true,
Some(already) => {
return Err(DTGCredentialError::InvalidType(format!(
"names both {already} and {found}; a DTG credential has exactly \
one concrete subtype"
)));
}
None => {
concrete = Some(found);
false
}
},
None => {
let reason = RETIRED_TYPES
.iter()
.find(|(retired, _)| *retired == other)
.map_or("not a type the DTG v1 context defines", |(_, why)| why);
return Err(DTGCredentialError::InvalidType(format!(
"`{other}`: {reason}"
)));
}
},
};
if seen {
return Err(DTGCredentialError::InvalidType(format!(
"`{type_}` is listed twice"
)));
}
}
if !vc || !dtg {
return Err(DTGCredentialError::InvalidType(
"must include both `VerifiableCredential` and `DTGCredential`".into(),
));
}
let concrete = concrete.ok_or(DTGCredentialError::UnknownCredential)?;
if personhood && concrete != DTGCredentialType::Membership {
return Err(DTGCredentialError::InvalidType(format!(
"`{PERSONHOOD_HINT}` is a hint on a MembershipCredential only, not on a {concrete}"
)));
}
Ok(concrete)
}
fn check_context(context: &[String]) -> Result<W3CVCVersion, DTGCredentialError> {
let version = match context.first().map(String::as_str) {
Some(W3C_VC_V2_CONTEXT) => W3CVCVersion::V2_0,
Some(W3C_VC_V1_CONTEXT) => W3CVCVersion::V1_1,
_ => return Err(DTGCredentialError::UnknownVCVersion),
};
match context.get(1).map(String::as_str) {
Some(DTG_CONTEXT_V1) => Ok(version),
Some(other) => Err(DTGCredentialError::InvalidContext(format!(
"second entry is `{other}`, not `{DTG_CONTEXT_V1}`"
))),
None => Err(DTGCredentialError::InvalidContext(format!(
"`{DTG_CONTEXT_V1}` is not listed second"
))),
}
}
fn check_delegation_shape(d: &DelegationGrant) -> Result<(), DTGCredentialError> {
match (&d.accepts, &d.scope) {
(Some(_), Some(_)) => 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() => {
Err(DTGCredentialError::MalformedDelegation(
"an acceptance carries `accepts` and nothing else".into(),
))
}
(Some(_), None) => Ok(()),
(None, Some(scope)) if scope.is_empty() => 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, Some(_)) => Ok(()),
(None, None) => Err(DTGCredentialError::MalformedDelegation(
"carries neither `scope` nor `accepts`, so it is neither a grant nor an \
acceptance"
.into(),
)),
}
}
#[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,
pub issuer_scope: IssuerScope,
#[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>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub task_digest_multibase: 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
}
pub fn issuer_scope(&self) -> IssuerScope {
self.issuer_scope
}
pub fn subject(&self) -> &str {
match &self.credential_subject {
CredentialSubject::Basic(subject) => &subject.id,
CredentialSubject::Statement(subject) => &subject.id,
CredentialSubject::Membership(subject) => &subject.id,
CredentialSubject::Authority(subject) => &subject.id,
CredentialSubject::Delegation(subject) => &subject.id,
}
}
pub fn statement(&self) -> Option<&CredentialSubjectStatement> {
match &self.credential_subject {
CredentialSubject::Statement(subject) => Some(subject),
_ => None,
}
}
pub fn statement_mut(&mut self) -> Option<&mut CredentialSubjectStatement> {
match &mut self.credential_subject {
CredentialSubject::Statement(subject) => Some(subject),
_ => None,
}
}
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()
}
pub fn task_digest_multibase(&self) -> Option<&str> {
self.task_digest_multibase.as_deref()
}
fn check_depth(&self) -> Result<(), DTGCredentialError> {
let mut roots: Vec<(&Value, usize)> =
self.extra.values().map(|member| (member, 2)).collect();
if let Some(status) = &self.credential_status {
roots.push((status, 2));
}
if let CredentialSubject::Statement(subject) = &self.credential_subject {
if let Some(value) = subject.object.value() {
roots.push((value, 4));
}
roots.extend(subject.extra.values().map(|member| (member, 3)));
}
if exceeds_max_depth(roots) {
Err(DTGCredentialError::JsonTooDeep {
max: MAX_JSON_DEPTH,
})
} else {
Ok(())
}
}
}
impl DTGCredential {
pub(crate) fn build(
type_: DTGCredentialType,
issuer: String,
issuer_scope: IssuerScope,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
credential_subject: CredentialSubject,
) -> Self {
DTGCredential {
credential: DTGCommon {
context: vec![W3C_VC_V2_CONTEXT.to_string(), DTG_CONTEXT_V1.to_string()],
type_: vec![
"VerifiableCredential".to_string(),
"DTGCredential".to_string(),
type_.to_string(),
],
id: None,
issuer,
issuer_scope,
valid_from,
valid_until,
task_context: None,
task_digest_multibase: None,
credential_subject,
credential_status: None,
proof: None,
extra: serde_json::Map::new(),
},
type_,
version: W3CVCVersion::V2_0,
}
}
}
impl TryFrom<Value> for DTGCredential {
type Error = DTGCredentialError;
fn try_from(value: Value) -> Result<Self, Self::Error> {
let strings = |member: &str| -> Option<Vec<String>> {
value
.get(member)?
.as_array()?
.iter()
.map(|v| v.as_str().map(str::to_string))
.collect()
};
if let Some(context) = strings("@context") {
check_context(&context)?;
}
if let Some(types) = strings("type") {
check_type(&types)?;
}
let common: DTGCommon = serde_json::from_value(value)
.map_err(|e| DTGCredentialError::MalformedCredential(e.to_string()))?;
DTGCredential::try_from(common)
}
}
impl TryFrom<DTGCommon> for DTGCredential {
type Error = DTGCredentialError;
fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
let version = check_context(&value.context)?;
let type_ = check_type(&value.type_)?;
let value = match (&type_, value.credential_subject) {
(DTGCredentialType::Membership, CredentialSubject::Basic(subject)) => DTGCommon {
credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
id: subject.id,
digest_multibase: None,
}),
..value
},
(_, credential_subject) => DTGCommon {
credential_subject,
..value
},
};
let credential = DTGCredential {
credential: value,
type_,
version,
};
credential.check_conformance()?;
Ok(credential)
}
}
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()
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum CredentialSubject {
Basic(CredentialSubjectBasic),
Statement(CredentialSubjectStatement),
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", default)]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub max_attenuation: Option<u32>,
}
#[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(skip_serializing_if = "Option::is_none", default)]
pub digest_multibase: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WitnessContext {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub event: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub method: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::{
CredentialSubject, DTG_CONTEXT_V1, DTGCommon, DTGCredential, DTGCredentialError,
DTGCredentialType, ENDORSES_V1, IssuerScope, W3C_VC_V2_CONTEXT, W3CVCVersion, WITNESSED_V1,
check_predicate_iri, check_type, 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://registry.trustoverip.org/dtg/context/v1",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:web:chess-club.example",
"issuerScope": "public",
"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://registry.trustoverip.org/dtg/context/v1",
"https://w3id.org/security/suites/ed25519-2020/v1"
],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:web:chess-club.example",
"issuerScope": "public",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"issuerScope": "public",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential", "PersonhoodCredential"],
"issuer": "did:example:community",
"issuerScope": "public",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "RelationshipCredential"],
"issuer": "did:example:governmentAgencyDid",
"issuerScope": "public",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "InvitationCredential"],
"issuer": "did:example:governmentAgencyVicDid",
"issuerScope": "public",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "PersonaCredential"],
"issuer": "did:example:governmentAgencyDid",
"issuerScope": "public",
"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(_)
));
}
fn doc(type_: &str, scope: &str, subject: Value) -> Value {
serde_json::json!({
"@context": [W3C_VC_V2_CONTEXT, DTG_CONTEXT_V1],
"type": ["VerifiableCredential", "DTGCredential", type_],
"issuer": "did:example:issuer",
"issuerScope": scope,
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": subject,
})
}
fn parse(value: Value) -> Result<DTGCredential, String> {
serde_json::from_value::<DTGCredential>(value).map_err(|e| e.to_string())
}
#[test]
fn test_vsc_deserialize() {
let vsc = parse(doc(
"StatementCredential",
"directed",
serde_json::json!({
"id": "did:example:subject",
"predicate": ENDORSES_V1,
"object": { "value": { "type": "SkillEndorsement" } }
}),
))
.unwrap();
assert_eq!(vsc.type_(), DTGCredentialType::Statement);
assert_eq!(vsc.subject(), "did:example:subject");
assert_eq!(vsc.predicate(), Some(ENDORSES_V1));
assert_eq!(
vsc.statement().unwrap().object.value(),
Some(&serde_json::json!({ "type": "SkillEndorsement" }))
);
}
#[test]
fn test_vsc_object_is_exactly_one_member() {
let with = |object: Value| {
parse(doc(
"StatementCredential",
"directed",
serde_json::json!({
"id": "did:example:subject",
"predicate": "https://vtc.example/vocab#p",
"object": object
}),
))
};
assert!(with(serde_json::json!({ "id": "did:example:thing" })).is_ok());
assert!(with(serde_json::json!({ "digestMultibase": "zQm" })).is_ok());
assert!(with(serde_json::json!({ "value": null })).is_ok());
assert!(with(serde_json::json!({})).is_err(), "no member");
assert!(
with(serde_json::json!({ "id": "did:example:a", "value": 1 })).is_err(),
"two members"
);
assert!(
with(serde_json::json!({ "other": 1 })).is_err(),
"unknown member"
);
assert!(
with(serde_json::json!({ "id": "not-an-iri" })).is_err(),
"`object.id` is a DID or other IRI"
);
}
#[test]
fn test_vsc_keeps_unmodelled_subject_members() {
let wire = doc(
"StatementCredential",
"directed",
serde_json::json!({
"id": "did:example:subject",
"predicate": "https://vtc.example/vocab#p",
"object": { "value": 1 },
"note": { "added": "by a later profile version" }
}),
);
let vsc = parse(wire.clone()).unwrap();
assert!(vsc.statement().unwrap().extra.contains_key("note"));
assert_eq!(
vsc.digest_multibase().unwrap(),
digest_multibase_json(&wire).unwrap()
);
}
#[test]
fn test_vsc_predicate_must_be_an_absolute_nfc_iri() {
for bad in [
"dtg:witnessed",
"witnessed",
"/dtg/vsc/witnessed/1",
"https://registry.trustoverip.org/dtg/vsc/witnessed/1 ",
"https:///no-authority",
"https://vtc.example/vocab#caf\u{0065}\u{0301}",
] {
let result = parse(doc(
"StatementCredential",
"public",
serde_json::json!({
"id": "did:example:subject",
"predicate": bad,
"object": { "value": true }
}),
));
assert!(result.is_err(), "`{bad}` must be refused");
}
for good in [
WITNESSED_V1,
"https://vtc.example/vocab/vetting/v1#vetted",
"https://vtc.example/vocab#caf\u{00e9}",
"urn:example:predicate:1",
"did:example:vocab#term",
] {
check_predicate_iri(good).unwrap_or_else(|e| panic!("`{good}`: {e}"));
}
}
#[test]
fn test_vsc_witnessed_profile_is_enforced_at_parse() {
let witnessed = |scope: &str, cited: bool, object: Value| {
let mut vwc = doc(
"StatementCredential",
scope,
serde_json::json!({
"id": "did:example:subject",
"predicate": WITNESSED_V1,
"object": object,
"witnessContext": { "method": "in-person-proximity" }
}),
);
if cited {
vwc["taskContext"] = serde_json::json!("urn:uuid:session");
vwc["taskDigestMultibase"] =
serde_json::json!("zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n");
}
serde_json::from_value::<DTGCredential>(vwc)
};
let digest = serde_json::json!({ "digestMultibase": "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n" });
let vwc = witnessed("directed", true, digest.clone()).unwrap();
assert_eq!(
vwc.statement()
.unwrap()
.witness_context
.as_ref()
.unwrap()
.method
.as_deref(),
Some("in-person-proximity")
);
assert!(witnessed("public", true, digest.clone()).is_ok());
assert!(witnessed("pairwise", true, digest.clone()).is_err());
assert!(witnessed("directed", false, digest.clone()).is_err());
assert!(witnessed("directed", true, serde_json::json!({ "value": {} })).is_err());
let mut named_only = doc(
"StatementCredential",
"directed",
serde_json::json!({
"id": "did:example:subject",
"predicate": WITNESSED_V1,
"object": digest
}),
);
named_only["taskContext"] = serde_json::json!("urn:uuid:session");
assert!(matches!(
parse_err(named_only),
DTGCredentialError::MissingTaskDigest
));
}
fn parse_err(value: Value) -> DTGCredentialError {
let mut common: DTGCommon = serde_json::from_value(value).expect("shape parses");
common.proof = None;
DTGCredential::try_from(common).expect_err("refused")
}
#[test]
fn test_issuer_scope_is_required_and_case_sensitive() {
let subject = serde_json::json!({ "id": "did:example:subject" });
for scope in ["pairwise", "directed", "public"] {
let vrc = parse(doc("RelationshipCredential", scope, subject.clone())).unwrap();
assert_eq!(vrc.issuer_scope().as_str(), scope);
assert_eq!(scope.parse::<IssuerScope>().unwrap(), vrc.issuer_scope());
}
for scope in ["Public", "PAIRWISE", "community", ""] {
assert!(
parse(doc("RelationshipCredential", scope, subject.clone())).is_err(),
"`{scope}` is not a declaration"
);
assert!(scope.parse::<IssuerScope>().is_err());
}
let mut missing = doc("RelationshipCredential", "pairwise", subject);
missing.as_object_mut().unwrap().remove("issuerScope");
let err = parse(missing).unwrap_err();
assert!(err.contains("issuerScope"), "{err}");
}
#[test]
fn test_issuer_scope_is_ordered() {
assert!(IssuerScope::Pairwise < IssuerScope::Directed);
assert!(IssuerScope::Directed < IssuerScope::Public);
assert!(IssuerScope::Public.satisfies(IssuerScope::Directed));
assert!(IssuerScope::Directed.satisfies(IssuerScope::Directed));
assert!(!IssuerScope::Pairwise.satisfies(IssuerScope::Directed));
assert_eq!(
serde_json::to_value(IssuerScope::Directed).unwrap(),
"directed"
);
}
#[test]
fn test_community_issued_vmc_must_be_public() {
let grant = |scope| {
doc(
"MembershipCredential",
scope,
serde_json::json!({ "id": "did:example:member" }),
)
};
assert!(parse(grant("public")).is_ok());
assert!(matches!(
parse_err(grant("directed")),
DTGCredentialError::IssuerScopeTooNarrow {
declared: IssuerScope::Directed,
minimum: IssuerScope::Public,
}
));
let ack = doc(
"MembershipCredential",
"pairwise",
serde_json::json!({
"id": "did:example:community",
"digestMultibase": "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n"
}),
);
assert_eq!(parse(ack).unwrap().issuer_scope(), IssuerScope::Pairwise);
}
#[test]
fn test_retired_types_are_refused() {
for (retired, subject) in [
(
"EndorsementCredential",
serde_json::json!({ "id": "did:example:subject", "endorsement": {} }),
),
(
"WitnessCredential",
serde_json::json!({ "id": "did:example:subject", "digestMultibase": "zQm" }),
),
(
"RCardCredential",
serde_json::json!({ "id": "did:example:subject", "card": [] }),
),
] {
let mut vc = doc(retired, "public", subject);
vc["taskContext"] = serde_json::json!("urn:uuid:session");
let err = parse(vc).unwrap_err();
assert!(err.contains(retired), "{retired}: {err}");
}
}
#[test]
fn test_type_array_rules() {
let subject = serde_json::json!({ "id": "did:example:subject" });
let with_types = |types: Value| {
let mut vc = doc("RelationshipCredential", "public", subject.clone());
vc["type"] = types;
parse(vc)
};
assert!(
with_types(serde_json::json!([
"VerifiableCredential",
"DTGCredential",
"RelationshipCredential",
"InvitationCredential"
]))
.is_err(),
"two concrete subtypes"
);
assert!(
with_types(serde_json::json!([
"VerifiableCredential",
"RelationshipCredential"
]))
.is_err(),
"no DTGCredential"
);
assert!(
with_types(serde_json::json!([
"VerifiableCredential",
"DTGCredential",
"RelationshipCredential",
"PersonhoodCredential"
]))
.is_err(),
"the personhood hint belongs on a VMC"
);
assert!(
with_types(serde_json::json!([
"VerifiableCredential",
"DTGCredential",
"RelationshipCredential",
"SomethingElse"
]))
.is_err(),
"an undefined type"
);
assert!(
with_types(serde_json::json!([
"VerifiableCredential",
"DTGCredential",
"RelationshipCredential",
"RelationshipCredential"
]))
.is_err(),
"listed twice"
);
}
#[test]
fn test_context_rules() {
let subject = serde_json::json!({ "id": "did:example:subject" });
let with_context = |context: Value| {
let mut vc = doc("RelationshipCredential", "public", subject.clone());
vc["@context"] = context;
parse(vc)
};
assert!(
with_context(serde_json::json!([
W3C_VC_V2_CONTEXT,
DTG_CONTEXT_V1,
"https://w3id.org/security/suites/ed25519-2020/v1"
]))
.is_ok(),
"further contexts may follow"
);
for bad in [
serde_json::json!([W3C_VC_V2_CONTEXT]),
serde_json::json!([DTG_CONTEXT_V1, W3C_VC_V2_CONTEXT]),
serde_json::json!([
W3C_VC_V2_CONTEXT,
"https://firstperson.network/credentials/dtg/v1"
]),
serde_json::json!([
W3C_VC_V2_CONTEXT,
"https://www.registry.trustoverip.org/dtg/context/v1"
]),
serde_json::json!([
W3C_VC_V2_CONTEXT,
"https://registry.trustoverip.org/dtg/context/v1/"
]),
serde_json::json!([
W3C_VC_V2_CONTEXT,
"https://w3id.org/security/suites/ed25519-2020/v1",
DTG_CONTEXT_V1
]),
] {
assert!(with_context(bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn test_deserialize_unknown() {
match serde_json::from_str::<DTGCredential>(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential"],
"issuer": "did:example:governmentAgencyDid",
"issuerScope": "public",
"validFrom": "2024-06-18T10:00:00Z",
"credentialSubject": { "id": "did:example:citizenRDid" }
}"#,
) {
Ok(_) => panic!("Expected Unknown Credential type"),
Err(e) => assert_eq!(e.to_string(), "Unknown credential type"),
};
}
#[test]
fn test_deserialize_mismatched_credential_subject() {
for (type_, subject) in [
(
"StatementCredential",
serde_json::json!({ "id": "did:example:subject" }),
),
(
"RelationshipCredential",
serde_json::json!({ "id": "did:example:s", "predicate": ENDORSES_V1, "object": { "value": 1 } }),
),
(
"AuthorityCredential",
serde_json::json!({ "id": "did:example:subject" }),
),
] {
assert!(parse(doc(type_, "public", subject)).is_err(), "{type_}");
}
}
#[test]
fn test_proof_signed() {
let cred: DTGCredential = match serde_json::from_str(
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"issuerScope": "public",
"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 = parse(doc(
"MembershipCredential",
"public",
serde_json::json!({ "id": "did:example:rDid" }),
))
.unwrap();
assert!(!cred.signed());
assert!(cred.proof_value().is_none());
}
#[test]
fn test_helpers() {
let cred = parse(doc(
"MembershipCredential",
"public",
serde_json::json!({ "id": "did:example:subject" }),
))
.unwrap();
assert_eq!(cred.issuer(), "did:example:issuer");
assert_eq!(cred.issuer_scope(), IssuerScope::Public);
assert_eq!(cred.subject(), "did:example:subject");
assert_eq!(
cred.valid_from()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"2024-06-18T10:00:00Z"
);
assert_eq!(cred.valid_until(), None);
}
#[test]
fn test_valid_until() {
let mut vc = doc(
"MembershipCredential",
"public",
serde_json::json!({ "id": "did:example:subject" }),
);
vc["validUntil"] = serde_json::json!("2030-01-01T00:00:00Z");
let cred = parse(vc).unwrap();
assert_eq!(
cred.valid_until()
.unwrap()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"2030-01-01T00:00:00Z"
);
}
#[test]
fn test_bad_type() {
assert!(check_type(&["bad_type".to_string()]).is_err());
}
#[test]
fn test_badly_constructed_credential_is_refused_by_validate() {
let mut cred = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
IssuerScope::Pairwise,
"did:example:subject".to_string(),
Utc::now(),
None,
);
cred.credential_mut().type_[2] = "StatementCredential".to_string();
assert!(cred.validate().is_err());
let mut vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
Utc::now(),
None,
false,
);
vmc.credential_mut().issuer_scope = IssuerScope::Pairwise;
assert!(matches!(
vmc.validate(),
Err(DTGCredentialError::IssuerScopeTooNarrow { .. })
));
}
#[test]
fn test_task_context_round_trip() {
let mut vc = doc(
"RelationshipCredential",
"pairwise",
serde_json::json!({ "id": "did:example:observed" }),
);
vc["taskContext"] = serde_json::json!("thread-abc-123");
let cred = parse(vc).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 = parse(doc(
"RelationshipCredential",
"pairwise",
serde_json::json!({ "id": "did:example:subject" }),
))
.unwrap();
assert_eq!(vrc.task_context(), None);
assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
let vec = parse(doc(
"StatementCredential",
"pairwise",
serde_json::json!({
"id": "did:example:subject",
"predicate": ENDORSES_V1,
"object": { "value": "a good egg" }
}),
))
.unwrap();
assert_eq!(vec.task_context(), None);
}
#[test]
fn test_digest_multibase() {
let vrc = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"did:example:subject".to_string(),
valid_from,
None,
);
let session = serde_json::json!({
"id": "urn:uuid:session",
"type": "https://trusttasks.org/spec/witness/session/0.1",
"threadId": "urn:uuid:session",
});
let vwc = DTGCredential::new_witnessed_vsc(
"did:example:witness".to_string(),
IssuerScope::Public,
&serde_json::to_value(&vrc).unwrap(),
&session,
valid_from,
None,
None,
)
.unwrap();
assert_eq!(vwc.subject(), "did:example:issuer");
assert!(vwc.verify_digest(&vrc).unwrap());
let other = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"did:example:subject".to_string(),
valid_from,
None,
);
let vec = DTGCredential::new_endorses_vsc(
"did:example:peer".to_string(),
IssuerScope::Directed,
"did:example:issuer".to_string(),
serde_json::json!({ "skill": "chess" }),
valid_from,
None,
)
.unwrap();
assert_eq!(vec.subject_digest(), None);
let vwc = vec;
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, "zQmeUhnd33Xp8egdUiQjPCVgANMXKSL7Nt5sCZbkcs2h2A1");
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:efcb1e1022a5dbfd35af908d18ed7cc20af00cef049bd83d8ff955b97f4d07fe"
);
}
#[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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
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(),
IssuerScope::Directed,
"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(),
IssuerScope::Directed,
"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_for(
&grant,
"did:example:member",
IssuerScope::Directed,
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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
valid_from,
None,
)
.expect("builds");
let vrc = DTGCredential::new_vrc(
"did:example:member".to_string(),
IssuerScope::Pairwise,
"did:example:community".to_string(),
valid_from,
None,
);
assert!(!ack.acknowledges(&vrc).unwrap());
let vwc = DTGCredential::new_witnessed_vsc(
"did:example:witness".to_string(),
IssuerScope::Public,
&wire(&grant),
&serde_json::json!({
"id": "urn:uuid:session",
"type": "https://trusttasks.org/spec/witness/session/0.1",
"threadId": "urn:uuid:session",
}),
valid_from,
None,
None,
)
.unwrap();
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(),
IssuerScope::Pairwise,
"did:example:b".to_string(),
valid_from,
None,
);
assert!(matches!(
DTGCredential::new_member_vmc_for(
&wire(&vrc),
"did:example:b",
IssuerScope::Directed,
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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
valid_from,
None,
)
.expect("builds");
assert!(matches!(
DTGCredential::new_member_vmc_for(
&wire(&ack),
"did:example:community",
IssuerScope::Directed,
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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:member",
"issuerScope": "directed",
"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", "https://registry.trustoverip.org/dtg/context/v1"],
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:member",
"issuerScope": "public",
"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_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
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 vrc = |valid_until| {
DTGCredential::new_vrc(
"did:example:issuer".to_string(),
IssuerScope::Pairwise,
"did:example:subject".to_string(),
now,
valid_until,
)
};
let value = serde_json::to_value(vrc(Some(now + chrono::Duration::days(1)))).unwrap();
let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
assert_eq!(cred2.valid_until, Some(now + chrono::Duration::days(1)));
let value = serde_json::to_value(vrc(None)).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(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"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(),
IssuerScope::Pairwise,
"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!"),
}
}
#[test]
fn validate_refuses_an_inverted_window() {
let from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let vmc = |valid_from, valid_until| {
DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
valid_from,
valid_until,
false,
)
};
assert!(matches!(
vmc(from, Some(from - chrono::Duration::hours(1))).validate(),
Err(DTGCredentialError::InvalidValidityWindow { .. })
));
assert!(matches!(
vmc(from, Some(from)).validate(),
Err(DTGCredentialError::InvalidValidityWindow { .. })
));
assert!(vmc(from, None).validate().is_ok());
assert!(
vmc(from - chrono::Duration::days(3650), Some(from))
.validate()
.is_ok()
);
}
#[test]
fn new_member_vmc_refuses_an_inverted_window() {
let 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(),
from,
None,
false,
);
assert!(matches!(
DTGCredential::new_member_vmc_for(
&wire(&grant),
"did:example:member",
IssuerScope::Directed,
from,
Some(from - chrono::Duration::hours(1)),
),
Err(DTGCredentialError::InvalidValidityWindow { .. })
));
}
#[cfg(feature = "affinidi-signing")]
#[tokio::test]
async fn sign_refuses_an_inverted_window() {
use affinidi_secrets_resolver::secrets::Secret;
let secret = Secret::generate_ed25519(None, None);
let mut vrc = DTGCredential::new_vrc(
"did:example:issuer".to_string(),
IssuerScope::Pairwise,
"did:example:subject".to_string(),
Utc::now(),
Some(Utc::now() - chrono::Duration::days(1)),
);
assert!(matches!(
vrc.sign(&secret, None).await,
Err(DTGCredentialError::InvalidValidityWindow { .. })
));
assert!(!vrc.signed(), "a refused credential must not carry a proof");
}
}