1use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SolidWellKnown {
28 #[serde(rename = "@context")]
29 pub context: serde_json::Value,
30
31 pub solid_oidc_issuer: String,
32
33 pub notification_gateway: String,
34
35 pub storage: String,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub webfinger: Option<String>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub api: Option<SolidWellKnownApi>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SolidWellKnownApi {
47 pub accounts: SolidWellKnownAccounts,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SolidWellKnownAccounts {
53 pub new: String,
54 pub recover: String,
55 pub signin: String,
56 pub signout: String,
57}
58
59pub fn well_known_solid(pod_base: &str, oidc_issuer: &str) -> SolidWellKnown {
61 let base = pod_base.trim_end_matches('/');
62 SolidWellKnown {
63 context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
64 solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
65 notification_gateway: format!("{base}/.notifications"),
66 storage: format!("{base}/"),
67 webfinger: Some(format!("{base}/.well-known/webfinger")),
68 api: Some(SolidWellKnownApi {
69 accounts: SolidWellKnownAccounts {
70 new: format!("{base}/api/accounts/new"),
71 recover: format!("{base}/api/accounts/recover"),
72 signin: format!("{base}/login"),
73 signout: format!("{base}/logout"),
74 },
75 }),
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct WebFingerJrd {
86 pub subject: String,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub aliases: Vec<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub links: Vec<WebFingerLink>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct WebFingerLink {
95 pub rel: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub href: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
99 pub content_type: Option<String>,
100}
101
102pub fn webfinger_response(resource: &str, pod_base: &str, webid: &str) -> Option<WebFingerJrd> {
105 if !resource.starts_with("acct:") && !resource.starts_with("https://") {
106 return None;
107 }
108 let base = pod_base.trim_end_matches('/');
109 Some(WebFingerJrd {
110 subject: resource.to_string(),
111 aliases: vec![webid.to_string()],
112 links: vec![
113 WebFingerLink {
114 rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
115 href: Some(format!("{base}/")),
116 content_type: None,
117 },
118 WebFingerLink {
119 rel: "http://www.w3.org/ns/solid#webid".to_string(),
120 href: Some(webid.to_string()),
121 content_type: None,
122 },
123 WebFingerLink {
124 rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
125 href: Some(format!("{base}/")),
126 content_type: None,
127 },
128 ],
129 })
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Nip05Document {
140 pub names: std::collections::HashMap<String, String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
143}
144
145pub fn verify_nip05(identifier: &str, document: &Nip05Document) -> Result<String, PodError> {
148 let (local, _domain) = identifier
149 .split_once('@')
150 .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
151 let lookup = if local.is_empty() { "_" } else { local };
152 let pubkey = document
153 .names
154 .get(lookup)
155 .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
156 if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
157 return Err(PodError::Nip98(format!(
158 "NIP-05 pubkey malformed for {identifier}"
159 )));
160 }
161 Ok(pubkey.clone())
162}
163
164pub fn nip05_document(names: impl IntoIterator<Item = (String, String)>) -> Nip05Document {
166 Nip05Document {
167 names: names.into_iter().collect(),
168 relays: None,
169 }
170}
171
172#[derive(Debug, Clone)]
181pub struct DevSession {
182 pub webid: String,
183 pub pubkey: Option<String>,
184 pub is_admin: bool,
185}
186
187pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
191 DevSession {
192 webid: webid.into(),
193 pubkey: None,
194 is_admin,
195 }
196}
197
198#[allow(clippy::items_after_test_module)]
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn well_known_solid_advertises_oidc_and_storage() {
213 let d = well_known_solid("https://pod.example/", "https://op.example/");
214 assert_eq!(d.solid_oidc_issuer, "https://op.example");
215 assert!(d.notification_gateway.ends_with(".notifications"));
216 assert!(d.storage.ends_with('/'));
217 }
218
219 #[test]
220 fn webfinger_returns_links_for_acct() {
221 let j = webfinger_response(
222 "acct:alice@pod.example",
223 "https://pod.example",
224 "https://pod.example/profile/card#me",
225 )
226 .unwrap();
227 assert_eq!(j.subject, "acct:alice@pod.example");
228 assert!(j
229 .links
230 .iter()
231 .any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
232 }
233
234 #[test]
235 fn webfinger_rejects_unknown_scheme() {
236 assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
237 }
238
239 #[test]
240 fn nip05_verify_returns_pubkey() {
241 let mut names = std::collections::HashMap::new();
242 names.insert("alice".to_string(), "a".repeat(64));
243 let doc = nip05_document(names);
244 let pk = verify_nip05("alice@pod.example", &doc).unwrap();
245 assert_eq!(pk, "a".repeat(64));
246 }
247
248 #[test]
249 fn nip05_verify_rejects_malformed_pubkey() {
250 let mut names = std::collections::HashMap::new();
251 names.insert("alice".to_string(), "shortkey".to_string());
252 let doc = nip05_document(names);
253 assert!(verify_nip05("alice@p", &doc).is_err());
254 }
255
256 #[test]
257 fn nip05_root_name_resolves_via_underscore() {
258 let mut names = std::collections::HashMap::new();
259 names.insert("_".to_string(), "b".repeat(64));
260 let doc = nip05_document(names);
261 assert!(verify_nip05("@pod.example", &doc).is_ok());
262 }
263
264 #[test]
265 fn dev_session_stores_admin_flag() {
266 let s = dev_session("https://me/profile#me", true);
267 assert!(s.is_admin);
268 assert_eq!(s.webid, "https://me/profile#me");
269 }
270}
271
272#[cfg(feature = "did-nostr")]
291pub mod did_nostr {
292 use std::collections::HashMap;
293 use std::sync::{Arc, RwLock};
294 use std::time::{Duration, Instant};
295
296 use reqwest::Client;
297 use serde::{Deserialize, Serialize};
298 use url::Url;
299
300 use crate::security::ssrf::SsrfPolicy;
301
302 pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
306 format!(
307 "{}/.well-known/did/nostr/{}.json",
308 origin.trim_end_matches('/'),
309 pubkey
310 )
311 }
312
313 pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
342 #[cfg(feature = "did-nostr-types")]
346 if let Ok(pk) = crate::did_nostr_types::NostrPubkey::from_hex(pubkey) {
347 let mut doc = crate::did_nostr_types::render_did_document(&pk);
348 if !also_known_as.is_empty() {
349 doc["alsoKnownAs"] = serde_json::json!(also_known_as);
350 }
351 return doc;
352 }
353
354 let did = format!("did:nostr:{pubkey}");
368 let mut doc = serde_json::json!({
369 "@context": [
370 "https://www.w3.org/ns/did/v1",
371 "https://www.w3.org/ns/cid/v1",
372 "https://w3id.org/nostr/context"
373 ],
374 "id": did,
375 "type": "DIDNostr",
376 "verificationMethod": [],
377 "authentication": [],
378 "assertionMethod": []
379 });
380 if !also_known_as.is_empty() {
381 doc["alsoKnownAs"] = serde_json::json!(also_known_as);
382 }
383 doc
384 }
385
386 #[derive(Debug, Clone, Deserialize, Serialize)]
389 pub struct DidNostrDoc {
390 pub id: String,
391 #[serde(default, rename = "alsoKnownAs")]
392 pub also_known_as: Vec<String>,
393 }
394
395 pub struct DidNostrResolver {
398 ssrf: Arc<SsrfPolicy>,
399 client: Client,
400 cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
401 success_ttl: Duration,
402 failure_ttl: Duration,
403 }
404
405 struct CachedEntry {
406 fetched: Instant,
407 web_id: Option<String>,
408 }
409
410 impl DidNostrResolver {
411 pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
415 let client = Client::builder()
420 .timeout(Duration::from_secs(10))
421 .redirect(reqwest::redirect::Policy::none())
422 .build()
423 .unwrap_or_else(|_| Client::new());
424 Self {
425 ssrf,
426 client,
427 cache: Arc::new(RwLock::new(HashMap::new())),
428 success_ttl: Duration::from_secs(300),
429 failure_ttl: Duration::from_secs(60),
430 }
431 }
432
433 pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
435 self.success_ttl = success;
436 self.failure_ttl = failure;
437 self
438 }
439
440 pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
454 if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
458 return None;
459 }
460 let pubkey = pubkey.to_ascii_lowercase();
461 let pubkey = pubkey.as_str();
462 let cache_key = format!("{origin}|{pubkey}");
463
464 if let Ok(guard) = self.cache.read() {
466 if let Some(entry) = guard.get(&cache_key) {
467 let ttl = if entry.web_id.is_some() {
468 self.success_ttl
469 } else {
470 self.failure_ttl
471 };
472 if entry.fetched.elapsed() < ttl {
473 return entry.web_id.clone();
474 }
475 }
476 }
477
478 let result =
479 tokio::time::timeout(RESOLVE_DEADLINE, self.resolve_uncached(origin, pubkey))
480 .await
481 .unwrap_or(None);
482
483 if let Ok(mut guard) = self.cache.write() {
484 if guard.len() >= MAX_CACHE_ENTRIES {
487 if let Some(oldest) = guard
488 .iter()
489 .min_by_key(|(_, e)| e.fetched)
490 .map(|(k, _)| k.clone())
491 {
492 guard.remove(&oldest);
493 }
494 }
495 guard.insert(
496 cache_key,
497 CachedEntry {
498 fetched: Instant::now(),
499 web_id: result.clone(),
500 },
501 );
502 }
503
504 result
505 }
506
507 fn pinned_client(&self, url: &Url, ip: std::net::IpAddr) -> Client {
512 let Some(url::Host::Domain(host)) = url.host() else {
513 return self.client.clone();
514 };
515 let port = url.port_or_known_default().unwrap_or(80);
516 Client::builder()
517 .timeout(Duration::from_secs(10))
518 .redirect(reqwest::redirect::Policy::none())
519 .resolve(host, std::net::SocketAddr::new(ip, port))
520 .build()
521 .unwrap_or_else(|_| self.client.clone())
522 }
523
524 async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
525 let origin_url = Url::parse(origin).ok()?;
527 let origin_ip = self.ssrf.resolve_and_check(&origin_url).await.ok()?;
528
529 let url = did_nostr_well_known_url(origin, pubkey);
532 let resp = self
533 .pinned_client(&origin_url, origin_ip)
534 .get(&url)
535 .header("accept", "application/did+json, application/json")
536 .send()
537 .await
538 .ok()?
539 .error_for_status()
540 .ok()?;
541 let doc: DidNostrDoc = serde_json::from_slice(&read_capped(resp).await?).ok()?;
542
543 if doc.id != format!("did:nostr:{pubkey}") {
544 return None;
545 }
546
547 let did_iri = format!("did:nostr:{pubkey}");
549 for candidate in doc.also_known_as.iter().take(MAX_CANDIDATES) {
551 if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
552 return Some(web_id);
553 }
554 }
555 None
556 }
557
558 async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
559 let url = Url::parse(candidate).ok()?;
560 let ip = self.ssrf.resolve_and_check(&url).await.ok()?;
561 let resp = self
562 .pinned_client(&url, ip)
563 .get(url.as_str())
564 .header("accept", "text/turtle, application/ld+json")
565 .send()
566 .await
567 .ok()?
568 .error_for_status()
569 .ok()?;
570 let body = read_capped(resp).await?;
571
572 if backlink_present(&body, candidate, did_iri) {
578 Some(candidate.to_string())
579 } else {
580 None
581 }
582 }
583 }
584
585 pub const MAX_CANDIDATES: usize = 4;
587 const RESOLVE_DEADLINE: Duration = Duration::from_secs(15);
589 const MAX_DOC_BYTES: usize = 512 * 1024;
591 const MAX_CACHE_ENTRIES: usize = 10_000;
593
594 const SAME_AS_IRIS: [&str; 4] = [
595 "http://www.w3.org/2002/07/owl#sameAs",
596 "https://schema.org/sameAs",
597 "http://schema.org/sameAs",
598 "https://schema.org/sameAs/",
599 ];
600 const SAME_AS_KEYS: [&str; 7] = [
601 "owl:sameAs",
602 "schema:sameAs",
603 "sameAs",
604 "http://www.w3.org/2002/07/owl#sameAs",
605 "https://schema.org/sameAs",
606 "http://schema.org/sameAs",
607 "https://schema.org/sameAs/",
608 ];
609
610 async fn read_capped(mut resp: reqwest::Response) -> Option<Vec<u8>> {
611 if resp
612 .content_length()
613 .is_some_and(|n| n as usize > MAX_DOC_BYTES)
614 {
615 return None;
616 }
617 let mut out = Vec::new();
618 while let Some(chunk) = resp.chunk().await.ok()? {
619 if out.len() + chunk.len() > MAX_DOC_BYTES {
620 return None;
621 }
622 out.extend_from_slice(&chunk);
623 }
624 Some(out)
625 }
626
627 pub fn backlink_present(body: &[u8], candidate: &str, did_iri: &str) -> bool {
632 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
633 return json_backlink(&v, candidate, did_iri);
634 }
635 let doc_base = candidate.split('#').next().unwrap_or(candidate);
636 match crate::rdf_input::parse_turtle(body, doc_base) {
637 Ok(g) => g.triples().any(|t| {
638 matches!(&t.subject, crate::ldp::Term::Iri(s) if s == candidate)
639 && matches!(&t.predicate, crate::ldp::Term::Iri(p) if SAME_AS_IRIS.contains(&p.as_str()))
640 && matches!(&t.object, crate::ldp::Term::Iri(o) if o == did_iri)
641 }),
642 Err(_) => false,
643 }
644 }
645
646 fn json_backlink(v: &serde_json::Value, candidate: &str, did_iri: &str) -> bool {
647 use serde_json::Value;
648 let base = Url::parse(candidate).ok();
649 let resolve = |id: &str| -> String {
650 base.as_ref()
651 .and_then(|b| b.join(id).ok())
652 .map(|u| u.to_string())
653 .unwrap_or_else(|| id.to_string())
654 };
655 let is_did = |x: &Value| match x {
656 Value::String(s) => s == did_iri,
657 Value::Object(m) => m.get("@id").and_then(Value::as_str) == Some(did_iri),
658 _ => false,
659 };
660 fn walk(
661 v: &Value,
662 depth: usize,
663 visit: &mut dyn FnMut(&serde_json::Map<String, Value>) -> bool,
664 ) -> bool {
665 if depth > 16 {
666 return false;
667 }
668 match v {
669 Value::Object(m) => visit(m) || m.values().any(|c| walk(c, depth + 1, visit)),
670 Value::Array(a) => a.iter().any(|c| walk(c, depth + 1, visit)),
671 _ => false,
672 }
673 }
674 walk(v, 0, &mut |m| {
675 let id = m.get("@id").or_else(|| m.get("id")).and_then(Value::as_str);
676 let Some(id) = id else { return false };
677 if resolve(id) != candidate {
678 return false;
679 }
680 SAME_AS_KEYS.iter().any(|k| match m.get(*k) {
681 Some(Value::Array(a)) => a.iter().any(is_did),
682 Some(x) => is_did(x),
683 None => false,
684 })
685 })
686 }
687
688 #[cfg(test)]
689 mod pin_tests {
690 use super::*;
691 use wiremock::matchers::method;
692 use wiremock::{Mock, MockServer, ResponseTemplate};
693
694 #[tokio::test]
697 async fn pinned_client_connects_to_the_vetted_address() {
698 let server = MockServer::start().await;
699 Mock::given(method("GET"))
700 .respond_with(ResponseTemplate::new(200))
701 .mount(&server)
702 .await;
703 let port = server.address().port();
704 let url = Url::parse(&format!("http://rebind.invalid:{port}/x")).unwrap();
705 let r = DidNostrResolver::new(Arc::new(SsrfPolicy::new()));
706 let unpinned = r.client.get(url.as_str()).send().await;
707 assert!(unpinned.is_err(), "the name must not resolve by itself");
708 let ok = r
709 .pinned_client(&url, "127.0.0.1".parse().unwrap())
710 .get(url.as_str())
711 .send()
712 .await
713 .expect("pinned request reaches the vetted address");
714 assert_eq!(ok.status(), 200);
715 }
716 }
717}
718
719pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
727 serde_json::json!({
728 "links": [
729 {
730 "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
731 "href": format!(
732 "{}/.well-known/nodeinfo/2.1",
733 base_url.trim_end_matches('/')
734 )
735 }
736 ]
737 })
738}
739
740pub fn nodeinfo_2_1(
743 software_name: &str,
744 software_version: &str,
745 open_registrations: bool,
746 total_users: u64,
747) -> serde_json::Value {
748 serde_json::json!({
749 "version": "2.1",
750 "software": {
751 "name": software_name,
752 "version": software_version,
753 "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
754 "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
755 },
756 "protocols": ["solid", "activitypub"],
757 "services": {
758 "inbound": [],
759 "outbound": []
760 },
761 "openRegistrations": open_registrations,
762 "usage": {
763 "users": {
764 "total": total_users
765 }
766 },
767 "metadata": {}
768 })
769}