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