Skip to main content

ma_core/ipfs/
publish.rs

1//! DID document publishing to IPFS/IPNS.
2//!
3//! Provides request/response types, validation, and (with the `kubo` feature)
4//! the [`IpfsDidPublisher`] for publishing signed DID documents via the
5//! `ma/ipfs/0.0.1` service.
6
7use anyhow::{anyhow, Result};
8use ma_did::{Did, Document, Message};
9use serde::{Deserialize, Serialize};
10
11pub const MA_IPNS_ALIAS_HASH_PREFIX: &str = "ma-";
12
13#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
14use base64::engine::general_purpose::STANDARD as B64;
15#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
16use base64::Engine;
17#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
18use std::time::Duration;
19
20#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
21use super::kubo::{dag_put, import_key, list_keys, name_publish_with_retry, IpnsPublishOptions};
22#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
23use reqwest::Url;
24
25use crate::service::CONTENT_TYPE_IPFS_REQUEST;
26
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct IpfsPublishDidRequest {
29    pub did_document_json: String,
30    #[serde(default)]
31    pub ipns_private_key_base64: String,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
35pub struct IpfsPublishDidResponse {
36    pub ok: bool,
37    pub message: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub did: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub cid: Option<String>,
42}
43
44pub struct ValidatedIpfsPublish {
45    pub request: IpfsPublishDidRequest,
46    pub document: Document,
47    pub document_did: Did,
48}
49
50#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
51#[derive(Clone, Debug)]
52pub struct IpfsDidPublisher {
53    kubo_url: String,
54}
55
56#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
57impl IpfsDidPublisher {
58    pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
59        let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
60        Ok(Self { kubo_url })
61    }
62
63    pub fn kubo_url(&self) -> &str {
64        &self.kubo_url
65    }
66
67    pub async fn publish_signed_message(
68        &self,
69        message_cbor: &[u8],
70    ) -> Result<IpfsPublishDidResponse> {
71        handle_ipfs_publish(&self.kubo_url, message_cbor).await
72    }
73
74    pub async fn publish_document(
75        &self,
76        did_document_json: &str,
77        ipns_private_key_base64: &str,
78    ) -> Result<Option<String>> {
79        publish_did_document_to_kubo(&self.kubo_url, did_document_json, ipns_private_key_base64)
80            .await
81    }
82
83    pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
84        super::kubo::wait_for_api(&self.kubo_url, attempts).await
85    }
86}
87
88#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
89fn normalize_kubo_url(input: &str) -> Result<String> {
90    let trimmed = input.trim();
91    if trimmed.is_empty() {
92        return Err(anyhow!("kubo_url must not be empty"));
93    }
94
95    let parsed =
96        Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
97
98    let scheme = parsed.scheme();
99    if scheme != "http" && scheme != "https" {
100        return Err(anyhow!(
101            "kubo_url must use http or https scheme, got '{}'",
102            scheme
103        ));
104    }
105
106    if parsed.host_str().is_none() {
107        return Err(anyhow!("kubo_url must include a host"));
108    }
109
110    if parsed.query().is_some() || parsed.fragment().is_some() {
111        return Err(anyhow!(
112            "kubo_url must not include query params or fragments"
113        ));
114    }
115
116    let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
117    if let Some(port) = parsed.port() {
118        base.push(':');
119        base.push_str(&port.to_string());
120    }
121
122    let mut path = parsed.path().trim_end_matches('/').to_string();
123    if path.ends_with("/api/v0") {
124        path.truncate(path.len() - "/api/v0".len());
125    }
126    if !path.is_empty() && path != "/" {
127        if !path.starts_with('/') {
128            base.push('/');
129        }
130        base.push_str(&path);
131    }
132
133    Ok(base)
134}
135
136pub fn validate_ipfs_publish_request(message_cbor: &[u8]) -> Result<ValidatedIpfsPublish> {
137    let message =
138        Message::from_cbor(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
139
140    if message.content_type != CONTENT_TYPE_IPFS_REQUEST {
141        return Err(anyhow!(
142            "expected {} on ma/ipfs/1, got {}",
143            CONTENT_TYPE_IPFS_REQUEST,
144            message.content_type
145        ));
146    }
147
148    let sender_did = Did::try_from(message.from.as_str())
149        .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
150
151    let request: IpfsPublishDidRequest = serde_json::from_slice(&message.content)
152        .map_err(|e| anyhow!("invalid IPFS publish payload: {}", e))?;
153
154    let document = Document::unmarshal(&request.did_document_json)
155        .map_err(|e| anyhow!("invalid DID document JSON: {}", e))?;
156    document
157        .validate()
158        .map_err(|e| anyhow!("invalid DID document: {}", e))?;
159    document
160        .verify()
161        .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
162
163    let document_did = Did::try_from(document.id.as_str())
164        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
165
166    if document_did.ipns != sender_did.ipns {
167        return Err(anyhow!(
168            "sender IPNS '{}' does not match document IPNS '{}'",
169            sender_did.ipns,
170            document_did.ipns
171        ));
172    }
173
174    message
175        .verify_with_document(&document)
176        .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
177
178    Ok(ValidatedIpfsPublish {
179        request,
180        document,
181        document_did,
182    })
183}
184
185#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
186pub async fn publish_did_document_to_kubo(
187    kubo_url: &str,
188    did_document_json: &str,
189    ipns_private_key_base64: &str,
190) -> Result<Option<String>> {
191    let document = Document::unmarshal(did_document_json)
192        .map_err(|e| anyhow!("invalid DID document JSON: {}", e))?;
193    let document_did = Did::try_from(document.id.as_str())
194        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
195    let document_ipns_id = document_did.ipns.clone();
196
197    // Deterministic key name derived from the DID IPNS identity.
198    // Same DID always maps to the same Kubo key name — idempotent, no cleanup needed.
199    let hash = blake3::hash(document_ipns_id.as_bytes());
200    let key_name = format!("{}{}", MA_IPNS_ALIAS_HASH_PREFIX, &hash.to_hex()[..16]);
201
202    let existing_key = list_keys(kubo_url)
203        .await?
204        .into_iter()
205        .find(|k| k.name == key_name);
206
207    if let Some(existing) = existing_key {
208        if existing.id.trim() != document_ipns_id {
209            return Err(anyhow!(
210                "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
211                key_name,
212                existing.id,
213                document_ipns_id
214            ));
215        }
216    } else {
217        if ipns_private_key_base64.trim().is_empty() {
218            return Err(anyhow!(
219                "ipns_private_key_base64 is required when key is not present in Kubo"
220            ));
221        }
222
223        let key_bytes = B64
224            .decode(ipns_private_key_base64.trim())
225            .map_err(|e| anyhow!("invalid base64 key payload: {}", e))?;
226
227        let imported = import_key(kubo_url, &key_name, key_bytes).await?;
228        if imported.id.trim() != document_ipns_id {
229            return Err(anyhow!(
230                "imported key IPNS id '{}' does not match document DID IPNS '{}'",
231                imported.id,
232                document_ipns_id
233            ));
234        }
235    }
236
237    let published_cid = dag_put(kubo_url, &document).await?;
238    let ipns_options = IpnsPublishOptions::default();
239    name_publish_with_retry(
240        kubo_url,
241        &key_name,
242        &published_cid,
243        &ipns_options,
244        3,
245        Duration::from_secs(1),
246    )
247    .await?;
248
249    Ok(Some(published_cid))
250}
251
252#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
253pub async fn handle_ipfs_publish(
254    kubo_url: &str,
255    message_cbor: &[u8],
256) -> Result<IpfsPublishDidResponse> {
257    let validated = validate_ipfs_publish_request(message_cbor)?;
258
259    let cid = publish_did_document_to_kubo(
260        kubo_url,
261        &validated.request.did_document_json,
262        &validated.request.ipns_private_key_base64,
263    )
264    .await?;
265
266    Ok(IpfsPublishDidResponse {
267        ok: true,
268        message: "did document published via ma/ipfs/0.0.1".to_string(),
269        did: Some(validated.document_did.id()),
270        cid,
271    })
272}
273
274#[cfg(all(test, not(target_arch = "wasm32"), feature = "kubo"))]
275mod tests {
276    use super::normalize_kubo_url;
277
278    #[test]
279    fn normalizes_trailing_slash() {
280        assert_eq!(
281            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
282            "http://127.0.0.1:5001"
283        );
284    }
285
286    #[test]
287    fn strips_api_v0_suffix() {
288        assert_eq!(
289            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
290            "http://127.0.0.1:5001"
291        );
292    }
293
294    #[test]
295    fn keeps_custom_base_path() {
296        assert_eq!(
297            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
298            "http://localhost:5001/kubo"
299        );
300    }
301
302    #[test]
303    fn rejects_empty_url() {
304        assert!(normalize_kubo_url("   ").is_err());
305    }
306
307    #[test]
308    fn rejects_non_http_scheme() {
309        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
310    }
311}