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 expected_key_name(parts: &[&str], ipns_id: &str) -> String {
682        let hash = blake3::hash(ipns_id.as_bytes());
683        format!(
684            "{}{}-{}",
685            MA_IPNS_ALIAS_HASH_PREFIX,
686            parts.join("-"),
687            &hash.to_hex()[..16]
688        )
689    }
690
691    #[test]
692    fn document_key_name_uses_ma_type() {
693        let identity = test_identity(11);
694        let mut document = identity.document.clone();
695        document.set_ma_extension(MaExtension::new().kind("agent"));
696
697        assert_eq!(
698            ipns_key_name_for_document(&document),
699            expected_key_name(&["agent"], &identity.subject_url.ipns)
700        );
701    }
702
703    #[test]
704    fn document_key_name_falls_back_to_unknown_type() {
705        let identity = test_identity(12);
706
707        assert_eq!(
708            ipns_key_name_for_document(&identity.document),
709            expected_key_name(&["unknown"], &identity.subject_url.ipns)
710        );
711    }
712
713    #[test]
714    fn key_name_parts_allow_runtime_slug() {
715        let ipns_id = "k51qzi5uqu5example";
716
717        assert_eq!(
718            ipns_key_name_for_parts(&["runtime", "my-slug"], ipns_id),
719            expected_key_name(&["runtime", "my-slug"], ipns_id)
720        );
721        assert_eq!(
722            ipns_key_name_for_parts(&["runtime", "my-slug", "runtime"], ipns_id),
723            expected_key_name(&["runtime", "my-slug", "runtime"], ipns_id)
724        );
725    }
726
727    #[test]
728    fn key_name_parts_are_sanitized() {
729        let ipns_id = "k51qzi5uqu5example";
730
731        assert_eq!(
732            ipns_key_name_for_parts(&["Runtime", "my slug!", "***"], ipns_id),
733            expected_key_name(&["runtime", "my-slug", "unknown"], ipns_id)
734        );
735    }
736
737    #[test]
738    fn generate_request_embeds_cbor_document_and_private_key() {
739        let identity = test_identity(21);
740        let payload =
741            generate_identity_publish_request(&identity.document, b"secret-key").expect("payload");
742        let request: IdentityPublishRequest =
743            ciborium::de::from_reader(payload.as_slice()).expect("decode request");
744
745        assert_eq!(
746            request.document,
747            identity.document.encode().expect("document bytes")
748        );
749        assert_eq!(request.ipns_secret_key, b"secret-key".to_vec());
750    }
751
752    #[test]
753    fn validate_identity_publish_request_accepts_signed_request() {
754        let identity = test_identity(22);
755        let signing_key = test_signing_key(&identity);
756        let payload =
757            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
758        let message = Message::new(
759            identity.document.id.clone(),
760            String::new(),
761            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
762            "application/cbor",
763            &payload,
764            &signing_key,
765        )
766        .expect("message");
767        let encoded = message.encode().expect("message cbor");
768
769        let validated = validate_identity_publish_request(&encoded).expect("validated request");
770        assert_eq!(validated.document, identity.document);
771        assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
772    }
773
774    #[test]
775    fn validate_identity_publish_request_rejects_wrong_content_type() {
776        let identity = test_identity(23);
777        let signing_key = test_signing_key(&identity);
778        let payload =
779            generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
780        let message = Message::new(
781            identity.document.id.clone(),
782            String::new(),
783            "application/x-test",
784            "application/cbor",
785            &payload,
786            &signing_key,
787        )
788        .expect("message");
789        let encoded = message.encode().expect("message cbor");
790
791        let err = validate_identity_publish_request(&encoded)
792            .err()
793            .expect("wrong content type");
794        assert!(err
795            .to_string()
796            .contains("expected application/vnd.ma.identity.publish.request"));
797    }
798
799    #[test]
800    fn validate_identity_publish_request_rejects_ipns_mismatch() {
801        let sender_identity = test_identity(24);
802        let document_identity = test_identity(25);
803        let signing_key = test_signing_key(&sender_identity);
804        let payload =
805            generate_identity_publish_request(&document_identity.document, b"private-key")
806                .expect("payload");
807        let message = Message::new(
808            sender_identity.document.id.clone(),
809            String::new(),
810            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
811            "application/cbor",
812            &payload,
813            &signing_key,
814        )
815        .expect("message");
816        let encoded = message.encode().expect("message cbor");
817
818        let err = validate_identity_publish_request(&encoded)
819            .err()
820            .expect("ipns mismatch");
821        assert!(err.to_string().contains("does not match document IPNS"));
822    }
823
824    #[test]
825    fn validate_identity_publish_request_rejects_invalid_document_bytes() {
826        let identity = test_identity(26);
827        let signing_key = test_signing_key(&identity);
828        let payload = encode_cbor(&IdentityPublishRequest {
829            document: b"not dag-cbor".to_vec(),
830            ipns_secret_key: b"private-key".to_vec(),
831        })
832        .expect("encode request");
833        let message = Message::new(
834            identity.document.id.clone(),
835            String::new(),
836            MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
837            "application/cbor",
838            &payload,
839            &signing_key,
840        )
841        .expect("message");
842        let encoded = message.encode().expect("message cbor");
843
844        let err = validate_identity_publish_request(&encoded)
845            .err()
846            .expect("invalid document");
847        assert!(
848            err.to_string()
849                .contains("invalid identity-publish request payload")
850                || err.to_string().contains("invalid DID document dag-cbor")
851        );
852    }
853
854    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
855    #[test]
856    fn normalizes_trailing_slash() {
857        assert_eq!(
858            normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
859            "http://127.0.0.1:5001"
860        );
861    }
862
863    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
864    #[test]
865    fn strips_api_v0_suffix() {
866        assert_eq!(
867            normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
868            "http://127.0.0.1:5001"
869        );
870    }
871
872    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
873    #[test]
874    fn keeps_custom_base_path() {
875        assert_eq!(
876            normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
877            "http://localhost:5001/kubo"
878        );
879    }
880
881    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
882    #[test]
883    fn rejects_empty_url() {
884        assert!(normalize_kubo_url("   ").is_err());
885    }
886
887    #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
888    #[test]
889    fn rejects_non_http_scheme() {
890        assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
891    }
892}