Skip to main content

identity_kit/methods/web/
mod.rs

1//! `did:web`: a DID document served over HTTPS by a web origin.
2//!
3//! The [did:web method] maps a DID to the URL of its document: `did:web:example.com` is served
4//! at `https://example.com/.well-known/did.json`, and `did:web:example.com%3A8443:user:alice`
5//! at `https://example.com:8443/user/alice/did.json` (a port is percent-encoded as `%3A`, path
6//! segments are separated by `:`). [`document_url`] implements that mapping and
7//! [`did_from_url`] its inverse. The document is fetched through an [`HttpClient`] the caller
8//! injects; [`ReqwestClient`] (feature `reqwest`) is a hardened default that speaks HTTPS only,
9//! follows no redirect, refuses non-public addresses and caps the response size.
10//!
11//! The method is updated by republishing the document and deactivated by removing it; neither
12//! has an API, so [`DidWeb`] as a [`DidProducer`] builds the document to publish and nothing
13//! more.
14//!
15//! [did:web method]: https://w3c-ccg.github.io/did-method-web/
16
17use core::fmt;
18use core::fmt::Write as _;
19use std::error::Error;
20use std::sync::Arc;
21
22use url::{Host, Url};
23
24use crate::did::{
25    Context, Did, DidDocument, MethodType, ResolutionError, ResolutionErrorKind, ResolutionOptions,
26    ResolutionResult, VerificationMaterial, VerificationMethod, VerificationRelationship,
27};
28use crate::jose::Jwk;
29use crate::resolver::{DidProducer, DidResolver, Hosting, MethodCapabilities, ProduceError};
30
31use super::check_accept;
32
33#[cfg(feature = "reqwest")]
34mod client;
35
36#[cfg(feature = "reqwest")]
37#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
38pub use client::ReqwestClient;
39#[cfg(feature = "local-network")]
40#[cfg_attr(docsrs, doc(cfg(feature = "local-network")))]
41pub use client::{LocalNetwork, ReqwestClientBuilder};
42
43/// The method name, `web`.
44pub const METHOD: &str = "web";
45
46/// The `@context` of a document [`DidWeb`] produces.
47pub const CONTEXT: [&str; 2] = [
48    Context::DID_V1,
49    "https://w3id.org/security/suites/jws-2020/v1",
50];
51
52// ---------------------------------------------------------------------------------------------
53// HTTP
54// ---------------------------------------------------------------------------------------------
55
56/// What [`DidWeb`] needs from HTTP: a `GET` of a URL.
57///
58/// Implement it over the HTTP client of the application so `did:web` resolution shares its
59/// connection pool, proxy and TLS settings. The implementation decides the security policy:
60/// [`ReqwestClient`] is the one this crate ships.
61///
62/// Every URL a client is given comes from untrusted input (a DID, a credential's status list
63/// reference), so an implementation must refuse what [`ReqwestClient`] refuses, or weigh the
64/// consequence: plain `http`, redirects, hosts that resolve to loopback, private, link-local or
65/// other non-public addresses, unbounded bodies and unbounded waits.
66///
67/// A client follows no redirect itself: it returns the 3xx response, with its `Location` in
68/// [`HttpResponse::location`]. A caller that follows redirects, such as the status list check
69/// of the `sd-jwt` feature, fetches each target with a new call, so that every hop goes
70/// through the client's rules.
71#[async_trait::async_trait]
72pub trait HttpClient: Send + Sync {
73    /// Fetches `url`, returning the status and the body of the response.
74    ///
75    /// # Errors
76    ///
77    /// Any failure to get a response: a refused URL, a network or TLS error, a timeout, a body
78    /// over the client's limit. An HTTP error status is a response, not an error.
79    async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError>;
80
81    /// Fetches `url` asking for a body of `media_type`, sent as the `Accept` header.
82    ///
83    /// The default calls [`get`](Self::get) and sends no `Accept` header; [`ReqwestClient`]
84    /// sends it. Implement it when a server negotiates the content type.
85    ///
86    /// # Errors
87    ///
88    /// As [`get`](Self::get).
89    async fn get_accepting(&self, url: &Url, media_type: &str) -> Result<HttpResponse, HttpError> {
90        let _ = media_type;
91        self.get(url).await
92    }
93}
94
95macro_rules! forward_http_client {
96    ($($t:ty),* $(,)?) => {$(
97        #[async_trait::async_trait]
98        impl<T: HttpClient + ?Sized> HttpClient for $t {
99            async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
100                (**self).get(url).await
101            }
102
103            async fn get_accepting(
104                &self,
105                url: &Url,
106                media_type: &str,
107            ) -> Result<HttpResponse, HttpError> {
108                (**self).get_accepting(url, media_type).await
109            }
110        }
111    )*};
112}
113
114forward_http_client!(&T, Box<T>, Arc<T>);
115
116/// An HTTP response: the status code, the body and, for a redirect, its target.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub struct HttpResponse {
120    /// The status code, `200` for a served document.
121    pub status: u16,
122    /// The body, which a `did:web` resolver parses as JSON.
123    pub body: Vec<u8>,
124    /// The `Location` header of a 3xx response, as sent: possibly relative to the requested
125    /// URL. `None` for any other response.
126    pub location: Option<String>,
127}
128
129impl HttpResponse {
130    /// A response with `status` and `body`, and no `location`.
131    pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
132        Self {
133            status,
134            body: body.into(),
135            location: None,
136        }
137    }
138
139    /// This response with the `Location` header `location`.
140    #[must_use]
141    pub fn with_location(mut self, location: impl Into<String>) -> Self {
142        self.location = Some(location.into());
143        self
144    }
145}
146
147/// Why an [`HttpClient`] could not get a response.
148#[derive(Debug)]
149pub struct HttpError(Box<dyn Error + Send + Sync + 'static>);
150
151impl HttpError {
152    /// Wraps `error`.
153    pub fn new<E: Error + Send + Sync + 'static>(error: E) -> Self {
154        Self(Box::new(error))
155    }
156
157    /// An error carrying `message`.
158    pub fn msg<M: fmt::Display>(message: M) -> Self {
159        Self(message.to_string().into())
160    }
161
162    /// The wrapped error.
163    pub fn inner(&self) -> &(dyn Error + Send + Sync + 'static) {
164        self.0.as_ref()
165    }
166}
167
168impl fmt::Display for HttpError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        fmt::Display::fmt(&self.0, f)
171    }
172}
173
174impl Error for HttpError {
175    fn source(&self) -> Option<&(dyn Error + 'static)> {
176        self.0.source()
177    }
178}
179
180impl From<Box<dyn Error + Send + Sync + 'static>> for HttpError {
181    fn from(error: Box<dyn Error + Send + Sync + 'static>) -> Self {
182        Self(error)
183    }
184}
185
186// ---------------------------------------------------------------------------------------------
187// DID <-> URL
188// ---------------------------------------------------------------------------------------------
189
190/// Why a URL has no `did:web`, or a `did:web` no document URL.
191#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
192#[non_exhaustive]
193pub enum WebError {
194    /// The URL is not an HTTPS location a `did:web` can name.
195    #[error("`{url}` cannot be a did:web: {reason}")]
196    InvalidUrl {
197        /// The URL.
198        url: Url,
199        /// What is wrong with it.
200        reason: &'static str,
201    },
202    /// The DID is not a well-formed `did:web`.
203    #[error("`{did}` is not a valid did:web: {reason}")]
204    InvalidDid {
205        /// The DID.
206        did: Did,
207        /// What is wrong with it.
208        reason: &'static str,
209    },
210}
211
212/// The directory of the document of a DID without path segments.
213const WELL_KNOWN: &str = ".well-known";
214
215/// The file name of the document.
216const DOCUMENT_FILE: &str = "did.json";
217
218/// The `did:web` whose document lives under `url`: `https://example.com` gives
219/// `did:web:example.com`, `https://example.com:8443/user/alice` gives
220/// `did:web:example.com%3A8443:user:alice`.
221///
222/// The URL must be `https`, name a domain (not an IP address) and carry no credentials, query
223/// or fragment. A trailing slash is ignored, as is the document file name that
224/// [`document_url`] appends (`/.well-known/did.json` or `/did.json`), so a document URL maps
225/// back to its DID; other path segments become `:`-separated id segments, percent-encoded where
226/// the DID syntax requires it.
227///
228/// # Errors
229///
230/// [`WebError::InvalidUrl`] when the URL breaks one of those rules.
231///
232/// # Examples
233///
234/// ```
235/// use identity_kit::methods::web::did_from_url;
236/// use url::Url;
237///
238/// let did = did_from_url(&Url::parse("https://example.com:3000/user/alice/")?)?;
239/// assert_eq!(did.as_str(), "did:web:example.com%3A3000:user:alice");
240/// let did = did_from_url(&Url::parse("https://example.com/.well-known/did.json")?)?;
241/// assert_eq!(did.as_str(), "did:web:example.com");
242/// assert!(did_from_url(&Url::parse("https://192.0.2.1/")?).is_err());
243/// # Ok::<(), Box<dyn std::error::Error>>(())
244/// ```
245pub fn did_from_url(url: &Url) -> Result<Did, WebError> {
246    let invalid = |reason| WebError::InvalidUrl {
247        url: url.clone(),
248        reason,
249    };
250    if url.scheme() != "https" {
251        return Err(invalid("the scheme must be `https`"));
252    }
253    let domain = match url.host() {
254        Some(Host::Domain(domain)) => domain,
255        Some(_) => return Err(invalid("the host must be a domain name, not an IP address")),
256        None => return Err(invalid("the URL has no host")),
257    };
258    if !domain
259        .bytes()
260        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
261    {
262        return Err(invalid("the host has characters a DID cannot carry"));
263    }
264    if !url.username().is_empty() || url.password().is_some() {
265        return Err(invalid("the URL must not carry credentials"));
266    }
267    if url.query().is_some() || url.fragment().is_some() {
268        return Err(invalid("the URL must not have a query or a fragment"));
269    }
270    let mut id = domain.to_owned();
271    if let Some(port) = url.port() {
272        let _ = write!(id, "%3A{port}");
273    }
274    let mut segments: Vec<&str> = url
275        .path_segments()
276        .map(Iterator::collect)
277        .unwrap_or_default();
278    if segments.last() == Some(&"") {
279        segments.pop();
280    }
281    if segments.last() == Some(&DOCUMENT_FILE) {
282        segments.pop();
283        if segments.as_slice() == [WELL_KNOWN] {
284            segments.clear();
285        }
286    }
287    for segment in segments {
288        if segment.is_empty() {
289            return Err(invalid("the path has an empty segment"));
290        }
291        id.push(':');
292        id.push_str(&encode_segment(segment));
293    }
294    Did::parse(&format!("did:web:{id}")).map_err(|_| invalid("the URL does not map to a DID"))
295}
296
297/// The `did:web` of the origin of `url`: as [`did_from_url`], but the URL must have no path.
298///
299/// # Errors
300///
301/// [`WebError::InvalidUrl`] when the URL has a path, or breaks a rule of [`did_from_url`].
302pub fn did_from_origin(url: &Url) -> Result<Did, WebError> {
303    if !matches!(url.path(), "" | "/") {
304        return Err(WebError::InvalidUrl {
305            url: url.clone(),
306            reason: "an origin has no path",
307        });
308    }
309    did_from_url(url)
310}
311
312/// `segment` with every byte outside the DID `idchar` set percent-encoded.
313fn encode_segment(segment: &str) -> String {
314    let bytes = segment.as_bytes();
315    let mut out = String::with_capacity(segment.len());
316    let mut i = 0;
317    while i < bytes.len() {
318        let b = bytes[i];
319        if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
320            out.push(char::from(b));
321        } else if b == b'%'
322            && bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit)
323            && bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)
324        {
325            out.push_str(&segment[i..i + 3]);
326            i += 3;
327            continue;
328        } else {
329            let _ = write!(out, "%{b:02X}");
330        }
331        i += 1;
332    }
333    out
334}
335
336/// The URL of the document of `did` (the Read operation's URL construction):
337/// `https://host[:port]/.well-known/did.json` for a bare domain, else
338/// `https://host[:port]/<segments>/did.json`.
339///
340/// # Errors
341///
342/// [`WebError::InvalidDid`] when `did` is not a `did:web`, names an IP address, has a
343/// malformed port, or has an empty, `.` or `..` path segment, percent-encoded or not.
344///
345/// # Examples
346///
347/// ```
348/// use identity_kit::did::Did;
349/// use identity_kit::methods::web::document_url;
350///
351/// let url = document_url(&Did::parse("did:web:example.com")?)?;
352/// assert_eq!(url.as_str(), "https://example.com/.well-known/did.json");
353///
354/// let url = document_url(&Did::parse("did:web:example.com%3A3000:user:alice")?)?;
355/// assert_eq!(url.as_str(), "https://example.com:3000/user/alice/did.json");
356/// # Ok::<(), Box<dyn std::error::Error>>(())
357/// ```
358pub fn document_url(did: &Did) -> Result<Url, WebError> {
359    let invalid = |reason| WebError::InvalidDid {
360        did: did.clone(),
361        reason,
362    };
363    if did.method() != METHOD {
364        return Err(invalid("the method is not `web`"));
365    }
366    let mut segments = did.method_specific_id().split(':');
367    let authority = percent_decode(segments.next().unwrap_or_default());
368    let (host, port) = match authority.rsplit_once(':') {
369        Some((host, port)) => (host, Some(port)),
370        None => (authority.as_str(), None),
371    };
372    if host.is_empty()
373        || !host
374            .bytes()
375            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
376    {
377        return Err(invalid("the host is not a domain name"));
378    }
379    if let Some(port) = port
380        && (port.is_empty() || port.len() > 5 || !port.bytes().all(|b| b.is_ascii_digit()))
381    {
382        return Err(invalid("the port is not a number"));
383    }
384    let mut path = String::new();
385    let mut has_segments = false;
386    for segment in segments {
387        if is_dot_segment(segment) {
388            return Err(invalid("a path segment is empty, `.` or `..`"));
389        }
390        has_segments = true;
391        path.push('/');
392        path.push_str(segment);
393    }
394    if !has_segments {
395        path.push('/');
396        path.push_str(WELL_KNOWN);
397    }
398    let port = port.map(|port| format!(":{port}")).unwrap_or_default();
399    let url = Url::parse(&format!("https://{host}{port}{path}/{DOCUMENT_FILE}"))
400        .map_err(|_| invalid("the DID does not map to a URL"))?;
401    match url.host() {
402        Some(Host::Domain(_)) => Ok(url),
403        _ => Err(invalid("the host is an IP address")),
404    }
405}
406
407/// Whether `segment` is empty, or is `.` or `..` as the URL parser reads it: a segment of `.` and
408/// `%2E` in any combination and case is a dot segment, which the parser collapses into the path
409/// before it, so `did:web:example.com:a:%2E%2E:b` would name `https://example.com/b/did.json`.
410fn is_dot_segment(segment: &str) -> bool {
411    let mut dots = 0;
412    let mut i = 0;
413    let bytes = segment.as_bytes();
414    while i < bytes.len() {
415        if bytes[i] == b'.' {
416            i += 1;
417        } else if bytes[i..]
418            .get(..3)
419            .is_some_and(|b| b.eq_ignore_ascii_case(b"%2e"))
420        {
421            i += 3;
422        } else {
423            return false;
424        }
425        dots += 1;
426    }
427    dots <= 2
428}
429
430/// Decodes the percent-encoded octets of a DID segment, which the DID syntax has checked to be
431/// complete; non-UTF-8 octets become replacement characters and fail the host check.
432#[expect(clippy::cast_possible_truncation, reason = "a hex digit is below 16")]
433fn percent_decode(s: &str) -> String {
434    let bytes = s.as_bytes();
435    let mut out = Vec::with_capacity(bytes.len());
436    let mut i = 0;
437    while i < bytes.len() {
438        match (bytes[i], bytes.get(i + 1), bytes.get(i + 2)) {
439            (b'%', Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
440                let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8;
441                out.push(hex(*hi) << 4 | hex(*lo));
442                i += 3;
443            }
444            (b, _, _) => {
445                out.push(b);
446                i += 1;
447            }
448        }
449    }
450    String::from_utf8_lossy(&out).into_owned()
451}
452
453// ---------------------------------------------------------------------------------------------
454// The method
455// ---------------------------------------------------------------------------------------------
456
457/// The `did:web` method: a [`DidResolver`] fetching documents through an [`HttpClient`], and a
458/// [`DidProducer`] building the document to publish.
459///
460/// # Examples
461///
462/// ```
463/// use identity_kit::did::Did;
464/// use identity_kit::methods::web::{DidWeb, HttpClient, HttpError, HttpResponse};
465/// use identity_kit::resolver::Resolver;
466/// use url::Url;
467///
468/// /// Serves one document from memory.
469/// struct Static(String);
470///
471/// #[async_trait::async_trait]
472/// impl HttpClient for Static {
473///     async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
474///         Ok(if url.as_str() == "https://example.com/.well-known/did.json" {
475///             HttpResponse::new(200, self.0.as_bytes())
476///         } else {
477///             HttpResponse::new(404, Vec::new())
478///         })
479///     }
480/// }
481///
482/// # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
483/// let document = r#"{"id": "did:web:example.com"}"#.to_owned();
484/// let resolver = Resolver::builder().with(DidWeb::new(Static(document))).build();
485/// let did = Did::parse("did:web:example.com")?;
486/// assert_eq!(resolver.resolve_document(&did).await?.id, did);
487/// # Ok::<(), Box<dyn std::error::Error>>(())
488/// # })?;
489/// # Ok::<(), Box<dyn std::error::Error>>(())
490/// ```
491#[derive(Debug, Clone)]
492pub struct DidWeb<C> {
493    client: C,
494}
495
496impl<C: HttpClient> DidWeb<C> {
497    /// The method fetching documents with `client`.
498    pub const fn new(client: C) -> Self {
499        Self { client }
500    }
501
502    /// The HTTP client.
503    pub const fn client(&self) -> &C {
504        &self.client
505    }
506}
507
508#[async_trait::async_trait]
509impl<C: HttpClient> DidResolver for DidWeb<C> {
510    fn method(&self) -> &'static str {
511        METHOD
512    }
513
514    /// Fetches the document of `did` from [`document_url`] and checks that its `id` is `did`.
515    ///
516    /// # Errors
517    ///
518    /// A [`ResolutionError`] whose kind is:
519    ///
520    /// - [`InvalidDid`](ResolutionErrorKind::InvalidDid) when `did` has no document URL, caused
521    ///   by the [`WebError`].
522    /// - [`NotFound`](ResolutionErrorKind::NotFound) on a `404` or `410` response.
523    /// - [`InternalError`](ResolutionErrorKind::InternalError) when the client fails, caused by
524    ///   its [`HttpError`], or when it returns any other non-success status, which the detail
525    ///   names.
526    /// - [`InvalidDidDocument`](ResolutionErrorKind::InvalidDidDocument) when the body is not a
527    ///   DID document, caused by the [`serde_json::Error`], or is the document of another DID,
528    ///   which the detail names.
529    /// - [`RepresentationNotSupported`](ResolutionErrorKind::RepresentationNotSupported) for an
530    ///   `accept` that is not JSON.
531    async fn resolve(
532        &self,
533        did: &Did,
534        options: &ResolutionOptions,
535    ) -> Result<ResolutionResult, ResolutionError> {
536        check_accept(options)?;
537        let url = document_url(did).map_err(|error| {
538            ResolutionError::new(ResolutionErrorKind::InvalidDid).with_source(error)
539        })?;
540        let response = self.client.get(&url).await.map_err(|error| {
541            ResolutionError::new(ResolutionErrorKind::InternalError)
542                .with_detail(format!("fetching `{url}` failed"))
543                .with_source(error)
544        })?;
545        let kind = match response.status {
546            200..=299 => None,
547            404 | 410 => Some(ResolutionErrorKind::NotFound),
548            _ => Some(ResolutionErrorKind::InternalError),
549        };
550        if let Some(kind) = kind {
551            return Err(ResolutionError::new(kind).with_detail(format!(
552                "`{url}` responded with HTTP status {}",
553                response.status
554            )));
555        }
556        let document: DidDocument = serde_json::from_slice(&response.body).map_err(|error| {
557            ResolutionError::new(ResolutionErrorKind::InvalidDidDocument)
558                .with_detail(format!("the body of `{url}` is not a DID document"))
559                .with_source(error)
560        })?;
561        if document.id != *did {
562            return Err(
563                ResolutionError::new(ResolutionErrorKind::InvalidDidDocument).with_detail(format!(
564                    "`{url}` holds the document of `{}`, not `{did}`",
565                    document.id
566                )),
567            );
568        }
569        Ok(ResolutionResult::new(document))
570    }
571}
572
573#[async_trait::async_trait]
574impl<C: HttpClient> DidProducer for DidWeb<C> {
575    /// The HTTPS URL the document will be served under, as [`did_from_url`] reads it.
576    type Params = Url;
577
578    fn method(&self) -> &'static str {
579        METHOD
580    }
581
582    /// Hosted on the web, any number of keys, updated by republishing; fragments come from the
583    /// keys.
584    fn capabilities(&self) -> MethodCapabilities {
585        MethodCapabilities::new(Hosting::Web).with_updatable(true)
586    }
587
588    /// The document to publish at `url` for `keys`.
589    ///
590    /// Each key becomes a `JsonWebKey2020` verification method whose fragment is the key's
591    /// `kid`, or `key-<index>` when it has none. A key with `use` `enc`, or an X25519 key, is
592    /// referenced from `keyAgreement`; every other key from `authentication`,
593    /// `assertionMethod`, `capabilityInvocation` and `capabilityDelegation`. The document's
594    /// `@context` is [`CONTEXT`].
595    ///
596    /// # Errors
597    ///
598    /// - [`ProduceError::NoKeys`] without a key.
599    /// - [`ProduceError::InvalidParams`] when `url` has no `did:web` (see [`did_from_url`]).
600    /// - [`ProduceError::UnsupportedKey`] for a `kid` that is not a valid fragment.
601    /// - [`ProduceError::Document`] for two keys with the same `kid`.
602    async fn produce(&self, keys: &[Jwk], url: Url) -> Result<DidDocument, ProduceError> {
603        if keys.is_empty() {
604            return Err(ProduceError::NoKeys);
605        }
606        let did = did_from_url(&url).map_err(|error| ProduceError::InvalidParams {
607            reason: "the URL is not a `did:web` location".to_owned(),
608            source: Some(Box::new(error)),
609        })?;
610        let mut builder = DidDocument::builder(did.clone()).context(Context::uris(CONTEXT));
611        for (index, key) in keys.iter().enumerate() {
612            let id = match key.kid() {
613                Some(kid) => {
614                    did.join(&format!("#{kid}"))
615                        .map_err(|error| ProduceError::UnsupportedKey {
616                            index,
617                            reason: format!("`kid` {kid:?} is not a valid DID URL fragment"),
618                            source: Some(Box::new(error)),
619                        })?
620                }
621                None => did.to_url().with_fragment(&format!("key-{index}")),
622            };
623            let relationships: &[VerificationRelationship] =
624                if key.key_use() == Some("enc") || key.crv() == Some("X25519") {
625                    &[VerificationRelationship::KeyAgreement]
626                } else {
627                    &[
628                        VerificationRelationship::Authentication,
629                        VerificationRelationship::AssertionMethod,
630                        VerificationRelationship::CapabilityInvocation,
631                        VerificationRelationship::CapabilityDelegation,
632                    ]
633                };
634            let method = VerificationMethod::new(
635                id,
636                MethodType::JSON_WEB_KEY_2020,
637                did.clone(),
638                VerificationMaterial::jwk(key),
639            );
640            builder = builder.verification_method(method, relationships.iter().copied());
641        }
642        Ok(builder.build()?)
643    }
644}