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 (has a fragment).
117    pub fn validate_url(input: &str) -> Result<()> {
118        match Self::parse(input)? {
119            (_, Some(_)) => Ok(()),
120            (_, None) => Err(MaError::MissingFragment),
121        }
122    }
123
124    /// Validate that `input` is a bare DID identity (no fragment).
125    pub fn validate_identity(input: &str) -> Result<()> {
126        match Self::parse(input)? {
127            (_, None) => Ok(()),
128            (_, Some(_)) => Err(MaError::UnexpectedFragment),
129        }
130    }
131
132    /// True when this DID has a fragment (is a DID URL).
133    #[must_use]
134    pub fn is_url(&self) -> bool {
135        self.fragment.is_some()
136    }
137
138    /// True when this DID has no fragment (bare DID).
139    #[must_use]
140    pub fn is_bare(&self) -> bool {
141        self.fragment.is_none()
142    }
143}
144
145impl TryFrom<&str> for Did {
146    type Error = MaError;
147
148    /// Parse any valid DID URL.  A bare DID (no fragment) is a valid DID URL
149    /// per W3C DID Core §3.2 — the fragment is optional.
150    fn try_from(value: &str) -> Result<Self> {
151        let (ipns, fragment) = Self::parse(value)?;
152        Ok(Self { ipns, fragment })
153    }
154}
155
156fn validate_identifier(input: &str) -> Result<()> {
157    if input.is_empty() {
158        return Err(MaError::MissingIdentifier);
159    }
160
161    let cid = Cid::try_from(input).map_err(|_| MaError::InvalidIdentifier)?;
162    if cid.version() != Version::V1
163        || cid.codec() != LIBP2P_KEY_CODEC
164        || multibase::encode(multibase::Base::Base36Lower, cid.to_bytes()) != input
165    {
166        return Err(MaError::InvalidIdentifier);
167    }
168
169    let peer_id =
170        PeerId::from_multihash(cid.hash().to_owned()).map_err(|_| MaError::InvalidIdentifier)?;
171    let public_key = PublicKey::try_decode_protobuf(peer_id.as_ref().digest())
172        .map_err(|_| MaError::InvalidIdentifier)?;
173    if public_key.try_into_ed25519().is_err() {
174        return Err(MaError::InvalidIdentifier);
175    }
176    Ok(())
177}
178
179fn validate_fragment(input: &str) -> Result<()> {
180    if input.is_empty()
181        || !input
182            .bytes()
183            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
184    {
185        return Err(MaError::InvalidFragment(input.to_string()));
186    }
187    Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    const IDENTIFIER: &str = "k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
195    const BARE: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr";
196    const URL: &str = "did:ma:k51qzi5uqu5dj9807pbuod1pplf0vxh8m4lfy3ewl9qbm2s8dsf9ugdf9gedhr#lobby";
197
198    #[test]
199    fn is_url_with_fragment() {
200        let did = Did::try_from(URL).unwrap();
201        assert!(did.is_url());
202        assert!(!did.is_bare());
203    }
204
205    #[test]
206    fn is_bare_without_fragment() {
207        let did = Did::try_from(BARE).unwrap();
208        assert!(did.is_bare());
209        assert!(!did.is_url());
210    }
211
212    #[test]
213    fn validate_url_accepts_fragment() {
214        assert!(Did::validate_url(URL).is_ok());
215    }
216
217    #[test]
218    fn validate_url_rejects_bare() {
219        assert!(Did::validate_url(BARE).is_err());
220    }
221
222    #[test]
223    fn validate_identity_accepts_bare() {
224        assert!(Did::validate_identity(BARE).is_ok());
225    }
226
227    #[test]
228    fn validate_identity_rejects_fragment() {
229        assert!(Did::validate_identity(URL).is_err());
230    }
231
232    #[test]
233    fn new_url_none_generates_nanoid() {
234        let url = Did::new_url(IDENTIFIER, None::<String>).unwrap();
235        assert!(url.is_url());
236        assert!(!url.fragment.unwrap().is_empty());
237    }
238
239    #[test]
240    fn new_url_accepts_nanoid_fragment() {
241        let url = Did::new_url(IDENTIFIER, Some("bahner")).unwrap();
242        assert_eq!(url.fragment.as_deref(), Some("bahner"));
243    }
244
245    #[test]
246    fn new_url_rejects_invalid_chars() {
247        assert!(Did::new_url(IDENTIFIER, Some("has space")).is_err());
248        assert!(Did::new_url(IDENTIFIER, Some("has.dot")).is_err());
249        assert!(Did::new_url(IDENTIFIER, Some("")).is_err());
250    }
251
252    #[test]
253    fn try_from_accepts_valid_fragment() {
254        let did = Did::try_from(URL).unwrap();
255        assert_eq!(did.fragment.as_deref(), Some("lobby"));
256    }
257
258    #[test]
259    fn rejects_non_ipns_identifier() {
260        assert!(Did::validate("did:ma:k51qzi5uqu5abc").is_err());
261    }
262
263    #[test]
264    fn rejects_non_canonical_ipns_base() {
265        let cid = Cid::try_from(IDENTIFIER).expect("valid CID");
266        let base32 = multibase::encode(multibase::Base::Base32Lower, cid.to_bytes());
267        assert!(Did::validate(&format!("did:ma:{base32}")).is_err());
268    }
269}