From 8df9a40010c4279b6e067306994029dfe7db5847 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 5 Nov 2026 19:04:04 +0530 Subject: [PATCH 1/4] Add ts keys list and ts keys verify commands --- .../src/commands/cache/purge.rs | 17 +- .../src/commands/keys/list.rs | 127 ++++++++++++ .../src/commands/keys/mod.rs | 119 ++++++++++++ .../src/commands/keys/verify.rs | 141 ++++++++++++++ crates/trusted-server-cli/src/commands/mod.rs | 6 +- crates/trusted-server-cli/src/run.rs | 8 + crates/trusted-server-cli/src/url_guard.rs | 26 +++ crates/trusted-server-cli/tests/keys_cli.rs | 181 ++++++++++++++++++ docs/guide/key-rotation.md | 31 ++- 9 files changed, 636 insertions(+), 20 deletions(-) create mode 100644 crates/trusted-server-cli/src/commands/keys/list.rs create mode 100644 crates/trusted-server-cli/src/commands/keys/mod.rs create mode 100644 crates/trusted-server-cli/src/commands/keys/verify.rs create mode 100644 crates/trusted-server-cli/tests/keys_cli.rs 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..c06cd22593 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/list.rs @@ -0,0 +1,127 @@ +//! Listing the signing keys a service publishes at `/.well-known/trusted-server.json`. + +use core::fmt::Write as _; + +use serde::Deserialize; + +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{}", + key.kid.as_deref().unwrap_or(""), + key.kty.as_deref().unwrap_or("-"), + 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() { + let hint = if status.as_u16() == 404 { + " — request signing may be disabled on this service" + } else { + "" + }; + 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 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..987b21b950 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/mod.rs @@ -0,0 +1,119 @@ +//! `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::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)] + pub payload: String, + + /// Base64-encoded signature to check. + #[arg(long)] + 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}"))?; + 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..9d25b26dd7 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/verify.rs @@ -0,0 +1,141 @@ +//! Asking a service whether a signature verifies, through `POST /verify-signature`. + +use serde::Deserialize; + +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 {}", response.kid), + RunOutcome::Success, + )); + } + let detail = response + .error + .map(|error| format!(" ({error})")) + .unwrap_or_default(); + Ok(( + format!( + "not verified: {} for kid {}{detail}", + response.message, 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 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 fa91e0a44d..41cb225489 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -51,6 +51,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), @@ -175,6 +178,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(); diff --git a/crates/trusted-server-cli/src/url_guard.rs b/crates/trusted-server-cli/src/url_guard.rs index 240f10d543..1d07899971 100644 --- a/crates/trusted-server-cli/src/url_guard.rs +++ b/crates/trusted-server-cli/src/url_guard.rs @@ -41,6 +41,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..d95d26359f --- /dev/null +++ b/crates/trusted-server-cli/tests/keys_cli.rs @@ -0,0 +1,181 @@ +//! 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.trim_end_matches('\n'), + body, + "should print the service's document byte for byte" + ); +} + +#[test] +fn list_reports_a_failed_fetch_as_an_error() { + let server = + FixtureServer::start(|_request| FixtureResponse::html("not found").with_status(404)); + + let mut out = Vec::new(); + let error = run(KeysCommand::List(list_args(&server, false)), &mut out) + .expect_err("should fail on a 404"); + + assert!(error.contains("404"), "should name the status: {error}"); + assert!(out.is_empty(), "should print no keys"); +} + +#[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/key-rotation.md b/docs/guide/key-rotation.md index 3f8b6f4d96..b87b09019b 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,33 @@ 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`, which exits +non-zero when the signature does not verify: + +```bash +ts keys verify --service https://edge.example.com \ + --kid ts-2024-02-15 --payload '' --signature '' +``` + +Both commands require HTTPS, with plain HTTP allowed only for loopback development +services. + ### Using the Rust API ```rust From d995804c6000ab3778b9ddadea1bb2dc85c6fb8a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 10 Nov 2026 19:25:27 +0530 Subject: [PATCH 2/4] Accept hyphen-leading signatures and escape service text in ts keys ts keys verify now accepts --signature and --payload values that start with a hyphen, which about one base64url signature in 64 does. Kid, type, curve, message and error text from the service go through escape_terminal_text before they reach the terminal. ts keys list hints that a 404 means --service is not a Trusted Server and that a 500 means unreadable signing keys. ts keys verify names the possible causes behind the service's sanitized internal verification error. The HTTPS refusal now says it guards the request and the answer, not only credentials. Add tests for redirect refusal, --json validation, a non-success verify status and the exact --json output. Signed-off-by: dhruv8sh --- .../src/commands/keys/list.rs | 35 ++++++++-- .../src/commands/keys/mod.rs | 4 +- .../src/commands/keys/verify.rs | 60 ++++++++++++++-- crates/trusted-server-cli/src/run.rs | 27 +++++++ crates/trusted-server-cli/src/url_guard.rs | 7 +- crates/trusted-server-cli/tests/keys_cli.rs | 70 +++++++++++++++++-- 6 files changed, 178 insertions(+), 25 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/keys/list.rs b/crates/trusted-server-cli/src/commands/keys/list.rs index c06cd22593..3b7d240beb 100644 --- a/crates/trusted-server-cli/src/commands/keys/list.rs +++ b/crates/trusted-server-cli/src/commands/keys/list.rs @@ -4,6 +4,7 @@ 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}; @@ -39,9 +40,9 @@ fn render_keys(body: &str) -> CliResult { writeln!( rendered, "{}\t{}\t{}", - key.kid.as_deref().unwrap_or(""), - key.kty.as_deref().unwrap_or("-"), - key.crv.as_deref().unwrap_or("-"), + 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"); } @@ -59,10 +60,12 @@ 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() { - let hint = if status.as_u16() == 404 { - " — request signing may be disabled on this service" - } else { - "" + // 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{}", @@ -116,6 +119,24 @@ mod tests { ); } + #[test] + fn service_controlled_fields_cannot_forge_lines_or_columns() { + let body = json!({ + "version": "1.0", + "jwks": { "keys": [ + { "kty": "OKP", "crv": "Ed25519", "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\tEd25519\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"); diff --git a/crates/trusted-server-cli/src/commands/keys/mod.rs b/crates/trusted-server-cli/src/commands/keys/mod.rs index 987b21b950..ffd9418220 100644 --- a/crates/trusted-server-cli/src/commands/keys/mod.rs +++ b/crates/trusted-server-cli/src/commands/keys/mod.rs @@ -48,11 +48,11 @@ pub struct VerifyArgs { pub kid: String, /// Canonical payload that was signed. - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] pub payload: String, /// Base64-encoded signature to check. - #[arg(long)] + #[arg(long, allow_hyphen_values = true)] pub signature: String, } diff --git a/crates/trusted-server-cli/src/commands/keys/verify.rs b/crates/trusted-server-cli/src/commands/keys/verify.rs index 9d25b26dd7..296fb640c2 100644 --- a/crates/trusted-server-cli/src/commands/keys/verify.rs +++ b/crates/trusted-server-cli/src/commands/keys/verify.rs @@ -2,6 +2,7 @@ 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; @@ -32,18 +33,28 @@ fn interpret(body: &str) -> CliResult<(String, RunOutcome)> { .map_err(|error| format!("invalid verification response: {error}"))?; if response.verified { return Ok(( - format!("verified: signature is valid for kid {}", response.kid), + format!( + "verified: signature is valid for kid {}", + escape_terminal_text(&response.kid) + ), RunOutcome::Success, )); } - let detail = response - .error - .map(|error| format!(" ({error})")) - .unwrap_or_default(); + // 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}", - response.message, response.kid + escape_terminal_text(&response.message), + escape_terminal_text(&response.kid) ), RunOutcome::AssertionFailed, )) @@ -134,6 +145,43 @@ mod tests { 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:?}" + ); + } + #[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/run.rs b/crates/trusted-server-cli/src/run.rs index 41cb225489..f239d4c373 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -233,6 +233,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 1d07899971..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() { diff --git a/crates/trusted-server-cli/tests/keys_cli.rs b/crates/trusted-server-cli/tests/keys_cli.rs index d95d26359f..3da85fd47f 100644 --- a/crates/trusted-server-cli/tests/keys_cli.rs +++ b/crates/trusted-server-cli/tests/keys_cli.rs @@ -78,23 +78,79 @@ fn list_json_prints_the_discovery_document_unchanged() { let rendered = String::from_utf8(out).expect("output should be UTF-8"); assert_eq!( - rendered.trim_end_matches('\n'), - body, - "should print the service's document byte for byte" + 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() { - let server = - FixtureServer::start(|_request| FixtureResponse::html("not found").with_status(404)); + 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 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 fail on a 404"); + .expect_err("should refuse a redirected discovery document"); - assert!(error.contains("404"), "should name the status: {error}"); + 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] From d5aa7abaeec91162bc72bb1d8f6bb30419921118 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 10 Nov 2026 19:25:27 +0530 Subject: [PATCH 3/4] Point the signing guides at ts keys list and ts keys verify Replace curl calls for the discovery document and /verify-signature in the error reference, request-signing guide, API reference and key-rotation troubleshooting with ts keys list and ts keys verify. Document the verify exit codes and that a deactivated key kept in storage still verifies. Add ts cache and ts keys rows to the CLI index and a changelog entry. Signed-off-by: dhruv8sh --- CHANGELOG.md | 1 + docs/guide/api-reference.md | 5 +++++ docs/guide/cli.md | 5 +++++ docs/guide/error-reference.md | 4 ++-- docs/guide/key-rotation.md | 19 +++++++++++++------ docs/guide/request-signing.md | 22 ++++++++++++++-------- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5d0b9ea8..26d2ae6ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,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/docs/guide/api-reference.md b/docs/guide/api-reference.md index 99059adba0..464ca9ef79 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -682,6 +682,8 @@ the store state needed to enumerate active public keys. **Example:** ```bash +ts keys list --service https://edge.example.com [--json] +# or curl https://edge.example.com/.well-known/trusted-server.json ``` @@ -739,6 +741,9 @@ policy. `[request_signing]` storage must be readable. **Example:** ```bash +ts keys verify --service https://edge.example.com --kid ts-2025-01-A \ + --payload 'SGVsbG8gV29ybGQ=' --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"}' diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 70227ca493..59aa9f69a0 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -23,6 +23,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> ` | @@ -43,6 +45,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..d4600549ee 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -471,8 +471,8 @@ echo -n "Hello World" | base64 **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 b87b09019b..e996e2f01a 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -316,14 +316,20 @@ 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`, which exits -non-zero when the signature does not verify: +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. @@ -618,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 @@ -656,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 From bf5c370d3779bc0aa876fba0f2952ad5363688bf Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 12 Nov 2026 16:58:38 +0530 Subject: [PATCH 4/4] Escape error bodies and fix verify payload docs Escape non-success response bodies in ts keys after trimming, pin the remaining escaped fields in tests, and document that /verify-signature verifies the raw payload, not a base64 encoding. Signed-off-by: dhruv8sh --- .../src/commands/keys/list.rs | 9 +++++-- .../src/commands/keys/mod.rs | 8 +++++++ .../src/commands/keys/verify.rs | 14 +++++++++++ crates/trusted-server-cli/tests/keys_cli.rs | 24 +++++++++++++++++++ docs/guide/api-reference.md | 10 ++++---- docs/guide/error-reference.md | 11 ++++++--- 6 files changed, 67 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/keys/list.rs b/crates/trusted-server-cli/src/commands/keys/list.rs index 3b7d240beb..07caa1a4b2 100644 --- a/crates/trusted-server-cli/src/commands/keys/list.rs +++ b/crates/trusted-server-cli/src/commands/keys/list.rs @@ -124,7 +124,11 @@ mod tests { let body = json!({ "version": "1.0", "jwks": { "keys": [ - { "kty": "OKP", "crv": "Ed25519", "kid": "\u{1b}[2Kts-2024-09-09\tforged" }, + { + "kty": "OKP\u{7}", + "crv": "Ed25519\u{1b}[1G", + "kid": "\u{1b}[2Kts-2024-09-09\tforged" + }, ]} }) .to_string(); @@ -132,7 +136,8 @@ mod tests { let rendered = render_keys(&body).expect("should render a valid document"); assert_eq!( - rendered, "\\u{001B}[2Kts-2024-09-09\\u{0009}forged\tOKP\tEd25519\n", + 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" ); } diff --git a/crates/trusted-server-cli/src/commands/keys/mod.rs b/crates/trusted-server-cli/src/commands/keys/mod.rs index ffd9418220..39cfcc1416 100644 --- a/crates/trusted-server-cli/src/commands/keys/mod.rs +++ b/crates/trusted-server-cli/src/commands/keys/mod.rs @@ -10,6 +10,7 @@ use std::time::Duration; use clap::Subcommand; +use crate::ad_templates::output::escape_terminal_text; use crate::error::CliResult; use crate::run::RunOutcome; @@ -114,6 +115,13 @@ fn send(endpoint: &str, request: &KeysRequest<'_>) -> CliResult<(reqwest::Status .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 index 296fb640c2..2785516b62 100644 --- a/crates/trusted-server-cli/src/commands/keys/verify.rs +++ b/crates/trusted-server-cli/src/commands/keys/verify.rs @@ -180,6 +180,20 @@ mod tests { !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] diff --git a/crates/trusted-server-cli/tests/keys_cli.rs b/crates/trusted-server-cli/tests/keys_cli.rs index 3da85fd47f..64af87d21c 100644 --- a/crates/trusted-server-cli/tests/keys_cli.rs +++ b/crates/trusted-server-cli/tests/keys_cli.rs @@ -107,6 +107,30 @@ fn list_reports_a_failed_fetch_as_an_error() { } } +#[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| { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 0bedc7e194..7c867d4696 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -690,7 +690,9 @@ the store state needed to enumerate active public keys. **Example:** ```bash -ts keys list --service https://edge.example.com [--json] +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 ``` @@ -719,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" } @@ -750,11 +752,11 @@ policy. `[request_signing]` storage must be readable. ```bash ts keys verify --service https://edge.example.com --kid ts-2025-01-A \ - --payload 'SGVsbG8gV29ybGQ=' --signature 'abc123...' + --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/error-reference.md b/docs/guide/error-reference.md index d4600549ee..bcf5ea7950 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -461,11 +461,16 @@ 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:**