diff --git a/crates/tranquil-gate/src/config.rs b/crates/tranquil-gate/src/config.rs index 32aca8660..bdcff4243 100644 --- a/crates/tranquil-gate/src/config.rs +++ b/crates/tranquil-gate/src/config.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{Context, ensure}; use base64::{Engine, engine::general_purpose::STANDARD}; use confique::Config as _; -use jacquard_common::types::string::Did; +use jacquard_common::types::string::{Did, Handle}; use k256::ecdsa::SigningKey; #[derive(confique::Config)] @@ -21,6 +21,10 @@ pub struct Config { pub tranquil_url: String, #[config(env = "GATE_TRANQUIL_DID")] pub tranquil_did: String, + #[config(env = "GATE_HANDLE_DOMAIN")] + pub handle_domain: String, + #[config(env = "GATE_HANDLE_PEERS", parse_env = confique::env::parse::list_by_comma, default = [])] + pub handle_peers: Vec, #[config(env = "GATE_DELIBERI_URL")] pub deliberi_url: String, #[config(env = "GATE_DELIBERI_DID")] @@ -65,11 +69,17 @@ impl Config { ] { Did::new(value.as_str()).with_context(|| format!("invalid {name}"))?; } + Handle::new(format!("label.{}", self.handle_domain).as_str()) + .context("handle_domain must be a domain that handles can be made under")?; + let peers = self.handle_peers.iter().map(|peer| ("handle_peers", peer)); for (name, value) in [ ("tranquil_url", &self.tranquil_url), ("deliberi_url", &self.deliberi_url), ("plc_url", &self.plc_url), - ] { + ] + .into_iter() + .chain(peers) + { let url = reqwest::Url::parse(value).with_context(|| format!("invalid {name}"))?; ensure!( matches!(url.scheme(), "http" | "https") diff --git a/crates/tranquil-gate/src/lib.rs b/crates/tranquil-gate/src/lib.rs index 4130ca9a6..5520c77a5 100644 --- a/crates/tranquil-gate/src/lib.rs +++ b/crates/tranquil-gate/src/lib.rs @@ -30,6 +30,9 @@ struct RuntimeConfig { did: String, tranquil_url: String, signing_key: SigningKey, + handle_domain: String, + handle_hosts: Vec, + reserved_handles: Vec, } #[derive(Clone)] @@ -75,6 +78,16 @@ impl App { did: config.did.clone(), tranquil_url: config.tranquil_url.trim_end_matches('/').into(), signing_key, + handle_domain: config.handle_domain.to_ascii_lowercase(), + handle_hosts: std::iter::once(&config.tranquil_url) + .chain(&config.handle_peers) + .map(|url| url.trim_end_matches('/').to_owned()) + .collect(), + reserved_handles: [&config.did, &config.tranquil_did] + .into_iter() + .filter_map(|did| did.strip_prefix("did:web:")) + .map(str::to_ascii_lowercase) + .collect(), }), http, auth: ServiceAuthConfig::new(Did::new_owned(config.tranquil_did.clone())?, directory), @@ -191,11 +204,64 @@ struct Account { controller_did: String, } +// tranquil gives a delegated account whatever handle it's asked for without +// resolving it, so the gate pins the domain +fn qualify(handle: &str, domain: &str) -> Option { + let handle = handle.to_ascii_lowercase(); + let label = handle.strip_suffix(domain)?.strip_suffix('.')?; + let valid = !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + valid.then_some(handle) +} + +// worded like tranquil's own refusal, since the web shows it as is +fn handle_not_available() -> Error { + Error { + message: Some("Handle already taken".into()), + ..Error::code(StatusCode::BAD_REQUEST, "HandleNotAvailable") + } +} + impl App { + // pdses only answer describeRepo for their own accounts, so anything but + // RepoNotFound counts as taken, and an unreachable host fails closed + async fn ensure_available(&self, handle: &str) -> Result<(), Error> { + for host in &self.config.handle_hosts { + let response = self + .http + .get(format!("{host}/xrpc/com.atproto.repo.describeRepo")) + .query(&[("repo", handle)]) + .send() + .await?; + match response.status() { + status if status.is_success() => return Err(handle_not_available()), + StatusCode::BAD_REQUEST => { + let payload: UpstreamError = response.json().await.unwrap_or_default(); + if payload.error.as_deref() != Some("RepoNotFound") { + return Err(handle_not_available()); + } + } + status => return Err(upstream(format!("{host} responded {status}"))), + } + } + Ok(()) + } + async fn create(&self, actor: &str, token: &str, input: CreateInput) -> Result { let cfg = &self.config; + let handle = qualify(&input.handle, &cfg.handle_domain) + .ok_or_else(|| Error::code(StatusCode::BAD_REQUEST, "InvalidHandle"))?; + if cfg.reserved_handles.contains(&handle) { + return Err(handle_not_available()); + } self.gate.check(actor).await?; + self.ensure_available(&handle).await?; let response = self .http @@ -204,7 +270,7 @@ impl App { cfg.tranquil_url )) .bearer_auth(token) - .json(&json!({"handle": input.handle, "controllerScopes": OWNER})) + .json(&json!({"handle": handle, "controllerScopes": OWNER})) .send() .await?; let mut account: Account = relay(response).await?.json().await?; diff --git a/crates/tranquil-gate/src/tests.rs b/crates/tranquil-gate/src/tests.rs index 312d28702..d8044b7e9 100644 --- a/crates/tranquil-gate/src/tests.rs +++ b/crates/tranquil-gate/src/tests.rs @@ -5,7 +5,7 @@ use k256::ecdsa::{Signature, signature::Signer}; use tower::ServiceExt; use wiremock::{ Mock, MockServer, ResponseTemplate, - matchers::{body_json, header, method, path}, + matchers::{body_json, header, method, path, query_param}, }; const ACTOR: &str = "did:plc:abcdefghijklmnopqrstuvwx"; @@ -15,6 +15,10 @@ const LIST: &str = "farm.tranquil.delegation.listControlledAccounts"; const RESOLVE: &str = "sh.tangled.identity.resolveCommitters"; async fn setup() -> (App, MockServer) { + setup_with_peers(&[]).await +} + +async fn setup_with_peers(peers: &[&MockServer]) -> (App, MockServer) { let server = MockServer::start().await; let http = reqwest::Client::new(); let directory = Arc::new( @@ -40,6 +44,11 @@ async fn setup() -> (App, MockServer) { did: GATE.into(), tranquil_url: server.uri(), signing_key, + handle_domain: "example".into(), + handle_hosts: std::iter::once(server.uri()) + .chain(peers.iter().map(|peer| peer.uri())) + .collect(), + reserved_handles: vec!["gate.example".into()], }), http, auth: ServiceAuthConfig::new(Did::new_owned(GATE).unwrap(), directory), @@ -89,6 +98,19 @@ async fn request(app: App, token: Option<&str>) -> Response { .unwrap() } +async fn describe_repo(server: &MockServer, status: u16, body: Value) { + Mock::given(method("GET")) + .and(path("/xrpc/com.atproto.repo.describeRepo")) + .and(query_param("repo", "org.example")) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(server) + .await; +} + +async fn handle_free(server: &MockServer) { + describe_repo(server, 400, json!({"error": "RepoNotFound"})).await; +} + async fn email(server: &MockServer, values: Vec<&str>) { Mock::given(method("POST")) .and(path(format!("/xrpc/{RESOLVE}"))) @@ -176,6 +198,7 @@ async fn upstream_client_errors_pass_through_and_server_errors_fail_closed() { assert_eq!(error.status, StatusCode::NOT_FOUND); assert_eq!(error.error, "UpstreamRejected"); email(&server, vec!["person@example.com"]).await; + handle_free(&server).await; // Tranquil rejecting the creation is relayed with Tranquil's own status, // error code, and message, so a taken handle is distinguishable from the // per-controller cap. @@ -222,6 +245,7 @@ async fn forwards_the_create_token_without_controller_overrides() { let (app, server) = setup().await; let create = token(&app, GATE, CREATE, now() + 60, None); email(&server, vec!["person@example.com"]).await; + handle_free(&server).await; Mock::given(method("POST")) .and(path("/xrpc/_delegation.createDelegatedAccount")) .and(header("authorization", format!("Bearer {create}"))) @@ -244,8 +268,102 @@ async fn forwards_the_create_token_without_controller_overrides() { assert_eq!(account.controller_did, ACTOR); // The gate never enumerates delegates or opens a session of its own. assert!(server.received_requests().await.unwrap().iter().all(|r| { - r.url.query().is_none() + (r.url.query().is_none() || r.url.path().ends_with("describeRepo")) && !r.url.path().contains("createSession") && !r.url.path().contains("listControlledAccounts") })); } + +#[test] +fn qualifies_only_single_labels_under_the_handle_domain() { + assert_eq!( + qualify("Acme.Example", "example").as_deref(), + Some("acme.example") + ); + assert_eq!( + qualify("a-1.example", "example").as_deref(), + Some("a-1.example") + ); + for handle in [ + "example", + ".example", + "acme.other", + "acme.notexample", + "a.b.example", + "-acme.example", + "acme-.example", + "ac_me.example", + "acme.example.evil", + ] { + assert_eq!(qualify(handle, "example"), None, "{handle}"); + } +} + +#[tokio::test] +async fn refuses_handles_outside_the_domain_or_reserved_before_checking_email() { + let (app, server) = setup().await; + for (handle, code) in [ + ("org.elsewhere", "InvalidHandle"), + ("gate.example", "HandleNotAvailable"), + ] { + let error = app + .create( + ACTOR, + "create-token", + CreateInput { + handle: handle.into(), + }, + ) + .await + .err() + .unwrap(); + assert_eq!(error.error, code, "{handle}"); + } + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn refuses_a_handle_any_pds_in_the_domain_already_holds() { + let input = || CreateInput { + handle: "org.example".into(), + }; + for (status, body) in [ + ( + 200, + json!({"did": "did:plc:someone", "handle": "org.example"}), + ), + (400, json!({"error": "RepoDeactivated"})), + ] { + let peer = MockServer::start().await; + describe_repo(&peer, status, body).await; + let (app, server) = setup_with_peers(&[&peer]).await; + email(&server, vec!["person@example.com"]).await; + handle_free(&server).await; + let error = app + .create(ACTOR, "create-token", input()) + .await + .err() + .unwrap(); + assert_eq!(error.error, "HandleNotAvailable"); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|r| !r.url.path().contains("createDelegatedAccount")) + ); + } + + let peer = MockServer::start().await; + describe_repo(&peer, 503, json!({})).await; + let (app, server) = setup_with_peers(&[&peer]).await; + email(&server, vec!["person@example.com"]).await; + handle_free(&server).await; + let error = app + .create(ACTOR, "create-token", input()) + .await + .err() + .unwrap(); + assert_eq!(error.error, "UpstreamUnavailable"); +} diff --git a/crates/tranquil-gate/tests/config.rs b/crates/tranquil-gate/tests/config.rs index 19e02c046..e7f9aefa8 100644 --- a/crates/tranquil-gate/tests/config.rs +++ b/crates/tranquil-gate/tests/config.rs @@ -5,6 +5,7 @@ did = "did:web:gate.example" signing_key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" tranquil_url = "https://tranquil.example" tranquil_did = "did:web:tranquil.example" +handle_domain = "orgs.test" deliberi_url = "https://deliberi.example" deliberi_did = "did:web:deliberi.example" plc_url = "https://plc.directory" @@ -72,6 +73,12 @@ fn invalid_configuration_fails_before_listening() { "tranquil_url", ), ("GATE_SIGNING_KEY", "AAAA", "signing_key"), + ("GATE_HANDLE_DOMAIN", "not a domain", "handle_domain"), + ( + "GATE_HANDLE_PEERS", + "https://pds.test,https://example.com?query", + "handle_peers", + ), ("GATE_LOG_FORMAT", "invalid", "log.format"), ("RUST_LOG", "[invalid", "log.filter"), ( @@ -111,6 +118,11 @@ fn existing_environment_only_configuration_works() { ), ("GATE_TRANQUIL_URL", "https://tranquil.example"), ("GATE_TRANQUIL_DID", "did:web:tranquil.example"), + ("GATE_HANDLE_DOMAIN", "orgs.test"), + ( + "GATE_HANDLE_PEERS", + "https://pds.test,http://127.0.0.1:3001", + ), ("GATE_DELIBERI_URL", "https://deliberi.example"), ("GATE_DELIBERI_DID", "did:web:deliberi.example"), ("GATE_PLC_URL", "https://plc.directory"), diff --git a/docker-compose.yml b/docker-compose.yml index 495a329aa..c4e0366cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,6 +108,7 @@ services: GATE_DID: did:web:delegates.tngl.boltless.dev GATE_TRANQUIL_DID: did:web:tranquil.tngl.boltless.dev GATE_TRANQUIL_URL: http://tranquil:3000 + GATE_HANDLE_DOMAIN: tranquil.tngl.boltless.dev GATE_DELIBERI_URL: http://deliberi:6565 GATE_DELIBERI_DID: did:web:deliberi.tngl.boltless.dev GATE_PLC_URL: http://plc:8080 diff --git a/nix/modules/tranquil-gate.nix b/nix/modules/tranquil-gate.nix index 5a8a1ed49..84d76869d 100644 --- a/nix/modules/tranquil-gate.nix +++ b/nix/modules/tranquil-gate.nix @@ -36,6 +36,17 @@ in description = "service DID of the tranquil PDS"; }; + handleDomain = mkOption { + type = types.str; + description = "domain every created account's handle sits directly under"; + }; + + handlePeers = mkOption { + type = types.listOf types.str; + default = []; + description = "base urls of other PDSes issuing handles under handleDomain"; + }; + deliberiUrl = mkOption { type = types.str; description = "base url of deliberi, used to check email verification"; @@ -96,6 +107,7 @@ in "GATE_DID=${cfg.did}" "GATE_TRANQUIL_URL=${cfg.tranquilUrl}" "GATE_TRANQUIL_DID=${cfg.tranquilDid}" + "GATE_HANDLE_DOMAIN=${cfg.handleDomain}" "GATE_DELIBERI_URL=${cfg.deliberiUrl}" "GATE_DELIBERI_DID=${cfg.deliberiDid}" "GATE_PLC_URL=${cfg.plcUrl}" @@ -103,6 +115,7 @@ in "GATE_LOG_FORMAT=${cfg.logFormat}" "RUST_LOG=${cfg.logFilter}" ] + ++ optional (cfg.handlePeers != []) "GATE_HANDLE_PEERS=${concatStringsSep "," cfg.handlePeers}" ++ optional (cfg.extraCaFile != null) "GATE_EXTRA_CA_FILE=${cfg.extraCaFile}"; ExecStart = getExe cfg.package; Restart = "always";