1use core::fmt;
17use std::collections::BTreeMap;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_json::Value;
22use url::Url;
23
24use crate::did::{Did, DidUrl, Uri};
25use crate::jose::{DefaultVerifier, JwsHeader, JwsVerifier, Signer};
26use crate::vc::validation::{
27 CredentialValidationOptions, DecodedCredential, JwtCredentialValidator, KeyResolver,
28 ValidationError, decode_credential,
29};
30use crate::vc::{Credential, CredentialError, Jwt, JwtClaims, Subject};
31
32pub const CONTEXT: &str = "https://identity.foundation/.well-known/did-configuration/v1";
34
35pub const CREDENTIAL_TYPE: &str = "DomainLinkageCredential";
37
38pub const WELL_KNOWN_PATH: &str = "/.well-known/did-configuration.json";
40
41pub fn configuration_url(origin: &Url) -> Result<Url, DomainLinkageError> {
58 let origin = normalize_origin(origin.as_str())?;
59 Url::parse(&format!("{origin}{WELL_KNOWN_PATH}"))
60 .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin}: {e}")))
61}
62
63pub fn normalize_origin(origin: &str) -> Result<String, DomainLinkageError> {
70 let with_scheme = if has_scheme(origin) {
71 origin.to_owned()
72 } else {
73 format!("https://{origin}")
74 };
75 let url = Url::parse(&with_scheme)
76 .map_err(|e| DomainLinkageError::InvalidOrigin(format!("{origin:?}: {e}")))?;
77 if url.host_str().is_none() {
78 return Err(DomainLinkageError::InvalidOrigin(format!(
79 "{origin:?} has no host"
80 )));
81 }
82 let normalized = url.origin();
83 if !normalized.is_tuple() {
84 return Err(DomainLinkageError::InvalidOrigin(format!(
85 "{origin:?} is not a domain origin"
86 )));
87 }
88 Ok(normalized.ascii_serialization())
89}
90
91fn has_scheme(s: &str) -> bool {
93 let Some((scheme, rest)) = s.split_once(':') else {
94 return false;
95 };
96 let mut chars = scheme.chars();
97 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
98 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
99 && !rest.starts_with(|c: char| c.is_ascii_digit())
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(untagged)]
105#[non_exhaustive]
106pub enum LinkedDid {
107 Jwt(Jwt),
109 LinkedDataProof(serde_json::Map<String, Value>),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136#[non_exhaustive]
137pub struct DomainLinkageConfiguration {
138 #[serde(rename = "@context")]
140 pub context: String,
141 pub linked_dids: Vec<LinkedDid>,
143}
144
145impl Default for DomainLinkageConfiguration {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl DomainLinkageConfiguration {
152 pub fn new() -> Self {
154 Self {
155 context: CONTEXT.to_owned(),
156 linked_dids: Vec::new(),
157 }
158 }
159
160 #[must_use]
162 pub fn with_credential(mut self, credential: Jwt) -> Self {
163 self.linked_dids.push(LinkedDid::Jwt(credential));
164 self
165 }
166
167 pub fn jwts(&self) -> impl Iterator<Item = (usize, &Jwt)> {
169 self.linked_dids
170 .iter()
171 .enumerate()
172 .filter_map(|(i, entry)| match entry {
173 LinkedDid::Jwt(jwt) => Some((i, jwt)),
174 _ => None,
175 })
176 }
177
178 pub fn check(&self) -> Result<(), DomainLinkageError> {
184 if self.context != CONTEXT {
185 return Err(DomainLinkageError::InvalidContext(self.context.clone()));
186 }
187 Ok(())
188 }
189}
190
191impl<'de> Deserialize<'de> for DomainLinkageConfiguration {
192 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
193 #[derive(Deserialize)]
194 #[serde(deny_unknown_fields)]
195 struct Raw {
196 #[serde(rename = "@context")]
197 context: String,
198 linked_dids: Vec<LinkedDid>,
199 }
200 let raw = Raw::deserialize(deserializer)?;
201 let configuration = Self {
202 context: raw.context,
203 linked_dids: raw.linked_dids,
204 };
205 configuration.check().map_err(serde::de::Error::custom)?;
206 Ok(configuration)
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub struct DomainLinkageCredentialBuilder {
240 pub did: Did,
242 pub origin: String,
244 pub issuance_date: DateTime<Utc>,
246 pub expiration_date: DateTime<Utc>,
248}
249
250impl DomainLinkageCredentialBuilder {
251 pub fn new(
258 did: Did,
259 origin: &Url,
260 issuance_date: DateTime<Utc>,
261 expiration_date: DateTime<Utc>,
262 ) -> Result<Self, DomainLinkageError> {
263 Ok(Self {
264 did,
265 origin: normalize_origin(origin.as_str())?,
266 issuance_date,
267 expiration_date,
268 })
269 }
270
271 pub fn credential(&self) -> Credential {
273 let subject =
274 Subject::new(Uri::from(self.did.clone())).with_property("origin", self.origin.clone());
275 Credential::new(self.did.clone(), self.issuance_date, subject)
276 .with_context(CONTEXT)
277 .with_type(CREDENTIAL_TYPE)
278 .with_expiration_date(self.expiration_date)
279 }
280
281 pub fn jwt_claims(&self) -> JwtClaims {
284 let mut claims = JwtClaims::new()
285 .with_nbf(self.issuance_date)
286 .with_exp(self.expiration_date);
287 claims.iss = Some(self.did.as_str().to_owned());
288 claims.sub = Some(self.did.as_str().to_owned());
289 claims.additional.insert(
290 "vc".to_owned(),
291 serde_json::to_value(self.credential()).expect("a credential serializes"),
292 );
293 claims
294 }
295
296 pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, DomainLinkageError> {
303 let header = JwsHeader::new(signer.alg()).with_kid(kid.as_str());
304 let jwt = Jwt::sign(&header, &self.jwt_claims(), signer)
305 .await
306 .map_err(ValidationError::from)?;
307 Ok(jwt)
308 }
309}
310
311#[derive(Debug, thiserror::Error)]
313#[non_exhaustive]
314pub enum DomainLinkageError {
315 #[error("invalid origin: {0}")]
317 InvalidOrigin(String),
318 #[error("`@context` is {0:?}, not `{CONTEXT}`")]
320 InvalidContext(String),
321 #[error("header `typ` {0:?} must not be present")]
323 TypPresent(String),
324 #[error("header parameter `{0}` must not be present")]
326 UnexpectedHeaderParameter(String),
327 #[error("claim `{0}` must not be present")]
329 UnexpectedClaim(String),
330 #[error("claim `{0}` is required")]
332 MissingClaim(&'static str),
333 #[error("the credential must not have an `id`")]
335 IdPresent,
336 #[error("the credential's `@context` does not include `{CONTEXT}`")]
338 MissingContext,
339 #[error("the credential's `type` does not include `{CREDENTIAL_TYPE}`")]
341 MissingType,
342 #[error("the credential must have an `expirationDate`")]
344 MissingExpirationDate,
345 #[error("`credentialSubject.id` {0:?} is not a DID")]
347 SubjectNotDid(Option<String>),
348 #[error("{role} `{found}` is not the subject `{subject}`")]
350 SubjectMismatch {
351 role: &'static str,
353 found: String,
355 subject: Did,
357 },
358 #[error("`credentialSubject.origin` {0:?} is not one origin")]
360 InvalidOriginProperty(Option<Value>),
361 #[error("origin {found:?} is not {expected:?}")]
363 OriginMismatch {
364 expected: String,
366 found: String,
368 },
369 #[error(transparent)]
371 Validation(#[from] ValidationError),
372 #[error("the configuration holds no JWT credential for `{0}`")]
374 NoCredentialFor(Did),
375 #[error("no domain linkage credential validated: {}", format_errors(.0))]
378 NoValidCredential(BTreeMap<usize, DomainLinkageError>),
379}
380
381impl From<CredentialError> for DomainLinkageError {
382 fn from(error: CredentialError) -> Self {
383 Self::Validation(error.into())
384 }
385}
386
387fn format_errors(errors: &BTreeMap<usize, DomainLinkageError>) -> String {
389 errors
390 .iter()
391 .map(|(index, error)| format!("{index}: {error}"))
392 .collect::<Vec<_>>()
393 .join("; ")
394}
395
396#[derive(Debug)]
398#[non_exhaustive]
399pub struct DomainLinkageReport {
400 pub linked: Vec<(usize, Did, DecodedCredential)>,
402 pub failed: BTreeMap<usize, DomainLinkageError>,
404}
405
406impl DomainLinkageReport {
407 pub fn dids(&self) -> impl Iterator<Item = &Did> {
409 self.linked.iter().map(|(_, did, _)| did)
410 }
411}
412
413#[derive(Debug)]
435pub struct DomainLinkageValidator<R, V = DefaultVerifier> {
436 credentials: JwtCredentialValidator<R, V>,
437}
438
439impl<R: KeyResolver, V: JwsVerifier> DomainLinkageValidator<R, V> {
440 pub fn new(resolver: R, verifier: V) -> Self {
442 Self {
443 credentials: JwtCredentialValidator::new(resolver, verifier),
444 }
445 }
446
447 pub fn from_credential_validator(credentials: JwtCredentialValidator<R, V>) -> Self {
449 Self { credentials }
450 }
451
452 pub fn credential_validator(&self) -> &JwtCredentialValidator<R, V> {
454 &self.credentials
455 }
456
457 pub async fn validate_credential(
464 &self,
465 jwt: &Jwt,
466 origin: &Url,
467 options: &CredentialValidationOptions,
468 ) -> Result<DecodedCredential, DomainLinkageError> {
469 let expected = normalize_origin(origin.as_str())?;
470 let decoded = decode_credential(jwt)?;
471 check_format(&decoded, &expected)?;
472 self.credentials.validate(jwt, options).await?;
473 Ok(decoded)
474 }
475
476 pub async fn validate_linkage(
484 &self,
485 configuration: &DomainLinkageConfiguration,
486 did: &Did,
487 origin: &Url,
488 options: &CredentialValidationOptions,
489 ) -> Result<DecodedCredential, DomainLinkageError> {
490 configuration.check()?;
491 let mut errors = BTreeMap::new();
492 for (index, jwt) in configuration.jwts() {
493 if issuer_of(jwt).as_ref() != Some(did) {
494 continue;
495 }
496 match self.validate_credential(jwt, origin, options).await {
497 Ok(decoded) => return Ok(decoded),
498 Err(error) => {
499 errors.insert(index, error);
500 }
501 }
502 }
503 if errors.is_empty() {
504 return Err(DomainLinkageError::NoCredentialFor(did.clone()));
505 }
506 Err(DomainLinkageError::NoValidCredential(errors))
507 }
508
509 pub async fn validate_configuration(
517 &self,
518 configuration: &DomainLinkageConfiguration,
519 origin: &Url,
520 options: &CredentialValidationOptions,
521 ) -> Result<DomainLinkageReport, DomainLinkageError> {
522 configuration.check()?;
523 let mut linked = Vec::new();
524 let mut failed = BTreeMap::new();
525 for (index, jwt) in configuration.jwts() {
526 match self.validate_credential(jwt, origin, options).await {
527 Ok(decoded) => {
528 let did = Did::parse(decoded.credential.issuer.id().as_str())
529 .expect("the format check accepted the issuer as a DID");
530 linked.push((index, did, decoded));
531 }
532 Err(error) => {
533 failed.insert(index, error);
534 }
535 }
536 }
537 if linked.is_empty() {
538 return Err(DomainLinkageError::NoValidCredential(failed));
539 }
540 Ok(DomainLinkageReport { linked, failed })
541 }
542}
543
544fn issuer_of(jwt: &Jwt) -> Option<Did> {
547 crate::vc::validation::extract_issuer(jwt).ok()
548}
549
550fn check_format(
553 decoded: &DecodedCredential,
554 expected_origin: &str,
555) -> Result<(), DomainLinkageError> {
556 let header = &decoded.header;
557 if let Some(typ) = &header.typ {
558 return Err(DomainLinkageError::TypPresent(typ.clone()));
559 }
560 if header.kid.is_none() {
561 return Err(ValidationError::MissingKid.into());
562 }
563 for (name, present) in [
564 ("cty", header.cty.is_some()),
565 ("jwk", header.jwk.is_some()),
566 ("crit", header.crit.is_some()),
567 ] {
568 if present {
569 return Err(DomainLinkageError::UnexpectedHeaderParameter(
570 name.to_owned(),
571 ));
572 }
573 }
574 if let Some(name) = header.additional.keys().next() {
575 return Err(DomainLinkageError::UnexpectedHeaderParameter(name.clone()));
576 }
577
578 let claims = &decoded.claims;
579 for (name, present) in [
580 ("aud", claims.aud.is_some()),
581 ("iat", claims.iat.is_some()),
582 ("jti", claims.jti.is_some()),
583 ] {
584 if present {
585 return Err(DomainLinkageError::UnexpectedClaim(name.to_owned()));
586 }
587 }
588 if let Some(name) = claims.additional.keys().find(|name| *name != "vc") {
589 return Err(DomainLinkageError::UnexpectedClaim(name.clone()));
590 }
591 let iss = claims
592 .iss
593 .as_deref()
594 .ok_or(DomainLinkageError::MissingClaim("iss"))?;
595 let sub = claims
596 .sub
597 .as_deref()
598 .ok_or(DomainLinkageError::MissingClaim("sub"))?;
599 if claims.nbf.is_none() {
600 return Err(DomainLinkageError::MissingClaim("nbf"));
601 }
602 if claims.exp.is_none() {
603 return Err(DomainLinkageError::MissingClaim("exp"));
604 }
605
606 let credential = &decoded.credential;
607 if credential.id.is_some() {
608 return Err(DomainLinkageError::IdPresent);
609 }
610 if !credential.context.contains(CONTEXT) {
611 return Err(DomainLinkageError::MissingContext);
612 }
613 if !credential.has_type(CREDENTIAL_TYPE) {
614 return Err(DomainLinkageError::MissingType);
615 }
616 if credential.expiration_date.is_none() {
617 return Err(DomainLinkageError::MissingExpirationDate);
618 }
619 let subject = credential
622 .subject()
623 .ok_or(DomainLinkageError::SubjectNotDid(None))?;
624 let subject_id = subject.id.as_ref().map(Uri::as_str);
625 let subject_did = subject_id
626 .and_then(|id| Did::parse(id).ok())
627 .ok_or_else(|| DomainLinkageError::SubjectNotDid(subject_id.map(str::to_owned)))?;
628 for (role, found) in [
629 ("issuer", credential.issuer.id().as_str()),
630 ("iss", iss),
631 ("sub", sub),
632 ] {
633 if found != subject_did.as_str() {
634 return Err(DomainLinkageError::SubjectMismatch {
635 role,
636 found: found.to_owned(),
637 subject: subject_did,
638 });
639 }
640 }
641 let origin = subject.properties.get("origin");
642 let Some(Value::String(origin)) = origin else {
643 return Err(DomainLinkageError::InvalidOriginProperty(origin.cloned()));
644 };
645 let found = normalize_origin(origin)?;
646 if found != expected_origin {
647 return Err(DomainLinkageError::OriginMismatch {
648 expected: expected_origin.to_owned(),
649 found,
650 });
651 }
652 Ok(())
653}
654
655impl fmt::Display for DomainLinkageConfiguration {
656 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
657 let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
658 f.write_str(&json)
659 }
660}