Skip to main content

atproto_identity/webvh/
scid.rs

1//! SCID computation, verification, and entry hash operations for did:webvh.
2//!
3//! Implements the Self-Certifying Identifier (SCID) algorithm and entry hash
4//! computation used to create verifiable chains of DID document updates.
5//! All hashing uses SHA-256 with multihash encoding and base58btc multibase output.
6
7use sha2::{Digest, Sha256};
8
9use crate::errors::WebVHDIDError;
10
11use super::jcs;
12
13/// SHA-256 multihash header: algorithm identifier (0x12) + digest length (0x20 = 32 bytes).
14const MULTIHASH_SHA256_HEADER: [u8; 2] = [0x12, 0x20];
15
16/// Valid base58btc alphabet characters.
17const BASE58BTC_ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
18
19/// Minimum expected SCID length in base58btc characters (includes 'z' prefix).
20const SCID_MIN_LENGTH: usize = 45;
21
22/// Maximum expected SCID length in base58btc characters (includes 'z' prefix).
23const SCID_MAX_LENGTH: usize = 48;
24
25/// Hash algorithms supported by did:webvh.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HashAlgorithm {
28    /// SHA-256 (multihash code 0x12, digest length 0x20).
29    Sha256,
30}
31
32/// Detects the hash algorithm from a base58btc-encoded multihash SCID.
33///
34/// Decodes the multibase string, reads the multihash header, and returns
35/// the identified algorithm.
36pub fn detect_hash_algorithm(scid: &str) -> Result<HashAlgorithm, WebVHDIDError> {
37    let (_, decoded) = multibase::decode(scid).map_err(|e| WebVHDIDError::InvalidSCIDFormat {
38        details: format!("failed to decode SCID multibase: {}", e),
39    })?;
40
41    if decoded.len() < 2 {
42        return Err(WebVHDIDError::InvalidSCIDFormat {
43            details: "SCID multihash too short".to_string(),
44        });
45    }
46
47    match (decoded[0], decoded[1]) {
48        (0x12, 0x20) => Ok(HashAlgorithm::Sha256),
49        (code, length) => Err(WebVHDIDError::UnsupportedHashAlgorithm {
50            details: format!(
51                "multihash code {:#04x} with length {:#04x} is not supported",
52                code, length
53            ),
54        }),
55    }
56}
57
58/// Validates the hash algorithm in a SCID matches the permitted algorithms
59/// for the given method version.
60///
61/// For "did:webvh:1.0" and "did:webvh:0.5", only SHA-256 is permitted.
62pub fn validate_hash_algorithm(
63    scid: &str,
64    method_version: &str,
65) -> Result<HashAlgorithm, WebVHDIDError> {
66    let algorithm = detect_hash_algorithm(scid)?;
67
68    match method_version {
69        "did:webvh:1.0" | "did:webvh:0.5" => match algorithm {
70            HashAlgorithm::Sha256 => Ok(algorithm),
71        },
72        _ => Err(WebVHDIDError::UnsupportedHashAlgorithm {
73            details: format!("unknown method version: {}", method_version),
74        }),
75    }
76}
77
78/// Validates the format of a SCID string.
79///
80/// Checks that the SCID:
81/// - Is exactly 46 characters long
82/// - Contains only valid base58btc characters
83/// - Starts with 'z' (base58btc multibase prefix)
84pub fn validate_scid_format(scid: &str) -> Result<(), WebVHDIDError> {
85    if !scid.starts_with('z') {
86        return Err(WebVHDIDError::InvalidSCIDFormat {
87            details: format!(
88                "SCID must start with 'z' (base58btc prefix), got '{}'",
89                scid.chars().next().unwrap_or(' ')
90            ),
91        });
92    }
93
94    if scid.len() < SCID_MIN_LENGTH || scid.len() > SCID_MAX_LENGTH {
95        return Err(WebVHDIDError::InvalidSCIDFormat {
96            details: format!(
97                "SCID must be {}-{} characters, got {}",
98                SCID_MIN_LENGTH,
99                SCID_MAX_LENGTH,
100                scid.len()
101            ),
102        });
103    }
104
105    // Check all characters after the 'z' prefix are valid base58btc
106    for ch in scid[1..].chars() {
107        if !BASE58BTC_ALPHABET.contains(ch) {
108            return Err(WebVHDIDError::InvalidSCIDFormat {
109                details: format!("invalid base58btc character in SCID: '{}'", ch),
110            });
111        }
112    }
113
114    Ok(())
115}
116
117/// Computes a SHA-256 multihash of the input data.
118///
119/// Returns bytes in the format: `[0x12, 0x20, ...32 hash bytes...]`
120pub fn compute_multihash_sha256(data: &[u8]) -> Vec<u8> {
121    let hash = Sha256::digest(data);
122    let mut result = Vec::with_capacity(2 + hash.len());
123    result.extend_from_slice(&MULTIHASH_SHA256_HEADER);
124    result.extend_from_slice(&hash);
125    result
126}
127
128/// Computes the base58btc-encoded multihash of the input data.
129///
130/// Returns the multibase-encoded string with the `z` prefix (base58btc).
131pub fn compute_multihash_base58btc(data: &[u8]) -> String {
132    let multihash = compute_multihash_sha256(data);
133    multibase::encode(multibase::Base::Base58Btc, &multihash)
134}
135
136/// Computes the entry hash for a log entry.
137///
138/// Algorithm:
139/// 1. Remove the `proof` and `versionId` fields from the entry
140/// 2. JCS-canonicalize the result
141/// 3. SHA-256 multihash the canonical bytes
142/// 4. Base58btc multibase encode
143///
144/// The `versionId` is excluded because it contains the hash itself
145/// (format: `{N}-{hash}`), making its inclusion circular.
146pub fn compute_entry_hash(entry: &serde_json::Value) -> Result<String, WebVHDIDError> {
147    let mut hashable = entry.clone();
148    if let Some(obj) = hashable.as_object_mut() {
149        obj.remove("proof");
150        obj.remove("versionId");
151    } else {
152        return Err(WebVHDIDError::InvalidVersionId {
153            entry: 0,
154            details: "log entry is not a JSON object".to_string(),
155        });
156    }
157
158    let canonical = jcs::canonicalize(&hashable);
159    Ok(compute_multihash_base58btc(canonical.as_bytes()))
160}
161
162/// Parses a version ID into its number and hash components.
163///
164/// Version IDs have the format `{number}-{hash}`, e.g., `1-QmV8pidQB1...`.
165pub fn parse_version_id(version_id: &str) -> Result<(u64, &str), WebVHDIDError> {
166    let dash_pos = version_id
167        .find('-')
168        .ok_or_else(|| WebVHDIDError::InvalidVersionId {
169            entry: 0,
170            details: format!("missing dash separator in version ID: {}", version_id),
171        })?;
172
173    let number_str = &version_id[..dash_pos];
174    let hash = &version_id[dash_pos + 1..];
175
176    let number: u64 = number_str
177        .parse()
178        .map_err(|_| WebVHDIDError::InvalidVersionId {
179            entry: 0,
180            details: format!("invalid version number: {}", number_str),
181        })?;
182
183    if hash.is_empty() {
184        return Err(WebVHDIDError::InvalidVersionId {
185            entry: 0,
186            details: "empty hash in version ID".to_string(),
187        });
188    }
189
190    Ok((number, hash))
191}
192
193/// Verifies the entry hash matches the hash component of the version ID.
194///
195/// Returns the parsed version number on success.
196pub fn verify_version_id(
197    entry: &serde_json::Value,
198    expected_number: u64,
199    entry_index: usize,
200) -> Result<u64, WebVHDIDError> {
201    let version_id = entry
202        .get("versionId")
203        .and_then(|v| v.as_str())
204        .ok_or_else(|| WebVHDIDError::InvalidVersionId {
205            entry: entry_index,
206            details: "missing or non-string versionId".to_string(),
207        })?;
208
209    let (number, expected_hash) = parse_version_id(version_id).map_err(|e| {
210        if let WebVHDIDError::InvalidVersionId { details, .. } = e {
211            WebVHDIDError::InvalidVersionId {
212                entry: entry_index,
213                details,
214            }
215        } else {
216            e
217        }
218    })?;
219
220    if number != expected_number {
221        return Err(WebVHDIDError::InvalidVersionId {
222            entry: entry_index,
223            details: format!("expected version {}, got {}", expected_number, number),
224        });
225    }
226
227    let computed_hash = compute_entry_hash(entry)?;
228
229    if computed_hash != expected_hash {
230        return Err(WebVHDIDError::EntryHashVerificationFailed { entry: entry_index });
231    }
232
233    Ok(number)
234}
235
236/// Verifies the SCID of a genesis log entry.
237///
238/// Algorithm:
239/// 1. Clone the genesis entry and remove `proof`
240/// 2. Replace `versionId` with the literal string `"{SCID}"`
241/// 3. Text-replace all occurrences of the actual SCID with `{SCID}`
242/// 4. JCS-canonicalize and compute multihash
243/// 5. Compare with the provided SCID
244pub fn verify_scid(scid: &str, genesis_entry: &serde_json::Value) -> Result<(), WebVHDIDError> {
245    let mut entry = genesis_entry.clone();
246
247    // Remove proof
248    if let Some(obj) = entry.as_object_mut() {
249        obj.remove("proof");
250    }
251
252    // Set versionId to "{SCID}"
253    if let Some(obj) = entry.as_object_mut() {
254        obj.insert(
255            "versionId".to_string(),
256            serde_json::Value::String("{SCID}".to_string()),
257        );
258    }
259
260    // Serialize to JSON string
261    let json_str = serde_json::to_string(&entry).map_err(|e| WebVHDIDError::InvalidSCIDFormat {
262        details: format!("failed to serialize genesis entry: {}", e),
263    })?;
264
265    // Replace all SCID occurrences with placeholder
266    let replaced = json_str.replace(scid, "{SCID}");
267
268    // Parse back to Value for JCS canonicalization
269    let replaced_value: serde_json::Value =
270        serde_json::from_str(&replaced).map_err(|e| WebVHDIDError::InvalidSCIDFormat {
271            details: format!("failed to parse replaced entry: {}", e),
272        })?;
273
274    let canonical = jcs::canonicalize(&replaced_value);
275    let computed_scid = compute_multihash_base58btc(canonical.as_bytes());
276
277    if computed_scid != scid {
278        return Err(WebVHDIDError::SCIDVerificationFailed);
279    }
280
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use serde_json::json;
288
289    #[test]
290    fn test_compute_multihash_sha256() {
291        let data = b"hello";
292        let result = compute_multihash_sha256(data);
293        // Should start with SHA-256 multihash header
294        assert_eq!(result[0], 0x12); // SHA-256 identifier
295        assert_eq!(result[1], 0x20); // 32 bytes length
296        assert_eq!(result.len(), 34); // 2 header + 32 hash
297
298        // Verify deterministic
299        let result2 = compute_multihash_sha256(data);
300        assert_eq!(result, result2);
301    }
302
303    #[test]
304    fn test_compute_multihash_base58btc() {
305        let data = b"hello";
306        let result = compute_multihash_base58btc(data);
307        // Should start with 'z' (base58btc multibase prefix)
308        assert!(result.starts_with('z'));
309
310        // Verify deterministic
311        let result2 = compute_multihash_base58btc(data);
312        assert_eq!(result, result2);
313    }
314
315    #[test]
316    fn test_compute_entry_hash() {
317        let entry = json!({
318            "versionId": "1-test",
319            "versionTime": "2025-04-29T17:15:59Z",
320            "parameters": {"method": "did:webvh:1.0"},
321            "state": {"id": "did:webvh:test:example.com"},
322            "proof": [{"type": "DataIntegrityProof"}]
323        });
324
325        let hash = compute_entry_hash(&entry).unwrap();
326        assert!(hash.starts_with('z'));
327
328        // Proof should not affect hash
329        let mut entry2 = entry.clone();
330        entry2["proof"] = json!([{"type": "DataIntegrityProof", "extra": "data"}]);
331        let hash2 = compute_entry_hash(&entry2).unwrap();
332        assert_eq!(hash, hash2);
333    }
334
335    #[test]
336    fn test_compute_entry_hash_not_object() {
337        let entry = json!("not an object");
338        assert!(compute_entry_hash(&entry).is_err());
339    }
340
341    #[test]
342    fn test_parse_version_id() {
343        let (num, hash) = parse_version_id("1-QmTest").unwrap();
344        assert_eq!(num, 1);
345        assert_eq!(hash, "QmTest");
346
347        let (num, hash) = parse_version_id("42-zAbcdef").unwrap();
348        assert_eq!(num, 42);
349        assert_eq!(hash, "zAbcdef");
350    }
351
352    #[test]
353    fn test_parse_version_id_invalid() {
354        // No dash
355        assert!(parse_version_id("1QmTest").is_err());
356        // Empty hash
357        assert!(parse_version_id("1-").is_err());
358        // Non-numeric version
359        assert!(parse_version_id("abc-QmTest").is_err());
360    }
361
362    #[test]
363    fn test_verify_version_id_valid() {
364        // Create an entry, compute its hash, then set the versionId
365        let mut entry = json!({
366            "versionTime": "2025-04-29T17:15:59Z",
367            "parameters": {"method": "did:webvh:1.0"},
368            "state": {"id": "did:webvh:test:example.com"}
369        });
370
371        // Compute the actual hash (versionId is excluded from hash)
372        let hash = compute_entry_hash(&entry).unwrap();
373        entry["versionId"] = serde_json::Value::String(format!("1-{}", hash));
374
375        // Should verify successfully
376        let result = verify_version_id(&entry, 1, 1);
377        assert!(result.is_ok());
378        assert_eq!(result.unwrap(), 1);
379    }
380
381    #[test]
382    fn test_verify_version_id_wrong_number() {
383        let entry = json!({
384            "versionId": "2-zSomeHash",
385            "parameters": {},
386            "state": {}
387        });
388
389        let result = verify_version_id(&entry, 1, 1);
390        assert!(matches!(
391            result,
392            Err(WebVHDIDError::InvalidVersionId { .. })
393        ));
394    }
395
396    #[test]
397    fn test_verify_version_id_hash_mismatch() {
398        let entry = json!({
399            "versionId": "1-zWrongHash",
400            "versionTime": "2025-04-29T17:15:59Z",
401            "parameters": {},
402            "state": {}
403        });
404
405        let result = verify_version_id(&entry, 1, 1);
406        assert!(matches!(
407            result,
408            Err(WebVHDIDError::EntryHashVerificationFailed { .. })
409        ));
410    }
411
412    #[test]
413    fn test_verify_scid_roundtrip() {
414        // Build a preliminary entry with {SCID} placeholders
415        let preliminary = json!({
416            "versionId": "{SCID}",
417            "versionTime": "2025-04-29T17:15:59Z",
418            "parameters": {
419                "method": "did:webvh:1.0",
420                "scid": "{SCID}",
421                "updateKeys": ["z6MkTestKey"]
422            },
423            "state": {
424                "@context": ["https://www.w3.org/ns/did/v1"],
425                "id": "did:webvh:{SCID}:example.com"
426            }
427        });
428
429        // Compute SCID
430        let canonical = jcs::canonicalize(&preliminary);
431        let scid = compute_multihash_base58btc(canonical.as_bytes());
432
433        // Replace {SCID} with actual value
434        let json_str = serde_json::to_string(&preliminary).unwrap();
435        let replaced = json_str.replace("{SCID}", &scid);
436        let genesis: serde_json::Value = serde_json::from_str(&replaced).unwrap();
437
438        // Verification should succeed
439        assert!(verify_scid(&scid, &genesis).is_ok());
440    }
441
442    #[test]
443    fn test_verify_scid_mismatch() {
444        let entry = json!({
445            "versionId": "zWrongSCID",
446            "versionTime": "2025-04-29T17:15:59Z",
447            "parameters": {
448                "method": "did:webvh:1.0",
449                "scid": "zWrongSCID",
450                "updateKeys": ["z6MkTestKey"]
451            },
452            "state": {
453                "@context": ["https://www.w3.org/ns/did/v1"],
454                "id": "did:webvh:zWrongSCID:example.com"
455            }
456        });
457
458        assert!(matches!(
459            verify_scid("zWrongSCID", &entry),
460            Err(WebVHDIDError::SCIDVerificationFailed)
461        ));
462    }
463
464    #[test]
465    fn test_entry_hash_deterministic() {
466        let entry = json!({
467            "versionId": "1-test",
468            "versionTime": "2025-01-01T00:00:00Z",
469            "parameters": {"method": "did:webvh:1.0"},
470            "state": {"id": "test"}
471        });
472
473        let hash1 = compute_entry_hash(&entry).unwrap();
474        let hash2 = compute_entry_hash(&entry).unwrap();
475        assert_eq!(hash1, hash2);
476    }
477}