clawdentity_core/identity/
did.rs1use std::net::IpAddr;
2
3use ulid::Ulid;
4
5use crate::error::{CoreError, Result};
6
7const DID_SCHEME: &str = "did";
8const DID_METHOD: &str = "cdi";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DidEntity {
12 Human,
13 Agent,
14}
15
16impl DidEntity {
17 fn as_str(self) -> &'static str {
18 match self {
19 Self::Human => "human",
20 Self::Agent => "agent",
21 }
22 }
23
24 fn from_str(value: &str) -> Option<Self> {
25 match value {
26 "human" => Some(Self::Human),
27 "agent" => Some(Self::Agent),
28 _ => None,
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ParsedDid {
35 pub method: String,
36 pub authority: String,
37 pub entity: DidEntity,
38 pub ulid: String,
39}
40
41fn is_valid_dns_label(value: &str) -> bool {
42 !value.is_empty()
43 && value.len() <= 63
44 && !value.starts_with('-')
45 && !value.ends_with('-')
46 && value.chars().all(|character| {
47 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
48 })
49}
50
51fn is_valid_dns_authority(value: &str) -> bool {
52 let labels: Vec<&str> = value.split('.').collect();
53 labels.len() >= 2 && labels.into_iter().all(is_valid_dns_label)
54}
55
56fn validate_ulid(value: &str, context: &str) -> Result<()> {
57 Ulid::from_string(value)
58 .map_err(|_| CoreError::InvalidInput(format!("{context} must be a valid ULID")))?;
59 Ok(())
60}
61
62pub fn normalize_did_authority(value: &str) -> Result<String> {
64 let authority = value.trim().to_ascii_lowercase();
65 let valid = authority == "localhost"
66 || authority.parse::<IpAddr>().is_ok()
67 || is_valid_dns_authority(&authority);
68 if !valid {
69 return Err(CoreError::InvalidInput(
70 "DID authority must be a valid hostname".to_string(),
71 ));
72 }
73 Ok(authority)
74}
75
76pub fn did_authority_from_url(url: &str, field_name: &str) -> Result<String> {
78 let parsed = url::Url::parse(url)
79 .map_err(|_| CoreError::InvalidInput(format!("{field_name} must be a valid URL")))?;
80 let host = parsed
81 .host_str()
82 .ok_or_else(|| CoreError::InvalidInput(format!("{field_name} must include a host")))?;
83 normalize_did_authority(host)
84}
85
86pub fn make_did(authority: &str, entity: DidEntity, ulid: &str) -> Result<String> {
88 let authority = normalize_did_authority(authority)?;
89 validate_ulid(ulid, "DID ulid")?;
90 Ok(format!(
91 "{DID_SCHEME}:{DID_METHOD}:{authority}:{}:{ulid}",
92 entity.as_str()
93 ))
94}
95
96pub fn make_human_did(authority: &str, ulid: &str) -> Result<String> {
98 make_did(authority, DidEntity::Human, ulid)
99}
100
101pub fn make_agent_did(authority: &str, ulid: &str) -> Result<String> {
103 make_did(authority, DidEntity::Agent, ulid)
104}
105
106pub fn new_human_did(authority: &str) -> Result<String> {
108 make_human_did(authority, &Ulid::new().to_string())
109}
110
111pub fn new_agent_did(authority: &str) -> Result<String> {
113 make_agent_did(authority, &Ulid::new().to_string())
114}
115
116pub fn parse_did(value: &str) -> Result<ParsedDid> {
118 let parts: Vec<&str> = value.split(':').collect();
119 if parts.len() != 5 {
120 return Err(CoreError::InvalidInput(format!("Invalid DID: {value}")));
121 }
122
123 let [scheme, method, raw_authority, raw_entity, raw_ulid] =
124 [parts[0], parts[1], parts[2], parts[3], parts[4]];
125 if scheme != DID_SCHEME || method != DID_METHOD {
126 return Err(CoreError::InvalidInput(format!("Invalid DID: {value}")));
127 }
128
129 let authority = normalize_did_authority(raw_authority)
130 .map_err(|_| CoreError::InvalidInput(format!("Invalid DID: {value}")))?;
131 if authority != raw_authority {
132 return Err(CoreError::InvalidInput(format!("Invalid DID: {value}")));
133 }
134
135 let entity = DidEntity::from_str(raw_entity)
136 .ok_or_else(|| CoreError::InvalidInput(format!("Invalid DID: {value}")))?;
137 validate_ulid(raw_ulid, "DID ulid")
138 .map_err(|_| CoreError::InvalidInput(format!("Invalid DID: {value}")))?;
139
140 Ok(ParsedDid {
141 method: DID_METHOD.to_string(),
142 authority,
143 entity,
144 ulid: raw_ulid.to_string(),
145 })
146}
147
148pub fn parse_agent_did(value: &str) -> Result<ParsedDid> {
150 let did = parse_did(value)?;
151 if did.entity != DidEntity::Agent {
152 return Err(CoreError::InvalidInput(format!(
153 "Invalid agent DID: {value}"
154 )));
155 }
156 Ok(did)
157}
158
159pub fn parse_human_did(value: &str) -> Result<ParsedDid> {
161 let did = parse_did(value)?;
162 if did.entity != DidEntity::Human {
163 return Err(CoreError::InvalidInput(format!(
164 "Invalid human DID: {value}"
165 )));
166 }
167 Ok(did)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::{
173 DidEntity, did_authority_from_url, make_agent_did, make_human_did, parse_agent_did,
174 parse_did, parse_human_did,
175 };
176
177 const AUTHORITY: &str = "registry.clawdentity.com";
178 const AGENT_ULID: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
179 const HUMAN_ULID: &str = "01HF7YAT31JZHSMW1CG6Q6MHB7";
180
181 #[test]
182 fn make_human_did_uses_expected_format() {
183 let did = make_human_did(AUTHORITY, HUMAN_ULID).expect("did");
184 assert_eq!(
185 did,
186 "did:cdi:registry.clawdentity.com:human:01HF7YAT31JZHSMW1CG6Q6MHB7"
187 );
188 }
189
190 #[test]
191 fn make_agent_did_uses_expected_format() {
192 let did = make_agent_did(AUTHORITY, AGENT_ULID).expect("did");
193 assert_eq!(
194 did,
195 "did:cdi:registry.clawdentity.com:agent:01ARZ3NDEKTSV4RRFFQ69G5FAV"
196 );
197 }
198
199 #[test]
200 fn parse_did_accepts_expected_format() {
201 let did = "did:cdi:registry.clawdentity.com:agent:01ARZ3NDEKTSV4RRFFQ69G5FAV";
202 let parsed = parse_did(did).expect("did should parse");
203 assert_eq!(parsed.method, "cdi");
204 assert_eq!(parsed.authority, AUTHORITY);
205 assert_eq!(parsed.entity, DidEntity::Agent);
206 assert_eq!(parsed.ulid, AGENT_ULID);
207 }
208
209 #[test]
210 fn parse_did_rejects_invalid_values() {
211 assert!(parse_did("did:claw:agent:not-ulid").is_err());
212 assert!(parse_did("did:cdi:bad_authority:agent:01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err());
213 assert!(
214 parse_did("did:cdi:registry.clawdentity.com:robot:01ARZ3NDEKTSV4RRFFQ69G5FAV").is_err()
215 );
216 assert!(parse_did("did:cdi:registry.clawdentity.com:agent:not-ulid").is_err());
217 }
218
219 #[test]
220 fn parse_entity_specific_helpers_enforce_entity() {
221 let agent_did = "did:cdi:registry.clawdentity.com:agent:01ARZ3NDEKTSV4RRFFQ69G5FAV";
222 let human_did = "did:cdi:registry.clawdentity.com:human:01HF7YAT31JZHSMW1CG6Q6MHB7";
223 assert!(parse_agent_did(agent_did).is_ok());
224 assert!(parse_human_did(human_did).is_ok());
225 assert!(parse_agent_did(human_did).is_err());
226 assert!(parse_human_did(agent_did).is_err());
227 }
228
229 #[test]
230 fn derives_authority_from_url() {
231 let authority = did_authority_from_url("https://registry.clawdentity.com/v1/keys", "iss")
232 .expect("authority");
233 assert_eq!(authority, AUTHORITY);
234 }
235}