diff --git a/Cargo.lock b/Cargo.lock index 51b6536fa..a9718db7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7568,6 +7568,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -9437,6 +9438,33 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tranquil-gate" +version = "0.0.1" +dependencies = [ + "anyhow", + "axum", + "base64", + "bobbin-runtime", + "bs58", + "clap", + "confique", + "jacquard-axum", + "jacquard-common", + "jacquard-identity", + "k256", + "reqwest 0.13.1", + "serde", + "serde_json", + "tangled-axum", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "wiremock", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/crates/tranquil-gate/Cargo.toml b/crates/tranquil-gate/Cargo.toml new file mode 100644 index 000000000..62bea5751 --- /dev/null +++ b/crates/tranquil-gate/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tranquil-gate" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +axum.workspace = true +base64.workspace = true +bobbin-runtime.workspace = true +clap.workspace = true +confique.workspace = true +bs58.workspace = true +jacquard-axum.workspace = true +jacquard-common.workspace = true +jacquard-identity.workspace = true +k256.workspace = true +reqwest = { workspace = true, features = ["query"] } +serde.workspace = true +serde_json.workspace = true +tangled-axum.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true +wiremock.workspace = true +tower.workspace = true diff --git a/crates/tranquil-gate/config.example.toml b/crates/tranquil-gate/config.example.toml new file mode 100644 index 000000000..352b9e07a --- /dev/null +++ b/crates/tranquil-gate/config.example.toml @@ -0,0 +1,13 @@ +bind = "0.0.0.0:3100" +did = "did:web:gate.example" +# Supply the base64-encoded secp256k1 private key using GATE_SIGNING_KEY. +tranquil_url = "https://tranquil.example" +tranquil_did = "did:web:tranquil.example" +deliberi_url = "https://deliberi.example" +deliberi_did = "did:web:deliberi.example" +plc_url = "https://plc.directory" +# extra_ca_file = "/etc/tranquil-gate/ca.pem" + +[log] +format = "text" # or "json" +filter = "info,tranquil_gate=debug" diff --git a/crates/tranquil-gate/src/config.rs b/crates/tranquil-gate/src/config.rs new file mode 100644 index 000000000..32aca8660 --- /dev/null +++ b/crates/tranquil-gate/src/config.rs @@ -0,0 +1,101 @@ +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use confique::Config as _; +use jacquard_common::types::string::Did; +use k256::ecdsa::SigningKey; + +#[derive(confique::Config)] +pub struct Config { + #[config(env = "GATE_BIND", default = "0.0.0.0:3100")] + pub bind: SocketAddr, + #[config(env = "GATE_DID")] + pub did: String, + #[config(env = "GATE_SIGNING_KEY")] + pub signing_key: String, + #[config(env = "GATE_TRANQUIL_URL")] + pub tranquil_url: String, + #[config(env = "GATE_TRANQUIL_DID")] + pub tranquil_did: String, + #[config(env = "GATE_DELIBERI_URL")] + pub deliberi_url: String, + #[config(env = "GATE_DELIBERI_DID")] + pub deliberi_did: String, + #[config(env = "GATE_PLC_URL")] + pub plc_url: String, + #[config(env = "GATE_EXTRA_CA_FILE")] + pub extra_ca_file: Option, + #[config(env = "GATE_REQUIRE_VERIFIED_EMAIL", default = true)] + pub require_verified_email: bool, + #[config(nested)] + pub log: LogConfig, +} + +#[derive(confique::Config)] +pub struct LogConfig { + #[config(env = "GATE_LOG_FORMAT", default = "text")] + pub format: String, + #[config(env = "RUST_LOG", default = "info")] + pub filter: String, +} + +impl Config { + pub fn load(path: Option<&Path>) -> anyhow::Result { + let mut builder = Self::builder().env(); + if let Some(path) = path { + builder = builder.preloaded(confique::File::new(path)?.required().load()?); + } + let config = builder + .file("/etc/tranquil-gate/config.toml") + .load() + .context("load gate configuration")?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> anyhow::Result<()> { + for (name, value) in [ + ("did", &self.did), + ("tranquil_did", &self.tranquil_did), + ("deliberi_did", &self.deliberi_did), + ] { + Did::new(value.as_str()).with_context(|| format!("invalid {name}"))?; + } + for (name, value) in [ + ("tranquil_url", &self.tranquil_url), + ("deliberi_url", &self.deliberi_url), + ("plc_url", &self.plc_url), + ] { + let url = reqwest::Url::parse(value).with_context(|| format!("invalid {name}"))?; + ensure!( + matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "{name} must be an HTTP(S) base URL without credentials, query, or fragment" + ); + } + self.signing_key()?; + ensure!( + matches!(self.log.format.as_str(), "text" | "json"), + "log.format must be text or json" + ); + tracing_subscriber::EnvFilter::try_new(format!("info,{}", self.log.filter)) + .context("invalid log.filter")?; + Ok(()) + } + + pub(crate) fn signing_key(&self) -> anyhow::Result { + let bytes = STANDARD + .decode(&self.signing_key) + .context("signing_key must be base64")?; + SigningKey::from_slice(&bytes) + .context("signing_key must be a 32-byte secp256k1 private key") + } +} diff --git a/crates/tranquil-gate/src/gate.rs b/crates/tranquil-gate/src/gate.rs new file mode 100644 index 000000000..6e921184e --- /dev/null +++ b/crates/tranquil-gate/src/gate.rs @@ -0,0 +1,144 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use k256::ecdsa::{Signature, SigningKey, signature::Signer}; +use serde::Deserialize; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{Error, now, relay}; +use axum::http::StatusCode; + +#[derive(Deserialize)] +struct Committers { + committers: Vec, +} + +/// Registration policy gating delegated account creation on Tranquil. +/// +/// Build with [`Gate::builder`], then call [`Gate::check`] for each request. +pub struct Gate { + http: reqwest::Client, + gate_did: String, + signing_key: SigningKey, + deliberi_url: String, + deliberi_did: String, + require_verified_email: bool, +} + +pub struct GateBuilder { + http: reqwest::Client, + gate_did: String, + signing_key: SigningKey, + deliberi_url: String, + deliberi_did: String, + require_verified_email: bool, +} + +impl Gate { + pub fn builder( + http: reqwest::Client, + gate_did: impl Into, + signing_key: SigningKey, + deliberi_url: impl Into, + deliberi_did: impl Into, + ) -> GateBuilder { + GateBuilder { + http, + gate_did: gate_did.into(), + signing_key, + deliberi_url: deliberi_url.into(), + deliberi_did: deliberi_did.into(), + require_verified_email: true, + } + } + + /// Tranquil enforces the per-controller delegate cap itself, keyed on the + /// authenticated controller, so the gate only checks what Tranquil cannot. + pub async fn check(&self, did: &str) -> Result<(), Error> { + if self.require_verified_email { + self.check_verified_email(did).await?; + } + Ok(()) + } + + async fn check_verified_email(&self, did: &str) -> Result<(), Error> { + let response = self + .http + .post(format!( + "{}/xrpc/sh.tangled.identity.resolveCommitters", + self.deliberi_url + )) + .bearer_auth(self.resolver_token()) + .json(&json!({"actor": did})) + .send() + .await?; + let verified: Committers = relay(response).await?.json().await?; + if !verified.committers.iter().any(|value| is_email(value)) { + return Err(Error::code(StatusCode::FORBIDDEN, "VerifiedEmailRequired")); + } + Ok(()) + } + + fn resolver_token(&self) -> String { + let timestamp = now(); + let claims = json!({"iss": self.gate_did, "aud": self.deliberi_did, "iat": timestamp, + "exp": timestamp + 60, "lxm": "sh.tangled.identity.resolveCommitters", + "jti": format!("{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()) + ); + let signature: Signature = self.signing_key.sign(input.as_bytes()); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) + } +} + +impl GateBuilder { + pub fn require_verified_email(mut self, require: bool) -> Self { + self.require_verified_email = require; + self + } + + pub fn build(self) -> Gate { + Gate { + http: self.http, + gate_did: self.gate_did, + signing_key: self.signing_key, + deliberi_url: self.deliberi_url, + deliberi_did: self.deliberi_did, + require_verified_email: self.require_verified_email, + } + } +} + +fn is_email(value: &str) -> bool { + let value = value.trim(); + !value.starts_with("did:") + && value.len() <= 320 + && !value.chars().any(char::is_whitespace) + && !value.chars().any(char::is_control) + && value.split_once('@').is_some_and(|(local, domain)| { + !local.is_empty() && !domain.is_empty() && !domain.contains('@') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_filter() { + for value in [ + "did:plc:abcdefghijklmnopqrstuvwx", + "", + "@host", + "user@", + "a@@b", + "a\0@b", + "a b@host", + ] { + assert!(!is_email(value)); + } + assert!(is_email("person@example.com")); + } +} diff --git a/crates/tranquil-gate/src/lib.rs b/crates/tranquil-gate/src/lib.rs new file mode 100644 index 000000000..4130ca9a6 --- /dev/null +++ b/crates/tranquil-gate/src/lib.rs @@ -0,0 +1,269 @@ +pub mod config; +mod gate; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use bobbin_runtime::{ReqwestHttp, UnixMicros}; +use gate::Gate; +use jacquard_axum::service_auth::{ServiceAuthConfig, ServiceAuthError}; +use jacquard_common::{deps::fluent_uri::Uri, service_auth::ServiceAuthClaims, types::string::Did}; +use jacquard_identity::{JacquardResolver, resolver::PlcSource}; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + borrow::Cow, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tangled_axum::atproto::{AtprotoService, ClaimsPolicy, ExtractAnyServiceAuth, Verified}; + +const OWNER: &str = "atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic transition:chat.bsky transition:email"; +type Directory = JacquardResolver; + +struct RuntimeConfig { + did: String, + tranquil_url: String, + signing_key: SigningKey, +} + +#[derive(Clone)] +pub struct App { + config: Arc, + http: reqwest::Client, + auth: ServiceAuthConfig>, + gate: Arc, +} + +impl App { + /// Validate configuration and prepare shared clients, auth policy, and quota lock. + pub fn new(config: &config::Config) -> anyhow::Result { + config.validate()?; + let mut http = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()); + if let Some(path) = &config.extra_ca_file { + http = + http.add_root_certificate(reqwest::Certificate::from_pem(&std::fs::read(path)?)?); + } + let http = http.build()?; + let plc = format!("{}/", config.plc_url.trim_end_matches('/')); + let directory = Arc::new( + JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()) + .with_plc_source(PlcSource::PlcDirectory { + base: Uri::parse(plc.as_str())?.to_owned(), + }), + ); + let signing_key = config.signing_key()?; + let gate = Gate::builder( + http.clone(), + config.did.clone(), + signing_key.clone(), + config.deliberi_url.trim_end_matches('/'), + config.deliberi_did.clone(), + ) + .require_verified_email(config.require_verified_email) + .build(); + Ok(Self { + config: Arc::new(RuntimeConfig { + did: config.did.clone(), + tranquil_url: config.tranquil_url.trim_end_matches('/').into(), + signing_key, + }), + http, + auth: ServiceAuthConfig::new(Did::new_owned(config.tranquil_did.clone())?, directory), + gate: Arc::new(gate), + }) + } +} + +impl AtprotoService for App { + type Resolver = Directory; + fn resolver(&self) -> &Directory { + self.auth.resolver() + } + async fn verify_claims( + &self, + claims: &ServiceAuthClaims, + ) -> Result { + if claims.lxm.as_ref().map(|v| v.as_str()) != Some("farm.tranquil.delegation.createAccount") + { + return Err(ServiceAuthError::MethodBindingRequired); + } + ClaimsPolicy::from(&self.auth) + .require_lxm(true) + .check(claims, UnixMicros::new(now() * 1_000_000)) + .await + } +} + +pub(crate) fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs() +} + +/// An xrpc error payload: `{"error": ..., "message": ...}`. +pub(crate) struct Error { + pub(crate) status: StatusCode, + pub(crate) error: Cow<'static, str>, + pub(crate) message: Option, +} +impl Error { + pub(crate) fn code(status: StatusCode, error: &'static str) -> Self { + Self { + status, + error: Cow::Borrowed(error), + message: None, + } + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + let mut body = json!({"error": self.error}); + if let Some(message) = self.message { + body["message"] = Value::String(message); + } + (self.status, Json(body)).into_response() + } +} +pub(crate) fn upstream(e: impl std::fmt::Display) -> Error { + tracing::warn!(error = %e, "upstream request failed"); + Error::code(StatusCode::BAD_GATEWAY, "UpstreamUnavailable") +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + upstream(error) + } +} + +#[derive(Default, Deserialize)] +struct UpstreamError { + error: Option, + message: Option, +} + +/// Pass a 2xx through, relay a 4xx with the upstream's own error code so the +/// caller can tell a taken handle from a delegate cap, and fail closed on 5xx. +pub(crate) async fn relay(response: reqwest::Response) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + if status.is_client_error() { + let payload: UpstreamError = match response.json().await { + Ok(payload) => payload, + Err(e) => { + tracing::warn!(%status, error = %e, "upstream error body unreadable"); + UpstreamError::default() + } + }; + return Err(Error { + status, + error: payload + .error + .map_or(Cow::Borrowed("UpstreamRejected"), Cow::Owned), + message: payload.message, + }); + } + Err(upstream(format!("upstream responded {status}"))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct CreateInput { + handle: String, +} +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct Account { + did: String, + handle: String, + #[serde(default)] + controller_did: String, +} + +impl App { + async fn create(&self, actor: &str, token: &str, input: CreateInput) -> Result { + let cfg = &self.config; + + self.gate.check(actor).await?; + + let response = self + .http + .post(format!( + "{}/xrpc/_delegation.createDelegatedAccount", + cfg.tranquil_url + )) + .bearer_auth(token) + .json(&json!({"handle": input.handle, "controllerScopes": OWNER})) + .send() + .await?; + let mut account: Account = relay(response).await?.json().await?; + account.controller_did = actor.to_owned(); + tracing::info!(controller = actor, did = %account.did, "created delegated account"); + Ok(account) + } +} + +async fn create( + State(app): State, + ExtractAnyServiceAuth(claims, token): ExtractAnyServiceAuth, + Json(input): Json, +) -> Result, Error> { + if input.handle.is_empty() || input.handle.len() > 253 || input.handle.trim() != input.handle { + return Err(Error::code(StatusCode::BAD_REQUEST, "InvalidHandle")); + } + tokio::spawn(async move { app.create(claims.iss.as_str(), &token, input).await }) + .await + .map_err(upstream)? + .map(Json) +} + +async fn did_document(State(app): State) -> Json { + let did = &app.config.did; + let mut key = vec![0xe7, 0x01]; // secp256k1-pub multicodec + key.extend_from_slice( + app.config + .signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes(), + ); + Json(json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": [ + { + "id": format!("{did}#atproto"), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string()) + } + ] + })) +} + +async fn health() -> Json { + Json(json!({"status": "ok"})) +} + +pub fn router(app: App) -> Router { + Router::new() + .route("/xrpc/_health", get(health)) + .route("/.well-known/did.json", get(did_document)) + .route("/xrpc/sh.tangled.delegation.createAccount", post(create)) + .layer(DefaultBodyLimit::max(4096)) + .with_state(app) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tranquil-gate/src/main.rs b/crates/tranquil-gate/src/main.rs new file mode 100644 index 000000000..c9fab79a5 --- /dev/null +++ b/crates/tranquil-gate/src/main.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; + +use clap::Parser; +use tranquil_gate::{App, config::Config, router}; + +mod telemetry; + +#[derive(Parser)] +#[command(about = "Provision Tranquil delegated accounts for verified Tangled users")] +struct Cli { + #[arg(short, long, env = "GATE_CONFIG", value_name = "FILE")] + config: Option, + + #[arg(long)] + check_config: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config = Config::load(cli.config.as_deref())?; + telemetry::init(&config.log)?; + let app = App::new(&config)?; + if cli.check_config { + println!("configuration is valid"); + return Ok(()); + } + let listener = tokio::net::TcpListener::bind(config.bind).await?; + tracing::info!(address = %listener.local_addr()?, "tranquil-gate listening"); + axum::serve(listener, router(app)) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate => {}, + } +} diff --git a/crates/tranquil-gate/src/telemetry.rs b/crates/tranquil-gate/src/telemetry.rs new file mode 100644 index 000000000..f8cf4352e --- /dev/null +++ b/crates/tranquil-gate/src/telemetry.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use bobbin_runtime::{Clock, SystemClock}; +use tracing_subscriber::{EnvFilter, filter::LevelFilter}; +use tranquil_gate::config::LogConfig; + +struct ClockTimer(Arc); + +impl tracing_subscriber::fmt::time::FormatTime for ClockTimer { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + write!(w, "{}", self.0.now_unix_micros().raw()) + } +} + +pub fn init(config: &LogConfig) -> anyhow::Result<()> { + let filter = EnvFilter::try_new(format!("{},{}", LevelFilter::INFO, config.filter))?; + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_timer(ClockTimer(Arc::new(SystemClock::new()))); + let result = match config.format.as_str() { + "text" => subscriber.try_init(), + "json" => subscriber.json().try_init(), + _ => anyhow::bail!("log.format must be text or json"), + }; + result.map_err(|error| anyhow::anyhow!("initialize tracing: {error}")) +} diff --git a/crates/tranquil-gate/src/tests.rs b/crates/tranquil-gate/src/tests.rs new file mode 100644 index 000000000..312d28702 --- /dev/null +++ b/crates/tranquil-gate/src/tests.rs @@ -0,0 +1,251 @@ +use super::*; +use axum::{body::Body, http::Request}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use k256::ecdsa::{Signature, signature::Signer}; +use tower::ServiceExt; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, header, method, path}, +}; + +const ACTOR: &str = "did:plc:abcdefghijklmnopqrstuvwx"; +const GATE: &str = "did:web:gate.example"; +const CREATE: &str = "farm.tranquil.delegation.createAccount"; +const LIST: &str = "farm.tranquil.delegation.listControlledAccounts"; +const RESOLVE: &str = "sh.tangled.identity.resolveCommitters"; + +async fn setup() -> (App, MockServer) { + let server = MockServer::start().await; + let http = reqwest::Client::new(); + let directory = Arc::new( + JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()).with_plc_source( + PlcSource::PlcDirectory { + base: Uri::parse(format!("{}/", server.uri()).as_str()) + .unwrap() + .to_owned(), + }, + ), + ); + let signing_key = SigningKey::from_slice(&[1; 32]).unwrap(); + let gate = gate::Gate::builder( + http.clone(), + GATE, + signing_key.clone(), + server.uri(), + "did:web:deliberi.example", + ) + .build(); + let app = App { + config: Arc::new(RuntimeConfig { + did: GATE.into(), + tranquil_url: server.uri(), + signing_key, + }), + http, + auth: ServiceAuthConfig::new(Did::new_owned(GATE).unwrap(), directory), + gate: Arc::new(gate), + }; + let mut key = vec![0xe7, 1]; + key.extend_from_slice( + app.config + .signing_key + .verifying_key() + .to_encoded_point(true) + .as_bytes(), + ); + Mock::given(method("GET")).and(path(format!("/{ACTOR}"))).respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": ACTOR, "verificationMethod": [{"id": format!("{ACTOR}#atproto"), "type":"Multikey", "controller": ACTOR, + "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string())}] + }))).mount(&server).await; + (app, server) +} + +fn token(app: &App, aud: &str, lxm: &str, exp: u64, key: Option<&SigningKey>) -> String { + let payload = json!({"iss": ACTOR, "aud": aud, "lxm": lxm, "iat": now(), "exp": exp, "jti": format!("test-{lxm}")}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()) + ); + let signature: Signature = key + .unwrap_or(&app.config.signing_key) + .sign(input.as_bytes()); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) +} + +async fn request(app: App, token: Option<&str>) -> Response { + let mut request = Request::post("/xrpc/sh.tangled.delegation.createAccount") + .header("content-type", "application/json"); + if let Some(token) = token { + request = request.header("authorization", format!("Bearer {token}")); + } + router(app) + .oneshot( + request + .body(Body::from(json!({"handle":"org.example"}).to_string())) + .unwrap(), + ) + .await + .unwrap() +} + +async fn email(server: &MockServer, values: Vec<&str>) { + Mock::given(method("POST")) + .and(path(format!("/xrpc/{RESOLVE}"))) + .and(body_json(json!({"actor": ACTOR}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"committers": values}))) + .mount(server) + .await; +} + +#[tokio::test] +async fn rejects_missing_wrong_audience_expired_wrong_method_and_bad_signature() { + let (app, server) = setup().await; + let bad_key = SigningKey::from_slice(&[2; 32]).unwrap(); + let tokens = [ + None, + Some(token( + &app, + "did:web:other.example", + CREATE, + now() + 60, + None, + )), + Some(token(&app, GATE, CREATE, now() - 1, None)), + Some(token(&app, GATE, RESOLVE, now() + 60, None)), + Some(token(&app, GATE, CREATE, now() + 60, Some(&bad_key))), + ]; + for token in tokens { + assert!( + request(app.clone(), token.as_deref()) + .await + .status() + .is_client_error() + ); + } + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|r| r.method == "GET") + ); +} + +#[tokio::test] +async fn requires_an_email_not_the_did_fallback_and_rejects_replay() { + let (app, server) = setup().await; + email(&server, vec![ACTOR]).await; + let jwt = token(&app, GATE, CREATE, now() + 60, None); + let response = request(app.clone(), Some(&jwt)).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert!( + std::str::from_utf8(&body) + .unwrap() + .contains("VerifiedEmailRequired") + ); + assert!(request(app, Some(&jwt)).await.status().is_client_error()); + assert_eq!( + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.method == "POST") + .count(), + 1 + ); +} + +#[tokio::test] +async fn upstream_client_errors_pass_through_and_server_errors_fail_closed() { + let (app, server) = setup().await; + let input = || CreateInput { + handle: "org.example".into(), + }; + // No email mock yet, so the resolver 404s and the gate relays that status. + let error = app + .create(ACTOR, "create-token", input()) + .await + .err() + .unwrap(); + assert_eq!(error.status, StatusCode::NOT_FOUND); + assert_eq!(error.error, "UpstreamRejected"); + email(&server, vec!["person@example.com"]).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. + Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) + .respond_with(ResponseTemplate::new(400).set_body_json( + json!({"error": "InvalidDelegation", "message": "delegate cap reached"}), + )) + .up_to_n_times(1) + .mount(&server) + .await; + let error = app + .create(ACTOR, "create-token", input()) + .await + .err() + .unwrap(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert_eq!(error.error, "InvalidDelegation"); + assert_eq!(error.message.as_deref(), Some("delegate cap reached")); + Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let error = app + .create(ACTOR, "create-token", input()) + .await + .err() + .unwrap(); + assert_eq!(error.status, StatusCode::BAD_GATEWAY); + assert_eq!(error.error, "UpstreamUnavailable"); +} + +#[tokio::test] +async fn rejects_missing_nonce() { + let (app, _) = setup().await; + let claims: ServiceAuthClaims = serde_json::from_value(json!({ + "iss": ACTOR, "aud": GATE, "iat": now(), "exp": now() + 60, "lxm": CREATE, "jti": null + })) + .unwrap(); + assert!(app.verify_claims(&claims).await.is_err()); +} + +#[tokio::test] +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; + Mock::given(method("POST")) + .and(path("/xrpc/_delegation.createDelegatedAccount")) + .and(header("authorization", format!("Bearer {create}"))) + .and(body_json( + json!({"handle":"org.example", "controllerScopes":OWNER}), + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"did":"did:plc:org", "handle":"org.example"})), + ) + .expect(1) + .mount(&server) + .await; + let response = request(app, Some(&create)).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let account: Account = serde_json::from_slice(&body).unwrap(); + 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.path().contains("createSession") + && !r.url.path().contains("listControlledAccounts") + })); +} diff --git a/crates/tranquil-gate/tests/config.rs b/crates/tranquil-gate/tests/config.rs new file mode 100644 index 000000000..19e02c046 --- /dev/null +++ b/crates/tranquil-gate/tests/config.rs @@ -0,0 +1,124 @@ +use std::process::{Command, Output}; + +const CONFIG: &str = r#" +did = "did:web:gate.example" +signing_key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" +tranquil_url = "https://tranquil.example" +tranquil_did = "did:web:tranquil.example" +deliberi_url = "https://deliberi.example" +deliberi_did = "did:web:deliberi.example" +plc_url = "https://plc.directory" +[log] +format = "json" +filter = "tranquil_gate=debug" +"#; + +fn check(config: &str, env: &[(&str, &str)]) -> Output { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, config).unwrap(); + Command::new(env!("CARGO_BIN_EXE_tranquil-gate")) + .env_clear() + .args(["--check-config", "--config"]) + .arg(path) + .envs(env.iter().copied()) + .output() + .unwrap() +} + +#[test] +fn toml_and_environment_overrides() { + let output = check(CONFIG, &[]); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + // An invalid file value must be replaced before validation. + assert!( + check( + &CONFIG.replace("https://tranquil.example", "invalid"), + &[("GATE_TRANQUIL_URL", "http://127.0.0.1:3000")] + ) + .status + .success() + ); + assert!( + check( + &CONFIG.replace("format = \"json\"", "format = \"invalid\""), + &[("GATE_LOG_FORMAT", "text")] + ) + .status + .success() + ); + assert!( + check( + &CONFIG.replace("tranquil_gate=debug", "[invalid"), + &[("RUST_LOG", "info")] + ) + .status + .success() + ); +} + +#[test] +fn invalid_configuration_fails_before_listening() { + for (name, value, message) in [ + ("GATE_BIND", "invalid", "bind"), + ("GATE_DID", "invalid", "did"), + ( + "GATE_TRANQUIL_URL", + "https://example.com?query", + "tranquil_url", + ), + ("GATE_SIGNING_KEY", "AAAA", "signing_key"), + ("GATE_LOG_FORMAT", "invalid", "log.format"), + ("RUST_LOG", "[invalid", "log.filter"), + ( + "GATE_EXTRA_CA_FILE", + "/nonexistent/gate-ca.pem", + "No such file", + ), + ] { + let output = check(CONFIG, &[(name, value)]); + assert!(!output.status.success(), "{name} accepted"); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains(message), "{name}: {error}"); + } +} + +#[test] +fn explicit_missing_file_is_an_error() { + let output = Command::new(env!("CARGO_BIN_EXE_tranquil-gate")) + .env_clear() + .env("GATE_CONFIG", "/nonexistent/gate-config.toml") + .arg("--check-config") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("gate-config.toml")); +} + +#[test] +fn existing_environment_only_configuration_works() { + let output = check( + "", + &[ + ("GATE_DID", "did:web:gate.example"), + ( + "GATE_SIGNING_KEY", + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + ), + ("GATE_TRANQUIL_URL", "https://tranquil.example"), + ("GATE_TRANQUIL_DID", "did:web:tranquil.example"), + ("GATE_DELIBERI_URL", "https://deliberi.example"), + ("GATE_DELIBERI_DID", "did:web:deliberi.example"), + ("GATE_PLC_URL", "https://plc.directory"), + ], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +}