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