Skip to main content

solid_pod_rs_server/
cross_identity.rs

1//! did:nostr to WebID cross-identity for NIP-98 callers.
2//!
3//! JSS upgrades a verified Nostr pubkey to a WebID after NIP-98 verification
4//! (`src/auth/nostr.js` 30 and 326-337), resolving through
5//! `resolveDidNostrToWebId` (`src/auth/did-nostr.js` 171-345): the DID
6//! document's `alsoKnownAs` names a WebID, and the WebID profile must link
7//! back to the same `did:nostr`. A one-way link is never accepted.
8//!
9//! Here a verified link makes the caller present as BOTH identities, so an
10//! ACL naming either `acl:agent <webid>` or `acl:agent <did:nostr:...>`
11//! matches. (JSS replaces the did:nostr agent with the WebID; keeping both
12//! is the deliberate superset the parity brief asks for.)
13//!
14//! Two sources, tried in order:
15//!
16//! 1. the pod's own profile card: the owner's `nostr:pubkey` triple, which
17//!    this server's `/.well-known/did/nostr/<pk>.json` points back at, so the
18//!    link is bidirectional by construction (no network);
19//! 2. with feature `did-nostr`, remote DID-document origins configured by the
20//!    operator, resolved by
21//!    `solid_pod_rs::interop::did_nostr::DidNostrResolver` (SSRF policy,
22//!    bounded TTL cache with negative caching, bidirectional back-link).
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27use solid_pod_rs::storage::Storage;
28
29/// Resolves a verified Nostr pubkey to the WebID bound to it, if any.
30#[async_trait]
31pub trait CrossIdentityResolver: Send + Sync {
32    /// `pubkey` is 64 lowercase hex characters. Return `Some(webid)` only
33    /// for a bidirectionally verified link.
34    async fn linked_web_id(&self, pubkey: &str) -> Option<String>;
35}
36
37/// True for a 64-character lowercase hex string.
38pub fn is_hex_pubkey(s: &str) -> bool {
39    s.len() == 64
40        && s.bytes()
41            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
42}
43
44/// Default resolver: local profile card, then optional remote origins.
45pub struct PodCrossIdentity {
46    storage: Arc<dyn Storage>,
47    base_url: String,
48    #[cfg(feature = "did-nostr")]
49    remote: Option<(
50        solid_pod_rs::interop::did_nostr::DidNostrResolver,
51        Vec<String>,
52    )>,
53}
54
55impl PodCrossIdentity {
56    /// Local-only resolver for the pod served at `base_url`.
57    pub fn new(storage: Arc<dyn Storage>, base_url: impl Into<String>) -> Self {
58        Self {
59            storage,
60            base_url: base_url.into().trim_end_matches('/').to_string(),
61            #[cfg(feature = "did-nostr")]
62            remote: None,
63        }
64    }
65
66    /// Also consult DID-document `origins` through `resolver`.
67    #[cfg(feature = "did-nostr")]
68    pub fn with_remote(
69        mut self,
70        resolver: solid_pod_rs::interop::did_nostr::DidNostrResolver,
71        origins: Vec<String>,
72    ) -> Self {
73        self.remote = Some((resolver, origins));
74        self
75    }
76
77    async fn local(&self, pubkey: &str) -> Option<String> {
78        let (body, _) = self.storage.get("/profile/card").await.ok()?;
79        let owner = solid_pod_rs::webid::extract_nostr_pubkey(&body)
80            .ok()
81            .flatten()?;
82        if owner.eq_ignore_ascii_case(pubkey) {
83            Some(format!("{}/profile/card#me", self.base_url))
84        } else {
85            None
86        }
87    }
88}
89
90#[async_trait]
91impl CrossIdentityResolver for PodCrossIdentity {
92    async fn linked_web_id(&self, pubkey: &str) -> Option<String> {
93        if !is_hex_pubkey(pubkey) {
94            return None;
95        }
96        if let Some(w) = self.local(pubkey).await {
97            return Some(w);
98        }
99        #[cfg(feature = "did-nostr")]
100        if let Some((resolver, origins)) = &self.remote {
101            for origin in origins {
102                if let Some(w) = resolver.resolve(origin, pubkey).await {
103                    return Some(w);
104                }
105            }
106        }
107        None
108    }
109}
110
111/// The identities a verified principal presents: the linked WebID(s) for a
112/// `did:nostr:<hex>` agent, empty for anyone else. Never returns the agent
113/// itself, and only `http(s)` URLs.
114pub async fn linked_identities(
115    resolver: Option<&Arc<dyn CrossIdentityResolver>>,
116    agent_uri: Option<&str>,
117) -> Vec<String> {
118    let (Some(resolver), Some(agent)) = (resolver, agent_uri) else {
119        return Vec::new();
120    };
121    let Some(pk) = agent.strip_prefix("did:nostr:") else {
122        return Vec::new();
123    };
124    match resolver.linked_web_id(pk).await {
125        Some(w) if (w.starts_with("https://") || w.starts_with("http://")) && w != agent => {
126            vec![w]
127        }
128        _ => Vec::new(),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use bytes::Bytes;
136    use solid_pod_rs::storage::memory::MemoryBackend;
137
138    const PK: &str = "abcd000000000000000000000000000000000000000000000000000000000001";
139
140    #[tokio::test]
141    async fn local_profile_link_resolves() {
142        let st = Arc::new(MemoryBackend::new());
143        let card = format!(
144            r##"<html><script type="application/ld+json">{{"@id":"#me","nostr:pubkey":"{PK}"}}</script></html>"##
145        );
146        st.put("/profile/card", Bytes::from(card), "text/html")
147            .await
148            .unwrap();
149        let r = PodCrossIdentity::new(st, "https://pod.example/");
150        assert_eq!(
151            r.linked_web_id(PK).await.as_deref(),
152            Some("https://pod.example/profile/card#me")
153        );
154        let other = "ef".repeat(32);
155        assert_eq!(r.linked_web_id(&other).await, None);
156    }
157
158    #[tokio::test]
159    async fn malformed_pubkey_never_resolves() {
160        let st = Arc::new(MemoryBackend::new());
161        let r = PodCrossIdentity::new(st, "https://pod.example");
162        assert_eq!(r.linked_web_id("../etc").await, None);
163        assert_eq!(r.linked_web_id(&PK.to_uppercase()).await, None);
164    }
165
166    struct Fake(Option<&'static str>);
167    #[async_trait]
168    impl CrossIdentityResolver for Fake {
169        async fn linked_web_id(&self, _pk: &str) -> Option<String> {
170            self.0.map(String::from)
171        }
172    }
173
174    #[tokio::test]
175    async fn linked_identities_filters() {
176        let did = format!("did:nostr:{PK}");
177        let ok: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(Some("https://a.example/me#i")));
178        assert_eq!(
179            linked_identities(Some(&ok), Some(&did)).await,
180            vec!["https://a.example/me#i".to_string()]
181        );
182        // A WebID principal is never re-resolved.
183        assert!(linked_identities(Some(&ok), Some("https://x.example/#me"))
184            .await
185            .is_empty());
186        // Anonymous and no-resolver cases.
187        assert!(linked_identities(Some(&ok), None).await.is_empty());
188        assert!(linked_identities(None, Some(&did)).await.is_empty());
189        // Non-http result (e.g. a hostile javascript: or did: value) is dropped.
190        let bad: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(Some("did:web:evil")));
191        assert!(linked_identities(Some(&bad), Some(&did)).await.is_empty());
192        let none: Arc<dyn CrossIdentityResolver> = Arc::new(Fake(None));
193        assert!(linked_identities(Some(&none), Some(&did)).await.is_empty());
194    }
195}