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 crate::{Did, Document, Message};
8use anyhow::{anyhow, Result};
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 web_time::Duration;
15
16#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
17use crate::kubo::{
18    dag_put, import_key, list_keys, name_publish_with_retry, wait_for_api, IpnsPublishOptions,
19};
20#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
21use reqwest::Url;
22
23use crate::service::MESSAGE_TYPE_IPFS_REQUEST;
24
25// ── Unified wire format ──────────────────────────────────────────────────────
26
27/// Unified CBOR payload for all `application/x-ma-ipfs-request` messages
28/// on `/ma/ipfs/0.0.1`. The `kind` field selects the operation.
29#[derive(Clone, Debug, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum IpfsRequestPayload {
32    /// Publish a signed DID document to IPFS/IPNS on behalf of the sender.
33    DidDocumentPublish {
34        /// dag-cbor encoded signed [`Document`].
35        document: Vec<u8>,
36        /// Raw 32-byte IPNS signing key (Ed25519 seed). Must be zeroized by receiver.
37        ipns_secret_key: Vec<u8>,
38    },
39    /// Store arbitrary content on IPFS; receiver replies with the resulting CID.
40    Store {
41        content: Vec<u8>,
42        content_type: String,
43    },
44}
45
46fn encode_ipfs_request_payload(payload: &IpfsRequestPayload) -> Result<Vec<u8>> {
47    let mut buf = Vec::new();
48    ciborium::ser::into_writer(payload, &mut buf)
49        .map_err(|e| anyhow!("failed to encode IPFS request payload as CBOR: {}", e))?;
50    Ok(buf)
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct IpfsPublishDidResponse {
55    pub ok: bool,
56    pub message: String,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub did: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub cid: Option<String>,
61}
62
63pub struct ValidatedIpfsPublish {
64    pub document_bytes: Vec<u8>,
65    pub ipns_secret_key: Vec<u8>,
66    pub document: Document,
67    pub document_did: Did,
68}
69
70/// Validated store request.
71pub struct ValidatedIpfsStore {
72    pub content: Vec<u8>,
73    pub content_type: String,
74    pub sender_did: String,
75    pub msg_id: String,
76}
77
78/// Unified validated IPFS request — returned by [`validate_ipfs_request`].
79pub enum ValidatedIpfsRequest {
80    DidDocumentPublish(Box<ValidatedIpfsPublish>),
81    Store(ValidatedIpfsStore),
82}
83
84/// Build CBOR content bytes for `application/x-ma-ipfs-request` (did-document-publish kind).
85///
86/// The returned bytes are the payload to place in `Message.content` when
87/// sending to `/ma/ipfs/0.0.1`.
88pub fn generate_ipfs_publish_request(
89    did_document: &Document,
90    ipns_secret_key: &[u8],
91) -> Result<Vec<u8>> {
92    let document_bytes = did_document
93        .encode()
94        .map_err(|e| anyhow!("failed to encode DID document as dag-cbor: {}", e))?;
95    encode_ipfs_request_payload(&IpfsRequestPayload::DidDocumentPublish {
96        document: document_bytes,
97        ipns_secret_key: ipns_secret_key.to_vec(),
98    })
99}
100
101/// Build a signed `application/x-ma-ipfs-request` message (store kind).
102///
103/// Returns the complete signed [`Message`] ready to send on `/ma/ipfs/0.0.1`.
104pub fn generate_ipfs_store_request(
105    sender_did: &str,
106    publisher_did: &str,
107    content: Vec<u8>,
108    content_type: &str,
109    signing_key: &crate::SigningKey,
110) -> Result<Message> {
111    let payload = encode_ipfs_request_payload(&IpfsRequestPayload::Store {
112        content,
113        content_type: content_type.to_string(),
114    })?;
115    Message::new(
116        sender_did,
117        publisher_did,
118        MESSAGE_TYPE_IPFS_REQUEST,
119        "application/cbor",
120        &payload,
121        signing_key,
122    )
123    .map_err(|e| anyhow!("failed to build ipfs-store message: {}", e))
124}
125
126#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
127#[derive(Clone, Debug)]
128pub struct IpfsDidPublisher {
129    kubo_url: String,
130}
131
132#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
133impl IpfsDidPublisher {
134    pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
135        let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
136        Ok(Self { kubo_url })
137    }
138
139    pub fn kubo_url(&self) -> &str {
140        &self.kubo_url
141    }
142
143    pub async fn publish_signed_message(
144        &self,
145        message_cbor: &[u8],
146    ) -> Result<IpfsPublishDidResponse> {
147        handle_ipfs_publish(&self.kubo_url, message_cbor).await
148    }
149
150    pub async fn publish_document(
151        &self,
152        did_document: &[u8],
153        ipns_private_key: &[u8],
154    ) -> Result<Option<String>> {
155        publish_did_document_to_kubo(&self.kubo_url, did_document, ipns_private_key).await
156    }
157
158    pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
159        wait_for_api(&self.kubo_url, attempts).await
160    }
161}
162
163#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
164fn normalize_kubo_url(input: &str) -> Result<String> {
165    let trimmed = input.trim();
166    if trimmed.is_empty() {
167        return Err(anyhow!("kubo_url must not be empty"));
168    }
169
170    let parsed =
171        Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
172
173    let scheme = parsed.scheme();
174    if scheme != "http" && scheme != "https" {
175        return Err(anyhow!(
176            "kubo_url must use http or https scheme, got '{}'",
177            scheme
178        ));
179    }
180
181    if parsed.host_str().is_none() {
182        return Err(anyhow!("kubo_url must include a host"));
183    }
184
185    if parsed.query().is_some() || parsed.fragment().is_some() {
186        return Err(anyhow!(
187            "kubo_url must not include query params or fragments"
188        ));
189    }
190
191    let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
192    if let Some(port) = parsed.port() {
193        base.push(':');
194        base.push_str(&port.to_string());
195    }
196
197    let mut path = parsed.path().trim_end_matches('/').to_string();
198    if path.ends_with("/api/v0") {
199        path.truncate(path.len() - "/api/v0".len());
200    }
201    if !path.is_empty() && path != "/" {
202        if !path.starts_with('/') {
203            base.push('/');
204        }
205        base.push_str(&path);
206    }
207
208    Ok(base)
209}
210
211/// Validate a full did-document-publish request from raw message CBOR bytes.
212///
213/// Used internally by [`IpfsDidPublisher::publish_signed_message`].
214pub fn validate_ipfs_publish_request(message_cbor: &[u8]) -> Result<ValidatedIpfsPublish> {
215    let message =
216        Message::decode(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
217    match validate_ipfs_request(&message)? {
218        ValidatedIpfsRequest::DidDocumentPublish(v) => Ok(*v),
219        ValidatedIpfsRequest::Store(_) => Err(anyhow!(
220            "expected did-document-publish kind on /ma/ipfs/0.0.1, got store"
221        )),
222    }
223}
224
225/// Validate any `application/x-ma-ipfs-request` message, dispatching on `kind`.
226///
227/// For `did-document-publish`: verifies the DID document signature and that
228/// the sender IPNS matches the document DID. Returns a [`ValidatedIpfsPublish`].
229///
230/// For `store`: extracts content and sender identity. Returns a [`ValidatedIpfsStore`].
231pub fn validate_ipfs_request(message: &Message) -> Result<ValidatedIpfsRequest> {
232    if message.message_type != MESSAGE_TYPE_IPFS_REQUEST {
233        return Err(anyhow!(
234            "expected {} on /ma/ipfs/0.0.1, got {}",
235            MESSAGE_TYPE_IPFS_REQUEST,
236            message.message_type
237        ));
238    }
239
240    let payload: IpfsRequestPayload = ciborium::de::from_reader(message.payload().as_slice())
241        .map_err(|e| anyhow!("invalid IPFS request payload: {}", e))?;
242
243    match payload {
244        IpfsRequestPayload::DidDocumentPublish {
245            document: document_bytes,
246            ipns_secret_key,
247        } => {
248            let sender_did = Did::try_from(message.from.as_str())
249                .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
250
251            let document = Document::decode(&document_bytes)
252                .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
253            document
254                .validate()
255                .map_err(|e| anyhow!("invalid DID document: {}", e))?;
256            document
257                .verify()
258                .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
259
260            let document_did = Did::try_from(document.id.as_str())
261                .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
262
263            if document_did.ipns != sender_did.ipns {
264                return Err(anyhow!(
265                    "sender IPNS '{}' does not match document IPNS '{}'",
266                    sender_did.ipns,
267                    document_did.ipns
268                ));
269            }
270
271            message
272                .verify_with_document(&document)
273                .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
274
275            Ok(ValidatedIpfsRequest::DidDocumentPublish(Box::new(
276                ValidatedIpfsPublish {
277                    document_bytes,
278                    ipns_secret_key,
279                    document,
280                    document_did,
281                },
282            )))
283        }
284        IpfsRequestPayload::Store {
285            content,
286            content_type,
287        } => Ok(ValidatedIpfsRequest::Store(ValidatedIpfsStore {
288            content,
289            content_type,
290            sender_did: message.from.clone(),
291            msg_id: message.id.clone(),
292        })),
293    }
294}
295
296#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
297pub async fn publish_did_document_to_kubo(
298    kubo_url: &str,
299    did_document: &[u8],
300    ipns_private_key: &[u8],
301) -> Result<Option<String>> {
302    let document = Document::decode(did_document)
303        .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
304    let document_did = Did::try_from(document.id.as_str())
305        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
306    let document_ipns_id = document_did.ipns.clone();
307
308    // Deterministic key name derived from the DID IPNS identity.
309    // Same DID always maps to the same Kubo key name — idempotent, no cleanup needed.
310    let hash = blake3::hash(document_ipns_id.as_bytes());
311    let key_name = format!("{}{}", MA_IPNS_ALIAS_HASH_PREFIX, &hash.to_hex()[..16]);
312
313    let existing_key = list_keys(kubo_url)
314        .await?
315        .into_iter()
316        .find(|k| k.name == key_name);
317
318    if let Some(existing) = existing_key {
319        if existing.id.trim() != document_ipns_id {
320            return Err(anyhow!(
321                "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
322                key_name,
323                existing.id,
324                document_ipns_id
325            ));
326        }
327    } else {
328        if ipns_private_key.is_empty() {
329            return Err(anyhow!(
330                "ipns_private_key is required when key is not present in Kubo"
331            ));
332        }
333
334        let raw_key: [u8; 32] = ipns_private_key
335            .try_into()
336            .map_err(|_| anyhow!("ipns_private_key must be 32 bytes"))?;
337        let keypair = libp2p_identity::Keypair::ed25519_from_bytes(raw_key)
338            .map_err(|e| anyhow!("invalid ipns key: {}", e))?;
339        let protobuf_key = keypair
340            .to_protobuf_encoding()
341            .map_err(|e| anyhow!("failed to encode ipns key: {}", e))?;
342        let imported = import_key(kubo_url, &key_name, protobuf_key).await?;
343        if imported.id.trim() != document_ipns_id {
344            return Err(anyhow!(
345                "imported key IPNS id '{}' does not match document DID IPNS '{}'",
346                imported.id,
347                document_ipns_id
348            ));
349        }
350    }
351
352    let published_cid = dag_put(kubo_url, &document).await?;
353    let ipns_options = IpnsPublishOptions::default();
354    name_publish_with_retry(
355        kubo_url,
356        &key_name,
357        &published_cid,
358        &ipns_options,
359        3,
360        Duration::from_secs(1),
361    )
362    .await?;
363
364    Ok(Some(published_cid))
365}
366
367#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
368pub async fn handle_ipfs_publish(
369    kubo_url: &str,
370    message_cbor: &[u8],
371) -> Result<IpfsPublishDidResponse> {
372    let validated = validate_ipfs_publish_request(message_cbor)?;
373
374    let cid = publish_did_document_to_kubo(
375        kubo_url,
376        &validated.document_bytes,
377        &validated.ipns_secret_key,
378    )
379    .await?;
380
381    Ok(IpfsPublishDidResponse {
382        ok: true,
383        message: "did document published via ma/ipfs/0.0.1".to_string(),
384        did: Some(validated.document_did.id()),
385        cid,
386    })
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::{generate_identity_from_secret, Did, SigningKey};
393
394    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
395    use super::normalize_kubo_url;
396
397    fn test_identity(seed: u8) -> crate::GeneratedIdentity {
398        generate_identity_from_secret([seed; 32]).expect("identity")
399    }
400
401    fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
402        let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
403        let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
404            .expect("decode key")
405            .try_into()
406            .expect("private key bytes");
407        SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
408    }
409
410    #[test]
411    fn generate_request_embeds_cbor_document_and_private_key() {
412        let identity = test_identity(21);
413        let payload =
414            generate_ipfs_publish_request(&identity.document, b"secret-key").expect("payload");
415        let request: IpfsRequestPayload =
416            ciborium::de::from_reader(payload.as_slice()).expect("decode request");
417
418        match request {
419            IpfsRequestPayload::DidDocumentPublish {
420                document,
421                ipns_secret_key,
422            } => {
423                assert_eq!(
424                    document,
425                    identity.document.encode().expect("document bytes")
426                );
427                assert_eq!(ipns_secret_key, b"secret-key".to_vec());
428            }
429            IpfsRequestPayload::Store { .. } => panic!("expected DidDocumentPublish variant"),
430        }
431    }
432
433    #[test]
434    fn validate_ipfs_publish_request_accepts_signed_request() {
435        let identity = test_identity(22);
436        let signing_key = test_signing_key(&identity);
437        let payload =
438            generate_ipfs_publish_request(&identity.document, b"private-key").expect("payload");
439        let message = Message::new(
440            identity.document.id.clone(),
441            String::new(),
442            MESSAGE_TYPE_IPFS_REQUEST,
443            "application/cbor",
444            &payload,
445            &signing_key,
446        )
447        .expect("message");
448        let encoded = message.encode().expect("message cbor");
449
450        let validated = validate_ipfs_publish_request(&encoded).expect("validated request");
451        assert_eq!(validated.document, identity.document);
452        assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
453    }
454
455    #[test]
456    fn validate_ipfs_publish_request_rejects_wrong_content_type() {
457        let identity = test_identity(23);
458        let signing_key = test_signing_key(&identity);
459        let payload =
460            generate_ipfs_publish_request(&identity.document, b"private-key").expect("payload");
461        let message = Message::new(
462            identity.document.id.clone(),
463            String::new(),
464            "application/x-test",
465            "application/cbor",
466            &payload,
467            &signing_key,
468        )
469        .expect("message");
470        let encoded = message.encode().expect("message cbor");
471
472        let err = validate_ipfs_publish_request(&encoded)
473            .err()
474            .expect("wrong content type");
475        assert!(err
476            .to_string()
477            .contains("expected application/x-ma-ipfs-request"));
478    }
479
480    #[test]
481    fn validate_ipfs_publish_request_rejects_ipns_mismatch() {
482        let sender_identity = test_identity(24);
483        let document_identity = test_identity(25);
484        let signing_key = test_signing_key(&sender_identity);
485        let payload = generate_ipfs_publish_request(&document_identity.document, b"private-key")
486            .expect("payload");
487        let message = Message::new(
488            sender_identity.document.id.clone(),
489            String::new(),
490            MESSAGE_TYPE_IPFS_REQUEST,
491            "application/cbor",
492            &payload,
493            &signing_key,
494        )
495        .expect("message");
496        let encoded = message.encode().expect("message cbor");
497
498        let err = validate_ipfs_publish_request(&encoded)
499            .err()
500            .expect("ipns mismatch");
501        assert!(err.to_string().contains("does not match document IPNS"));
502    }
503
504    #[test]
505    fn validate_ipfs_publish_request_rejects_invalid_document_bytes() {
506        let identity = test_identity(26);
507        let signing_key = test_signing_key(&identity);
508        let payload = encode_ipfs_request_payload(&IpfsRequestPayload::DidDocumentPublish {
509            document: b"not dag-cbor".to_vec(),
510            ipns_secret_key: b"private-key".to_vec(),
511        })
512        .expect("encode request");
513        let message = Message::new(
514            identity.document.id.clone(),
515            String::new(),
516            MESSAGE_TYPE_IPFS_REQUEST,
517            "application/cbor",
518            &payload,
519            &signing_key,
520        )
521        .expect("message");
522        let encoded = message.encode().expect("message cbor");
523
524        let err = validate_ipfs_publish_request(&encoded)
525            .err()
526            .expect("invalid document");
527        assert!(
528            err.to_string().contains("invalid IPFS request payload")
529                || err.to_string().contains("invalid DID document dag-cbor")
530        );
531    }
532
533    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
534    #[test]
535    fn normalizes_trailing_slash() {
536        assert_eq!(
537            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
538            "http://127.0.0.1:5001"
539        );
540    }
541
542    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
543    #[test]
544    fn strips_api_v0_suffix() {
545        assert_eq!(
546            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
547            "http://127.0.0.1:5001"
548        );
549    }
550
551    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
552    #[test]
553    fn keeps_custom_base_path() {
554        assert_eq!(
555            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
556            "http://localhost:5001/kubo"
557        );
558    }
559
560    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
561    #[test]
562    fn rejects_empty_url() {
563        assert!(normalize_kubo_url("   ").is_err());
564    }
565
566    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
567    #[test]
568    fn rejects_non_http_scheme() {
569        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
570    }
571}