diff --git a/CHANGELOG.md b/CHANGELOG.md index 2512b62994..f05630737b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `ts keys list [--json]` to print the signing keys a service publishes in `/.well-known/trusted-server.json`, and `ts keys verify` to ask a service whether a signature verifies under a kid (exit `0` verified, `1` not verified, `2` when the check could not be made). Neither command needs the admin password (#1431). - Add opt-in mobile ad-render tracing through `[integrations.gpt_diagnostics].trace_page_enabled` (default `false`, requiring GPT diagnostics). The same-tab `/_ts/trace` viewer presents bounded, redacted browser-local request, auction and GPT observations with copy, download, share and independent local/server cleanup controls. See the GPT diagnostics guide for deployment and rollout checks. - Add the opt-in `[trusted_forwarder]` section with required `auth_header` and secret-store `shared_secret` reference fields for authenticated public-origin forwarding. - `[rewrite] include_domains` limits creative asset rewriting to listed hosts. When non-empty, only asset URLs on matching hosts are proxied through `/first-party/proxy`, so only those hosts receive the EC ID on newly rewritten or signed asset URLs. The list is not enforced when a proxy URL is fetched, so previously issued proxy URLs can still forward the EC ID to hosts later removed from it. Other assets keep their original URL, and `/first-party/sign` returns `502` for them so the creative runtime loads them directly. Click-through links are not affected, and `exclude_domains` still wins. Entries must be host names, optionally prefixed with `*.`, dotted-quad IPv4 addresses, or bracketed IPv6 addresses; entries with a scheme, port, path, spaces or non-ASCII characters are rejected at load. Upgrading: deploy the binary first, then push a config that sets `include_domains`. Rolling back: remove `include_domains`, push the resulting config, then roll back the binary; older binaries reject the key because `[rewrite]` denies unknown fields. diff --git a/crates/trusted-server-cli/src/commands/cache/purge.rs b/crates/trusted-server-cli/src/commands/cache/purge.rs index dcfa38880b..f33f08bd0c 100644 --- a/crates/trusted-server-cli/src/commands/cache/purge.rs +++ b/crates/trusted-server-cli/src/commands/cache/purge.rs @@ -32,22 +32,7 @@ fn request_body(args: &PurgeArgs) -> CliResult { /// Join the service base URL and the purge path without doubling or dropping a slash. fn purge_endpoint(service: &str) -> CliResult { - let mut url = - reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?; - crate::url_guard::require_credential_safe_transport(&url, "--service")?; - if url.host().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !url.path().trim_matches('/').is_empty() - { - return cli_error( - "--service must be a base HTTPS URL without credentials, a path, query, or fragment", - ); - } - url.set_path(PURGE_PATH); - Ok(url.to_string()) + crate::url_guard::service_endpoint(service, PURGE_PATH) } /// The acknowledgment returned by the purge endpoint. diff --git a/crates/trusted-server-cli/src/commands/keys/list.rs b/crates/trusted-server-cli/src/commands/keys/list.rs new file mode 100644 index 0000000000..07caa1a4b2 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/list.rs @@ -0,0 +1,153 @@ +//! Listing the signing keys a service publishes at `/.well-known/trusted-server.json`. + +use core::fmt::Write as _; + +use serde::Deserialize; + +use crate::ad_templates::output::escape_terminal_text; +use crate::commands::keys::{KeysRequest, ListArgs, send}; +use crate::error::{CliResult, cli_error}; + +const DISCOVERY_PATH: &str = "/.well-known/trusted-server.json"; + +/// The parts of the discovery document this command reads. +#[derive(Deserialize)] +struct Discovery { + jwks: Jwks, +} + +#[derive(Deserialize)] +struct Jwks { + keys: Vec, +} + +#[derive(Deserialize)] +struct Jwk { + kid: Option, + kty: Option, + crv: Option, +} + +/// Render one line per published key: kid, key type, and curve. +fn render_keys(body: &str) -> CliResult { + let discovery: Discovery = serde_json::from_str(body) + .map_err(|error| format!("invalid discovery document: {error}"))?; + if discovery.jwks.keys.is_empty() { + return Ok("no active signing keys are published\n".to_owned()); + } + let mut rendered = String::new(); + for key in &discovery.jwks.keys { + writeln!( + rendered, + "{}\t{}\t{}", + escape_terminal_text(key.kid.as_deref().unwrap_or("")), + escape_terminal_text(key.kty.as_deref().unwrap_or("-")), + escape_terminal_text(key.crv.as_deref().unwrap_or("-")), + ) + .expect("should write to a String"); + } + Ok(rendered) +} + +/// Execute `ts keys list`. +/// +/// # Errors +/// +/// Returns an error when the service URL is unsafe or malformed, the service cannot be +/// reached, or it answers with a non-success status or a body that is not a discovery +/// document. +pub fn run_list(args: &ListArgs, out: &mut impl std::io::Write) -> CliResult<()> { + let endpoint = crate::url_guard::service_endpoint(&args.service, DISCOVERY_PATH)?; + let (status, body) = send(&endpoint, &KeysRequest::Get)?; + if !status.is_success() { + // Every adapter registers the discovery route, so a 404 means `--service` names + // something other than a Trusted Server, while unreadable signing stores answer 500. + let hint = match status.as_u16() { + 404 => " — check that --service names a Trusted Server deployment", + 500 => " — the service could not read its signing keys; check its jwks_store", + _ => "", + }; + return cli_error(format!( + "fetching the discovery document failed: {status}{hint}\n{}", + body.trim() + )); + } + + // Validated even for --json, so a script never mistakes an error page for keys. + let rendered = render_keys(&body)?; + let mut output = if args.json { body } else { rendered }; + if !output.ends_with('\n') { + output.push('\n'); + } + write!(out, "{output}").map_err(|error| format!("failed to write the key list: {error}")) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn each_key_renders_its_kid_type_and_curve() { + let body = json!({ + "version": "1.0", + "jwks": { "keys": [ + { "kty": "OKP", "crv": "Ed25519", "x": "example", "kid": "ts-2024-01-15" }, + { "kty": "OKP", "crv": "Ed25519", "x": "example", "kid": "ts-2024-02-15" }, + ]} + }) + .to_string(); + + let rendered = render_keys(&body).expect("should render a valid document"); + + assert_eq!( + rendered, "ts-2024-01-15\tOKP\tEd25519\nts-2024-02-15\tOKP\tEd25519\n", + "should print one line per key" + ); + } + + #[test] + fn an_empty_key_set_says_so() { + let body = json!({ "version": "1.0", "jwks": { "keys": [] } }).to_string(); + + let rendered = render_keys(&body).expect("should render an empty set"); + + assert!( + rendered.contains("no active signing keys"), + "should say no keys are published, got: {rendered}" + ); + } + + #[test] + fn service_controlled_fields_cannot_forge_lines_or_columns() { + let body = json!({ + "version": "1.0", + "jwks": { "keys": [ + { + "kty": "OKP\u{7}", + "crv": "Ed25519\u{1b}[1G", + "kid": "\u{1b}[2Kts-2024-09-09\tforged" + }, + ]} + }) + .to_string(); + + let rendered = render_keys(&body).expect("should render a valid document"); + + assert_eq!( + rendered, + "\\u{001B}[2Kts-2024-09-09\\u{0009}forged\tOKP\\u{0007}\tEd25519\\u{001B}[1G\n", + "should escape control characters, tabs included, in service-controlled fields" + ); + } + + #[test] + fn a_body_that_is_not_a_discovery_document_is_an_error() { + assert!(render_keys("").is_err(), "should reject HTML"); + assert!( + render_keys(&json!({ "keys": [] }).to_string()).is_err(), + "should reject a bare JWKS without the discovery wrapper" + ); + } +} diff --git a/crates/trusted-server-cli/src/commands/keys/mod.rs b/crates/trusted-server-cli/src/commands/keys/mod.rs new file mode 100644 index 0000000000..39cfcc1416 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/mod.rs @@ -0,0 +1,127 @@ +//! `ts keys` — inspect a deployed service's request-signing keys. +//! +//! Both subcommands call routes that need no credentials, so neither asks for nor sends +//! the admin password. + +pub mod list; +pub mod verify; + +use std::time::Duration; + +use clap::Subcommand; + +use crate::ad_templates::output::escape_terminal_text; +use crate::error::CliResult; +use crate::run::RunOutcome; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Subcommands under `ts keys`. +#[derive(Debug, Subcommand)] +pub enum KeysCommand { + /// List the active signing keys a service publishes in its discovery document. + List(ListArgs), + /// Ask a service whether a signature over a payload verifies under a key. + Verify(VerifyArgs), +} + +/// Arguments for `ts keys list`. +#[derive(Debug, clap::Args)] +pub struct ListArgs { + /// HTTPS base URL of the service; HTTP is permitted only for loopback development. + #[arg(long)] + pub service: String, + + /// Print the discovery document exactly as the service returned it. + #[arg(long)] + pub json: bool, +} + +/// Arguments for `ts keys verify`. +#[derive(Debug, clap::Args)] +pub struct VerifyArgs { + /// HTTPS base URL of the service; HTTP is permitted only for loopback development. + #[arg(long)] + pub service: String, + + /// Key identifier the signature was made with. + #[arg(long)] + pub kid: String, + + /// Canonical payload that was signed. + #[arg(long, allow_hyphen_values = true)] + pub payload: String, + + /// Base64-encoded signature to check. + #[arg(long, allow_hyphen_values = true)] + pub signature: String, +} + +/// Run a `ts keys` subcommand. +/// +/// `ts keys verify` returns [`RunOutcome::AssertionFailed`] when the signature does not +/// verify, so the process exits non-zero and a script can rely on the exit code. +/// +/// # Errors +/// +/// Returns an error when the service URL is unsafe or malformed, the service cannot be +/// reached, or it answers with a non-success status or an unexpected body. +pub fn run(command: KeysCommand, out: &mut impl std::io::Write) -> CliResult { + match command { + KeysCommand::List(args) => list::run_list(&args, out).map(|()| RunOutcome::Success), + KeysCommand::Verify(args) => verify::run_verify(&args, out), + } +} + +/// An unauthenticated request to send to the service. +enum KeysRequest<'body> { + Get, + PostJson(&'body str), +} + +/// Send one request without credentials and return the status and body text. +/// +/// Redirects are not followed: a service that redirects these routes is misconfigured, +/// and following one would quietly report on a different host than the operator named. +fn send(endpoint: &str, request: &KeysRequest<'_>) -> CliResult<(reqwest::StatusCode, String)> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("failed to start the HTTP runtime: {error}"))?; + + // Without this the first HTTPS request panics with "No provider set". + crate::tls::install_crypto_provider(); + + runtime.block_on(async { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to build the HTTP client: {error}"))?; + let builder = match request { + KeysRequest::Get => client.get(endpoint), + KeysRequest::PostJson(body) => client + .post(endpoint) + .header("content-type", "application/json") + .body((*body).to_owned()), + }; + let response = builder + .header("accept", "application/json") + .send() + .await + .map_err(|error| format!("could not reach {endpoint}: {error}"))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| format!("could not read the response from {endpoint}: {error}"))?; + // Error bodies are service-controlled and reach stderr, so escape them; + // successful bodies stay byte for byte for `--json` and parsing. + let text = if status.is_success() { + text + } else { + escape_terminal_text(text.trim()).into_owned() + }; + Ok((status, text)) + }) +} diff --git a/crates/trusted-server-cli/src/commands/keys/verify.rs b/crates/trusted-server-cli/src/commands/keys/verify.rs new file mode 100644 index 0000000000..2785516b62 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/verify.rs @@ -0,0 +1,203 @@ +//! Asking a service whether a signature verifies, through `POST /verify-signature`. + +use serde::Deserialize; + +use crate::ad_templates::output::escape_terminal_text; +use crate::commands::keys::{KeysRequest, VerifyArgs, send}; +use crate::error::{CliResult, cli_error}; +use crate::run::RunOutcome; + +const VERIFY_PATH: &str = "/verify-signature"; + +/// The verification answer returned by the service. +#[derive(Deserialize)] +struct VerifyResponse { + verified: bool, + kid: String, + message: String, + error: Option, +} + +fn request_body(args: &VerifyArgs) -> String { + serde_json::json!({ + "payload": args.payload, + "signature": args.signature, + "kid": args.kid, + }) + .to_string() +} + +/// Turn the service's answer into the line to print and the outcome to exit with. +fn interpret(body: &str) -> CliResult<(String, RunOutcome)> { + let response: VerifyResponse = serde_json::from_str(body) + .map_err(|error| format!("invalid verification response: {error}"))?; + if response.verified { + return Ok(( + format!( + "verified: signature is valid for kid {}", + escape_terminal_text(&response.kid) + ), + RunOutcome::Success, + )); + } + // The service folds an unknown kid, a malformed signature, and an unreadable signing + // store into this one sanitized value, so name the possibilities instead of echoing it. + let detail = match response.error.as_deref() { + Some("internal verification error") => " (the service could not check it: the kid \ + may not exist, the signature may not be 64 bytes of base64, or the service could \ + not read its keys)" + .to_owned(), + Some(error) => format!(" ({})", escape_terminal_text(error)), + None => String::new(), + }; + Ok(( + format!( + "not verified: {} for kid {}{detail}", + escape_terminal_text(&response.message), + escape_terminal_text(&response.kid) + ), + RunOutcome::AssertionFailed, + )) +} + +/// Execute `ts keys verify`. +/// +/// Returns [`RunOutcome::AssertionFailed`] when the service reports that the signature +/// did not verify. +/// +/// # Errors +/// +/// Returns an error when the service URL is unsafe or malformed, the service cannot be +/// reached, or it answers with a non-success status or an unexpected body. +pub fn run_verify(args: &VerifyArgs, out: &mut impl std::io::Write) -> CliResult { + let endpoint = crate::url_guard::service_endpoint(&args.service, VERIFY_PATH)?; + let body = request_body(args); + let (status, response_body) = send(&endpoint, &KeysRequest::PostJson(&body))?; + if !status.is_success() { + return cli_error(format!( + "signature verification request failed: {status}\n{}", + response_body.trim() + )); + } + let (line, outcome) = interpret(&response_body)?; + writeln!(out, "{line}") + .map_err(|error| format!("failed to write the verification result: {error}"))?; + Ok(outcome) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn the_request_body_carries_every_field_escaped() { + let args = VerifyArgs { + service: "https://edge.example.com".to_owned(), + kid: "ts-2024-01-15".to_owned(), + payload: r#"{"id":"example"}"#.to_owned(), + signature: "c2lnbmF0dXJl".to_owned(), + }; + + let parsed: serde_json::Value = + serde_json::from_str(&request_body(&args)).expect("should be valid JSON"); + + assert_eq!( + parsed, + json!({ + "payload": r#"{"id":"example"}"#, + "signature": "c2lnbmF0dXJl", + "kid": "ts-2024-01-15", + }), + "should send the payload verbatim" + ); + } + + #[test] + fn a_verified_answer_succeeds() { + let body = json!({ + "verified": true, + "kid": "ts-2024-01-15", + "message": "Signature verified successfully", + }) + .to_string(); + + let (line, outcome) = interpret(&body).expect("should interpret"); + + assert_eq!(outcome, RunOutcome::Success, "should exit zero"); + assert!(line.starts_with("verified:"), "got: {line}"); + } + + #[test] + fn an_unverified_answer_fails_the_assertion() { + let body = json!({ + "verified": false, + "kid": "ts-2024-01-15", + "message": "Signature verification failed", + "error": "Invalid signature", + }) + .to_string(); + + let (line, outcome) = interpret(&body).expect("should interpret"); + + assert_eq!(outcome, RunOutcome::AssertionFailed, "should exit non-zero"); + assert!(line.contains("Invalid signature"), "got: {line}"); + } + + #[test] + fn the_sanitized_internal_error_names_its_possible_causes() { + let body = json!({ + "verified": false, + "kid": "ts-2099-01-01", + "message": "Verification error", + "error": "internal verification error", + }) + .to_string(); + + let (line, outcome) = interpret(&body).expect("should interpret"); + + assert_eq!(outcome, RunOutcome::AssertionFailed, "should exit non-zero"); + assert!( + line.contains("the kid may not exist"), + "should name what the service could not check: {line}" + ); + } + + #[test] + fn service_controlled_fields_are_escaped() { + let body = json!({ + "verified": false, + "kid": "ts-2024-01-15\u{1b}[2K", + "message": "Signature verification failed\u{1b}[1G", + "error": "Invalid signature\u{1b}]0;title\u{7}", + }) + .to_string(); + + let (line, _) = interpret(&body).expect("should interpret"); + + assert!( + !line.contains('\u{1b}') && !line.contains('\u{7}'), + "should not pass control characters to the terminal: {line:?}" + ); + + let verified = json!({ + "verified": true, + "kid": "ts-2024-01-15\u{1b}[2K", + "message": "Signature verified successfully", + }) + .to_string(); + + let (line, _) = interpret(&verified).expect("should interpret"); + + assert!( + !line.contains('\u{1b}'), + "should not pass control characters to the terminal: {line:?}" + ); + } + + #[test] + fn an_unexpected_body_is_an_error() { + assert!(interpret("not json").is_err(), "should reject non-JSON"); + } +} diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 0f4354f007..1797fb26b1 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -4,8 +4,10 @@ pub(crate) mod pbs; // `dev` is `pub` so the macOS-gated `tests/proxy_e2e.rs` suite can reach // `commands::dev::proxy`, `origin` is `pub` so `tests/origin_probe.rs` can drive the // shareability probe against a local fixture, and `cache` is `pub` so -// `tests/cache_purge.rs` can drive a purge against one; the other command modules are -// crate-internal. +// `tests/cache_purge.rs` can drive a purge against one, and `keys` is `pub` so +// `tests/keys_cli.rs` can drive both key commands against one; the other command +// modules are crate-internal. pub mod cache; pub mod dev; +pub mod keys; pub mod origin; diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 992452da32..e3984d07da 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -65,6 +65,9 @@ enum Command { /// Local developer tools (e.g. the macOS/Linux production-hostname proxy). #[command(subcommand)] Dev(crate::commands::dev::DevCommand), + /// Inspect a deployed service's request-signing keys. + #[command(subcommand)] + Keys(crate::commands::keys::KeysCommand), /// Questions about a publisher origin's behaviour. #[command(subcommand)] Origin(crate::commands::origin::OriginCommand), @@ -189,6 +192,11 @@ fn dispatch(args: Args) -> Result { let mut out = stdout.lock(); crate::commands::cache::run(command, &mut out).map(|()| RunOutcome::Success) } + Command::Keys(command) => { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + crate::commands::keys::run(command, &mut out) + } Command::Origin(command) => { let stdout = std::io::stdout(); let mut out = stdout.lock(); @@ -247,6 +255,33 @@ mod tests { ); } + #[test] + fn keys_verify_accepts_values_that_start_with_a_hyphen() { + // Trusted Server signs with unpadded base64url, whose alphabet includes `-`, so + // about one signature in 64 starts with one. + let args = parse(&[ + "ts", + "keys", + "verify", + "--service", + "https://edge.example.com", + "--kid", + "ts-2024-01-15", + "--payload", + "-example-payload", + "--signature", + "-LRTQNDx", + ]); + let Command::Keys(crate::commands::keys::KeysCommand::Verify(verify)) = args.command else { + panic!("expected keys verify command"); + }; + assert_eq!( + verify.payload, "-example-payload", + "should keep the payload" + ); + assert_eq!(verify.signature, "-LRTQNDx", "should keep the signature"); + } + #[test] fn parses_prebid_server_inspect() { assert!( diff --git a/crates/trusted-server-cli/src/url_guard.rs b/crates/trusted-server-cli/src/url_guard.rs index 240f10d543..67a2c6201c 100644 --- a/crates/trusted-server-cli/src/url_guard.rs +++ b/crates/trusted-server-cli/src/url_guard.rs @@ -1,4 +1,5 @@ -//! Transport checks shared by the commands that send an operator's credentials to a URL. +//! Transport checks shared by the commands that send an operator's credentials to a URL +//! or report what a service answered. //! //! One implementation rather than one per command: a second copy is a second place for the //! loopback exemption to drift, and every command that gets this wrong sends a session @@ -28,8 +29,8 @@ fn is_loopback(url: &reqwest::Url) -> bool { pub(crate) fn require_credential_safe_transport(url: &reqwest::Url, flag: &str) -> CliResult<()> { if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback(url)) { return cli_error(format!( - "{flag} requires HTTPS to protect the credentials sent with the request; HTTP is \ - allowed only for loopback development services" + "{flag} requires HTTPS so the request and the service's answer cannot be read or \ + altered in transit; HTTP is allowed only for loopback development services" )); } if !url.username().is_empty() || url.password().is_some() { @@ -41,6 +42,32 @@ pub(crate) fn require_credential_safe_transport(url: &reqwest::Url, flag: &str) Ok(()) } +/// Join a service base URL and an endpoint path without doubling or dropping a slash. +/// +/// The service must be a bare base URL: credentials, a path, a query, or a fragment would +/// each let the typed value change which endpoint is reached. +/// +/// # Errors +/// +/// Returns an error when `service` is not an absolute URL, fails +/// [`require_credential_safe_transport`], or carries more than a scheme, host, and port. +pub(crate) fn service_endpoint(service: &str, path: &str) -> CliResult { + let mut url = + reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?; + require_credential_safe_transport(&url, "--service")?; + if url.host().is_none() + || url.query().is_some() + || url.fragment().is_some() + || !url.path().trim_matches('/').is_empty() + { + return cli_error( + "--service must be a base HTTPS URL without credentials, a path, query, or fragment", + ); + } + url.set_path(path); + Ok(url.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-cli/tests/keys_cli.rs b/crates/trusted-server-cli/tests/keys_cli.rs new file mode 100644 index 0000000000..64af87d21c --- /dev/null +++ b/crates/trusted-server-cli/tests/keys_cli.rs @@ -0,0 +1,261 @@ +//! Tests for `ts keys list` and `ts keys verify` against a local fixture standing in for +//! a deployed service. +//! +//! Run with `./scripts/test-cli.sh`. + +mod support_origin; + +use serde_json::json; +use support_origin::{FixtureResponse, FixtureServer}; +use trusted_server_cli::RunOutcome; +use trusted_server_cli::commands::keys::{KeysCommand, ListArgs, VerifyArgs, run}; + +fn discovery_body() -> String { + json!({ + "version": "1.0", + "jwks": { "keys": [ + { "kty": "OKP", "crv": "Ed25519", "x": "example-x", "kid": "ts-2024-01-15", "alg": "EdDSA" }, + { "kty": "OKP", "crv": "Ed25519", "x": "example-y", "kid": "ts-2024-02-15", "alg": "EdDSA" }, + ]} + }) + .to_string() +} + +fn json_response(body: String) -> FixtureResponse { + FixtureResponse::html(body).with_header("content-type", "application/json") +} + +fn list_args(server: &FixtureServer, json: bool) -> ListArgs { + ListArgs { + service: server.url(""), + json, + } +} + +fn verify_args(server: &FixtureServer) -> VerifyArgs { + VerifyArgs { + service: server.url(""), + kid: "ts-2024-01-15".to_owned(), + payload: "example-payload".to_owned(), + signature: "c2lnbmF0dXJl".to_owned(), + } +} + +#[test] +fn list_prints_each_active_kid_with_type_and_curve() { + let server = FixtureServer::start(|request| { + assert_eq!(request.method, "GET", "should fetch the discovery document"); + assert_eq!(request.path, "/.well-known/trusted-server.json"); + assert!( + request.header("authorization").is_none(), + "should never send credentials" + ); + json_response(discovery_body()) + }); + + let mut out = Vec::new(); + let outcome = + run(KeysCommand::List(list_args(&server, false)), &mut out).expect("should list the keys"); + + assert_eq!(outcome, RunOutcome::Success, "should exit zero"); + let rendered = String::from_utf8(out).expect("output should be UTF-8"); + assert_eq!( + rendered, "ts-2024-01-15\tOKP\tEd25519\nts-2024-02-15\tOKP\tEd25519\n", + "should print one line per active key" + ); +} + +#[test] +fn list_json_prints_the_discovery_document_unchanged() { + let body = discovery_body(); + let server = { + let body = body.clone(); + FixtureServer::start(move |_request| json_response(body.clone())) + }; + + let mut out = Vec::new(); + run(KeysCommand::List(list_args(&server, true)), &mut out).expect("should list the keys"); + + let rendered = String::from_utf8(out).expect("output should be UTF-8"); + assert_eq!( + rendered, + format!("{body}\n"), + "should print the service's document byte for byte, plus a final newline" + ); +} + +#[test] +fn list_reports_a_failed_fetch_as_an_error() { + for (status, hint) in [(404, "names a Trusted Server"), (500, "jwks_store")] { + let server = FixtureServer::start(move |_request| { + FixtureResponse::html("error").with_status(status) + }); + + let mut out = Vec::new(); + let error = run(KeysCommand::List(list_args(&server, false)), &mut out) + .expect_err("should fail on an error status"); + + assert!( + error.contains(&status.to_string()), + "should name the status: {error}" + ); + assert!( + error.contains(hint), + "should point at the likely cause: {error}" + ); + assert!(out.is_empty(), "should print no keys"); + } +} + +#[test] +fn error_bodies_are_escaped_for_both_commands() { + let server = FixtureServer::start(|_request| { + FixtureResponse::html("\u{1b}[2Kforged\rline\n").with_status(500) + }); + + let mut out = Vec::new(); + let list_error = run(KeysCommand::List(list_args(&server, false)), &mut out) + .expect_err("should fail on an error status"); + let verify_error = run(KeysCommand::Verify(verify_args(&server)), &mut out) + .expect_err("should fail on an error status"); + + for error in [list_error, verify_error] { + assert!( + !error.contains('\u{1b}') && !error.contains('\r'), + "should not pass control characters to the terminal: {error:?}" + ); + assert!( + error.ends_with("\\u{000D}line"), + "should trim the trailing newline before escaping: {error:?}" + ); + } +} + +#[test] +fn list_does_not_follow_a_redirect() { + let server = FixtureServer::start(|request| { + if request.path == "/.well-known/trusted-server.json" { + FixtureResponse::html("") + .with_status(302) + .with_header("location", "/moved.json") + } else { + json_response(discovery_body()) + } + }); + + let mut out = Vec::new(); + let error = run(KeysCommand::List(list_args(&server, false)), &mut out) + .expect_err("should refuse a redirected discovery document"); + + assert!(error.contains("302"), "should name the redirect: {error}"); + assert!(out.is_empty(), "should print no keys"); + assert_eq!(server.request_count(), 1, "should not follow the redirect"); +} + +#[test] +fn list_json_rejects_a_page_that_is_not_a_discovery_document() { + let server = FixtureServer::start(|_request| FixtureResponse::html("login")); + + let mut out = Vec::new(); + run(KeysCommand::List(list_args(&server, true)), &mut out) + .expect_err("should validate the document even with --json"); + + assert!(out.is_empty(), "should not print the page as keys"); +} + +#[test] +fn verify_reports_a_rejected_request_as_an_error() { + let server = FixtureServer::start(|_request| { + FixtureResponse::html("payload too large").with_status(413) + }); + + let mut out = Vec::new(); + let error = + run(KeysCommand::Verify(verify_args(&server)), &mut out).expect_err("should fail on a 413"); + + assert!(error.contains("413"), "should name the status: {error}"); + assert!(out.is_empty(), "should print no verification result"); +} + +#[test] +fn verify_sends_the_fields_and_succeeds_when_verified() { + let server = FixtureServer::start(|request| { + assert_eq!(request.method, "POST", "should post to the verify route"); + assert_eq!(request.path, "/verify-signature"); + assert_eq!(request.header("content-type"), Some("application/json")); + assert!( + request.header("authorization").is_none(), + "should never send credentials" + ); + let body: serde_json::Value = + serde_json::from_slice(&request.body).expect("should send JSON"); + assert_eq!( + body, + json!({ + "payload": "example-payload", + "signature": "c2lnbmF0dXJl", + "kid": "ts-2024-01-15", + }), + "should send the payload, signature, and kid" + ); + json_response( + json!({ + "verified": true, + "kid": "ts-2024-01-15", + "message": "Signature verified successfully", + }) + .to_string(), + ) + }); + + let mut out = Vec::new(); + let outcome = + run(KeysCommand::Verify(verify_args(&server)), &mut out).expect("should get an answer"); + + assert_eq!(outcome, RunOutcome::Success, "should exit zero"); + let rendered = String::from_utf8(out).expect("output should be UTF-8"); + assert!(rendered.starts_with("verified:"), "got: {rendered}"); +} + +#[test] +fn verify_exits_non_zero_when_the_signature_does_not_verify() { + let server = FixtureServer::start(|_request| { + json_response( + json!({ + "verified": false, + "kid": "ts-2024-01-15", + "message": "Signature verification failed", + "error": "Invalid signature", + }) + .to_string(), + ) + }); + + let mut out = Vec::new(); + let outcome = + run(KeysCommand::Verify(verify_args(&server)), &mut out).expect("should get an answer"); + + assert_eq!(outcome, RunOutcome::AssertionFailed, "should exit non-zero"); + assert_ne!(outcome.exit_code(), 0, "should give scripts a failing code"); + let rendered = String::from_utf8(out).expect("output should be UTF-8"); + assert!(rendered.starts_with("not verified:"), "got: {rendered}"); +} + +#[test] +fn plaintext_remote_services_are_rejected() { + for service in ["http://edge.example.com", "ftp://127.0.0.1"] { + let mut out = Vec::new(); + let error = run( + KeysCommand::List(ListArgs { + service: service.to_owned(), + json: false, + }), + &mut out, + ) + .expect_err("should refuse insecure remote transport"); + assert!( + error.contains("HTTPS"), + "should explain the transport requirement: {error}" + ); + } +} diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index a84707f492..7c867d4696 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -690,6 +690,10 @@ the store state needed to enumerate active public keys. **Example:** ```bash +ts keys list --service https://edge.example.com +# the discovery document exactly as the service returned it +ts keys list --service https://edge.example.com --json +# or curl https://edge.example.com/.well-known/trusted-server.json ``` @@ -717,7 +721,7 @@ policy. `[request_signing]` storage must be readable. ```json { - "payload": "base64-encoded-data", + "payload": "message that was signed", "signature": "base64-signature", "kid": "ts-2025-01-A" } @@ -747,9 +751,12 @@ policy. `[request_signing]` storage must be readable. **Example:** ```bash +ts keys verify --service https://edge.example.com --kid ts-2025-01-A \ + --payload 'Hello World' --signature 'abc123...' +# or curl -X POST https://edge.example.com/verify-signature \ -H "Content-Type: application/json" \ - -d '{"payload":"SGVsbG8gV29ybGQ=","signature":"abc123...","kid":"ts-2025-01-A"}' + -d '{"payload":"Hello World","signature":"abc123...","kid":"ts-2025-01-A"}' ``` --- diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 099c046341..c1e8833b49 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -25,6 +25,8 @@ commands are shown explicitly. | `ts auth logout` | Linux + macOS | Sign out (`wrangler logout` / `fastly profile delete` / `spin cloud logout`) | `ts auth logout --adapter ` | | `ts auth status` | Linux + macOS | Show the current session (`wrangler whoami` / `fastly profile list` / `spin cloud info`) | `ts auth status --adapter ` | | `ts build` | Linux + macOS | Build the project for a target adapter | `ts build --adapter [ADAPTER_ARGS]...` | +| `ts cache` | Linux + macOS | Shared template cache commands | `ts cache ` | +| `ts cache purge` | Linux + macOS | Purge cached templates and tagged origin responses through a deployed service's admin endpoint | `ts cache purge [OPTIONS] --service ` | | `ts config` | Linux + macOS | Trusted Server app-config commands | `ts config ` | | `ts config ad-templates` | Linux + macOS | Diagnose server-side ad-template configuration and path matching | `ts config ad-templates ` | | `ts config ad-templates check` | Linux + macOS | Assert that a page path or URL matches the expected slot set | `ts config ad-templates check [OPTIONS] <--expected-slot \|--expect-no-slots> ` | @@ -45,6 +47,9 @@ commands are shown explicitly. | `ts dev proxy ca regenerate` | macOS only | Regenerate the per-machine CA (invalidates prior trust) | `ts dev proxy ca regenerate` | | `ts dev proxy ca uninstall` | macOS only | Remove the CA from the OS trust store | `ts dev proxy ca uninstall` | | `ts healthcheck` | Linux + macOS | Probe a deployed version until it reports healthy | `ts healthcheck [OPTIONS] --adapter --domain --service-id --version ` | +| `ts keys` | Linux + macOS | Inspect a deployed service's request-signing keys | `ts keys ` | +| `ts keys list` | Linux + macOS | List the active signing keys a service publishes in its discovery document | `ts keys list [OPTIONS] --service ` | +| `ts keys verify` | Linux + macOS | Ask a service whether a signature over a payload verifies under a key | `ts keys verify --service --kid --payload --signature ` | | `ts prebid` | Linux + macOS | Trusted Server Prebid commands | `ts prebid ` | | `ts prebid client` | Linux + macOS | Generate a local external Prebid client bundle and update config metadata | `ts prebid client [OPTIONS]` | | `ts prebid server` | Linux + macOS | Configure and operate a self-hosted Prebid Server deployment | `ts prebid server [OPTIONS] ` | diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index c2ddcebbd9..bcf5ea7950 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -461,18 +461,23 @@ curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ **Causes & Solutions:** -**Wrong payload encoding:** +**Payload is not exactly what was signed:** + +Send the signed message itself, not an encoding of it. The service verifies the +UTF-8 bytes of `payload` as received, so base64-encoding it, or dropping a trailing +newline, makes a valid signature fail. The signature may be base64url, as Trusted +Server signs, or standard base64. ```bash -# Ensure base64 encoding (not base64url) -echo -n "Hello World" | base64 +ts keys verify --service https://edge.example.com \ + --kid ts-2025-01-A --payload 'Hello World' --signature '' ``` **Wrong key ID:** ```bash -# Check current key ID -curl https://edge.example.com/.well-known/trusted-server.json | jq '.jwks.keys[].kid' +# List the published key IDs +ts keys list --service https://edge.example.com ``` **Signature from wrong key:** diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index 3f8b6f4d96..e996e2f01a 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -196,10 +196,10 @@ Verify your setup is correct: fastly compute serve # Check that stores are accessible -curl http://localhost:7676/.well-known/trusted-server.json +ts keys list --service http://localhost:7676 ``` -You should see a JWKS response with your public keys. +You should see one line per active public key. ## Key Rotation Process @@ -300,6 +300,39 @@ curl -X POST https://your-domain/_ts/admin/keys/rotate \ } ``` +### Confirm the New Key Is Published + +Before deactivating the previous key, confirm the new key appears in the service's +discovery document (`/.well-known/trusted-server.json`). `ts keys list` fetches it and +prints each active kid with its key type and curve; it needs no admin password: + +```bash +ts keys list --service https://edge.example.com +``` + +``` +ts-2024-01-15 OKP Ed25519 +ts-2024-02-15 OKP Ed25519 +``` + +Pass `--json` to print the discovery document exactly as the service returned it. To +check a signature end to end against the new key, use `ts keys verify`: + +```bash +ts keys verify --service https://edge.example.com \ + --kid ts-2024-02-15 --payload '' --signature '' +``` + +It exits `0` when the service reports that the signature verified, `1` when the service +reports that it did not (an unknown kid reports this way too), and `2` when the check +could not be made, for example because the service was unreachable or answered with an +error status. The service checks the key stored under the kid whether or not that kid is +still active, so a deactivated key that was kept in storage still verifies. Use +`ts keys list` to see which keys partners can fetch. + +Both commands require HTTPS, with plain HTTP allowed only for loopback development +services. + ### Using the Rust API ```rust @@ -591,8 +624,8 @@ Document your rotation: Test rotation in staging first: - Verify new key generation -- Test signature verification -- Validate JWKS endpoint +- Test signature verification with `ts keys verify` +- Validate the JWKS endpoint with `ts keys list` - Check partner integrations ## Troubleshooting @@ -629,14 +662,15 @@ Test rotation in staging first: **Solutions**: - Verify old key is still in active-kids -- Check JWKS endpoint includes old key +- Check the old key is still published with `ts keys list --service https://edge.example.com` - Wait for partner caches to update ### Key Not in JWKS **Symptoms**: -- New key missing from `.well-known/trusted-server.json` +- New key missing from `.well-known/trusted-server.json` (check with + `ts keys list --service https://edge.example.com`) **Solutions**: diff --git a/docs/guide/request-signing.md b/docs/guide/request-signing.md index ecb6de9f5f..1bcd4740ec 100644 --- a/docs/guide/request-signing.md +++ b/docs/guide/request-signing.md @@ -138,6 +138,14 @@ Trusted Server provides a built-in endpoint for testing signatures: } ``` +From the CLI, `ts keys verify` sends this request and exits non-zero when the +signature does not verify: + +```bash +ts keys verify --service https://edge.example.com \ + --kid ts-2024-01-15 --payload 'message to verify' --signature '' +``` + ## Discovery Endpoint Public keys are published via a standardized discovery endpoint following IAB patterns. @@ -165,6 +173,9 @@ Public keys are published via a standardized discovery endpoint following IAB pa } ``` +From the CLI, `ts keys list --service https://edge.example.com` prints each +published kid with its key type and curve. + ### Using Discovery for Verification Partners can fetch and cache your public keys: @@ -332,14 +343,9 @@ Use the verification endpoint: # Sign a payload SIGNATURE=$(sign-payload "test message") -# Verify via API -curl -X POST https://your-domain/verify-signature \ - -H "Content-Type: application/json" \ - -d '{ - "payload": "test message", - "signature": "'$SIGNATURE'", - "kid": "ts-2024-01-15" - }' +# Verify via the service; exits non-zero when the signature does not verify +ts keys verify --service https://edge.example.com \ + --kid ts-2024-01-15 --payload 'test message' --signature "$SIGNATURE" ``` ## Next Steps