use core::fmt;
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use url::Url;
use crate::did::{Did, DidUrl, Uri};
use crate::jose::{DefaultVerifier, JwsHeader, JwsVerifier, Signer};
use crate::vc::validation::{
CredentialValidationOptions, DecodedCredential, JwtCredentialValidator, KeyResolver,
ValidationError, decode_credential,
};
use crate::vc::{Credential, CredentialError, Jwt, JwtClaims, Subject};
pub const CONTEXT: &str = "https://identity.foundation/.well-known/did-configuration/v1";
pub const CREDENTIAL_TYPE: &str = "DomainLinkageCredential";
pub const WELL_KNOWN_PATH: &str = "/.well-known/did-configuration.json";
pub fn configuration_url(origin: &Url) -> Result<Url, DomainLinkageError> {
let origin = normalize_origin(origin.as_str())?;
Url::parse(&format!("{origin}{WELL_KNOWN_PATH}"))
.map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin}: {e}")))
}
pub fn normalize_origin(origin: &str) -> Result<String, DomainLinkageError> {
let with_scheme = if has_scheme(origin) {
origin.to_owned()
} else {
format!("https://{origin}")
};
let url = Url::parse(&with_scheme)
.map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin:?}: {e}")))?;
if url.host_str().is_none() {
return Err(DomainLinkageError::InvalidOrigin(format!(
"{origin:?} has no host"
)));
}
let normalized = url.origin();
if !normalized.is_tuple() {
return Err(DomainLinkageError::InvalidOrigin(format!(
"{origin:?} is not a domain origin"
)));
}
Ok(normalized.ascii_serialization())
}
fn has_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.starts_with(|c: char| c.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum LinkedDid {
Jwt(Jwt),
LinkedDataProof(serde_json::Map<String, Value>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct DomainLinkageConfiguration {
#[serde(rename = "@context")]
pub context: String,
pub linked_dids: Vec<LinkedDid>,
}
impl Default for DomainLinkageConfiguration {
fn default() -> Self {
Self::new()
}
}
impl DomainLinkageConfiguration {
pub fn new() -> Self {
Self {
context: CONTEXT.to_owned(),
linked_dids: Vec::new(),
}
}
#[must_use]
pub fn with_credential(mut self, credential: Jwt) -> Self {
self.linked_dids.push(LinkedDid::Jwt(credential));
self
}
pub fn jwts(&self) -> impl Iterator<Item = (usize, &Jwt)> {
self.linked_dids
.iter()
.enumerate()
.filter_map(|(i, entry)| match entry {
LinkedDid::Jwt(jwt) => Some((i, jwt)),
_ => None,
})
}
pub fn check(&self) -> Result<(), DomainLinkageError> {
if self.context != CONTEXT {
return Err(DomainLinkageError::InvalidContext(self.context.clone()));
}
Ok(())
}
}
impl<'de> Deserialize<'de> for DomainLinkageConfiguration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Raw {
#[serde(rename = "@context")]
context: String,
linked_dids: Vec<LinkedDid>,
}
let raw = Raw::deserialize(deserializer)?;
let configuration = Self {
context: raw.context,
linked_dids: raw.linked_dids,
};
configuration.check().map_err(serde::de::Error::custom)?;
Ok(configuration)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DomainLinkageCredentialBuilder {
pub did: Did,
pub origin: String,
pub issuance_date: DateTime<Utc>,
pub expiration_date: DateTime<Utc>,
}
impl DomainLinkageCredentialBuilder {
pub fn new(
did: Did,
origin: &Url,
issuance_date: DateTime<Utc>,
expiration_date: DateTime<Utc>,
) -> Result<Self, DomainLinkageError> {
Ok(Self {
did,
origin: normalize_origin(origin.as_str())?,
issuance_date,
expiration_date,
})
}
pub fn credential(&self) -> Credential {
let subject =
Subject::new(Uri::from(self.did.clone())).with_property("origin", self.origin.clone());
Credential::new(self.did.clone(), self.issuance_date, subject)
.with_context(CONTEXT)
.with_type(CREDENTIAL_TYPE)
.with_expiration_date(self.expiration_date)
}
pub fn jwt_claims(&self) -> JwtClaims {
let mut claims = JwtClaims::new()
.with_nbf(self.issuance_date)
.with_exp(self.expiration_date);
claims.iss = Some(self.did.as_str().to_owned());
claims.sub = Some(self.did.as_str().to_owned());
claims.additional.insert(
"vc".to_owned(),
serde_json::to_value(self.credential()).expect("a credential serializes"),
);
claims
}
pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, DomainLinkageError> {
let header = JwsHeader::new(signer.alg()).with_kid(kid.as_str());
let jwt = Jwt::sign(&header, &self.jwt_claims(), signer)
.await
.map_err(ValidationError::from)?;
Ok(jwt)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DomainLinkageError {
#[error("invalid origin: {0}")]
InvalidOrigin(String),
#[error("`@context` is {0:?}, not `{CONTEXT}`")]
InvalidContext(String),
#[error("header `typ` {0:?} must not be present")]
TypPresent(String),
#[error("header parameter `{0}` must not be present")]
UnexpectedHeaderParameter(String),
#[error("claim `{0}` must not be present")]
UnexpectedClaim(String),
#[error("claim `{0}` is required")]
MissingClaim(&'static str),
#[error("the credential must not have an `id`")]
IdPresent,
#[error("the credential's `@context` does not include `{CONTEXT}`")]
MissingContext,
#[error("the credential's `type` does not include `{CREDENTIAL_TYPE}`")]
MissingType,
#[error("the credential must have an `expirationDate`")]
MissingExpirationDate,
#[error("`credentialSubject.id` {0:?} is not a DID")]
SubjectNotDid(Option<String>),
#[error("{role} `{found}` is not the subject `{subject}`")]
SubjectMismatch {
role: &'static str,
found: String,
subject: Did,
},
#[error("`credentialSubject.origin` {0:?} is not one origin")]
InvalidOriginProperty(Option<Value>),
#[error("origin {found:?} is not {expected:?}")]
OriginMismatch {
expected: String,
found: String,
},
#[error(transparent)]
Validation(#[from] ValidationError),
#[error("the configuration holds no JWT credential for `{0}`")]
NoCredentialFor(Did),
#[error("no domain linkage credential validated: {}", format_errors(.0))]
NoValidCredential(BTreeMap<usize, DomainLinkageError>),
}
impl From<CredentialError> for DomainLinkageError {
fn from(error: CredentialError) -> Self {
Self::Validation(error.into())
}
}
fn format_errors(errors: &BTreeMap<usize, DomainLinkageError>) -> String {
errors
.iter()
.map(|(index, error)| format!("{index}: {error}"))
.collect::<Vec<_>>()
.join("; ")
}
#[derive(Debug)]
#[non_exhaustive]
pub struct DomainLinkageReport {
pub linked: Vec<(usize, Did, DecodedCredential)>,
pub failed: BTreeMap<usize, DomainLinkageError>,
}
impl DomainLinkageReport {
pub fn dids(&self) -> impl Iterator<Item = &Did> {
self.linked.iter().map(|(_, did, _)| did)
}
}
#[derive(Debug)]
pub struct DomainLinkageValidator<R, V = DefaultVerifier> {
credentials: JwtCredentialValidator<R, V>,
}
impl<R: KeyResolver, V: JwsVerifier> DomainLinkageValidator<R, V> {
pub fn new(resolver: R, verifier: V) -> Self {
Self {
credentials: JwtCredentialValidator::new(resolver, verifier),
}
}
pub fn from_credential_validator(credentials: JwtCredentialValidator<R, V>) -> Self {
Self { credentials }
}
pub fn credential_validator(&self) -> &JwtCredentialValidator<R, V> {
&self.credentials
}
pub async fn validate_credential(
&self,
jwt: &Jwt,
origin: &Url,
options: &CredentialValidationOptions,
) -> Result<DecodedCredential, DomainLinkageError> {
let expected = normalize_origin(origin.as_str())?;
let decoded = decode_credential(jwt)?;
check_format(&decoded, &expected)?;
self.credentials.validate(jwt, options).await?;
Ok(decoded)
}
pub async fn validate_linkage(
&self,
configuration: &DomainLinkageConfiguration,
did: &Did,
origin: &Url,
options: &CredentialValidationOptions,
) -> Result<DecodedCredential, DomainLinkageError> {
configuration.check()?;
let mut errors = BTreeMap::new();
for (index, jwt) in configuration.jwts() {
if issuer_of(jwt).as_ref() != Some(did) {
continue;
}
match self.validate_credential(jwt, origin, options).await {
Ok(decoded) => return Ok(decoded),
Err(error) => {
errors.insert(index, error);
}
}
}
if errors.is_empty() {
return Err(DomainLinkageError::NoCredentialFor(did.clone()));
}
Err(DomainLinkageError::NoValidCredential(errors))
}
pub async fn validate_configuration(
&self,
configuration: &DomainLinkageConfiguration,
origin: &Url,
options: &CredentialValidationOptions,
) -> Result<DomainLinkageReport, DomainLinkageError> {
configuration.check()?;
let mut linked = Vec::new();
let mut failed = BTreeMap::new();
for (index, jwt) in configuration.jwts() {
match self.validate_credential(jwt, origin, options).await {
Ok(decoded) => {
let did = Did::parse(decoded.credential.issuer.id().as_str())
.expect("the format check accepted the issuer as a DID");
linked.push((index, did, decoded));
}
Err(error) => {
failed.insert(index, error);
}
}
}
if linked.is_empty() {
return Err(DomainLinkageError::NoValidCredential(failed));
}
Ok(DomainLinkageReport { linked, failed })
}
}
fn issuer_of(jwt: &Jwt) -> Option<Did> {
crate::vc::validation::extract_issuer(jwt).ok()
}
fn check_format(
decoded: &DecodedCredential,
expected_origin: &str,
) -> Result<(), DomainLinkageError> {
let header = &decoded.header;
if let Some(typ) = &header.typ {
return Err(DomainLinkageError::TypPresent(typ.clone()));
}
if header.kid.is_none() {
return Err(ValidationError::MissingKid.into());
}
for (name, present) in [
("cty", header.cty.is_some()),
("jwk", header.jwk.is_some()),
("crit", header.crit.is_some()),
] {
if present {
return Err(DomainLinkageError::UnexpectedHeaderParameter(
name.to_owned(),
));
}
}
if let Some(name) = header.additional.keys().next() {
return Err(DomainLinkageError::UnexpectedHeaderParameter(name.clone()));
}
let claims = &decoded.claims;
for (name, present) in [
("aud", claims.aud.is_some()),
("iat", claims.iat.is_some()),
("jti", claims.jti.is_some()),
] {
if present {
return Err(DomainLinkageError::UnexpectedClaim(name.to_owned()));
}
}
if let Some(name) = claims.additional.keys().find(|name| *name != "vc") {
return Err(DomainLinkageError::UnexpectedClaim(name.clone()));
}
let iss = claims
.iss
.as_deref()
.ok_or(DomainLinkageError::MissingClaim("iss"))?;
let sub = claims
.sub
.as_deref()
.ok_or(DomainLinkageError::MissingClaim("sub"))?;
if claims.nbf.is_none() {
return Err(DomainLinkageError::MissingClaim("nbf"));
}
if claims.exp.is_none() {
return Err(DomainLinkageError::MissingClaim("exp"));
}
let credential = &decoded.credential;
if credential.id.is_some() {
return Err(DomainLinkageError::IdPresent);
}
if !credential.context.contains(CONTEXT) {
return Err(DomainLinkageError::MissingContext);
}
if !credential.has_type(CREDENTIAL_TYPE) {
return Err(DomainLinkageError::MissingType);
}
if credential.expiration_date.is_none() {
return Err(DomainLinkageError::MissingExpirationDate);
}
let subject = credential
.subject()
.ok_or(DomainLinkageError::SubjectNotDid(None))?;
let subject_id = subject.id.as_ref().map(Uri::as_str);
let subject_did = subject_id
.and_then(|id| Did::parse(id).ok())
.ok_or_else(|| DomainLinkageError::SubjectNotDid(subject_id.map(str::to_owned)))?;
for (role, found) in [
("issuer", credential.issuer.id().as_str()),
("iss", iss),
("sub", sub),
] {
if found != subject_did.as_str() {
return Err(DomainLinkageError::SubjectMismatch {
role,
found: found.to_owned(),
subject: subject_did,
});
}
}
let origin = subject.properties.get("origin");
let Some(Value::String(origin)) = origin else {
return Err(DomainLinkageError::InvalidOriginProperty(origin.cloned()));
};
let found = normalize_origin(origin)?;
if found != expected_origin {
return Err(DomainLinkageError::OriginMismatch {
expected: expected_origin.to_owned(),
found,
});
}
Ok(())
}
impl fmt::Display for DomainLinkageConfiguration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
f.write_str(&json)
}
}