Skip to main content

chio_did/
lib.rs

1//! Self-certifying `did:chio` identifiers and DID Document resolution.
2//!
3//! `did:chio` is intentionally narrow: the method-specific identifier is the
4//! lowercase hex form of an Ed25519 public key already used by Chio agents and
5//! operators. Basic resolution is self-certifying and requires no registry
6//! lookup. Optional receipt-log service endpoints can be attached by the
7//! resolving environment.
8
9#![forbid(unsafe_code)]
10#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
11
12#[cfg(feature = "fuzz")]
13pub mod fuzz;
14
15use std::fmt;
16use std::str::FromStr;
17
18use chio_core_types::{crypto::SigningAlgorithm, PublicKey};
19use serde::{Deserialize, Serialize};
20use url::Url;
21
22const DID_CHIO_PREFIX: &str = "did:chio:";
23const DID_CONTEXT_V1: &str = "https://www.w3.org/ns/did/v1";
24const ED25519_VERIFICATION_KEY_2020: &str = "Ed25519VerificationKey2020";
25const ED25519_PUB_MULTICODEC_PREFIX: [u8; 2] = [0xed, 0x01];
26pub const RECEIPT_LOG_SERVICE_TYPE: &str = "ChioReceiptLogService";
27pub const PASSPORT_STATUS_SERVICE_TYPE: &str = "ChioPassportStatusService";
28
29#[derive(Debug, thiserror::Error)]
30pub enum DidError {
31    #[error("did:chio identifiers must start with did:chio:")]
32    InvalidPrefix,
33
34    #[error("did:chio method-specific identifier must be exactly 64 lowercase hex characters")]
35    InvalidMethodSpecificId,
36
37    #[error("invalid did:chio public key: {0}")]
38    InvalidPublicKey(String),
39
40    #[error("did:chio only supports ed25519 public keys, got {0}")]
41    UnsupportedKeyAlgorithm(String),
42
43    #[error("invalid service endpoint URL: {0}")]
44    InvalidServiceEndpoint(String),
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct DidChio {
49    public_key: PublicKey,
50}
51
52impl DidChio {
53    pub fn from_public_key(public_key: PublicKey) -> Result<Self, DidError> {
54        if public_key.algorithm() != SigningAlgorithm::Ed25519 {
55            return Err(DidError::UnsupportedKeyAlgorithm(
56                public_key.algorithm().prefix().to_string(),
57            ));
58        }
59        Ok(Self { public_key })
60    }
61
62    pub fn try_from_public_key(public_key: PublicKey) -> Result<Self, DidError> {
63        Self::from_public_key(public_key)
64    }
65
66    #[must_use]
67    pub fn public_key(&self) -> &PublicKey {
68        &self.public_key
69    }
70
71    #[must_use]
72    pub fn as_str(&self) -> String {
73        format!("{DID_CHIO_PREFIX}{}", self.public_key.to_hex())
74    }
75
76    #[must_use]
77    pub fn verification_method_id(&self) -> String {
78        format!("{}#key-1", self.as_str())
79    }
80
81    #[must_use]
82    pub fn public_key_multibase(&self) -> String {
83        let mut value = Vec::with_capacity(ED25519_PUB_MULTICODEC_PREFIX.len() + 32);
84        value.extend_from_slice(&ED25519_PUB_MULTICODEC_PREFIX);
85        value.extend_from_slice(self.public_key.as_bytes());
86        format!("z{}", bs58::encode(value).into_string())
87    }
88
89    #[must_use]
90    pub fn resolve(&self) -> DidDocument {
91        self.resolve_with_options(&ResolveOptions::default())
92    }
93
94    #[must_use]
95    pub fn resolve_with_options(&self, options: &ResolveOptions) -> DidDocument {
96        let did = self.as_str();
97        let verification_method_id = self.verification_method_id();
98        DidDocument {
99            context: DID_CONTEXT_V1.to_string(),
100            id: did.clone(),
101            verification_method: vec![DidVerificationMethod {
102                id: verification_method_id.clone(),
103                verification_type: ED25519_VERIFICATION_KEY_2020.to_string(),
104                controller: did.clone(),
105                public_key_multibase: self.public_key_multibase(),
106            }],
107            authentication: vec![verification_method_id.clone()],
108            assertion_method: vec![verification_method_id],
109            service: options.services.clone(),
110        }
111    }
112}
113
114impl fmt::Display for DidChio {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.as_str())
117    }
118}
119
120impl TryFrom<PublicKey> for DidChio {
121    type Error = DidError;
122
123    fn try_from(value: PublicKey) -> Result<Self, Self::Error> {
124        Self::from_public_key(value)
125    }
126}
127
128impl FromStr for DidChio {
129    type Err = DidError;
130
131    fn from_str(value: &str) -> Result<Self, Self::Err> {
132        let suffix = value
133            .strip_prefix(DID_CHIO_PREFIX)
134            .ok_or(DidError::InvalidPrefix)?;
135        if suffix.len() != 64 || !suffix.bytes().all(is_lower_hex_byte) {
136            return Err(DidError::InvalidMethodSpecificId);
137        }
138        let public_key = PublicKey::from_hex(suffix)
139            .map_err(|error| DidError::InvalidPublicKey(error.to_string()))?;
140        Self::from_public_key(public_key)
141    }
142}
143
144fn is_lower_hex_byte(byte: u8) -> bool {
145    byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct ResolveOptions {
150    services: Vec<DidService>,
151}
152
153impl ResolveOptions {
154    #[must_use]
155    pub fn with_service(mut self, service: DidService) -> Self {
156        self.services.push(service);
157        self
158    }
159
160    #[must_use]
161    pub fn services(&self) -> &[DidService] {
162        &self.services
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct DidDocument {
168    #[serde(rename = "@context")]
169    pub context: String,
170    pub id: String,
171    #[serde(rename = "verificationMethod")]
172    pub verification_method: Vec<DidVerificationMethod>,
173    pub authentication: Vec<String>,
174    #[serde(rename = "assertionMethod")]
175    pub assertion_method: Vec<String>,
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub service: Vec<DidService>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct DidVerificationMethod {
182    pub id: String,
183    #[serde(rename = "type")]
184    pub verification_type: String,
185    pub controller: String,
186    #[serde(rename = "publicKeyMultibase")]
187    pub public_key_multibase: String,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct DidService {
192    pub id: String,
193    #[serde(rename = "type")]
194    pub service_type: String,
195    #[serde(rename = "serviceEndpoint")]
196    pub service_endpoint: String,
197}
198
199impl DidService {
200    pub fn new(
201        id: impl Into<String>,
202        service_type: impl Into<String>,
203        service_endpoint: impl Into<String>,
204    ) -> Result<Self, DidError> {
205        let service_endpoint = service_endpoint.into();
206        let parsed = Url::parse(&service_endpoint)
207            .map_err(|error| DidError::InvalidServiceEndpoint(error.to_string()))?;
208        if parsed.scheme() != "https" {
209            return Err(DidError::InvalidServiceEndpoint(
210                "service endpoint must use https".to_string(),
211            ));
212        }
213        Ok(Self {
214            id: id.into(),
215            service_type: service_type.into(),
216            service_endpoint,
217        })
218    }
219
220    pub fn receipt_log(
221        did: &DidChio,
222        ordinal: usize,
223        service_endpoint: impl Into<String>,
224    ) -> Result<Self, DidError> {
225        let fragment = service_fragment("receipt-log", ordinal);
226        Self::new(
227            format!("{}#{fragment}", did.as_str()),
228            RECEIPT_LOG_SERVICE_TYPE,
229            service_endpoint,
230        )
231    }
232
233    pub fn passport_status(
234        did: &DidChio,
235        ordinal: usize,
236        service_endpoint: impl Into<String>,
237    ) -> Result<Self, DidError> {
238        let fragment = service_fragment("passport-status", ordinal);
239        Self::new(
240            format!("{}#{fragment}", did.as_str()),
241            PASSPORT_STATUS_SERVICE_TYPE,
242            service_endpoint,
243        )
244    }
245}
246
247fn service_fragment(base: &str, ordinal: usize) -> String {
248    if ordinal == 0 {
249        base.to_string()
250    } else {
251        format!("{base}-{}", ordinal + 1)
252    }
253}
254
255pub fn resolve_did_arc(value: &str, options: &ResolveOptions) -> Result<DidDocument, DidError> {
256    DidChio::from_str(value).map(|did| did.resolve_with_options(options))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use chio_core_types::Keypair;
263
264    fn fixed_did() -> DidChio {
265        let seed = [7u8; 32];
266        DidChio::from_public_key(Keypair::from_seed(&seed).public_key()).expect("ed25519 key")
267    }
268
269    #[test]
270    fn parses_canonical_did_chio_identifier() {
271        let canonical = fixed_did().to_string();
272        let parsed = DidChio::from_str(&canonical).expect("did");
273        assert_eq!(parsed.to_string(), canonical);
274    }
275
276    #[test]
277    fn rejects_invalid_method_specific_id() {
278        let error = DidChio::from_str("did:chio:not-hex").expect_err("invalid did");
279        assert!(matches!(error, DidError::InvalidMethodSpecificId));
280    }
281
282    #[test]
283    fn rejects_uppercase_method_specific_id() {
284        let canonical = fixed_did().to_string();
285        let uppercase_suffix = canonical[DID_CHIO_PREFIX.len()..].to_uppercase();
286        let did = format!("{DID_CHIO_PREFIX}{uppercase_suffix}");
287
288        let error = DidChio::from_str(&did).expect_err("uppercase hex must reject");
289        assert!(matches!(error, DidError::InvalidMethodSpecificId));
290    }
291
292    #[test]
293    fn rejects_non_ed25519_public_keys() {
294        let p256_generator = PublicKey::from_hex(
295            "p256:046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c2964fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5",
296        )
297        .expect("valid p256 public key");
298
299        let error =
300            DidChio::try_from_public_key(p256_generator).expect_err("unsupported algorithm");
301        assert!(matches!(error, DidError::UnsupportedKeyAlgorithm(_)));
302    }
303
304    #[test]
305    fn resolves_document_with_ed25519_multibase_key() {
306        let did = fixed_did();
307        let document = did.resolve();
308
309        assert_eq!(document.id, did.to_string());
310        assert_eq!(document.authentication, vec![did.verification_method_id()]);
311        assert_eq!(
312            document.assertion_method,
313            vec![did.verification_method_id()]
314        );
315
316        let encoded = document.verification_method[0]
317            .public_key_multibase
318            .strip_prefix('z')
319            .expect("base58btc prefix");
320        let decoded = bs58::decode(encoded).into_vec().expect("decode multibase");
321        assert_eq!(&decoded[..2], &ED25519_PUB_MULTICODEC_PREFIX);
322        assert_eq!(&decoded[2..], did.public_key().as_bytes());
323    }
324
325    #[test]
326    fn service_fragment_uses_unsuffixed_first_entry_and_one_based_suffixes() {
327        assert_eq!(service_fragment("receipt-log", 0), "receipt-log");
328        assert_eq!(service_fragment("receipt-log", 1), "receipt-log-2");
329        assert_eq!(service_fragment("passport-status", 9), "passport-status-10");
330    }
331
332    #[test]
333    fn attaches_validated_receipt_log_services_deterministically() {
334        let did = fixed_did();
335        let options = ResolveOptions::default()
336            .with_service(
337                DidService::receipt_log(&did, 0, "https://trust.example.com/v1/receipts")
338                    .expect("receipt log"),
339            )
340            .with_service(
341                DidService::receipt_log(&did, 1, "https://mirror.example.com/v1/receipts")
342                    .expect("receipt log"),
343            );
344
345        let document = did.resolve_with_options(&options);
346        assert_eq!(document.service.len(), 2);
347        assert_eq!(document.service[0].id, format!("{did}#receipt-log"));
348        assert_eq!(document.service[1].id, format!("{did}#receipt-log-2"));
349        assert_eq!(document.service[0].service_type, RECEIPT_LOG_SERVICE_TYPE);
350    }
351
352    #[test]
353    fn rejects_invalid_receipt_log_service_endpoint() {
354        let did = fixed_did();
355        let error =
356            DidService::receipt_log(&did, 0, "not-a-url").expect_err("invalid service endpoint");
357        assert!(matches!(error, DidError::InvalidServiceEndpoint(_)));
358    }
359
360    #[test]
361    fn rejects_non_https_service_endpoints() {
362        let did = fixed_did();
363
364        for endpoint in [
365            "http://trust.example.com/v1/receipts",
366            "file:///var/lib/chio/receipts.sqlite3",
367            "ftp://trust.example.com/receipts",
368        ] {
369            let error = DidService::receipt_log(&did, 0, endpoint)
370                .expect_err("non-https service endpoint must reject");
371            assert!(
372                matches!(error, DidError::InvalidServiceEndpoint(_)),
373                "endpoint {endpoint:?} returned {error:?}"
374            );
375        }
376    }
377}