use serde::{Deserialize, Serialize};
use crate::error::PodError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolidWellKnown {
#[serde(rename = "@context")]
pub context: serde_json::Value,
pub solid_oidc_issuer: String,
pub notification_gateway: String,
pub storage: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub webfinger: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<SolidWellKnownApi>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolidWellKnownApi {
pub accounts: SolidWellKnownAccounts,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolidWellKnownAccounts {
pub new: String,
pub recover: String,
pub signin: String,
pub signout: String,
}
pub fn well_known_solid(pod_base: &str, oidc_issuer: &str) -> SolidWellKnown {
let base = pod_base.trim_end_matches('/');
SolidWellKnown {
context: serde_json::json!("https://www.w3.org/ns/solid/terms"),
solid_oidc_issuer: oidc_issuer.trim_end_matches('/').to_string(),
notification_gateway: format!("{base}/.notifications"),
storage: format!("{base}/"),
webfinger: Some(format!("{base}/.well-known/webfinger")),
api: Some(SolidWellKnownApi {
accounts: SolidWellKnownAccounts {
new: format!("{base}/api/accounts/new"),
recover: format!("{base}/api/accounts/recover"),
signin: format!("{base}/login"),
signout: format!("{base}/logout"),
},
}),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebFingerJrd {
pub subject: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<WebFingerLink>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebFingerLink {
pub rel: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "type")]
pub content_type: Option<String>,
}
pub fn webfinger_response(resource: &str, pod_base: &str, webid: &str) -> Option<WebFingerJrd> {
if !resource.starts_with("acct:") && !resource.starts_with("https://") {
return None;
}
let base = pod_base.trim_end_matches('/');
Some(WebFingerJrd {
subject: resource.to_string(),
aliases: vec![webid.to_string()],
links: vec![
WebFingerLink {
rel: "http://openid.net/specs/connect/1.0/issuer".to_string(),
href: Some(format!("{base}/")),
content_type: None,
},
WebFingerLink {
rel: "http://www.w3.org/ns/solid#webid".to_string(),
href: Some(webid.to_string()),
content_type: None,
},
WebFingerLink {
rel: "http://www.w3.org/ns/pim/space#storage".to_string(),
href: Some(format!("{base}/")),
content_type: None,
},
],
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip05Document {
pub names: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relays: Option<std::collections::HashMap<String, Vec<String>>>,
}
pub fn verify_nip05(identifier: &str, document: &Nip05Document) -> Result<String, PodError> {
let (local, _domain) = identifier
.split_once('@')
.ok_or_else(|| PodError::Nip98(format!("invalid NIP-05 identifier: {identifier}")))?;
let lookup = if local.is_empty() { "_" } else { local };
let pubkey = document
.names
.get(lookup)
.ok_or_else(|| PodError::NotFound(format!("NIP-05 name not found: {lookup}")))?;
if pubkey.len() != 64 || hex::decode(pubkey).is_err() {
return Err(PodError::Nip98(format!(
"NIP-05 pubkey malformed for {identifier}"
)));
}
Ok(pubkey.clone())
}
pub fn nip05_document(names: impl IntoIterator<Item = (String, String)>) -> Nip05Document {
Nip05Document {
names: names.into_iter().collect(),
relays: None,
}
}
#[derive(Debug, Clone)]
pub struct DevSession {
pub webid: String,
pub pubkey: Option<String>,
pub is_admin: bool,
}
pub fn dev_session(webid: impl Into<String>, is_admin: bool) -> DevSession {
DevSession {
webid: webid.into(),
pubkey: None,
is_admin,
}
}
#[allow(clippy::items_after_test_module)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn well_known_solid_advertises_oidc_and_storage() {
let d = well_known_solid("https://pod.example/", "https://op.example/");
assert_eq!(d.solid_oidc_issuer, "https://op.example");
assert!(d.notification_gateway.ends_with(".notifications"));
assert!(d.storage.ends_with('/'));
}
#[test]
fn webfinger_returns_links_for_acct() {
let j = webfinger_response(
"acct:alice@pod.example",
"https://pod.example",
"https://pod.example/profile/card#me",
)
.unwrap();
assert_eq!(j.subject, "acct:alice@pod.example");
assert!(j
.links
.iter()
.any(|l| l.rel == "http://www.w3.org/ns/solid#webid"));
}
#[test]
fn webfinger_rejects_unknown_scheme() {
assert!(webfinger_response("mailto:a@b", "https://p", "https://w").is_none());
}
#[test]
fn nip05_verify_returns_pubkey() {
let mut names = std::collections::HashMap::new();
names.insert("alice".to_string(), "a".repeat(64));
let doc = nip05_document(names);
let pk = verify_nip05("alice@pod.example", &doc).unwrap();
assert_eq!(pk, "a".repeat(64));
}
#[test]
fn nip05_verify_rejects_malformed_pubkey() {
let mut names = std::collections::HashMap::new();
names.insert("alice".to_string(), "shortkey".to_string());
let doc = nip05_document(names);
assert!(verify_nip05("alice@p", &doc).is_err());
}
#[test]
fn nip05_root_name_resolves_via_underscore() {
let mut names = std::collections::HashMap::new();
names.insert("_".to_string(), "b".repeat(64));
let doc = nip05_document(names);
assert!(verify_nip05("@pod.example", &doc).is_ok());
}
#[test]
fn dev_session_stores_admin_flag() {
let s = dev_session("https://me/profile#me", true);
assert!(s.is_admin);
assert_eq!(s.webid, "https://me/profile#me");
}
}
#[cfg(feature = "did-nostr")]
pub mod did_nostr {
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::security::ssrf::SsrfPolicy;
pub fn did_nostr_well_known_url(origin: &str, pubkey: &str) -> String {
format!(
"{}/.well-known/did/nostr/{}.json",
origin.trim_end_matches('/'),
pubkey
)
}
pub fn did_nostr_document(pubkey: &str, also_known_as: &[String]) -> serde_json::Value {
#[cfg(feature = "did-nostr-types")]
if let Ok(pk) = crate::did_nostr_types::NostrPubkey::from_hex(pubkey) {
let mut doc = crate::did_nostr_types::render_did_document(&pk);
if !also_known_as.is_empty() {
doc["alsoKnownAs"] = serde_json::json!(also_known_as);
}
return doc;
}
let did = format!("did:nostr:{pubkey}");
let mut doc = serde_json::json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://www.w3.org/ns/cid/v1",
"https://w3id.org/nostr/context"
],
"id": did,
"type": "DIDNostr",
"verificationMethod": [],
"authentication": [],
"assertionMethod": []
});
if !also_known_as.is_empty() {
doc["alsoKnownAs"] = serde_json::json!(also_known_as);
}
doc
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DidNostrDoc {
pub id: String,
#[serde(default, rename = "alsoKnownAs")]
pub also_known_as: Vec<String>,
}
pub struct DidNostrResolver {
ssrf: Arc<SsrfPolicy>,
client: Client,
cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
success_ttl: Duration,
failure_ttl: Duration,
}
struct CachedEntry {
fetched: Instant,
web_id: Option<String>,
}
impl DidNostrResolver {
pub fn new(ssrf: Arc<SsrfPolicy>) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| Client::new());
Self {
ssrf,
client,
cache: Arc::new(RwLock::new(HashMap::new())),
success_ttl: Duration::from_secs(300),
failure_ttl: Duration::from_secs(60),
}
}
pub fn with_ttls(mut self, success: Duration, failure: Duration) -> Self {
self.success_ttl = success;
self.failure_ttl = failure;
self
}
pub async fn resolve(&self, origin: &str, pubkey: &str) -> Option<String> {
if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let pubkey = pubkey.to_ascii_lowercase();
let pubkey = pubkey.as_str();
let cache_key = format!("{origin}|{pubkey}");
if let Ok(guard) = self.cache.read() {
if let Some(entry) = guard.get(&cache_key) {
let ttl = if entry.web_id.is_some() {
self.success_ttl
} else {
self.failure_ttl
};
if entry.fetched.elapsed() < ttl {
return entry.web_id.clone();
}
}
}
let result =
tokio::time::timeout(RESOLVE_DEADLINE, self.resolve_uncached(origin, pubkey))
.await
.unwrap_or(None);
if let Ok(mut guard) = self.cache.write() {
if guard.len() >= MAX_CACHE_ENTRIES {
if let Some(oldest) = guard
.iter()
.min_by_key(|(_, e)| e.fetched)
.map(|(k, _)| k.clone())
{
guard.remove(&oldest);
}
}
guard.insert(
cache_key,
CachedEntry {
fetched: Instant::now(),
web_id: result.clone(),
},
);
}
result
}
fn pinned_client(&self, url: &Url, ip: std::net::IpAddr) -> Client {
let Some(url::Host::Domain(host)) = url.host() else {
return self.client.clone();
};
let port = url.port_or_known_default().unwrap_or(80);
Client::builder()
.timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.resolve(host, std::net::SocketAddr::new(ip, port))
.build()
.unwrap_or_else(|_| self.client.clone())
}
async fn resolve_uncached(&self, origin: &str, pubkey: &str) -> Option<String> {
let origin_url = Url::parse(origin).ok()?;
let origin_ip = self.ssrf.resolve_and_check(&origin_url).await.ok()?;
let url = did_nostr_well_known_url(origin, pubkey);
let resp = self
.pinned_client(&origin_url, origin_ip)
.get(&url)
.header("accept", "application/did+json, application/json")
.send()
.await
.ok()?
.error_for_status()
.ok()?;
let doc: DidNostrDoc = serde_json::from_slice(&read_capped(resp).await?).ok()?;
if doc.id != format!("did:nostr:{pubkey}") {
return None;
}
let did_iri = format!("did:nostr:{pubkey}");
for candidate in doc.also_known_as.iter().take(MAX_CANDIDATES) {
if let Some(web_id) = self.try_candidate(candidate, &did_iri).await {
return Some(web_id);
}
}
None
}
async fn try_candidate(&self, candidate: &str, did_iri: &str) -> Option<String> {
let url = Url::parse(candidate).ok()?;
let ip = self.ssrf.resolve_and_check(&url).await.ok()?;
let resp = self
.pinned_client(&url, ip)
.get(url.as_str())
.header("accept", "text/turtle, application/ld+json")
.send()
.await
.ok()?
.error_for_status()
.ok()?;
let body = read_capped(resp).await?;
if backlink_present(&body, candidate, did_iri) {
Some(candidate.to_string())
} else {
None
}
}
}
pub const MAX_CANDIDATES: usize = 4;
const RESOLVE_DEADLINE: Duration = Duration::from_secs(15);
const MAX_DOC_BYTES: usize = 512 * 1024;
const MAX_CACHE_ENTRIES: usize = 10_000;
const SAME_AS_IRIS: [&str; 4] = [
"http://www.w3.org/2002/07/owl#sameAs",
"https://schema.org/sameAs",
"http://schema.org/sameAs",
"https://schema.org/sameAs/",
];
const SAME_AS_KEYS: [&str; 7] = [
"owl:sameAs",
"schema:sameAs",
"sameAs",
"http://www.w3.org/2002/07/owl#sameAs",
"https://schema.org/sameAs",
"http://schema.org/sameAs",
"https://schema.org/sameAs/",
];
async fn read_capped(mut resp: reqwest::Response) -> Option<Vec<u8>> {
if resp
.content_length()
.is_some_and(|n| n as usize > MAX_DOC_BYTES)
{
return None;
}
let mut out = Vec::new();
while let Some(chunk) = resp.chunk().await.ok()? {
if out.len() + chunk.len() > MAX_DOC_BYTES {
return None;
}
out.extend_from_slice(&chunk);
}
Some(out)
}
pub fn backlink_present(body: &[u8], candidate: &str, did_iri: &str) -> bool {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) {
return json_backlink(&v, candidate, did_iri);
}
let doc_base = candidate.split('#').next().unwrap_or(candidate);
match crate::rdf_input::parse_turtle(body, doc_base) {
Ok(g) => g.triples().any(|t| {
matches!(&t.subject, crate::ldp::Term::Iri(s) if s == candidate)
&& matches!(&t.predicate, crate::ldp::Term::Iri(p) if SAME_AS_IRIS.contains(&p.as_str()))
&& matches!(&t.object, crate::ldp::Term::Iri(o) if o == did_iri)
}),
Err(_) => false,
}
}
fn json_backlink(v: &serde_json::Value, candidate: &str, did_iri: &str) -> bool {
use serde_json::Value;
let base = Url::parse(candidate).ok();
let resolve = |id: &str| -> String {
base.as_ref()
.and_then(|b| b.join(id).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| id.to_string())
};
let is_did = |x: &Value| match x {
Value::String(s) => s == did_iri,
Value::Object(m) => m.get("@id").and_then(Value::as_str) == Some(did_iri),
_ => false,
};
fn walk(
v: &Value,
depth: usize,
visit: &mut dyn FnMut(&serde_json::Map<String, Value>) -> bool,
) -> bool {
if depth > 16 {
return false;
}
match v {
Value::Object(m) => visit(m) || m.values().any(|c| walk(c, depth + 1, visit)),
Value::Array(a) => a.iter().any(|c| walk(c, depth + 1, visit)),
_ => false,
}
}
walk(v, 0, &mut |m| {
let id = m.get("@id").or_else(|| m.get("id")).and_then(Value::as_str);
let Some(id) = id else { return false };
if resolve(id) != candidate {
return false;
}
SAME_AS_KEYS.iter().any(|k| match m.get(*k) {
Some(Value::Array(a)) => a.iter().any(is_did),
Some(x) => is_did(x),
None => false,
})
})
}
#[cfg(test)]
mod pin_tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn pinned_client_connects_to_the_vetted_address() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let port = server.address().port();
let url = Url::parse(&format!("http://rebind.invalid:{port}/x")).unwrap();
let r = DidNostrResolver::new(Arc::new(SsrfPolicy::new()));
let unpinned = r.client.get(url.as_str()).send().await;
assert!(unpinned.is_err(), "the name must not resolve by itself");
let ok = r
.pinned_client(&url, "127.0.0.1".parse().unwrap())
.get(url.as_str())
.send()
.await
.expect("pinned request reaches the vetted address");
assert_eq!(ok.status(), 200);
}
}
}
pub fn nodeinfo_discovery(base_url: &str) -> serde_json::Value {
serde_json::json!({
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
"href": format!(
"{}/.well-known/nodeinfo/2.1",
base_url.trim_end_matches('/')
)
}
]
})
}
pub fn nodeinfo_2_1(
software_name: &str,
software_version: &str,
open_registrations: bool,
total_users: u64,
) -> serde_json::Value {
serde_json::json!({
"version": "2.1",
"software": {
"name": software_name,
"version": software_version,
"repository": "https://github.com/dreamlab-ai/solid-pod-rs",
"homepage": "https://github.com/dreamlab-ai/solid-pod-rs"
},
"protocols": ["solid", "activitypub"],
"services": {
"inbound": [],
"outbound": []
},
"openRegistrations": open_registrations,
"usage": {
"users": {
"total": total_users
}
},
"metadata": {}
})
}