baseid_did/methods/
webvh.rs

1//! `did:webvh` method implementation (formerly `did:tdw`).
2//!
3//! Trust DID Web with Verifiable History — extends did:web with a
4//! cryptographically verifiable log of all DID document changes.
5//!
6//! Reference: <https://identity.foundation/didwebvh/v1.0/>
7
8use baseid_core::error::DidError;
9use sha2::{Digest, Sha256};
10
11use crate::document::DidDocument;
12use crate::resolution::{DidResolver, ResolutionMetadata, ResolutionResult};
13
14/// A single entry in the did:webvh log chain.
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct WebvhLogEntry {
18    pub version_id: String,
19    pub version_time: String,
20    #[serde(default)]
21    pub parameters: WebvhParameters,
22    pub state: DidDocument,
23}
24
25/// Parameters for a did:webvh log entry.
26#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct WebvhParameters {
29    #[serde(default)]
30    pub method: String,
31    #[serde(default)]
32    pub scid: String,
33    #[serde(default)]
34    pub update_keys: Vec<String>,
35    #[serde(default)]
36    pub portable: bool,
37    #[serde(default)]
38    pub deactivated: bool,
39    #[serde(default)]
40    pub ttl: u64,
41}
42
43/// Resolver for `did:webvh` method.
44///
45/// Fetches the `.well-known/did.jsonl` file and processes the log chain.
46pub struct DidWebvhResolver {
47    client: reqwest::Client,
48}
49
50impl Default for DidWebvhResolver {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl DidWebvhResolver {
57    pub fn new() -> Self {
58        Self {
59            client: reqwest::Client::new(),
60        }
61    }
62
63    /// Convert a did:webvh DID to the HTTPS URL for fetching the log.
64    pub fn did_to_url(did: &str) -> baseid_core::Result<String> {
65        if !did.starts_with("did:webvh:") {
66            return Err(DidError::UnsupportedMethod.into());
67        }
68
69        let rest = &did["did:webvh:".len()..];
70        // Split on ':' — first part is SCID, rest is domain + path
71        let parts: Vec<&str> = rest.splitn(2, ':').collect();
72        if parts.len() < 2 {
73            return Err(DidError::InvalidDid.into());
74        }
75
76        let _scid = parts[0];
77        let domain_path = parts[1];
78
79        // Process domain and path (same as did:web)
80        let segments: Vec<&str> = domain_path.split(':').collect();
81        let domain = segments[0].replace("%3A", ":");
82        let path = if segments.len() > 1 {
83            format!("/{}", segments[1..].join("/"))
84        } else {
85            "/.well-known".to_string()
86        };
87
88        Ok(format!("https://{domain}{path}/did.jsonl"))
89    }
90
91    /// Process a did:webvh log (JSONL content) and return the final DID document.
92    pub fn process_log(did: &str, jsonl: &str) -> baseid_core::Result<DidDocument> {
93        let mut last_doc: Option<DidDocument> = None;
94        let mut expected_version = 1u64;
95
96        for line in jsonl.lines() {
97            let line = line.trim();
98            if line.is_empty() {
99                continue;
100            }
101
102            let entry: WebvhLogEntry =
103                serde_json::from_str(line).map_err(|_| DidError::ResolutionFailed)?;
104
105            // Validate version ordering
106            let version_num = entry
107                .version_id
108                .split('-')
109                .next()
110                .and_then(|s| s.parse::<u64>().ok())
111                .ok_or(DidError::ResolutionFailed)?;
112
113            if version_num != expected_version {
114                return Err(DidError::ResolutionFailed.into());
115            }
116            expected_version += 1;
117
118            // Check if deactivated
119            if entry.parameters.deactivated {
120                return Err(DidError::NotFound.into());
121            }
122
123            let mut doc = entry.state;
124            if doc.id.is_empty() {
125                doc.id = did.to_string();
126            }
127            last_doc = Some(doc);
128        }
129
130        last_doc.ok_or_else(|| DidError::NotFound.into())
131    }
132
133    /// Compute the SCID from a preliminary log entry.
134    pub fn compute_scid(preliminary_entry: &str) -> String {
135        let hash = Sha256::digest(preliminary_entry.as_bytes());
136        let mut multihash = vec![0x12, 0x20];
137        multihash.extend_from_slice(&hash);
138        format!("z{}", base58_encode(&multihash))
139    }
140}
141
142impl DidResolver for DidWebvhResolver {
143    fn method(&self) -> &str {
144        "webvh"
145    }
146
147    async fn resolve(&self, did: &str) -> baseid_core::Result<ResolutionResult> {
148        let url = Self::did_to_url(did)?;
149
150        let response = self
151            .client
152            .get(&url)
153            .send()
154            .await
155            .map_err(|_| DidError::ResolutionFailed)?;
156
157        if !response.status().is_success() {
158            return Err(DidError::NotFound.into());
159        }
160
161        let body = response
162            .text()
163            .await
164            .map_err(|_| DidError::ResolutionFailed)?;
165
166        let document = Self::process_log(did, &body)?;
167
168        Ok(ResolutionResult {
169            document: Some(document),
170            metadata: ResolutionMetadata {
171                content_type: Some("application/did+ld+json".to_string()),
172                error: None,
173            },
174        })
175    }
176}
177
178fn base58_encode(data: &[u8]) -> String {
179    const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
180    let mut result = Vec::new();
181    let mut data = data.to_vec();
182    let leading_zeros = data.iter().take_while(|&&b| b == 0).count();
183    while !data.is_empty() {
184        let mut remainder = 0u32;
185        let mut new_data = Vec::new();
186        for &byte in &data {
187            let acc = (remainder << 8) | byte as u32;
188            let digit = acc / 58;
189            remainder = acc % 58;
190            if !new_data.is_empty() || digit > 0 {
191                new_data.push(digit as u8);
192            }
193        }
194        result.push(ALPHABET[remainder as usize]);
195        data = new_data;
196    }
197    result.extend(std::iter::repeat_n(b'1', leading_zeros));
198    result.reverse();
199    String::from_utf8(result).unwrap_or_default()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::document::{VerificationMethod, VerificationRelationship};
206
207    #[test]
208    fn did_to_url_simple_domain() {
209        let url = DidWebvhResolver::did_to_url("did:webvh:zSCID123:example.com").unwrap();
210        assert_eq!(url, "https://example.com/.well-known/did.jsonl");
211    }
212
213    #[test]
214    fn did_to_url_with_path() {
215        let url =
216            DidWebvhResolver::did_to_url("did:webvh:zSCID123:example.com:dids:issuer").unwrap();
217        assert_eq!(url, "https://example.com/dids/issuer/did.jsonl");
218    }
219
220    #[test]
221    fn did_to_url_with_port() {
222        let url = DidWebvhResolver::did_to_url("did:webvh:zSCID123:example.com%3A3000").unwrap();
223        assert_eq!(url, "https://example.com:3000/.well-known/did.jsonl");
224    }
225
226    #[test]
227    fn process_single_entry_log() {
228        let doc = DidDocument {
229            id: "did:webvh:zSCID:example.com".to_string(),
230            context: vec!["https://www.w3.org/ns/did/v1".to_string()],
231            verification_method: vec![VerificationMethod {
232                id: "#key-1".to_string(),
233                r#type: "Multikey".to_string(),
234                controller: "did:webvh:zSCID:example.com".to_string(),
235                public_key_jwk: None,
236                public_key_multibase: Some("z6MkTest".to_string()),
237            }],
238            authentication: vec![VerificationRelationship::Reference("#key-1".to_string())],
239            assertion_method: vec![],
240            key_agreement: vec![],
241            service: vec![],
242        };
243
244        let entry = WebvhLogEntry {
245            version_id: "1-hash123".to_string(),
246            version_time: "2024-01-01T00:00:00Z".to_string(),
247            parameters: WebvhParameters {
248                method: "did:webvh:1.0".to_string(),
249                scid: "zSCID".to_string(),
250                ..Default::default()
251            },
252            state: doc,
253        };
254
255        let jsonl = serde_json::to_string(&entry).unwrap();
256        let result = DidWebvhResolver::process_log("did:webvh:zSCID:example.com", &jsonl).unwrap();
257        assert_eq!(result.verification_method.len(), 1);
258    }
259
260    #[test]
261    fn process_multi_version_log() {
262        let make_entry = |version: u64, key: &str| -> String {
263            serde_json::to_string(&WebvhLogEntry {
264                version_id: format!("{version}-hash{version}"),
265                version_time: format!("2024-01-0{version}T00:00:00Z"),
266                parameters: WebvhParameters::default(),
267                state: DidDocument {
268                    id: "did:webvh:zSCID:example.com".to_string(),
269                    context: vec!["https://www.w3.org/ns/did/v1".to_string()],
270                    verification_method: vec![VerificationMethod {
271                        id: "#key-1".to_string(),
272                        r#type: "Multikey".to_string(),
273                        controller: String::new(),
274                        public_key_jwk: None,
275                        public_key_multibase: Some(key.to_string()),
276                    }],
277                    authentication: vec![],
278                    assertion_method: vec![],
279                    key_agreement: vec![],
280                    service: vec![],
281                },
282            })
283            .unwrap()
284        };
285
286        let jsonl = format!(
287            "{}\n{}\n{}",
288            make_entry(1, "zKey1"),
289            make_entry(2, "zKey2"),
290            make_entry(3, "zKey3")
291        );
292        let doc = DidWebvhResolver::process_log("did:webvh:zSCID:example.com", &jsonl).unwrap();
293        // Should return the latest version
294        assert_eq!(
295            doc.verification_method[0].public_key_multibase.as_deref(),
296            Some("zKey3")
297        );
298    }
299
300    #[test]
301    fn process_deactivated_did() {
302        let entry = WebvhLogEntry {
303            version_id: "1-hash".to_string(),
304            version_time: "2024-01-01T00:00:00Z".to_string(),
305            parameters: WebvhParameters {
306                deactivated: true,
307                ..Default::default()
308            },
309            state: DidDocument {
310                id: String::new(),
311                context: vec![],
312                verification_method: vec![],
313                authentication: vec![],
314                assertion_method: vec![],
315                key_agreement: vec![],
316                service: vec![],
317            },
318        };
319        let jsonl = serde_json::to_string(&entry).unwrap();
320        let result = DidWebvhResolver::process_log("did:webvh:zSCID:example.com", &jsonl);
321        assert!(result.is_err());
322    }
323
324    #[test]
325    fn reject_invalid_version_order() {
326        let entry1 = serde_json::to_string(&WebvhLogEntry {
327            version_id: "1-hash1".to_string(),
328            version_time: "2024-01-01T00:00:00Z".to_string(),
329            parameters: WebvhParameters::default(),
330            state: DidDocument {
331                id: String::new(),
332                context: vec![],
333                verification_method: vec![],
334                authentication: vec![],
335                assertion_method: vec![],
336                key_agreement: vec![],
337                service: vec![],
338            },
339        })
340        .unwrap();
341        let entry3 = serde_json::to_string(&WebvhLogEntry {
342            version_id: "3-hash3".to_string(), // Skipped version 2
343            version_time: "2024-01-03T00:00:00Z".to_string(),
344            parameters: WebvhParameters::default(),
345            state: DidDocument {
346                id: String::new(),
347                context: vec![],
348                verification_method: vec![],
349                authentication: vec![],
350                assertion_method: vec![],
351                key_agreement: vec![],
352                service: vec![],
353            },
354        })
355        .unwrap();
356        let jsonl = format!("{}\n{}", entry1, entry3);
357        let result = DidWebvhResolver::process_log("did:webvh:zSCID:example.com", &jsonl);
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn compute_scid_deterministic() {
363        let scid1 = DidWebvhResolver::compute_scid("test entry 1");
364        let scid2 = DidWebvhResolver::compute_scid("test entry 1");
365        let scid3 = DidWebvhResolver::compute_scid("test entry 2");
366        assert_eq!(scid1, scid2); // Deterministic
367        assert_ne!(scid1, scid3); // Different input → different SCID
368        assert!(scid1.starts_with('z')); // Multibase base58btc
369    }
370
371    #[test]
372    fn reject_invalid_did() {
373        assert!(DidWebvhResolver::did_to_url("did:web:example.com").is_err());
374        assert!(DidWebvhResolver::did_to_url("did:webvh:").is_err());
375    }
376}