1#![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}