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.overwrite,
562            options.attempts,
563            options.initial_backoff,
564        )
565        .await
566        {
567            Ok(()) => (
568                Some(RemotePinStatus::Replicated {
569                    cleanup_scheduled: false,
570                }),
571                Some(remote.service),
572            ),
573            Err(error) => (
574                Some(RemotePinStatus::Degraded {
575                    error: error.to_string(),
576                }),
577                None,
578            ),
579        },
580        None => (None, None),
581    };
582    let cleanup_scheduled = options.overwrite
583        && cleanup.schedule(PinCleanupRequest {
584            kubo_url: kubo_url.to_string(),
585            name: pin_name,
586            protected_cid: published_cid.to_string(),
587            cleanup_local: true,
588            remote_service,
589        });
590    let remote_pin = remote_pin.map(|status| match status {
591        RemotePinStatus::Replicated { .. } => RemotePinStatus::Replicated { cleanup_scheduled },
592        RemotePinStatus::Degraded { error } => RemotePinStatus::Degraded { error },
593    });
594
595    (cleanup_scheduled, remote_pin)
596}
597
598#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
599async fn remote_pin_with_retry(
600    kubo_url: &str,
601    remote: &RemotePinOptions,
602    cid: &str,
603    overwrite: bool,
604    attempts: u32,
605    initial_backoff: Duration,
606) -> Result<()> {
607    if attempts == 0 {
608        return Err(anyhow!("remote pin attempts must be >= 1"));
609    }
610    // Mirror the local policy: with overwrite the fresh remote pin gets the
611    // in-flight name and is renamed by the cleanup worker after the old pins
612    // are gone.
613    let add_name = if overwrite {
614        in_flight_pin_name(&remote.name)
615    } else {
616        remote.name.clone()
617    };
618    let mut delay = initial_backoff;
619    let mut previous_delay = Duration::ZERO;
620    let mut last_error = None;
621    for attempt in 1..=attempts {
622        match remote_pin_add_named(kubo_url, &remote.service, cid, &add_name).await {
623            Ok(()) => return Ok(()),
624            Err(error) => last_error = Some(error),
625        }
626        if attempt < attempts {
627            tokio::time::sleep(delay).await;
628            let next = previous_delay.saturating_add(delay);
629            previous_delay = delay;
630            delay = std::cmp::min(next, Duration::from_secs(30));
631        }
632    }
633    Err(last_error.expect("at least one remote pin attempt"))
634}
635
636#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
637pub async fn handle_ipfs_publish(
638    kubo_url: &str,
639    message_cbor: &[u8],
640) -> Result<IpfsPublishDidResponse> {
641    let validated = validate_identity_publish_request(message_cbor)?;
642
643    let published = publish_did_document_to_kubo(
644        kubo_url,
645        PinCleanupScheduler::global(),
646        validated.document_bytes,
647        Zeroizing::new(validated.ipns_secret_key),
648        DidDocumentPublishOptions::default(),
649    )
650    .await?;
651
652    Ok(IpfsPublishDidResponse {
653        ok: true,
654        message: "did document published via ma/ipfs/0.0.1".to_string(),
655        did: Some(validated.document_did.id()),
656        cid: Some(published.cid),
657    })
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use crate::{generate_identity_from_secret, Did, MaExtension, SigningKey};
664
665    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
666    use super::normalize_kubo_url;
667
668    fn test_identity(seed: u8) -> crate::GeneratedIdentity {
669        generate_identity_from_secret([seed; 32]).expect("identity")
670    }
671
672    fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
673        let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
674        let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
675            .expect("decode key")
676            .try_into()
677            .expect("private key bytes");
678        SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
679    }
680
681    fn ipfs_url(identity: &crate::GeneratedIdentity) -> String {
682        format!("{}#ipfs", identity.document.id)
683    }
684
685    fn expected_key_name(parts: &[&str], ipns_id: &str) -> String {
686        let hash = blake3::hash(ipns_id.as_bytes());
687        format!(
688            "{}{}-{}",
689            MA_IPNS_ALIAS_HASH_PREFIX,
690            parts.join("-"),
691            &hash.to_hex()[..16]
692        )
693    }
694
695    #[test]
696    fn document_key_name_uses_ma_type() {
697        let identity = test_identity(11);
698        let mut document = identity.document.clone();
699        document.set_ma_extension(MaExtension::new().kind("agent"));
700
701        assert_eq!(
702            ipns_key_name_for_document(&document),
703            expected_key_name(&["agent"], &identity.subject_url.ipns)
704        );
705    }
706
707    #[test]
708    fn document_key_name_falls_back_to_unknown_type() {
709        let identity = test_identity(12);
710
711        assert_eq!(
712            ipns_key_name_for_document(&identity.document),
713            expected_key_name(&["unknown"], &identity.subject_url.ipns)
714        );
715    }
716
717    #[test]
718    fn key_name_parts_allow_runtime_slug() {
719        let ipns_id = "k51qzi5uqu5example";
720
721        assert_eq!(
722            ipns_key_name_for_parts(&["runtime", "my-slug"], ipns_id),
723            expected_key_name(&["runtime", "my-slug"], ipns_id)
724        );
725        assert_eq!(
726            ipns_key_name_for_parts(&["runtime", "my-slug", "runtime"], ipns_id),
727            expected_key_name(&["runtime", "my-slug", "runtime"], ipns_id)
728        );
729    }
730
731    #[test]
732    fn key_name_parts_are_sanitized() {
733        let ipns_id = "k51qzi5uqu5example";
734
735        assert_eq!(
736            ipns_key_name_for_parts(&["Runtime", "my slug!", "***"], ipns_id),
737            expected_key_name(&["runtime", "my-slug", "unknown"], ipns_id)
738        );
739    }
740
741    #[test]
742    fn generate_request_embeds_cbor_document_and_private_key() {
743        let identity = test_identity(21);
744        let payload =
745            generate_identity_publish_request(&identity.document, b"secret-key").expect("payload");
746        let request: IdentityPublishRequest =
747            ciborium::de::from_reader(payload.as_slice()).expect("decode request");
748
749        assert_eq!(
750            request.document,
751            identity.document.encode().expect("document bytes")
752        );
753        assert_eq!(request.ipns_secret_key, b"secret-key".to_vec());
754    }
755
756    #[test]
757    fn validate_identity_publish_request_accepts_signed_request() {
758        let identity = test_identity(22);
759        let signing_key = test_signing_key(&identity);
760        let payload =
761            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
762        let message = Message::new(
763            identity.document.id.clone(),
764            ipfs_url(&identity),
765            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
766            "application/cbor",
767            &payload,
768            &signing_key,
769        )
770        .expect("message");
771        let encoded = message.encode().expect("message cbor");
772
773        let validated = validate_identity_publish_request(&encoded).expect("validated request");
774        assert_eq!(validated.document, identity.document);
775        assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
776    }
777
778    #[test]
779    fn validate_identity_publish_request_rejects_wrong_content_type() {
780        let identity = test_identity(23);
781        let signing_key = test_signing_key(&identity);
782        let payload =
783            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
784        let message = Message::new(
785            identity.document.id.clone(),
786            ipfs_url(&identity),
787            "application/x-test",
788            "application/cbor",
789            &payload,
790            &signing_key,
791        )
792        .expect("message");
793        let encoded = message.encode().expect("message cbor");
794
795        let err = validate_identity_publish_request(&encoded)
796            .err()
797            .expect("wrong content type");
798        assert!(err
799            .to_string()
800            .contains("expected application/vnd.ma.identity.publish.request"));
801    }
802
803    #[test]
804    fn validate_identity_publish_request_rejects_ipns_mismatch() {
805        let sender_identity = test_identity(24);
806        let document_identity = test_identity(25);
807        let signing_key = test_signing_key(&sender_identity);
808        let payload =
809            generate_identity_publish_request(&document_identity.document, b"private-key")
810                .expect("payload");
811        let message = Message::new(
812            sender_identity.document.id.clone(),
813            ipfs_url(&sender_identity),
814            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
815            "application/cbor",
816            &payload,
817            &signing_key,
818        )
819        .expect("message");
820        let encoded = message.encode().expect("message cbor");
821
822        let err = validate_identity_publish_request(&encoded)
823            .err()
824            .expect("ipns mismatch");
825        assert!(err.to_string().contains("does not match document IPNS"));
826    }
827
828    #[test]
829    fn validate_identity_publish_request_rejects_invalid_document_bytes() {
830        let identity = test_identity(26);
831        let signing_key = test_signing_key(&identity);
832        let payload = encode_cbor(&IdentityPublishRequest {
833            document: b"not dag-cbor".to_vec(),
834            ipns_secret_key: b"private-key".to_vec(),
835        })
836        .expect("encode request");
837        let message = Message::new(
838            identity.document.id.clone(),
839            ipfs_url(&identity),
840            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
841            "application/cbor",
842            &payload,
843            &signing_key,
844        )
845        .expect("message");
846        let encoded = message.encode().expect("message cbor");
847
848        let err = validate_identity_publish_request(&encoded)
849            .err()
850            .expect("invalid document");
851        assert!(
852            err.to_string()
853                .contains("invalid identity-publish request payload")
854                || err.to_string().contains("invalid DID document dag-cbor")
855        );
856    }
857
858    #[test]
859    fn validate_identity_publish_request_rejects_malformed_document_shapes() {
860        let identity = test_identity(27);
861        let signing_key = test_signing_key(&identity);
862
863        let mut wrong_context = identity.document.clone();
864        wrong_context.context = vec!["https://www.w3.org/ns/did/v1".to_string()];
865
866        let mut fragmented_id = identity.document.clone();
867        fragmented_id.id.push_str("#subject");
868
869        let mut fragmented_controller = identity.document.clone();
870        fragmented_controller.controller[0].push_str("#controller");
871
872        let mut wrong_method_type = identity.document.clone();
873        wrong_method_type.verification_method[0].key_type = "JsonWebKey2020".to_string();
874
875        let mut missing_relationship_target = identity.document.clone();
876        missing_relationship_target.assertion_method[0] =
877            format!("{}#unknown", missing_relationship_target.id);
878
879        let mut wrong_assertion_codec = identity.document.clone();
880        wrong_assertion_codec.assertion_method[0] = wrong_assertion_codec.key_agreement[0].clone();
881
882        let mut wrong_agreement_codec = identity.document.clone();
883        wrong_agreement_codec.key_agreement[0] = wrong_agreement_codec.assertion_method[0].clone();
884
885        for (name, document) in [
886            ("wrong context", wrong_context),
887            ("fragmented document id", fragmented_id),
888            ("fragmented controller", fragmented_controller),
889            ("wrong verification method type", wrong_method_type),
890            ("missing relationship target", missing_relationship_target),
891            ("wrong assertion codec", wrong_assertion_codec),
892            ("wrong key-agreement codec", wrong_agreement_codec),
893        ] {
894            let payload = generate_identity_publish_request(&document, b"private-key")
895                .expect("publish payload");
896            let message = Message::new(
897                identity.document.id.clone(),
898                ipfs_url(&identity),
899                MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
900                "application/cbor",
901                &payload,
902                &signing_key,
903            )
904            .expect("message");
905
906            let err = validate_identity_publish_request(&message.encode().expect("message cbor"))
907                .err()
908                .unwrap_or_else(|| panic!("accepted malformed document: {name}"));
909            assert!(
910                err.to_string().contains("invalid DID document"),
911                "unexpected error for {name}: {err}"
912            );
913        }
914    }
915
916    #[test]
917    fn validate_identity_publish_request_rejects_invalid_proof_metadata() {
918        let identity = test_identity(28);
919        let signing_key = test_signing_key(&identity);
920
921        let mut wrong_type = identity.document.clone();
922        wrong_type.proof.proof_type = "DataIntegrityProof".to_string();
923
924        let mut wrong_purpose = identity.document.clone();
925        wrong_purpose.proof.proof_purpose = "authentication".to_string();
926
927        for (name, document) in [("proof type", wrong_type), ("proof purpose", wrong_purpose)] {
928            let payload = generate_identity_publish_request(&document, b"private-key")
929                .expect("publish payload");
930            let message = Message::new(
931                identity.document.id.clone(),
932                ipfs_url(&identity),
933                MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
934                "application/cbor",
935                &payload,
936                &signing_key,
937            )
938            .expect("message");
939
940            let err = validate_identity_publish_request(&message.encode().expect("message cbor"))
941                .err()
942                .unwrap_or_else(|| panic!("accepted invalid {name}"));
943            assert!(
944                err.to_string()
945                    .contains("DID document signature verification failed"),
946                "unexpected error for {name}: {err}"
947            );
948        }
949    }
950
951    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
952    #[test]
953    fn normalizes_trailing_slash() {
954        assert_eq!(
955            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
956            "http://127.0.0.1:5001"
957        );
958    }
959
960    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
961    #[test]
962    fn strips_api_v0_suffix() {
963        assert_eq!(
964            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
965            "http://127.0.0.1:5001"
966        );
967    }
968
969    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
970    #[test]
971    fn keeps_custom_base_path() {
972        assert_eq!(
973            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
974            "http://localhost:5001/kubo"
975        );
976    }
977
978    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
979    #[test]
980    fn rejects_empty_url() {
981        assert!(normalize_kubo_url("   ").is_err());
982    }
983
984    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
985    #[test]
986    fn rejects_non_http_scheme() {
987        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
988    }
989}