Skip to main content

solid_pod_rs/
interop.rs

1//! Interop / discovery helpers.
2//!
3//! This module rounds out the crate's public Solid surface with small,
4//! framework-agnostic helpers for ecosystem discovery flows:
5//!
6//! - **`.well-known/solid`** — Solid Protocol §4.1.2 discovery document.
7//! - **WebFinger** — RFC 7033, used to map acct: URIs to WebIDs.
8//! - **NIP-05 verification** — Nostr pubkey ↔ DNS name binding.
9//! - **Dev-mode session bypass** — consumer-crate helper for tests.
10//!
11//! None of these helpers perform network I/O on their own; they return
12//! response bodies and signal objects that the consumer crate wires
13//! into its HTTP server.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::PodError;
18
19// ---------------------------------------------------------------------------
20// .well-known/solid discovery document
21// ---------------------------------------------------------------------------
22
23/// Solid Protocol `.well-known/solid` discovery document. The doc
24/// advertises the OIDC issuer, the pod URL, and the Notifications
25/// endpoint. JSS parity: includes `api.accounts` URLs.
26#[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/// JSS-compatible account management API pointers.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SolidWellKnownApi {
47    pub accounts: SolidWellKnownAccounts,
48}
49
50/// JSS-compatible account endpoint URLs.
51#[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
59/// Build the discovery document for a pod root.
60pub fn well_known_solid(
61    pod_base: &str,
62    oidc_issuer: &str,
63) -> SolidWellKnown {
64    let base = pod_base.trim_end_matches('/');
65    SolidWellKnown {
66        context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
67        solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
68        notification_gateway: format!("{base}/.notifications"),
69        storage: format!("{base}/"),
70        webfinger: Some(format!("{base}/.well-known/webfinger")),
71        api: Some(SolidWellKnownApi {
72            accounts: SolidWellKnownAccounts {
73                new: format!("{base}/api/accounts/new"),
74                recover: format!("{base}/api/accounts/recover"),
75                signin: format!("{base}/login"),
76                signout: format!("{base}/logout"),
77            },
78        }),
79    }
80}
81
82// ---------------------------------------------------------------------------
83// WebFinger (RFC 7033)
84// ---------------------------------------------------------------------------
85
86/// WebFinger JRD (JSON Resource Descriptor) response.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct WebFingerJrd {
89    pub subject: String,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub aliases: Vec<String>,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub links: Vec<WebFingerLink>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct WebFingerLink {
98    pub rel: String,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub href: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none", rename = "type")]
102    pub content_type: Option<String>,
103}
104
105/// Produce a WebFinger JRD response pointing `acct:user@host` at the
106/// user's WebID. Returns `None` if the resource is not recognised.
107pub fn webfinger_response(
108    resource: &str,
109    pod_base: &str,
110    webid: &str,
111) -> Option<WebFingerJrd> {
112    if !resource.starts_with("acct:") && !resource.starts_with("https://") {
113        return None;
114    }
115    let base = pod_base.trim_end_matches('/');
116    Some(WebFingerJrd {
117        subject: resource.to_string(),
118        aliases: vec![webid.to_string()],
119        links: vec![
120            WebFingerLink {
121                rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
122                href: Some(format!("{base}/")),
123                content_type: None,
124            },
125            WebFingerLink {
126                rel: "http://www.w3.org/ns/solid#webid".to_string(),
127                href: Some(webid.to_string()),
128                content_type: None,
129            },
130            WebFingerLink {
131                rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
132                href: Some(format!("{base}/")),
133                content_type: None,
134            },
135        ],
136    })
137}
138
139// ---------------------------------------------------------------------------
140// NIP-05 verification
141// ---------------------------------------------------------------------------
142
143/// NIP-05 response document (the JSON served at
144/// `.well-known/nostr.json?name=<local>`).
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Nip05Document {
147    pub names: std::collections::HashMap<String, String>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
150}
151
152/// Verify a NIP-05 identifier (`local@example.com`) against a fetched
153/// NIP-05 document. Returns the resolved hex pubkey on success.
154pub fn verify_nip05(
155    identifier: &str,
156    document: &Nip05Document,
157) -> Result<String, PodError> {
158    let (local, _domain) = identifier
159        .split_once('@')
160        .ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
161    let lookup = if local.is_empty() { "_" } else { local };
162    let pubkey = document
163        .names
164        .get(lookup)
165        .ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
166    if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
167        return Err(PodError::Nip98(format!(
168            "NIP-05 pubkey malformed for {identifier}"
169        )));
170    }
171    Ok(pubkey.clone())
172}
173
174/// Build the NIP-05 document structure for a pod's own hosted names.
175pub fn nip05_document(
176    names: impl IntoIterator<Item = (String, String)>,
177) -> Nip05Document {
178    Nip05Document {
179        names: names.into_iter().collect(),
180        relays: None,
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Dev-mode session bypass
186// ---------------------------------------------------------------------------
187
188/// Dev-mode session — ergonomic handle a consumer crate can plug into
189/// its request-processing pipeline in place of NIP-98/OIDC verification
190/// during tests or local development. The bypass is only constructable
191/// via explicit allow, never through a header the client supplies.
192#[derive(Debug, Clone)]
193pub struct DevSession {
194    pub webid: String,
195    pub pubkey: Option<String>,
196    pub is_admin: bool,
197}
198
199/// Build a dev-session bypass. Callers are expected to gate this on a
200/// top-level `ENABLE_DEV_SESSION=1` or similar environment check —
201/// the helper itself will not read env to avoid accidental activation.
202pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
203    DevSession {
204        webid: webid.into(),
205        pubkey: None,
206        is_admin,
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Tests
212// ---------------------------------------------------------------------------
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn well_known_solid_advertises_oidc_and_storage() {
220        let d = well_known_solid("https://pod.example/", "https://op.example/");
221        assert_eq!(d.solid_oidc_issuer, "https://op.example");
222        assert!(d.notification_gateway.ends_with(".notifications"));
223        assert!(d.storage.ends_with('/'));
224    }
225
226    #[test]
227    fn webfinger_returns_links_for_acct() {
228        let j = webfinger_response(
229            "acct:alice@pod.example",
230            "https://pod.example",
231            "https://pod.example/profile/card#me",
232        )
233        .unwrap();
234        assert_eq!(j.subject, "acct:alice@pod.example");
235        assert!(j.links.iter().any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
236    }
237
238    #[test]
239    fn webfinger_rejects_unknown_scheme() {
240        assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
241    }
242
243    #[test]
244    fn nip05_verify_returns_pubkey() {
245        let mut names = std::collections::HashMap::new();
246        names.insert("alice".to_string(), "a".repeat(64));
247        let doc = nip05_document(names);
248        let pk = verify_nip05("alice@pod.example", &doc).unwrap();
249        assert_eq!(pk, "a".repeat(64));
250    }
251
252    #[test]
253    fn nip05_verify_rejects_malformed_pubkey() {
254        let mut names = std::collections::HashMap::new();
255        names.insert("alice".to_string(), "shortkey".to_string());
256        let doc = nip05_document(names);
257        assert!(verify_nip05("alice@p", &doc).is_err());
258    }
259
260    #[test]
261    fn nip05_root_name_resolves_via_underscore() {
262        let mut names = std::collections::HashMap::new();
263        names.insert("_".to_string(), "b".repeat(64));
264        let doc = nip05_document(names);
265        assert!(verify_nip05("@pod.example", &doc).is_ok());
266    }
267
268    #[test]
269    fn dev_session_stores_admin_flag() {
270        let s = dev_session("https://me/profile#me", true);
271        assert!(s.is_admin);
272        assert_eq!(s.webid, "https://me/profile#me");
273    }
274}
275
276// ---------------------------------------------------------------------------
277// did:nostr resolver (Sprint 6 D)
278// ---------------------------------------------------------------------------
279
280/// did:nostr resolver — DID-Doc publication + bidirectional
281/// `alsoKnownAs`/`owl:sameAs` verification.
282///
283/// Mirrors `JavaScriptSolidServer/src/auth/did-nostr.js`: given
284/// `did:nostr:<pubkey>` hosted on an origin, fetch
285/// `https://<origin>/.well-known/did/nostr/<pubkey>.json`, iterate the
286/// `alsoKnownAs` entries, fetch each candidate WebID profile, and
287/// verify it carries an `owl:sameAs` / `schema:sameAs` back-link to
288/// `did:nostr:<pubkey>`. Only a verified WebID is returned.
289///
290/// Defence-in-depth: every outbound request (DID Doc + each WebID
291/// candidate) runs through the configured [`SsrfPolicy`] before
292/// network I/O. A small in-memory TTL cache covers both success and
293/// negative results so a dark origin does not hammer the downstream.
294#[cfg(feature = "did-nostr")]
295pub mod did_nostr {
296    use std::collections::HashMap;
297    use std::sync::{Arc, RwLock};
298    use std::time::{Duration, Instant};
299
300    use reqwest::Client;
301    use serde::{Deserialize, Serialize};
302    use url::Url;
303
304    use crate::security::ssrf::SsrfPolicy;
305
306    /// Compose the well-known DID Doc location for a Nostr pubkey
307    /// hosted on a given origin. Mirrors JSS `did-nostr.js:79` where
308    /// the resolver URL is `<base>/<pubkey>.json`.
309    pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
310        format!(
311            "{}/.well-known/did/nostr/{}.json",
312            origin.trim_end_matches('/'),
313            pubkey
314        )
315    }
316
317    /// Build a minimal DID Doc for publication at the well-known URL.
318    /// Tier-1 schema (matches JSS): `id`, `alsoKnownAs`, and a single
319    /// `verificationMethod` entry of type
320    /// `SchnorrSecp256k1VerificationKey2019` derived from the x-only pubkey.
321    ///
322    /// Per ADR-074 D1 (cross-system DID:Nostr canonicalisation): all DreamLab
323    /// emitters MUST use `SchnorrSecp256k1VerificationKey2019` (the only
324    /// published W3C secp256k1 Schnorr suite). The legacy `NostrSchnorrKey2024`
325    /// term was a forum invention that no W3C verifier can resolve. Tier-1
326    /// includes the secp256k1-2019 suite context so the term resolves.
327    pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
328        serde_json::json!({
329            "@context": [
330                "https://www.w3.org/ns/did/v1",
331                "https://w3id.org/security/suites/secp256k1-2019/v1"
332            ],
333            "id": format!("did:nostr:{}", pubkey),
334            "alsoKnownAs": also_known_as,
335            "verificationMethod": [{
336                "id": format!("did:nostr:{}#nostr-schnorr", pubkey),
337                "type": "SchnorrSecp256k1VerificationKey2019",
338                "controller": format!("did:nostr:{}", pubkey),
339                "publicKeyHex": pubkey,
340            }]
341        })
342    }
343
344    /// Parsed DID Doc. Only the subset of fields relevant to WebID
345    /// resolution is typed; unknown fields are ignored.
346    #[derive(Debug, Clone, Deserialize, Serialize)]
347    pub struct DidNostrDoc {
348        pub id: String,
349        #[serde(default, rename = "alsoKnownAs")]
350        pub also_known_as: Vec<String>,
351    }
352
353    /// TTL-cached `did:nostr:<pubkey>` → WebID resolver with per-hop
354    /// SSRF enforcement.
355    pub struct DidNostrResolver {
356        ssrf: Arc<SsrfPolicy>,
357        client: Client,
358        cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
359        success_ttl: Duration,
360        failure_ttl: Duration,
361    }
362
363    struct CachedEntry {
364        fetched: Instant,
365        web_id: Option<String>,
366    }
367
368    impl DidNostrResolver {
369        /// Construct a resolver with the default HTTP client (10 s
370        /// timeout) and TTLs matching JSS (5 min success, 1 min
371        /// failure).
372        pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
373            let client = Client::builder()
374                .timeout(Duration::from_secs(10))
375                .build()
376                .unwrap_or_else(|_| Client::new());
377            Self {
378                ssrf,
379                client,
380                cache: Arc::new(RwLock::new(HashMap::new())),
381                success_ttl: Duration::from_secs(300),
382                failure_ttl: Duration::from_secs(60),
383            }
384        }
385
386        /// Override the default success / failure cache TTLs.
387        pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
388            self.success_ttl = success;
389            self.failure_ttl = failure;
390            self
391        }
392
393        /// Resolve `did:nostr:<pubkey>` against `origin` to a verified
394        /// WebID. Returns `None` if:
395        ///
396        /// - SSRF policy denies the origin or any WebID candidate.
397        /// - DID Doc fetch fails or the doc's `id` does not match
398        ///   `did:nostr:<pubkey>`.
399        /// - `alsoKnownAs` is empty.
400        /// - No candidate WebID carries a back-link (`owl:sameAs` or
401        ///   `schema:sameAs`) to the same `did:nostr:<pubkey>`.
402        ///
403        /// Both success and failure are cached; subsequent calls
404        /// within the matching TTL are served from memory without
405        /// network I/O.
406        pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
407            let cache_key = format!("{origin}|{pubkey}");
408
409            // Cache lookup (read lock).
410            if let Ok(guard) = self.cache.read() {
411                if let Some(entry) = guard.get(&cache_key) {
412                    let ttl = if entry.web_id.is_some() {
413                        self.success_ttl
414                    } else {
415                        self.failure_ttl
416                    };
417                    if entry.fetched.elapsed() < ttl {
418                        return entry.web_id.clone();
419                    }
420                }
421            }
422
423            let result = self.resolve_uncached(origin, pubkey).await;
424
425            if let Ok(mut guard) = self.cache.write() {
426                guard.insert(
427                    cache_key,
428                    CachedEntry {
429                        fetched: Instant::now(),
430                        web_id: result.clone(),
431                    },
432                );
433            }
434
435            result
436        }
437
438        async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
439            // 1. SSRF check on origin.
440            let origin_url = Url::parse(origin).ok()?;
441            self.ssrf.resolve_and_check(&origin_url).await.ok()?;
442
443            // 2. Fetch DID Doc.
444            let url = did_nostr_well_known_url(origin, pubkey);
445            let resp = self
446                .client
447                .get(&url)
448                .header("accept", "application/did+json, application/json")
449                .send()
450                .await
451                .ok()?
452                .error_for_status()
453                .ok()?;
454            let doc: DidNostrDoc = resp.json().await.ok()?;
455
456            if doc.id != format!("did:nostr:{pubkey}") {
457                return None;
458            }
459
460            // 3. Iterate candidates; return the first verified WebID.
461            let did_iri = format!("did:nostr:{pubkey}");
462            for candidate in &doc.also_known_as {
463                if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
464                    return Some(web_id);
465                }
466            }
467            None
468        }
469
470        async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
471            let url = Url::parse(candidate).ok()?;
472            self.ssrf.resolve_and_check(&url).await.ok()?;
473            let resp = self
474                .client
475                .get(url.as_str())
476                .header("accept", "text/turtle, application/ld+json")
477                .send()
478                .await
479                .ok()?
480                .error_for_status()
481                .ok()?;
482            let body = resp.text().await.ok()?;
483
484            // Back-link check — literal string match suffices for the
485            // bidirectional guarantee because the DID IRI is by spec a
486            // verbatim literal (no relativisation in either RDF flavour).
487            let has_predicate = body.contains("owl:sameAs")
488                || body.contains("schema:sameAs")
489                || body.contains("http://www.w3.org/2002/07/owl#sameAs")
490                || body.contains("https://schema.org/sameAs");
491            if has_predicate && body.contains(did_iri) {
492                Some(candidate.to_string())
493            } else {
494                None
495            }
496        }
497    }
498}
499
500// ---------------------------------------------------------------------------
501// NodeInfo 2.1 (Sprint 7 C)
502// ---------------------------------------------------------------------------
503
504/// `/.well-known/nodeinfo` discovery document (JSON), per
505/// nodeinfo.diaspora.software §6. Points clients at one or more
506/// versioned NodeInfo docs.
507pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
508    serde_json::json!({
509        "links": [
510            {
511                "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
512                "href": format!(
513                    "{}/.well-known/nodeinfo/2.1",
514                    base_url.trim_end_matches('/')
515                )
516            }
517        ]
518    })
519}
520
521/// `/.well-known/nodeinfo/2.1` content document, per
522/// nodeinfo.diaspora.software §3 (schema 2.1).
523pub fn nodeinfo_2_1(
524    software_name: &str,
525    software_version: &str,
526    open_registrations: bool,
527    total_users: u64,
528) -> serde_json::Value {
529    serde_json::json!({
530        "version": "2.1",
531        "software": {
532            "name": software_name,
533            "version": software_version,
534            "repository": "https://github.com/dreamlab-ai/solid-pod-rs",
535            "homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
536        },
537        "protocols": ["solid", "activitypub"],
538        "services": {
539            "inbound": [],
540            "outbound": []
541        },
542        "openRegistrations": open_registrations,
543        "usage": {
544            "users": {
545                "total": total_users
546            }
547        },
548        "metadata": {}
549    })
550}