1use anyhow::{anyhow, Result};
8use did_ma::{Did, Document, Message};
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 base64::engine::general_purpose::STANDARD as B64;
15#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
16use base64::Engine;
17#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
18use std::time::Duration;
19
20#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
21use super::kubo::{dag_put, import_key, list_keys, name_publish_with_retry, IpnsPublishOptions};
22#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
23use reqwest::Url;
24
25use crate::service::CONTENT_TYPE_IPFS_REQUEST;
26
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct IpfsPublishDidRequest {
29 pub did_document_json: String,
30 #[serde(default)]
31 pub ipns_private_key_base64: String,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
35pub struct IpfsPublishDidResponse {
36 pub ok: bool,
37 pub message: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub did: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub cid: Option<String>,
42}
43
44pub struct ValidatedIpfsPublish {
45 pub request: IpfsPublishDidRequest,
46 pub document: Document,
47 pub document_did: Did,
48}
49
50#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
51#[derive(Clone, Debug)]
52pub struct IpfsDidPublisher {
53 kubo_url: String,
54}
55
56#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
57impl IpfsDidPublisher {
58 pub fn new(kubo_url: impl AsRef<str>) -> Result<Self> {
59 let kubo_url = normalize_kubo_url(kubo_url.as_ref())?;
60 Ok(Self { kubo_url })
61 }
62
63 pub fn kubo_url(&self) -> &str {
64 &self.kubo_url
65 }
66
67 pub async fn publish_signed_message(
68 &self,
69 message_cbor: &[u8],
70 ) -> Result<IpfsPublishDidResponse> {
71 handle_ipfs_publish(&self.kubo_url, message_cbor).await
72 }
73
74 pub async fn publish_document(
75 &self,
76 did_document_json: &str,
77 ipns_private_key_base64: &str,
78 ) -> Result<Option<String>> {
79 publish_did_document_to_kubo(&self.kubo_url, did_document_json, ipns_private_key_base64)
80 .await
81 }
82
83 pub async fn wait_until_ready(&self, attempts: u32) -> Result<()> {
84 super::kubo::wait_for_api(&self.kubo_url, attempts).await
85 }
86}
87
88#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
89fn normalize_kubo_url(input: &str) -> Result<String> {
90 let trimmed = input.trim();
91 if trimmed.is_empty() {
92 return Err(anyhow!("kubo_url must not be empty"));
93 }
94
95 let parsed =
96 Url::parse(trimmed).map_err(|e| anyhow!("invalid kubo_url '{}': {}", trimmed, e))?;
97
98 let scheme = parsed.scheme();
99 if scheme != "http" && scheme != "https" {
100 return Err(anyhow!(
101 "kubo_url must use http or https scheme, got '{}'",
102 scheme
103 ));
104 }
105
106 if parsed.host_str().is_none() {
107 return Err(anyhow!("kubo_url must include a host"));
108 }
109
110 if parsed.query().is_some() || parsed.fragment().is_some() {
111 return Err(anyhow!(
112 "kubo_url must not include query params or fragments"
113 ));
114 }
115
116 let mut base = format!("{}://{}", scheme, parsed.host_str().unwrap_or_default());
117 if let Some(port) = parsed.port() {
118 base.push(':');
119 base.push_str(&port.to_string());
120 }
121
122 let mut path = parsed.path().trim_end_matches('/').to_string();
123 if path.ends_with("/api/v0") {
124 path.truncate(path.len() - "/api/v0".len());
125 }
126 if !path.is_empty() && path != "/" {
127 if !path.starts_with('/') {
128 base.push('/');
129 }
130 base.push_str(&path);
131 }
132
133 Ok(base)
134}
135
136pub fn validate_ipfs_publish_request(message_cbor: &[u8]) -> Result<ValidatedIpfsPublish> {
137 let message =
138 Message::from_cbor(message_cbor).map_err(|e| anyhow!("invalid signed message: {}", e))?;
139
140 if message.content_type != CONTENT_TYPE_IPFS_REQUEST {
141 return Err(anyhow!(
142 "expected {} on ma/ipfs/1, got {}",
143 CONTENT_TYPE_IPFS_REQUEST,
144 message.content_type
145 ));
146 }
147
148 let sender_did = Did::try_from(message.from.as_str())
149 .map_err(|e| anyhow!("invalid sender did '{}': {}", message.from, e))?;
150
151 let request: IpfsPublishDidRequest = serde_json::from_slice(&message.content)
152 .map_err(|e| anyhow!("invalid IPFS publish payload: {}", e))?;
153
154 let document = Document::unmarshal(&request.did_document_json)
155 .map_err(|e| anyhow!("invalid DID document JSON: {}", e))?;
156 document
157 .validate()
158 .map_err(|e| anyhow!("invalid DID document: {}", e))?;
159 document
160 .verify()
161 .map_err(|e| anyhow!("DID document signature verification failed: {}", e))?;
162
163 let document_did = Did::try_from(document.id.as_str())
164 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
165
166 if document_did.ipns != sender_did.ipns {
167 return Err(anyhow!(
168 "sender IPNS '{}' does not match document IPNS '{}'",
169 sender_did.ipns,
170 document_did.ipns
171 ));
172 }
173
174 message
175 .verify_with_document(&document)
176 .map_err(|e| anyhow!("request signature verification failed: {}", e))?;
177
178 Ok(ValidatedIpfsPublish {
179 request,
180 document,
181 document_did,
182 })
183}
184
185#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
186pub async fn publish_did_document_to_kubo(
187 kubo_url: &str,
188 did_document_json: &str,
189 ipns_private_key_base64: &str,
190) -> Result<Option<String>> {
191 let document = Document::unmarshal(did_document_json)
192 .map_err(|e| anyhow!("invalid DID document JSON: {}", e))?;
193 let document_did = Did::try_from(document.id.as_str())
194 .map_err(|e| anyhow!("invalid document DID '{}': {}", document.id, e))?;
195 let document_ipns_id = document_did.ipns.clone();
196
197 let hash = blake3::hash(document_ipns_id.as_bytes());
200 let key_name = format!("{}{}", MA_IPNS_ALIAS_HASH_PREFIX, &hash.to_hex()[..16]);
201
202 let existing_key = list_keys(kubo_url)
203 .await?
204 .into_iter()
205 .find(|k| k.name == key_name);
206
207 if let Some(existing) = existing_key {
208 if existing.id.trim() != document_ipns_id {
209 return Err(anyhow!(
210 "existing key '{}' has IPNS id '{}' but document DID IPNS is '{}'",
211 key_name,
212 existing.id,
213 document_ipns_id
214 ));
215 }
216 } else {
217 if ipns_private_key_base64.trim().is_empty() {
218 return Err(anyhow!(
219 "ipns_private_key_base64 is required when key is not present in Kubo"
220 ));
221 }
222
223 let key_bytes = B64
224 .decode(ipns_private_key_base64.trim())
225 .map_err(|e| anyhow!("invalid base64 key payload: {}", e))?;
226
227 let imported = import_key(kubo_url, &key_name, key_bytes).await?;
228 if imported.id.trim() != document_ipns_id {
229 return Err(anyhow!(
230 "imported key IPNS id '{}' does not match document DID IPNS '{}'",
231 imported.id,
232 document_ipns_id
233 ));
234 }
235 }
236
237 let published_cid = dag_put(kubo_url, &document).await?;
238 let ipns_options = IpnsPublishOptions::default();
239 name_publish_with_retry(
240 kubo_url,
241 &key_name,
242 &published_cid,
243 &ipns_options,
244 3,
245 Duration::from_secs(1),
246 )
247 .await?;
248
249 Ok(Some(published_cid))
250}
251
252#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
253pub async fn handle_ipfs_publish(
254 kubo_url: &str,
255 message_cbor: &[u8],
256) -> Result<IpfsPublishDidResponse> {
257 let validated = validate_ipfs_publish_request(message_cbor)?;
258
259 let cid = publish_did_document_to_kubo(
260 kubo_url,
261 &validated.request.did_document_json,
262 &validated.request.ipns_private_key_base64,
263 )
264 .await?;
265
266 Ok(IpfsPublishDidResponse {
267 ok: true,
268 message: "did document published via ma/ipfs/0.0.1".to_string(),
269 did: Some(validated.document_did.id()),
270 cid,
271 })
272}
273
274#[cfg(all(test, not(target_arch = "wasm32"), feature = "kubo"))]
275mod tests {
276 use super::normalize_kubo_url;
277
278 #[test]
279 fn normalizes_trailing_slash() {
280 assert_eq!(
281 normalize_kubo_url("http://127.0.0.1:5001/").expect("normalize url"),
282 "http://127.0.0.1:5001"
283 );
284 }
285
286 #[test]
287 fn strips_api_v0_suffix() {
288 assert_eq!(
289 normalize_kubo_url("http://127.0.0.1:5001/api/v0").expect("normalize url"),
290 "http://127.0.0.1:5001"
291 );
292 }
293
294 #[test]
295 fn keeps_custom_base_path() {
296 assert_eq!(
297 normalize_kubo_url("http://localhost:5001/kubo").expect("normalize url"),
298 "http://localhost:5001/kubo"
299 );
300 }
301
302 #[test]
303 fn rejects_empty_url() {
304 assert!(normalize_kubo_url(" ").is_err());
305 }
306
307 #[test]
308 fn rejects_non_http_scheme() {
309 assert!(normalize_kubo_url("ftp://127.0.0.1:5001").is_err());
310 }
311}