use chrono::{DateTime, Utc};
use dtg_credentials::{DTGCredentialError, DTGCredentialType, IssuerScope};
use serde_json::Value as JsonValue;
use vti_common::error::AppError;
pub const DTG_CONTEXTS: [&str; 2] = [
dtg_credentials::W3C_VC_V2_CONTEXT,
dtg_credentials::DTG_CONTEXT_V1,
];
pub const DTG_BASE_TYPES: [&str; 2] = ["VerifiableCredential", "DTGCredential"];
pub fn concrete_type(doc: &JsonValue) -> Option<String> {
match doc.get("type")? {
JsonValue::Array(types) => types
.iter()
.filter_map(JsonValue::as_str)
.find(|t| {
!matches!(
*t,
"VerifiableCredential" | "DTGCredential" | "PersonhoodCredential"
)
})
.map(str::to_string),
JsonValue::String(t) => Some(t.clone()),
_ => None,
}
}
pub fn statement_predicate(claims: &JsonValue) -> Option<String> {
let subject = match claims.get("credentialSubject") {
Some(subject) if subject.is_object() => subject,
_ => claims,
};
subject
.get("predicate")
.and_then(JsonValue::as_str)
.map(str::to_string)
}
pub fn classify_dtg(doc: &JsonValue) -> Result<DTGCredentialType, AppError> {
let ctx: Vec<String> = doc
.get("@context")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| {
AppError::Validation("credential `@context` missing or not an array".into())
})?;
for (position, required) in DTG_CONTEXTS.iter().enumerate() {
if ctx.get(position).map(String::as_str) != Some(*required) {
return Err(AppError::Validation(format!(
"credential `@context` must carry `{required}` at position {position}"
)));
}
}
let types: Vec<String> = doc
.get("type")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| AppError::Validation("credential `type` missing or not an array".into()))?;
for required in DTG_BASE_TYPES {
if !types.iter().any(|t| t == required) {
return Err(AppError::Validation(format!(
"credential `type` must include `{required}`"
)));
}
}
let subtype = DTGCredentialType::try_from(types.as_slice())
.map_err(|e| AppError::Validation(format!("credential `type`: {e}")))?;
let scope = doc
.get("issuerScope")
.and_then(JsonValue::as_str)
.ok_or_else(|| AppError::Validation("credential `issuerScope` missing".into()))?;
scope
.parse::<IssuerScope>()
.map_err(|e| AppError::Validation(format!("credential `issuerScope`: {e}")))?;
if matches!(subtype, DTGCredentialType::Statement) {
match vta_sdk::vetting::dtg_shape(doc) {
Ok(_) => {}
Err(DTGCredentialError::MissingTaskContext) => {
return Err(crate::credentials::task_context::missing());
}
Err(e) => {
return Err(AppError::Validation(format!("statement credential: {e}")));
}
}
}
Ok(subtype)
}
pub fn require_dtg_type(
doc: &JsonValue,
expected: DTGCredentialType,
now: DateTime<Utc>,
context: &str,
) -> Result<(), AppError> {
let got = classify_dtg(doc)?;
if std::mem::discriminant(&got) != std::mem::discriminant(&expected) {
return Err(AppError::Validation(format!("{context}; got a {got}")));
}
check_validity_window(doc, now, &got.to_string())
}
pub fn check_validity_window(
doc: &JsonValue,
now: DateTime<Utc>,
label: &str,
) -> Result<(), AppError> {
if let Some((key, valid_from)) = read_time(doc, VALID_FROM_NAMES, label)?
&& valid_from > now
{
return Err(AppError::Validation(format!(
"{label} `{key}` {valid_from} is in the future"
)));
}
if let Some((_, valid_until)) = read_time(doc, VALID_UNTIL_NAMES, label)?
&& valid_until <= now
{
return Err(AppError::Validation(format!(
"{label} expired at {valid_until}"
)));
}
Ok(())
}
pub fn validity_window(
doc: &JsonValue,
label: &str,
) -> Result<crate::credentials::lifecycle::ValidityWindow, AppError> {
Ok(crate::credentials::lifecycle::ValidityWindow {
valid_from: read_time(doc, VALID_FROM_NAMES, label)?.map(|(_, t)| t),
valid_until: read_time(doc, VALID_UNTIL_NAMES, label)?.map(|(_, t)| t),
})
}
const VALID_FROM_NAMES: (&str, &str) = ("validFrom", "issuanceDate");
const VALID_UNTIL_NAMES: (&str, &str) = ("validUntil", "expirationDate");
fn read_time(
doc: &JsonValue,
names: (&'static str, &'static str),
label: &str,
) -> Result<Option<(&'static str, DateTime<Utc>)>, AppError> {
let present = |name: &str| doc.get(name).filter(|v| !v.is_null());
let (v2, v11) = names;
let (key, raw) = match (present(v2), present(v11)) {
(Some(_), Some(_)) => {
return Err(AppError::Validation(format!(
"{label} carries both `{v2}` and `{v11}`; they are two names for \
one property and stating both is ambiguous"
)));
}
(Some(v), None) => (v2, v),
(None, Some(v)) => (v11, v),
(None, None) => return Ok(None),
};
let s = raw
.as_str()
.ok_or_else(|| AppError::Validation(format!("{label} `{key}` is not a string")))?;
let t = DateTime::parse_from_rfc3339(s).map_err(|e| {
AppError::Validation(format!("{label} `{key}` is not an RFC 3339 timestamp: {e}"))
})?;
Ok(Some((key, t.with_timezone(&Utc))))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::dtg_json;
use chrono::{Duration, Utc};
use dtg_credentials::{DTGCredential, IssuerScope};
fn vrc() -> JsonValue {
vrc_valid(Utc::now(), None)
}
fn vrc_valid(from: DateTime<Utc>, until: Option<DateTime<Utc>>) -> JsonValue {
dtg_json(&DTGCredential::new_vrc(
"did:peer:2.zR1".into(),
IssuerScope::Pairwise,
"did:peer:2.zR2".into(),
from,
until,
))
}
fn vmc() -> JsonValue {
dtg_json(&DTGCredential::new_vmc(
"did:web:community.example".into(),
"did:key:zMember".into(),
Utc::now(),
None,
false,
))
}
fn vsc() -> JsonValue {
dtg_json(
&DTGCredential::new_endorses_vsc(
"did:web:issuer.example".into(),
IssuerScope::Public,
"did:key:zSubject".into(),
serde_json::json!({ "skill": "moderation" }),
Utc::now(),
None,
)
.unwrap(),
)
}
fn vac() -> JsonValue {
dtg_json(
&DTGCredential::new_community_role_vac(
"did:web:community.example".into(),
"did:key:zMember".into(),
"moderator",
Utc::now(),
Utc::now() + Duration::days(30),
)
.unwrap(),
)
}
#[test]
fn classifies_every_subtype_the_catalog_mints() {
for (doc, expected, label) in [
(vrc(), DTGCredentialType::Relationship, "VRC"),
(vmc(), DTGCredentialType::Membership, "VMC"),
(vsc(), DTGCredentialType::Statement, "VSC"),
(vac(), DTGCredentialType::Authority, "VAC"),
] {
let got = classify_dtg(&doc)
.unwrap_or_else(|e| panic!("catalog-minted {label} must classify: {e:?}"));
assert_eq!(
std::mem::discriminant(&got),
std::mem::discriminant(&expected),
"{label} classified as {got}"
);
}
}
#[test]
fn refuses_a_witness_statement_with_no_task_context() {
let mut witness = vsc();
witness["issuerScope"] = serde_json::json!("directed");
witness["credentialSubject"]["predicate"] =
serde_json::json!(dtg_credentials::WITNESSED_V1);
witness["credentialSubject"]["object"] = serde_json::json!({ "digestMultibase": dtg_credential_digest_multibase(&vrc()).unwrap() });
let err = classify_dtg(&witness).expect_err("a VWC without taskContext must be refused");
assert!(format!("{err:?}").contains("taskContext"), "{err:?}");
witness["taskContext"] = serde_json::json!("urn:uuid:some-exchange");
witness["taskDigestMultibase"] =
serde_json::json!(digest_multibase(&serde_json::json!({ "id": "x" })).unwrap());
assert_eq!(
std::mem::discriminant(&classify_dtg(&witness).expect("a bound VWC classifies")),
std::mem::discriminant(&DTGCredentialType::Statement)
);
}
#[test]
fn refuses_a_credential_without_a_valid_issuer_scope() {
let mut doc = vrc();
doc.as_object_mut().unwrap().remove("issuerScope");
assert!(classify_dtg(&doc).is_err(), "missing issuerScope");
doc["issuerScope"] = serde_json::json!("Public");
assert!(classify_dtg(&doc).is_err(), "case-sensitive");
}
#[test]
fn refuses_the_retired_context_and_a_misplaced_v1_context() {
let mut doc = vrc();
doc["@context"] = serde_json::json!([
dtg_credentials::W3C_VC_V2_CONTEXT,
"https://example.org/some-older-dtg-context"
]);
assert!(classify_dtg(&doc).is_err());
doc["@context"] = serde_json::json!([
dtg_credentials::DTG_CONTEXT_V1,
dtg_credentials::W3C_VC_V2_CONTEXT
]);
assert!(classify_dtg(&doc).is_err(), "order matters");
}
#[test]
fn requires_the_expected_subtype() {
let now = Utc::now();
require_dtg_type(
&vrc(),
DTGCredentialType::Relationship,
now,
"this endpoint publishes relationship edges",
)
.expect("a VRC satisfies a VRC requirement");
let err = require_dtg_type(
&vmc(),
DTGCredentialType::Relationship,
now,
"this endpoint publishes relationship edges",
)
.expect_err("a VMC must not satisfy a VRC requirement");
assert!(format!("{err:?}").contains("relationship edges"));
}
#[test]
fn rejects_a_credential_whose_window_has_passed() {
let now = Utc::now();
let expired = vrc_valid(now - Duration::days(30), Some(now - Duration::days(1)));
let err = check_validity_window(&expired, now, "RelationshipCredential")
.expect_err("an expired credential must be rejected");
assert!(
format!("{err:?}").contains("expired at"),
"rejection should say when it expired: {err:?}"
);
assert!(
require_dtg_type(
&expired,
DTGCredentialType::Relationship,
now,
"this endpoint publishes relationship edges",
)
.is_err(),
"the shape check must not admit an expired credential"
);
}
#[test]
fn rejects_a_credential_not_yet_valid() {
let now = Utc::now();
let future = vrc_valid(now + Duration::days(1), Some(now + Duration::days(30)));
let err = check_validity_window(&future, now, "RelationshipCredential")
.expect_err("a credential whose validFrom is in the future must be rejected");
assert!(format!("{err:?}").contains("in the future"), "{err:?}");
}
#[test]
fn window_boundaries_are_valid_from_inclusive_and_valid_until_exclusive() {
let now = Utc::now();
let starts_now = vrc_valid(now, Some(now + Duration::days(1)));
check_validity_window(&starts_now, now, "RelationshipCredential")
.expect("validFrom == now is inside the window");
let ends_now = vrc_valid(now - Duration::days(1), Some(now));
assert!(
check_validity_window(&ends_now, now, "RelationshipCredential").is_err(),
"validUntil == now is outside the window"
);
}
#[test]
fn a_credential_with_no_valid_until_is_not_expired() {
let now = Utc::now();
let open_ended = vrc_valid(now - Duration::days(365), None);
assert!(
open_ended.get("validUntil").is_none(),
"the catalog omits validUntil when it is None; this test is about that shape"
);
check_validity_window(&open_ended, now, "RelationshipCredential")
.expect("an open-ended credential has no upper bound to be outside of");
}
#[test]
fn enforces_the_window_under_the_vc_1_1_property_names() {
let now = Utc::now();
let mut expired = vrc_valid(now - Duration::days(30), Some(now - Duration::days(1)));
let from = expired["validFrom"].take();
let until = expired["validUntil"].take();
let obj = expired
.as_object_mut()
.expect("credential is a JSON object");
obj.remove("validFrom");
obj.remove("validUntil");
obj.insert("issuanceDate".into(), from);
obj.insert("expirationDate".into(), until);
let err = check_validity_window(&expired, now, "RelationshipCredential")
.expect_err("a 1.1-named expired credential must be rejected too");
assert!(format!("{err:?}").contains("expired at"), "{err:?}");
serde_json::from_value::<DTGCredential>(expired)
.expect("the catalog accepts issuanceDate/expirationDate as aliases");
}
#[test]
fn rejects_a_credential_stating_both_spellings_of_one_bound() {
let now = Utc::now();
let mut doc = vrc_valid(now - Duration::days(1), Some(now + Duration::days(1)));
doc["expirationDate"] = doc["validUntil"].clone();
assert!(
check_validity_window(&doc, now, "RelationshipCredential").is_err(),
"validUntil + expirationDate together are ambiguous"
);
assert!(
serde_json::from_value::<DTGCredential>(doc).is_err(),
"the catalog parser rejects the same document; ingress must agree"
);
}
#[test]
fn rejects_an_unparseable_bound() {
let now = Utc::now();
let mut doc = vrc_valid(now - Duration::days(1), Some(now + Duration::days(1)));
doc["validUntil"] = serde_json::json!("whenever");
assert!(check_validity_window(&doc, now, "RelationshipCredential").is_err());
doc["validUntil"] = serde_json::json!(1_700_000_000);
assert!(check_validity_window(&doc, now, "RelationshipCredential").is_err());
}
#[test]
fn rejects_a_credential_missing_either_half_of_the_common_structure() {
let mut no_ctx = vrc();
no_ctx["@context"] = serde_json::json!(["https://www.w3.org/ns/credentials/v2"]);
assert!(classify_dtg(&no_ctx).is_err(), "missing DTG context");
let mut no_base = vrc();
no_base["type"] = serde_json::json!(["VerifiableCredential", "RelationshipCredential"]);
assert!(classify_dtg(&no_base).is_err(), "missing DTGCredential");
let mut no_subtype = vrc();
no_subtype["type"] = serde_json::json!(["VerifiableCredential", "DTGCredential"]);
assert!(classify_dtg(&no_subtype).is_err(), "no concrete subtype");
}
#[test]
fn refuses_types_the_specification_does_not_define() {
for fiction in [
"VerifiableRecognitionCredential",
"VerifiableMembershipCredential",
"VerifiableStatementCredential",
] {
let mut doc = vrc();
doc["type"] = serde_json::json!(["VerifiableCredential", "DTGCredential", fiction]);
assert!(
classify_dtg(&doc).is_err(),
"`{fiction}` is not a DTG credential type"
);
}
}
}
pub fn digest_multibase(doc: &JsonValue) -> Result<String, AppError> {
use sha2::{Digest, Sha256};
let canonical = serde_json_canonicalizer::to_vec(doc)
.map_err(|e| AppError::Validation(format!("credential is not canonicalizable: {e}")))?;
let mut multihash = Vec::with_capacity(34);
multihash.extend_from_slice(&[0x12, 0x20]);
multihash.extend_from_slice(&Sha256::digest(&canonical));
Ok(multibase::encode(multibase::Base::Base58Btc, multihash))
}
pub fn dtg_credential_digest_multibase(doc: &JsonValue) -> Result<String, AppError> {
dtg_credentials::digest_multibase_json(doc)
.map_err(|e| AppError::Validation(format!("credential is not canonicalizable: {e}")))
}
#[cfg(test)]
mod digest_tests {
use super::*;
#[test]
fn member_order_does_not_change_the_digest() {
let a = serde_json::json!({ "b": 1, "a": { "y": 2, "x": 3 } });
let b = serde_json::json!({ "a": { "x": 3, "y": 2 }, "b": 1 });
assert_eq!(digest_multibase(&a).unwrap(), digest_multibase(&b).unwrap());
}
#[test]
fn is_base58btc_multibase() {
let d = digest_multibase(&serde_json::json!({ "a": 1 })).unwrap();
assert!(
d.starts_with('z'),
"expected a base58btc multibase, got {d}"
);
}
#[test]
fn dtg_credential_digest_ignores_the_proof() {
let unsigned = serde_json::json!({
"type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
"issuer": "did:example:community",
"credentialSubject": { "id": "did:example:member" }
});
let mut signed = unsigned.clone();
signed["proof"] = serde_json::json!({
"type": "DataIntegrityProof",
"proofValue": "z3FXQ..."
});
assert_eq!(
dtg_credential_digest_multibase(&unsigned).unwrap(),
dtg_credential_digest_multibase(&signed).unwrap()
);
}
#[test]
fn the_two_digests_are_not_interchangeable() {
let doc = serde_json::json!({ "a": 1, "proof": { "proofValue": "zSig" } });
assert_ne!(
digest_multibase(&doc).unwrap(),
dtg_credential_digest_multibase(&doc).unwrap()
);
}
#[test]
fn different_documents_digest_differently() {
let a = digest_multibase(&serde_json::json!({ "a": 1 })).unwrap();
let b = digest_multibase(&serde_json::json!({ "a": 2 })).unwrap();
assert_ne!(a, b);
}
}