1use crate::{Did, Document, 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_IPFS_REQUEST;
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum IpfsRequestPayload {
32 DidDocumentPublish {
34 document: Vec<u8>,
36 ipns_secret_key: Vec<u8>,
38 },
39 Store {
41 content: Vec<u8>,
42 content_type: String,
43 },
44}
45
46fn encode_ipfs_request_payload(payload: &IpfsRequestPayload) -> Result<Vec<u8>> {
47 let mut buf = Vec::new();
48 ciborium::ser::into_writer(payload, &mut buf)
49 .map_err(|e| anyhow!("failed to encode IPFS request payload as CBOR: {}", e))?;
50 Ok(buf)
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct IpfsPublishDidResponse {
55 pub ok: bool,
56 pub message: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub did: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub cid: Option<String>,
61}
62
63pub struct ValidatedIpfsPublish {
64 pub document_bytes: Vec<u8>,
65 pub ipns_secret_key: Vec<u8>,
66 pub document: Document,
67 pub document_did: Did,
68}
69
70pub struct ValidatedIpfsStore {
72 pub content: Vec<u8>,
73 pub content_type: String,
74 pub sender_did: String,
75 pub msg_id: String,
76}
77
78pub enum ValidatedIpfsRequest {
80 DidDocumentPublish(Box<ValidatedIpfsPublish>),
81 Store(ValidatedIpfsStore),
82}
83
84pub fn generate_ipfs_publish_request(
89 did_document: &Document,
90 ipns_secret_key: &[u8],
91) -> Result<Vec<u8>> {
92 let document_bytes = did_document
93 .encode()
94 .map_err(|e| anyhow!("failed to encode DID document as dag-cbor: {}", e))?;
95 encode_ipfs_request_payload(&IpfsRequestPayload::DidDocumentPublish {
96 document: document_bytes,
97 ipns_secret_key: ipns_secret_key.to_vec(),
98 })
99}
100
101pub fn generate_ipfs_store_request(
105 sender_did: &str,
106 publisher_did: &str,
107 content: Vec<u8>,
108 content_type: &str,
109 signing_key: &crate::SigningKey,
110) -> Result<Message> {
111 let payload = encode_ipfs_request_payload(&IpfsRequestPayload::Store {
112 content,
113 content_type: content_type.to_string(),
114 })?;
115 Message::new(
116 sender_did,
117 publisher_did,
118 MESSAGE_TYPE_IPFS_REQUEST,
119 "application/cbor",
120 &payload,
121 signing_key,
122 )
123 .map_err(|e| anyhow!("failed to build ipfs-store message: {}", e))
124}
125
126#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
127#[derive(Clone, Debug)]
128pub struct IpfsDidPublisher {
129 kubo_url: String,
130}
131
132#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
133impl IpfsDidPublisher {
134 pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
135 let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
136 Ok(Self { kubo_url })
137 }
138
139 pub fn kubo_url(&self) -> &str {
140 &self.kubo_url
141 }
142
143 pub async fn publish_signed_message(
144 &self,
145 message_cbor: &[u8],
146 ) -> Result<IpfsPublishDidResponse> {
147 handle_ipfs_publish(&self.kubo_url, message_cbor).await
148 }
149
150 pub async fn publish_document(
151 &self,
152 did_document: &[u8],
153 ipns_private_key: &[u8],
154 ) -> Result<Option<String>> {
155 publish_did_document_to_kubo(&self.kubo_url, did_document, ipns_private_key).await
156 }
157
158 pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
159 wait_for_api(&self.kubo_url, attempts).await
160 }
161}
162
163#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
164fn normalize_kubo_url(input: &str) -> Result<String> {
165 let trimmed = input.trim();
166 if trimmed.is_empty() {
167 return Err(anyhow!("kubo_url must not be empty"));
168 }
169
170 let parsed =
171 Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
172
173 let scheme = parsed.scheme();
174 if scheme != "http" && scheme != "https" {
175 return Err(anyhow!(
176 "kubo_url must use http or https scheme, got '{}'",
177 scheme
178 ));
179 }
180
181 if parsed.host_str().is_none() {
182 return Err(anyhow!("kubo_url must include a host"));
183 }
184
185 if parsed.query().is_some() || parsed.fragment().is_some() {
186 return Err(anyhow!(
187 "kubo_url must not include query params or fragments"
188 ));
189 }
190
191 let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
192 if let Some(port) = parsed.port() {
193 base.push(':');
194 base.push_str(&port.to_string());
195 }
196
197 let mut path = parsed.path().trim_end_matches('/').to_string();
198 if path.ends_with("/api/v0") {
199 path.truncate(path.len() - "/api/v0".len());
200 }
201 if !path.is_empty() && path != "/" {
202 if !path.starts_with('/') {
203 base.push('/');
204 }
205 base.push_str(&path);
206 }
207
208 Ok(base)
209}
210
211pub fn validate_ipfs_publish_request(message_cbor: &[u8]) -> Result<ValidatedIpfsPublish> {
215 let message =
216 Message::decode(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
217 match validate_ipfs_request(&message)? {
218 ValidatedIpfsRequest::DidDocumentPublish(v) => Ok(*v),
219 ValidatedIpfsRequest::Store(_) => Err(anyhow!(
220 "expected did-document-publish kind on /ma/ipfs/0.0.1, got store"
221 )),
222 }
223}
224
225pub fn validate_ipfs_request(message: &Message) -> Result<ValidatedIpfsRequest> {
232 if message.message_type != MESSAGE_TYPE_IPFS_REQUEST {
233 return Err(anyhow!(
234 "expected {} on /ma/ipfs/0.0.1, got {}",
235 MESSAGE_TYPE_IPFS_REQUEST,
236 message.message_type
237 ));
238 }
239
240 let payload: IpfsRequestPayload = ciborium::de::from_reader(message.payload().as_slice())
241 .map_err(|e| anyhow!("invalid IPFS request payload: {}", e))?;
242
243 match payload {
244 IpfsRequestPayload::DidDocumentPublish {
245 document: document_bytes,
246 ipns_secret_key,
247 } => {
248 let sender_did = Did::try_from(message.from.as_str())
249 .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
250
251 let document = Document::decode(&document_bytes)
252 .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
253 document
254 .validate()
255 .map_err(|e| anyhow!("invalid DID document: {}", e))?;
256 document
257 .verify()
258 .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
259
260 let document_did = Did::try_from(document.id.as_str())
261 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
262
263 if document_did.ipns != sender_did.ipns {
264 return Err(anyhow!(
265 "sender IPNS '{}' does not match document IPNS '{}'",
266 sender_did.ipns,
267 document_did.ipns
268 ));
269 }
270
271 message
272 .verify_with_document(&document)
273 .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
274
275 Ok(ValidatedIpfsRequest::DidDocumentPublish(Box::new(
276 ValidatedIpfsPublish {
277 document_bytes,
278 ipns_secret_key,
279 document,
280 document_did,
281 },
282 )))
283 }
284 IpfsRequestPayload::Store {
285 content,
286 content_type,
287 } => Ok(ValidatedIpfsRequest::Store(ValidatedIpfsStore {
288 content,
289 content_type,
290 sender_did: message.from.clone(),
291 msg_id: message.id.clone(),
292 })),
293 }
294}
295
296#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
297pub async fn publish_did_document_to_kubo(
298 kubo_url: &str,
299 did_document: &[u8],
300 ipns_private_key: &[u8],
301) -> Result<Option<String>> {
302 let document = Document::decode(did_document)
303 .map_err(|e| anyhow!("invalid DID document dag-cbor: {}", e))?;
304 let document_did = Did::try_from(document.id.as_str())
305 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
306 let document_ipns_id = document_did.ipns.clone();
307
308 let hash = blake3::hash(document_ipns_id.as_bytes());
311 let key_name = format!("{}{}", MA_IPNS_ALIAS_HASH_PREFIX, &hash.to_hex()[..16]);
312
313 let existing_key = list_keys(kubo_url)
314 .await?
315 .into_iter()
316 .find(|k| k.name == key_name);
317
318 if let Some(existing) = existing_key {
319 if existing.id.trim() != document_ipns_id {
320 return Err(anyhow!(
321 "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
322 key_name,
323 existing.id,
324 document_ipns_id
325 ));
326 }
327 } else {
328 if ipns_private_key.is_empty() {
329 return Err(anyhow!(
330 "ipns_private_key is required when key is not present in Kubo"
331 ));
332 }
333
334 let raw_key: [u8; 32] = ipns_private_key
335 .try_into()
336 .map_err(|_| anyhow!("ipns_private_key must be 32 bytes"))?;
337 let keypair = libp2p_identity::Keypair::ed25519_from_bytes(raw_key)
338 .map_err(|e| anyhow!("invalid ipns key: {}", e))?;
339 let protobuf_key = keypair
340 .to_protobuf_encoding()
341 .map_err(|e| anyhow!("failed to encode ipns key: {}", e))?;
342 let imported = import_key(kubo_url, &key_name, protobuf_key).await?;
343 if imported.id.trim() != document_ipns_id {
344 return Err(anyhow!(
345 "imported key IPNS id '{}' does not match document DID IPNS '{}'",
346 imported.id,
347 document_ipns_id
348 ));
349 }
350 }
351
352 let published_cid = dag_put(kubo_url, &document).await?;
353 let ipns_options = IpnsPublishOptions::default();
354 name_publish_with_retry(
355 kubo_url,
356 &key_name,
357 &published_cid,
358 &ipns_options,
359 3,
360 Duration::from_secs(1),
361 )
362 .await?;
363
364 Ok(Some(published_cid))
365}
366
367#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
368pub async fn handle_ipfs_publish(
369 kubo_url: &str,
370 message_cbor: &[u8],
371) -> Result<IpfsPublishDidResponse> {
372 let validated = validate_ipfs_publish_request(message_cbor)?;
373
374 let cid = publish_did_document_to_kubo(
375 kubo_url,
376 &validated.document_bytes,
377 &validated.ipns_secret_key,
378 )
379 .await?;
380
381 Ok(IpfsPublishDidResponse {
382 ok: true,
383 message: "did document published via ma/ipfs/0.0.1".to_string(),
384 did: Some(validated.document_did.id()),
385 cid,
386 })
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::{generate_identity_from_secret, Did, SigningKey};
393
394 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
395 use super::normalize_kubo_url;
396
397 fn test_identity(seed: u8) -> crate::GeneratedIdentity {
398 generate_identity_from_secret([seed; 32]).expect("identity")
399 }
400
401 fn test_signing_key(identity: &crate::GeneratedIdentity) -> SigningKey {
402 let sign_url = Did::new_url(&identity.subject_url.ipns, None::<String>).expect("did url");
403 let private_key: [u8; 32] = hex::decode(&identity.signing_private_key_hex)
404 .expect("decode key")
405 .try_into()
406 .expect("private key bytes");
407 SigningKey::from_private_key_bytes(sign_url, private_key).expect("signing key")
408 }
409
410 #[test]
411 fn generate_request_embeds_cbor_document_and_private_key() {
412 let identity = test_identity(21);
413 let payload =
414 generate_ipfs_publish_request(&identity.document, b"secret-key").expect("payload");
415 let request: IpfsRequestPayload =
416 ciborium::de::from_reader(payload.as_slice()).expect("decode request");
417
418 match request {
419 IpfsRequestPayload::DidDocumentPublish {
420 document,
421 ipns_secret_key,
422 } => {
423 assert_eq!(
424 document,
425 identity.document.encode().expect("document bytes")
426 );
427 assert_eq!(ipns_secret_key, b"secret-key".to_vec());
428 }
429 IpfsRequestPayload::Store { .. } => panic!("expected DidDocumentPublish variant"),
430 }
431 }
432
433 #[test]
434 fn validate_ipfs_publish_request_accepts_signed_request() {
435 let identity = test_identity(22);
436 let signing_key = test_signing_key(&identity);
437 let payload =
438 generate_ipfs_publish_request(&identity.document, b"private-key").expect("payload");
439 let message = Message::new(
440 identity.document.id.clone(),
441 String::new(),
442 MESSAGE_TYPE_IPFS_REQUEST,
443 "application/cbor",
444 &payload,
445 &signing_key,
446 )
447 .expect("message");
448 let encoded = message.encode().expect("message cbor");
449
450 let validated = validate_ipfs_publish_request(&encoded).expect("validated request");
451 assert_eq!(validated.document, identity.document);
452 assert_eq!(validated.ipns_secret_key, b"private-key".to_vec());
453 }
454
455 #[test]
456 fn validate_ipfs_publish_request_rejects_wrong_content_type() {
457 let identity = test_identity(23);
458 let signing_key = test_signing_key(&identity);
459 let payload =
460 generate_ipfs_publish_request(&identity.document, b"private-key").expect("payload");
461 let message = Message::new(
462 identity.document.id.clone(),
463 String::new(),
464 "application/x-test",
465 "application/cbor",
466 &payload,
467 &signing_key,
468 )
469 .expect("message");
470 let encoded = message.encode().expect("message cbor");
471
472 let err = validate_ipfs_publish_request(&encoded)
473 .err()
474 .expect("wrong content type");
475 assert!(err
476 .to_string()
477 .contains("expected application/x-ma-ipfs-request"));
478 }
479
480 #[test]
481 fn validate_ipfs_publish_request_rejects_ipns_mismatch() {
482 let sender_identity = test_identity(24);
483 let document_identity = test_identity(25);
484 let signing_key = test_signing_key(&sender_identity);
485 let payload = generate_ipfs_publish_request(&document_identity.document, b"private-key")
486 .expect("payload");
487 let message = Message::new(
488 sender_identity.document.id.clone(),
489 String::new(),
490 MESSAGE_TYPE_IPFS_REQUEST,
491 "application/cbor",
492 &payload,
493 &signing_key,
494 )
495 .expect("message");
496 let encoded = message.encode().expect("message cbor");
497
498 let err = validate_ipfs_publish_request(&encoded)
499 .err()
500 .expect("ipns mismatch");
501 assert!(err.to_string().contains("does not match document IPNS"));
502 }
503
504 #[test]
505 fn validate_ipfs_publish_request_rejects_invalid_document_bytes() {
506 let identity = test_identity(26);
507 let signing_key = test_signing_key(&identity);
508 let payload = encode_ipfs_request_payload(&IpfsRequestPayload::DidDocumentPublish {
509 document: b"not dag-cbor".to_vec(),
510 ipns_secret_key: b"private-key".to_vec(),
511 })
512 .expect("encode request");
513 let message = Message::new(
514 identity.document.id.clone(),
515 String::new(),
516 MESSAGE_TYPE_IPFS_REQUEST,
517 "application/cbor",
518 &payload,
519 &signing_key,
520 )
521 .expect("message");
522 let encoded = message.encode().expect("message cbor");
523
524 let err = validate_ipfs_publish_request(&encoded)
525 .err()
526 .expect("invalid document");
527 assert!(
528 err.to_string().contains("invalid IPFS request payload")
529 || err.to_string().contains("invalid DID document dag-cbor")
530 );
531 }
532
533 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
534 #[test]
535 fn normalizes_trailing_slash() {
536 assert_eq!(
537 normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
538 "http://127.0.0.1:5001"
539 );
540 }
541
542 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
543 #[test]
544 fn strips_api_v0_suffix() {
545 assert_eq!(
546 normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
547 "http://127.0.0.1:5001"
548 );
549 }
550
551 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
552 #[test]
553 fn keeps_custom_base_path() {
554 assert_eq!(
555 normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
556 "http://localhost:5001/kubo"
557 );
558 }
559
560 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
561 #[test]
562 fn rejects_empty_url() {
563 assert!(normalize_kubo_url(" ").is_err());
564 }
565
566 #[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
567 #[test]
568 fn rejects_non_http_scheme() {
569 assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
570 }
571}