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.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 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}