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, Ipld, 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
52fn sanitize_key_part(part: &str) -> String {
53    let mut sanitized = String::new();
54    let mut last_was_separator = false;
55
56    for byte in part.bytes() {
57        let ch = byte as char;
58        if ch.is_ascii_alphanumeric() {
59            sanitized.push(ch.to_ascii_lowercase());
60            last_was_separator = false;
61        } else if ch == '-' || ch == '_' {
62            if !last_was_separator && !sanitized.is_empty() {
63                sanitized.push(ch);
64                last_was_separator = true;
65            }
66        } else if !last_was_separator && !sanitized.is_empty() {
67            sanitized.push('-');
68            last_was_separator = true;
69        }
70    }
71
72    while sanitized.ends_with(['-', '_']) {
73        sanitized.pop();
74    }
75
76    if sanitized.is_empty() {
77        "unknown".to_string()
78    } else {
79        sanitized
80    }
81}
82
83fn document_ma_type(document: &Document) -> &str {
84    match document.ma.as_ref() {
85        Some(Ipld::Map(map)) => match map.get("type") {
86            Some(Ipld::String(kind)) => kind,
87            _ => "unknown",
88        },
89        _ => "unknown",
90    }
91}
92
93/// Build a deterministic Kubo IPNS key name from operator-visible parts.
94///
95/// The IPNS identity is only exposed as a short blake3 suffix. Callers may add
96/// local context such as a runtime slug, while delegated agent publishes can
97/// remain anonymous apart from their `ma.type`.
98#[must_use]
99pub fn ipns_key_name_for_parts(parts: &[&str], ipns_id: &str) -> String {
100    let name_parts = if parts.is_empty() {
101        vec!["unknown".to_string()]
102    } else {
103        parts.iter().map(|part| sanitize_key_part(part)).collect()
104    };
105    let hash = blake3::hash(ipns_id.as_bytes());
106    format!(
107        "{}{}-{}",
108        MA_IPNS_ALIAS_HASH_PREFIX,
109        name_parts.join("-"),
110        &hash.to_hex()[..16]
111    )
112}
113
114/// Build the default deterministic Kubo IPNS key name for a DID document.
115///
116/// Uses `ma.type` when present and falls back to `unknown`.
117#[must_use]
118pub fn ipns_key_name_for_document(document: &Document) -> String {
119    let document_did = Did::try_from(document.id.as_str());
120    let ipns_id = document_did
121        .as_ref()
122        .map_or(document.id.as_str(), |did| did.ipns.as_str());
123    ipns_key_name_for_parts(&[document_ma_type(document)], ipns_id)
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127pub struct IpfsPublishDidResponse {
128    pub ok: bool,
129    pub message: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub did: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub cid: Option<String>,
134}
135
136pub struct ValidatedIdentityPublish {
137    pub document_bytes: Vec<u8>,
138    pub ipns_secret_key: Vec<u8>,
139    pub document: Document,
140    pub document_did: Did,
141}
142
143/// Validated store request.
144pub struct ValidatedIpfsStore {
145    pub content: Vec<u8>,
146    pub content_type: String,
147    pub sender_did: String,
148    pub msg_id: String,
149}
150
151/// Build CBOR content bytes for `application/vnd.ma.identity.publish.request`.
152///
153/// The returned bytes are the payload to place in `Message.content` when
154/// sending to `/ma/ipfs/0.0.1`.
155pub fn generate_identity_publish_request(
156    did_document: &Document,
157    ipns_secret_key: &[u8],
158) -> Result<Vec<u8>> {
159    let document_bytes = did_document
160        .encode()
161        .map_err(|e| anyhow!("failed to encode DID document as dag-cbor: {}", e))?;
162    encode_cbor(&IdentityPublishRequest {
163        document: document_bytes,
164        ipns_secret_key: ipns_secret_key.to_vec(),
165    })
166}
167
168/// Build a signed `application/vnd.ma.ipfs.request` message (generic store).
169///
170/// Returns the complete signed [`Message`] ready to send on `/ma/ipfs/0.0.1`.
171pub fn generate_ipfs_store_request(
172    sender_did: &str,
173    publisher_did: &str,
174    content: Vec<u8>,
175    content_type: &str,
176    signing_key: &crate::SigningKey,
177) -> Result<Message> {
178    let payload = encode_cbor(&IpfsStoreRequest {
179        content,
180        content_type: content_type.to_string(),
181    })?;
182    Message::new(
183        sender_did,
184        publisher_did,
185        MESSAGE_TYPE_IPFS_REQUEST,
186        "application/cbor",
187        &payload,
188        signing_key,
189    )
190    .map_err(|e| anyhow!("failed to build ipfs-store message: {}", e))
191}
192
193#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
194#[derive(Clone, Debug)]
195pub struct IpfsDidPublisher {
196    kubo_url: String,
197}
198
199#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
200impl IpfsDidPublisher {
201    pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
202        let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
203        Ok(Self { kubo_url })
204    }
205
206    pub fn kubo_url(&self) -> &str {
207        &self.kubo_url
208    }
209
210    pub async fn publish_signed_message(
211        &self,
212        message_cbor: &[u8],
213    ) -> Result<IpfsPublishDidResponse> {
214        handle_ipfs_publish(&self.kubo_url, message_cbor).await
215    }
216
217    pub async fn publish_document(
218        &self,
219        did_document: &[u8],
220        ipns_private_key: &[u8],
221    ) -> Result<Option<String>> {
222        publish_did_document_to_kubo(&self.kubo_url, did_document, ipns_private_key).await
223    }
224
225    pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
226        wait_for_api(&self.kubo_url, attempts).await
227    }
228}
229
230#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
231fn normalize_kubo_url(input: &str) -> Result<String> {
232    let trimmed = input.trim();
233    if trimmed.is_empty() {
234        return Err(anyhow!("kubo_url must not be empty"));
235    }
236
237    let parsed =
238        Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
239
240    let scheme = parsed.scheme();
241    if scheme != "http" && scheme != "https" {
242        return Err(anyhow!(
243            "kubo_url must use http or https scheme, got '{}'",
244            scheme
245        ));
246    }
247
248    if parsed.host_str().is_none() {
249        return Err(anyhow!("kubo_url must include a host"));
250    }
251
252    if parsed.query().is_some() || parsed.fragment().is_some() {
253        return Err(anyhow!(
254            "kubo_url must not include query params or fragments"
255        ));
256    }
257
258    let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
259    if let Some(port) = parsed.port() {
260        base.push(':');
261        base.push_str(&port.to_string());
262    }
263
264    let mut path = parsed.path().trim_end_matches('/').to_string();
265    if path.ends_with("/api/v0") {
266        path.truncate(path.len() - "/api/v0".len());
267    }
268    if !path.is_empty() && path != "/" {
269        if !path.starts_with('/') {
270            base.push('/');
271        }
272        base.push_str(&path);
273    }
274
275    Ok(base)
276}
277
278/// Validate a full identity-publish request from raw message CBOR bytes.
279///
280/// Used internally by [`IpfsDidPublisher::publish_signed_message`].
281pub fn validate_identity_publish_request(message_cbor: &[u8]) -> Result<ValidatedIdentityPublish> {
282    let message =
283        Message::decode(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
284    validate_identity_publish_message(&message)
285}
286
287/// Validate an `application/vnd.ma.identity.publish.request` message.
288///
289/// Verifies the DID document signature and that the sender IPNS matches the
290/// document DID. Returns a [`ValidatedIdentityPublish`].
291pub fn validate_identity_publish_message(message: &Message) -> Result<ValidatedIdentityPublish> {
292    if message.message_type != MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST {
293        return Err(anyhow!(
294            "expected {} on /ma/ipfs/0.0.1, got {}",
295            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
296            message.message_type
297        ));
298    }
299
300    let payload: IdentityPublishRequest =
301        ciborium::de::from_reader(message.payload().as_slice())
302            .map_err(|e| anyhow!("invalid identity-publish request payload: {}", e))?;
303    let IdentityPublishRequest {
304        document: document_bytes,
305        ipns_secret_key,
306    } = payload;
307
308    let sender_did = Did::try_from(message.from.as_str())
309        .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
310
311    let document = Document::decode(&document_bytes)
312        .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
313    document
314        .validate()
315        .map_err(|e| anyhow!("invalid DID document: {}", e))?;
316    document
317        .verify()
318        .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
319
320    let document_did = Did::try_from(document.id.as_str())
321        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
322
323    if document_did.ipns != sender_did.ipns {
324        return Err(anyhow!(
325            "sender IPNS '{}' does not match document IPNS '{}'",
326            sender_did.ipns,
327            document_did.ipns
328        ));
329    }
330
331    message
332        .verify_with_document(&document)
333        .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
334
335    Ok(ValidatedIdentityPublish {
336        document_bytes,
337        ipns_secret_key,
338        document,
339        document_did,
340    })
341}
342
343/// Validate an `application/vnd.ma.ipfs.request` message (generic store).
344///
345/// Extracts content and sender identity. Returns a [`ValidatedIpfsStore`].
346pub fn validate_ipfs_request(message: &Message) -> Result<ValidatedIpfsStore> {
347    if message.message_type != MESSAGE_TYPE_IPFS_REQUEST {
348        return Err(anyhow!(
349            "expected {} on /ma/ipfs/0.0.1, got {}",
350            MESSAGE_TYPE_IPFS_REQUEST,
351            message.message_type
352        ));
353    }
354
355    let payload: IpfsStoreRequest = ciborium::de::from_reader(message.payload().as_slice())
356        .map_err(|e| anyhow!("invalid IPFS store request payload: {}", e))?;
357
358    Ok(ValidatedIpfsStore {
359        content: payload.content,
360        content_type: payload.content_type,
361        sender_did: message.from.clone(),
362        msg_id: message.id.clone(),
363    })
364}
365
366#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
367pub async fn publish_did_document_to_kubo(
368    kubo_url: &str,
369    did_document: &[u8],
370    ipns_private_key: &[u8],
371) -> Result<Option<String>> {
372    let document = Document::decode(did_document)
373        .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
374    let document_did = Did::try_from(document.id.as_str())
375        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
376    let document_ipns_id = document_did.ipns.clone();
377
378    // Deterministic key name derived from the DID IPNS identity.
379    // Same DID always maps to the same Kubo key name — idempotent, no cleanup needed.
380    let key_name = ipns_key_name_for_document(&document);
381
382    let existing_key = list_keys(kubo_url)
383        .await?
384        .into_iter()
385        .find(|k| k.name == key_name);
386
387    if let Some(existing) = existing_key {
388        if existing.id.trim() != document_ipns_id {
389            return Err(anyhow!(
390                "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
391                key_name,
392                existing.id,
393                document_ipns_id
394            ));
395        }
396    } else {
397        if ipns_private_key.is_empty() {
398            return Err(anyhow!(
399                "ipns_private_key is required when key is not present in Kubo"
400            ));
401        }
402
403        let raw_key: [u8; 32] = ipns_private_key
404            .try_into()
405            .map_err(|_| anyhow!("ipns_private_key must be 32 bytes"))?;
406        let keypair = libp2p_identity::Keypair::ed25519_from_bytes(raw_key)
407            .map_err(|e| anyhow!("invalid ipns key: {}", e))?;
408        let protobuf_key = keypair
409            .to_protobuf_encoding()
410            .map_err(|e| anyhow!("failed to encode ipns key: {}", e))?;
411        let imported = import_key(kubo_url, &key_name, protobuf_key).await?;
412        if imported.id.trim() != document_ipns_id {
413            return Err(anyhow!(
414                "imported key IPNS id '{}' does not match document DID IPNS '{}'",
415                imported.id,
416                document_ipns_id
417            ));
418        }
419    }
420
421    let published_cid = dag_put(kubo_url, &document).await?;
422    let ipns_options = IpnsPublishOptions::default();
423    name_publish_with_retry(
424        kubo_url,
425        &key_name,
426        &published_cid,
427        &ipns_options,
428        3,
429        Duration::from_secs(1),
430    )
431    .await?;
432
433    Ok(Some(published_cid))
434}
435
436#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
437pub async fn handle_ipfs_publish(
438    kubo_url: &str,
439    message_cbor: &[u8],
440) -> Result<IpfsPublishDidResponse> {
441    let validated = validate_identity_publish_request(message_cbor)?;
442
443    let cid = publish_did_document_to_kubo(
444        kubo_url,
445        &validated.document_bytes,
446        &validated.ipns_secret_key,
447    )
448    .await?;
449
450    Ok(IpfsPublishDidResponse {
451        ok: true,
452        message: "did document published via ma/ipfs/0.0.1".to_string(),
453        did: Some(validated.document_did.id()),
454        cid,
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::{generate_identity_from_secret, Did, MaExtension, SigningKey};
462
463    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
464    use super::normalize_kubo_url;
465
466    fn test_identity(seed: u8) -> crate::GeneratedIdentity {
467        generate_identity_from_secret([seed; 32]).expect("identity")
468    }
469
470    fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
471        let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
472        let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
473            .expect("decode key")
474            .try_into()
475            .expect("private key bytes");
476        SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
477    }
478
479    fn expected_key_name(parts: &[&str], ipns_id: &str) -> String {
480        let hash = blake3::hash(ipns_id.as_bytes());
481        format!(
482            "{}{}-{}",
483            MA_IPNS_ALIAS_HASH_PREFIX,
484            parts.join("-"),
485            &hash.to_hex()[..16]
486        )
487    }
488
489    #[test]
490    fn document_key_name_uses_ma_type() {
491        let identity = test_identity(11);
492        let mut document = identity.document.clone();
493        document.set_ma_extension(MaExtension::new().kind("agent"));
494
495        assert_eq!(
496            ipns_key_name_for_document(&document),
497            expected_key_name(&["agent"], &identity.subject_url.ipns)
498        );
499    }
500
501    #[test]
502    fn document_key_name_falls_back_to_unknown_type() {
503        let identity = test_identity(12);
504
505        assert_eq!(
506            ipns_key_name_for_document(&identity.document),
507            expected_key_name(&["unknown"], &identity.subject_url.ipns)
508        );
509    }
510
511    #[test]
512    fn key_name_parts_allow_runtime_slug() {
513        let ipns_id = "k51qzi5uqu5example";
514
515        assert_eq!(
516            ipns_key_name_for_parts(&["runtime", "my-slug"], ipns_id),
517            expected_key_name(&["runtime", "my-slug"], ipns_id)
518        );
519        assert_eq!(
520            ipns_key_name_for_parts(&["runtime", "my-slug", "runtime"], ipns_id),
521            expected_key_name(&["runtime", "my-slug", "runtime"], ipns_id)
522        );
523    }
524
525    #[test]
526    fn key_name_parts_are_sanitized() {
527        let ipns_id = "k51qzi5uqu5example";
528
529        assert_eq!(
530            ipns_key_name_for_parts(&["Runtime", "my slug!", "***"], ipns_id),
531            expected_key_name(&["runtime", "my-slug", "unknown"], ipns_id)
532        );
533    }
534
535    #[test]
536    fn generate_request_embeds_cbor_document_and_private_key() {
537        let identity = test_identity(21);
538        let payload =
539            generate_identity_publish_request(&identity.document, b"secret-key").expect("payload");
540        let request: IdentityPublishRequest =
541            ciborium::de::from_reader(payload.as_slice()).expect("decode request");
542
543        assert_eq!(
544            request.document,
545            identity.document.encode().expect("document bytes")
546        );
547        assert_eq!(request.ipns_secret_key, b"secret-key".to_vec());
548    }
549
550    #[test]
551    fn validate_identity_publish_request_accepts_signed_request() {
552        let identity = test_identity(22);
553        let signing_key = test_signing_key(&identity);
554        let payload =
555            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
556        let message = Message::new(
557            identity.document.id.clone(),
558            String::new(),
559            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
560            "application/cbor",
561            &payload,
562            &signing_key,
563        )
564        .expect("message");
565        let encoded = message.encode().expect("message cbor");
566
567        let validated = validate_identity_publish_request(&encoded).expect("validated request");
568        assert_eq!(validated.document, identity.document);
569        assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
570    }
571
572    #[test]
573    fn validate_identity_publish_request_rejects_wrong_content_type() {
574        let identity = test_identity(23);
575        let signing_key = test_signing_key(&identity);
576        let payload =
577            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
578        let message = Message::new(
579            identity.document.id.clone(),
580            String::new(),
581            "application/x-test",
582            "application/cbor",
583            &payload,
584            &signing_key,
585        )
586        .expect("message");
587        let encoded = message.encode().expect("message cbor");
588
589        let err = validate_identity_publish_request(&encoded)
590            .err()
591            .expect("wrong content type");
592        assert!(err
593            .to_string()
594            .contains("expected application/vnd.ma.identity.publish.request"));
595    }
596
597    #[test]
598    fn validate_identity_publish_request_rejects_ipns_mismatch() {
599        let sender_identity = test_identity(24);
600        let document_identity = test_identity(25);
601        let signing_key = test_signing_key(&sender_identity);
602        let payload =
603            generate_identity_publish_request(&document_identity.document, b"private-key")
604                .expect("payload");
605        let message = Message::new(
606            sender_identity.document.id.clone(),
607            String::new(),
608            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
609            "application/cbor",
610            &payload,
611            &signing_key,
612        )
613        .expect("message");
614        let encoded = message.encode().expect("message cbor");
615
616        let err = validate_identity_publish_request(&encoded)
617            .err()
618            .expect("ipns mismatch");
619        assert!(err.to_string().contains("does not match document IPNS"));
620    }
621
622    #[test]
623    fn validate_identity_publish_request_rejects_invalid_document_bytes() {
624        let identity = test_identity(26);
625        let signing_key = test_signing_key(&identity);
626        let payload = encode_cbor(&IdentityPublishRequest {
627            document: b"not dag-cbor".to_vec(),
628            ipns_secret_key: b"private-key".to_vec(),
629        })
630        .expect("encode request");
631        let message = Message::new(
632            identity.document.id.clone(),
633            String::new(),
634            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
635            "application/cbor",
636            &payload,
637            &signing_key,
638        )
639        .expect("message");
640        let encoded = message.encode().expect("message cbor");
641
642        let err = validate_identity_publish_request(&encoded)
643            .err()
644            .expect("invalid document");
645        assert!(
646            err.to_string()
647                .contains("invalid identity-publish request payload")
648                || err.to_string().contains("invalid DID document dag-cbor")
649        );
650    }
651
652    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
653    #[test]
654    fn normalizes_trailing_slash() {
655        assert_eq!(
656            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
657            "http://127.0.0.1:5001"
658        );
659    }
660
661    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
662    #[test]
663    fn strips_api_v0_suffix() {
664        assert_eq!(
665            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
666            "http://127.0.0.1:5001"
667        );
668    }
669
670    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
671    #[test]
672    fn keeps_custom_base_path() {
673        assert_eq!(
674            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
675            "http://localhost:5001/kubo"
676        );
677    }
678
679    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
680    #[test]
681    fn rejects_empty_url() {
682        assert!(normalize_kubo_url("   ").is_err());
683    }
684
685    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
686    #[test]
687    fn rejects_non_http_scheme() {
688        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
689    }
690}