Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 1 addition & 16 deletions crates/trusted-server-cli/src/commands/cache/purge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,7 @@ fn request_body(args: &PurgeArgs) -> CliResult<String> {

/// Join the service base URL and the purge path without doubling or dropping a slash.
fn purge_endpoint(service: &str) -> CliResult<String> {
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.
Expand Down
153 changes: 153 additions & 0 deletions crates/trusted-server-cli/src/commands/keys/list.rs
Original file line number Diff line number Diff line change
@@ -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<Jwk>,
}

#[derive(Deserialize)]
struct Jwk {
kid: Option<String>,
kty: Option<String>,
crv: Option<String>,
}

/// Render one line per published key: kid, key type, and curve.
fn render_keys(body: &str) -> CliResult<String> {
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("<no kid>")),
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("<html></html>").is_err(), "should reject HTML");
assert!(
render_keys(&json!({ "keys": [] }).to_string()).is_err(),
"should reject a bare JWKS without the discovery wrapper"
);
}
}
127 changes: 127 additions & 0 deletions crates/trusted-server-cli/src/commands/keys/mod.rs
Original file line number Diff line number Diff line change
@@ -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<RunOutcome> {
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))
Comment thread
dhruv8sh marked this conversation as resolved.
Resolved
})
}
Loading
Loading