use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::create::{check_window, check_witness_session, issuer_of};
use crate::{
CredentialSubject, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType,
IssuerScope, WitnessContext,
};
pub const ENDORSES_V1: &str = "https://registry.trustoverip.org/dtg/vsc/endorses/1";
pub const WITNESSED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/witnessed/1";
pub const VETTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/vetted/1";
pub const PRESENTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/presented/1";
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum ObjectKind {
Id,
DigestMultibase,
Value,
}
impl std::fmt::Display for ObjectKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ObjectKind::Id => "id",
ObjectKind::DigestMultibase => "digestMultibase",
ObjectKind::Value => "value",
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum StatementObject {
Id(String),
DigestMultibase(String),
Value(Value),
}
impl StatementObject {
pub fn kind(&self) -> ObjectKind {
match self {
StatementObject::Id(_) => ObjectKind::Id,
StatementObject::DigestMultibase(_) => ObjectKind::DigestMultibase,
StatementObject::Value(_) => ObjectKind::Value,
}
}
pub fn id(&self) -> Option<&str> {
match self {
StatementObject::Id(id) => Some(id),
_ => None,
}
}
pub fn digest_multibase(&self) -> Option<&str> {
match self {
StatementObject::DigestMultibase(digest) => Some(digest),
_ => None,
}
}
pub fn value(&self) -> Option<&Value> {
match self {
StatementObject::Value(value) => Some(value),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CredentialSubjectStatement {
pub id: String,
pub predicate: String,
pub object: StatementObject,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub witness_context: Option<WitnessContext>,
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PredicateProfile {
pub iri: &'static str,
pub object_kinds: &'static [ObjectKind],
pub task_context_required: bool,
pub minimum_issuer_scope: Option<IssuerScope>,
}
const CORE_PROFILES: [PredicateProfile; 4] = [
PredicateProfile {
iri: ENDORSES_V1,
object_kinds: &[ObjectKind::Value],
task_context_required: false,
minimum_issuer_scope: None,
},
PredicateProfile {
iri: WITNESSED_V1,
object_kinds: &[ObjectKind::DigestMultibase],
task_context_required: true,
minimum_issuer_scope: Some(IssuerScope::Directed),
},
PredicateProfile {
iri: VETTED_V1,
object_kinds: &[ObjectKind::Value],
task_context_required: true,
minimum_issuer_scope: Some(IssuerScope::Directed),
},
PredicateProfile {
iri: PRESENTED_V1,
object_kinds: &[ObjectKind::DigestMultibase],
task_context_required: true,
minimum_issuer_scope: Some(IssuerScope::Directed),
},
];
impl PredicateProfile {
pub fn core(iri: &str) -> Option<&'static PredicateProfile> {
CORE_PROFILES.iter().find(|profile| profile.iri == iri)
}
pub fn all_core() -> &'static [PredicateProfile] {
&CORE_PROFILES
}
pub(crate) fn check(
&self,
common: &DTGCommon,
subject: &CredentialSubjectStatement,
) -> Result<(), DTGCredentialError> {
check_constraints(
self.object_kinds,
self.task_context_required,
self.minimum_issuer_scope,
common,
subject,
)
}
}
pub(crate) fn check_constraints(
object_kinds: &[ObjectKind],
task_context_required: bool,
minimum_issuer_scope: Option<IssuerScope>,
common: &DTGCommon,
subject: &CredentialSubjectStatement,
) -> Result<(), DTGCredentialError> {
let kind = subject.object.kind();
if !object_kinds.contains(&kind) {
return Err(DTGCredentialError::ProfileViolation(format!(
"`{}` does not permit an `object.{kind}`",
subject.predicate
)));
}
if let Some(minimum) = minimum_issuer_scope
&& !common.issuer_scope.satisfies(minimum)
{
return Err(DTGCredentialError::IssuerScopeTooNarrow {
declared: common.issuer_scope,
minimum,
});
}
if task_context_required {
if common.task_context.is_none() {
return Err(DTGCredentialError::MissingTaskContext);
}
if common.task_digest_multibase.is_none() {
return Err(DTGCredentialError::MissingTaskDigest);
}
}
Ok(())
}
pub(crate) fn check_statement(
common: &DTGCommon,
subject: &CredentialSubjectStatement,
) -> Result<(), DTGCredentialError> {
check_predicate_iri(&subject.predicate)?;
if let StatementObject::Id(id) = &subject.object
&& !has_iri_scheme(id)
{
return Err(DTGCredentialError::ProfileViolation(format!(
"`object.id` `{id}` is not a DID or other absolute IRI"
)));
}
match PredicateProfile::core(&subject.predicate) {
Some(profile) => profile.check(common, subject),
None => Ok(()),
}
}
fn has_iri_scheme(s: &str) -> bool {
let Some((scheme, rest)) = s.split_once(':') else {
return false;
};
let mut chars = scheme.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& !rest.is_empty()
}
pub fn check_predicate_iri(predicate: &str) -> Result<(), DTGCredentialError> {
let invalid = |why: &str| {
Err(DTGCredentialError::InvalidPredicate(format!(
"`{predicate}` {why}"
)))
};
if !unicode_normalization::is_nfc(predicate) {
return invalid("is not in Unicode Normalization Form C");
}
if let Some(c) = predicate
.chars()
.find(|c| c.is_whitespace() || c.is_control() || "<>\"{}|\\^`".contains(*c))
{
return invalid(&format!("contains {c:?}, which an IRI cannot"));
}
if !has_iri_scheme(predicate) {
return invalid(
"is not an absolute IRI — a relative reference or a bare JSON-LD term is never \
expanded",
);
}
let (scheme, rest) = predicate.split_once(':').expect("has a scheme");
let hierarchical = rest
.strip_prefix("//")
.is_some_and(|authority| !authority.is_empty() && !authority.starts_with('/'));
let opaque_scheme = scheme.eq_ignore_ascii_case("urn") || scheme.eq_ignore_ascii_case("did");
if !hierarchical && !opaque_scheme {
return invalid(
"is a compact IRI (CURIE); a predicate is always written as the absolute IRI \
and is never expanded against a context",
);
}
Ok(())
}
impl DTGCredential {
pub fn new_vsc(
issuer: String,
issuer_scope: IssuerScope,
subject: String,
predicate: impl Into<String>,
object: StatementObject,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
) -> Result<Self, DTGCredentialError> {
check_window(valid_from, valid_until)?;
let predicate = predicate.into();
check_predicate_iri(&predicate)?;
let vsc = Self::build(
DTGCredentialType::Statement,
issuer,
issuer_scope,
valid_from,
valid_until,
CredentialSubject::Statement(CredentialSubjectStatement {
id: subject,
predicate,
object,
witness_context: None,
extra: serde_json::Map::new(),
}),
);
vsc.credential.check_depth()?;
let statement = vsc.statement().expect("built as a statement");
check_statement_before_citation(&vsc.credential, statement)?;
Ok(vsc)
}
pub fn new_endorses_vsc(
issuer: String,
issuer_scope: IssuerScope,
subject: String,
endorsement: Value,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
) -> Result<Self, DTGCredentialError> {
Self::new_vsc(
issuer,
issuer_scope,
subject,
ENDORSES_V1,
StatementObject::Value(endorsement),
valid_from,
valid_until,
)
}
pub fn new_witnessed_vsc(
issuer: String,
issuer_scope: IssuerScope,
witnessed: &Value,
session: &Value,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
witness_context: Option<WitnessContext>,
) -> Result<Self, DTGCredentialError> {
check_window(valid_from, valid_until)?;
check_minimum_scope(WITNESSED_V1, issuer_scope)?;
check_witness_session(session)?;
crate::check_json_depth(witnessed)?;
let subject = witnessed.as_object().and_then(issuer_of).ok_or_else(|| {
DTGCredentialError::ProfileViolation(
"the witnessed credential has no `issuer`, so there is no party the \
witness observed issuing it"
.into(),
)
})?;
let mut vwc = Self::new_vsc(
issuer,
issuer_scope,
subject,
WITNESSED_V1,
StatementObject::DigestMultibase(crate::digest_multibase_json(witnessed)?),
valid_from,
valid_until,
)?;
if let Some(statement) = vwc.credential.statement_mut() {
statement.witness_context = witness_context;
}
vwc.with_task_citation(session)
}
pub fn new_vetted_vsc(
issuer: String,
issuer_scope: IssuerScope,
subject: String,
vetting: Value,
session: &Value,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
) -> Result<Self, DTGCredentialError> {
check_window(valid_from, valid_until)?;
check_minimum_scope(VETTED_V1, issuer_scope)?;
Self::new_vsc(
issuer,
issuer_scope,
subject,
VETTED_V1,
StatementObject::Value(vetting),
valid_from,
valid_until,
)?
.with_task_citation(session)
}
pub fn new_presented_vsc(
issuer: String,
issuer_scope: IssuerScope,
presented: &Value,
session: &Value,
valid_from: DateTime<Utc>,
valid_until: Option<DateTime<Utc>>,
) -> Result<Self, DTGCredentialError> {
check_window(valid_from, valid_until)?;
check_minimum_scope(PRESENTED_V1, issuer_scope)?;
crate::check_json_depth(presented)?;
let subject = subject_of(presented).ok_or_else(|| {
DTGCredentialError::ProfileViolation(
"the presented credential has no `credentialSubject.id`, so there is no \
holder to name"
.into(),
)
})?;
Self::new_vsc(
issuer,
issuer_scope,
subject,
PRESENTED_V1,
StatementObject::DigestMultibase(crate::digest_multibase_json(presented)?),
valid_from,
valid_until,
)?
.with_task_citation(session)
}
pub fn witnesses_issuance_of(&self, credential: &Value) -> Result<bool, DTGCredentialError> {
let named = credential.as_object().and_then(issuer_of);
self.statement_names(WITNESSED_V1, credential, named)
}
pub fn witnesses_presentation_of(
&self,
credential: &Value,
) -> Result<bool, DTGCredentialError> {
self.statement_names(PRESENTED_V1, credential, subject_of(credential))
}
fn statement_names(
&self,
predicate: &str,
credential: &Value,
expected_subject: Option<String>,
) -> Result<bool, DTGCredentialError> {
let Some(statement) = self.statement() else {
return Ok(false);
};
let Some(carried) = statement.object.digest_multibase() else {
return Ok(false);
};
if statement.predicate != predicate
|| expected_subject.as_deref() != Some(statement.id.as_str())
{
return Ok(false);
}
crate::digests_match(carried, &crate::digest_multibase_json(credential)?)
}
}
fn check_statement_before_citation(
common: &DTGCommon,
subject: &CredentialSubjectStatement,
) -> Result<(), DTGCredentialError> {
check_predicate_iri(&subject.predicate)?;
match PredicateProfile::core(&subject.predicate) {
Some(profile) => check_constraints(
profile.object_kinds,
false,
profile.minimum_issuer_scope,
common,
subject,
),
None => Ok(()),
}
}
fn check_minimum_scope(
predicate: &str,
issuer_scope: IssuerScope,
) -> Result<(), DTGCredentialError> {
match PredicateProfile::core(predicate).and_then(|p| p.minimum_issuer_scope) {
Some(minimum) if !issuer_scope.satisfies(minimum) => {
Err(DTGCredentialError::IssuerScopeTooNarrow {
declared: issuer_scope,
minimum,
})
}
_ => Ok(()),
}
}
fn subject_of(credential: &Value) -> Option<String> {
credential
.get("credentialSubject")
.and_then(|subject| subject.get("id"))
.and_then(Value::as_str)
.map(str::to_string)
}