Skip to main content

ma_core/
did.rs

1use cid::{Cid, Version};
2use libp2p_identity::{PeerId, PublicKey};
3use nanoid::nanoid;
4
5use crate::error::{MaError, MaResult as Result};
6
7pub const DID_PREFIX: &str = "did:ma:";
8const LIBP2P_KEY_CODEC: u64 = 0x72;
9
10/// A parsed `did:ma:` identifier.
11///
12/// Without a fragment this is a bare DID: `did:ma:<ipns>`.
13/// With a fragment it becomes a DID URL: `did:ma:<ipns>#<fragment>`.
14///
15/// # Examples
16///
17/// ```
18/// use ma_core::Did;
19///
20/// // Bare DID (identity)
21/// let id = Did::new_identity("k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr").unwrap();
22/// assert!(id.is_bare());
23/// assert_eq!(id.base_id(), "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr");
24///
25/// // DID URL with auto-generated fragment
26/// let url = Did::new_url("k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr", None::<String>).unwrap();
27/// assert!(url.is_url());
28///
29/// // Parse an incoming DID URL
30/// let parsed = Did::try_from("did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr#lobby").unwrap();
31/// assert_eq!(parsed.fragment.as_deref(), Some("lobby"));
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub struct Did {
35    pub ipns: String,
36    /// Local atom/inbox name (for example an avatar inbox in a world).
37    /// In practice this often matches a Kubo key name, but this coupling is loose.
38    pub fragment: Option<String>,
39}
40
41impl Did {
42    /// Create a bare DID (`did:ma:<ipns>`) with no fragment.
43    pub fn new_identity(ipns: impl Into<String>) -> Result<Self> {
44        let ipns = ipns.into();
45        validate_identifier(&ipns)?;
46        Ok(Self {
47            ipns,
48            fragment: None,
49        })
50    }
51
52    /// Create a DID URL (`did:ma:<ipns>#<fragment>`).
53    /// If `fragment` is `None`, a nanoid is generated automatically.
54    /// Provided fragments are validated as nanoids (`[A-Za-z0-9_-]+`).
55    pub fn new_url(ipns: impl Into<String>, fragment: Option<impl Into<String>>) -> Result<Self> {
56        let frag = match fragment {
57            Some(f) => f.into(),
58            None => nanoid!(),
59        };
60        let ipns = ipns.into();
61        validate_identifier(&ipns)?;
62        validate_fragment(&frag)?;
63        Ok(Self {
64            ipns,
65            fragment: Some(frag),
66        })
67    }
68
69    #[must_use]
70    pub fn base_id(&self) -> String {
71        format!("{DID_PREFIX}{}", self.ipns)
72    }
73
74    pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
75        Self::new_url(self.ipns.clone(), Some(fragment))
76    }
77
78    #[must_use]
79    pub fn id(&self) -> String {
80        match &self.fragment {
81            Some(fragment) => format!("{}#{fragment}", self.base_id()),
82            None => self.base_id(),
83        }
84    }
85
86    pub fn parse(input: &str) -> Result<(String, Option<String>)> {
87        if input.is_empty() {
88            return Err(MaError::EmptyDid);
89        }
90
91        let stripped = input
92            .strip_prefix(DID_PREFIX)
93            .ok_or(MaError::InvalidDidPrefix)?;
94
95        let parts: Vec<_> = stripped.split('#').collect();
96        match parts.as_slice() {
97            [] | [""] => Err(MaError::MissingIdentifier),
98            [_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
99            [identifier] => {
100                validate_identifier(identifier)?;
101                Ok(((*identifier).to_string(), None))
102            }
103            [identifier, fragment] => {
104                validate_identifier(identifier)?;
105                validate_fragment(fragment)?;
106                Ok(((*identifier).to_string(), Some((*fragment).to_string())))
107            }
108            _ => Err(MaError::InvalidDidFormat),
109        }
110    }
111
112    pub fn validate(input: &str) -> Result<()> {
113        Self::parse(input).map(|_| ())
114    }
115
116    /// Validate that `input` is a DID URL. The fragment is optional.
117    pub fn validate_url(input: &str) -> Result<()> {
118        Self::validate(input)
119    }
120
121    /// Validate that `input` is a DID URL identifying a fragment resource.
122    pub fn validate_resource(input: &str) -> Result<()> {
123        match Self::parse(input)? {
124            (_, Some(_)) => Ok(()),
125            (_, None) => Err(MaError::MissingFragment),
126        }
127    }
128
129    /// Validate that `input` is a bare DID identity (no fragment).
130    pub fn validate_identity(input: &str) -> Result<()> {
131        match Self::parse(input)? {
132            (_, None) => Ok(()),
133            (_, Some(_)) => Err(MaError::UnexpectedFragment),
134        }
135    }
136
137    /// True when this DID has a fragment (is a DID URL).
138    #[must_use]
139    pub fn is_url(&self) -> bool {
140        self.fragment.is_some()
141    }
142
143    /// True when this DID has no fragment (bare DID).
144    #[must_use]
145    pub fn is_bare(&self) -> bool {
146        self.fragment.is_none()
147    }
148}
149
150impl TryFrom<&str> for Did {
151    type Error = MaError;
152
153    /// Parse any valid DID URL.  A bare DID (no fragment) is a valid DID URL
154    /// per W3C DID Core §3.2 — the fragment is optional.
155    fn try_from(value: &str) -> Result<Self> {
156        let (ipns, fragment) = Self::parse(value)?;
157        Ok(Self { ipns, fragment })
158    }
159}
160
161fn validate_identifier(input: &str) -> Result<()> {
162    if input.is_empty() {
163        return Err(MaError::MissingIdentifier);
164    }
165
166    let cid = Cid::try_from(input).map_err(|_| MaError::InvalidIdentifier)?;
167    if cid.version() != Version::V1
168        || cid.codec() != LIBP2P_KEY_CODEC
169        || multibase::encode(multibase::Base::Base36Lower, cid.to_bytes()) != input
170    {
171        return Err(MaError::InvalidIdentifier);
172    }
173
174    let peer_id =
175        PeerId::from_multihash(cid.hash().to_owned()).map_err(|_| MaError::InvalidIdentifier)?;
176    let public_key = PublicKey::try_decode_protobuf(peer_id.as_ref().digest())
177        .map_err(|_| MaError::InvalidIdentifier)?;
178    if public_key.try_into_ed25519().is_err() {
179        return Err(MaError::InvalidIdentifier);
180    }
181    Ok(())
182}
183
184fn validate_fragment(input: &str) -> Result<()> {
185    if input.is_empty()
186        || !input
187            .bytes()
188            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
189    {
190        return Err(MaError::InvalidFragment(input.to_string()));
191    }
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    const IDENTIFIER: &str = "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
200    const BARE: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
201    const URL: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr#lobby";
202
203    #[test]
204    fn is_url_with_fragment() {
205        let did = Did::try_from(URL).unwrap();
206        assert!(did.is_url());
207        assert!(!did.is_bare());
208    }
209
210    #[test]
211    fn is_bare_without_fragment() {
212        let did = Did::try_from(BARE).unwrap();
213        assert!(did.is_bare());
214        assert!(!did.is_url());
215    }
216
217    #[test]
218    fn validate_url_accepts_fragment() {
219        assert!(Did::validate_url(URL).is_ok());
220    }
221
222    #[test]
223    fn validate_url_accepts_bare() {
224        assert!(Did::validate_url(BARE).is_ok());
225    }
226
227    #[test]
228    fn validate_resource_requires_fragment() {
229        assert!(Did::validate_resource(URL).is_ok());
230        assert!(Did::validate_resource(BARE).is_err());
231    }
232
233    #[test]
234    fn validate_identity_accepts_bare() {
235        assert!(Did::validate_identity(BARE).is_ok());
236    }
237
238    #[test]
239    fn validate_identity_rejects_fragment() {
240        assert!(Did::validate_identity(URL).is_err());
241    }
242
243    #[test]
244    fn new_url_none_generates_nanoid() {
245        let url = Did::new_url(IDENTIFIER, None::<String>).unwrap();
246        assert!(url.is_url());
247        assert!(!url.fragment.unwrap().is_empty());
248    }
249
250    #[test]
251    fn new_url_accepts_nanoid_fragment() {
252        let url = Did::new_url(IDENTIFIER, Some("bahner")).unwrap();
253        assert_eq!(url.fragment.as_deref(), Some("bahner"));
254    }
255
256    #[test]
257    fn new_url_rejects_invalid_chars() {
258        assert!(Did::new_url(IDENTIFIER, Some("has space")).is_err());
259        assert!(Did::new_url(IDENTIFIER, Some("has.dot")).is_err());
260        assert!(Did::new_url(IDENTIFIER, Some("")).is_err());
261    }
262
263    #[test]
264    fn try_from_accepts_valid_fragment() {
265        let did = Did::try_from(URL).unwrap();
266        assert_eq!(did.fragment.as_deref(), Some("lobby"));
267    }
268
269    #[test]
270    fn rejects_non_ipns_identifier() {
271        assert!(Did::validate("did:ma:k51qzi5uqu5abc").is_err());
272    }
273
274    #[test]
275    fn rejects_non_canonical_ipns_base() {
276        let cid = Cid::try_from(IDENTIFIER).expect("valid CID");
277        let base32 = multibase::encode(multibase::Base::Base32Lower, cid.to_bytes());
278        assert!(Did::validate(&format!("did:ma:{base32}")).is_err());
279    }
280}