From 944ba47d33fedc1b6d4016e95f4edbb70f4933b4 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 9 Sep 2026 15:04:24 +0100 Subject: [PATCH] tranquil-gate: delegated account creation gate A small axum service in front of Tranquil's delegation API. It verifies the caller's service auth, enforces the verified-email and delegate-count policy, then forwards to Tranquil with its own credentials. Configuration goes through Confique, with env vars taking precedence over an optional TOML file, and the signing key is published at /.well-known/did.json. --- Cargo.lock | 28 +++ crates/tranquil-gate/Cargo.toml | 31 +++ crates/tranquil-gate/config.example.toml | 13 ++ crates/tranquil-gate/src/config.rs | 101 +++++++++ crates/tranquil-gate/src/gate.rs | 144 ++++++++++++ crates/tranquil-gate/src/lib.rs | 269 +++++++++++++++++++++++ crates/tranquil-gate/src/main.rs | 50 +++++ crates/tranquil-gate/src/telemetry.rs | 26 +++ crates/tranquil-gate/src/tests.rs | 251 +++++++++++++++++++++ crates/tranquil-gate/tests/config.rs | 124 +++++++++++ 10 files changed, 1037 insertions(+) create mode 100644 crates/tranquil-gate/Cargo.toml create mode 100644 crates/tranquil-gate/config.example.toml create mode 100644 crates/tranquil-gate/src/config.rs create mode 100644 crates/tranquil-gate/src/gate.rs create mode 100644 crates/tranquil-gate/src/lib.rs create mode 100644 crates/tranquil-gate/src/main.rs create mode 100644 crates/tranquil-gate/src/telemetry.rs create mode 100644 crates/tranquil-gate/src/tests.rs create mode 100644 crates/tranquil-gate/tests/config.rs 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) + ); +} -- 2.51.2