From 786a892658ef659e82ea85b771193c7e0aec0c5a Mon Sep 17 00:00:00 2001 From: tomdebone Date: Wed, 9 Sep 2026 23:01:16 +0200 Subject: [PATCH] feat(pds): DID-Dokument unter /.well-known/did.json ausliefern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die AppView soll die Access-Tokens der PDS prüfen können, ohne dass PDS_JWT_SECRET den PDS-Prozess verlässt. Verifiziert wird ES256 mit dem *öffentlichen* Teil des P-256-Schlüssels — den veröffentlicht die PDS jetzt als verificationMethod (Multikey) in ihrem DID-Dokument. Damit fällt auch die hartkodierte Service-DID: describeServer gab stur did:web:pds.maarcadetweet.local zurück, unabhängig von PDS_PUBLIC_URL. Beide Endpoints leiten sie jetzt aus einer Quelle ab (AppConfig::pds_did(), did:web-Regel mit %3A-kodiertem Port). Der `iss` des Access-Tokens baute die DID zuvor ohne Port-Kodierung zusammen — also in einer Form, der kein did:web-Resolver folgen könnte. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX --- crates/at-shared/src/config.rs | 209 +++++++++++++++++++++ crates/pds-server/src/jwt_issuer.rs | 8 +- crates/pds-server/src/main.rs | 63 ++++++- crates/pds-server/tests/pds_integration.rs | 77 +++++++- 4 files changed, 354 insertions(+), 3 deletions(-) diff --git a/crates/at-shared/src/config.rs b/crates/at-shared/src/config.rs index 33975f3..b2390a9 100644 --- a/crates/at-shared/src/config.rs +++ b/crates/at-shared/src/config.rs @@ -7,6 +7,17 @@ fn default_handle_sync_interval() -> u64 { 300 } +/// Default for `APPVIEW_AUTH_REQUIRED`. +/// +/// `true` — the AppView's private endpoints (notifications, home +/// timeline) reject unauthenticated requests. Fail closed: an operator +/// who forgets the variable gets the safe behaviour, and the only way +/// to serve another user's notifications to an anonymous caller is to +/// opt out explicitly. +fn default_auth_required() -> bool { + true +} + #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub pds_host: String, @@ -46,6 +57,27 @@ pub struct AppConfig { /// directory. Default: 300s (5 minutes). #[serde(default = "default_handle_sync_interval")] pub appview_handle_sync_interval_secs: u64, + /// Whether the AppView enforces bearer-token auth on the endpoints + /// that serve a single user's private data (`/api/notifications*`, + /// `/api/timeline/home`). Default `true`. + /// + /// Set `APPVIEW_AUTH_REQUIRED=false` to get the pre-auth behaviour + /// (every endpoint public). That mode exists for two callers: + /// the fail-open integration suites, which seed synthetic DIDs the + /// PDS has never issued a token for, and an instance that is + /// already isolated at the network layer (VPN / private subnet). + /// The AppView warns loudly at startup when it is off. + #[serde(default = "default_auth_required")] + pub appview_auth_required: bool, + /// Browser origins allowed to call the AppView's `/api/*` routes, + /// from the comma-separated `APPVIEW_CORS_ORIGINS`. Empty means + /// "no allowlist configured" — the AppView then keeps the historic + /// `Access-Control-Allow-Origin: *` behaviour and warns at startup. + /// + /// Example (Tauri webview origins differ per platform): + /// `APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430` + #[serde(default)] + pub appview_cors_origins: Vec, } impl AppConfig { @@ -82,6 +114,183 @@ impl AppConfig { .ok() .and_then(|s| s.parse().ok()) .unwrap_or_else(default_handle_sync_interval), + appview_auth_required: std::env::var("APPVIEW_AUTH_REQUIRED") + .ok() + .map(|s| parse_bool_env(&s)) + .unwrap_or_else(default_auth_required), + appview_cors_origins: std::env::var("APPVIEW_CORS_ORIGINS") + .ok() + .map(|s| parse_csv_env(&s)) + .unwrap_or_default(), }) } + + /// The `did:web:` DID of *this* PDS, derived from `PDS_PUBLIC_URL`. + /// + /// One derivation, two consumers: `com.atproto.server.describeServer` + /// (which used to return a hardcoded `did:web:pds.maarcadetweet.local` + /// no matter what the operator configured) and + /// `GET /.well-known/did.json`, which publishes the server's signing + /// key under exactly this id. If those two ever disagreed, a client + /// that trusts `describeServer` would fetch the key document of a + /// different identity. + pub fn pds_did(&self) -> String { + did_web_from_url(&self.pds_public_url) + } + + /// Base URL the AppView uses to reach the PDS. + /// + /// `PDS_INTERNAL_URL` when set (the cluster-internal hostname), + /// otherwise `PDS_PUBLIC_URL`. Both the handle-sync resolver and the + /// signing-key fetch go through here, so the two can't end up + /// talking to different PDS instances. + pub fn pds_base_url(&self) -> String { + self.pds_internal_url + .clone() + .unwrap_or_else(|| self.pds_public_url.clone()) + } +} + +/// Interpret an environment variable as a boolean. +/// +/// Accepts the spellings people actually type in a `.env` file. Anything +/// unrecognised counts as `false` for an explicitly-set variable — the +/// caller decides what an *absent* variable means (see +/// [`default_auth_required`]), and a typo like `APPVIEW_AUTH_REQUIRED=ture` +/// must never silently read as "on" when the operator's intent was to +/// switch something off... nor as "off" for a security switch. Since +/// this is only reached when the variable *is* set, and the only +/// security-relevant user of it defaults to `true` when unset, we treat +/// unknown values as `false` and rely on the startup warning to make a +/// disabled auth switch impossible to miss in the logs. +fn parse_bool_env(raw: &str) -> bool { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +/// Split a comma-separated environment variable into trimmed, +/// non-empty entries. `"a, b,,c "` → `["a", "b", "c"]`. +fn parse_csv_env(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Turn an `http(s)://host[:port][/path]` URL into a `did:web:` DID. +/// +/// The did:web method spec maps the authority to the method-specific +/// id, with two wrinkles that matter here: +/// +/// - a port is **percent-encoded** (`:` → `%3A`), because a bare colon +/// already separates the DID's own segments. `http://127.0.0.1:2583` +/// is therefore `did:web:127.0.0.1%3A2583`, *not* +/// `did:web:127.0.0.1:2583` (which would parse as host `127.0.0.1` +/// plus a path segment `2583`). +/// - path segments, if any, are appended separated by `:`. +/// +/// The default ports (80/443) are kept rather than stripped: the +/// resolution rule is a textual one, and a client that reverses this +/// mapping has to end up at the same URL we serve the document from. +pub fn did_web_from_url(url: &str) -> String { + // Strip the scheme. We accept a bare `host:port` too, which is what + // a misconfigured `PDS_PUBLIC_URL` often contains. + let rest = url + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + // Drop any userinfo (`user@host`) and query/fragment — neither has + // a place in a did:web identifier. + let rest = rest.split(['?', '#']).next().unwrap_or(rest); + let rest = rest.rsplit('@').next().unwrap_or(rest); + + let mut parts = rest.split('/'); + let authority = parts.next().unwrap_or(""); + let host = authority.replacen(':', "%3A", 1); + let mut did = format!("did:web:{host}"); + for segment in parts.filter(|s| !s.is_empty()) { + did.push(':'); + did.push_str(segment); + } + did +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn did_web_encodes_port_as_percent_3a() { + // The dev default. A literal colon here would be read as a + // did:web path segment, so it has to be percent-encoded. + assert_eq!( + did_web_from_url("http://127.0.0.1:2583"), + "did:web:127.0.0.1%3A2583" + ); + assert_eq!( + did_web_from_url("https://pds.example.com:8443"), + "did:web:pds.example.com%3A8443" + ); + } + + #[test] + fn did_web_without_port_is_plain_host() { + assert_eq!( + did_web_from_url("https://pds.maarcadetweet.local"), + "did:web:pds.maarcadetweet.local" + ); + // Trailing slash must not produce an empty path segment. + assert_eq!( + did_web_from_url("https://pds.example.com/"), + "did:web:pds.example.com" + ); + // Scheme-less input is tolerated. + assert_eq!(did_web_from_url("pds.example.com"), "did:web:pds.example.com"); + } + + #[test] + fn did_web_appends_path_segments_with_colons() { + assert_eq!( + did_web_from_url("https://example.com/user/alice"), + "did:web:example.com:user:alice" + ); + // Port + path together: only the port gets percent-encoded. + assert_eq!( + did_web_from_url("http://example.com:2583/pds"), + "did:web:example.com%3A2583:pds" + ); + } + + #[test] + fn did_web_ignores_userinfo_query_and_fragment() { + assert_eq!( + did_web_from_url("https://user@example.com?x=1#frag"), + "did:web:example.com" + ); + } + + #[test] + fn bool_env_accepts_common_spellings() { + for on in ["1", "true", "TRUE", " yes ", "on"] { + assert!(parse_bool_env(on), "{on} should parse as true"); + } + for off in ["0", "false", "no", "off", "", "nonsense"] { + assert!(!parse_bool_env(off), "{off} should parse as false"); + } + } + + #[test] + fn csv_env_trims_and_drops_empties() { + assert_eq!( + parse_csv_env("tauri://localhost, http://127.0.0.1:1430 ,,"), + vec![ + "tauri://localhost".to_string(), + "http://127.0.0.1:1430".to_string() + ] + ); + assert!(parse_csv_env(" ").is_empty()); + } } diff --git a/crates/pds-server/src/jwt_issuer.rs b/crates/pds-server/src/jwt_issuer.rs index 3ea2e22..c613583 100644 --- a/crates/pds-server/src/jwt_issuer.rs +++ b/crates/pds-server/src/jwt_issuer.rs @@ -39,7 +39,13 @@ pub fn issue_access_jwt( let now = chrono::Utc::now().timestamp(); let exp = now + 3600; let claims = JwtClaims { - iss: format!("did:web:{}", cfg.pds_public_url.trim_start_matches("http://").trim_start_matches("https://")), + // Same derivation as `describeServer` and `/.well-known/did.json` + // (`AppConfig::pds_did`), so a verifier can take `iss`, resolve + // the did:web document and arrive at the key this token is + // signed with. The previous inline version dropped the + // percent-encoding of the port, producing an `iss` that no + // did:web resolver could follow. + iss: cfg.pds_did(), sub: did.to_string(), aud: "did:web:appview.maarcadetweet.local".into(), iat: now, diff --git a/crates/pds-server/src/main.rs b/crates/pds-server/src/main.rs index 525e436..58512dc 100644 --- a/crates/pds-server/src/main.rs +++ b/crates/pds-server/src/main.rs @@ -73,6 +73,7 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/", get(root)) .route("/healthz", get(healthz)) + .route("/.well-known/did.json", get(did_document)) .route( "/xrpc/com.atproto.server.describeServer", get(describe_server), @@ -163,9 +164,69 @@ async fn healthz() -> Json { Json(json!({ "ok": true })) } +/// `GET /.well-known/did.json` — the PDS's own DID document. +/// +/// This is how the AppView (and any other relying party) learns the +/// P-256 public key that the access JWTs in +/// `Authorization: Bearer …` are signed with. Without it the AppView +/// could not verify a token at all, and the only alternative would be +/// shipping `PDS_JWT_SECRET` to a second service — a private signing +/// key crossing a service boundary, for a check that needs nothing but +/// the public half. +/// +/// Nothing in this response is secret. `publicKeyMultibase` is the +/// uncompressed P-256 point derived from `PDS_JWT_SECRET` by +/// [`jwt_issuer::server_p256_public_multibase`]; the secret itself +/// never leaves this process. +/// +/// The document id is [`AppConfig::pds_did`], i.e. it follows +/// `PDS_PUBLIC_URL` — so a `did:web:` resolver that starts from the DID, +/// rebuilds the URL and fetches this path lands back here rather than at +/// some other host's document. +async fn did_document(State(state): State) -> Result, (axum::http::StatusCode, Json)> { + let did = state.cfg.pds_did(); + let public_multibase = jwt_issuer::server_p256_public_multibase(&state.cfg).map_err(|e| { + // A malformed `PDS_JWT_SECRET` is the one way this fails, and + // it is exactly the failure that also breaks every token this + // server issues — surface it instead of publishing a document + // with a missing key. + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": "InternalServerError", + "message": format!("server key unavailable: {e}"), + })), + ) + })?; + Ok(Json(json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + ], + "id": did, + "verificationMethod": [{ + // `#atproto` is the fragment AT Proto uses for a repo's + // signing key; we reuse it for the server key so a generic + // did:web consumer finds it in the usual place. + "id": format!("{did}#atproto"), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": public_multibase, + }], + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": state.cfg.pds_public_url, + }], + }))) +} + async fn describe_server(State(state): State) -> Json { Json(DescribeServerResp { - did: "did:web:pds.maarcadetweet.local".into(), + // Derived from `PDS_PUBLIC_URL`, never hardcoded — see + // `AppConfig::pds_did`. The same value ids the document at + // `/.well-known/did.json`. + did: state.cfg.pds_did(), available_user_domains: vec![state .cfg .pds_handle_dns_zone diff --git a/crates/pds-server/tests/pds_integration.rs b/crates/pds-server/tests/pds_integration.rs index 7bf7c28..2b4618b 100644 --- a/crates/pds-server/tests/pds_integration.rs +++ b/crates/pds-server/tests/pds_integration.rs @@ -38,11 +38,86 @@ async fn describe_server() { .json() .await .unwrap(); - assert!(r["did"].is_string()); + // The DID is derived from `PDS_PUBLIC_URL`, not hardcoded — so we + // assert the *shape* (any deployment must produce a did:web) and + // leave the exact value to `at_shared::config`'s unit tests. + let did = r["did"].as_str().expect("describeServer must return a did"); + assert!(did.starts_with("did:web:"), "did = {did}"); assert!(r["available_user_domains"].is_array()); assert_eq!(r["invite_code_required"], json!(false)); } +/// `GET /.well-known/did.json` — the document the AppView fetches to +/// learn the key our access tokens are signed with. +/// +/// Two properties matter beyond "it returns JSON": the document's `id` +/// must be the same DID `describeServer` advertises (otherwise a client +/// that trusts one and resolves the other ends up at a different +/// identity), and it must carry a usable `publicKeyMultibase`. +#[tokio::test] +async fn did_document_publishes_the_server_key() { + if !wait_for_pds().await { + eprintln!("pds not running, skipping"); + return; + } + let c = client().await; + let doc: Value = c + .get(format!("{}/.well-known/did.json", PDS_URL)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let id = doc["id"].as_str().expect("did document needs an id"); + assert!(id.starts_with("did:web:"), "id = {id}"); + + let described: Value = c + .get(format!("{}/xrpc/com.atproto.server.describeServer", PDS_URL)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + described["did"].as_str().unwrap(), + id, + "describeServer and the did document must name the same identity" + ); + + let vm = &doc["verificationMethod"][0]; + assert_eq!(vm["type"], json!("Multikey")); + assert_eq!(vm["controller"], json!(id)); + assert_eq!(vm["id"], json!(format!("{id}#atproto"))); + let key = vm["publicKeyMultibase"] + .as_str() + .expect("verificationMethod needs publicKeyMultibase"); + // base58-btc multibase — the `z` prefix the AppView's decoder wants. + assert!(key.starts_with('z'), "key = {key}"); + + // And it really is the key our tokens verify against: mint a + // session and check the access JWT against the published key. + let handle = format!("didjson_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple()); + let acc: Value = c + .post(format!("{}/xrpc/com.atproto.server.createAccount", PDS_URL)) + .json(&json!({"handle": handle, "password": "hunter2hunter2"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let jwt = acc["access_jwt"].as_str().expect("access_jwt"); + let claims = at_crypto::jwt::verify_jwt(jwt, key) + .expect("access token must verify against the published key"); + assert_eq!(claims.sub, acc["did"].as_str().unwrap()); + assert_eq!(claims.scope.as_deref(), Some("com.atproto.access")); + // `iss` is the same did:web the document identifies. + assert_eq!(claims.iss, id); +} + #[tokio::test] async fn create_account_session_refresh_resolve() { if !wait_for_pds().await {