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_cbor, import_key, in_flight_pin_name, list_keys, name_publish_with_retry,
19    pin_add_named, remote_pin_add_named, wait_for_api, IpnsPublishOptions, PinCleanupRequest,
20    PinCleanupScheduler,
21};
22#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
23use reqwest::Url;
24#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
25use zeroize::Zeroizing;
26
27use crate::service::{MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST, MESSAGE_TYPE_IPFS_REQUEST};
28
29// ── Wire formats ──────────────────────────────────────────────────────────
30
31/// CBOR payload for `application/vnd.ma.identity.publish.request` messages
32/// on `/ma/ipfs/0.0.1`.
33#[derive(Clone, Debug, Serialize, Deserialize)]
34pub struct IdentityPublishRequest {
35    /// dag-cbor encoded signed [`Document`].
36    pub document: Vec<u8>,
37    /// Raw 32-byte IPNS signing key (Ed25519 seed). Must be zeroized by receiver.
38    pub ipns_secret_key: Vec<u8>,
39}
40
41/// CBOR payload for `application/vnd.ma.ipfs.request` messages on
42/// `/ma/ipfs/0.0.1`. Receiver replies with the resulting CID.
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct IpfsStoreRequest {
45    pub content: Vec<u8>,
46    pub content_type: String,
47}
48
49fn encode_cbor<T: Serialize>(payload: &T) -> Result<Vec<u8>> {
50    let mut buf = Vec::new();
51    ciborium::ser::into_writer(payload, &mut buf)
52        .map_err(|e| anyhow!("failed to encode CBOR payload: {}", e))?;
53    Ok(buf)
54}
55
56fn sanitize_key_part(part: &str) -> String {
57    let mut sanitized = String::new();
58    let mut last_was_separator = false;
59
60    for byte in part.bytes() {
61        let ch = byte as char;
62        if ch.is_ascii_alphanumeric() {
63            sanitized.push(ch.to_ascii_lowercase());
64            last_was_separator = false;
65        } else if ch == '-' || ch == '_' {
66            if !last_was_separator && !sanitized.is_empty() {
67                sanitized.push(ch);
68                last_was_separator = true;
69            }
70        } else if !last_was_separator && !sanitized.is_empty() {
71            sanitized.push('-');
72            last_was_separator = true;
73        }
74    }
75
76    while sanitized.ends_with(['-', '_']) {
77        sanitized.pop();
78    }
79
80    if sanitized.is_empty() {
81        "unknown".to_string()
82    } else {
83        sanitized
84    }
85}
86
87fn document_ma_type(document: &Document) -> &str {
88    match document.ma.as_ref() {
89        Some(Ipld::Map(map)) => match map.get("type") {
90            Some(Ipld::String(kind)) => kind,
91            _ => "unknown",
92        },
93        _ => "unknown",
94    }
95}
96
97/// Build a deterministic Kubo IPNS key name from operator-visible parts.
98///
99/// The IPNS identity is only exposed as a short blake3 suffix. Callers may add
100/// local context such as a runtime slug, while delegated agent publishes can
101/// remain anonymous apart from their `ma.type`.
102#[must_use]
103pub fn ipns_key_name_for_parts(parts: &[&str], ipns_id: &str) -> String {
104    let name_parts = if parts.is_empty() {
105        vec!["unknown".to_string()]
106    } else {
107        parts.iter().map(|part| sanitize_key_part(part)).collect()
108    };
109    let hash = blake3::hash(ipns_id.as_bytes());
110    format!(
111        "{}{}-{}",
112        MA_IPNS_ALIAS_HASH_PREFIX,
113        name_parts.join("-"),
114        &hash.to_hex()[..16]
115    )
116}
117
118/// Build the default deterministic Kubo IPNS key name for a DID document.
119///
120/// Uses `ma.type` when present and falls back to `unknown`.
121#[must_use]
122pub fn ipns_key_name_for_document(document: &Document) -> String {
123    let document_did = Did::try_from(document.id.as_str());
124    let ipns_id = document_did
125        .as_ref()
126        .map_or(document.id.as_str(), |did| did.ipns.as_str());
127    ipns_key_name_for_parts(&[document_ma_type(document)], ipns_id)
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct IpfsPublishDidResponse {
132    pub ok: bool,
133    pub message: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub did: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub cid: Option<String>,
138}
139
140#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
141#[derive(Clone, Debug)]
142/// Publication policy for a native DID document.
143pub struct DidDocumentPublishOptions {
144    /// Deterministic Kubo key-name components; defaults to the document kind.
145    pub key_parts: Vec<String>,
146    /// IPNS publication settings.
147    pub ipns: IpnsPublishOptions,
148    /// Number of bounded retries for IPNS and remote pinning.
149    pub attempts: u32,
150    /// Initial Fibonacci retry delay.
151    pub initial_backoff: Duration,
152    /// Optional remote pin service replication policy.
153    pub remote_pin: Option<RemotePinOptions>,
154    /// Replace older pins with the same name in a background best-effort job.
155    pub overwrite: bool,
156}
157
158#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
159impl Default for DidDocumentPublishOptions {
160    fn default() -> Self {
161        Self {
162            key_parts: Vec::new(),
163            ipns: IpnsPublishOptions::default(),
164            attempts: 3,
165            initial_backoff: Duration::from_secs(1),
166            remote_pin: None,
167            overwrite: true,
168        }
169    }
170}
171
172#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
173#[derive(Clone, Debug, Eq, PartialEq)]
174/// Remote Kubo pin service configuration for a published DID document.
175pub struct RemotePinOptions {
176    /// Kubo pin-service name.
177    pub service: String,
178    /// Human-readable label for the remote pin.
179    pub name: String,
180}
181
182#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
183#[derive(Clone, Debug, Eq, PartialEq)]
184/// Remote replication outcome after local pinning and IPNS publication.
185pub enum RemotePinStatus {
186    /// The new CID was replicated and stale-pin cleanup was scheduled.
187    Replicated { cleanup_scheduled: bool },
188    /// Local publication succeeded, but replication failed after retries.
189    Degraded { error: String },
190}
191
192#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
193#[derive(Clone, Debug, Eq, PartialEq)]
194/// Confirmed outcome of a locally pinned DID document publication.
195pub struct PublishedDidDocument {
196    /// CID stored and published through IPNS.
197    pub cid: String,
198    /// Deterministic Kubo IPNS key alias.
199    pub key_name: String,
200    /// IPNS identity rooted by the DID.
201    pub ipns_id: String,
202    /// Kubo accepted the required local recursive pin.
203    pub local_pinned: bool,
204    /// Whether a detached stale-pin cleanup job was scheduled.
205    pub cleanup_scheduled: bool,
206    /// Optional remote replication state.
207    pub remote_pin: Option<RemotePinStatus>,
208}
209
210pub struct ValidatedIdentityPublish {
211    pub document_bytes: Vec<u8>,
212    pub ipns_secret_key: Vec<u8>,
213    pub document: Document,
214    pub document_did: Did,
215}
216
217/// Validated store request.
218pub struct ValidatedIpfsStore {
219    pub content: Vec<u8>,
220    pub content_type: String,
221    pub sender_did: String,
222    pub msg_id: String,
223}
224
225/// Build CBOR content bytes for `application/vnd.ma.identity.publish.request`.
226///
227/// The returned bytes are the payload to place in `Message.content` when
228/// sending to `/ma/ipfs/0.0.1`.
229pub fn generate_identity_publish_request(
230    did_document: &Document,
231    ipns_secret_key: &[u8],
232) -> Result<Vec<u8>> {
233    let document_bytes = did_document
234        .encode()
235        .map_err(|e| anyhow!("failed to encode DID document as dag-cbor: {}", e))?;
236    encode_cbor(&IdentityPublishRequest {
237        document: document_bytes,
238        ipns_secret_key: ipns_secret_key.to_vec(),
239    })
240}
241
242/// Build a signed `application/vnd.ma.ipfs.request` message (generic store).
243///
244/// Returns the complete signed [`Message`] ready to send on `/ma/ipfs/0.0.1`.
245pub fn generate_ipfs_store_request(
246    sender_did: &str,
247    publisher_did: &str,
248    content: Vec<u8>,
249    content_type: &str,
250    signing_key: &crate::SigningKey,
251) -> Result<Message> {
252    let payload = encode_cbor(&IpfsStoreRequest {
253        content,
254        content_type: content_type.to_string(),
255    })?;
256    Message::new(
257        sender_did,
258        publisher_did,
259        MESSAGE_TYPE_IPFS_REQUEST,
260        "application/cbor",
261        &payload,
262        signing_key,
263    )
264    .map_err(|e| anyhow!("failed to build ipfs-store message: {}", e))
265}
266
267#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
268#[derive(Clone, Debug)]
269pub struct IpfsDidPublisher {
270    kubo_url: String,
271}
272
273#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
274impl IpfsDidPublisher {
275    pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
276        let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
277        Ok(Self { kubo_url })
278    }
279
280    pub fn kubo_url(&self) -> &str {
281        &self.kubo_url
282    }
283
284    pub async fn publish_signed_message(
285        &self,
286        message_cbor: &[u8],
287    ) -> Result<IpfsPublishDidResponse> {
288        handle_ipfs_publish(&self.kubo_url, message_cbor).await
289    }
290
291    pub async fn publish_document(
292        &self,
293        did_document: Vec<u8>,
294        ipns_private_key: Zeroizing<Vec<u8>>,
295        options: DidDocumentPublishOptions,
296    ) -> Result<PublishedDidDocument> {
297        publish_did_document_to_kubo(
298            &self.kubo_url,
299            PinCleanupScheduler::global(),
300            did_document,
301            ipns_private_key,
302            options,
303        )
304        .await
305    }
306
307    pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
308        wait_for_api(&self.kubo_url, attempts).await
309    }
310}
311
312#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
313fn normalize_kubo_url(input: &str) -> Result<String> {
314    let trimmed = input.trim();
315    if trimmed.is_empty() {
316        return Err(anyhow!("kubo_url must not be empty"));
317    }
318
319    let parsed =
320        Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
321
322    let scheme = parsed.scheme();
323    if scheme != "http" && scheme != "https" {
324        return Err(anyhow!(
325            "kubo_url must use http or https scheme, got '{}'",
326            scheme
327        ));
328    }
329
330    if parsed.host_str().is_none() {
331        return Err(anyhow!("kubo_url must include a host"));
332    }
333
334    if parsed.query().is_some() || parsed.fragment().is_some() {
335        return Err(anyhow!(
336            "kubo_url must not include query params or fragments"
337        ));
338    }
339
340    let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
341    if let Some(port) = parsed.port() {
342        base.push(':');
343        base.push_str(&port.to_string());
344    }
345
346    let mut path = parsed.path().trim_end_matches('/').to_string();
347    if path.ends_with("/api/v0") {
348        path.truncate(path.len() - "/api/v0".len());
349    }
350    if !path.is_empty() && path != "/" {
351        if !path.starts_with('/') {
352            base.push('/');
353        }
354        base.push_str(&path);
355    }
356
357    Ok(base)
358}
359
360/// Validate a full identity-publish request from raw message CBOR bytes.
361///
362/// Used internally by [`IpfsDidPublisher::publish_signed_message`].
363pub fn validate_identity_publish_request(message_cbor: &[u8]) -> Result<ValidatedIdentityPublish> {
364    let message =
365        Message::decode(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
366    validate_identity_publish_message(&message)
367}
368
369/// Validate an `application/vnd.ma.identity.publish.request` message.
370///
371/// Verifies the DID document signature and that the sender IPNS matches the
372/// document DID. Returns a [`ValidatedIdentityPublish`].
373pub fn validate_identity_publish_message(message: &Message) -> Result<ValidatedIdentityPublish> {
374    if message.message_type != MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST {
375        return Err(anyhow!(
376            "expected {} on /ma/ipfs/0.0.1, got {}",
377            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
378            message.message_type
379        ));
380    }
381
382    let payload: IdentityPublishRequest =
383        ciborium::de::from_reader(message.payload().as_slice())
384            .map_err(|e| anyhow!("invalid identity-publish request payload: {}", e))?;
385    let IdentityPublishRequest {
386        document: document_bytes,
387        ipns_secret_key,
388    } = payload;
389
390    let sender_did = Did::try_from(message.from.as_str())
391        .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
392
393    let document = Document::decode(&document_bytes)
394        .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
395    document
396        .validate()
397        .map_err(|e| anyhow!("invalid DID document: {}", e))?;
398    document
399        .verify()
400        .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
401
402    let document_did = Did::try_from(document.id.as_str())
403        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
404
405    if document_did.ipns != sender_did.ipns {
406        return Err(anyhow!(
407            "sender IPNS '{}' does not match document IPNS '{}'",
408            sender_did.ipns,
409            document_did.ipns
410        ));
411    }
412
413    message
414        .verify_with_document(&document)
415        .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
416
417    Ok(ValidatedIdentityPublish {
418        document_bytes,
419        ipns_secret_key,
420        document,
421        document_did,
422    })
423}
424
425/// Validate an `application/vnd.ma.ipfs.request` message (generic store).
426///
427/// Extracts content and sender identity. Returns a [`ValidatedIpfsStore`].
428pub fn validate_ipfs_request(message: &Message) -> Result<ValidatedIpfsStore> {
429    if message.message_type != MESSAGE_TYPE_IPFS_REQUEST {
430        return Err(anyhow!(
431            "expected {} on /ma/ipfs/0.0.1, got {}",
432            MESSAGE_TYPE_IPFS_REQUEST,
433            message.message_type
434        ));
435    }
436
437    let payload: IpfsStoreRequest = ciborium::de::from_reader(message.payload().as_slice())
438        .map_err(|e| anyhow!("invalid IPFS store request payload: {}", e))?;
439
440    Ok(ValidatedIpfsStore {
441        content: payload.content,
442        content_type: payload.content_type,
443        sender_did: message.from.clone(),
444        msg_id: message.id.clone(),
445    })
446}
447
448#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
449async fn publish_did_document_to_kubo(
450    kubo_url: &str,
451    cleanup: &PinCleanupScheduler,
452    did_document: Vec<u8>,
453    ipns_private_key: Zeroizing<Vec<u8>>,
454    options: DidDocumentPublishOptions,
455) -> Result<PublishedDidDocument> {
456    let document = Document::decode(&did_document)
457        .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
458    let document_did = Did::try_from(document.id.as_str())
459        .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
460    let document_ipns_id = document_did.ipns.clone();
461
462    let key_parts: Vec<&str> = if options.key_parts.is_empty() {
463        vec![document_ma_type(&document)]
464    } else {
465        options.key_parts.iter().map(String::as_str).collect()
466    };
467    let key_name = ipns_key_name_for_parts(&key_parts, &document_ipns_id);
468    let existing_key = list_keys(kubo_url)
469        .await?
470        .into_iter()
471        .find(|k| k.name == key_name);
472
473    if let Some(existing) = existing_key {
474        if existing.id.trim() != document_ipns_id {
475            return Err(anyhow!(
476                "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
477                key_name,
478                existing.id,
479                document_ipns_id
480            ));
481        }
482    } else {
483        if ipns_private_key.is_empty() {
484            return Err(anyhow!(
485                "ipns_private_key is required when key is not present in Kubo"
486            ));
487        }
488
489        let raw_key: [u8; 32] = ipns_private_key
490            .as_slice()
491            .try_into()
492            .map_err(|_| anyhow!("ipns_private_key must be 32 bytes"))?;
493        let keypair = libp2p_identity::Keypair::ed25519_from_bytes(raw_key)
494            .map_err(|e| anyhow!("invalid ipns key: {}", e))?;
495        let protobuf_key = keypair
496            .to_protobuf_encoding()
497            .map_err(|e| anyhow!("failed to encode ipns key: {}", e))?;
498        let imported = import_key(kubo_url, &key_name, protobuf_key).await?;
499        if imported.id.trim() != document_ipns_id {
500            return Err(anyhow!(
501                "imported key IPNS id '{}' does not match document DID IPNS '{}'",
502                imported.id,
503                document_ipns_id
504            ));
505        }
506    }
507
508    let pin_name = options
509        .remote_pin
510        .as_ref()
511        .map(|remote| remote.name.clone())
512        .unwrap_or_else(|| key_name.clone());
513    // With overwrite, pin under an in-flight name so the fresh pin is safe
514    // while the cleanup worker removes stale pins; the worker renames it to
515    // the requested name once everything is clean.
516    let add_name = if options.overwrite {
517        in_flight_pin_name(&pin_name)
518    } else {
519        pin_name.clone()
520    };
521    let published_cid = dag_put_cbor(kubo_url, did_document, false).await?;
522    pin_add_named(kubo_url, &published_cid, &add_name).await?;
523    name_publish_with_retry(
524        kubo_url,
525        &key_name,
526        &document_ipns_id,
527        &published_cid,
528        &options.ipns,
529        options.attempts,
530        options.initial_backoff,
531    )
532    .await?;
533
534    let (cleanup_scheduled, remote_pin) =
535        confirm_pins_and_schedule_cleanup(kubo_url, cleanup, pin_name, &published_cid, options)
536            .await;
537
538    Ok(PublishedDidDocument {
539        cid: published_cid,
540        key_name,
541        ipns_id: document_ipns_id,
542        local_pinned: true,
543        cleanup_scheduled,
544        remote_pin,
545    })
546}
547
548#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
549async fn confirm_pins_and_schedule_cleanup(
550    kubo_url: &str,
551    cleanup: &PinCleanupScheduler,
552    pin_name: String,
553    published_cid: &str,
554    options: DidDocumentPublishOptions,
555) -> (bool, Option<RemotePinStatus>) {
556    let (remote_pin, remote_service) = match options.remote_pin {
557        Some(remote) => match remote_pin_with_retry(
558            kubo_url,
559            &remote,
560            published_cid,
561            options.attempts,
562            options.initial_backoff,
563        )
564        .await
565        {
566            Ok(()) => (
567                Some(RemotePinStatus::Replicated {
568                    cleanup_scheduled: false,
569                }),
570                Some(remote.service),
571            ),
572            Err(error) => (
573                Some(RemotePinStatus::Degraded {
574                    error: error.to_string(),
575                }),
576                None,
577            ),
578        },
579        None => (None, None),
580    };
581    let cleanup_scheduled = options.overwrite
582        && cleanup.schedule(PinCleanupRequest {
583            kubo_url: kubo_url.to_string(),
584            name: pin_name,
585            protected_cid: published_cid.to_string(),
586            cleanup_local: true,
587            remote_service,
588        });
589    let remote_pin = remote_pin.map(|status| match status {
590        RemotePinStatus::Replicated { .. } => RemotePinStatus::Replicated { cleanup_scheduled },
591        RemotePinStatus::Degraded { error } => RemotePinStatus::Degraded { error },
592    });
593
594    (cleanup_scheduled, remote_pin)
595}
596
597#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
598async fn remote_pin_with_retry(
599    kubo_url: &str,
600    remote: &RemotePinOptions,
601    cid: &str,
602    attempts: u32,
603    initial_backoff: Duration,
604) -> Result<()> {
605    if attempts == 0 {
606        return Err(anyhow!("remote pin attempts must be >= 1"));
607    }
608    let mut delay = initial_backoff;
609    let mut previous_delay = Duration::ZERO;
610    let mut last_error = None;
611    for attempt in 1..=attempts {
612        match remote_pin_add_named(kubo_url, &remote.service, cid, &remote.name).await {
613            Ok(()) => return Ok(()),
614            Err(error) => last_error = Some(error),
615        }
616        if attempt < attempts {
617            tokio::time::sleep(delay).await;
618            let next = previous_delay.saturating_add(delay);
619            previous_delay = delay;
620            delay = std::cmp::min(next, Duration::from_secs(30));
621        }
622    }
623    Err(last_error.expect("at least one remote pin attempt"))
624}
625
626#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
627pub async fn handle_ipfs_publish(
628    kubo_url: &str,
629    message_cbor: &[u8],
630) -> Result<IpfsPublishDidResponse> {
631    let validated = validate_identity_publish_request(message_cbor)?;
632
633    let published = publish_did_document_to_kubo(
634        kubo_url,
635        PinCleanupScheduler::global(),
636        validated.document_bytes,
637        Zeroizing::new(validated.ipns_secret_key),
638        DidDocumentPublishOptions::default(),
639    )
640    .await?;
641
642    Ok(IpfsPublishDidResponse {
643        ok: true,
644        message: "did document published via ma/ipfs/0.0.1".to_string(),
645        did: Some(validated.document_did.id()),
646        cid: Some(published.cid),
647    })
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use crate::{generate_identity_from_secret, Did, MaExtension, SigningKey};
654
655    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
656    use super::normalize_kubo_url;
657
658    fn test_identity(seed: u8) -> crate::GeneratedIdentity {
659        generate_identity_from_secret([seed; 32]).expect("identity")
660    }
661
662    fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
663        let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
664        let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
665            .expect("decode key")
666            .try_into()
667            .expect("private key bytes");
668        SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
669    }
670
671    fn ipfs_url(identity: &crate::GeneratedIdentity) -> String {
672        format!("{}#ipfs", identity.document.id)
673    }
674
675    fn expected_key_name(parts: &[&str], ipns_id: &str) -> String {
676        let hash = blake3::hash(ipns_id.as_bytes());
677        format!(
678            "{}{}-{}",
679            MA_IPNS_ALIAS_HASH_PREFIX,
680            parts.join("-"),
681            &hash.to_hex()[..16]
682        )
683    }
684
685    #[test]
686    fn document_key_name_uses_ma_type() {
687        let identity = test_identity(11);
688        let mut document = identity.document.clone();
689        document.set_ma_extension(MaExtension::new().kind("agent"));
690
691        assert_eq!(
692            ipns_key_name_for_document(&document),
693            expected_key_name(&["agent"], &identity.subject_url.ipns)
694        );
695    }
696
697    #[test]
698    fn document_key_name_falls_back_to_unknown_type() {
699        let identity = test_identity(12);
700
701        assert_eq!(
702            ipns_key_name_for_document(&identity.document),
703            expected_key_name(&["unknown"], &identity.subject_url.ipns)
704        );
705    }
706
707    #[test]
708    fn key_name_parts_allow_runtime_slug() {
709        let ipns_id = "k51qzi5uqu5example";
710
711        assert_eq!(
712            ipns_key_name_for_parts(&["runtime", "my-slug"], ipns_id),
713            expected_key_name(&["runtime", "my-slug"], ipns_id)
714        );
715        assert_eq!(
716            ipns_key_name_for_parts(&["runtime", "my-slug", "runtime"], ipns_id),
717            expected_key_name(&["runtime", "my-slug", "runtime"], ipns_id)
718        );
719    }
720
721    #[test]
722    fn key_name_parts_are_sanitized() {
723        let ipns_id = "k51qzi5uqu5example";
724
725        assert_eq!(
726            ipns_key_name_for_parts(&["Runtime", "my slug!", "***"], ipns_id),
727            expected_key_name(&["runtime", "my-slug", "unknown"], ipns_id)
728        );
729    }
730
731    #[test]
732    fn generate_request_embeds_cbor_document_and_private_key() {
733        let identity = test_identity(21);
734        let payload =
735            generate_identity_publish_request(&identity.document, b"secret-key").expect("payload");
736        let request: IdentityPublishRequest =
737            ciborium::de::from_reader(payload.as_slice()).expect("decode request");
738
739        assert_eq!(
740            request.document,
741            identity.document.encode().expect("document bytes")
742        );
743        assert_eq!(request.ipns_secret_key, b"secret-key".to_vec());
744    }
745
746    #[test]
747    fn validate_identity_publish_request_accepts_signed_request() {
748        let identity = test_identity(22);
749        let signing_key = test_signing_key(&identity);
750        let payload =
751            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
752        let message = Message::new(
753            identity.document.id.clone(),
754            ipfs_url(&identity),
755            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
756            "application/cbor",
757            &payload,
758            &signing_key,
759        )
760        .expect("message");
761        let encoded = message.encode().expect("message cbor");
762
763        let validated = validate_identity_publish_request(&encoded).expect("validated request");
764        assert_eq!(validated.document, identity.document);
765        assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
766    }
767
768    #[test]
769    fn validate_identity_publish_request_rejects_wrong_content_type() {
770        let identity = test_identity(23);
771        let signing_key = test_signing_key(&identity);
772        let payload =
773            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
774        let message = Message::new(
775            identity.document.id.clone(),
776            ipfs_url(&identity),
777            "application/x-test",
778            "application/cbor",
779            &payload,
780            &signing_key,
781        )
782        .expect("message");
783        let encoded = message.encode().expect("message cbor");
784
785        let err = validate_identity_publish_request(&encoded)
786            .err()
787            .expect("wrong content type");
788        assert!(err
789            .to_string()
790            .contains("expected application/vnd.ma.identity.publish.request"));
791    }
792
793    #[test]
794    fn validate_identity_publish_request_rejects_ipns_mismatch() {
795        let sender_identity = test_identity(24);
796        let document_identity = test_identity(25);
797        let signing_key = test_signing_key(&sender_identity);
798        let payload =
799            generate_identity_publish_request(&document_identity.document, b"private-key")
800                .expect("payload");
801        let message = Message::new(
802            sender_identity.document.id.clone(),
803            ipfs_url(&sender_identity),
804            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
805            "application/cbor",
806            &payload,
807            &signing_key,
808        )
809        .expect("message");
810        let encoded = message.encode().expect("message cbor");
811
812        let err = validate_identity_publish_request(&encoded)
813            .err()
814            .expect("ipns mismatch");
815        assert!(err.to_string().contains("does not match document IPNS"));
816    }
817
818    #[test]
819    fn validate_identity_publish_request_rejects_invalid_document_bytes() {
820        let identity = test_identity(26);
821        let signing_key = test_signing_key(&identity);
822        let payload = encode_cbor(&IdentityPublishRequest {
823            document: b"not dag-cbor".to_vec(),
824            ipns_secret_key: b"private-key".to_vec(),
825        })
826        .expect("encode request");
827        let message = Message::new(
828            identity.document.id.clone(),
829            ipfs_url(&identity),
830            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
831            "application/cbor",
832            &payload,
833            &signing_key,
834        )
835        .expect("message");
836        let encoded = message.encode().expect("message cbor");
837
838        let err = validate_identity_publish_request(&encoded)
839            .err()
840            .expect("invalid document");
841        assert!(
842            err.to_string()
843                .contains("invalid identity-publish request payload")
844                || err.to_string().contains("invalid DID document dag-cbor")
845        );
846    }
847
848    #[test]
849    fn validate_identity_publish_request_rejects_malformed_document_shapes() {
850        let identity = test_identity(27);
851        let signing_key = test_signing_key(&identity);
852
853        let mut wrong_context = identity.document.clone();
854        wrong_context.context = vec!["https://www.w3.org/ns/did/v1".to_string()];
855
856        let mut fragmented_id = identity.document.clone();
857        fragmented_id.id.push_str("#subject");
858
859        let mut fragmented_controller = identity.document.clone();
860        fragmented_controller.controller[0].push_str("#controller");
861
862        let mut wrong_method_type = identity.document.clone();
863        wrong_method_type.verification_method[0].key_type = "JsonWebKey2020".to_string();
864
865        let mut missing_relationship_target = identity.document.clone();
866        missing_relationship_target.assertion_method[0] =
867            format!("{}#unknown", missing_relationship_target.id);
868
869        let mut wrong_assertion_codec = identity.document.clone();
870        wrong_assertion_codec.assertion_method[0] = wrong_assertion_codec.key_agreement[0].clone();
871
872        let mut wrong_agreement_codec = identity.document.clone();
873        wrong_agreement_codec.key_agreement[0] = wrong_agreement_codec.assertion_method[0].clone();
874
875        for (name, document) in [
876            ("wrong context", wrong_context),
877            ("fragmented document id", fragmented_id),
878            ("fragmented controller", fragmented_controller),
879            ("wrong verification method type", wrong_method_type),
880            ("missing relationship target", missing_relationship_target),
881            ("wrong assertion codec", wrong_assertion_codec),
882            ("wrong key-agreement codec", wrong_agreement_codec),
883        ] {
884            let payload = generate_identity_publish_request(&document, b"private-key")
885                .expect("publish payload");
886            let message = Message::new(
887                identity.document.id.clone(),
888                ipfs_url(&identity),
889                MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
890                "application/cbor",
891                &payload,
892                &signing_key,
893            )
894            .expect("message");
895
896            let err = validate_identity_publish_request(&message.encode().expect("message cbor"))
897                .err()
898                .unwrap_or_else(|| panic!("accepted malformed document: {name}"));
899            assert!(
900                err.to_string().contains("invalid DID document"),
901                "unexpected error for {name}: {err}"
902            );
903        }
904    }
905
906    #[test]
907    fn validate_identity_publish_request_rejects_invalid_proof_metadata() {
908        let identity = test_identity(28);
909        let signing_key = test_signing_key(&identity);
910
911        let mut wrong_type = identity.document.clone();
912        wrong_type.proof.proof_type = "DataIntegrityProof".to_string();
913
914        let mut wrong_purpose = identity.document.clone();
915        wrong_purpose.proof.proof_purpose = "authentication".to_string();
916
917        for (name, document) in [("proof type", wrong_type), ("proof purpose", wrong_purpose)] {
918            let payload = generate_identity_publish_request(&document, b"private-key")
919                .expect("publish payload");
920            let message = Message::new(
921                identity.document.id.clone(),
922                ipfs_url(&identity),
923                MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
924                "application/cbor",
925                &payload,
926                &signing_key,
927            )
928            .expect("message");
929
930            let err = validate_identity_publish_request(&message.encode().expect("message cbor"))
931                .err()
932                .unwrap_or_else(|| panic!("accepted invalid {name}"));
933            assert!(
934                err.to_string()
935                    .contains("DID document signature verification failed"),
936                "unexpected error for {name}: {err}"
937            );
938        }
939    }
940
941    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
942    #[test]
943    fn normalizes_trailing_slash() {
944        assert_eq!(
945            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
946            "http://127.0.0.1:5001"
947        );
948    }
949
950    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
951    #[test]
952    fn strips_api_v0_suffix() {
953        assert_eq!(
954            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
955            "http://127.0.0.1:5001"
956        );
957    }
958
959    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
960    #[test]
961    fn keeps_custom_base_path() {
962        assert_eq!(
963            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
964            "http://localhost:5001/kubo"
965        );
966    }
967
968    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
969    #[test]
970    fn rejects_empty_url() {
971        assert!(normalize_kubo_url("   ").is_err());
972    }
973
974    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
975    #[test]
976    fn rejects_non_http_scheme() {
977        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
978    }
979}