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, import_key, list_keys, name_publish_with_retry, wait_for_api, IpnsPublishOptions,
19};
20#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
21use reqwest::Url;
22
23use crate::service::{MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST, MESSAGE_TYPE_IPFS_REQUEST};
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct IdentityPublishRequest {
31 pub document: Vec<u8>,
33 pub ipns_secret_key: Vec<u8>,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
40pub struct IpfsStoreRequest {
41 pub content: Vec<u8>,
42 pub content_type: String,
43}
44
45fn encode_cbor<T: Serialize>(payload: &T) -> Result<Vec<u8>> {
46 let mut buf = Vec::new();
47 ciborium::ser::into_writer(payload, &mut buf)
48 .map_err(|e| anyhow!("failed to encode CBOR payload: {}", e))?;
49 Ok(buf)
50}
51
52fn sanitize_key_part(part: &str) -> String {
53 let mut sanitized = String::new();
54 let mut last_was_separator = false;
55
56 for byte in part.bytes() {
57 let ch = byte as char;
58 if ch.is_ascii_alphanumeric() {
59 sanitized.push(ch.to_ascii_lowercase());
60 last_was_separator = false;
61 } else if ch == '-' || ch == '_' {
62 if !last_was_separator && !sanitized.is_empty() {
63 sanitized.push(ch);
64 last_was_separator = true;
65 }
66 } else if !last_was_separator && !sanitized.is_empty() {
67 sanitized.push('-');
68 last_was_separator = true;
69 }
70 }
71
72 while sanitized.ends_with(['-', '_']) {
73 sanitized.pop();
74 }
75
76 if sanitized.is_empty() {
77 "unknown".to_string()
78 } else {
79 sanitized
80 }
81}
82
83fn document_ma_type(document: &Document) -> &str {
84 match document.ma.as_ref() {
85 Some(Ipld::Map(map)) => match map.get("type") {
86 Some(Ipld::String(kind)) => kind,
87 _ => "unknown",
88 },
89 _ => "unknown",
90 }
91}
92
93#[must_use]
99pub fn ipns_key_name_for_parts(parts: &[&str], ipns_id: &str) -> String {
100 let name_parts = if parts.is_empty() {
101 vec!["unknown".to_string()]
102 } else {
103 parts.iter().map(|part| sanitize_key_part(part)).collect()
104 };
105 let hash = blake3::hash(ipns_id.as_bytes());
106 format!(
107 "{}{}-{}",
108 MA_IPNS_ALIAS_HASH_PREFIX,
109 name_parts.join("-"),
110 &hash.to_hex()[..16]
111 )
112}
113
114#[must_use]
118pub fn ipns_key_name_for_document(document: &Document) -> String {
119 let document_did = Did::try_from(document.id.as_str());
120 let ipns_id = document_did
121 .as_ref()
122 .map_or(document.id.as_str(), |did| did.ipns.as_str());
123 ipns_key_name_for_parts(&[document_ma_type(document)], ipns_id)
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127pub struct IpfsPublishDidResponse {
128 pub ok: bool,
129 pub message: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub did: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub cid: Option<String>,
134}
135
136pub struct ValidatedIdentityPublish {
137 pub document_bytes: Vec<u8>,
138 pub ipns_secret_key: Vec<u8>,
139 pub document: Document,
140 pub document_did: Did,
141}
142
143pub struct ValidatedIpfsStore {
145 pub content: Vec<u8>,
146 pub content_type: String,
147 pub sender_did: String,
148 pub msg_id: String,
149}
150
151pub fn generate_identity_publish_request(
156 did_document: &Document,
157 ipns_secret_key: &[u8],
158) -> Result<Vec<u8>> {
159 let document_bytes = did_document
160 .encode()
161 .map_err(|e| anyhow!("failed to encode DID document as dag-cbor: {}", e))?;
162 encode_cbor(&IdentityPublishRequest {
163 document: document_bytes,
164 ipns_secret_key: ipns_secret_key.to_vec(),
165 })
166}
167
168pub fn generate_ipfs_store_request(
172 sender_did: &str,
173 publisher_did: &str,
174 content: Vec<u8>,
175 content_type: &str,
176 signing_key: &crate::SigningKey,
177) -> Result<Message> {
178 let payload = encode_cbor(&IpfsStoreRequest {
179 content,
180 content_type: content_type.to_string(),
181 })?;
182 Message::new(
183 sender_did,
184 publisher_did,
185 MESSAGE_TYPE_IPFS_REQUEST,
186 "application/cbor",
187 &payload,
188 signing_key,
189 )
190 .map_err(|e| anyhow!("failed to build ipfs-store message: {}", e))
191}
192
193#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
194#[derive(Clone, Debug)]
195pub struct IpfsDidPublisher {
196 kubo_url: String,
197}
198
199#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
200impl IpfsDidPublisher {
201 pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
202 let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
203 Ok(Self { kubo_url })
204 }
205
206 pub fn kubo_url(&self) -> &str {
207 &self.kubo_url
208 }
209
210 pub async fn publish_signed_message(
211 &self,
212 message_cbor: &[u8],
213 ) -> Result<IpfsPublishDidResponse> {
214 handle_ipfs_publish(&self.kubo_url, message_cbor).await
215 }
216
217 pub async fn publish_document(
218 &self,
219 did_document: &[u8],
220 ipns_private_key: &[u8],
221 ) -> Result<Option<String>> {
222 publish_did_document_to_kubo(&self.kubo_url, did_document, ipns_private_key).await
223 }
224
225 pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
226 wait_for_api(&self.kubo_url, attempts).await
227 }
228}
229
230#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
231fn normalize_kubo_url(input: &str) -> Result<String> {
232 let trimmed = input.trim();
233 if trimmed.is_empty() {
234 return Err(anyhow!("kubo_url must not be empty"));
235 }
236
237 let parsed =
238 Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
239
240 let scheme = parsed.scheme();
241 if scheme != "http" && scheme != "https" {
242 return Err(anyhow!(
243 "kubo_url must use http or https scheme, got '{}'",
244 scheme
245 ));
246 }
247
248 if parsed.host_str().is_none() {
249 return Err(anyhow!("kubo_url must include a host"));
250 }
251
252 if parsed.query().is_some() || parsed.fragment().is_some() {
253 return Err(anyhow!(
254 "kubo_url must not include query params or fragments"
255 ));
256 }
257
258 let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
259 if let Some(port) = parsed.port() {
260 base.push(':');
261 base.push_str(&port.to_string());
262 }
263
264 let mut path = parsed.path().trim_end_matches('/').to_string();
265 if path.ends_with("/api/v0") {
266 path.truncate(path.len() - "/api/v0".len());
267 }
268 if !path.is_empty() && path != "/" {
269 if !path.starts_with('/') {
270 base.push('/');
271 }
272 base.push_str(&path);
273 }
274
275 Ok(base)
276}
277
278pub fn validate_identity_publish_request(message_cbor: &[u8]) -> Result<ValidatedIdentityPublish> {
282 let message =
283 Message::decode(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
284 validate_identity_publish_message(&message)
285}
286
287pub fn validate_identity_publish_message(message: &Message) -> Result<ValidatedIdentityPublish> {
292 if message.message_type != MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST {
293 return Err(anyhow!(
294 "expected {} on /ma/ipfs/0.0.1, got {}",
295 MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
296 message.message_type
297 ));
298 }
299
300 let payload: IdentityPublishRequest =
301 ciborium::de::from_reader(message.payload().as_slice())
302 .map_err(|e| anyhow!("invalid identity-publish request payload: {}", e))?;
303 let IdentityPublishRequest {
304 document: document_bytes,
305 ipns_secret_key,
306 } = payload;
307
308 let sender_did = Did::try_from(message.from.as_str())
309 .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
310
311 let document = Document::decode(&document_bytes)
312 .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
313 document
314 .validate()
315 .map_err(|e| anyhow!("invalid DID document: {}", e))?;
316 document
317 .verify()
318 .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
319
320 let document_did = Did::try_from(document.id.as_str())
321 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
322
323 if document_did.ipns != sender_did.ipns {
324 return Err(anyhow!(
325 "sender IPNS '{}' does not match document IPNS '{}'",
326 sender_did.ipns,
327 document_did.ipns
328 ));
329 }
330
331 message
332 .verify_with_document(&document)
333 .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
334
335 Ok(ValidatedIdentityPublish {
336 document_bytes,
337 ipns_secret_key,
338 document,
339 document_did,
340 })
341}
342
343pub fn validate_ipfs_request(message: &Message) -> Result<ValidatedIpfsStore> {
347 if message.message_type != MESSAGE_TYPE_IPFS_REQUEST {
348 return Err(anyhow!(
349 "expected {} on /ma/ipfs/0.0.1, got {}",
350 MESSAGE_TYPE_IPFS_REQUEST,
351 message.message_type
352 ));
353 }
354
355 let payload: IpfsStoreRequest = ciborium::de::from_reader(message.payload().as_slice())
356 .map_err(|e| anyhow!("invalid IPFS store request payload: {}", e))?;
357
358 Ok(ValidatedIpfsStore {
359 content: payload.content,
360 content_type: payload.content_type,
361 sender_did: message.from.clone(),
362 msg_id: message.id.clone(),
363 })
364}
365
366#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
367pub async fn publish_did_document_to_kubo(
368 kubo_url: &str,
369 did_document: &[u8],
370 ipns_private_key: &[u8],
371) -> Result<Option<String>> {
372 let document = Document::decode(did_document)
373 .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
374 let document_did = Did::try_from(document.id.as_str())
375 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
376 let document_ipns_id = document_did.ipns.clone();
377
378 let key_name = ipns_key_name_for_document(&document);
381
382 let existing_key = list_keys(kubo_url)
383 .await?
384 .into_iter()
385 .find(|k| k.name == key_name);
386
387 if let Some(existing) = existing_key {
388 if existing.id.trim() != document_ipns_id {
389 return Err(anyhow!(
390 "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
391 key_name,
392 existing.id,
393 document_ipns_id
394 ));
395 }
396 } else {
397 if ipns_private_key.is_empty() {
398 return Err(anyhow!(
399 "ipns_private_key is required when key is not present in Kubo"
400 ));
401 }
402
403 let raw_key: [u8; 32] = ipns_private_key
404 .try_into()
405 .map_err(|_| anyhow!("ipns_private_key must be 32 bytes"))?;
406 let keypair = libp2p_identity::Keypair::ed25519_from_bytes(raw_key)
407 .map_err(|e| anyhow!("invalid ipns key: {}", e))?;
408 let protobuf_key = keypair
409 .to_protobuf_encoding()
410 .map_err(|e| anyhow!("failed to encode ipns key: {}", e))?;
411 let imported = import_key(kubo_url, &key_name, protobuf_key).await?;
412 if imported.id.trim() != document_ipns_id {
413 return Err(anyhow!(
414 "imported key IPNS id '{}' does not match document DID IPNS '{}'",
415 imported.id,
416 document_ipns_id
417 ));
418 }
419 }
420
421 let published_cid = dag_put(kubo_url, &document).await?;
422 let ipns_options = IpnsPublishOptions::default();
423 name_publish_with_retry(
424 kubo_url,
425 &key_name,
426 &published_cid,
427 &ipns_options,
428 3,
429 Duration::from_secs(1),
430 )
431 .await?;
432
433 Ok(Some(published_cid))
434}
435
436#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
437pub async fn handle_ipfs_publish(
438 kubo_url: &str,
439 message_cbor: &[u8],
440) -> Result<IpfsPublishDidResponse> {
441 let validated = validate_identity_publish_request(message_cbor)?;
442
443 let cid = publish_did_document_to_kubo(
444 kubo_url,
445 &validated.document_bytes,
446 &validated.ipns_secret_key,
447 )
448 .await?;
449
450 Ok(IpfsPublishDidResponse {
451 ok: true,
452 message: "did document published via ma/ipfs/0.0.1".to_string(),
453 did: Some(validated.document_did.id()),
454 cid,
455 })
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use crate::{generate_identity_from_secret, Did, MaExtension, SigningKey};
462
463 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
464 use super::normalize_kubo_url;
465
466 fn test_identity(seed: u8) -> crate::GeneratedIdentity {
467 generate_identity_from_secret([seed; 32]).expect("identity")
468 }
469
470 fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
471 let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
472 let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
473 .expect("decode key")
474 .try_into()
475 .expect("private key bytes");
476 SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
477 }
478
479 fn expected_key_name(parts: &[&str], ipns_id: &str) -> String {
480 let hash = blake3::hash(ipns_id.as_bytes());
481 format!(
482 "{}{}-{}",
483 MA_IPNS_ALIAS_HASH_PREFIX,
484 parts.join("-"),
485 &hash.to_hex()[..16]
486 )
487 }
488
489 #[test]
490 fn document_key_name_uses_ma_type() {
491 let identity = test_identity(11);
492 let mut document = identity.document.clone();
493 document.set_ma_extension(MaExtension::new().kind("agent"));
494
495 assert_eq!(
496 ipns_key_name_for_document(&document),
497 expected_key_name(&["agent"], &identity.subject_url.ipns)
498 );
499 }
500
501 #[test]
502 fn document_key_name_falls_back_to_unknown_type() {
503 let identity = test_identity(12);
504
505 assert_eq!(
506 ipns_key_name_for_document(&identity.document),
507 expected_key_name(&["unknown"], &identity.subject_url.ipns)
508 );
509 }
510
511 #[test]
512 fn key_name_parts_allow_runtime_slug() {
513 let ipns_id = "k51qzi5uqu5example";
514
515 assert_eq!(
516 ipns_key_name_for_parts(&["runtime", "my-slug"], ipns_id),
517 expected_key_name(&["runtime", "my-slug"], ipns_id)
518 );
519 assert_eq!(
520 ipns_key_name_for_parts(&["runtime", "my-slug", "runtime"], ipns_id),
521 expected_key_name(&["runtime", "my-slug", "runtime"], ipns_id)
522 );
523 }
524
525 #[test]
526 fn key_name_parts_are_sanitized() {
527 let ipns_id = "k51qzi5uqu5example";
528
529 assert_eq!(
530 ipns_key_name_for_parts(&["Runtime", "my slug!", "***"], ipns_id),
531 expected_key_name(&["runtime", "my-slug", "unknown"], ipns_id)
532 );
533 }
534
535 #[test]
536 fn generate_request_embeds_cbor_document_and_private_key() {
537 let identity = test_identity(21);
538 let payload =
539 generate_identity_publish_request(&identity.document, b"secret-key").expect("payload");
540 let request: IdentityPublishRequest =
541 ciborium::de::from_reader(payload.as_slice()).expect("decode request");
542
543 assert_eq!(
544 request.document,
545 identity.document.encode().expect("document bytes")
546 );
547 assert_eq!(request.ipns_secret_key, b"secret-key".to_vec());
548 }
549
550 #[test]
551 fn validate_identity_publish_request_accepts_signed_request() {
552 let identity = test_identity(22);
553 let signing_key = test_signing_key(&identity);
554 let payload =
555 generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
556 let message = Message::new(
557 identity.document.id.clone(),
558 String::new(),
559 MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
560 "application/cbor",
561 &payload,
562 &signing_key,
563 )
564 .expect("message");
565 let encoded = message.encode().expect("message cbor");
566
567 let validated = validate_identity_publish_request(&encoded).expect("validated request");
568 assert_eq!(validated.document, identity.document);
569 assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
570 }
571
572 #[test]
573 fn validate_identity_publish_request_rejects_wrong_content_type() {
574 let identity = test_identity(23);
575 let signing_key = test_signing_key(&identity);
576 let payload =
577 generate_identity_publish_request(&identity.document, b"private-key").expect("payload");
578 let message = Message::new(
579 identity.document.id.clone(),
580 String::new(),
581 "application/x-test",
582 "application/cbor",
583 &payload,
584 &signing_key,
585 )
586 .expect("message");
587 let encoded = message.encode().expect("message cbor");
588
589 let err = validate_identity_publish_request(&encoded)
590 .err()
591 .expect("wrong content type");
592 assert!(err
593 .to_string()
594 .contains("expected application/vnd.ma.identity.publish.request"));
595 }
596
597 #[test]
598 fn validate_identity_publish_request_rejects_ipns_mismatch() {
599 let sender_identity = test_identity(24);
600 let document_identity = test_identity(25);
601 let signing_key = test_signing_key(&sender_identity);
602 let payload =
603 generate_identity_publish_request(&document_identity.document, b"private-key")
604 .expect("payload");
605 let message = Message::new(
606 sender_identity.document.id.clone(),
607 String::new(),
608 MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
609 "application/cbor",
610 &payload,
611 &signing_key,
612 )
613 .expect("message");
614 let encoded = message.encode().expect("message cbor");
615
616 let err = validate_identity_publish_request(&encoded)
617 .err()
618 .expect("ipns mismatch");
619 assert!(err.to_string().contains("does not match document IPNS"));
620 }
621
622 #[test]
623 fn validate_identity_publish_request_rejects_invalid_document_bytes() {
624 let identity = test_identity(26);
625 let signing_key = test_signing_key(&identity);
626 let payload = encode_cbor(&IdentityPublishRequest {
627 document: b"not dag-cbor".to_vec(),
628 ipns_secret_key: b"private-key".to_vec(),
629 })
630 .expect("encode request");
631 let message = Message::new(
632 identity.document.id.clone(),
633 String::new(),
634 MESSAGE_TYPE_IDENTITY_PUBLISH_REQUEST,
635 "application/cbor",
636 &payload,
637 &signing_key,
638 )
639 .expect("message");
640 let encoded = message.encode().expect("message cbor");
641
642 let err = validate_identity_publish_request(&encoded)
643 .err()
644 .expect("invalid document");
645 assert!(
646 err.to_string()
647 .contains("invalid identity-publish request payload")
648 || err.to_string().contains("invalid DID document dag-cbor")
649 );
650 }
651
652 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
653 #[test]
654 fn normalizes_trailing_slash() {
655 assert_eq!(
656 normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
657 "http://127.0.0.1:5001"
658 );
659 }
660
661 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
662 #[test]
663 fn strips_api_v0_suffix() {
664 assert_eq!(
665 normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
666 "http://127.0.0.1:5001"
667 );
668 }
669
670 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
671 #[test]
672 fn keeps_custom_base_path() {
673 assert_eq!(
674 normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
675 "http://localhost:5001/kubo"
676 );
677 }
678
679 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
680 #[test]
681 fn rejects_empty_url() {
682 assert!(normalize_kubo_url(" ").is_err());
683 }
684
685 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
686 #[test]
687 fn rejects_non_http_scheme() {
688 assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
689 }
690}