Skip to main content

clawdentity_core/identity/
did.rs

1use 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
62/// TODO(clawdentity): document `normalize_did_authority`.
63pub 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
76/// TODO(clawdentity): document `did_authority_from_url`.
77pub 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
86/// TODO(clawdentity): document `make_did`.
87pub 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
96/// TODO(clawdentity): document `make_human_did`.
97pub fn make_human_did(authority: &str, ulid: &str) -> Result<String> {
98    make_did(authority, DidEntity::Human, ulid)
99}
100
101/// TODO(clawdentity): document `make_agent_did`.
102pub fn make_agent_did(authority: &str, ulid: &str) -> Result<String> {
103    make_did(authority, DidEntity::Agent, ulid)
104}
105
106/// TODO(clawdentity): document `new_human_did`.
107pub fn new_human_did(authority: &str) -> Result<String> {
108    make_human_did(authority, &Ulid::new().to_string())
109}
110
111/// TODO(clawdentity): document `new_agent_did`.
112pub fn new_agent_did(authority: &str) -> Result<String> {
113    make_agent_did(authority, &Ulid::new().to_string())
114}
115
116/// TODO(clawdentity): document `parse_did`.
117pub 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
148/// TODO(clawdentity): document `parse_agent_did`.
149pub 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
159/// TODO(clawdentity): document `parse_human_did`.
160pub 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}