1use 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#[derive(Clone, Debug, Serialize, Deserialize)]
34pub struct IdentityPublishRequest {
35 pub document: Vec<u8>,
37 pub ipns_secret_key: Vec<u8>,
39}
40
41#[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#[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#[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)]
142pub struct DidDocumentPublishOptions {
144 pub key_parts: Vec<String>,
146 pub ipns: IpnsPublishOptions,
148 pub attempts: u32,
150 pub initial_backoff: Duration,
152 pub remote_pin: Option<RemotePinOptions>,
154 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)]
174pub struct RemotePinOptions {
176 pub service: String,
178 pub name: String,
180}
181
182#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
183#[derive(Clone, Debug, Eq, PartialEq)]
184pub enum RemotePinStatus {
186 Replicated { cleanup_scheduled: bool },
188 Degraded { error: String },
190}
191
192#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct PublishedDidDocument {
196 pub cid: String,
198 pub key_name: String,
200 pub ipns_id: String,
202 pub local_pinned: bool,
204 pub cleanup_scheduled: bool,
206 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
217pub struct ValidatedIpfsStore {
219 pub content: Vec<u8>,
220 pub content_type: String,
221 pub sender_did: String,
222 pub msg_id: String,
223}
224
225pub 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
242pub 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
360pub 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
369pub 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
425pub 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 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}