From 12301a68a8d28a72f48232e1f833c97b09344d19 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:08:36 +0100 Subject: [PATCH 01/25] A build reports when and from what it was made Core gains `build_info`, which holds the crate version, the time the build was made, the commit and the build run, each fixed when the build was compiled. A WebAssembly guest is given no custom environment variable when it runs on some hosts, so a fact read then would read as absent there. `SOURCE_DATE_EPOCH` gives the build time where it is set, so a reproducible build stays reproducible, and the time of the build stands in otherwise. `TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN` give the other two, and each reads `unknown` without its input. The date arithmetic is written out in a file the build script and a test both compile, so the build script takes on no date dependency. Each is the builder's own report and nothing measures the binary. The attestation evidence in the next commit is what reads them. --- crates/trusted-server-core/build.rs | 43 +++++++++++ crates/trusted-server-core/build_time.rs | 32 ++++++++ crates/trusted-server-core/src/build_info.rs | 81 ++++++++++++++++++++ crates/trusted-server-core/src/lib.rs | 1 + 4 files changed, 157 insertions(+) create mode 100644 crates/trusted-server-core/build_time.rs create mode 100644 crates/trusted-server-core/src/build_info.rs diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 98d32159be..04dc14065f 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use edgezero_core::manifest::ManifestLoader; +mod build_time; + /// Source file that must stay out of the generated list. /// /// `migration_guards.rs` holds the banned pattern itself, as the regex literal @@ -15,6 +17,47 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); write_default_config_store_id(); write_migration_guard_sources(); + write_build_time(); + write_build_identity(); +} + +/// Bakes the build time into the binary as `TRUSTED_SERVER_BUILT_AT`. +/// +/// Some hosts give a WebAssembly guest no custom environment variable when it +/// runs, so the time a build was made has to be in the binary or it cannot be +/// reported at all. +/// +/// `SOURCE_DATE_EPOCH` is honored where it is set, so a reproducible build +/// stays reproducible rather than differing only by this string. +fn write_build_time() { + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + let seconds = env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or_default()) + .unwrap_or_default() + }); + println!( + "cargo:rustc-env=TRUSTED_SERVER_BUILT_AT={}", + build_time::rfc3339_utc(seconds) + ); +} + +/// Bakes in the commit and the build run the builder names, as +/// `TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN`, or `unknown`. +fn write_build_identity() { + for name in ["TRUSTED_SERVER_COMMIT", "TRUSTED_SERVER_BUILD_RUN"] { + println!("cargo:rerun-if-env-changed={name}"); + let value = env::var(name) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) + .unwrap_or_else(|| "unknown".to_owned()); + println!("cargo:rustc-env={name}={value}"); + } } /// Keeps every adapter's compiled default synchronized with the repository diff --git a/crates/trusted-server-core/build_time.rs b/crates/trusted-server-core/build_time.rs new file mode 100644 index 0000000000..3e392ea802 --- /dev/null +++ b/crates/trusted-server-core/build_time.rs @@ -0,0 +1,32 @@ +//! The build time written as RFC 3339 in UTC. +//! +//! A module of the build script, which bakes the time into the binary, and of +//! the tests in `src/build_info.rs`, which check the arithmetic against known +//! dates. + +/// Formats seconds since the Unix epoch as RFC 3339 in UTC. +/// +/// Written out rather than taken from a date crate, because a build script +/// dependency is a dependency of every build of this crate and this is a dozen +/// lines of arithmetic. Days are civil days from the epoch, using the standard +/// days-to-civil algorithm, which is correct for any date this will see. +pub(crate) fn rfc3339_utc(seconds: i64) -> String { + let days = seconds.div_euclid(86_400); + let rem = seconds.rem_euclid(86_400); + let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60); + + // Days-to-civil: shift the era so March is month 0 and leap days land at + // the end of a 400 year era. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!("{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z") +} diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs new file mode 100644 index 0000000000..e72a5b3707 --- /dev/null +++ b/crates/trusted-server-core/src/build_info.rs @@ -0,0 +1,81 @@ +//! What this build reports about itself, fixed when it was compiled. +//! +//! `env!` is resolved by the compiler. Some hosts give a WebAssembly guest no +//! custom environment variable when it runs, so a fact read then would read +//! as absent there. +//! +//! | Constant | Build input | Without it | +//! | --- | --- | --- | +//! | [`BUILT_AT`] | `SOURCE_DATE_EPOCH`, in seconds | The time of the build | +//! | [`COMMIT`] | `TRUSTED_SERVER_COMMIT` | `unknown` | +//! | [`BUILD_RUN`] | `TRUSTED_SERVER_BUILD_RUN` | `unknown` | +//! +//! Each is the builder's own report. Nothing here measures the binary. + +/// The crate version. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// When this build was made, RFC 3339 in UTC to the second. +pub const BUILT_AT: &str = env!("TRUSTED_SERVER_BUILT_AT"); + +/// The commit this build was made from, or `unknown`. +pub const COMMIT: &str = env!("TRUSTED_SERVER_COMMIT"); + +/// The build run that made this binary, or `unknown`. +pub const BUILD_RUN: &str = env!("TRUSTED_SERVER_BUILD_RUN"); + +/// The build script's date arithmetic, compiled here so a test can call it. +#[cfg(test)] +#[path = "../build_time.rs"] +mod build_time; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_build_time_is_rfc_3339_in_utc() { + let shape = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + .expect("should compile the pattern"); + + assert!( + shape.is_match(BUILT_AT), + "should be RFC 3339 in UTC: {BUILT_AT}" + ); + } + + #[test] + fn the_commit_and_the_run_are_never_empty() { + for (name, value) in [("commit", COMMIT), ("build run", BUILD_RUN)] { + assert!( + !value.is_empty() && !value.chars().any(char::is_control), + "the {name} should be the builder's value or `unknown`, got {value:?}" + ); + } + } + + #[test] + fn the_build_script_writes_known_instants_as_rfc_3339() { + for (seconds, expected, instant) in [ + (0, "1970-01-01T00:00:00Z", "the epoch"), + ( + 951_782_400, + "2000-02-29T00:00:00Z", + "the leap day of a 400th year", + ), + (1_000_000_000, "2001-09-09T01:46:40Z", "a time of day"), + (4_102_444_800, "2100-01-01T00:00:00Z", "the start of 2100"), + ( + 4_107_542_400, + "2100-03-01T00:00:00Z", + "the day after 28 February 2100", + ), + ] { + assert_eq!( + build_time::rfc3339_utc(seconds), + expected, + "should write {instant}, {seconds} seconds after the epoch" + ); + } + } +} diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 21b3961ae5..be72b25620 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -33,6 +33,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod build_info; pub mod cache_policy; pub mod cache_purge; pub mod config; From 4edaec846fc6a55b646a22ae44c2e8934fbd67f7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:10:02 +0100 Subject: [PATCH 02/25] Signed evidence of who operates a deployment and which build it runs With `[attestation]` in the settings, core answers with evidence in the sense of RFC 9334. It names the host the request arrived for, the publisher whose settings answered, the platform service, the build, the time and the caller's nonce, and it is signed with ECDSA P-256 over a configured context, one line feed and the evidence bytes. The context keeps a signature made here from passing as one the same key made for any other purpose. A module on a CDN cannot measure its own bytes, so the evidence shows who operates the deployment and not what its code is. A verifier holding the operator's public keys decides whether to trust it, and the page sends the reader there and claims nothing itself. - `[attestation]` holds `endpoint`, `operator`, `context` and `verify_url`, and refuses any other field. The endpoint defaults to `/_ts/attestation`, answers as JSON with `.json` added, and may not be an address the deployment already answers. - The host signed is the request's `Host` header. A forwarded host header is never read. - The signing keys are a schedule the build script compiles in from the file `TRUSTED_SERVER_ATTESTATION_KEYS` names, each in force from its start until the next one starts. No store the settings can reach holds one. A build given no schedule signs nothing and answers 503. - An endpoint under a prefix of its own owns that prefix. The prefix lists its pages and any other address beneath it is not found. Under the shared `/_ts` prefix only reads of the two addresses are taken. A settings validation error now carries the validator's message where it gives one, which is how a refused endpoint names the address it clashed with. --- Cargo.lock | 29 + Cargo.toml | 1 + crates/trusted-server-core/Cargo.toml | 1 + crates/trusted-server-core/build.rs | 47 + crates/trusted-server-core/src/attestation.rs | 1811 +++++++++++++++++ crates/trusted-server-core/src/inspect.rs | 2 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/settings.rs | 17 +- 8 files changed, 1907 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-core/src/attestation.rs diff --git a/Cargo.lock b/Cargo.lock index a85ac5ba01..a3d0c64152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1470,6 +1470,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1705,6 +1719,7 @@ dependencies = [ "ff", "generic-array", "group", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -4290,8 +4305,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2 0.10.9", ] [[package]] @@ -5156,6 +5173,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5456,6 +5483,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -6881,6 +6909,7 @@ dependencies = [ "lol_html", "matchit 0.9.2", "mime", + "p256", "rand 0.8.6", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3407f373ef..2b82dbc37a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ log-fastly = "0.12" lol_html = "2.7.2" matchit = "0.9" mime = "0.3" +p256 = { version = "0.13", default-features = false, features = ["alloc", "ecdsa", "pkcs8"] } predicates = "3" prost-build = "0.13" rand = "0.8" diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 439c9317b6..8faba589e9 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -37,6 +37,7 @@ log = { workspace = true } lol_html = { workspace = true } matchit = { workspace = true } mime = { workspace = true } +p256 = { workspace = true } rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 04dc14065f..6c87253a6d 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -19,6 +19,53 @@ fn main() { write_migration_guard_sources(); write_build_time(); write_build_identity(); + write_attestation_keys(); +} + +/// Embeds the attestation signing key schedule in the file +/// `TRUSTED_SERVER_ATTESTATION_KEYS` names, or an empty schedule. +/// +/// One key per line, being the key id, the Unix second it comes into force +/// and the private key as base64 DER, separated by tabs. The schedule is +/// compiled into the binary, so no signing key sits in a store the service +/// reads when it runs. +fn write_attestation_keys() { + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER_ATTESTATION_KEYS"); + let mut generated = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); + if let Some(path) = + env::var_os("TRUSTED_SERVER_ATTESTATION_KEYS").filter(|path| !path.is_empty()) + { + let path = PathBuf::from(path); + println!("cargo:rerun-if-changed={}", path.display()); + let text = fs::read_to_string(&path).expect("should read the attestation key schedule"); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + let mut fields = line.split('\t'); + let key_id = fields.next().unwrap_or_default(); + let starts_at: i64 = fields + .next() + .unwrap_or_default() + .parse() + .expect("should read each attestation key's start as Unix seconds"); + let private_key = fields.next().unwrap_or_default(); + assert!( + fields.next().is_none() && is_plain(key_id) && is_plain(private_key), + "each attestation key line should hold a key id, a start and a key", + ); + generated.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); + } + } + generated.push_str("];"); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("should read OUT_DIR")); + fs::write(out_dir.join("attestation_keys.rs"), generated) + .expect("should write the attestation key schedule"); +} + +/// Whether a schedule field holds only characters a key id or base64 uses. +fn is_plain(field: &str) -> bool { + !field.is_empty() + && field + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:+/=".contains(&b)) } /// Bakes the build time into the binary as `TRUSTED_SERVER_BUILT_AT`. diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs new file mode 100644 index 0000000000..3ee917817f --- /dev/null +++ b/crates/trusted-server-core/src/attestation.rs @@ -0,0 +1,1811 @@ +//! Signed evidence of who operates this deployment and which build it runs. +//! +//! The endpoint answers with evidence in the sense of RFC 9334, a description +//! of the build and the platform signed with the operator's key. A module on +//! a CDN cannot measure its own bytes, so the evidence proves who operates the +//! deployment, not what its code is. A verifier holding the operator's public +//! keys decides whether to trust it, and the page sends the reader there +//! rather than claiming validity itself. +//! +//! The signing keys are constants in the binary, compiled in from a schedule +//! the build supplies, each in force from its start until the next one starts. +//! No store or secret the settings can reach holds them, but whoever holds the +//! package holds every key its schedule carries, so a published build carries +//! only the key in force when it was built and the ones after it. A build +//! given no schedule signs nothing. The signature is ECDSA P-256 with SHA-256, +//! deterministic per RFC 6979, over the configured context, one line feed, +//! then the evidence bytes. The context stops a signature made here from +//! passing as one the same key made for any other purpose. + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use p256::ecdsa::signature::Signer as _; +use p256::ecdsa::{Signature, SigningKey}; +use p256::pkcs8::DecodePrivateKey as _; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use validator::{Validate, ValidationError}; + +use crate::inspect::config::{CONFIG_JSON_PATH, CONFIG_PAGE_PATH}; +use crate::inspect::data::DATA_PAGE_PATH; +use crate::inspect::html_escape; +use crate::inspect::permissions::{PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH}; +use crate::publisher::{PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH}; +use crate::settings::Settings; + +// `ATTESTATION_KEYS`, written by build.rs from the schedule the build names. +include!(concat!(env!("OUT_DIR"), "/attestation_keys.rs")); + +/// The address of the attestation page when the settings name none. The JSON +/// form is at the same address plus `.json`. +pub const DEFAULT_ENDPOINT: &str = "/_ts/attestation"; + +/// The addresses beneath an underscore prefix that a deployment answers +/// itself whatever the settings say, so an endpoint may take none of them. A +/// segment in braces stands for any one segment, as it does in the router. +/// +/// An endpoint at one of these would register a second route at the same +/// address, which stops the router being built. Each adapter's tests check +/// that its router registers nothing beneath an underscore prefix that is +/// missing here. +pub const RESERVED_PATHS: &[&str] = &[ + CONFIG_PAGE_PATH, + CONFIG_JSON_PATH, + PERMISSIONS_PAGE_PATH, + PERMISSIONS_JSON_PATH, + DATA_PAGE_PATH, + PAGE_BIDS_PATH, + PAGE_BIDS_LEGACY_PATH, + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", + "/_ts/admin/ec", + "/_ts/admin/ec/{id}", + "/_ts/admin/eids", + "/_ts/api/v1/batch-sync", + "/_ts/api/v1/identify", + "/_ts/api/v1/ec/resolve", + "/_ts/set-tester", + "/_ts/clear-tester", + // Answered ahead of the router by an adapter whose host exposes the TLS + // fingerprint or serves several requests from one sandbox. + "/_ts/debug/ja4", + "/_ts/debug/sandbox", +]; + +/// The signature algorithm, as JOSE names it. +const ALGORITHM: &str = "ES256"; + +/// The version of the evidence format. +const EVIDENCE_VERSION: u8 = 1; + +/// The longest nonce a caller may have signed. +const MAX_NONCE_LEN: usize = 64; + +/// The architecture whose roles and terms the endpoint follows. +const STANDARD_URL: &str = "https://www.rfc-editor.org/rfc/rfc9334"; + +/// A key id, the Unix second the key comes into force, and the private key +/// as base64 of its SEC1 or PKCS #8 DER encoding. +type ScheduledKey<'a> = (&'a str, i64, &'a str); + +/// The `[attestation]` settings section. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct AttestationConfig { + /// The address of the attestation page, with the JSON form at the same + /// address plus `.json`. When it is not the default, reads of the default + /// address redirect here. Neither form may be one of the + /// [`RESERVED_PATHS`]. + #[serde(default = "default_endpoint")] + #[validate(custom(function = validate_endpoint))] + pub endpoint: String, + /// Who the evidence says operates this deployment. + #[validate(length(min = 1, max = 64))] + pub operator: String, + /// The text signed ahead of the evidence, naming what the signature is + /// for. + #[validate(length(min = 1, max = 64), custom(function = validate_single_line))] + pub context: String, + /// Where a relying party checks the evidence. + #[validate(custom(function = validate_https_url))] + pub verify_url: String, +} + +fn default_endpoint() -> String { + DEFAULT_ENDPOINT.to_owned() +} + +/// What the evidence says about the platform, supplied by the adapter. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformIdentity { + /// The platform's name. + pub name: String, + /// The platform's identifier for the service running this build. + pub service_id: String, + /// The platform's version number of that service. + pub service_version: String, + /// Whether the platform's staging environment answered. + pub staging: bool, + /// The location that answered. + pub pop: String, +} + +impl PlatformIdentity { + /// A platform that reports its name and nothing else. + #[must_use] + pub fn named(name: &str) -> Self { + Self { + name: name.to_owned(), + service_id: "unknown".to_owned(), + service_version: "unknown".to_owned(), + staging: false, + pop: "unknown".to_owned(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Build { + version: &'static str, + built_at: &'static str, + commit: &'static str, + run: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AttestationEvidence<'a> { + v: u8, + operator: &'a str, + publisher: &'a str, + host: &'a str, + platform: &'a PlatformIdentity, + build: Build, + #[serde(skip_serializing_if = "Option::is_none")] + nonce: Option<&'a str>, + issued_at: String, +} + +/// The addresses the endpoint answers at, the page first, or `None` when the +/// settings carry no `[attestation]` section. +#[must_use] +pub fn attestation_paths(settings: &Settings) -> Option<[String; 2]> { + Some(paths_of(&settings.attestation.as_ref()?.endpoint)) +} + +/// The page and JSON addresses of an endpoint. +fn paths_of(endpoint: &str) -> [String; 2] { + [endpoint.to_owned(), format!("{endpoint}.json")] +} + +/// The first segment of an endpoint, being the prefix its pages sit under. +fn prefix_of(endpoint: &str) -> &str { + endpoint + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or_default() +} + +/// A route a deployment registers for its attestation pages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrefixRoute { + /// The path, in the router's own syntax. + pub path: String, + /// Whether every method is answered here rather than by the publisher's + /// origin. True for the routes beneath a prefix the deployment owns. + pub every_method: bool, +} + +/// The routes to register for the attestation pages, empty when the settings +/// carry no `[attestation]` section. +/// +/// A deployment whose endpoint sits under a prefix of its own owns that +/// prefix, so the prefix itself lists the pages beneath it and any other path +/// there is not found. The default prefix is shared with other routes, so +/// only the pages themselves are taken from it. When the endpoint is not the +/// default, the default address is registered too, to redirect. +#[must_use] +pub fn prefix_routes(settings: &Settings) -> Vec { + let Some(config) = settings.attestation.as_ref() else { + return Vec::new(); + }; + let prefix = prefix_of(&config.endpoint); + let owns_prefix = prefix != prefix_of(DEFAULT_ENDPOINT); + let route = |path: String, every_method: bool| PrefixRoute { path, every_method }; + let mut routes: Vec = paths_of(&config.endpoint) + .into_iter() + .map(|path| route(path, owns_prefix)) + .collect(); + if config.endpoint != DEFAULT_ENDPOINT { + routes.extend( + paths_of(DEFAULT_ENDPOINT) + .into_iter() + .map(|path| route(path, false)), + ); + } + if owns_prefix { + routes.extend( + [ + format!("/{prefix}"), + format!("/{prefix}/"), + format!("/{prefix}/{{*rest}}"), + ] + .into_iter() + .map(|path| route(path, true)), + ); + } + routes +} + +/// Answers every request routed for the attestation pages, being the pages +/// themselves, a redirect from the default address when the endpoint has +/// moved, the list of pages at an owned prefix, and not found for anything +/// else. +#[must_use] +pub fn handle_prefix( + settings: &Settings, + platform: &PlatformIdentity, + req: &Request, +) -> Response { + let Some(config) = settings.attestation.as_ref() else { + return error_response( + StatusCode::NOT_FOUND, + "This deployment serves no attestation.", + ); + }; + let read = matches!(*req.method(), Method::GET | Method::HEAD); + let path = req.uri().path(); + let [page, json] = paths_of(&config.endpoint); + if read && (path == page || path == json) { + return handle_attestation(settings, platform, req); + } + if read && config.endpoint != DEFAULT_ENDPOINT { + let [default_page, default_json] = paths_of(DEFAULT_ENDPOINT); + if path == default_page { + return redirect(&page, req.uri().query()); + } + if path == default_json { + return redirect(&json, req.uri().query()); + } + } + let prefix = prefix_of(&config.endpoint); + if read && (path == format!("/{prefix}") || path == format!("/{prefix}/")) { + return response( + StatusCode::OK, + "text/html; charset=utf-8", + render_index(&page, &json), + ); + } + response( + StatusCode::NOT_FOUND, + "text/html; charset=utf-8", + render_not_found(prefix), + ) +} + +/// Sends the caller to `location`, keeping the query it asked with so a +/// nonce survives the move. +fn redirect(location: &str, query: Option<&str>) -> Response { + let target = match query { + Some(query) if !query.is_empty() => format!("{location}?{query}"), + _ => location.to_owned(), + }; + let mut redirect = response( + StatusCode::MOVED_PERMANENTLY, + "application/json", + json!({ "location": target }).to_string(), + ); + if let Ok(value) = HeaderValue::from_str(&target) { + redirect.headers_mut().insert(header::LOCATION, value); + } + redirect +} + +/// Answers with evidence signed by the compiled-in key in force now, as a +/// page or, at the `.json` address, as data. +/// +/// Every failure becomes a JSON body with a status, so an adapter only routes +/// here. The handler makes no outbound request. +#[must_use] +pub fn handle_attestation( + settings: &Settings, + platform: &PlatformIdentity, + req: &Request, +) -> Response { + answer( + settings, + platform, + req, + ATTESTATION_KEYS, + crate::ec::current_timestamp(), + ) +} + +fn answer( + settings: &Settings, + platform: &PlatformIdentity, + req: &Request, + schedule: &[ScheduledKey<'_>], + now: u64, +) -> Response { + let Some(config) = settings.attestation.as_ref() else { + return error_response( + StatusCode::NOT_FOUND, + "This deployment serves no attestation.", + ); + }; + let nonce = match nonce_from_query(req.uri().query()) { + Ok(nonce) => nonce, + Err(message) => return error_response(StatusCode::BAD_REQUEST, message), + }; + let Some(&(key_id, _, private_key)) = key_in_force(schedule, now) else { + log::error!("attestation has no signing key in force at {now}"); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "No signing key is built in for this time, so no evidence can be signed.", + ); + }; + let host = request_host(req); + let evidence = AttestationEvidence { + v: EVIDENCE_VERSION, + operator: &config.operator, + publisher: &settings.publisher.domain, + host: &host, + platform, + build: Build { + version: crate::build_info::VERSION, + built_at: crate::build_info::BUILT_AT, + commit: crate::build_info::COMMIT, + run: crate::build_info::BUILD_RUN, + }, + nonce: nonce.as_deref(), + issued_at: rfc3339(now), + }; + let Ok(payload) = serde_json::to_vec(&evidence) else { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "The evidence could not be written.", + ); + }; + let signature = match sign(&config.context, &payload, private_key) { + Ok(signature) => signature, + Err(message) => { + log::error!("attestation key `{key_id}` is unusable: {message}"); + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "The signing key could not be read, so no evidence can be signed.", + ); + } + }; + let envelope = json!({ + "evidence": serde_json::from_slice::(&payload).unwrap_or(Value::Null), + "payload": URL_SAFE_NO_PAD.encode(&payload), + "signature": { + "kid": key_id, + "alg": ALGORITHM, + "value": URL_SAFE_NO_PAD.encode(signature), + }, + "verify": config.verify_url, + }); + if req.uri().path().ends_with(".json") { + let body = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_owned()); + response(StatusCode::OK, "application/json", body) + } else { + response( + StatusCode::OK, + "text/html; charset=utf-8", + render_page( + &config.operator, + &config.verify_url, + &json_address(req), + &envelope, + ), + ) + } +} + +/// The absolute address of the JSON form of the page being served, from the +/// scheme the request arrived over and the host it was addressed to. +/// +/// The host is read the way [`request_host`] reads the one the evidence signs, +/// so the link and the signature name the same host. Forwarded host headers +/// are never read, because a caller can set them. +fn json_address(req: &Request) -> String { + let info = + crate::http_util::RequestInfo::from_request(req, &crate::platform::ClientInfo::default()); + let authority = req + .headers() + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .or_else(|| req.uri().authority().map(http::uri::Authority::as_str)) + .unwrap_or_default(); + format!("{}://{authority}{}.json", info.scheme, req.uri().path()) +} + +/// The key in force at `now`, being the latest to have started. Warns when +/// no later key follows it, because the build must then be refreshed. +fn key_in_force<'a>(schedule: &'a [ScheduledKey<'a>], now: u64) -> Option<&'a ScheduledKey<'a>> { + let now = i64::try_from(now).ok()?; + let current = schedule + .iter() + .filter(|(_, starts_at, _)| *starts_at <= now) + .max_by_key(|(_, starts_at, _)| *starts_at)?; + if !schedule + .iter() + .any(|(_, starts_at, _)| *starts_at > current.1) + { + log::warn!( + "attestation key `{}` is the last one built in, so the build must be refreshed", + current.0 + ); + } + Some(current) +} + +/// The bytes a signature covers, being the context, one line feed, then the +/// evidence. +#[must_use] +pub fn signed_bytes(context: &str, payload: &[u8]) -> Vec { + let mut message = Vec::with_capacity(context.len() + 1 + payload.len()); + message.extend_from_slice(context.as_bytes()); + message.push(b'\n'); + message.extend_from_slice(payload); + message +} + +/// Signs `payload` under `context` with a key held as base64 DER text, +/// returning the raw 64 byte `r` and `s` pair. +fn sign(context: &str, payload: &[u8], key_text: &str) -> Result, &'static str> { + let der = STANDARD + .decode(key_text.trim()) + .map_err(|_| "the key is not base64")?; + let secret = p256::SecretKey::from_sec1_der(&der) + .or_else(|_| p256::SecretKey::from_pkcs8_der(&der)) + .map_err(|_| "the key is not a P-256 private key in SEC1 or PKCS #8 DER")?; + let signature: Signature = SigningKey::from(secret).sign(&signed_bytes(context, payload)); + Ok(signature.to_bytes().to_vec()) +} + +/// The caller's nonce, when it asked for one and it is well formed. +fn nonce_from_query(query: Option<&str>) -> Result, &'static str> { + let Some(query) = query else { + return Ok(None); + }; + let Some((_, nonce)) = + url::form_urlencoded::parse(query.as_bytes()).find(|(name, _)| name == "nonce") + else { + return Ok(None); + }; + let well_formed = !nonce.is_empty() + && nonce.len() <= MAX_NONCE_LEN + && nonce + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); + if well_formed { + Ok(Some(nonce.into_owned())) + } else { + Err("A nonce is 1 to 64 characters from A to Z, a to z, 0 to 9, _ and -.") + } +} + +/// The host the request was addressed to, lowercased with any port removed. +fn request_host(req: &Request) -> String { + let raw = req + .headers() + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .or_else(|| req.uri().host()) + .unwrap_or_default(); + let without_port = match raw.rsplit_once(':') { + Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => raw, + }; + without_port.to_ascii_lowercase() +} + +/// Seconds since the epoch as RFC 3339 in UTC, to the second. +fn rfc3339(seconds: u64) -> String { + i64::try_from(seconds) + .ok() + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) + .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .unwrap_or_default() +} + +fn response(status: StatusCode, content_type: &'static str, body: String) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .header(header::CACHE_CONTROL, HeaderValue::from_static("no-store")) + .header( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build attestation response") +} + +fn error_response(status: StatusCode, message: &str) -> Response { + response( + status, + "application/json", + json!({ "error": message }).to_string(), + ) +} + +/// The page form: the claim in words, the link to the verifier, the address +/// of the JSON form to copy, the envelope itself, then the standard followed. +fn render_page(operator: &str, verify_url: &str, json_url: &str, envelope: &Value) -> String { + let verifier = url::Url::parse(verify_url) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .unwrap_or_else(|| verify_url.to_owned()); + let pretty = serde_json::to_string_pretty(envelope).unwrap_or_else(|_| "{}".to_owned()); + let body = format!( + "

Attestation

\n\ +

This page is served by a deployment that says it is operated by {operator}. \ +Do not take this page's word for it.

\n\ +

Check this claim at {verifier}

\n\ +

To retrieve this data as JSON for use in your own verification checks use the URL

\n\ +
{json_url}\ +
\n\ +

The JSON response is

\n\ +
{pretty}
\n\ +

The attester, evidence and verifier on this page follow the \ +IETF Remote Attestation Procedures (RATS) Architecture, \ +RFC 9334.

\n", + operator = html_escape(operator), + href = escape_attribute(verify_url), + verifier = html_escape(&verifier), + json_url = html_escape(json_url), + pretty = html_escape(&pretty), + ); + let script = "document.getElementById(\"copy\").addEventListener(\"click\",function(){\ +var button=this;\ +navigator.clipboard.writeText(document.getElementById(\"json-url\").textContent)\ +.then(function(){button.textContent=\"Copied\";});});"; + html_document("Attestation", &body, script) +} + +/// The list of pages served beneath the prefix, at the prefix itself. +fn render_index(page: &str, json: &str) -> String { + let body = format!( + "

Information pages

\n\ +

This deployment serves these pages about itself.

\n\ +
    \n\ +
  • Attestation, who operates this deployment \ +and which build it runs, with a link to check the claim.
  • \n\ +
  • Attestation as JSON, the same signed \ +evidence as data, for your own verification checks.
  • \n\ +
\n", + page = escape_attribute(page), + json = escape_attribute(json), + ); + html_document("Information pages", &body, "") +} + +/// The answer for any other address beneath a prefix the deployment owns. +fn render_not_found(prefix: &str) -> String { + let body = format!( + "

Not found

\n\ +

Nothing is served at this address. The pages this deployment serves are listed at \ +/{prefix}.

\n", + prefix = escape_attribute(prefix), + ); + html_document("Not found", &body, "") +} + +/// The shell every page here shares. It loads nothing from anywhere else. +fn html_document(title: &str, body: &str, script: &str) -> String { + let script = if script.is_empty() { + String::new() + } else { + format!("\n") + }; + format!( + "\n\ +\n\ +\n\ +\n\ +\n\ +\n\ +{title}\n\ +\n\ +\n\ +\n\ +
\n\ +{body}\ +
\n\ +{script}\ +\n\ +\n", + title = html_escape(title), + ) +} + +fn escape_attribute(value: &str) -> String { + html_escape(value).replace('"', """) +} + +/// Validates the endpoint, an absolute path of two or more segments of lower +/// case letters, digits, `_` and `-`, the first beginning with an underscore +/// so the pages sit apart from the publisher's own, and without `.json`, +/// which names the JSON form. Neither the page nor the JSON form may be one +/// of the [`RESERVED_PATHS`]. +/// +/// # Errors +/// +/// Returns `invalid_endpoint` for any other shape, and `reserved_endpoint`, +/// naming the address, when the page or the JSON form is reserved. +pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { + let segment_ok = |segment: &str| { + !segment.is_empty() + && segment + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + }; + let segments: Vec<&str> = value + .strip_prefix('/') + .map(|rest| rest.split('/').collect()) + .unwrap_or_default(); + let well_formed = value.len() <= 64 + && segments.len() >= 2 + && segments.iter().all(|segment| segment_ok(segment)) + && segments + .first() + .is_some_and(|first| first.len() > 1 && first.starts_with('_')); + if !well_formed { + return Err(ValidationError::new("invalid_endpoint")); + } + if let Some(reserved) = reserved_path_taken(value, RESERVED_PATHS) { + let mut error = ValidationError::new("reserved_endpoint"); + error.message = Some(format!("{reserved} is answered by the deployment itself").into()); + return Err(error); + } + Ok(()) +} + +/// The first of `reserved` that the endpoint's page or JSON form would take. +fn reserved_path_taken(endpoint: &str, reserved: &[&'static str]) -> Option<&'static str> { + let addresses = paths_of(endpoint); + reserved.iter().copied().find(|route| { + addresses + .iter() + .any(|address| route_answers(route, address)) + }) +} + +/// Whether a route answers an address, a segment in braces answering any one +/// segment. +fn route_answers(route: &str, address: &str) -> bool { + let mut route_segments = route.split('/'); + let mut address_segments = address.split('/'); + loop { + match (route_segments.next(), address_segments.next()) { + (None, None) => return true, + (Some(expected), Some(given)) if expected == given || expected.starts_with('{') => {} + _ => return false, + } + } +} + +fn validate_single_line(value: &str) -> Result<(), ValidationError> { + if value.chars().any(char::is_control) { + Err(ValidationError::new("control_character")) + } else { + Ok(()) + } +} + +fn validate_https_url(value: &str) -> Result<(), ValidationError> { + match url::Url::parse(value) { + Ok(url) if url.scheme() == "https" && url.host_str().is_some() => Ok(()), + _ => Err(ValidationError::new("invalid_https_url")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier as _; + use p256::pkcs8::{DecodePublicKey as _, EncodePublicKey as _}; + + use crate::test_support::tests::crate_test_settings_str; + + const CONTEXT: &str = "example-attestation:v1"; + const FIRST: [u8; 32] = [0x11; 32]; + const SECOND: [u8; 32] = [0x22; 32]; + const FIRST_STARTS: i64 = 1_000; + const SECOND_STARTS: i64 = 2_000; + + fn secret_key(scalar: &[u8; 32]) -> p256::SecretKey { + p256::SecretKey::from_slice(scalar).expect("a valid scalar") + } + + /// A key as a schedule holds it, base64 of its SEC1 DER form. + fn key_text(scalar: &[u8; 32]) -> String { + STANDARD.encode(secret_key(scalar).to_sec1_der().expect("encodes")) + } + + fn settings_with_attestation() -> Settings { + let mut settings = + Settings::from_toml(&crate_test_settings_str()).expect("test settings should load"); + settings.attestation = Some(AttestationConfig { + endpoint: "/_ex/attestation".to_owned(), + operator: "Example Operator".to_owned(), + context: CONTEXT.to_owned(), + verify_url: "https://verifier.example/verify?host=test-publisher.com".to_owned(), + }); + settings + } + + fn request(uri: &str) -> Request { + Request::builder() + .uri(uri) + .header(header::HOST, "Test-Publisher.com:443") + .body(EdgeBody::empty()) + .expect("request") + } + + fn platform() -> PlatformIdentity { + PlatformIdentity { + name: "example-platform".to_owned(), + service_id: "svc-1".to_owned(), + service_version: "39".to_owned(), + staging: false, + pop: "LHR".to_owned(), + } + } + + /// Answers from a two key schedule at `now`. + fn answer_at(settings: &Settings, uri: &str, now: u64) -> Response { + let first = key_text(&FIRST); + let second = key_text(&SECOND); + let schedule = [ + ("example-first", FIRST_STARTS, first.as_str()), + ("example-second", SECOND_STARTS, second.as_str()), + ]; + answer(settings, &platform(), &request(uri), &schedule, now) + } + + fn body_bytes(response: Response) -> Vec { + response + .into_body() + .into_bytes() + .expect("a buffered body") + .to_vec() + } + + fn json_body(response: Response) -> Value { + serde_json::from_slice(&body_bytes(response)).expect("a JSON body") + } + + fn verifying_key(scalar: &[u8; 32]) -> VerifyingKey { + let spki = secret_key(scalar) + .public_key() + .to_public_key_der() + .expect("encodes"); + VerifyingKey::from_public_key_der(spki.as_bytes()).expect("decodes") + } + + fn payload_and_signature(envelope: &Value) -> (Vec, Signature) { + let payload = URL_SAFE_NO_PAD + .decode(envelope["payload"].as_str().expect("payload")) + .expect("base64url payload"); + let raw = URL_SAFE_NO_PAD + .decode(envelope["signature"]["value"].as_str().expect("value")) + .expect("base64url signature"); + assert_eq!(raw.len(), 64, "the signature is the raw r and s pair"); + (payload, Signature::from_slice(&raw).expect("64 bytes")) + } + + /// A verifier with only the public key, in the `SubjectPublicKeyInfo` DER + /// form a key schedule publishes, accepts the signature over exactly the + /// context, one line feed, then the payload. Without the context, without + /// its line feed, or under another context it never verifies, which is + /// what binds a signature to its purpose. + #[test] + fn the_signature_covers_the_context_a_line_feed_and_the_payload() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json?nonce=abc123", + 1_500, + )); + assert_eq!(envelope["signature"]["alg"], "ES256"); + let (payload, signature) = payload_and_signature(&envelope); + let verifier = verifying_key(&FIRST); + + let bytes = signed_bytes(CONTEXT, &payload); + assert!(bytes.starts_with(CONTEXT.as_bytes())); + assert_eq!(bytes[CONTEXT.len()], b'\n'); + assert_eq!(&bytes[CONTEXT.len() + 1..], payload.as_slice()); + verifier + .verify(&bytes, &signature) + .expect("the signature covers context, line feed and payload"); + + for (case, message) in [ + ("no context", payload.clone()), + ( + "the context without its line feed", + [CONTEXT.as_bytes(), payload.as_slice()].concat(), + ), + ( + "another context", + signed_bytes("another-purpose:v1", &payload), + ), + ] { + assert!( + verifier.verify(&message, &signature).is_err(), + "the signature must not verify with {case}", + ); + } + } + + /// Each key signs from its own start until the next key's start, and the + /// evidence of a time verifies only with the key in force then. + #[test] + fn the_key_in_force_is_the_latest_to_have_started() { + let settings = settings_with_attestation(); + for (now, kid, signing, other) in [ + (1_000, "example-first", &FIRST, &SECOND), + (1_500, "example-first", &FIRST, &SECOND), + (1_999, "example-first", &FIRST, &SECOND), + (2_000, "example-second", &SECOND, &FIRST), + (2_500, "example-second", &SECOND, &FIRST), + ] { + let envelope = json_body(answer_at(&settings, "/_ex/attestation.json", now)); + assert_eq!(envelope["signature"]["kid"], kid, "at {now}"); + let (payload, signature) = payload_and_signature(&envelope); + let bytes = signed_bytes(CONTEXT, &payload); + assert!( + verifying_key(signing).verify(&bytes, &signature).is_ok(), + "at {now} the key in force signs" + ); + assert!( + verifying_key(other).verify(&bytes, &signature).is_err(), + "at {now} the other key must not verify" + ); + } + } + + /// Nothing is signed before the first key starts, from an empty schedule, + /// with a key that is not a P-256 private key, or in a build given no + /// schedule, which is every build its builder names no schedule for. + #[test] + fn no_usable_key_in_force_answers_service_unavailable() { + assert!(ATTESTATION_KEYS.is_empty(), "test builds carry no keys"); + let settings = settings_with_attestation(); + let first = key_text(&FIRST); + let second = key_text(&SECOND); + let ed25519_seed = STANDARD.encode([0x33u8; 32]); + let no_key = "No signing key is built in for this time"; + let unreadable = "The signing key could not be read"; + let cases: [(&str, Vec>, u64, &str); 5] = [ + ( + "before the first key starts", + vec![ + ("example-first", FIRST_STARTS, first.as_str()), + ("example-second", SECOND_STARTS, second.as_str()), + ], + 999, + no_key, + ), + ("an empty schedule", Vec::new(), 1_500, no_key), + ( + "a key that is not base64", + vec![("example-bad", FIRST_STARTS, "not base64 at all")], + 1_500, + unreadable, + ), + ( + "a key that is not a P-256 private key", + vec![("example-bad", FIRST_STARTS, ed25519_seed.as_str())], + 1_500, + unreadable, + ), + ( + "the schedule this build was given", + ATTESTATION_KEYS.to_vec(), + crate::ec::current_timestamp(), + no_key, + ), + ]; + for (case, schedule, now, says) in cases { + let response = answer( + &settings, + &platform(), + &request("/_ex/attestation.json"), + &schedule, + now, + ); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{case}"); + let body = json_body(response); + assert!( + body["error"] + .as_str() + .is_some_and(|error| error.starts_with(says)), + "{case}: {body}" + ); + } + } + + /// The decoded evidence shown to people is exactly the payload signed. + #[test] + fn the_evidence_shown_is_the_payload_signed() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json?nonce=abc123", + 1_500, + )); + let (payload, _) = payload_and_signature(&envelope); + let decoded: Value = serde_json::from_slice(&payload).expect("JSON payload"); + + assert_eq!(decoded, envelope["evidence"]); + assert_eq!(decoded["v"], 1); + assert_eq!(decoded["operator"], "Example Operator"); + assert_eq!(decoded["host"], "test-publisher.com"); + assert_eq!(decoded["nonce"], "abc123"); + assert_eq!(decoded["platform"]["serviceVersion"], "39"); + assert_eq!(decoded["build"]["version"], crate::build_info::VERSION); + assert_eq!(decoded["issuedAt"], "1970-01-01T00:25:00Z"); + assert_eq!( + envelope["verify"], + "https://verifier.example/verify?host=test-publisher.com" + ); + } + + /// With no nonce asked for, the evidence carries none. + #[test] + fn no_nonce_is_signed_unless_asked_for() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json", + 1_500, + )); + assert!(envelope["evidence"].get("nonce").is_none()); + } + + #[test] + fn a_malformed_nonce_is_refused() { + let settings = settings_with_attestation(); + for uri in [ + "/_ex/attestation.json?nonce=", + "/_ex/attestation.json?nonce=a%20b", + "/_ex/attestation.json?nonce=%3Cscript%3E", + ] { + let response = answer_at(&settings, uri, 1_500); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + } + let long = format!("/_ex/attestation.json?nonce={}", "a".repeat(65)); + let response = answer_at(&settings, &long, 1_500); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + /// A key held as PKCS #8 signs the same way as one held as SEC1. + #[test] + fn a_pkcs8_key_signs_too() { + use p256::pkcs8::EncodePrivateKey as _; + let pkcs8 = STANDARD.encode( + secret_key(&FIRST) + .to_pkcs8_der() + .expect("encodes") + .as_bytes(), + ); + let schedule = [("example-pkcs8", FIRST_STARTS, pkcs8.as_str())]; + let response = answer( + &settings_with_attestation(), + &platform(), + &request("/_ex/attestation.json"), + &schedule, + 1_500, + ); + assert_eq!(response.status(), StatusCode::OK); + } + + /// The page never says the evidence is valid, because only the verifier + /// can, and it links there. + #[test] + fn the_page_links_to_the_verifier_and_claims_nothing() { + let response = answer_at(&settings_with_attestation(), "/_ex/attestation", 1_500); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + let html = String::from_utf8(body_bytes(response)).expect("utf-8"); + assert!(html.contains("Check this claim at verifier.example")); + assert!(html.contains("href=\"https://verifier.example/verify?host=test-publisher.com\"")); + assert!(html.contains("Do not take this page's word for it.")); + // The JSON shown is escaped as text, which leaves quotes as they are. + assert!( + !html.contains("\"valid\""), + "the JSON shown carries no claim of validity" + ); + assert!(html.contains( + "To retrieve this data as JSON for use in your own verification checks use the URL" + )); + assert!( + html.contains("/_ex/attestation.json"), + "the JSON address is offered to copy", + ); + assert!(html.contains("The JSON response is")); + assert!(html.contains("href=\"https://www.rfc-editor.org/rfc/rfc9334\"")); + } + + /// The address offered to copy is absolute, and its scheme is the one the + /// request arrived over. + #[test] + fn the_json_address_carries_the_scheme_and_host_the_request_used() { + let mut req = request("/_ex/attestation"); + assert_eq!( + json_address(&req), + "http://Test-Publisher.com:443/_ex/attestation.json" + ); + req.headers_mut() + .insert("x-forwarded-proto", HeaderValue::from_static("https")); + assert!(json_address(&req).starts_with("https://")); + } + + /// The link names the host the evidence signs, never a forwarded one, on + /// an adapter that leaves forwarded host headers in place. + #[test] + fn the_json_address_never_names_a_forwarded_host() { + for (name, value) in [ + ("x-forwarded-host", "relay.example"), + ("forwarded", "host=relay.example"), + ] { + let mut req = request("/_ex/attestation"); + req.headers_mut() + .insert(name, HeaderValue::from_static(value)); + + let address = json_address(&req); + + assert!( + address.contains("://Test-Publisher.com:443/") && !address.contains("relay"), + "{name} should not reach the link: {address}" + ); + } + } + + #[test] + fn answers_are_never_cached_and_readable_from_any_origin() { + let settings = settings_with_attestation(); + for uri in ["/_ex/attestation", "/_ex/attestation.json"] { + let response = answer_at(&settings, uri, 1_500); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); + } + } + + #[test] + fn the_paths_follow_the_endpoint_and_default_to_ts() { + let mut settings = settings_with_attestation(); + assert_eq!( + attestation_paths(&settings), + Some([ + "/_ex/attestation".to_owned(), + "/_ex/attestation.json".to_owned() + ]) + ); + if let Some(config) = settings.attestation.as_mut() { + config.endpoint = DEFAULT_ENDPOINT.to_owned(); + } + assert_eq!( + attestation_paths(&settings), + Some([ + "/_ts/attestation".to_owned(), + "/_ts/attestation.json".to_owned() + ]) + ); + settings.attestation = None; + assert_eq!(attestation_paths(&settings), None); + } + + #[test] + fn the_endpoint_is_an_absolute_path_under_an_underscore_prefix() { + for good in [ + "/_ts/attestation", + "/_ex/attestation", + "/_a1/proof-of_operator", + "/_ex/pages/attestation", + ] { + assert!(validate_endpoint(good).is_ok(), "{good}"); + } + let too_long = format!("/_ex/{}", "a".repeat(60)); + for bad in [ + "", + "/", + "_ex/attestation", + "/attestation", + "/ex/attestation", + "/_/attestation", + "/_ex", + "/_ex/", + "/_ex//attestation", + "/_Ex/attestation", + "/_ex/attestation.json", + "/_ex/attestation/", + "/_ex/attestation?nonce=1", + too_long.as_str(), + ] { + assert!(validate_endpoint(bad).is_err(), "{bad}"); + } + } + + #[test] + fn the_section_loads_from_a_document_and_refuses_unknown_fields() { + let base = crate_test_settings_str(); + let with_section = format!( + "{base}\n[attestation]\n\ + endpoint = \"/_ex/attestation\"\n\ + operator = \"Example Operator\"\n\ + context = \"example-attestation:v1\"\n\ + verify_url = \"https://verifier.example/verify\"\n" + ); + let settings = Settings::from_toml(&with_section).expect("the section loads"); + let config = settings.attestation.as_ref().expect("the section is read"); + assert_eq!(config.endpoint, "/_ex/attestation"); + assert_eq!(config.operator, "Example Operator"); + + let without_endpoint = with_section.replace("endpoint = \"/_ex/attestation\"\n", ""); + let settings = Settings::from_toml(&without_endpoint).expect("the endpoint has a default"); + assert_eq!( + settings.attestation.as_ref().map(|a| a.endpoint.as_str()), + Some(DEFAULT_ENDPOINT) + ); + + let bad_endpoint = with_section.replace("/_ex/attestation", "/_ex/attestation.json"); + assert!( + Settings::from_toml(&bad_endpoint).is_err(), + "the JSON form is derived, so an endpoint ending in .json is refused", + ); + + let with_key_name = format!("{with_section}signing_key = \"a_key\"\n"); + assert!( + Settings::from_toml(&with_key_name).is_err(), + "keys are built in, so a document naming one is refused", + ); + + let plain_http = with_section.replace("https://verifier", "http://verifier"); + assert!(Settings::from_toml(&plain_http).is_err()); + } + + /// Every field the evidence depends on is checked when the settings load. + /// The context is signed ahead of a line feed, so one holding a control + /// character would make that framing ambiguous, and the operator and the + /// context are each 1 to 64 characters. + #[test] + fn each_field_the_evidence_depends_on_is_checked_at_load() { + let base = crate_test_settings_str(); + let document = |operator: Option<&str>, context: Option<&str>, verify_url: Option<&str>| { + let mut text = format!("{base}\n[attestation]\n"); + for (key, value) in [ + ("operator", operator), + ("context", context), + ("verify_url", verify_url), + ] { + if let Some(value) = value { + text.push_str(&format!("{key} = \"{value}\"\n")); + } + } + text + }; + let operator = Some("Example Operator"); + let context = Some("example-attestation:v1"); + let verify_url = Some("https://verifier.example/verify"); + let longest = "a".repeat(64); + let too_long = "a".repeat(65); + + Settings::from_toml(&document( + Some(longest.as_str()), + Some(longest.as_str()), + verify_url, + )) + .expect("an operator and a context of 64 characters load"); + for (case, (operator, context, verify_url), refusal) in [ + ( + "a context holding a line feed", + (operator, Some("example\\nattestation"), verify_url), + "attestation.context: control_character", + ), + ( + "an empty operator", + (Some(""), context, verify_url), + "attestation.operator: length", + ), + ( + "a 65 character operator", + (Some(too_long.as_str()), context, verify_url), + "attestation.operator: length", + ), + ( + "an empty context", + (operator, Some(""), verify_url), + "attestation.context: length", + ), + ( + "a 65 character context", + (operator, Some(too_long.as_str()), verify_url), + "attestation.context: length", + ), + ( + "no operator", + (None, context, verify_url), + "missing field `operator`", + ), + ( + "no context", + (operator, None, verify_url), + "missing field `context`", + ), + ( + "no verify_url", + (operator, context, None), + "missing field `verify_url`", + ), + ( + "a verify_url with no host", + (operator, context, Some("https://")), + "attestation.verify_url: invalid_https_url", + ), + ] { + let Err(error) = Settings::from_toml(&document(operator, context, verify_url)) else { + panic!("{case} should be refused"); + }; + let text = format!("{error:?}"); + assert!( + text.contains(refusal), + "{case} should be refused as {refusal}: {text}" + ); + } + } + + /// An endpoint whose page or JSON form is an address the deployment + /// answers itself is refused when the settings load, naming that address, + /// because a second route there stops the router being built. + #[test] + fn an_endpoint_at_a_reserved_address_is_refused_naming_it() { + let base = crate_test_settings_str(); + let document = |endpoint: &str| { + format!( + "{base}\n[attestation]\n\ + endpoint = \"{endpoint}\"\n\ + operator = \"Example Operator\"\n\ + context = \"example-attestation:v1\"\n\ + verify_url = \"https://verifier.example/verify\"\n" + ) + }; + for (endpoint, reserved) in [ + ("/_ts/config", "/_ts/config"), + ("/_ts/permissions", "/_ts/permissions"), + ("/_ts/set-tester", "/_ts/set-tester"), + ("/_ts/api/v1/identify", "/_ts/api/v1/identify"), + ("/__ts/page-bids", "/__ts/page-bids"), + ] { + let Err(error) = Settings::from_toml(&document(endpoint)) else { + panic!("{endpoint} should be refused"); + }; + let text = format!("{error:?}"); + assert!( + text.contains("attestation.endpoint: reserved_endpoint"), + "{endpoint} should be refused as reserved: {text}" + ); + assert!( + text.contains(&format!( + "({reserved} is answered by the deployment itself)" + )), + "{endpoint} should name {reserved}: {text}" + ); + } + for endpoint in [ + DEFAULT_ENDPOINT, + "/_ex/attestation", + "/_ts/proof", + "/_ts/configuration", + ] { + let settings = Settings::from_toml(&document(endpoint)) + .unwrap_or_else(|error| panic!("{endpoint} should load: {error:?}")); + assert_eq!( + settings.attestation.as_ref().map(|a| a.endpoint.as_str()), + Some(endpoint) + ); + } + } + + /// The JSON form is checked as well as the page, and a segment in braces + /// stands for any one segment. + #[test] + fn both_forms_are_checked_against_the_reserved_addresses() { + assert_eq!( + reserved_path_taken("/_ex/page", &["/_ex/page.json"]), + Some("/_ex/page.json"), + "the JSON form clashes" + ); + assert_eq!( + reserved_path_taken("/_ex/page", &["/_ex/{name}"]), + Some("/_ex/{name}"), + "a segment in braces answers the page" + ); + assert_eq!( + reserved_path_taken("/_ex/page", &["/_ex", "/_ex/page/more", "/_ex/other.json"]), + None, + "a parent, a child and another JSON form do not clash" + ); + } + + fn request_with(method: Method, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::HOST, "Test-Publisher.com:443") + .body(EdgeBody::empty()) + .expect("request") + } + + fn html(response: Response) -> String { + String::from_utf8(body_bytes(response)).expect("utf-8") + } + + /// The routes as (path, every method) pairs. + fn routes_of(settings: &Settings) -> Vec<(String, bool)> { + prefix_routes(settings) + .into_iter() + .map(|route| (route.path, route.every_method)) + .collect() + } + + /// A deployment whose endpoint sits under a prefix of its own owns all + /// of that prefix and answers the default address with a redirect. The + /// default prefix gives up only the pages themselves, because other + /// routes share it. + #[test] + fn an_own_prefix_is_owned_and_the_default_is_shared() { + let mut settings = settings_with_attestation(); + assert_eq!( + routes_of(&settings), + [ + ("/_ex/attestation".to_owned(), true), + ("/_ex/attestation.json".to_owned(), true), + ("/_ts/attestation".to_owned(), false), + ("/_ts/attestation.json".to_owned(), false), + ("/_ex".to_owned(), true), + ("/_ex/".to_owned(), true), + ("/_ex/{*rest}".to_owned(), true), + ] + ); + + if let Some(config) = settings.attestation.as_mut() { + config.endpoint = "/_ts/proof".to_owned(); + } + assert_eq!( + routes_of(&settings), + [ + ("/_ts/proof".to_owned(), false), + ("/_ts/proof.json".to_owned(), false), + ("/_ts/attestation".to_owned(), false), + ("/_ts/attestation.json".to_owned(), false), + ] + ); + + if let Some(config) = settings.attestation.as_mut() { + config.endpoint = DEFAULT_ENDPOINT.to_owned(); + } + assert_eq!( + routes_of(&settings), + [ + ("/_ts/attestation".to_owned(), false), + ("/_ts/attestation.json".to_owned(), false), + ] + ); + + settings.attestation = None; + assert!(prefix_routes(&settings).is_empty()); + } + + /// When the endpoint is not the default, a read of the default address + /// is sent to the endpoint, page to page and JSON to JSON, with the query + /// kept so a nonce survives. Other methods there are not found. + #[test] + fn reads_of_the_default_address_redirect_to_the_endpoint() { + let settings = settings_with_attestation(); + for (method, uri, location) in [ + (Method::GET, "/_ts/attestation", "/_ex/attestation"), + (Method::HEAD, "/_ts/attestation", "/_ex/attestation"), + ( + Method::GET, + "/_ts/attestation.json?nonce=abc123", + "/_ex/attestation.json?nonce=abc123", + ), + ( + Method::HEAD, + "/_ts/attestation.json", + "/_ex/attestation.json", + ), + ] { + let case = format!("{method} {uri}"); + let response = handle_prefix(&settings, &platform(), &request_with(method, uri)); + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY, "{case}"); + assert_eq!(response.headers()[header::LOCATION], location, "{case}"); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "no-store", + "{case}" + ); + assert_eq!(json_body(response)["location"], location, "{case}"); + } + + let post = handle_prefix( + &settings, + &platform(), + &request_with(Method::POST, "/_ts/attestation"), + ); + assert_eq!(post.status(), StatusCode::NOT_FOUND); + } + + /// With the default endpoint the default address is the endpoint, so it + /// is answered there and nothing redirects. + #[test] + fn the_default_address_is_answered_directly_under_the_default_endpoint() { + let mut settings = settings_with_attestation(); + if let Some(config) = settings.attestation.as_mut() { + config.endpoint = DEFAULT_ENDPOINT.to_owned(); + } + for uri in ["/_ts/attestation", "/_ts/attestation.json"] { + let response = handle_prefix(&settings, &platform(), &request(uri)); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "{uri} reaches the handler, which has no key built in", + ); + assert!(response.headers().get(header::LOCATION).is_none(), "{uri}"); + } + let response = handle_prefix(&settings, &platform(), &request("/_ex/attestation")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + /// With no `[attestation]` section nothing is signed. The prefix handler + /// and the page handler both answer every address not found, in JSON, + /// even with a key in force. + #[test] + fn without_a_section_every_address_is_not_found_in_json() { + let mut settings = settings_with_attestation(); + settings.attestation = None; + for uri in [ + "/_ts/attestation", + "/_ts/attestation.json?nonce=abc123", + "/_ex/attestation", + ] { + for (handler, response) in [ + ( + "prefix", + handle_prefix(&settings, &platform(), &request(uri)), + ), + ("page", answer_at(&settings, uri, 1_500)), + ] { + let case = format!("{handler} {uri}"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{case}"); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "application/json", + "{case}" + ); + assert_eq!( + json_body(response)["error"], + "This deployment serves no attestation.", + "{case}" + ); + } + } + } + + /// The prefix itself lists the pages beneath it. + #[test] + fn the_prefix_lists_its_pages() { + let settings = settings_with_attestation(); + for uri in ["/_ex", "/_ex/"] { + let response = handle_prefix(&settings, &platform(), &request(uri)); + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let page = html(response); + assert!(page.contains("href=\"/_ex/attestation\""), "{uri}"); + assert!(page.contains("href=\"/_ex/attestation.json\""), "{uri}"); + } + } + + /// Any other address beneath an owned prefix is not found, and says where + /// the pages are. + #[test] + fn any_other_address_beneath_the_prefix_is_not_found() { + let settings = settings_with_attestation(); + let response = handle_prefix(&settings, &platform(), &request("/_ex/nothing/here")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(html(response).contains("href=\"/_ex\"")); + + let post = handle_prefix( + &settings, + &platform(), + &request_with(Method::POST, "/_ex/attestation"), + ); + assert_eq!( + post.status(), + StatusCode::NOT_FOUND, + "only reads are answered" + ); + } + + /// A read of an attestation address reaches the attestation handler, + /// which with no key built in answers that it cannot sign. + #[test] + fn a_read_of_an_attestation_address_reaches_the_attestation_handler() { + let settings = settings_with_attestation(); + for method in [Method::GET, Method::HEAD] { + let response = handle_prefix( + &settings, + &platform(), + &request_with(method.clone(), "/_ex/attestation.json"), + ); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "{method}" + ); + } + } + + /// The evidence signed for a request whose Host header is `host`, with + /// any further headers given. + fn evidence_for(host: &str, headers: &[(&str, &str)]) -> Value { + let mut builder = Request::builder() + .uri("/_ex/attestation.json") + .header(header::HOST, host); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let req = builder.body(EdgeBody::empty()).expect("request"); + let first = key_text(&FIRST); + let schedule = [("example-first", FIRST_STARTS, first.as_str())]; + let envelope = json_body(answer( + &settings_with_attestation(), + &platform(), + &req, + &schedule, + 1_500, + )); + let (payload, _) = payload_and_signature(&envelope); + serde_json::from_slice(&payload).expect("JSON payload") + } + + /// The signed host is the Host header the request arrived with. A + /// forwarded host header, which a relay can set, never replaces it. + #[test] + fn forwarded_host_headers_never_become_the_signed_host() { + let evidence = evidence_for( + "test-publisher.com", + &[ + ("x-forwarded-host", "relay.example"), + ("forwarded", "host=relay.example;proto=https"), + ("x-original-host", "relay.example"), + ], + ); + assert_eq!(evidence["host"], "test-publisher.com"); + } + + /// The signed publisher comes from the settings, so a request under + /// another name changes the signed host and never the publisher. + #[test] + fn the_signed_publisher_comes_from_the_settings() { + let settings = settings_with_attestation(); + let evidence = evidence_for("www.relay.example", &[]); + assert_eq!(evidence["host"], "www.relay.example"); + assert_eq!(evidence["publisher"], settings.publisher.domain.as_str()); + } + + /// Without a Host header the host comes from the request address, lower + /// cased and without its port. An IPv6 literal keeps its brackets. + #[test] + fn the_host_falls_back_to_the_request_address() { + let absolute = Request::builder() + .uri("https://Test-Publisher.com:8443/_ex/attestation.json") + .body(EdgeBody::empty()) + .expect("request"); + assert_eq!(request_host(&absolute), "test-publisher.com"); + let literal = Request::builder() + .uri("/_ex/attestation.json") + .header(header::HOST, "[::1]:8080") + .body(EdgeBody::empty()) + .expect("request"); + assert_eq!(request_host(&literal), "[::1]"); + } + + /// Changing a signed byte, here the host, stops the signature verifying, + /// so a copy cannot be edited to name another site. + #[test] + fn an_altered_payload_fails_verification() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json?nonce=abc123", + 1_500, + )); + let (payload, signature) = payload_and_signature(&envelope); + let text = String::from_utf8(payload).expect("utf-8"); + let altered = text.replacen( + "\"host\":\"test-publisher.com\"", + "\"host\":\"relay.example\"", + 1, + ); + assert_ne!(altered, text, "the host is in the payload"); + assert!( + verifying_key(&FIRST) + .verify(&signed_bytes(CONTEXT, altered.as_bytes()), &signature) + .is_err(), + "an altered payload must not verify", + ); + } + + /// Signing is deterministic, as RFC 6979 describes, so the same evidence + /// always carries the same signature. + #[test] + fn the_same_evidence_carries_the_same_signature() { + let settings = settings_with_attestation(); + let uri = "/_ex/attestation.json?nonce=abc123"; + let first = json_body(answer_at(&settings, uri, 1_500)); + let second = json_body(answer_at(&settings, uri, 1_500)); + assert_eq!(first["payload"], second["payload"]); + assert_eq!(first["signature"]["value"], second["signature"]["value"]); + } + + /// The envelope carries exactly the documented fields. + #[test] + fn the_envelope_carries_exactly_the_documented_fields() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json", + 1_500, + )); + let names = |value: &Value| { + let mut names: Vec = value + .as_object() + .expect("an object") + .keys() + .cloned() + .collect(); + names.sort(); + names + }; + assert_eq!( + names(&envelope), + ["evidence", "payload", "signature", "verify"] + ); + assert_eq!(names(&envelope["signature"]), ["alg", "kid", "value"]); + } + + /// The evidence fields are signed in the documented order. + #[test] + fn the_evidence_fields_are_signed_in_the_documented_order() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json?nonce=abc123", + 1_500, + )); + let (payload, _) = payload_and_signature(&envelope); + let text = String::from_utf8(payload).expect("utf-8"); + let order = [ + "\"v\":", + "\"operator\":", + "\"publisher\":", + "\"host\":", + "\"platform\":", + "\"name\":", + "\"serviceId\":", + "\"serviceVersion\":", + "\"staging\":", + "\"pop\":", + "\"build\":", + "\"version\":", + "\"builtAt\":", + "\"commit\":", + "\"run\":", + "\"nonce\":", + "\"issuedAt\":", + ]; + let positions: Vec = order + .iter() + .map(|field| { + text.find(field) + .unwrap_or_else(|| panic!("{field} is signed: {text}")) + }) + .collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{text}"); + } + + /// The longest nonce allowed is signed back unchanged, and a nonce sent + /// percent-encoded is signed as its decoded value. + #[test] + fn a_nonce_is_signed_back_as_sent() { + let settings = settings_with_attestation(); + let longest = "a".repeat(64); + let envelope = json_body(answer_at( + &settings, + &format!("/_ex/attestation.json?nonce={longest}"), + 1_500, + )); + let (payload, _) = payload_and_signature(&envelope); + let evidence: Value = serde_json::from_slice(&payload).expect("JSON payload"); + assert_eq!(evidence["nonce"], longest.as_str()); + let encoded = json_body(answer_at( + &settings, + "/_ex/attestation.json?nonce=Ab_9%2Dz", + 1_500, + )); + assert_eq!(encoded["evidence"]["nonce"], "Ab_9-z"); + } + + /// Markup from the document reaches the page as text, in the claim and + /// in the JSON shown. + #[test] + fn the_page_escapes_what_it_shows() { + let mut settings = settings_with_attestation(); + if let Some(config) = settings.attestation.as_mut() { + config.operator = "Example & Co".to_owned(); + } + let page = html(answer_at(&settings, "/_ex/attestation", 1_500)); + assert!(!page.contains("Example"), "{page}"); + assert!(page.contains("<b>Example</b> & Co")); + } + + /// The evidence reports the build identity compiled in and the platform + /// as the adapter describes it, staging included. + #[test] + fn the_evidence_reports_the_build_and_the_platform() { + let envelope = json_body(answer_at( + &settings_with_attestation(), + "/_ex/attestation.json", + 1_500, + )); + let evidence = &envelope["evidence"]; + assert_eq!(evidence["build"]["commit"], crate::build_info::COMMIT); + assert_eq!(evidence["build"]["run"], crate::build_info::BUILD_RUN); + assert_eq!(evidence["build"]["builtAt"], crate::build_info::BUILT_AT); + assert_eq!(evidence["platform"]["name"], "example-platform"); + assert_eq!(evidence["platform"]["serviceId"], "svc-1"); + assert_eq!(evidence["platform"]["staging"], false); + assert_eq!(evidence["platform"]["pop"], "LHR"); + + let mut staged = platform(); + staged.staging = true; + let first = key_text(&FIRST); + let schedule = [("example-first", FIRST_STARTS, first.as_str())]; + let from_staging = json_body(answer( + &settings_with_attestation(), + &staged, + &request("/_ex/attestation.json"), + &schedule, + 1_500, + )); + assert_eq!(from_staging["evidence"]["platform"]["staging"], true); + } +} diff --git a/crates/trusted-server-core/src/inspect.rs b/crates/trusted-server-core/src/inspect.rs index f874892964..1152aede9d 100644 --- a/crates/trusted-server-core/src/inspect.rs +++ b/crates/trusted-server-core/src/inspect.rs @@ -85,7 +85,7 @@ p.lede{{color:#bbb}}pre{{background:#1a1c1d}}}}\n\ } /// Escapes `value` for HTML text content. -fn html_escape(value: &str) -> String { +pub(crate) fn html_escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index be72b25620..647e4a8889 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -30,6 +30,7 @@ )] pub(crate) mod asset_image_optimizer; +pub mod attestation; pub mod auction; pub mod auction_config_types; pub mod auth; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 48587f2641..29608338b9 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -3918,6 +3918,15 @@ pub struct Settings { skip_serializing_if = "crate::inspect::config::InspectConfig::is_default" )] pub inspect: crate::inspect::config::InspectConfig, + /// The attestation endpoint, which serves evidence signed with the + /// operator's key at the address its `endpoint` names. See + /// [`crate::attestation`]. + /// + /// `None` leaves the address to the publisher's origin, and stays out of + /// serialized config blobs for the reason given on `trusted_client_ip`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub attestation: Option, /// The page changes run on a document fetched from the origin, written /// as `[[fetch]]` entries. See [`crate::middleware`]. #[serde(default, skip_serializing_if = "PhaseEntries::is_empty")] @@ -4715,8 +4724,14 @@ fn validation_error_summary(errors: &validator::ValidationErrors) -> String { }; match kind { validator::ValidationErrorsKind::Field(validations) => { + // A validator's message, where it gives one, says what the + // code cannot, such as which address an endpoint clashed + // with. for validation in validations { - messages.push(format!("{path}: {}", validation.code)); + messages.push(match &validation.message { + Some(message) => format!("{path}: {} ({message})", validation.code), + None => format!("{path}: {}", validation.code), + }); } } validator::ValidationErrorsKind::Struct(inner) => { From 94ae6a2ef5e04afa4d6eebdfe287dab417d3afe0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:14:13 +0100 Subject: [PATCH 03/25] Every adapter serves the attestation pages Each adapter registers the routes the settings ask for. Reads reach the handler, and other methods reach the publisher's origin unless the deployment owns the prefix. The Fastly adapter answers ahead of the Edge Cookie lifecycle and the request filters, because the pages are read only and a verifier fetching one from a server must not be turned away as a bot. The Fastly adapter reports the service, the version, the staging flag and the location from the environment variables Fastly gives every Compute service. The Axum, Cloudflare and Spin adapters report their own name. Each adapter's tests hold every fixed route it registers beneath an underscore prefix to the list an endpoint may not take, so a new fixed route cannot leave an endpoint free to register its address twice. The Fastly tests build a router for every endpoint the settings accept, and the parity tests hold the other three adapters to one answer for the owned prefix, the redirect from the default address and a deployment with no section. --- crates/trusted-server-adapter-axum/src/app.rs | 36 +++ .../tests/routes.rs | 18 ++ .../src/app.rs | 25 ++ .../tests/routes.rs | 18 ++ .../trusted-server-adapter-fastly/src/app.rs | 283 ++++++++++++++++++ crates/trusted-server-adapter-spin/src/app.rs | 29 ++ .../tests/routes.rs | 18 ++ .../tests/parity.rs | 173 +++++++++++ 8 files changed, 600 insertions(+) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index e2c253c782..99b4e8353a 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -9,6 +9,7 @@ use edgezero_core::http::{ }; use edgezero_core::router::RouterService; use error_stack::Report; +use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, @@ -888,6 +889,23 @@ impl TrustedServerApp { } } +/// Answers the attestation pages without the request preparers, because +/// the pages are read only. +fn attestation_route_handler( + state: Arc, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(async move { + Ok(handle_prefix( + &state.settings, + &PlatformIdentity::named("axum"), + &ctx.into_request(), + )) + }) + } +} + fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); @@ -925,6 +943,24 @@ fn build_router(state: &Arc) -> RouterService { } } + // The attestation pages answer at the address the settings choose, so + // they are registered here rather than with the fixed routes. Reads reach + // the handler, and other methods reach the publisher's origin unless the + // deployment owns the prefix. + let routes = prefix_routes(&state.settings); + if !routes.is_empty() { + let attestation = attestation_route_handler(Arc::clone(state)); + for route in &routes { + for method in publisher_fallback_methods() { + if matches!(method, Method::GET | Method::HEAD) || route.every_method { + router = router.route(&route.path, method, attestation.clone()); + } else { + router = router.route(&route.path, method, fallback.clone()); + } + } + } + } + for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); router = router.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 237a556525..6b49bcb3f9 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -1175,3 +1175,21 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } + +/// Every route registered beneath an underscore prefix is one of the +/// addresses an attestation endpoint may not take, so a new fixed route +/// cannot leave an endpoint free to register its address twice. +#[test] +fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { + use trusted_server_core::attestation::RESERVED_PATHS; + + for route in test_router().routes() { + let path = route.path(); + if path.starts_with("/_") { + assert!( + RESERVED_PATHS.contains(&path), + "{path} should be listed in attestation::RESERVED_PATHS" + ); + } + } +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index ef302d68ac..7de0f9e5c6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -8,6 +8,7 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; use edgezero_core::router::RouterService; use error_stack::Report; +use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, @@ -981,6 +982,30 @@ fn build_router(state: &Arc) -> RouterService { router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } + // The attestation pages answer at the address the settings choose, so + // they are registered here rather than with the fixed routes. Reads reach + // the handler, and other methods reach the publisher's origin unless the + // deployment owns the prefix. + let routes = prefix_routes(&state.settings); + if !routes.is_empty() { + let attestation = make_handler(Arc::clone(&state), |s, _services, req| async move { + Ok(handle_prefix( + &s.settings, + &PlatformIdentity::named("cloudflare"), + &req, + )) + }); + for route in &routes { + for method in publisher_fallback_methods() { + if matches!(method, Method::GET | Method::HEAD) || route.every_method { + router = router.route(&route.path, method, attestation.clone()); + } else { + router = router.route(&route.path, method, fallback.clone()); + } + } + } + } + for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); router = router.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index e817f10fcc..ce95980e88 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -918,3 +918,21 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } + +/// Every route registered beneath an underscore prefix is one of the +/// addresses an attestation endpoint may not take, so a new fixed route +/// cannot leave an endpoint free to register its address twice. +#[test] +fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { + use trusted_server_core::attestation::RESERVED_PATHS; + + for route in test_router().routes() { + let path = route.path(); + if path.starts_with("/_") { + assert!( + RESERVED_PATHS.contains(&path), + "{path} should be listed in attestation::RESERVED_PATHS" + ); + } + } +} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 92cc4ae56b..64871f8e45 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -32,6 +32,7 @@ //! | GET | `/_ts/permissions` and `/_ts/permissions.json` | [`handle_permissions`] | //! | GET | `/_ts/config` and `/_ts/config.json` | [`handle_config`] | //! | GET | `/_ts/data` | [`handle_data`] | +//! | GET, HEAD | The address `[attestation] endpoint` names, and the same with `.json` | [`handle_prefix`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | @@ -104,6 +105,7 @@ use edgezero_core::http::{ }; use edgezero_core::router::RouterService; use error_stack::Report; +use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ @@ -1587,6 +1589,42 @@ fn named_route_handler( } } +/// Answers the attestation pages ahead of the Edge Cookie lifecycle and the +/// request filters, because the pages are read only and a verifier fetching +/// one from a server must not be turned away as a bot. +fn attestation_route_handler( + state: Arc, +) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(async move { + Ok(handle_prefix( + &state.settings, + &fastly_platform_identity(), + &ctx.into_request(), + )) + }) + } +} + +/// What the evidence says about the Fastly service answering, from the +/// environment variables Fastly gives every Compute service. +fn fastly_platform_identity() -> PlatformIdentity { + let read = |name: &str| { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_owned()) + }; + PlatformIdentity { + name: "fastly".to_owned(), + service_id: read("FASTLY_SERVICE_ID"), + service_version: read("FASTLY_SERVICE_VERSION"), + staging: std::env::var("FASTLY_IS_STAGING").is_ok_and(|value| value == "1"), + pop: read("FASTLY_POP"), + } +} + fn fallback_route_handler( state: Arc, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { @@ -1656,6 +1694,24 @@ impl TrustedServerApp { } } + // The attestation pages answer at the address the settings choose, so + // they are registered here rather than in the table. Reads reach the + // handler, and other methods reach the publisher's origin unless the + // deployment owns the prefix. + let routes = prefix_routes(&state.settings); + if !routes.is_empty() { + let attestation = attestation_route_handler(Arc::clone(state)); + for route in &routes { + for method in publisher_fallback_methods() { + if matches!(method, Method::GET | Method::HEAD) || route.every_method { + router = router.route(&route.path, method, attestation.clone()); + } else { + router = router.route(&route.path, method, fallback_handler.clone()); + } + } + } + } + // matchit's `/{*rest}` does not match the bare root `/` — register // explicit root routes so `/` reaches the publisher fallback too. for method in publisher_fallback_methods() { @@ -2740,6 +2796,233 @@ mod tests { ); } + /// Settings whose attestation pages sit at `endpoint`. + fn settings_with_attestation_at(endpoint: &str) -> Settings { + let mut settings = test_settings(); + settings.attestation = Some(trusted_server_core::attestation::AttestationConfig { + endpoint: endpoint.to_owned(), + operator: "Example Operator".to_owned(), + context: "example-attestation:v1".to_owned(), + verify_url: "https://verifier.example/verify".to_owned(), + }); + settings + } + + /// The router of a deployment whose attestation pages sit at `endpoint`. + fn router_with_attestation_at(endpoint: &str) -> RouterService { + let state = build_state_from_settings(settings_with_attestation_at(endpoint)) + .expect("should build test state"); + TrustedServerApp::routes_for_state(&state) + } + + fn header_text(response: &Response, name: header::HeaderName) -> String { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() + } + + /// A deployment whose endpoint sits under a prefix of its own answers + /// everything beneath that prefix here. Reads of the two attestation + /// addresses reach the handler, the prefix lists its pages, and anything + /// else is not found rather than sent to the publisher's origin. Test + /// builds carry no signing key, so the handler answers 503 in JSON. + #[test] + fn an_owned_prefix_is_answered_here_and_never_by_the_origin() { + let router = router_with_attestation_at("/_ex/attestation"); + + let cases = [ + (Method::GET, "/_ex", 200, "text/html"), + (Method::GET, "/_ex/", 200, "text/html"), + ( + Method::GET, + "/_ex/attestation.json", + 503, + "application/json", + ), + ( + Method::HEAD, + "/_ex/attestation.json", + 503, + "application/json", + ), + (Method::GET, "/_ex/attestation", 503, "application/json"), + (Method::POST, "/_ex/attestation", 404, "text/html"), + (Method::PUT, "/_ex/attestation.json", 404, "text/html"), + (Method::DELETE, "/_ex", 404, "text/html"), + (Method::GET, "/_ex/unknown/page", 404, "text/html"), + ]; + for (method, path, status, content_type) in cases { + let case = format!("{method} {path}"); + let response = route(&router, empty_request(method, path)); + assert_eq!( + response.status().as_u16(), + status, + "should answer {case} here" + ); + assert!( + header_text(&response, header::CONTENT_TYPE).starts_with(content_type), + "should answer {case} as {content_type}" + ); + assert_eq!( + header_text(&response, header::CACHE_CONTROL), + "no-store", + "should keep the answer to {case} out of every cache" + ); + } + } + + /// When the endpoint is not the default, reads of the default address are + /// sent to the endpoint, keeping the nonce. With the default endpoint the + /// default address is answered directly, and no other address redirects. + #[test] + fn the_default_address_redirects_to_the_endpoint_the_settings_name() { + use trusted_server_core::attestation::DEFAULT_ENDPOINT; + + let router = router_with_attestation_at("/_ex/attestation"); + for (method, path, location) in [ + (Method::GET, "/_ts/attestation", "/_ex/attestation"), + (Method::HEAD, "/_ts/attestation", "/_ex/attestation"), + ( + Method::GET, + "/_ts/attestation.json?nonce=abc123", + "/_ex/attestation.json?nonce=abc123", + ), + ] { + let case = format!("{method} {path}"); + let response = route(&router, empty_request(method, path)); + assert_eq!(response.status().as_u16(), 301, "should redirect {case}"); + assert_eq!( + header_text(&response, header::LOCATION), + location, + "should send {case} to the endpoint with its query kept" + ); + assert_eq!( + header_text(&response, header::CACHE_CONTROL), + "no-store", + "should keep the redirect of {case} out of every cache" + ); + } + + let router = router_with_attestation_at(DEFAULT_ENDPOINT); + for path in ["/_ts/attestation", "/_ts/attestation.json"] { + let response = route(&router, empty_request(Method::GET, path)); + assert_eq!( + response.status().as_u16(), + 503, + "{path} should reach the handler, which has no key built in" + ); + assert!( + response.headers().get(header::LOCATION).is_none(), + "{path} should not redirect under the default endpoint" + ); + } + } + + /// Without the section the two addresses belong to the publisher, so a + /// deployment that serves no attestation takes nothing from its origin. + #[test] + fn without_the_section_no_attestation_route_is_registered() { + let registered: Vec = test_router() + .routes() + .iter() + .map(|route| route.path().to_owned()) + .filter(|path| path.contains("attestation")) + .collect(); + + assert!( + registered.is_empty(), + "should register no attestation route, found {registered:?}" + ); + } + + /// Every route this router registers beneath an underscore prefix is one + /// of the addresses an attestation endpoint may not take, so a new fixed + /// route cannot leave an endpoint free to register its address twice. + #[test] + fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { + use trusted_server_core::attestation::RESERVED_PATHS; + + for route in test_router().routes() { + let path = route.path(); + if path.starts_with("/_") { + assert!( + RESERVED_PATHS.contains(&path), + "{path} should be listed in attestation::RESERVED_PATHS" + ); + } + } + } + + /// Every endpoint the settings accept builds a router whose attestation + /// pages answer, because a second route at one address panics when the + /// router is built, and this adapter builds it on every request. The + /// candidates are every address the router answers beneath an underscore + /// prefix, as a page with a sample segment for one in braces, and further + /// addresses beneath the same prefixes. + #[test] + fn every_endpoint_the_settings_accept_builds_a_router() { + use trusted_server_core::attestation::{DEFAULT_ENDPOINT, validate_endpoint}; + + let mut candidates: Vec = test_router() + .routes() + .iter() + .map(edgezero_core::router::RouteInfo::path) + .filter(|path| path.starts_with("/_")) + .map(|path| { + let page = path.strip_suffix(".json").unwrap_or(path); + page.split('/') + .map(|segment| { + if segment.starts_with('{') { + "proof" + } else { + segment + } + }) + .collect::>() + .join("/") + }) + .collect(); + candidates.extend( + [ + DEFAULT_ENDPOINT, + "/_ts/proof", + "/_ts/api/v1/proof", + "/__ts/proof", + ] + .map(str::to_owned), + ); + candidates.sort(); + candidates.dedup(); + + let accepted: Vec = candidates + .into_iter() + .filter(|endpoint| validate_endpoint(endpoint).is_ok()) + .collect(); + for endpoint in &accepted { + let router = router_with_attestation_at(endpoint); + let json = format!("{endpoint}.json"); + let response = route(&router, empty_request(Method::GET, &json)); + assert_eq!( + response.status().as_u16(), + 503, + "{json} should reach the handler, which has no key built in" + ); + } + assert_eq!( + accepted, + [ + "/__ts/proof", + "/_ts/api/v1/proof", + DEFAULT_ENDPOINT, + "/_ts/proof" + ], + "should accept only the endpoints no fixed route answers" + ); + } + #[test] fn dispatch_clear_tester_clears_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 7a3cbfce69..ec222620fa 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -12,6 +12,7 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; use edgezero_core::router::RouterService; use error_stack::Report; +use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, @@ -1208,6 +1209,34 @@ fn build_router(state: &Arc) -> RouterService { } } } + // The attestation pages answer at the address the settings choose, so + // they are registered here rather than with the fixed routes. Reads reach + // the handler, and other methods reach the publisher's origin unless the + // deployment owns the prefix. + let routes = prefix_routes(&state.settings); + if !routes.is_empty() { + let s = Arc::clone(&state); + let attestation_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + Ok::(handle_prefix( + &s.settings, + &PlatformIdentity::named("spin"), + &ctx.into_request(), + )) + } + }; + for route in &routes { + for method in publisher_fallback_methods() { + if matches!(method, Method::GET | Method::HEAD) || route.every_method { + builder = builder.route(&route.path, method, attestation_handler.clone()); + } else { + builder = builder.route(&route.path, method, fallback.clone()); + } + } + } + } + for method in publisher_fallback_methods() { builder = builder.route("/", method.clone(), fallback.clone()); builder = builder.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 1403c11494..088e0ce57d 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -1205,3 +1205,21 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } + +/// Every route registered beneath an underscore prefix is one of the +/// addresses an attestation endpoint may not take, so a new fixed route +/// cannot leave an endpoint free to register its address twice. +#[test] +fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { + use trusted_server_core::attestation::RESERVED_PATHS; + + for route in test_router().routes() { + let path = route.path(); + if path.starts_with("/_") { + assert!( + RESERVED_PATHS.contains(&path), + "{path} should be listed in attestation::RESERVED_PATHS" + ); + } + } +} diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index d825c9608a..ef37686928 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -1325,3 +1325,176 @@ async fn adapter_buffers_nextjs_auction_output() { } } } + +/// The shared settings with an `[attestation]` section whose endpoint sits +/// under a prefix of its own, so the deployment owns the prefix. +fn attestation_settings() -> Settings { + let mut settings = test_settings(); + settings.attestation = Some(trusted_server_core::attestation::AttestationConfig { + endpoint: "/_ex/attestation".to_owned(), + operator: "Example Operator".to_owned(), + context: "example-attestation:v1".to_owned(), + verify_url: "https://verifier.example/verify".to_owned(), + }); + settings +} + +/// The value of header `name`, or an empty string when it is absent. +fn header_text(headers: &HeaderMap, name: &str) -> String { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() +} + +/// One adapter's answer to a request. +struct Answer { + adapter: &'static str, + status: u16, + headers: HeaderMap, +} + +/// What each host-runnable adapter answers to `method` at `uri` under +/// `settings`. +async fn answers(settings: &Settings, method: &str, uri: &str) -> Vec { + let mut axum = EdgeZeroAxumService::new( + AxumApp::routes_with_settings(settings.clone()).expect("should build Axum router"), + ); + let request = AxumRequest::builder() + .method(method) + .uri(uri) + .body(AxumBody::empty()) + .expect("should build request"); + let response = axum + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should respond"); + let mut answers = vec![Answer { + adapter: "axum", + status: response.status().as_u16(), + headers: response.headers().clone(), + }]; + + for (adapter, router) in [ + ( + "cloudflare", + CloudflareApp::routes_with_settings(settings.clone()) + .expect("should build Cloudflare router"), + ), + ( + "spin", + SpinApp::routes_with_settings(settings.clone()).expect("should build Spin router"), + ), + ] { + let request = request_builder() + .method(method) + .uri(uri) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let response = router.oneshot(request).await.expect("should respond"); + answers.push(Answer { + adapter, + status: response.status().as_u16(), + headers: response.headers().clone(), + }); + } + answers +} + +/// A deployment whose endpoint sits under a prefix of its own answers +/// everything beneath that prefix the same way on every adapter. Reads of the +/// two attestation addresses reach the handler, the prefix lists its pages, +/// and anything else is not found rather than sent to the publisher's origin. +/// Test builds carry no signing key, so the handler answers 503 in JSON. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attestation_prefix_parity() { + let cases = [ + ("GET", "/_ex", 200, "text/html"), + ("GET", "/_ex/", 200, "text/html"), + ("GET", "/_ex/attestation.json", 503, "application/json"), + ("HEAD", "/_ex/attestation.json", 503, "application/json"), + ("GET", "/_ex/attestation", 503, "application/json"), + ("POST", "/_ex/attestation", 404, "text/html"), + ("PUT", "/_ex/attestation.json", 404, "text/html"), + ("DELETE", "/_ex", 404, "text/html"), + ("GET", "/_ex/unknown/page", 404, "text/html"), + ]; + let settings = attestation_settings(); + for (method, uri, status, content_type) in cases { + for answer in answers(&settings, method, uri).await { + let case = format!("{} {method} {uri}", answer.adapter); + let got_type = header_text(&answer.headers, "content-type"); + assert_eq!(answer.status, status, "should answer {case} alike"); + assert!( + got_type.starts_with(content_type), + "should answer {case} as {content_type}, got {got_type}" + ); + assert_eq!( + header_text(&answer.headers, "cache-control"), + "no-store", + "should keep the answer to {case} out of every cache" + ); + } + } +} + +/// When the endpoint is not the default, every adapter sends a read of the +/// default address to the endpoint, page to page and JSON to JSON, keeping +/// the nonce. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attestation_default_address_redirect_parity() { + let cases = [ + ("GET", "/_ts/attestation", "/_ex/attestation"), + ("HEAD", "/_ts/attestation", "/_ex/attestation"), + ( + "GET", + "/_ts/attestation.json?nonce=abc123", + "/_ex/attestation.json?nonce=abc123", + ), + ]; + let settings = attestation_settings(); + for (method, uri, location) in cases { + for answer in answers(&settings, method, uri).await { + let case = format!("{} {method} {uri}", answer.adapter); + assert_eq!(answer.status, 301, "should redirect {case}"); + assert_eq!( + header_text(&answer.headers, "location"), + location, + "should send {case} to the endpoint with its query kept" + ); + assert_eq!( + header_text(&answer.headers, "cache-control"), + "no-store", + "should keep the redirect of {case} out of every cache" + ); + } + } +} + +/// Without the section no adapter takes the default address, so a read of it +/// reaches the publisher's origin as any other path does. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attestation_is_not_served_without_its_section_parity() { + let settings = test_settings(); + let unknown = answers(&settings, "GET", "/a-path-no-route-names").await; + let attestation = answers(&settings, "GET", "/_ts/attestation.json").await; + + for (unknown, attestation) in unknown.iter().zip(&attestation) { + assert_eq!( + attestation.status, unknown.status, + "{} should answer the attestation address as it answers any path of the origin", + attestation.adapter + ); + // The handler answers 404 where there is no section, so a 404 here + // would say a route had been registered for it. + assert_ne!( + attestation.status, 404, + "{} should not answer from the attestation handler", + attestation.adapter + ); + } +} From dffb3b29518202d39ff121ff08528433b005aad3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:14:19 +0100 Subject: [PATCH 04/25] The attestation guide, for an operator, a relying party and a reviewer `docs/guide/attestation.md` says what the evidence proves and what it does not, the addresses and answers, the settings, the key schedule, the formats, the checks a verifier makes, how replay and relay are met, and the limits. Every test it names is in the tree. The configuration guide gains the `[attestation]` section, the API reference the endpoint and its two rows in the route table, the example configuration a commented block, the docs sidebar the page, and the changelog the addition. --- CHANGELOG.md | 1 + docs/.vitepress/config.mts | 1 + docs/guide/api-reference.md | 20 ++ docs/guide/attestation.md | 477 ++++++++++++++++++++++++++++++++++++ docs/guide/configuration.md | 31 +++ trusted-server.example.toml | 14 ++ 6 files changed, 544 insertions(+) create mode 100644 docs/guide/attestation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 748ce13e66..61e32d4189 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 +- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 11bcff0f5b..e4d327628b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -132,6 +132,7 @@ export default withMermaid( { text: 'Request Signing', link: '/guide/request-signing' }, { text: 'Key Rotation', link: '/guide/key-rotation' }, { text: 'Proxy Signing', link: '/guide/proxy-signing' }, + { text: 'Attestation', link: '/guide/attestation' }, ], }, { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index f70254031c..1bc09ca88a 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -67,6 +67,8 @@ authority, scheme, and client-address headers in the innermost middleware. | normal | `/verify-signature` | `POST` | literal | `always` | real | real | real | real | | normal | `/{*rest}` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | template | `publisher_fallback` | publisher fallback | publisher fallback | publisher fallback | publisher fallback | | normal | `/` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `publisher_fallback` | publisher fallback | publisher fallback | publisher fallback | publisher fallback | +| normal | `.json` | `GET`, `HEAD` | config derived | `settings.attestation` | real | real | real | real | +| normal | `` | `GET`, `HEAD` | config derived | `settings.attestation` | real | real | real | real | | normal | `{*rest}` | `GET`, `HEAD` | config derived | `settings.proxy.asset_routes[]` | real | — | — | — | | startup | `/health` | `GET` | literal | `startup_error` | real | — | — | real | | startup | `/{*rest}` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | template | `startup_error` | startup error (`500`) | startup error (`500`) | startup error (`500`) | startup error (`503`) | @@ -298,6 +300,24 @@ not run for this request, so no cookie is set and no identity row is written. curl -s "https://edge.example.com/_ts/permissions.json" -H "Sec-GPC: 1" ``` +### GET `` and GET `.json` + +Signed evidence of who operates the deployment and which build it runs, as a +page and as data. [Attestation](/guide/attestation) gives the format and how a +relying party checks it. + +- **Configuration gate:** served only where the settings carry `[attestation]`. The address is `[attestation] endpoint`, which defaults to `/_ts/attestation`. Where the endpoint is not the default, a read of the default address answers `301` to the endpoint with the query kept. +- **Auth:** none. +- **Request:** `GET` or `HEAD`. An optional `nonce` query parameter of 1 to 64 characters from `A` to `Z`, `a` to `z`, `0` to `9`, `_` and `-` is signed back in the evidence. +- **Response:** `200` with the page, or with the envelope as `application/json` at the `.json` address. `400` for a malformed nonce. `503` where the build carries no signing key in force or the key cannot be read. The `400` and `503` bodies are JSON with an `error` field. +- **Cache and CORS:** `Cache-Control: no-store` and `Access-Control-Allow-Origin: *` on every answer. +- **Rate limiting:** none. +- **Other methods:** reach the publisher's origin under the shared `/_ts` prefix. Under a prefix the endpoint has to itself, every other method and address beneath that prefix answers `404` from the deployment. + +```bash +curl "https://publisher.example/_ts/attestation.json?nonce=Docs-Example_0929" +``` + ## Utility Endpoints ### GET /\_ts/set-tester diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md new file mode 100644 index 0000000000..37ce27700c --- /dev/null +++ b/docs/guide/attestation.md @@ -0,0 +1,477 @@ +# Attestation + +A deployment can serve evidence of who operates it and which build it runs, +signed with a key that only the operator's build carries. A relying party, such +as a supply-side platform (SSP, which sells the publisher's advertising space), +a consent management platform (CMP) or an identity provider working with the +publisher, fetches the evidence from the publisher's own address and asks a +verifier whether it is genuine. + +This page is written for three readers. + +1. An operator deploying Trusted Server, who needs the configuration, the key + schedule and the checks to make after a release. +2. A relying party, who needs to know what the evidence proves and how to check + it, with a verifier or without one. +3. A reviewer, who needs the limits, being what the evidence does not prove. + +The mechanism is the same for every operator, and this page describes it +without naming one. + +## Terms + +The first four terms are the roles of the +[IETF Remote Attestation Procedures (RATS) Architecture, RFC 9334](https://www.rfc-editor.org/rfc/rfc9334). +The quotations are from its section 4. + +| Term | Meaning | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Attester | "A role performed by an entity (typically a device) whose Evidence must be appraised in order to infer the extent to which the Attester is considered trustworthy". Here the attester is the deployment serving the publisher's site. | +| Evidence | "A set of Claims generated by an Attester to be appraised by a Verifier". Here the evidence is the signed JSON the deployment serves. | +| Verifier | "A role performed by an entity that appraises the validity of Evidence about an Attester and produces Attestation Results to be used by a Relying Party". Here the verifier is the service the settings' `verify_url` names. | +| Relying party | "A role performed by an entity that depends on the validity of information about an Attester for purposes of reliably applying application-specific actions". Here the relying party is a vendor working with the publisher. | +| Verdict | The verifier's answer, which RFC 9334 calls an attestation result. | +| Envelope | The JSON the endpoint answers with, carrying the evidence, the exact bytes that were signed and the signature. | +| Payload | The evidence's exact bytes as signed, carried in the envelope as base64url, which is base64 written with `-` and `_` in place of `+` and `/` and with no `=` padding. | +| Context | A short line of text signed ahead of the payload, naming what the signature is for, so a signature the same key makes for any other purpose can never pass as evidence. | +| Key id (`kid`) | The name of the signing key, which tells a verifier which public key to check the signature with. | +| Nonce | A random value the checker makes up and sends with its request. An answer that carries the value back must have been made after the request. | +| Replay | Serving a stored response somewhere other than where the response was made, for example saving `publisher.example`'s evidence and serving the copy from `bad-actor.example`. | +| Relay | Passing live requests through to a genuine deployment and passing its answers back, so another site answers every request with whatever the genuine site answers at that moment. | + +## What the evidence proves + +A signature that verifies proves that whoever made it held the operator's +signing key. The key is compiled into the build and the build stays in the +operator's hands, so a valid signature means that a deployment the operator +runs made the evidence. Under that signature the evidence states the host the +request arrived for, the publisher whose settings answered, the platform service +and the build that answered, the time, and the nonce when one was sent. + +The evidence does not prove these four things. + +1. **What code is running.** A module on a CDN cannot read its own code. + Fastly, for example, tells a module its service, version, staging flag and + location through + [environment variables](https://www.fastly.com/documentation/reference/compute/ecp-env/), + and none of them identifies the module's bytes. Whatever a module says about + itself, a changed module can say too, so the `build` fields are the + operator's own report of what it deployed. +2. **A hardware measurement.** Attestation in trusted hardware signs a hash of + the running code that the hardware itself took. Nothing here takes such a + measurement, and the trust rests on the operator's key alone. +3. **What the site serves.** The evidence says who operates the deployment + serving a host, not what pages the publisher's origin returns through it. +4. **That production answered.** The platform's staging environment signs with + the same key, and the evidence says so in `platform.staging`. + +## Addresses + +The deployment serves the endpoint at the address +[`[attestation] endpoint`](#attestation-section) sets, which defaults to +`/_ts/attestation`. + +| Address | Answer | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `{endpoint}` | A page stating the claim, linking to the verifier, giving the JSON address to copy and showing the envelope | +| `{endpoint}.json` | The envelope, as `application/json` | +| `/_ts/attestation` and its `.json` form | 301 to the endpoint, keeping the query, when the endpoint isn't the default | +| The endpoint's prefix, with and without `/` | A list of the two pages, served only when the endpoint sits under a prefix of its own | + +The routes exist only when the settings carry +[`[attestation]`](#attestation-section). Without that section every address +above reaches the publisher's origin. + +A read of the default address redirects so that a verifier or a link written +for `/_ts/attestation` still reaches the deployment's own address, with the +nonce kept. The test `reads_of_the_default_address_redirect_to_the_endpoint` +pins the redirect, and +`the_default_address_is_answered_directly_under_the_default_endpoint` pins +that a deployment using the default answers there with no redirect. + +Both attestation addresses answer with `Cache-Control: no-store`, because every +answer carries a time and often a nonce, so no cache may hand it out again. They +also answer with `Access-Control-Allow-Origin: *`, so a relying party's page can +read the JSON from a browser. The test +`answers_are_never_cached_and_readable_from_any_origin` fails without either +header. + +The page never says the evidence is valid, because only a verifier can. It +states the operator's claim, says "Do not take this page's word for it." and +links to `verify_url`. The test +`the_page_links_to_the_verifier_and_claims_nothing` pins that wording, and +`the_page_escapes_what_it_shows` pins that markup in the settings is shown as +text. + +### Methods + +An endpoint under a prefix of its own, such as `/_ex/attestation`, owns that +prefix. GET and HEAD reach the pages, the prefix itself lists them, and any +other address or method beneath the prefix answers 404 from the deployment +rather than reaching the publisher's origin. + +Under the default `_ts` prefix, which other routes share, only GET on the two +attestation addresses is taken. Every other method on those addresses reaches +the publisher's origin. + +The tests are `an_own_prefix_is_owned_and_the_default_is_shared`, +`the_prefix_lists_its_pages` and +`any_other_address_beneath_the_prefix_is_not_found` in core, +`an_owned_prefix_is_answered_here_and_never_by_the_origin` in the Fastly adapter +and `attestation_prefix_parity`, which checks that the Axum, Cloudflare and Spin +adapters answer every case the same way. + +### The nonce + +A caller may add a `nonce` query parameter of 1 to 64 characters from `A` to +`Z`, `a` to `z`, `0` to `9`, `_` and `-`. The deployment decodes any +percent-encoding and signs the value back unchanged in the evidence's `nonce` +field. Any other value answers 400. Without a nonce the evidence carries no +`nonce` field. The tests are `a_nonce_is_signed_back_as_sent`, +`a_malformed_nonce_is_refused` and `no_nonce_is_signed_unless_asked_for`. + +### Answers + +| Status | When | Body | +| ------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| 200 | Evidence was signed | The page, or the envelope at the `.json` address | +| 400 | The nonce is malformed | `{"error":"A nonce is 1 to 64 characters from A to Z, a to z, 0 to 9, _ and -."}` | +| 404 | Any other address or method beneath a prefix the deployment owns | A page pointing to the list at the prefix | +| 503 | No key in the build has come into force, or the build carries no key at all | `{"error":"No signing key is built in for this time, so no evidence can be signed."}` | +| 503 | The key in force is not a P-256 private key in SEC1 or PKCS #8 form | `{"error":"The signing key could not be read, so no evidence can be signed."}` | + +The 400 and 503 answers are JSON at both addresses. The tests are +`a_malformed_nonce_is_refused` and +`no_usable_key_in_force_answers_service_unavailable`. + +### What each adapter reports + +The Fastly adapter fills the platform fields from the service's environment. +The Axum, Cloudflare and Spin adapters report only their own name, with +`unknown` as the service id, service version and location, and `staging` false. + +On Fastly the endpoint answers ahead of the Edge Cookie lifecycle and the +request filters, because the endpoint is read only and a verifier fetching it +from a server must not be turned away as a bot. The endpoint makes no outbound +request and reads no store. + +## Configuration + +### `[attestation]` {#attestation-section} + +| Field | Type | Rule | Meaning | +| ------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | +| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | +| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | +| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | + +The section refuses any other field, so a document naming a signing key is +refused, because keys are compiled into the build and never configured. A +`verify_url` over plain `http` is refused too. The test +`the_section_loads_from_a_document_and_refuses_unknown_fields` pins both, and +`the_endpoint_is_an_absolute_path_under_an_underscore_prefix` pins the rule +for `endpoint`. + +Only the attestation pages move with `endpoint`. Every other route stays +beneath `/_ts/`. + +```toml +[attestation] +endpoint = "/_ex/attestation" +operator = "Example Operator" +context = "example-attestation:v1" +verify_url = "https://verifier.example/verify?host=publisher.example" +``` + +The page links to `verify_url` as written, and the deployment never calls it. A +verifier that fetches the host itself needs to know which host to fetch, so the +address normally names the publisher's domain, as here. + +## Signing keys + +Keys are compiled into the build, so no key is kept anywhere the running service +reads. The build input `TRUSTED_SERVER_ATTESTATION_KEYS` names a file, the key +schedule, which holds one key per line in three fields separated by tabs. + +1. The key id. +2. The Unix second the key comes into force. +3. The private key, ECDSA on the P-256 curve, as base64 of its SEC1 or PKCS #8 + DER encoding. + +Key ids and keys may hold only letters, digits and `-_.:+/=`, and a line of any +other shape fails the build. + +The key in force is the latest to have started, and it stays in force until the +next one starts. While the key in force is the last one in the build, the +deployment logs the warning +``attestation key `` is the last one built in, so the build must be refreshed`` +and goes on signing with that key. The test is +`the_key_in_force_is_the_latest_to_have_started`. + +Without the build input, or with an empty file, the schedule is empty and the +endpoint answers 503. That is every build its builder names no schedule for, +which the test `no_usable_key_in_force_answers_service_unavailable` pins. + +The build fields come from three more build inputs. +`TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN` become `build.commit` +and `build.run`, or `unknown` when absent, and `SOURCE_DATE_EPOCH`, when set, +gives `build.builtAt` in place of the time of the build. + +## Formats + +### The envelope + +This envelope shows the shape of an answer for a publisher at +`publisher.example`, asked for with the nonce `Docs-Example_0929`. The +payload, the signature, the service id, the commit and the run are +placeholders. + +```json +{ + "evidence": { + "build": { + "builtAt": "2026-09-29T09:18:54Z", + "commit": "", + "run": "", + "version": "0.1.0" + }, + "host": "publisher.example", + "issuedAt": "2026-09-29T10:36:29Z", + "nonce": "Docs-Example_0929", + "operator": "Example Operator", + "platform": { + "name": "fastly", + "pop": "LCY", + "serviceId": "", + "serviceVersion": "45", + "staging": false + }, + "publisher": "publisher.example", + "v": 1 + }, + "payload": "", + "signature": { + "alg": "ES256", + "kid": "example-2026-09-28", + "value": "" + }, + "verify": "https://verifier.example/verify?host=publisher.example" +} +``` + +| Field | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `evidence` | A readable copy of the payload, for people, with its fields in alphabetical order. No check reads it. | +| `payload` | The evidence's exact bytes as signed, in base64url | +| `signature.kid` | The key id of the key in force when the evidence was signed | +| `signature.alg` | Always `ES256` | +| `signature.value` | The signature as the raw 64 byte `r` and `s` pair, in base64url | +| `verify` | The settings' `verify_url` | + +The tests are `the_envelope_carries_exactly_the_documented_fields` and +`the_evidence_shown_is_the_payload_signed`. + +### The evidence + +The payload holds these fields in this order. + +| Field | Type | Source | +| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `v` | Number | The format version, `1` | +| `operator` | String | `[attestation] operator` | +| `publisher` | String | `publisher.domain` in the settings | +| `host` | String | The request's `Host` header, lower cased, with any numeric port removed. The request address's host stands in only when there is no `Host` header. A forwarded host header is never read. | +| `platform.name` | String | The adapter, for example `fastly` | +| `platform.serviceId` | String | On Fastly, `FASTLY_SERVICE_ID` | +| `platform.serviceVersion` | String | On Fastly, `FASTLY_SERVICE_VERSION` | +| `platform.staging` | Boolean | On Fastly, true when `FASTLY_IS_STAGING` is `1` | +| `platform.pop` | String | On Fastly, `FASTLY_POP`, the location that answered | +| `build.version` | String | The crate version | +| `build.builtAt` | String | RFC 3339, from `SOURCE_DATE_EPOCH` when set, otherwise the time of the build | +| `build.commit` | String | `TRUSTED_SERVER_COMMIT`, or `unknown` | +| `build.run` | String | `TRUSTED_SERVER_BUILD_RUN`, or `unknown` | +| `nonce` | String | The caller's nonce, present only when one was sent | +| `issuedAt` | String | The platform's clock, RFC 3339 in UTC to the second | + +A Fastly environment variable that is absent reads `unknown`. The tests are +`the_evidence_fields_are_signed_in_the_documented_order`, +`the_evidence_reports_the_build_and_the_platform`, +`forwarded_host_headers_never_become_the_signed_host`, +`the_signed_publisher_comes_from_the_settings` and +`the_host_falls_back_to_the_request_address`. + +### The signature + +The signed bytes are the settings' `context`, then one line feed (byte 10), +then the payload bytes. The signature is ECDSA on the P-256 curve with SHA-256, +made deterministically as [RFC 6979](https://www.rfc-editor.org/rfc/rfc6979) +describes, so the same evidence always carries the same signature. +[RFC 7518](https://www.rfc-editor.org/rfc/rfc7518) names this algorithm `ES256`. + +To check a signature, decode `payload`, put the context and a line feed in front +of it, and verify with the public key the `kid` names. Read the evidence only +after the signature verifies, and read it from the decoded payload, never from +the readable copy. No rule for writing JSON the same way twice is needed, +because the bytes are checked exactly as they arrived. + +The tests are +`the_signature_covers_the_context_a_line_feed_and_the_payload`, which also +shows the signature failing without the context, without its line feed and +under another context, `an_altered_payload_fails_verification` and +`the_same_evidence_carries_the_same_signature`. + +## Verifying evidence + +A verifier makes these checks, and the first four need no input from the +relying party. + +1. The envelope can be read, `alg` is `ES256` and the signature is 64 bytes. +2. The `kid` names a key the verifier holds for the operator. +3. The signature verifies over the context, a line feed and the payload. +4. The key's period covers `issuedAt`, and `issuedAt` is close to the + verifier's own clock. +5. The evidence carries the nonce the checker sent. +6. `host` equals the domain being asked about, and `publisher` is that domain + or a parent of it, so `www.publisher.example` passes for the publisher + `publisher.example`. +7. The operator, platform and service id are ones the operator lists as its + own. + +A verifier that fetches the host itself makes check 5 with a nonce it has just +made up and check 6 against the host it fetched from, so a stored or copied +answer cannot pass them. A verifier handed an envelope can make checks 5 and 6 +only against the values the relying party gives it. + +## Replay and relay + +Genuine evidence for one site must never pass as evidence for another. The +protection is that the host is inside the signed evidence, and the verifier +compares that host with the domain it was asked about. A relying party who +checks only the signature, or who follows the link on a copied page, can still +be fooled, so this section says what the checks cover and what a relying party +must do. [Replay and relay](#terms) are defined with the other terms. + +### What is signed + +Every field of [the evidence](#the-evidence) is signed, with the context in +front of it. Two fields decide which site the evidence speaks for, being `host`, +which comes from the request, and `publisher`, which comes from the settings. + +### Replay onto another domain + +Evidence copied from `publisher.example` and served from `bad-actor.example` still +says `"host": "publisher.example"`. A verifier asked about `bad-actor.example` +compares the two and refuses the evidence, because the hosts differ. Changing +the host in the copy changes the signed bytes, so the signature no longer +verifies. Changing only the readable `evidence` copy changes nothing, because +only the payload is read. + +The test `an_altered_payload_fails_verification` pins the signature failing +once the host changes. + +### Relay + +A relay can get fresh evidence from a genuine deployment, but the evidence still +names the genuine deployment's host, for three reasons. + +1. **The deployment signs the `Host` header the request arrived with.** A + forwarded host header such as `X-Forwarded-Host` or `Forwarded`, which a relay + can set, never becomes the signed host. On Fastly the adapter also removes + forwarded headers a client sent before core runs. The tests are + `forwarded_host_headers_never_become_the_signed_host` in core and + `sanitize_fastly_forwarded_headers_strips_spoofable` in the Fastly adapter. +2. **The platform passes a request to a service only under a name the service + serves.** Fastly answers any other name itself and the request never reaches + the deployment. To reach the deployment at all, a relay must send the genuine + host's name, and the evidence then names the genuine host. No test in this + repository covers this, because Fastly's routing does it, so an operator + makes the live check below against its own deployment. +3. **On a service that reads an app-config blob for each host, a host with no + blob of its own is answered `421`,** never from another publisher's blob. + See [One blob for each host](/guide/configuration#one-blob-for-each-host). + The test is `a_host_with_no_blob_is_refused_and_not_served_from_another_key` + in the Fastly adapter, and `scripts/config-by-host-local-test.sh` shows the + same on the running adapter. + +The live check asks one of the addresses a deployment's host resolves to for +the reserved name `bad-actor.example`, shown here for `publisher.example`. +Fastly answered these three requests itself. + +```text +curl -D - -o /dev/null -H "Host: bad-actor.example" http://publisher.example/_ts/attestation.json + HTTP/1.1 500 Domain Not Found + Server: Varnish + +curl -D - -H "Host: bad-actor.example" https://publisher.example/_ts/attestation.json + HTTP/1.1 421 Misdirected Request + Requested host does not match any Subject Alternative Names (SANs) on TLS certificate [...] in use with this connection. + +curl -k -D - -o /dev/null --resolve bad-actor.example:443: https://bad-actor.example/_ts/attestation.json + HTTP/1.1 421 Misdirected Request +``` + +A forwarded host header sent to the genuine host changes nothing either. With +`X-Forwarded-Host: bad-actor.example` and `Forwarded: host=bad-actor.example` +added, `https://publisher.example/_ts/attestation.json` still signs +`"host": "publisher.example"`. + +A verifier that holds the operator's list of services adds one more defense. +It refuses evidence whose operator, platform and service id are not ones the +operator lists, so a build carrying the key but running on a service the +operator does not list is reported even when its signature verifies. + +### Freshness + +`issuedAt` is the time of signing. A verifier refuses evidence whose `issuedAt` +is too far from its own clock in either direction. A nonce proves more than a +time can, because evidence carrying a nonce the checker has just made up +cannot have been made before the checker asked. A verifier that fetches the +host itself sends a fresh nonce every time, so a stored copy fails there. The +evidence is sent with `Cache-Control: no-store`, so no cache between the +parties hands back an old answer. + +### What a relying party must do + +1. **Build the verify address from the domain you are actually dealing with, + and never follow the link on the page being checked.** Whoever serves a page + writes its link, so a copied page can carry a link that asks about the + original host, and the verifier then answers valid about the original host. +2. **Accept only a verdict that says the evidence is valid for your own + domain with every check made.** A verdict that leaves a check out has not + told you the evidence is good. +3. **Send your own nonce whenever you fetch evidence yourself,** and pass the + same `nonce` and `host` when you hand the envelope to a verifier. +4. **Check `platform.staging`** if only answers from production are acceptable + to you. + +When checking the JSON without a verifier, check the host, the nonce, `issuedAt` +against a freshness window and the key's period against `issuedAt`, not only +the signature. The service check needs the operator's list of its services. + +## Security + +A key compiled into a binary is readable by whoever holds the binary. The +protection is that the binary never leaves the operator's hands, so the build +that carries keys must run only where the operator controls it, and the built +package must go only to the operator's own platform account. Anyone can copy the +page, its wording and its layout, and nobody without the key can make the +signature. + +The context keeps signatures apart by purpose. A verifier puts the context in +front of the payload before checking, so a signature the same key made over any +other bytes never verifies as evidence, and evidence never verifies as anything +else. The test is +`the_signature_covers_the_context_a_line_feed_and_the_payload`. + +## Limits + +1. **The build fields are a report.** They say what the operator deployed, in + the operator's own words, as + [What the evidence proves](#what-the-evidence-proves) explains. +2. **A signature alone proves nothing about the site in front of you.** Genuine + evidence copied onto another site still verifies as a signature. Only the + host, nonce and time checks tie it to your request. +3. **The link on a page answers for the host written into the link,** which is + why a relying party builds its own verify address. +4. **Staging evidence verifies.** It says `"staging": true`. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e60d368a99..a2da78c4c7 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -707,6 +707,37 @@ show = ["publisher.origin_url"] hide = ["response_headers"] ``` +## Attestation Configuration + +A deployment can serve evidence of who operates it and which build it runs, +signed with a key that only the operator's build carries. Without this section +the deployment serves no evidence and the address belongs to the publisher's +origin. [Attestation](/guide/attestation) describes the evidence, the signing +keys and how a relying party checks it. + +### `[attestation]` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint` | String | No | Where the page answers, with the JSON form at the same path plus `.json`. Default `/_ts/attestation`. An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not ending `.json` and not an address the deployment already answers | +| `operator` | String | Yes | Who the evidence says operates the deployment, 1 to 64 characters | +| `context` | String | Yes | The text signed ahead of the evidence, 1 to 64 characters with no control characters. A verifier must use exactly the same text | +| `verify_url` | String | Yes | An `https` address with a host, where the page sends a reader to check the claim | + +The section refuses any other field, so a configuration naming a signing key +is refused. Keys are compiled into the build from the file the build input +`TRUSTED_SERVER_ATTESTATION_KEYS` names, and a build given none answers `503` +at the endpoint and nothing else changes. + +**Example**: + +```toml +[attestation] +operator = "Example Operator" +context = "example-attestation:v1" +verify_url = "https://verifier.example/verify?host=publisher.example" +``` + ## EC Configuration Settings for generating privacy-preserving Edge Cookie identifiers. The `ec_store` KV store is the only KV-backed EC lifecycle store; it holds identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones. Live consent is interpreted from request cookies, headers, geolocation, and policy defaults, not separate KV persistence. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 77385dbac1..db43e917cc 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -879,3 +879,17 @@ auction_timeout_ms = 500 # provides. Find the ids by grepping your rendered HTML for `gtag/js?id=`. # allowed_tag_ids = ["G-EXAMPLE1", "AW-000000", "MC-EXAMPLE1"] +# Signed evidence of who operates this deployment and which build it runs, +# served at `endpoint` and at the same address with `.json`. Without this +# table the address belongs to the publisher's origin. The signing keys are +# compiled into the build from the file TRUSTED_SERVER_ATTESTATION_KEYS names +# and are never configured, so a build given none answers 503 there. +# See docs/guide/attestation.md. +# [attestation] +# Defaults to "/_ts/attestation". +# endpoint = "/_ts/attestation" +# operator = "Example Operator" +# The text signed ahead of the evidence. A verifier must use the same text. +# context = "example-attestation:v1" +# Where the page sends a reader to check the claim. Must be https. +# verify_url = "https://verifier.example/verify?host=example.com" From 896942cc79bf8b8e75fa022492bd2e67037061b9 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:24:41 +0100 Subject: [PATCH 05/25] What a key schedule may hold is tested The build script read the key schedule itself, so nothing tested what it took and what it refused. The reading moves to `key_schedule.rs`, a file the build script and the attestation tests both compile, as the build time's arithmetic already is. A schedule's key id and key are written into Rust source as strings. The tests hold that nothing either holds can end the string it is written in, that a line of any other shape stops the build by its line number, and that the message never repeats the line, because the line holds a private key. A file saved with either line ending reads the same. --- crates/trusted-server-core/build.rs | 54 ++++------- crates/trusted-server-core/key_schedule.rs | 57 +++++++++++ crates/trusted-server-core/src/attestation.rs | 97 +++++++++++++++++++ 3 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 crates/trusted-server-core/key_schedule.rs diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6c87253a6d..a3dbcab9c3 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use edgezero_core::manifest::ManifestLoader; mod build_time; +mod key_schedule; /// Source file that must stay out of the generated list. /// @@ -25,49 +26,30 @@ fn main() { /// Embeds the attestation signing key schedule in the file /// `TRUSTED_SERVER_ATTESTATION_KEYS` names, or an empty schedule. /// -/// One key per line, being the key id, the Unix second it comes into force -/// and the private key as base64 DER, separated by tabs. The schedule is -/// compiled into the binary, so no signing key sits in a store the service -/// reads when it runs. +/// The schedule is compiled into the binary, so no signing key sits in a +/// store the service reads when it runs. See [`key_schedule::constant_source`] +/// for what a schedule holds. fn write_attestation_keys() { println!("cargo:rerun-if-env-changed=TRUSTED_SERVER_ATTESTATION_KEYS"); - let mut generated = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); - if let Some(path) = - env::var_os("TRUSTED_SERVER_ATTESTATION_KEYS").filter(|path| !path.is_empty()) - { - let path = PathBuf::from(path); - println!("cargo:rerun-if-changed={}", path.display()); - let text = fs::read_to_string(&path).expect("should read the attestation key schedule"); - for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { - let mut fields = line.split('\t'); - let key_id = fields.next().unwrap_or_default(); - let starts_at: i64 = fields - .next() - .unwrap_or_default() - .parse() - .expect("should read each attestation key's start as Unix seconds"); - let private_key = fields.next().unwrap_or_default(); - assert!( - fields.next().is_none() && is_plain(key_id) && is_plain(private_key), - "each attestation key line should hold a key id, a start and a key", - ); - generated.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); + let schedule = env::var_os("TRUSTED_SERVER_ATTESTATION_KEYS") + .filter(|path| !path.is_empty()) + .map(|path| { + let path = PathBuf::from(path); + println!("cargo:rerun-if-changed={}", path.display()); + fs::read_to_string(&path).expect("should read the attestation key schedule") + }); + let source = match key_schedule::constant_source(schedule.as_deref()) { + Ok(source) => source, + Err(reason) => { + println!("cargo::error=the attestation key schedule is refused, because {reason}"); + std::process::exit(1); } - } - generated.push_str("];"); + }; let out_dir = PathBuf::from(env::var("OUT_DIR").expect("should read OUT_DIR")); - fs::write(out_dir.join("attestation_keys.rs"), generated) + fs::write(out_dir.join("attestation_keys.rs"), source) .expect("should write the attestation key schedule"); } -/// Whether a schedule field holds only characters a key id or base64 uses. -fn is_plain(field: &str) -> bool { - !field.is_empty() - && field - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_.:+/=".contains(&b)) -} - /// Bakes the build time into the binary as `TRUSTED_SERVER_BUILT_AT`. /// /// Some hosts give a WebAssembly guest no custom environment variable when it diff --git a/crates/trusted-server-core/key_schedule.rs b/crates/trusted-server-core/key_schedule.rs new file mode 100644 index 0000000000..b5f411fb18 --- /dev/null +++ b/crates/trusted-server-core/key_schedule.rs @@ -0,0 +1,57 @@ +//! The attestation key schedule a build is given, read into the constant +//! the build script writes. +//! +//! A module of the build script and of the tests in `src/attestation.rs`, +//! which check what a schedule may hold. + +/// The source of the `ATTESTATION_KEYS` constant for the text of a key +/// schedule, or of an empty schedule for `None`. +/// +/// A schedule holds one key on each line, being the key id, the Unix second +/// the key comes into force and the private key as base64 DER, separated by +/// tabs. Blank lines are passed over. +/// +/// A key id and a key may hold only letters, digits and `-_.:+/=`. Each is +/// written into Rust source as a string, so nothing a schedule holds can end +/// the string it is written in. +/// +/// # Errors +/// +/// Returns the number of the first line of any other shape and what is wrong +/// with it. The line itself is never repeated, because it holds a private +/// key. +pub(crate) fn constant_source(schedule: Option<&str>) -> Result { + let mut source = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); + for (index, line) in schedule.unwrap_or_default().lines().enumerate() { + let line = line.trim_matches([' ', '\r']); + if line.is_empty() { + continue; + } + let number = index + 1; + let fields: Vec<&str> = line.split('\t').collect(); + let [key_id, starts_at, private_key] = fields.as_slice() else { + return Err(format!( + "line {number} should hold a key id, a start and a key, separated by tabs" + )); + }; + let starts_at: i64 = starts_at + .parse() + .map_err(|_| format!("line {number} should give the key's start as Unix seconds"))?; + if !is_plain(key_id) || !is_plain(private_key) { + return Err(format!( + "line {number} should hold a key id and a key of letters, digits and -_.:+/= only" + )); + } + source.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); + } + source.push_str("];"); + Ok(source) +} + +/// Whether a schedule field holds only characters a key id or base64 uses. +fn is_plain(field: &str) -> bool { + !field.is_empty() + && field + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.:+/=".contains(&b)) +} diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 3ee917817f..8e54969b21 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -38,6 +38,12 @@ use crate::settings::Settings; // `ATTESTATION_KEYS`, written by build.rs from the schedule the build names. include!(concat!(env!("OUT_DIR"), "/attestation_keys.rs")); +/// The build script's reading of a key schedule, compiled here so a test can +/// call it. +#[cfg(test)] +#[path = "../key_schedule.rs"] +mod key_schedule; + /// The address of the attestation page when the settings name none. The JSON /// form is at the same address plus `.json`. pub const DEFAULT_ENDPOINT: &str = "/_ts/attestation"; @@ -824,6 +830,97 @@ mod tests { (payload, Signature::from_slice(&raw).expect("64 bytes")) } + /// A build given no schedule, an empty one or blank lines compiles a + /// schedule with no key in it, which is what makes the endpoint answer + /// 503. + #[test] + fn a_build_given_no_schedule_compiles_an_empty_one() { + let empty = "const ATTESTATION_KEYS: &[(&str, i64, &str)] = &[];"; + + for (case, schedule) in [ + ("no schedule", None), + ("an empty file", Some("")), + ("blank lines", Some("\n \n\r\n")), + ] { + assert_eq!( + key_schedule::constant_source(schedule).as_deref(), + Ok(empty), + "{case} should compile an empty schedule" + ); + } + } + + /// Each line becomes one key, in the order written, whichever line ending + /// the file was saved with. + #[test] + fn each_line_of_a_schedule_becomes_a_key() { + let first = key_text(&FIRST); + let second = key_text(&SECOND); + let expected = format!( + "const ATTESTATION_KEYS: &[(&str, i64, &str)] = &[\ + (\"example-first\", 1000, {first:?}),(\"example-second\", 2000, {second:?}),];" + ); + + for ending in ["\n", "\r\n"] { + let schedule = format!( + "example-first\t1000\t{first}{ending}example-second\t2000\t{second}{ending}" + ); + assert_eq!( + key_schedule::constant_source(Some(&schedule)), + Ok(expected.clone()), + "should read both keys from a file with {ending:?} line endings" + ); + } + } + + /// A line of any other shape stops the build. Its key is never repeated + /// in the message, and nothing it holds can end the string it would be + /// written in. + #[test] + fn a_schedule_line_of_any_other_shape_is_refused_without_showing_the_key() { + let key = key_text(&FIRST); + let cases = [ + ("two fields", format!("example-first\t{key}")), + ("four fields", format!("example-first\t1000\t{key}\textra")), + ( + "spaces in place of tabs", + format!("example-first 1000 {key}"), + ), + ( + "a start that is not a number", + format!("example-first\tsoon\t{key}"), + ), + ("an empty key id", format!("\t1000\t{key}")), + ("an empty key", "example-first\t1000\t".to_owned()), + ( + "a key id that would end its string", + format!("example\"); evil(\"\t1000\t{key}"), + ), + ( + "a key that would end its string", + format!("example-first\t1000\t{key}\"), (\"x"), + ), + ( + "a key holding a backslash", + format!("example-first\t1000\t{key}\\"), + ), + ]; + for (case, line) in cases { + let schedule = format!("example-zero\t500\t{key}\n{line}\n"); + let refused = key_schedule::constant_source(Some(&schedule)) + .expect_err("should refuse the schedule"); + + assert!( + refused.starts_with("line 2 "), + "{case} should be refused by its line number, got: {refused}" + ); + assert!( + !refused.contains(&key), + "{case} should be refused without repeating the key" + ); + } + } + /// A verifier with only the public key, in the `SubjectPublicKeyInfo` DER /// form a key schedule publishes, accepts the signature over exactly the /// context, one line feed, then the payload. Without the context, without From a4cebd8130b09aab4143f50020596ef66f93d933 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 11:26:16 +0100 Subject: [PATCH 06/25] A harness checks the evidence as a verifier would `scripts/attestation-local-test.sh` makes two signing keys, builds the Fastly adapter with their schedule compiled in and runs it under Viceroy. It then checks what the running adapter serves with OpenSSL and the public key alone, sharing no code with the adapter. The signature verifies over the context, a line feed and the payload. It fails with the other key of the schedule, under another context and once the host in the payload is changed. The key in force signs, and not the one that starts later. The evidence names the host the request named, lower case and without its port, and the publisher, the operator, the nonce, the platform, and the commit, run and build time the build was given. A forwarded host header never becomes the signed host, no nonce is signed unless one is asked for, a malformed nonce is refused, and another method is not answered with evidence. This is the one check of the schedule a build compiles in, because test builds carry no key. CI runs it last in the Fastly job, because it builds core with a schedule. --- .github/workflows/test.yml | 4 + docs/guide/attestation.md | 11 ++ scripts/README.md | 1 + scripts/attestation-local-test.sh | 316 ++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+) create mode 100755 scripts/attestation-local-test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f4237f7937..f218c36f8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,6 +77,10 @@ jobs: - name: Run config by host harness (reusable-sandbox feature) run: ./scripts/config-by-host-local-test.sh reuse + # Last, because it builds core with a key schedule compiled in. + - name: Run attestation harness + run: ./scripts/attestation-local-test.sh + test-axum: name: cargo test (axum native, ${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index 37ce27700c..c664003a37 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -343,6 +343,17 @@ made up and check 6 against the host it fetched from, so a stored or copied answer cannot pass them. A verifier handed an envelope can make checks 5 and 6 only against the values the relying party gives it. +## Seeing it run + +`./scripts/attestation-local-test.sh` makes two signing keys, builds the +Fastly adapter with their schedule compiled in and runs it under Viceroy. It +then checks the evidence as a verifier would, with OpenSSL and the public key +alone. The signature verifies over the context, a line feed and the payload, +and fails with the other key, under another context and once the host in the +payload is changed. The script also checks every field the evidence carries, +that a forwarded host header never becomes the signed host, and the answers +to a malformed nonce and to another method. + ## Replay and relay Genuine evidence for one site must never pass as evidence for another. The diff --git a/scripts/README.md b/scripts/README.md index 9b5c72ec86..a918447780 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,6 +5,7 @@ their documented orchestration handles a probe deliberately. | Script | Inputs and prerequisites | Side effects and cleanup | | ----------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `attestation-local-test.sh` | Viceroy, OpenSSL, `curl`, Python; optional ports | Makes signing keys, builds the adapter with them compiled in, checks the evidence it serves, stops owned servers, and removes the directory. | | `batch-sync.sh` | Endpoint, bearer token, EC ID, partner UID; `curl` and Python | Sends one authenticated batch-sync request. Its temporary response file is removed on exit. | | `benchmark.sh` | A running server; `curl`, `bc`, and optionally `hey` | May install `hey` through Homebrew. `--save` writes under `benchmark-results/`; it does not stop the server. | | `profile.sh` | Fastly CLI, Rust WASM target, `curl`; endpoint and request options | Builds and starts the Fastly app, stops the owned process, and retains a profile under `benchmark-results/profiles/`. `--open` launches the local viewer. | diff --git a/scripts/attestation-local-test.sh b/scripts/attestation-local-test.sh new file mode 100755 index 0000000000..10b73117eb --- /dev/null +++ b/scripts/attestation-local-test.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# +# Local harness for attestation. +# +# Builds the Fastly adapter with a key schedule made for this run, runs it +# under Viceroy against a stub origin, and checks the evidence the way a +# verifier would: with the public key alone, over the context, a line feed +# and the payload. Everything it needs is generated into a temp directory; +# your `trusted-server.toml` is never read and your tracked `fastly.toml` is +# never modified. The keys are made here, used here and deleted with the +# directory. +# +# The build is given `TRUSTED_SERVER_ATTESTATION_KEYS`, so core is rebuilt +# with the schedule in it, and rebuilt again by the next build that is not +# given one. Run this after the other harnesses. +# +# Usage: +# ./scripts/attestation-local-test.sh +# +# Environment: +# TS_BIN a built `ts`, in place of building one +# CARGO_TARGET_DIR where cargo builds, when it is not `target` +# ORIGIN_PORT the stub origin's port +# TS_PORT the port Viceroy listens on + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +ORIGIN_PORT="${ORIGIN_PORT:-9299}" +TS_PORT="${TS_PORT:-7899}" +HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" +TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" +PUBLISHER="publisher.example" +CONTEXT="harness-attestation:v1" +# 9 September 2001 at 01:46:40 UTC, so the build time the evidence reports +# is known before the build is made. +BUILD_EPOCH=1000000000 +BUILD_TIME="2001-09-09T01:46:40Z" +REQUEST_TIMEOUT_SECONDS="${REQUEST_TIMEOUT_SECONDS:-30}" + +PASS=0 +FAIL=0 +VICEROY_PID="" +ORIGIN_PID="" + +info() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS + 1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL + 1)); } + +check() { # check + if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1: got '$2', want '$3'"; fi +} + +cleanup() { + local status=$? + if [ -n "$VICEROY_PID" ]; then + kill "$VICEROY_PID" 2>/dev/null || true + wait "$VICEROY_PID" 2>/dev/null || true + fi + if [ -n "$ORIGIN_PID" ]; then + # macOS may launch the framework Python process as a child of the shim. + pkill -TERM -P "$ORIGIN_PID" 2>/dev/null || true + kill "$ORIGIN_PID" 2>/dev/null || true + wait "$ORIGIN_PID" 2>/dev/null || true + fi + rm -rf "$WORK" + exit $status +} +trap cleanup EXIT INT TERM + +for tool in viceroy curl openssl; do + command -v "$tool" >/dev/null || { + echo "$tool not found. Viceroy installs with: cargo install viceroy --version 0.17.0 --locked" >&2 + exit 1 + } +done +PYTHON="$(command -v python3 || command -v python || true)" +[ -n "$PYTHON" ] || { + echo "python3 not found. The harness reads the evidence with it." >&2 + exit 1 +} + +for port in "$ORIGIN_PORT" "$TS_PORT"; do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + echo "Port $port is already in use. Stop the process, or set" >&2 + echo "ORIGIN_PORT / TS_PORT to something free." >&2 + exit 1 + fi +done + +info "Making two signing keys and their schedule" +# The first key is in force from the epoch. The second starts in 2100, so +# the first signs everything this run asks for. +for name in first next; do + openssl ecparam -name prime256v1 -genkey -noout -outform DER -out "$WORK/$name.der" 2>/dev/null + openssl ec -inform DER -in "$WORK/$name.der" -pubout -out "$WORK/$name.pem" 2>/dev/null +done +printf 'harness-first\t0\t%s\nharness-next\t4102444800\t%s\n' \ + "$(openssl base64 -A -in "$WORK/first.der")" \ + "$(openssl base64 -A -in "$WORK/next.der")" > "$WORK/schedule.tsv" + +info "Building (debug wasm with the schedule compiled in, and the ts CLI)" +TRUSTED_SERVER_ATTESTATION_KEYS="$WORK/schedule.tsv" \ + TRUSTED_SERVER_COMMIT="harness-commit" \ + TRUSTED_SERVER_BUILD_RUN="harness-run" \ + SOURCE_DATE_EPOCH="$BUILD_EPOCH" \ + cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 >/dev/null +# Copied aside, because the next build that is given no schedule writes the +# same path. +cp "$TARGET_DIR/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm" "$WORK/app.wasm" +WASM="$WORK/app.wasm" +TS="${TS_BIN:-}" +if [ -z "$TS" ]; then + cargo build -p trusted-server-cli --target "$HOST_TRIPLE" >/dev/null + TS="$TARGET_DIR/$HOST_TRIPLE/debug/ts" +fi + +info "Starting a stub origin on :$ORIGIN_PORT" +mkdir -p "$WORK/origin" +printf 'ORIGIN_SENTINEL\n' > "$WORK/origin/index.html" +"$PYTHON" -m http.server "$ORIGIN_PORT" --bind 127.0.0.1 --directory "$WORK/origin" \ + > "$WORK/origin.log" 2>&1 & +ORIGIN_PID=$! +for _ in $(seq 1 40); do + curl -s -o /dev/null "http://127.0.0.1:$ORIGIN_PORT/" && break + sleep 0.25 +done + +info "Seeding an isolated config store (tracked fastly.toml remains untouched)" +cp "$REPO_ROOT/edgezero.toml" "$WORK/edgezero.toml" +cp "$REPO_ROOT/fastly.toml" "$WORK/fastly.toml" +ln -s "$REPO_ROOT/crates" "$WORK/crates" +cat >> "$WORK/fastly.toml" <<'SECRETSEOF' + +[[local_server.secret_stores.ts_secrets]] +key = "publisher_proxy_secret" +data = "fictional-local-proxy-secret-for-this-harness" + +[[local_server.secret_stores.ts_secrets]] +key = "ec_passphrase" +data = "fictional-local-ec-passphrase-secret-value" + +[[local_server.secret_stores.ts_secrets]] +key = "handler_password" +data = "fictional-local-handler-password-secret-value" +SECRETSEOF + +"$TS" config init --app-config "$WORK/app.toml" >/dev/null +cat >> "$WORK/app.toml" </dev/null +) + +info "Starting Trusted Server on :$TS_PORT" +RUST_LOG=info viceroy serve -C "$WORK/fastly.toml" \ + --addr "127.0.0.1:$TS_PORT" "$WASM" > "$WORK/viceroy.log" 2>&1 & +VICEROY_PID=$! +for _ in $(seq 1 40); do + grep -q "Listening on" "$WORK/viceroy.log" 2>/dev/null && break + sleep 0.5 +done +if ! grep -q "Listening on" "$WORK/viceroy.log" 2>/dev/null; then + echo "Trusted Server failed to start. Last log lines:" >&2 + tail -20 "$WORK/viceroy.log" >&2 + exit 1 +fi + +REQUESTS=0 +fetch() { # fetch [extra curl args...], sets STATUS, BODY and HEADERS + local target="$1" + shift + REQUESTS=$((REQUESTS + 1)) + BODY="$WORK/response-$REQUESTS.body" + HEADERS="$WORK/response-$REQUESTS.headers" + STATUS="$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D "$HEADERS" -o "$BODY" \ + -w '%{http_code}' -H "Host: Publisher.Example:8443" "$@" \ + "http://127.0.0.1:$TS_PORT$target" || true)" +} + +header() { # header , from the last response + tr -d '\r' < "$HEADERS" | awk -v name="$1:" 'tolower($1) == name { print $2 }' +} + +# Reads an envelope the way a verifier does. Writes the bytes the signature +# covers and the signature in the form openssl takes, and prints one field of +# the evidence, read from the payload and never from the readable copy. +cat > "$WORK/evidence.py" <<'PYEOF' +import base64 +import json +import sys + + +def b64url(text): + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def der_integer(raw): + raw = raw.lstrip(b"\x00") or b"\x00" + if raw[0] & 0x80: + raw = b"\x00" + raw + return b"\x02" + bytes([len(raw)]) + raw + + +envelope = json.load(open(sys.argv[1], encoding="utf-8")) +command = sys.argv[2] +payload = b64url(envelope["payload"]) +if command == "field": + value = json.loads(payload) + for part in sys.argv[3].split("."): + value = value.get(part, "") if isinstance(value, dict) else "" + print(value) +elif command == "envelope": + value = envelope + for part in sys.argv[3].split("."): + value = value.get(part, "") if isinstance(value, dict) else "" + print(value) +elif command == "write": + context, signed_path, signature_path = sys.argv[3:6] + tamper = len(sys.argv) > 6 and sys.argv[6] == "tamper" + if tamper: + payload = payload.replace(b"publisher.example", b"bad-actor.example") + open(signed_path, "wb").write(context.encode() + b"\n" + payload) + raw = b64url(envelope["signature"]["value"]) + assert len(raw) == 64, "the signature is the raw r and s pair" + body = der_integer(raw[:32]) + der_integer(raw[32:]) + open(signature_path, "wb").write(b"\x30" + bytes([len(body)]) + body) +PYEOF + +field() { "$PYTHON" "$WORK/evidence.py" "$BODY" field "$1"; } +envelope_field() { "$PYTHON" "$WORK/evidence.py" "$BODY" envelope "$1"; } + +verifies() { # verifies [tamper], prints yes or no + "$PYTHON" "$WORK/evidence.py" "$BODY" write "$2" "$WORK/signed.bin" "$WORK/signature.der" "${3:-}" + if openssl dgst -sha256 -verify "$WORK/$1.pem" -signature "$WORK/signature.der" \ + "$WORK/signed.bin" >/dev/null 2>&1; then + echo yes + else + echo no + fi +} + +info "The evidence, asked for as data with a nonce" +fetch "/_ts/attestation.json?nonce=Harness_1" +check "the endpoint answers" "$STATUS" "200" +check "no cache may store the answer" "$(header cache-control)" "no-store" +check "a page of any origin may read it" "$(header access-control-allow-origin)" "*" +check "the signature names its algorithm" "$(envelope_field signature.alg)" "ES256" +check "the key in force signed, and not the one that starts later" \ + "$(envelope_field signature.kid)" "harness-first" +check "the signature verifies over the context, a line feed and the payload" \ + "$(verifies first "$CONTEXT")" "yes" +check "it does not verify with the other key of the schedule" \ + "$(verifies next "$CONTEXT")" "no" +check "it does not verify under another context" \ + "$(verifies first "another-purpose:v1")" "no" +check "it does not verify once the host in the payload is changed" \ + "$(verifies first "$CONTEXT" tamper)" "no" +check "the host is the one the request named, lower case and without its port" \ + "$(field host)" "$PUBLISHER" +check "the publisher is the settings' own" "$(field publisher)" "$PUBLISHER" +check "the nonce is signed back as sent" "$(field nonce)" "Harness_1" +check "the operator is the settings' own" "$(field operator)" "Harness Operator" +check "the platform names itself" "$(field platform.name)" "fastly" +check "the build reports the commit it was given" "$(field build.commit)" "harness-commit" +check "the build reports the run it was given" "$(field build.run)" "harness-run" +check "the build reports the time it was given" "$(field build.builtAt)" "$BUILD_TIME" +ISSUED="$(field issuedAt)" +AGE="$("$PYTHON" -c 'import datetime, sys; t = datetime.datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc); print(int(abs((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) < 300))' "$ISSUED" 2>/dev/null || echo 0)" +check "the evidence was signed within the last five minutes" "$AGE" "1" + +info "What a relay or a careless caller cannot change" +fetch "/_ts/attestation.json?nonce=Harness_2" \ + -H "X-Forwarded-Host: bad-actor.example" -H "Forwarded: host=bad-actor.example" +check "a forwarded host header never becomes the signed host" "$(field host)" "$PUBLISHER" +check "and the evidence still verifies" "$(verifies first "$CONTEXT")" "yes" +fetch "/_ts/attestation.json" +if "$PYTHON" -c 'import base64, json, sys; e = json.load(open(sys.argv[1])); p = e["payload"]; sys.exit("nonce" in json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))))' "$BODY"; then + ok "no nonce is signed unless one is asked for" +else + bad "a nonce is signed although none was asked for" +fi +fetch "/_ts/attestation.json?nonce=not%20a%20nonce" +check "a malformed nonce is refused" "$STATUS" "400" +fetch "/_ts/attestation" +check "the page answers" "$STATUS" "200" +if grep -qF "Do not take this page" "$BODY"; then + ok "the page claims nothing and sends the reader to a verifier" +else + bad "the page does not tell the reader to check the claim" +fi +fetch "/_ts/attestation.json" -X POST +if [ "$STATUS" != "200" ] && ! grep -qF '"payload"' "$BODY"; then + ok "another method is not answered with evidence ($STATUS)" +else + bad "a POST was answered with evidence" +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +if [ "$FAIL" -ne 0 ]; then + echo "Viceroy log:" >&2 + tail -40 "$WORK/viceroy.log" >&2 + exit 1 +fi From 106045611a68e53c25c2dfbfc2db0ffc544f5bc0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 12:18:43 +0100 Subject: [PATCH 07/25] The permissions inspector's lock file takes the two signing crates The inspector builds core from a lock file of its own, and core now uses ECDSA on P-256, so its resolution gains `ecdsa` and `rfc6979` at the versions the workspace lock holds. --- tools/permissions-inspector/wasm/Cargo.lock | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock index 1ea2997e59..8391023d81 100644 --- a/tools/permissions-inspector/wasm/Cargo.lock +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -510,6 +510,20 @@ dependencies = [ "dtoa", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -593,6 +607,7 @@ dependencies = [ "ff", "generic-array", "group", + "pkcs8", "rand_core", "sec1", "subtle", @@ -1295,8 +1310,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2", ] [[package]] @@ -1573,6 +1590,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1638,6 +1665,7 @@ dependencies = [ "base16ct", "der", "generic-array", + "pkcs8", "subtle", "zeroize", ] @@ -2061,6 +2089,7 @@ dependencies = [ "lol_html", "matchit", "mime", + "p256", "rand", "regex", "serde", From 96d8caf89e3ed60684100e61a75997113a50b9bc Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:17:40 +0100 Subject: [PATCH 08/25] A key schedule refuses a repeated key id or start A schedule with two lines sharing a key id or a start second now fails the build, so the signing key never depends on line order. An endpoint under a prefix a common framework serves its assets from is refused. The last-key warning is logged once for each process. The guide says GET and HEAD, says where a build leaves its private keys in clear, and says deployment or build for the running binary. Comments that restated the guide or narrated tests are cut. --- crates/trusted-server-adapter-axum/src/app.rs | 5 +- .../src/app.rs | 5 +- .../trusted-server-adapter-fastly/src/app.rs | 10 +- crates/trusted-server-adapter-spin/src/app.rs | 5 +- crates/trusted-server-core/build.rs | 4 - crates/trusted-server-core/build_time.rs | 6 +- crates/trusted-server-core/key_schedule.rs | 21 ++- crates/trusted-server-core/src/attestation.rs | 135 ++++++++++++++---- docs/guide/attestation.md | 61 +++++--- docs/guide/configuration.md | 12 +- 10 files changed, 177 insertions(+), 87 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 99b4e8353a..bebdb9e8ec 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -943,10 +943,7 @@ fn build_router(state: &Arc) -> RouterService { } } - // The attestation pages answer at the address the settings choose, so - // they are registered here rather than with the fixed routes. Reads reach - // the handler, and other methods reach the publisher's origin unless the - // deployment owns the prefix. + // Routes chosen by the settings, so not in the fixed table. let routes = prefix_routes(&state.settings); if !routes.is_empty() { let attestation = attestation_route_handler(Arc::clone(state)); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7de0f9e5c6..6eed776206 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -982,10 +982,7 @@ fn build_router(state: &Arc) -> RouterService { router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } - // The attestation pages answer at the address the settings choose, so - // they are registered here rather than with the fixed routes. Reads reach - // the handler, and other methods reach the publisher's origin unless the - // deployment owns the prefix. + // Routes chosen by the settings, so not in the fixed table. let routes = prefix_routes(&state.settings); if !routes.is_empty() { let attestation = make_handler(Arc::clone(&state), |s, _services, req| async move { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index bbc9acfd2a..168db3bb13 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1683,10 +1683,7 @@ impl TrustedServerApp { } } - // The attestation pages answer at the address the settings choose, so - // they are registered here rather than in the table. Reads reach the - // handler, and other methods reach the publisher's origin unless the - // deployment owns the prefix. + // Routes chosen by the settings, so not in the fixed table. let routes = prefix_routes(&state.settings); if !routes.is_empty() { let attestation = attestation_route_handler(Arc::clone(state)); @@ -2947,10 +2944,7 @@ mod tests { /// Every endpoint the settings accept builds a router whose attestation /// pages answer, because a second route at one address panics when the - /// router is built, and this adapter builds it on every request. The - /// candidates are every address the router answers beneath an underscore - /// prefix, as a page with a sample segment for one in braces, and further - /// addresses beneath the same prefixes. + /// router is built. #[test] fn every_endpoint_the_settings_accept_builds_a_router() { use trusted_server_core::attestation::{DEFAULT_ENDPOINT, validate_endpoint}; diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ec222620fa..5eaca22c20 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1209,10 +1209,7 @@ fn build_router(state: &Arc) -> RouterService { } } } - // The attestation pages answer at the address the settings choose, so - // they are registered here rather than with the fixed routes. Reads reach - // the handler, and other methods reach the publisher's origin unless the - // deployment owns the prefix. + // Routes chosen by the settings, so not in the fixed table. let routes = prefix_routes(&state.settings); if !routes.is_empty() { let s = Arc::clone(&state); diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index a3dbcab9c3..6d3cf695bb 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -52,10 +52,6 @@ fn write_attestation_keys() { /// Bakes the build time into the binary as `TRUSTED_SERVER_BUILT_AT`. /// -/// Some hosts give a WebAssembly guest no custom environment variable when it -/// runs, so the time a build was made has to be in the binary or it cannot be -/// reported at all. -/// /// `SOURCE_DATE_EPOCH` is honored where it is set, so a reproducible build /// stays reproducible rather than differing only by this string. fn write_build_time() { diff --git a/crates/trusted-server-core/build_time.rs b/crates/trusted-server-core/build_time.rs index 3e392ea802..661ce19ce4 100644 --- a/crates/trusted-server-core/build_time.rs +++ b/crates/trusted-server-core/build_time.rs @@ -6,10 +6,8 @@ /// Formats seconds since the Unix epoch as RFC 3339 in UTC. /// -/// Written out rather than taken from a date crate, because a build script -/// dependency is a dependency of every build of this crate and this is a dozen -/// lines of arithmetic. Days are civil days from the epoch, using the standard -/// days-to-civil algorithm, which is correct for any date this will see. +/// Days are civil days from the epoch, using the standard days-to-civil +/// algorithm, which is correct for any date this will see. pub(crate) fn rfc3339_utc(seconds: i64) -> String { let days = seconds.div_euclid(86_400); let rem = seconds.rem_euclid(86_400); diff --git a/crates/trusted-server-core/key_schedule.rs b/crates/trusted-server-core/key_schedule.rs index b5f411fb18..03c46cf46e 100644 --- a/crates/trusted-server-core/key_schedule.rs +++ b/crates/trusted-server-core/key_schedule.rs @@ -13,15 +13,17 @@ /// /// A key id and a key may hold only letters, digits and `-_.:+/=`. Each is /// written into Rust source as a string, so nothing a schedule holds can end -/// the string it is written in. +/// the string it is written in. No two lines share a key id or a start, so +/// one key is in force at any second and a key id names one key. /// /// # Errors /// -/// Returns the number of the first line of any other shape and what is wrong -/// with it. The line itself is never repeated, because it holds a private -/// key. +/// Returns the number of the first line of any other shape, or of the first +/// that repeats an earlier line's key id or start, and what is wrong with it. +/// The line itself is never repeated, because it holds a private key. pub(crate) fn constant_source(schedule: Option<&str>) -> Result { let mut source = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); + let mut seen: Vec<(usize, &str, i64)> = Vec::new(); for (index, line) in schedule.unwrap_or_default().lines().enumerate() { let line = line.trim_matches([' ', '\r']); if line.is_empty() { @@ -42,6 +44,17 @@ pub(crate) fn constant_source(schedule: Option<&str>) -> Result "line {number} should hold a key id and a key of letters, digits and -_.:+/= only" )); } + if let Some((earlier, _, _)) = seen.iter().find(|(_, id, _)| id == key_id) { + return Err(format!( + "line {number} should not repeat the key id of line {earlier}" + )); + } + if let Some((earlier, _, _)) = seen.iter().find(|(_, _, start)| *start == starts_at) { + return Err(format!( + "line {number} should not start at the same second as line {earlier}" + )); + } + seen.push((number, *key_id, starts_at)); source.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); } source.push_str("];"); diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 8e54969b21..be5254ec66 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -1,21 +1,16 @@ //! Signed evidence of who operates this deployment and which build it runs. //! //! The endpoint answers with evidence in the sense of RFC 9334, a description -//! of the build and the platform signed with the operator's key. A module on -//! a CDN cannot measure its own bytes, so the evidence proves who operates the -//! deployment, not what its code is. A verifier holding the operator's public -//! keys decides whether to trust it, and the page sends the reader there +//! of the build and the platform signed with the operator's key. A deployment +//! on a CDN cannot measure its own bytes, so the evidence proves who operates +//! the deployment, not what its code is. A verifier holding the operator's +//! public keys decides whether to trust it, and the page sends the reader there //! rather than claiming validity itself. //! -//! The signing keys are constants in the binary, compiled in from a schedule -//! the build supplies, each in force from its start until the next one starts. -//! No store or secret the settings can reach holds them, but whoever holds the -//! package holds every key its schedule carries, so a published build carries -//! only the key in force when it was built and the ones after it. A build -//! given no schedule signs nothing. The signature is ECDSA P-256 with SHA-256, -//! deterministic per RFC 6979, over the configured context, one line feed, -//! then the evidence bytes. The context stops a signature made here from -//! passing as one the same key made for any other purpose. +//! The signature is ECDSA P-256 with SHA-256, deterministic per RFC 6979, over +//! the configured context, one line feed, then the evidence bytes. + +use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; @@ -51,11 +46,6 @@ pub const DEFAULT_ENDPOINT: &str = "/_ts/attestation"; /// The addresses beneath an underscore prefix that a deployment answers /// itself whatever the settings say, so an endpoint may take none of them. A /// segment in braces stands for any one segment, as it does in the router. -/// -/// An endpoint at one of these would register a second route at the same -/// address, which stops the router being built. Each adapter's tests check -/// that its router registers nothing beneath an underscore prefix that is -/// missing here. pub const RESERVED_PATHS: &[&str] = &[ CONFIG_PAGE_PATH, CONFIG_JSON_PATH, @@ -81,6 +71,20 @@ pub const RESERVED_PATHS: &[&str] = &[ "/_ts/debug/sandbox", ]; +/// Prefixes beneath which common web frameworks serve their assets from the +/// origin. An endpoint under one would own the prefix and answer `404` for +/// every asset there, so an endpoint may take none of them. +pub const ORIGIN_ASSET_PREFIXES: &[&str] = &[ + "_app", + "_astro", + "_content", + "_expo", + "_framework", + "_next", + "_nuxt", + "_vercel", +]; + /// The signature algorithm, as JOSE names it. const ALGORITHM: &str = "ES256"; @@ -417,11 +421,12 @@ fn answer( } /// The absolute address of the JSON form of the page being served, from the -/// scheme the request arrived over and the host it was addressed to. +/// request's scheme and the host it was addressed to. /// -/// The host is read the way [`request_host`] reads the one the evidence signs, -/// so the link and the signature name the same host. Forwarded host headers -/// are never read, because a caller can set them. +/// The scheme follows a `Forwarded` or `X-Forwarded-Proto` header when one is +/// sent. The host is read the way [`request_host`] reads the one the evidence +/// signs, so the link and the signature name the same host, and a forwarded +/// host header is never read. fn json_address(req: &Request) -> String { let info = crate::http_util::RequestInfo::from_request(req, &crate::platform::ClientInfo::default()); @@ -434,8 +439,17 @@ fn json_address(req: &Request) -> String { format!("{}://{authority}{}.json", info.scheme, req.uri().path()) } -/// The key in force at `now`, being the latest to have started. Warns when -/// no later key follows it, because the build must then be refreshed. +/// Set once the process has warned that it signs with the last key built in. +static LAST_KEY_WARNED: AtomicBool = AtomicBool::new(false); + +/// Whether this is the first time `warned` is asked, setting it. +fn first_time(warned: &AtomicBool) -> bool { + !warned.swap(true, Ordering::Relaxed) +} + +/// The key in force at `now`, being the latest to have started. Warns once +/// for each process when no later key follows it, because the build must +/// then be refreshed. fn key_in_force<'a>(schedule: &'a [ScheduledKey<'a>], now: u64) -> Option<&'a ScheduledKey<'a>> { let now = i64::try_from(now).ok()?; let current = schedule @@ -445,6 +459,7 @@ fn key_in_force<'a>(schedule: &'a [ScheduledKey<'a>], now: u64) -> Option<&'a Sc if !schedule .iter() .any(|(_, starts_at, _)| *starts_at > current.1) + && first_time(&LAST_KEY_WARNED) { log::warn!( "attestation key `{}` is the last one built in, so the build must be refreshed", @@ -661,12 +676,15 @@ fn escape_attribute(value: &str) -> String { /// case letters, digits, `_` and `-`, the first beginning with an underscore /// so the pages sit apart from the publisher's own, and without `.json`, /// which names the JSON form. Neither the page nor the JSON form may be one -/// of the [`RESERVED_PATHS`]. +/// of the [`RESERVED_PATHS`], and the prefix may not be one of the +/// [`ORIGIN_ASSET_PREFIXES`]. /// /// # Errors /// -/// Returns `invalid_endpoint` for any other shape, and `reserved_endpoint`, -/// naming the address, when the page or the JSON form is reserved. +/// Returns `invalid_endpoint` for any other shape, `reserved_endpoint`, +/// naming the address, when the page or the JSON form is reserved, and +/// `origin_prefix`, naming the prefix, when the prefix is one a framework +/// serves its assets from. pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { let segment_ok = |segment: &str| { !segment.is_empty() @@ -692,6 +710,13 @@ pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { error.message = Some(format!("{reserved} is answered by the deployment itself").into()); return Err(error); } + let prefix = prefix_of(value); + if ORIGIN_ASSET_PREFIXES.contains(&prefix) { + let mut error = ValidationError::new("origin_prefix"); + error.message = + Some(format!("/{prefix} is where a web framework serves its assets").into()); + return Err(error); + } Ok(()) } @@ -921,6 +946,33 @@ mod tests { } } + /// Two lines sharing a key id or a start stop the build, so the key in + /// force never depends on the order of the lines and a key id names one + /// key. + #[test] + fn a_schedule_repeating_a_key_id_or_a_start_is_refused() { + let first = key_text(&FIRST); + let second = key_text(&SECOND); + for (case, schedule, refusal) in [ + ( + "a repeated key id", + format!("example-first\t1000\t{first}\nexample-first\t2000\t{second}\n"), + "line 2 should not repeat the key id of line 1", + ), + ( + "a repeated start", + format!("example-first\t1000\t{first}\n\nexample-second\t1000\t{second}\n"), + "line 3 should not start at the same second as line 1", + ), + ] { + assert_eq!( + key_schedule::constant_source(Some(&schedule)), + Err(refusal.to_owned()), + "{case} should be refused" + ); + } + } + /// A verifier with only the public key, in the `SubjectPublicKeyInfo` DER /// form a key schedule publishes, accepts the signature over exactly the /// context, one line feed, then the payload. Without the context, without @@ -990,6 +1042,17 @@ mod tests { } } + /// The warning that the last key built in is signing is given once for + /// each process, not on every request. + #[test] + fn the_last_key_warning_is_given_once() { + let warned = AtomicBool::new(false); + + assert!(first_time(&warned), "should warn the first time"); + assert!(!first_time(&warned), "should not warn again"); + assert!(!first_time(&warned), "should not warn on any later request"); + } + /// Nothing is signed before the first key starts, from an empty schedule, /// with a key that is not a P-256 private key, or in a build given no /// schedule, which is every build its builder names no schedule for. @@ -1254,6 +1317,24 @@ mod tests { } } + /// An endpoint owns its prefix and answers `404` for every other address + /// beneath it, so a prefix a framework serves its assets from is refused, + /// naming it. + #[test] + fn an_endpoint_under_a_framework_asset_prefix_is_refused_naming_it() { + for prefix in ORIGIN_ASSET_PREFIXES { + let endpoint = format!("/{prefix}/attestation"); + let error = validate_endpoint(&endpoint).expect_err("should refuse the endpoint"); + + assert_eq!(error.code, "origin_prefix", "{endpoint} should be refused"); + assert_eq!( + error.message.as_deref(), + Some(format!("/{prefix} is where a web framework serves its assets").as_str()), + "{endpoint} should name its prefix" + ); + } + } + #[test] fn the_section_loads_from_a_document_and_refuses_unknown_fields() { let base = crate_test_settings_str(); diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index c664003a37..59b673e98a 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -50,13 +50,13 @@ and the build that answered, the time, and the nonce when one was sent. The evidence does not prove these four things. -1. **What code is running.** A module on a CDN cannot read its own code. - Fastly, for example, tells a module its service, version, staging flag and - location through +1. **What code is running.** A deployment on a CDN cannot read its own code. + Fastly, for example, tells a deployment its service, version, staging flag + and location through [environment variables](https://www.fastly.com/documentation/reference/compute/ecp-env/), - and none of them identifies the module's bytes. Whatever a module says about - itself, a changed module can say too, so the `build` fields are the - operator's own report of what it deployed. + and none of them identifies the build's bytes. Whatever a build says about + itself, a changed build can say too, so the `build` fields are the operator's + own report of what it deployed. 2. **A hardware measurement.** Attestation in trusted hardware signs a hash of the running code that the hardware itself took. Nothing here takes such a measurement, and the trust rests on the operator's key alone. @@ -110,11 +110,19 @@ prefix. GET and HEAD reach the pages, the prefix itself lists them, and any other address or method beneath the prefix answers 404 from the deployment rather than reaching the publisher's origin. -Under the default `_ts` prefix, which other routes share, only GET on the two -attestation addresses is taken. Every other method on those addresses reaches -the publisher's origin. +Choose a prefix the origin serves nothing beneath. An endpoint at +`/_next/attestation` would answer 404 for every asset a Next.js site serves +from `/_next`. The settings refuse an endpoint under a prefix a common +framework serves its assets from, being `_app`, `_astro`, `_content`, `_expo`, +`_framework`, `_next`, `_nuxt` and `_vercel`, and name the prefix. Any other +prefix the origin serves is the operator's to avoid. + +Under the default `_ts` prefix, which other routes share, only GET and HEAD on +the two attestation addresses are taken. Every other method on those addresses +reaches the publisher's origin. The tests are `an_own_prefix_is_owned_and_the_default_is_shared`, +`an_endpoint_under_a_framework_asset_prefix_is_refused_naming_it`, `the_prefix_lists_its_pages` and `any_other_address_beneath_the_prefix_is_not_found` in core, `an_owned_prefix_is_answered_here_and_never_by_the_origin` in the Fastly adapter @@ -159,12 +167,12 @@ request and reads no store. ### `[attestation]` {#attestation-section} -| Field | Type | Rule | Meaning | -| ------------ | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | -| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | -| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | -| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | +| Field | Type | Rule | Meaning | +| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not under a prefix a framework serves its assets from, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | +| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | +| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | +| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | The section refuses any other field, so a document naming a signing key is refused, because keys are compiled into the build and never configured. A @@ -200,14 +208,17 @@ schedule, which holds one key per line in three fields separated by tabs. DER encoding. Key ids and keys may hold only letters, digits and `-_.:+/=`, and a line of any -other shape fails the build. +other shape fails the build. No two lines may share a key id or a start, so a +key id names one key and the key in force never depends on the order of the +lines. The test is `a_schedule_repeating_a_key_id_or_a_start_is_refused`. The key in force is the latest to have started, and it stays in force until the next one starts. While the key in force is the last one in the build, the deployment logs the warning ``attestation key `` is the last one built in, so the build must be refreshed`` -and goes on signing with that key. The test is -`the_key_in_force_is_the_latest_to_have_started`. +once for each process and goes on signing with that key. The tests are +`the_key_in_force_is_the_latest_to_have_started` and +`the_last_key_warning_is_given_once`. Without the build input, or with an empty file, the schedule is empty and the endpoint answers 503. That is every build its builder names no schedule for, @@ -464,10 +475,16 @@ the signature. The service check needs the operator's list of its services. A key compiled into a binary is readable by whoever holds the binary. The protection is that the binary never leaves the operator's hands, so the build -that carries keys must run only where the operator controls it, and the built -package must go only to the operator's own platform account. Anyone can copy the -page, its wording and its layout, and nobody without the key can make the -signature. +that carries keys must run only where the operator controls it, and the binary +must go only to the operator's own platform account. Anyone can copy the page, +its wording and its layout, and nobody without the key can make the signature. + +A build also leaves the private keys in clear in +`target/**/out/attestation_keys.rs`, the file the build script writes the +schedule into. The file survives the build, and a CI job that caches its target +directory keeps it in that cache. A build that carries keys must therefore use a +target directory and a cache that only the operator can read, or delete the +file once the binary is made. The context keeps signatures apart by purpose. A verifier puts the context in front of the payload before checking, so a signature the same key made over any diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5a9559ce34..5c6c701578 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -717,12 +717,12 @@ keys and how a relying party checks it. ### `[attestation]` -| Field | Type | Required | Description | -| ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `endpoint` | String | No | Where the page answers, with the JSON form at the same path plus `.json`. Default `/_ts/attestation`. An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not ending `.json` and not an address the deployment already answers | -| `operator` | String | Yes | Who the evidence says operates the deployment, 1 to 64 characters | -| `context` | String | Yes | The text signed ahead of the evidence, 1 to 64 characters with no control characters. A verifier must use exactly the same text | -| `verify_url` | String | Yes | An `https` address with a host, where the page sends a reader to check the claim | +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint` | String | No | Where the page answers, with the JSON form at the same path plus `.json`. Default `/_ts/attestation`. An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not ending `.json`, not an address the deployment already answers and not under a prefix a framework serves its assets from, such as `_next` | +| `operator` | String | Yes | Who the evidence says operates the deployment, 1 to 64 characters | +| `context` | String | Yes | The text signed ahead of the evidence, 1 to 64 characters with no control characters. A verifier must use exactly the same text | +| `verify_url` | String | Yes | An `https` address with a host, where the page sends a reader to check the claim | The section refuses any other field, so a configuration naming a signing key is refused. Keys are compiled into the build from the file the build input From a656298ecdb4c4b7554632f5549ab0cc5b562175 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 09:53:20 +0100 Subject: [PATCH 09/25] The attestation evidence names the tag the build was made from A builder that names the release tag, as TRUSTED_SERVER_BUILD_TAG, has it reported under build.tag beside the commit and the run, or `unknown` when it names none. A check that reads a deployment after a release compares what is running with the tag that was shipped, without a header on any page. --- CHANGELOG.md | 2 +- crates/trusted-server-core/build.rs | 11 ++++++++--- crates/trusted-server-core/src/attestation.rs | 3 +++ crates/trusted-server-core/src/build_info.rs | 12 ++++++++++-- docs/guide/attestation.md | 15 ++++++++++----- scripts/attestation-local-test.sh | 2 ++ 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a62ce7cf1a..8cf71f25b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. +- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 6d3cf695bb..09983a891b 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -71,10 +71,15 @@ fn write_build_time() { ); } -/// Bakes in the commit and the build run the builder names, as -/// `TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN`, or `unknown`. +/// Bakes in the commit, the build run and the tag the builder names, as +/// `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and +/// `TRUSTED_SERVER_BUILD_TAG`, or `unknown`. fn write_build_identity() { - for name in ["TRUSTED_SERVER_COMMIT", "TRUSTED_SERVER_BUILD_RUN"] { + for name in [ + "TRUSTED_SERVER_COMMIT", + "TRUSTED_SERVER_BUILD_RUN", + "TRUSTED_SERVER_BUILD_TAG", + ] { println!("cargo:rerun-if-env-changed={name}"); let value = env::var(name) .ok() diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index be5254ec66..eda7706184 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -165,6 +165,7 @@ struct Build { built_at: &'static str, commit: &'static str, run: &'static str, + tag: &'static str, } #[derive(Debug, Serialize)] @@ -373,6 +374,7 @@ fn answer( built_at: crate::build_info::BUILT_AT, commit: crate::build_info::COMMIT, run: crate::build_info::BUILD_RUN, + tag: crate::build_info::BUILD_TAG, }, nonce: nonce.as_deref(), issued_at: rfc3339(now), @@ -1967,6 +1969,7 @@ mod tests { let evidence = &envelope["evidence"]; assert_eq!(evidence["build"]["commit"], crate::build_info::COMMIT); assert_eq!(evidence["build"]["run"], crate::build_info::BUILD_RUN); + assert_eq!(evidence["build"]["tag"], crate::build_info::BUILD_TAG); assert_eq!(evidence["build"]["builtAt"], crate::build_info::BUILT_AT); assert_eq!(evidence["platform"]["name"], "example-platform"); assert_eq!(evidence["platform"]["serviceId"], "svc-1"); diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs index e72a5b3707..38466d2904 100644 --- a/crates/trusted-server-core/src/build_info.rs +++ b/crates/trusted-server-core/src/build_info.rs @@ -9,6 +9,7 @@ //! | [`BUILT_AT`] | `SOURCE_DATE_EPOCH`, in seconds | The time of the build | //! | [`COMMIT`] | `TRUSTED_SERVER_COMMIT` | `unknown` | //! | [`BUILD_RUN`] | `TRUSTED_SERVER_BUILD_RUN` | `unknown` | +//! | [`BUILD_TAG`] | `TRUSTED_SERVER_BUILD_TAG` | `unknown` | //! //! Each is the builder's own report. Nothing here measures the binary. @@ -24,6 +25,9 @@ pub const COMMIT: &str = env!("TRUSTED_SERVER_COMMIT"); /// The build run that made this binary, or `unknown`. pub const BUILD_RUN: &str = env!("TRUSTED_SERVER_BUILD_RUN"); +/// The tag this build was made from, as the builder names it, or `unknown`. +pub const BUILD_TAG: &str = env!("TRUSTED_SERVER_BUILD_TAG"); + /// The build script's date arithmetic, compiled here so a test can call it. #[cfg(test)] #[path = "../build_time.rs"] @@ -45,8 +49,12 @@ mod tests { } #[test] - fn the_commit_and_the_run_are_never_empty() { - for (name, value) in [("commit", COMMIT), ("build run", BUILD_RUN)] { + fn the_commit_the_run_and_the_tag_are_never_empty() { + for (name, value) in [ + ("commit", COMMIT), + ("build run", BUILD_RUN), + ("build tag", BUILD_TAG), + ] { assert!( !value.is_empty() && !value.chars().any(char::is_control), "the {name} should be the builder's value or `unknown`, got {value:?}" diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index 59b673e98a..54ce27ce5b 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -224,10 +224,13 @@ Without the build input, or with an empty file, the schedule is empty and the endpoint answers 503. That is every build its builder names no schedule for, which the test `no_usable_key_in_force_answers_service_unavailable` pins. -The build fields come from three more build inputs. -`TRUSTED_SERVER_COMMIT` and `TRUSTED_SERVER_BUILD_RUN` become `build.commit` -and `build.run`, or `unknown` when absent, and `SOURCE_DATE_EPOCH`, when set, -gives `build.builtAt` in place of the time of the build. +The build fields come from four more build inputs. `TRUSTED_SERVER_COMMIT`, +`TRUSTED_SERVER_BUILD_RUN` and `TRUSTED_SERVER_BUILD_TAG` become +`build.commit`, `build.run` and `build.tag`, or `unknown` when absent, and +`SOURCE_DATE_EPOCH`, when set, gives `build.builtAt` in place of the time of +the build. The tag is the release the build was made from, so a check that +reads a deployment after a release compares what is running with the tag +that was shipped. ## Formats @@ -235,7 +238,7 @@ gives `build.builtAt` in place of the time of the build. This envelope shows the shape of an answer for a publisher at `publisher.example`, asked for with the nonce `Docs-Example_0929`. The -payload, the signature, the service id, the commit and the run are +payload, the signature, the service id, the commit, the run and the tag are placeholders. ```json @@ -245,6 +248,7 @@ placeholders. "builtAt": "2026-09-29T09:18:54Z", "commit": "", "run": "", + "tag": "", "version": "0.1.0" }, "host": "publisher.example", @@ -301,6 +305,7 @@ The payload holds these fields in this order. | `build.version` | String | The crate version | | `build.builtAt` | String | RFC 3339, from `SOURCE_DATE_EPOCH` when set, otherwise the time of the build | | `build.commit` | String | `TRUSTED_SERVER_COMMIT`, or `unknown` | +| `build.tag` | String | `TRUSTED_SERVER_BUILD_TAG`, the release tag the build was made from, or `unknown` | | `build.run` | String | `TRUSTED_SERVER_BUILD_RUN`, or `unknown` | | `nonce` | String | The caller's nonce, present only when one was sent | | `issuedAt` | String | The platform's clock, RFC 3339 in UTC to the second | diff --git a/scripts/attestation-local-test.sh b/scripts/attestation-local-test.sh index 10b73117eb..3f29976dfe 100755 --- a/scripts/attestation-local-test.sh +++ b/scripts/attestation-local-test.sh @@ -104,6 +104,7 @@ info "Building (debug wasm with the schedule compiled in, and the ts CLI)" TRUSTED_SERVER_ATTESTATION_KEYS="$WORK/schedule.tsv" \ TRUSTED_SERVER_COMMIT="harness-commit" \ TRUSTED_SERVER_BUILD_RUN="harness-run" \ + TRUSTED_SERVER_BUILD_TAG="harness-tag" \ SOURCE_DATE_EPOCH="$BUILD_EPOCH" \ cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 >/dev/null # Copied aside, because the next build that is given no schedule writes the @@ -276,6 +277,7 @@ check "the operator is the settings' own" "$(field operator)" "Harness Operator" check "the platform names itself" "$(field platform.name)" "fastly" check "the build reports the commit it was given" "$(field build.commit)" "harness-commit" check "the build reports the run it was given" "$(field build.run)" "harness-run" +check "the build reports the tag it was given" "$(field build.tag)" "harness-tag" check "the build reports the time it was given" "$(field build.builtAt)" "$BUILD_TIME" ISSUED="$(field issuedAt)" AGE="$("$PYTHON" -c 'import datetime, sys; t = datetime.datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc); print(int(abs((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) < 300))' "$ISSUED" 2>/dev/null || echo 0)" From 59e09b6cd9632b8198deaa92b38ed7d5b841e06e Mon Sep 17 00:00:00 2001 From: James Rogers Date: Tue, 13 Oct 2026 16:44:44 +0100 Subject: [PATCH 10/25] Allow the host clock in the build script's build time The wasm clock lint reaches core's build script, which runs on the host. --- crates/trusted-server-core/build.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 00a6df76b9..8ceaae0fd4 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -199,6 +199,10 @@ fn write_attestation_keys() { /// /// `SOURCE_DATE_EPOCH` is honored where it is set, so a reproducible build /// stays reproducible rather than differing only by this string. +#[allow( + clippy::disallowed_methods, + reason = "a build script runs on the host, where the std clock is sound" +)] fn write_build_time() { println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); let seconds = env::var("SOURCE_DATE_EPOCH") From 7ff40de6cbe59aaa89a298be21013ea66da55532 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:55:35 +0100 Subject: [PATCH 11/25] Sign no attestation evidence when the clock cannot be read The handler read the time with current_timestamp, which gives second 0 when the clock fails, so a schedule key in force from the epoch signed evidence issued at 1970-01-01T00:00:00Z. The handler now reads the clock with checked_current_timestamp and answers 503 without signing when it fails. a_clock_that_cannot_be_read_signs_nothing covers the page and the JSON address. --- crates/trusted-server-core/src/attestation.rs | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index eda7706184..1647c40c19 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -334,16 +334,19 @@ pub fn handle_attestation( platform, req, ATTESTATION_KEYS, - crate::ec::current_timestamp(), + crate::ec::checked_current_timestamp(), ) } +/// Answers from `schedule` at `now`, which is `None` when the clock cannot be +/// read. Nothing is signed then, because the evidence would carry a time that +/// never happened. fn answer( settings: &Settings, platform: &PlatformIdentity, req: &Request, schedule: &[ScheduledKey<'_>], - now: u64, + now: Option, ) -> Response { let Some(config) = settings.attestation.as_ref() else { return error_response( @@ -355,6 +358,12 @@ fn answer( Ok(nonce) => nonce, Err(message) => return error_response(StatusCode::BAD_REQUEST, message), }; + let Some(now) = now else { + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "The clock could not be read, so no evidence can be signed.", + ); + }; let Some(&(key_id, _, private_key)) = key_in_force(schedule, now) else { log::error!("attestation has no signing key in force at {now}"); return error_response( @@ -823,7 +832,7 @@ mod tests { ("example-first", FIRST_STARTS, first.as_str()), ("example-second", SECOND_STARTS, second.as_str()), ]; - answer(settings, &platform(), &request(uri), &schedule, now) + answer(settings, &platform(), &request(uri), &schedule, Some(now)) } fn body_bytes(response: Response) -> Vec { @@ -1103,7 +1112,7 @@ mod tests { &platform(), &request("/_ex/attestation.json"), &schedule, - now, + Some(now), ); assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{case}"); let body = json_body(response); @@ -1116,6 +1125,30 @@ mod tests { } } + /// A clock that cannot be read signs nothing, even with a key in force + /// from the epoch, because the evidence would carry a time that never + /// happened. + #[test] + fn a_clock_that_cannot_be_read_signs_nothing() { + let first = key_text(&FIRST); + let schedule = [("example-first", 0, first.as_str())]; + for uri in ["/_ex/attestation.json", "/_ex/attestation"] { + let response = answer( + &settings_with_attestation(), + &platform(), + &request(uri), + &schedule, + None, + ); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}"); + assert_eq!( + json_body(response), + json!({ "error": "The clock could not be read, so no evidence can be signed." }), + "{uri} should sign nothing" + ); + } + } + /// The decoded evidence shown to people is exactly the payload signed. #[test] fn the_evidence_shown_is_the_payload_signed() { @@ -1184,7 +1217,7 @@ mod tests { &platform(), &request("/_ex/attestation.json"), &schedule, - 1_500, + Some(1_500), ); assert_eq!(response.status(), StatusCode::OK); } @@ -1773,7 +1806,7 @@ mod tests { &platform(), &req, &schedule, - 1_500, + Some(1_500), )); let (payload, _) = payload_and_signature(&envelope); serde_json::from_slice(&payload).expect("JSON payload") @@ -1985,7 +2018,7 @@ mod tests { &staged, &request("/_ex/attestation.json"), &schedule, - 1_500, + Some(1_500), )); assert_eq!(from_staging["evidence"]["platform"]["staging"], true); } From e31dc944b214ecd3f813d3e6a23031281aa75a63 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:09:54 +0100 Subject: [PATCH 12/25] Stop the build when an attestation key cannot sign The build checked only the shape and characters of each key in the schedule, so a key that is not a P-256 private key passed the build and answered 503 from the second it came into force, which for a later key could be long after the binary went live. The reading of a key moves into signing_key.rs, which the build script and src/attestation.rs both include, and constant_source reads every key with it, so the build stops at the first key that cannot sign, naming its line and key id but never the key. The endpoint keeps its 503 for a key that cannot sign when it runs. a_schedule_key_that_cannot_sign_is_refused_without_showing_it covers a key that is not base64 and one that is not a P-256 private key, both starting as late as a schedule allows. --- crates/trusted-server-core/Cargo.toml | 2 + crates/trusted-server-core/build.rs | 1 + crates/trusted-server-core/key_schedule.rs | 17 ++++-- crates/trusted-server-core/signing_key.rs | 22 ++++++++ crates/trusted-server-core/src/attestation.rs | 53 +++++++++++++++---- 5 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 crates/trusted-server-core/signing_key.rs diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e68b1eca70..85272a5c91 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -89,8 +89,10 @@ tempfile = { workspace = true } [build-dependencies] # tests/template_build_digest.rs includes build.rs, so its dependencies must also # be available through normal or dev-dependencies. +base64 = { workspace = true } edgezero-core = { workspace = true } hex = { workspace = true } +p256 = { workspace = true } sha2 = { workspace = true } [[bench]] diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 8ceaae0fd4..878b8497ce 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -105,6 +105,7 @@ fn resolve_from_local_git() -> Option { mod build_time; mod key_schedule; +mod signing_key; /// Source file that must stay out of the generated list. /// diff --git a/crates/trusted-server-core/key_schedule.rs b/crates/trusted-server-core/key_schedule.rs index 03c46cf46e..de91a530ae 100644 --- a/crates/trusted-server-core/key_schedule.rs +++ b/crates/trusted-server-core/key_schedule.rs @@ -2,7 +2,8 @@ //! the build script writes. //! //! A module of the build script and of the tests in `src/attestation.rs`, -//! which check what a schedule may hold. +//! which check what a schedule may hold. Both also hold `signing_key.rs` as +//! `signing_key`, which reads each key the way the signer does. /// The source of the `ATTESTATION_KEYS` constant for the text of a key /// schedule, or of an empty schedule for `None`. @@ -14,13 +15,14 @@ /// A key id and a key may hold only letters, digits and `-_.:+/=`. Each is /// written into Rust source as a string, so nothing a schedule holds can end /// the string it is written in. No two lines share a key id or a start, so -/// one key is in force at any second and a key id names one key. +/// one key is in force at any second and a key id names one key. Each key +/// must be one the signer can read, however late it starts. /// /// # Errors /// -/// Returns the number of the first line of any other shape, or of the first -/// that repeats an earlier line's key id or start, and what is wrong with it. -/// The line itself is never repeated, because it holds a private key. +/// Returns the number of the first line of any other shape, of the first +/// that repeats an earlier line's key id or start, or of the first whose key +/// cannot sign, and what is wrong with it. The key itself is never repeated. pub(crate) fn constant_source(schedule: Option<&str>) -> Result { let mut source = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); let mut seen: Vec<(usize, &str, i64)> = Vec::new(); @@ -54,6 +56,11 @@ pub(crate) fn constant_source(schedule: Option<&str>) -> Result "line {number} should not start at the same second as line {earlier}" )); } + if let Err(reason) = super::signing_key::read(private_key) { + return Err(format!( + "line {number} holds key `{key_id}`, which cannot sign, as {reason}" + )); + } seen.push((number, *key_id, starts_at)); source.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); } diff --git a/crates/trusted-server-core/signing_key.rs b/crates/trusted-server-core/signing_key.rs new file mode 100644 index 0000000000..fd8686bc0f --- /dev/null +++ b/crates/trusted-server-core/signing_key.rs @@ -0,0 +1,22 @@ +//! The reading of an attestation signing key. +//! +//! A module of the build script, which refuses a schedule key that cannot +//! sign, and of `src/attestation.rs`, which signs with it, so a key the build +//! accepts is one the signer can read. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use p256::ecdsa::SigningKey; +use p256::pkcs8::DecodePrivateKey as _; + +/// Reads a key held as base64 of its SEC1 or PKCS #8 DER encoding, saying +/// why when it cannot sign and never repeating the key. +pub(crate) fn read(key_text: &str) -> Result { + let der = STANDARD + .decode(key_text.trim()) + .map_err(|_| "the key is not base64")?; + let secret = p256::SecretKey::from_sec1_der(&der) + .or_else(|_| p256::SecretKey::from_pkcs8_der(&der)) + .map_err(|_| "the key is not a P-256 private key in SEC1 or PKCS #8 DER")?; + Ok(SigningKey::from(secret)) +} diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 1647c40c19..f7cfc9ab1a 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -13,12 +13,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use base64::Engine as _; -use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use p256::ecdsa::Signature; use p256::ecdsa::signature::Signer as _; -use p256::ecdsa::{Signature, SigningKey}; -use p256::pkcs8::DecodePrivateKey as _; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use validator::{Validate, ValidationError}; @@ -33,6 +32,11 @@ use crate::settings::Settings; // `ATTESTATION_KEYS`, written by build.rs from the schedule the build names. include!(concat!(env!("OUT_DIR"), "/attestation_keys.rs")); +/// The reading of a signing key, shared with the build script, which refuses +/// a schedule key that cannot sign. +#[path = "../signing_key.rs"] +mod signing_key; + /// The build script's reading of a key schedule, compiled here so a test can /// call it. #[cfg(test)] @@ -494,13 +498,7 @@ pub fn signed_bytes(context: &str, payload: &[u8]) -> Vec { /// Signs `payload` under `context` with a key held as base64 DER text, /// returning the raw 64 byte `r` and `s` pair. fn sign(context: &str, payload: &[u8], key_text: &str) -> Result, &'static str> { - let der = STANDARD - .decode(key_text.trim()) - .map_err(|_| "the key is not base64")?; - let secret = p256::SecretKey::from_sec1_der(&der) - .or_else(|_| p256::SecretKey::from_pkcs8_der(&der)) - .map_err(|_| "the key is not a P-256 private key in SEC1 or PKCS #8 DER")?; - let signature: Signature = SigningKey::from(secret).sign(&signed_bytes(context, payload)); + let signature: Signature = signing_key::read(key_text)?.sign(&signed_bytes(context, payload)); Ok(signature.to_bytes().to_vec()) } @@ -773,6 +771,7 @@ fn validate_https_url(value: &str) -> Result<(), ValidationError> { #[cfg(test)] mod tests { use super::*; + use base64::engine::general_purpose::STANDARD; use p256::ecdsa::VerifyingKey; use p256::ecdsa::signature::Verifier as _; use p256::pkcs8::{DecodePublicKey as _, EncodePublicKey as _}; @@ -984,6 +983,40 @@ mod tests { } } + /// A key that cannot sign stops the build however late it starts, so no + /// built binary carries one. The message names the key's line and id and + /// never the key. + #[test] + fn a_schedule_key_that_cannot_sign_is_refused_without_showing_it() { + let first = key_text(&FIRST); + let latest = i64::MAX; + let ed25519_seed = STANDARD.encode([0x33u8; 32]); + for (case, key, reason) in [ + ( + "a key that is not base64", + "not-base64-at-all", + "the key is not base64", + ), + ( + "a key that is not a P-256 private key", + ed25519_seed.as_str(), + "the key is not a P-256 private key in SEC1 or PKCS #8 DER", + ), + ] { + let schedule = + format!("example-first\t1000\t{first}\nexample-later\t{latest}\t{key}\n"); + let refused = key_schedule::constant_source(Some(&schedule)) + .expect_err("should refuse the schedule"); + + assert_eq!( + refused, + format!("line 2 holds key `example-later`, which cannot sign, as {reason}"), + "{case} should be refused" + ); + assert!(!refused.contains(key), "{case} should never show the key"); + } + } + /// A verifier with only the public key, in the `SubjectPublicKeyInfo` DER /// form a key schedule publishes, accepts the signature over exactly the /// context, one line feed, then the payload. Without the context, without From 74dcbdeee5be726f14618831094149cfdca8289f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:56:41 +0100 Subject: [PATCH 13/25] Answer the attestation pages on Cloudflare without the request preparers Cloudflare registered the attestation handler through make_handler, which runs every registered preparer first and answers a preparer's error in place of the page, so on Cloudflare alone a preparer could change the host or nonce that is signed, or refuse the request. Axum, Spin and Fastly answer the pages without the preparers. Cloudflare now does the same through its own attestation_route_handler. attestation_routes_skip_the_request_preparers registers a preparer that refuses every request, shows that it refuses a request for the publisher's origin, and checks that the list at the prefix, the JSON address and the redirect from the default address answer as they do without it. --- .../src/app.rs | 25 +++-- .../tests/routes.rs | 98 +++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 6eed776206..0b2e3c8338 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -445,6 +445,23 @@ where } } +/// Answers the attestation pages without the request preparers, so no +/// preparer can change or refuse the request whose host and nonce are signed. +fn attestation_route_handler( + state: Arc, +) -> impl Fn(RequestContext) -> BoxedHandlerFuture + Clone + 'static { + move |ctx: RequestContext| { + let state = Arc::clone(&state); + Box::pin(async move { + Ok(handle_prefix( + &state.settings, + &PlatformIdentity::named("cloudflare"), + &ctx.into_request(), + )) + }) + } +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -985,13 +1002,7 @@ fn build_router(state: &Arc) -> RouterService { // Routes chosen by the settings, so not in the fixed table. let routes = prefix_routes(&state.settings); if !routes.is_empty() { - let attestation = make_handler(Arc::clone(&state), |s, _services, req| async move { - Ok(handle_prefix( - &s.settings, - &PlatformIdentity::named("cloudflare"), - &req, - )) - }); + let attestation = attestation_route_handler(Arc::clone(&state)); for route in &routes { for method in publisher_fallback_methods() { if matches!(method, Method::GET | Method::HEAD) || route.every_method { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index ce95980e88..65e912710e 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -936,3 +936,101 @@ fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { } } } + +/// The attestation pages are answered without the request preparers, so no +/// preparer can change or refuse the request whose host and nonce are +/// signed. The preparer here refuses every request, and the publisher +/// fallback shows that it runs. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attestation_routes_skip_the_request_preparers() { + use error_stack::Report; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; + + fn refuse_every_request( + _settings: &Settings, + _request: &mut Request, + ) -> Result<(), Report> { + Err(Report::new(TrustedServerError::Forbidden { + message: "refused by the route test's preparer".to_owned(), + })) + } + + fn never_registers( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + + fn selects_nothing(_settings: &Settings) -> Result> { + Ok(false) + } + + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "hmac" + + [ec.hmac] + passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true + + [attestation] + endpoint = "/_ex/attestation" + operator = "Example Operator" + context = "example-attestation:v1" + verify_url = "https://verifier.example/verify?host=test-publisher.example.com" + "#, + ) + .expect("should parse route test settings with an attestation section"); + let refusing = IntegrationBuilder::new( + "refusing-preparer", + "routes-test", + never_registers, + selects_nothing, + ) + .with_module_name("testing.refusing-preparer") + .with_request_preparer(refuse_every_request); + let router = TrustedServerApp::routes_with_registrations(settings, &[refusing]) + .expect("should build router with the refusing preparer"); + let get = |uri: &str| { + request_builder() + .method("GET") + .uri(uri) + .body(edgezero_core::body::Body::empty()) + .expect("should build request") + }; + + let page = route(router.clone(), get("/article")).await; + assert_eq!( + page.status(), + 403, + "the preparer should refuse a request for the publisher's origin" + ); + + for (uri, status) in [ + ("/_ex", 200), + ("/_ex/attestation.json", 503), + ("/_ts/attestation?nonce=abc123", 301), + ] { + let response = route(router.clone(), get(uri)).await; + assert_eq!( + response.status(), + status, + "{uri} should be answered without the preparers" + ); + } +} From 69492600295a3cec5ed303d3373d066fac498fcc Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:56:45 +0100 Subject: [PATCH 14/25] Describe the clock answer and the key check in the attestation guide The answers table adds the 503 for a clock that cannot be read, and the signing keys section says that a key that cannot sign fails the build, naming its line and key id but never the key. The changelog entry and the API reference say the same. --- CHANGELOG.md | 2 +- docs/guide/api-reference.md | 2 +- docs/guide/attestation.md | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3d67e0d8..20a9fda8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. +- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint, as does a clock that cannot be read. A key in the schedule that is not a P-256 private key stops the build. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 568c5c9dc8..b2d592490a 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -311,7 +311,7 @@ relying party checks it. - **Configuration gate:** served only where the settings carry `[attestation]`. The address is `[attestation] endpoint`, which defaults to `/_ts/attestation`. Where the endpoint is not the default, a read of the default address answers `301` to the endpoint with the query kept. - **Auth:** none. - **Request:** `GET` or `HEAD`. An optional `nonce` query parameter of 1 to 64 characters from `A` to `Z`, `a` to `z`, `0` to `9`, `_` and `-` is signed back in the evidence. -- **Response:** `200` with the page, or with the envelope as `application/json` at the `.json` address. `400` for a malformed nonce. `503` where the build carries no signing key in force or the key cannot be read. The `400` and `503` bodies are JSON with an `error` field. +- **Response:** `200` with the page, or with the envelope as `application/json` at the `.json` address. `400` for a malformed nonce. `503` where the build carries no signing key in force, the key cannot be read or the clock cannot be read. The `400` and `503` bodies are JSON with an `error` field. - **Cache and CORS:** `Cache-Control: no-store` and `Access-Control-Allow-Origin: *` on every answer. - **Rate limiting:** none. - **Other methods:** reach the publisher's origin under the shared `/_ts` prefix. Under a prefix the endpoint has to itself, every other method and address beneath that prefix answers `404` from the deployment. diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index 54ce27ce5b..e077ecfa0d 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -147,10 +147,12 @@ field. Any other value answers 400. Without a nonce the evidence carries no | 404 | Any other address or method beneath a prefix the deployment owns | A page pointing to the list at the prefix | | 503 | No key in the build has come into force, or the build carries no key at all | `{"error":"No signing key is built in for this time, so no evidence can be signed."}` | | 503 | The key in force is not a P-256 private key in SEC1 or PKCS #8 form | `{"error":"The signing key could not be read, so no evidence can be signed."}` | +| 503 | The platform's clock cannot be read | `{"error":"The clock could not be read, so no evidence can be signed."}` | The 400 and 503 answers are JSON at both addresses. The tests are -`a_malformed_nonce_is_refused` and -`no_usable_key_in_force_answers_service_unavailable`. +`a_malformed_nonce_is_refused`, +`no_usable_key_in_force_answers_service_unavailable` and +`a_clock_that_cannot_be_read_signs_nothing`. ### What each adapter reports @@ -212,6 +214,11 @@ other shape fails the build. No two lines may share a key id or a start, so a key id names one key and the key in force never depends on the order of the lines. The test is `a_schedule_repeating_a_key_id_or_a_start_is_refused`. +The build also reads each key the way the signer does, so a key that is not a +P-256 private key fails the build, however late it starts. The message names +the line and the key id but never the key. The test is +`a_schedule_key_that_cannot_sign_is_refused_without_showing_it`. + The key in force is the latest to have started, and it stays in force until the next one starts. While the key in force is the last one in the build, the deployment logs the warning From e5e7fbf8853a8292a4262e326b7740f55c9a9076 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 10:32:05 +0100 Subject: [PATCH 15/25] The attestation guide says how a Cloudflare or Spin build gets its keys --- docs/guide/attestation.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index e077ecfa0d..4430c71097 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -231,6 +231,12 @@ Without the build input, or with an empty file, the schedule is empty and the endpoint answers 503. That is every build its builder names no schedule for, which the test `no_usable_key_in_force_answers_service_unavailable` pins. +A Cloudflare build reads the input from the repository's root `.env`, which the +adapter's `build.sh` exports before it runs `worker-build`, and a Spin build +from the environment `spin build` runs in. Name the file by its absolute path, +because core's build script resolves a relative one from +`crates/trusted-server-core`. + The build fields come from four more build inputs. `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and `TRUSTED_SERVER_BUILD_TAG` become `build.commit`, `build.run` and `build.tag`, or `unknown` when absent, and From 8226e2dc5200040e83e34ce86fe5fe5675200682 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:06 +0000 Subject: [PATCH 16/25] Take attestation out of the stack --- .github/workflows/test.yml | 4 - CHANGELOG.md | 1 - Cargo.lock | 41 +- Cargo.toml | 1 - crates/trusted-server-adapter-axum/src/app.rs | 33 - .../tests/routes.rs | 18 - .../src/app.rs | 33 - .../tests/routes.rs | 116 - .../trusted-server-adapter-fastly/src/app.rs | 277 --- crates/trusted-server-adapter-spin/src/app.rs | 26 - .../tests/routes.rs | 18 - crates/trusted-server-core/Cargo.toml | 3 - crates/trusted-server-core/build.rs | 78 - crates/trusted-server-core/build_time.rs | 30 - crates/trusted-server-core/key_schedule.rs | 77 - crates/trusted-server-core/signing_key.rs | 22 - crates/trusted-server-core/src/attestation.rs | 2058 ----------------- crates/trusted-server-core/src/build_info.rs | 89 - crates/trusted-server-core/src/inspect.rs | 2 +- crates/trusted-server-core/src/lib.rs | 2 - crates/trusted-server-core/src/settings.rs | 14 +- .../tests/parity.rs | 173 -- docs/.vitepress/config.mts | 1 - docs/guide/api-reference.md | 20 - docs/guide/attestation.md | 523 ----- docs/guide/configuration.md | 31 - scripts/README.md | 1 - scripts/attestation-local-test.sh | 318 --- tools/permissions-inspector/wasm/Cargo.lock | 29 - trusted-server.example.toml | 14 - 30 files changed, 4 insertions(+), 4049 deletions(-) delete mode 100644 crates/trusted-server-core/build_time.rs delete mode 100644 crates/trusted-server-core/key_schedule.rs delete mode 100644 crates/trusted-server-core/signing_key.rs delete mode 100644 crates/trusted-server-core/src/attestation.rs delete mode 100644 crates/trusted-server-core/src/build_info.rs delete mode 100644 docs/guide/attestation.md delete mode 100755 scripts/attestation-local-test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1104b66fb..2cc52e976f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,10 +71,6 @@ jobs: - name: Run config by host harness (reusable-sandbox feature) run: ./scripts/config-by-host-local-test.sh reuse - # Last, because it builds core with a key schedule compiled in. - - name: Run attestation harness - run: ./scripts/attestation-local-test.sh - test-axum: name: cargo test (axum native, ${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c916605b5f..4005c4ca02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint, as does a clock that cannot be read. A key in the schedule that is not a P-256 private key stops the build. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --local --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. Where text a middleware writes into a script would end the script, or begin `, -) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { - move |ctx: RequestContext| { - let state = Arc::clone(&state); - Box::pin(async move { - Ok(handle_prefix( - &state.settings, - &PlatformIdentity::named("axum"), - &ctx.into_request(), - )) - }) - } -} - fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); @@ -943,21 +925,6 @@ fn build_router(state: &Arc) -> RouterService { } } - // Routes chosen by the settings, so not in the fixed table. - let routes = prefix_routes(&state.settings); - if !routes.is_empty() { - let attestation = attestation_route_handler(Arc::clone(state)); - for route in &routes { - for method in publisher_fallback_methods() { - if matches!(method, Method::GET | Method::HEAD) || route.every_method { - router = router.route(&route.path, method, attestation.clone()); - } else { - router = router.route(&route.path, method, fallback.clone()); - } - } - } - } - for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); router = router.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index a66257db29..e4149f2c5e 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -1176,24 +1176,6 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { ); } -/// Every route registered beneath an underscore prefix is one of the -/// addresses an attestation endpoint may not take, so a new fixed route -/// cannot leave an endpoint free to register its address twice. -#[test] -fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { - use trusted_server_core::attestation::RESERVED_PATHS; - - for route in test_router().routes() { - let path = route.path(); - if path.starts_with("/_") { - assert!( - RESERVED_PATHS.contains(&path), - "{path} should be listed in attestation::RESERVED_PATHS" - ); - } - } -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn health_reports_git_version() { let mut service = make_service(); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 0b2e3c8338..ef302d68ac 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -8,7 +8,6 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; use edgezero_core::router::RouterService; use error_stack::Report; -use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, @@ -445,23 +444,6 @@ where } } -/// Answers the attestation pages without the request preparers, so no -/// preparer can change or refuse the request whose host and nonce are signed. -fn attestation_route_handler( - state: Arc, -) -> impl Fn(RequestContext) -> BoxedHandlerFuture + Clone + 'static { - move |ctx: RequestContext| { - let state = Arc::clone(&state); - Box::pin(async move { - Ok(handle_prefix( - &state.settings, - &PlatformIdentity::named("cloudflare"), - &ctx.into_request(), - )) - }) - } -} - // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -999,21 +981,6 @@ fn build_router(state: &Arc) -> RouterService { router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); } - // Routes chosen by the settings, so not in the fixed table. - let routes = prefix_routes(&state.settings); - if !routes.is_empty() { - let attestation = attestation_route_handler(Arc::clone(&state)); - for route in &routes { - for method in publisher_fallback_methods() { - if matches!(method, Method::GET | Method::HEAD) || route.every_method { - router = router.route(&route.path, method, attestation.clone()); - } else { - router = router.route(&route.path, method, fallback.clone()); - } - } - } - } - for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); router = router.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 65e912710e..e817f10fcc 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -918,119 +918,3 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } - -/// Every route registered beneath an underscore prefix is one of the -/// addresses an attestation endpoint may not take, so a new fixed route -/// cannot leave an endpoint free to register its address twice. -#[test] -fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { - use trusted_server_core::attestation::RESERVED_PATHS; - - for route in test_router().routes() { - let path = route.path(); - if path.starts_with("/_") { - assert!( - RESERVED_PATHS.contains(&path), - "{path} should be listed in attestation::RESERVED_PATHS" - ); - } - } -} - -/// The attestation pages are answered without the request preparers, so no -/// preparer can change or refuse the request whose host and nonce are -/// signed. The preparer here refuses every request, and the publisher -/// fallback shows that it runs. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attestation_routes_skip_the_request_preparers() { - use error_stack::Report; - use trusted_server_core::error::TrustedServerError; - use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; - - fn refuse_every_request( - _settings: &Settings, - _request: &mut Request, - ) -> Result<(), Report> { - Err(Report::new(TrustedServerError::Forbidden { - message: "refused by the route test's preparer".to_owned(), - })) - } - - fn never_registers( - _settings: &Settings, - ) -> Result, Report> { - Ok(None) - } - - fn selects_nothing(_settings: &Settings) -> Result> { - Ok(false) - } - - let settings = Settings::from_toml( - r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - - [publisher] - domain = "test-publisher.example.com" - cookie_domain = ".test-publisher.example.com" - origin_url = "https://origin.test-publisher.example.com" - proxy_secret = "route-test-proxy-secret" - - [ec] - module = "hmac" - - [ec.hmac] - passphrase = "test-secret-key-32-bytes-minimum" - - [geo] - assume_single_jurisdiction = true - - [attestation] - endpoint = "/_ex/attestation" - operator = "Example Operator" - context = "example-attestation:v1" - verify_url = "https://verifier.example/verify?host=test-publisher.example.com" - "#, - ) - .expect("should parse route test settings with an attestation section"); - let refusing = IntegrationBuilder::new( - "refusing-preparer", - "routes-test", - never_registers, - selects_nothing, - ) - .with_module_name("testing.refusing-preparer") - .with_request_preparer(refuse_every_request); - let router = TrustedServerApp::routes_with_registrations(settings, &[refusing]) - .expect("should build router with the refusing preparer"); - let get = |uri: &str| { - request_builder() - .method("GET") - .uri(uri) - .body(edgezero_core::body::Body::empty()) - .expect("should build request") - }; - - let page = route(router.clone(), get("/article")).await; - assert_eq!( - page.status(), - 403, - "the preparer should refuse a request for the publisher's origin" - ); - - for (uri, status) in [ - ("/_ex", 200), - ("/_ex/attestation.json", 503), - ("/_ts/attestation?nonce=abc123", 301), - ] { - let response = route(router.clone(), get(uri)).await; - assert_eq!( - response.status(), - status, - "{uri} should be answered without the preparers" - ); - } -} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 47512f325c..1a4c7a8e3c 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -32,7 +32,6 @@ //! | GET | `/_ts/permissions` and `/_ts/permissions.json` | [`handle_permissions`] | //! | GET | `/_ts/config` and `/_ts/config.json` | [`handle_config`] | //! | GET | `/_ts/data` | [`handle_data`] | -//! | GET, HEAD | The address `[attestation] endpoint` names, and the same with `.json` | [`handle_prefix`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | @@ -103,7 +102,6 @@ use edgezero_core::http::{ }; use edgezero_core::router::RouterService; use error_stack::Report; -use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ @@ -1583,42 +1581,6 @@ fn named_route_handler( } } -/// Answers the attestation pages ahead of the Edge Cookie lifecycle and the -/// request filters, because the pages are read only and a verifier fetching -/// one from a server must not be turned away as a bot. -fn attestation_route_handler( - state: Arc, -) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { - move |ctx: RequestContext| { - let state = Arc::clone(&state); - Box::pin(async move { - Ok(handle_prefix( - &state.settings, - &fastly_platform_identity(), - &ctx.into_request(), - )) - }) - } -} - -/// What the evidence says about the Fastly service answering, from the -/// environment variables Fastly gives every Compute service. -fn fastly_platform_identity() -> PlatformIdentity { - let read = |name: &str| { - std::env::var(name) - .ok() - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "unknown".to_owned()) - }; - PlatformIdentity { - name: "fastly".to_owned(), - service_id: read("FASTLY_SERVICE_ID"), - service_version: read("FASTLY_SERVICE_VERSION"), - staging: std::env::var("FASTLY_IS_STAGING").is_ok_and(|value| value == "1"), - pop: read("FASTLY_POP"), - } -} - fn fallback_route_handler( state: Arc, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { @@ -1688,21 +1650,6 @@ impl TrustedServerApp { } } - // Routes chosen by the settings, so not in the fixed table. - let routes = prefix_routes(&state.settings); - if !routes.is_empty() { - let attestation = attestation_route_handler(Arc::clone(state)); - for route in &routes { - for method in publisher_fallback_methods() { - if matches!(method, Method::GET | Method::HEAD) || route.every_method { - router = router.route(&route.path, method, attestation.clone()); - } else { - router = router.route(&route.path, method, fallback_handler.clone()); - } - } - } - } - // matchit's `/{*rest}` does not match the bare root `/` — register // explicit root routes so `/` reaches the publisher fallback too. for method in publisher_fallback_methods() { @@ -2762,230 +2709,6 @@ mod tests { ); } - /// Settings whose attestation pages sit at `endpoint`. - fn settings_with_attestation_at(endpoint: &str) -> Settings { - let mut settings = test_settings(); - settings.attestation = Some(trusted_server_core::attestation::AttestationConfig { - endpoint: endpoint.to_owned(), - operator: "Example Operator".to_owned(), - context: "example-attestation:v1".to_owned(), - verify_url: "https://verifier.example/verify".to_owned(), - }); - settings - } - - /// The router of a deployment whose attestation pages sit at `endpoint`. - fn router_with_attestation_at(endpoint: &str) -> RouterService { - let state = build_state_from_settings(settings_with_attestation_at(endpoint)) - .expect("should build test state"); - TrustedServerApp::routes_for_state(&state) - } - - fn header_text(response: &Response, name: header::HeaderName) -> String { - response - .headers() - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_owned() - } - - /// A deployment whose endpoint sits under a prefix of its own answers - /// everything beneath that prefix here. Reads of the two attestation - /// addresses reach the handler, the prefix lists its pages, and anything - /// else is not found rather than sent to the publisher's origin. Test - /// builds carry no signing key, so the handler answers 503 in JSON. - #[test] - fn an_owned_prefix_is_answered_here_and_never_by_the_origin() { - let router = router_with_attestation_at("/_ex/attestation"); - - let cases = [ - (Method::GET, "/_ex", 200, "text/html"), - (Method::GET, "/_ex/", 200, "text/html"), - ( - Method::GET, - "/_ex/attestation.json", - 503, - "application/json", - ), - ( - Method::HEAD, - "/_ex/attestation.json", - 503, - "application/json", - ), - (Method::GET, "/_ex/attestation", 503, "application/json"), - (Method::POST, "/_ex/attestation", 404, "text/html"), - (Method::PUT, "/_ex/attestation.json", 404, "text/html"), - (Method::DELETE, "/_ex", 404, "text/html"), - (Method::GET, "/_ex/unknown/page", 404, "text/html"), - ]; - for (method, path, status, content_type) in cases { - let case = format!("{method} {path}"); - let response = route(&router, empty_request(method, path)); - assert_eq!( - response.status().as_u16(), - status, - "should answer {case} here" - ); - assert!( - header_text(&response, header::CONTENT_TYPE).starts_with(content_type), - "should answer {case} as {content_type}" - ); - assert_eq!( - header_text(&response, header::CACHE_CONTROL), - "no-store", - "should keep the answer to {case} out of every cache" - ); - } - } - - /// When the endpoint is not the default, reads of the default address are - /// sent to the endpoint, keeping the nonce. With the default endpoint the - /// default address is answered directly, and no other address redirects. - #[test] - fn the_default_address_redirects_to_the_endpoint_the_settings_name() { - use trusted_server_core::attestation::DEFAULT_ENDPOINT; - - let router = router_with_attestation_at("/_ex/attestation"); - for (method, path, location) in [ - (Method::GET, "/_ts/attestation", "/_ex/attestation"), - (Method::HEAD, "/_ts/attestation", "/_ex/attestation"), - ( - Method::GET, - "/_ts/attestation.json?nonce=abc123", - "/_ex/attestation.json?nonce=abc123", - ), - ] { - let case = format!("{method} {path}"); - let response = route(&router, empty_request(method, path)); - assert_eq!(response.status().as_u16(), 301, "should redirect {case}"); - assert_eq!( - header_text(&response, header::LOCATION), - location, - "should send {case} to the endpoint with its query kept" - ); - assert_eq!( - header_text(&response, header::CACHE_CONTROL), - "no-store", - "should keep the redirect of {case} out of every cache" - ); - } - - let router = router_with_attestation_at(DEFAULT_ENDPOINT); - for path in ["/_ts/attestation", "/_ts/attestation.json"] { - let response = route(&router, empty_request(Method::GET, path)); - assert_eq!( - response.status().as_u16(), - 503, - "{path} should reach the handler, which has no key built in" - ); - assert!( - response.headers().get(header::LOCATION).is_none(), - "{path} should not redirect under the default endpoint" - ); - } - } - - /// Without the section the two addresses belong to the publisher, so a - /// deployment that serves no attestation takes nothing from its origin. - #[test] - fn without_the_section_no_attestation_route_is_registered() { - let registered: Vec = test_router() - .routes() - .iter() - .map(|route| route.path().to_owned()) - .filter(|path| path.contains("attestation")) - .collect(); - - assert!( - registered.is_empty(), - "should register no attestation route, found {registered:?}" - ); - } - - /// Every route this router registers beneath an underscore prefix is one - /// of the addresses an attestation endpoint may not take, so a new fixed - /// route cannot leave an endpoint free to register its address twice. - #[test] - fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { - use trusted_server_core::attestation::RESERVED_PATHS; - - for route in test_router().routes() { - let path = route.path(); - if path.starts_with("/_") { - assert!( - RESERVED_PATHS.contains(&path), - "{path} should be listed in attestation::RESERVED_PATHS" - ); - } - } - } - - /// Every endpoint the settings accept builds a router whose attestation - /// pages answer, because a second route at one address panics when the - /// router is built. - #[test] - fn every_endpoint_the_settings_accept_builds_a_router() { - use trusted_server_core::attestation::{DEFAULT_ENDPOINT, validate_endpoint}; - - let mut candidates: Vec = test_router() - .routes() - .iter() - .map(edgezero_core::router::RouteInfo::path) - .filter(|path| path.starts_with("/_")) - .map(|path| { - let page = path.strip_suffix(".json").unwrap_or(path); - page.split('/') - .map(|segment| { - if segment.starts_with('{') { - "proof" - } else { - segment - } - }) - .collect::>() - .join("/") - }) - .collect(); - candidates.extend( - [ - DEFAULT_ENDPOINT, - "/_ts/proof", - "/_ts/api/v1/proof", - "/__ts/proof", - ] - .map(str::to_owned), - ); - candidates.sort(); - candidates.dedup(); - - let accepted: Vec = candidates - .into_iter() - .filter(|endpoint| validate_endpoint(endpoint).is_ok()) - .collect(); - for endpoint in &accepted { - let router = router_with_attestation_at(endpoint); - let json = format!("{endpoint}.json"); - let response = route(&router, empty_request(Method::GET, &json)); - assert_eq!( - response.status().as_u16(), - 503, - "{json} should reach the handler, which has no key built in" - ); - } - assert_eq!( - accepted, - [ - "/__ts/proof", - "/_ts/api/v1/proof", - DEFAULT_ENDPOINT, - "/_ts/proof" - ], - "should accept only the endpoints no fixed route answers" - ); - } - #[test] fn dispatch_clear_tester_clears_cookie_on_configured_domain() { let mut settings = test_settings(); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index fabdfd874b..23b4c9e276 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -12,7 +12,6 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; use edgezero_core::router::RouterService; use error_stack::Report; -use trusted_server_core::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, @@ -1213,31 +1212,6 @@ fn build_router(state: &Arc) -> RouterService { } } } - // Routes chosen by the settings, so not in the fixed table. - let routes = prefix_routes(&state.settings); - if !routes.is_empty() { - let s = Arc::clone(&state); - let attestation_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - Ok::(handle_prefix( - &s.settings, - &PlatformIdentity::named("spin"), - &ctx.into_request(), - )) - } - }; - for route in &routes { - for method in publisher_fallback_methods() { - if matches!(method, Method::GET | Method::HEAD) || route.every_method { - builder = builder.route(&route.path, method, attestation_handler.clone()); - } else { - builder = builder.route(&route.path, method, fallback.clone()); - } - } - } - } - for method in publisher_fallback_methods() { builder = builder.route("/", method.clone(), fallback.clone()); builder = builder.route("/{*rest}", method, fallback.clone()); diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 088e0ce57d..1403c11494 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -1205,21 +1205,3 @@ async fn nextjs_auction_output_holds_until_the_structural_body_close() { "should not leak generated placeholders: {html}" ); } - -/// Every route registered beneath an underscore prefix is one of the -/// addresses an attestation endpoint may not take, so a new fixed route -/// cannot leave an endpoint free to register its address twice. -#[test] -fn every_fixed_route_beneath_an_underscore_prefix_is_reserved() { - use trusted_server_core::attestation::RESERVED_PATHS; - - for route in test_router().routes() { - let path = route.path(); - if path.starts_with("/_") { - assert!( - RESERVED_PATHS.contains(&path), - "{path} should be listed in attestation::RESERVED_PATHS" - ); - } - } -} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index f82eb7a228..e39521f102 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -37,7 +37,6 @@ log = { workspace = true } lol_html = { workspace = true } matchit = { workspace = true } mime = { workspace = true } -p256 = { workspace = true } rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } @@ -90,10 +89,8 @@ tempfile = { workspace = true } [build-dependencies] # tests/template_build_digest.rs includes build.rs, so its dependencies must also # be available through normal or dev-dependencies. -base64 = { workspace = true } edgezero-core = { workspace = true } hex = { workspace = true } -p256 = { workspace = true } sha2 = { workspace = true } [[bench]] diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index 7b57039ee8..ba94a65d79 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -105,10 +105,6 @@ fn resolve_from_local_git() -> Option { }) } -mod build_time; -mod key_schedule; -mod signing_key; - /// Source file that must stay out of the generated list. /// /// `migration_guards.rs` holds the banned pattern itself, as the regex literal @@ -165,83 +161,9 @@ fn main() { .expect("should write the template build digest"); write_migration_guard_sources(); - write_build_time(); - write_build_identity(); - write_attestation_keys(); emit_git_version(); } -/// Embeds the attestation signing key schedule in the file -/// `TRUSTED_SERVER_ATTESTATION_KEYS` names, or an empty schedule. -/// -/// The schedule is compiled into the binary, so no signing key sits in a -/// store the service reads when it runs. See [`key_schedule::constant_source`] -/// for what a schedule holds. -fn write_attestation_keys() { - println!("cargo:rerun-if-env-changed=TRUSTED_SERVER_ATTESTATION_KEYS"); - let schedule = env::var_os("TRUSTED_SERVER_ATTESTATION_KEYS") - .filter(|path| !path.is_empty()) - .map(|path| { - let path = PathBuf::from(path); - println!("cargo:rerun-if-changed={}", path.display()); - fs::read_to_string(&path).expect("should read the attestation key schedule") - }); - let source = match key_schedule::constant_source(schedule.as_deref()) { - Ok(source) => source, - Err(reason) => { - println!("cargo::error=the attestation key schedule is refused, because {reason}"); - std::process::exit(1); - } - }; - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("should read OUT_DIR")); - fs::write(out_dir.join("attestation_keys.rs"), source) - .expect("should write the attestation key schedule"); -} - -/// Bakes the build time into the binary as `TRUSTED_SERVER_BUILT_AT`. -/// -/// `SOURCE_DATE_EPOCH` is honored where it is set, so a reproducible build -/// stays reproducible rather than differing only by this string. -#[allow( - clippy::disallowed_methods, - reason = "a build script runs on the host, where the std clock is sound" -)] -fn write_build_time() { - println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); - let seconds = env::var("SOURCE_DATE_EPOCH") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or_default()) - .unwrap_or_default() - }); - println!( - "cargo:rustc-env=TRUSTED_SERVER_BUILT_AT={}", - build_time::rfc3339_utc(seconds) - ); -} - -/// Bakes in the commit, the build run and the tag the builder names, as -/// `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and -/// `TRUSTED_SERVER_BUILD_TAG`, or `unknown`. -fn write_build_identity() { - for name in [ - "TRUSTED_SERVER_COMMIT", - "TRUSTED_SERVER_BUILD_RUN", - "TRUSTED_SERVER_BUILD_TAG", - ] { - println!("cargo:rerun-if-env-changed={name}"); - let value = env::var(name) - .ok() - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty() && !value.chars().any(char::is_control)) - .unwrap_or_else(|| "unknown".to_owned()); - println!("cargo:rustc-env={name}={value}"); - } -} - /// Compiles the deployed git version in as `TRUSTED_SERVER__GIT_VERSION`. fn emit_git_version() { println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); diff --git a/crates/trusted-server-core/build_time.rs b/crates/trusted-server-core/build_time.rs deleted file mode 100644 index 661ce19ce4..0000000000 --- a/crates/trusted-server-core/build_time.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! The build time written as RFC 3339 in UTC. -//! -//! A module of the build script, which bakes the time into the binary, and of -//! the tests in `src/build_info.rs`, which check the arithmetic against known -//! dates. - -/// Formats seconds since the Unix epoch as RFC 3339 in UTC. -/// -/// Days are civil days from the epoch, using the standard days-to-civil -/// algorithm, which is correct for any date this will see. -pub(crate) fn rfc3339_utc(seconds: i64) -> String { - let days = seconds.div_euclid(86_400); - let rem = seconds.rem_euclid(86_400); - let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60); - - // Days-to-civil: shift the era so March is month 0 and leap days land at - // the end of a 400 year era. - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - format!("{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}Z") -} diff --git a/crates/trusted-server-core/key_schedule.rs b/crates/trusted-server-core/key_schedule.rs deleted file mode 100644 index de91a530ae..0000000000 --- a/crates/trusted-server-core/key_schedule.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! The attestation key schedule a build is given, read into the constant -//! the build script writes. -//! -//! A module of the build script and of the tests in `src/attestation.rs`, -//! which check what a schedule may hold. Both also hold `signing_key.rs` as -//! `signing_key`, which reads each key the way the signer does. - -/// The source of the `ATTESTATION_KEYS` constant for the text of a key -/// schedule, or of an empty schedule for `None`. -/// -/// A schedule holds one key on each line, being the key id, the Unix second -/// the key comes into force and the private key as base64 DER, separated by -/// tabs. Blank lines are passed over. -/// -/// A key id and a key may hold only letters, digits and `-_.:+/=`. Each is -/// written into Rust source as a string, so nothing a schedule holds can end -/// the string it is written in. No two lines share a key id or a start, so -/// one key is in force at any second and a key id names one key. Each key -/// must be one the signer can read, however late it starts. -/// -/// # Errors -/// -/// Returns the number of the first line of any other shape, of the first -/// that repeats an earlier line's key id or start, or of the first whose key -/// cannot sign, and what is wrong with it. The key itself is never repeated. -pub(crate) fn constant_source(schedule: Option<&str>) -> Result { - let mut source = String::from("const ATTESTATION_KEYS: &[(&str, i64, &str)] = &["); - let mut seen: Vec<(usize, &str, i64)> = Vec::new(); - for (index, line) in schedule.unwrap_or_default().lines().enumerate() { - let line = line.trim_matches([' ', '\r']); - if line.is_empty() { - continue; - } - let number = index + 1; - let fields: Vec<&str> = line.split('\t').collect(); - let [key_id, starts_at, private_key] = fields.as_slice() else { - return Err(format!( - "line {number} should hold a key id, a start and a key, separated by tabs" - )); - }; - let starts_at: i64 = starts_at - .parse() - .map_err(|_| format!("line {number} should give the key's start as Unix seconds"))?; - if !is_plain(key_id) || !is_plain(private_key) { - return Err(format!( - "line {number} should hold a key id and a key of letters, digits and -_.:+/= only" - )); - } - if let Some((earlier, _, _)) = seen.iter().find(|(_, id, _)| id == key_id) { - return Err(format!( - "line {number} should not repeat the key id of line {earlier}" - )); - } - if let Some((earlier, _, _)) = seen.iter().find(|(_, _, start)| *start == starts_at) { - return Err(format!( - "line {number} should not start at the same second as line {earlier}" - )); - } - if let Err(reason) = super::signing_key::read(private_key) { - return Err(format!( - "line {number} holds key `{key_id}`, which cannot sign, as {reason}" - )); - } - seen.push((number, *key_id, starts_at)); - source.push_str(&format!("({key_id:?}, {starts_at}, {private_key:?}),")); - } - source.push_str("];"); - Ok(source) -} - -/// Whether a schedule field holds only characters a key id or base64 uses. -fn is_plain(field: &str) -> bool { - !field.is_empty() - && field - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_.:+/=".contains(&b)) -} diff --git a/crates/trusted-server-core/signing_key.rs b/crates/trusted-server-core/signing_key.rs deleted file mode 100644 index fd8686bc0f..0000000000 --- a/crates/trusted-server-core/signing_key.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! The reading of an attestation signing key. -//! -//! A module of the build script, which refuses a schedule key that cannot -//! sign, and of `src/attestation.rs`, which signs with it, so a key the build -//! accepts is one the signer can read. - -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD; -use p256::ecdsa::SigningKey; -use p256::pkcs8::DecodePrivateKey as _; - -/// Reads a key held as base64 of its SEC1 or PKCS #8 DER encoding, saying -/// why when it cannot sign and never repeating the key. -pub(crate) fn read(key_text: &str) -> Result { - let der = STANDARD - .decode(key_text.trim()) - .map_err(|_| "the key is not base64")?; - let secret = p256::SecretKey::from_sec1_der(&der) - .or_else(|_| p256::SecretKey::from_pkcs8_der(&der)) - .map_err(|_| "the key is not a P-256 private key in SEC1 or PKCS #8 DER")?; - Ok(SigningKey::from(secret)) -} diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs deleted file mode 100644 index f7cfc9ab1a..0000000000 --- a/crates/trusted-server-core/src/attestation.rs +++ /dev/null @@ -1,2058 +0,0 @@ -//! Signed evidence of who operates this deployment and which build it runs. -//! -//! The endpoint answers with evidence in the sense of RFC 9334, a description -//! of the build and the platform signed with the operator's key. A deployment -//! on a CDN cannot measure its own bytes, so the evidence proves who operates -//! the deployment, not what its code is. A verifier holding the operator's -//! public keys decides whether to trust it, and the page sends the reader there -//! rather than claiming validity itself. -//! -//! The signature is ECDSA P-256 with SHA-256, deterministic per RFC 6979, over -//! the configured context, one line feed, then the evidence bytes. - -use std::sync::atomic::{AtomicBool, Ordering}; - -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use edgezero_core::body::Body as EdgeBody; -use http::{HeaderValue, Method, Request, Response, StatusCode, header}; -use p256::ecdsa::Signature; -use p256::ecdsa::signature::Signer as _; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use validator::{Validate, ValidationError}; - -use crate::inspect::config::{CONFIG_JSON_PATH, CONFIG_PAGE_PATH}; -use crate::inspect::data::DATA_PAGE_PATH; -use crate::inspect::html_escape; -use crate::inspect::permissions::{PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH}; -use crate::publisher::{PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH}; -use crate::settings::Settings; - -// `ATTESTATION_KEYS`, written by build.rs from the schedule the build names. -include!(concat!(env!("OUT_DIR"), "/attestation_keys.rs")); - -/// The reading of a signing key, shared with the build script, which refuses -/// a schedule key that cannot sign. -#[path = "../signing_key.rs"] -mod signing_key; - -/// The build script's reading of a key schedule, compiled here so a test can -/// call it. -#[cfg(test)] -#[path = "../key_schedule.rs"] -mod key_schedule; - -/// The address of the attestation page when the settings name none. The JSON -/// form is at the same address plus `.json`. -pub const DEFAULT_ENDPOINT: &str = "/_ts/attestation"; - -/// The addresses beneath an underscore prefix that a deployment answers -/// itself whatever the settings say, so an endpoint may take none of them. A -/// segment in braces stands for any one segment, as it does in the router. -pub const RESERVED_PATHS: &[&str] = &[ - CONFIG_PAGE_PATH, - CONFIG_JSON_PATH, - PERMISSIONS_PAGE_PATH, - PERMISSIONS_JSON_PATH, - DATA_PAGE_PATH, - PAGE_BIDS_PATH, - PAGE_BIDS_LEGACY_PATH, - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", - "/_ts/admin/cache/purge", - "/_ts/admin/ec", - "/_ts/admin/ec/{id}", - "/_ts/admin/eids", - "/_ts/api/v1/batch-sync", - "/_ts/api/v1/identify", - "/_ts/api/v1/ec/resolve", - "/_ts/set-tester", - "/_ts/clear-tester", - // Answered ahead of the router by an adapter whose host exposes the TLS - // fingerprint or serves several requests from one sandbox. - "/_ts/debug/ja4", - "/_ts/debug/sandbox", -]; - -/// Prefixes beneath which common web frameworks serve their assets from the -/// origin. An endpoint under one would own the prefix and answer `404` for -/// every asset there, so an endpoint may take none of them. -pub const ORIGIN_ASSET_PREFIXES: &[&str] = &[ - "_app", - "_astro", - "_content", - "_expo", - "_framework", - "_next", - "_nuxt", - "_vercel", -]; - -/// The signature algorithm, as JOSE names it. -const ALGORITHM: &str = "ES256"; - -/// The version of the evidence format. -const EVIDENCE_VERSION: u8 = 1; - -/// The longest nonce a caller may have signed. -const MAX_NONCE_LEN: usize = 64; - -/// The architecture whose roles and terms the endpoint follows. -const STANDARD_URL: &str = "https://www.rfc-editor.org/rfc/rfc9334"; - -/// A key id, the Unix second the key comes into force, and the private key -/// as base64 of its SEC1 or PKCS #8 DER encoding. -type ScheduledKey<'a> = (&'a str, i64, &'a str); - -/// The `[attestation]` settings section. -#[derive(Debug, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] -pub struct AttestationConfig { - /// The address of the attestation page, with the JSON form at the same - /// address plus `.json`. When it is not the default, reads of the default - /// address redirect here. Neither form may be one of the - /// [`RESERVED_PATHS`]. - #[serde(default = "default_endpoint")] - #[validate(custom(function = validate_endpoint))] - pub endpoint: String, - /// Who the evidence says operates this deployment. - #[validate(length(min = 1, max = 64))] - pub operator: String, - /// The text signed ahead of the evidence, naming what the signature is - /// for. - #[validate(length(min = 1, max = 64), custom(function = validate_single_line))] - pub context: String, - /// Where a relying party checks the evidence. - #[validate(custom(function = validate_https_url))] - pub verify_url: String, -} - -fn default_endpoint() -> String { - DEFAULT_ENDPOINT.to_owned() -} - -/// What the evidence says about the platform, supplied by the adapter. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PlatformIdentity { - /// The platform's name. - pub name: String, - /// The platform's identifier for the service running this build. - pub service_id: String, - /// The platform's version number of that service. - pub service_version: String, - /// Whether the platform's staging environment answered. - pub staging: bool, - /// The location that answered. - pub pop: String, -} - -impl PlatformIdentity { - /// A platform that reports its name and nothing else. - #[must_use] - pub fn named(name: &str) -> Self { - Self { - name: name.to_owned(), - service_id: "unknown".to_owned(), - service_version: "unknown".to_owned(), - staging: false, - pop: "unknown".to_owned(), - } - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct Build { - version: &'static str, - built_at: &'static str, - commit: &'static str, - run: &'static str, - tag: &'static str, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AttestationEvidence<'a> { - v: u8, - operator: &'a str, - publisher: &'a str, - host: &'a str, - platform: &'a PlatformIdentity, - build: Build, - #[serde(skip_serializing_if = "Option::is_none")] - nonce: Option<&'a str>, - issued_at: String, -} - -/// The addresses the endpoint answers at, the page first, or `None` when the -/// settings carry no `[attestation]` section. -#[must_use] -pub fn attestation_paths(settings: &Settings) -> Option<[String; 2]> { - Some(paths_of(&settings.attestation.as_ref()?.endpoint)) -} - -/// The page and JSON addresses of an endpoint. -fn paths_of(endpoint: &str) -> [String; 2] { - [endpoint.to_owned(), format!("{endpoint}.json")] -} - -/// The first segment of an endpoint, being the prefix its pages sit under. -fn prefix_of(endpoint: &str) -> &str { - endpoint - .trim_start_matches('/') - .split('/') - .next() - .unwrap_or_default() -} - -/// A route a deployment registers for its attestation pages. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PrefixRoute { - /// The path, in the router's own syntax. - pub path: String, - /// Whether every method is answered here rather than by the publisher's - /// origin. True for the routes beneath a prefix the deployment owns. - pub every_method: bool, -} - -/// The routes to register for the attestation pages, empty when the settings -/// carry no `[attestation]` section. -/// -/// A deployment whose endpoint sits under a prefix of its own owns that -/// prefix, so the prefix itself lists the pages beneath it and any other path -/// there is not found. The default prefix is shared with other routes, so -/// only the pages themselves are taken from it. When the endpoint is not the -/// default, the default address is registered too, to redirect. -#[must_use] -pub fn prefix_routes(settings: &Settings) -> Vec { - let Some(config) = settings.attestation.as_ref() else { - return Vec::new(); - }; - let prefix = prefix_of(&config.endpoint); - let owns_prefix = prefix != prefix_of(DEFAULT_ENDPOINT); - let route = |path: String, every_method: bool| PrefixRoute { path, every_method }; - let mut routes: Vec = paths_of(&config.endpoint) - .into_iter() - .map(|path| route(path, owns_prefix)) - .collect(); - if config.endpoint != DEFAULT_ENDPOINT { - routes.extend( - paths_of(DEFAULT_ENDPOINT) - .into_iter() - .map(|path| route(path, false)), - ); - } - if owns_prefix { - routes.extend( - [ - format!("/{prefix}"), - format!("/{prefix}/"), - format!("/{prefix}/{{*rest}}"), - ] - .into_iter() - .map(|path| route(path, true)), - ); - } - routes -} - -/// Answers every request routed for the attestation pages, being the pages -/// themselves, a redirect from the default address when the endpoint has -/// moved, the list of pages at an owned prefix, and not found for anything -/// else. -#[must_use] -pub fn handle_prefix( - settings: &Settings, - platform: &PlatformIdentity, - req: &Request, -) -> Response { - let Some(config) = settings.attestation.as_ref() else { - return error_response( - StatusCode::NOT_FOUND, - "This deployment serves no attestation.", - ); - }; - let read = matches!(*req.method(), Method::GET | Method::HEAD); - let path = req.uri().path(); - let [page, json] = paths_of(&config.endpoint); - if read && (path == page || path == json) { - return handle_attestation(settings, platform, req); - } - if read && config.endpoint != DEFAULT_ENDPOINT { - let [default_page, default_json] = paths_of(DEFAULT_ENDPOINT); - if path == default_page { - return redirect(&page, req.uri().query()); - } - if path == default_json { - return redirect(&json, req.uri().query()); - } - } - let prefix = prefix_of(&config.endpoint); - if read && (path == format!("/{prefix}") || path == format!("/{prefix}/")) { - return response( - StatusCode::OK, - "text/html; charset=utf-8", - render_index(&page, &json), - ); - } - response( - StatusCode::NOT_FOUND, - "text/html; charset=utf-8", - render_not_found(prefix), - ) -} - -/// Sends the caller to `location`, keeping the query it asked with so a -/// nonce survives the move. -fn redirect(location: &str, query: Option<&str>) -> Response { - let target = match query { - Some(query) if !query.is_empty() => format!("{location}?{query}"), - _ => location.to_owned(), - }; - let mut redirect = response( - StatusCode::MOVED_PERMANENTLY, - "application/json", - json!({ "location": target }).to_string(), - ); - if let Ok(value) = HeaderValue::from_str(&target) { - redirect.headers_mut().insert(header::LOCATION, value); - } - redirect -} - -/// Answers with evidence signed by the compiled-in key in force now, as a -/// page or, at the `.json` address, as data. -/// -/// Every failure becomes a JSON body with a status, so an adapter only routes -/// here. The handler makes no outbound request. -#[must_use] -pub fn handle_attestation( - settings: &Settings, - platform: &PlatformIdentity, - req: &Request, -) -> Response { - answer( - settings, - platform, - req, - ATTESTATION_KEYS, - crate::ec::checked_current_timestamp(), - ) -} - -/// Answers from `schedule` at `now`, which is `None` when the clock cannot be -/// read. Nothing is signed then, because the evidence would carry a time that -/// never happened. -fn answer( - settings: &Settings, - platform: &PlatformIdentity, - req: &Request, - schedule: &[ScheduledKey<'_>], - now: Option, -) -> Response { - let Some(config) = settings.attestation.as_ref() else { - return error_response( - StatusCode::NOT_FOUND, - "This deployment serves no attestation.", - ); - }; - let nonce = match nonce_from_query(req.uri().query()) { - Ok(nonce) => nonce, - Err(message) => return error_response(StatusCode::BAD_REQUEST, message), - }; - let Some(now) = now else { - return error_response( - StatusCode::SERVICE_UNAVAILABLE, - "The clock could not be read, so no evidence can be signed.", - ); - }; - let Some(&(key_id, _, private_key)) = key_in_force(schedule, now) else { - log::error!("attestation has no signing key in force at {now}"); - return error_response( - StatusCode::SERVICE_UNAVAILABLE, - "No signing key is built in for this time, so no evidence can be signed.", - ); - }; - let host = request_host(req); - let evidence = AttestationEvidence { - v: EVIDENCE_VERSION, - operator: &config.operator, - publisher: &settings.publisher.domain, - host: &host, - platform, - build: Build { - version: crate::build_info::VERSION, - built_at: crate::build_info::BUILT_AT, - commit: crate::build_info::COMMIT, - run: crate::build_info::BUILD_RUN, - tag: crate::build_info::BUILD_TAG, - }, - nonce: nonce.as_deref(), - issued_at: rfc3339(now), - }; - let Ok(payload) = serde_json::to_vec(&evidence) else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "The evidence could not be written.", - ); - }; - let signature = match sign(&config.context, &payload, private_key) { - Ok(signature) => signature, - Err(message) => { - log::error!("attestation key `{key_id}` is unusable: {message}"); - return error_response( - StatusCode::SERVICE_UNAVAILABLE, - "The signing key could not be read, so no evidence can be signed.", - ); - } - }; - let envelope = json!({ - "evidence": serde_json::from_slice::(&payload).unwrap_or(Value::Null), - "payload": URL_SAFE_NO_PAD.encode(&payload), - "signature": { - "kid": key_id, - "alg": ALGORITHM, - "value": URL_SAFE_NO_PAD.encode(signature), - }, - "verify": config.verify_url, - }); - if req.uri().path().ends_with(".json") { - let body = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_owned()); - response(StatusCode::OK, "application/json", body) - } else { - response( - StatusCode::OK, - "text/html; charset=utf-8", - render_page( - &config.operator, - &config.verify_url, - &json_address(req), - &envelope, - ), - ) - } -} - -/// The absolute address of the JSON form of the page being served, from the -/// request's scheme and the host it was addressed to. -/// -/// The scheme follows a `Forwarded` or `X-Forwarded-Proto` header when one is -/// sent. The host is read the way [`request_host`] reads the one the evidence -/// signs, so the link and the signature name the same host, and a forwarded -/// host header is never read. -fn json_address(req: &Request) -> String { - let info = - crate::http_util::RequestInfo::from_request(req, &crate::platform::ClientInfo::default()); - let authority = req - .headers() - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .or_else(|| req.uri().authority().map(http::uri::Authority::as_str)) - .unwrap_or_default(); - format!("{}://{authority}{}.json", info.scheme, req.uri().path()) -} - -/// Set once the process has warned that it signs with the last key built in. -static LAST_KEY_WARNED: AtomicBool = AtomicBool::new(false); - -/// Whether this is the first time `warned` is asked, setting it. -fn first_time(warned: &AtomicBool) -> bool { - !warned.swap(true, Ordering::Relaxed) -} - -/// The key in force at `now`, being the latest to have started. Warns once -/// for each process when no later key follows it, because the build must -/// then be refreshed. -fn key_in_force<'a>(schedule: &'a [ScheduledKey<'a>], now: u64) -> Option<&'a ScheduledKey<'a>> { - let now = i64::try_from(now).ok()?; - let current = schedule - .iter() - .filter(|(_, starts_at, _)| *starts_at <= now) - .max_by_key(|(_, starts_at, _)| *starts_at)?; - if !schedule - .iter() - .any(|(_, starts_at, _)| *starts_at > current.1) - && first_time(&LAST_KEY_WARNED) - { - log::warn!( - "attestation key `{}` is the last one built in, so the build must be refreshed", - current.0 - ); - } - Some(current) -} - -/// The bytes a signature covers, being the context, one line feed, then the -/// evidence. -#[must_use] -pub fn signed_bytes(context: &str, payload: &[u8]) -> Vec { - let mut message = Vec::with_capacity(context.len() + 1 + payload.len()); - message.extend_from_slice(context.as_bytes()); - message.push(b'\n'); - message.extend_from_slice(payload); - message -} - -/// Signs `payload` under `context` with a key held as base64 DER text, -/// returning the raw 64 byte `r` and `s` pair. -fn sign(context: &str, payload: &[u8], key_text: &str) -> Result, &'static str> { - let signature: Signature = signing_key::read(key_text)?.sign(&signed_bytes(context, payload)); - Ok(signature.to_bytes().to_vec()) -} - -/// The caller's nonce, when it asked for one and it is well formed. -fn nonce_from_query(query: Option<&str>) -> Result, &'static str> { - let Some(query) = query else { - return Ok(None); - }; - let Some((_, nonce)) = - url::form_urlencoded::parse(query.as_bytes()).find(|(name, _)| name == "nonce") - else { - return Ok(None); - }; - let well_formed = !nonce.is_empty() - && nonce.len() <= MAX_NONCE_LEN - && nonce - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); - if well_formed { - Ok(Some(nonce.into_owned())) - } else { - Err("A nonce is 1 to 64 characters from A to Z, a to z, 0 to 9, _ and -.") - } -} - -/// The host the request was addressed to, lowercased with any port removed. -fn request_host(req: &Request) -> String { - let raw = req - .headers() - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .or_else(|| req.uri().host()) - .unwrap_or_default(); - let without_port = match raw.rsplit_once(':') { - Some((host, port)) if port.bytes().all(|b| b.is_ascii_digit()) => host, - _ => raw, - }; - without_port.to_ascii_lowercase() -} - -/// Seconds since the epoch as RFC 3339 in UTC, to the second. -fn rfc3339(seconds: u64) -> String { - i64::try_from(seconds) - .ok() - .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) - .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) - .unwrap_or_default() -} - -fn response(status: StatusCode, content_type: &'static str, body: String) -> Response { - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) - .header(header::CACHE_CONTROL, HeaderValue::from_static("no-store")) - .header( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ) - .body(EdgeBody::from(body.into_bytes())) - .expect("should build attestation response") -} - -fn error_response(status: StatusCode, message: &str) -> Response { - response( - status, - "application/json", - json!({ "error": message }).to_string(), - ) -} - -/// The page form: the claim in words, the link to the verifier, the address -/// of the JSON form to copy, the envelope itself, then the standard followed. -fn render_page(operator: &str, verify_url: &str, json_url: &str, envelope: &Value) -> String { - let verifier = url::Url::parse(verify_url) - .ok() - .and_then(|url| url.host_str().map(str::to_owned)) - .unwrap_or_else(|| verify_url.to_owned()); - let pretty = serde_json::to_string_pretty(envelope).unwrap_or_else(|_| "{}".to_owned()); - let body = format!( - "

Attestation

\n\ -

This page is served by a deployment that says it is operated by {operator}. \ -Do not take this page's word for it.

\n\ -

Check this claim at {verifier}

\n\ -

To retrieve this data as JSON for use in your own verification checks use the URL

\n\ -
{json_url}\ -
\n\ -

The JSON response is

\n\ -
{pretty}
\n\ -

The attester, evidence and verifier on this page follow the \ -IETF Remote Attestation Procedures (RATS) Architecture, \ -RFC 9334.

\n", - operator = html_escape(operator), - href = escape_attribute(verify_url), - verifier = html_escape(&verifier), - json_url = html_escape(json_url), - pretty = html_escape(&pretty), - ); - let script = "document.getElementById(\"copy\").addEventListener(\"click\",function(){\ -var button=this;\ -navigator.clipboard.writeText(document.getElementById(\"json-url\").textContent)\ -.then(function(){button.textContent=\"Copied\";});});"; - html_document("Attestation", &body, script) -} - -/// The list of pages served beneath the prefix, at the prefix itself. -fn render_index(page: &str, json: &str) -> String { - let body = format!( - "

Information pages

\n\ -

This deployment serves these pages about itself.

\n\ -
    \n\ -
  • Attestation, who operates this deployment \ -and which build it runs, with a link to check the claim.
  • \n\ -
  • Attestation as JSON, the same signed \ -evidence as data, for your own verification checks.
  • \n\ -
\n", - page = escape_attribute(page), - json = escape_attribute(json), - ); - html_document("Information pages", &body, "") -} - -/// The answer for any other address beneath a prefix the deployment owns. -fn render_not_found(prefix: &str) -> String { - let body = format!( - "

Not found

\n\ -

Nothing is served at this address. The pages this deployment serves are listed at \ -/{prefix}.

\n", - prefix = escape_attribute(prefix), - ); - html_document("Not found", &body, "") -} - -/// The shell every page here shares. It loads nothing from anywhere else. -fn html_document(title: &str, body: &str, script: &str) -> String { - let script = if script.is_empty() { - String::new() - } else { - format!("\n") - }; - format!( - "\n\ -\n\ -\n\ -\n\ -\n\ -\n\ -{title}\n\ -\n\ -\n\ -\n\ -
\n\ -{body}\ -
\n\ -{script}\ -\n\ -\n", - title = html_escape(title), - ) -} - -fn escape_attribute(value: &str) -> String { - html_escape(value).replace('"', """) -} - -/// Validates the endpoint, an absolute path of two or more segments of lower -/// case letters, digits, `_` and `-`, the first beginning with an underscore -/// so the pages sit apart from the publisher's own, and without `.json`, -/// which names the JSON form. Neither the page nor the JSON form may be one -/// of the [`RESERVED_PATHS`], and the prefix may not be one of the -/// [`ORIGIN_ASSET_PREFIXES`]. -/// -/// # Errors -/// -/// Returns `invalid_endpoint` for any other shape, `reserved_endpoint`, -/// naming the address, when the page or the JSON form is reserved, and -/// `origin_prefix`, naming the prefix, when the prefix is one a framework -/// serves its assets from. -pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { - let segment_ok = |segment: &str| { - !segment.is_empty() - && segment - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') - }; - let segments: Vec<&str> = value - .strip_prefix('/') - .map(|rest| rest.split('/').collect()) - .unwrap_or_default(); - let well_formed = value.len() <= 64 - && segments.len() >= 2 - && segments.iter().all(|segment| segment_ok(segment)) - && segments - .first() - .is_some_and(|first| first.len() > 1 && first.starts_with('_')); - if !well_formed { - return Err(ValidationError::new("invalid_endpoint")); - } - if let Some(reserved) = reserved_path_taken(value, RESERVED_PATHS) { - let mut error = ValidationError::new("reserved_endpoint"); - error.message = Some(format!("{reserved} is answered by the deployment itself").into()); - return Err(error); - } - let prefix = prefix_of(value); - if ORIGIN_ASSET_PREFIXES.contains(&prefix) { - let mut error = ValidationError::new("origin_prefix"); - error.message = - Some(format!("/{prefix} is where a web framework serves its assets").into()); - return Err(error); - } - Ok(()) -} - -/// The first of `reserved` that the endpoint's page or JSON form would take. -fn reserved_path_taken(endpoint: &str, reserved: &[&'static str]) -> Option<&'static str> { - let addresses = paths_of(endpoint); - reserved.iter().copied().find(|route| { - addresses - .iter() - .any(|address| route_answers(route, address)) - }) -} - -/// Whether a route answers an address, a segment in braces answering any one -/// segment. -fn route_answers(route: &str, address: &str) -> bool { - let mut route_segments = route.split('/'); - let mut address_segments = address.split('/'); - loop { - match (route_segments.next(), address_segments.next()) { - (None, None) => return true, - (Some(expected), Some(given)) if expected == given || expected.starts_with('{') => {} - _ => return false, - } - } -} - -fn validate_single_line(value: &str) -> Result<(), ValidationError> { - if value.chars().any(char::is_control) { - Err(ValidationError::new("control_character")) - } else { - Ok(()) - } -} - -fn validate_https_url(value: &str) -> Result<(), ValidationError> { - match url::Url::parse(value) { - Ok(url) if url.scheme() == "https" && url.host_str().is_some() => Ok(()), - _ => Err(ValidationError::new("invalid_https_url")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD; - use p256::ecdsa::VerifyingKey; - use p256::ecdsa::signature::Verifier as _; - use p256::pkcs8::{DecodePublicKey as _, EncodePublicKey as _}; - - use crate::test_support::tests::crate_test_settings_str; - - const CONTEXT: &str = "example-attestation:v1"; - const FIRST: [u8; 32] = [0x11; 32]; - const SECOND: [u8; 32] = [0x22; 32]; - const FIRST_STARTS: i64 = 1_000; - const SECOND_STARTS: i64 = 2_000; - - fn secret_key(scalar: &[u8; 32]) -> p256::SecretKey { - p256::SecretKey::from_slice(scalar).expect("a valid scalar") - } - - /// A key as a schedule holds it, base64 of its SEC1 DER form. - fn key_text(scalar: &[u8; 32]) -> String { - STANDARD.encode(secret_key(scalar).to_sec1_der().expect("encodes")) - } - - fn settings_with_attestation() -> Settings { - let mut settings = - Settings::from_toml(&crate_test_settings_str()).expect("test settings should load"); - settings.attestation = Some(AttestationConfig { - endpoint: "/_ex/attestation".to_owned(), - operator: "Example Operator".to_owned(), - context: CONTEXT.to_owned(), - verify_url: "https://verifier.example/verify?host=test-publisher.com".to_owned(), - }); - settings - } - - fn request(uri: &str) -> Request { - Request::builder() - .uri(uri) - .header(header::HOST, "Test-Publisher.com:443") - .body(EdgeBody::empty()) - .expect("request") - } - - fn platform() -> PlatformIdentity { - PlatformIdentity { - name: "example-platform".to_owned(), - service_id: "svc-1".to_owned(), - service_version: "39".to_owned(), - staging: false, - pop: "LHR".to_owned(), - } - } - - /// Answers from a two key schedule at `now`. - fn answer_at(settings: &Settings, uri: &str, now: u64) -> Response { - let first = key_text(&FIRST); - let second = key_text(&SECOND); - let schedule = [ - ("example-first", FIRST_STARTS, first.as_str()), - ("example-second", SECOND_STARTS, second.as_str()), - ]; - answer(settings, &platform(), &request(uri), &schedule, Some(now)) - } - - fn body_bytes(response: Response) -> Vec { - response - .into_body() - .into_bytes() - .expect("a buffered body") - .to_vec() - } - - fn json_body(response: Response) -> Value { - serde_json::from_slice(&body_bytes(response)).expect("a JSON body") - } - - fn verifying_key(scalar: &[u8; 32]) -> VerifyingKey { - let spki = secret_key(scalar) - .public_key() - .to_public_key_der() - .expect("encodes"); - VerifyingKey::from_public_key_der(spki.as_bytes()).expect("decodes") - } - - fn payload_and_signature(envelope: &Value) -> (Vec, Signature) { - let payload = URL_SAFE_NO_PAD - .decode(envelope["payload"].as_str().expect("payload")) - .expect("base64url payload"); - let raw = URL_SAFE_NO_PAD - .decode(envelope["signature"]["value"].as_str().expect("value")) - .expect("base64url signature"); - assert_eq!(raw.len(), 64, "the signature is the raw r and s pair"); - (payload, Signature::from_slice(&raw).expect("64 bytes")) - } - - /// A build given no schedule, an empty one or blank lines compiles a - /// schedule with no key in it, which is what makes the endpoint answer - /// 503. - #[test] - fn a_build_given_no_schedule_compiles_an_empty_one() { - let empty = "const ATTESTATION_KEYS: &[(&str, i64, &str)] = &[];"; - - for (case, schedule) in [ - ("no schedule", None), - ("an empty file", Some("")), - ("blank lines", Some("\n \n\r\n")), - ] { - assert_eq!( - key_schedule::constant_source(schedule).as_deref(), - Ok(empty), - "{case} should compile an empty schedule" - ); - } - } - - /// Each line becomes one key, in the order written, whichever line ending - /// the file was saved with. - #[test] - fn each_line_of_a_schedule_becomes_a_key() { - let first = key_text(&FIRST); - let second = key_text(&SECOND); - let expected = format!( - "const ATTESTATION_KEYS: &[(&str, i64, &str)] = &[\ - (\"example-first\", 1000, {first:?}),(\"example-second\", 2000, {second:?}),];" - ); - - for ending in ["\n", "\r\n"] { - let schedule = format!( - "example-first\t1000\t{first}{ending}example-second\t2000\t{second}{ending}" - ); - assert_eq!( - key_schedule::constant_source(Some(&schedule)), - Ok(expected.clone()), - "should read both keys from a file with {ending:?} line endings" - ); - } - } - - /// A line of any other shape stops the build. Its key is never repeated - /// in the message, and nothing it holds can end the string it would be - /// written in. - #[test] - fn a_schedule_line_of_any_other_shape_is_refused_without_showing_the_key() { - let key = key_text(&FIRST); - let cases = [ - ("two fields", format!("example-first\t{key}")), - ("four fields", format!("example-first\t1000\t{key}\textra")), - ( - "spaces in place of tabs", - format!("example-first 1000 {key}"), - ), - ( - "a start that is not a number", - format!("example-first\tsoon\t{key}"), - ), - ("an empty key id", format!("\t1000\t{key}")), - ("an empty key", "example-first\t1000\t".to_owned()), - ( - "a key id that would end its string", - format!("example\"); evil(\"\t1000\t{key}"), - ), - ( - "a key that would end its string", - format!("example-first\t1000\t{key}\"), (\"x"), - ), - ( - "a key holding a backslash", - format!("example-first\t1000\t{key}\\"), - ), - ]; - for (case, line) in cases { - let schedule = format!("example-zero\t500\t{key}\n{line}\n"); - let refused = key_schedule::constant_source(Some(&schedule)) - .expect_err("should refuse the schedule"); - - assert!( - refused.starts_with("line 2 "), - "{case} should be refused by its line number, got: {refused}" - ); - assert!( - !refused.contains(&key), - "{case} should be refused without repeating the key" - ); - } - } - - /// Two lines sharing a key id or a start stop the build, so the key in - /// force never depends on the order of the lines and a key id names one - /// key. - #[test] - fn a_schedule_repeating_a_key_id_or_a_start_is_refused() { - let first = key_text(&FIRST); - let second = key_text(&SECOND); - for (case, schedule, refusal) in [ - ( - "a repeated key id", - format!("example-first\t1000\t{first}\nexample-first\t2000\t{second}\n"), - "line 2 should not repeat the key id of line 1", - ), - ( - "a repeated start", - format!("example-first\t1000\t{first}\n\nexample-second\t1000\t{second}\n"), - "line 3 should not start at the same second as line 1", - ), - ] { - assert_eq!( - key_schedule::constant_source(Some(&schedule)), - Err(refusal.to_owned()), - "{case} should be refused" - ); - } - } - - /// A key that cannot sign stops the build however late it starts, so no - /// built binary carries one. The message names the key's line and id and - /// never the key. - #[test] - fn a_schedule_key_that_cannot_sign_is_refused_without_showing_it() { - let first = key_text(&FIRST); - let latest = i64::MAX; - let ed25519_seed = STANDARD.encode([0x33u8; 32]); - for (case, key, reason) in [ - ( - "a key that is not base64", - "not-base64-at-all", - "the key is not base64", - ), - ( - "a key that is not a P-256 private key", - ed25519_seed.as_str(), - "the key is not a P-256 private key in SEC1 or PKCS #8 DER", - ), - ] { - let schedule = - format!("example-first\t1000\t{first}\nexample-later\t{latest}\t{key}\n"); - let refused = key_schedule::constant_source(Some(&schedule)) - .expect_err("should refuse the schedule"); - - assert_eq!( - refused, - format!("line 2 holds key `example-later`, which cannot sign, as {reason}"), - "{case} should be refused" - ); - assert!(!refused.contains(key), "{case} should never show the key"); - } - } - - /// A verifier with only the public key, in the `SubjectPublicKeyInfo` DER - /// form a key schedule publishes, accepts the signature over exactly the - /// context, one line feed, then the payload. Without the context, without - /// its line feed, or under another context it never verifies, which is - /// what binds a signature to its purpose. - #[test] - fn the_signature_covers_the_context_a_line_feed_and_the_payload() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json?nonce=abc123", - 1_500, - )); - assert_eq!(envelope["signature"]["alg"], "ES256"); - let (payload, signature) = payload_and_signature(&envelope); - let verifier = verifying_key(&FIRST); - - let bytes = signed_bytes(CONTEXT, &payload); - assert!(bytes.starts_with(CONTEXT.as_bytes())); - assert_eq!(bytes[CONTEXT.len()], b'\n'); - assert_eq!(&bytes[CONTEXT.len() + 1..], payload.as_slice()); - verifier - .verify(&bytes, &signature) - .expect("the signature covers context, line feed and payload"); - - for (case, message) in [ - ("no context", payload.clone()), - ( - "the context without its line feed", - [CONTEXT.as_bytes(), payload.as_slice()].concat(), - ), - ( - "another context", - signed_bytes("another-purpose:v1", &payload), - ), - ] { - assert!( - verifier.verify(&message, &signature).is_err(), - "the signature must not verify with {case}", - ); - } - } - - /// Each key signs from its own start until the next key's start, and the - /// evidence of a time verifies only with the key in force then. - #[test] - fn the_key_in_force_is_the_latest_to_have_started() { - let settings = settings_with_attestation(); - for (now, kid, signing, other) in [ - (1_000, "example-first", &FIRST, &SECOND), - (1_500, "example-first", &FIRST, &SECOND), - (1_999, "example-first", &FIRST, &SECOND), - (2_000, "example-second", &SECOND, &FIRST), - (2_500, "example-second", &SECOND, &FIRST), - ] { - let envelope = json_body(answer_at(&settings, "/_ex/attestation.json", now)); - assert_eq!(envelope["signature"]["kid"], kid, "at {now}"); - let (payload, signature) = payload_and_signature(&envelope); - let bytes = signed_bytes(CONTEXT, &payload); - assert!( - verifying_key(signing).verify(&bytes, &signature).is_ok(), - "at {now} the key in force signs" - ); - assert!( - verifying_key(other).verify(&bytes, &signature).is_err(), - "at {now} the other key must not verify" - ); - } - } - - /// The warning that the last key built in is signing is given once for - /// each process, not on every request. - #[test] - fn the_last_key_warning_is_given_once() { - let warned = AtomicBool::new(false); - - assert!(first_time(&warned), "should warn the first time"); - assert!(!first_time(&warned), "should not warn again"); - assert!(!first_time(&warned), "should not warn on any later request"); - } - - /// Nothing is signed before the first key starts, from an empty schedule, - /// with a key that is not a P-256 private key, or in a build given no - /// schedule, which is every build its builder names no schedule for. - #[test] - fn no_usable_key_in_force_answers_service_unavailable() { - assert!(ATTESTATION_KEYS.is_empty(), "test builds carry no keys"); - let settings = settings_with_attestation(); - let first = key_text(&FIRST); - let second = key_text(&SECOND); - let ed25519_seed = STANDARD.encode([0x33u8; 32]); - let no_key = "No signing key is built in for this time"; - let unreadable = "The signing key could not be read"; - let cases: [(&str, Vec>, u64, &str); 5] = [ - ( - "before the first key starts", - vec![ - ("example-first", FIRST_STARTS, first.as_str()), - ("example-second", SECOND_STARTS, second.as_str()), - ], - 999, - no_key, - ), - ("an empty schedule", Vec::new(), 1_500, no_key), - ( - "a key that is not base64", - vec![("example-bad", FIRST_STARTS, "not base64 at all")], - 1_500, - unreadable, - ), - ( - "a key that is not a P-256 private key", - vec![("example-bad", FIRST_STARTS, ed25519_seed.as_str())], - 1_500, - unreadable, - ), - ( - "the schedule this build was given", - ATTESTATION_KEYS.to_vec(), - crate::ec::current_timestamp(), - no_key, - ), - ]; - for (case, schedule, now, says) in cases { - let response = answer( - &settings, - &platform(), - &request("/_ex/attestation.json"), - &schedule, - Some(now), - ); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{case}"); - let body = json_body(response); - assert!( - body["error"] - .as_str() - .is_some_and(|error| error.starts_with(says)), - "{case}: {body}" - ); - } - } - - /// A clock that cannot be read signs nothing, even with a key in force - /// from the epoch, because the evidence would carry a time that never - /// happened. - #[test] - fn a_clock_that_cannot_be_read_signs_nothing() { - let first = key_text(&FIRST); - let schedule = [("example-first", 0, first.as_str())]; - for uri in ["/_ex/attestation.json", "/_ex/attestation"] { - let response = answer( - &settings_with_attestation(), - &platform(), - &request(uri), - &schedule, - None, - ); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}"); - assert_eq!( - json_body(response), - json!({ "error": "The clock could not be read, so no evidence can be signed." }), - "{uri} should sign nothing" - ); - } - } - - /// The decoded evidence shown to people is exactly the payload signed. - #[test] - fn the_evidence_shown_is_the_payload_signed() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json?nonce=abc123", - 1_500, - )); - let (payload, _) = payload_and_signature(&envelope); - let decoded: Value = serde_json::from_slice(&payload).expect("JSON payload"); - - assert_eq!(decoded, envelope["evidence"]); - assert_eq!(decoded["v"], 1); - assert_eq!(decoded["operator"], "Example Operator"); - assert_eq!(decoded["host"], "test-publisher.com"); - assert_eq!(decoded["nonce"], "abc123"); - assert_eq!(decoded["platform"]["serviceVersion"], "39"); - assert_eq!(decoded["build"]["version"], crate::build_info::VERSION); - assert_eq!(decoded["issuedAt"], "1970-01-01T00:25:00Z"); - assert_eq!( - envelope["verify"], - "https://verifier.example/verify?host=test-publisher.com" - ); - } - - /// With no nonce asked for, the evidence carries none. - #[test] - fn no_nonce_is_signed_unless_asked_for() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json", - 1_500, - )); - assert!(envelope["evidence"].get("nonce").is_none()); - } - - #[test] - fn a_malformed_nonce_is_refused() { - let settings = settings_with_attestation(); - for uri in [ - "/_ex/attestation.json?nonce=", - "/_ex/attestation.json?nonce=a%20b", - "/_ex/attestation.json?nonce=%3Cscript%3E", - ] { - let response = answer_at(&settings, uri, 1_500); - assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); - } - let long = format!("/_ex/attestation.json?nonce={}", "a".repeat(65)); - let response = answer_at(&settings, &long, 1_500); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - /// A key held as PKCS #8 signs the same way as one held as SEC1. - #[test] - fn a_pkcs8_key_signs_too() { - use p256::pkcs8::EncodePrivateKey as _; - let pkcs8 = STANDARD.encode( - secret_key(&FIRST) - .to_pkcs8_der() - .expect("encodes") - .as_bytes(), - ); - let schedule = [("example-pkcs8", FIRST_STARTS, pkcs8.as_str())]; - let response = answer( - &settings_with_attestation(), - &platform(), - &request("/_ex/attestation.json"), - &schedule, - Some(1_500), - ); - assert_eq!(response.status(), StatusCode::OK); - } - - /// The page never says the evidence is valid, because only the verifier - /// can, and it links there. - #[test] - fn the_page_links_to_the_verifier_and_claims_nothing() { - let response = answer_at(&settings_with_attestation(), "/_ex/attestation", 1_500); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers()[header::CONTENT_TYPE], - "text/html; charset=utf-8" - ); - let html = String::from_utf8(body_bytes(response)).expect("utf-8"); - assert!(html.contains("Check this claim at verifier.example")); - assert!(html.contains("href=\"https://verifier.example/verify?host=test-publisher.com\"")); - assert!(html.contains("Do not take this page's word for it.")); - // The JSON shown is escaped as text, which leaves quotes as they are. - assert!( - !html.contains("\"valid\""), - "the JSON shown carries no claim of validity" - ); - assert!(html.contains( - "To retrieve this data as JSON for use in your own verification checks use the URL" - )); - assert!( - html.contains("/_ex/attestation.json"), - "the JSON address is offered to copy", - ); - assert!(html.contains("The JSON response is")); - assert!(html.contains("href=\"https://www.rfc-editor.org/rfc/rfc9334\"")); - } - - /// The address offered to copy is absolute, and its scheme is the one the - /// request arrived over. - #[test] - fn the_json_address_carries_the_scheme_and_host_the_request_used() { - let mut req = request("/_ex/attestation"); - assert_eq!( - json_address(&req), - "http://Test-Publisher.com:443/_ex/attestation.json" - ); - req.headers_mut() - .insert("x-forwarded-proto", HeaderValue::from_static("https")); - assert!(json_address(&req).starts_with("https://")); - } - - /// The link names the host the evidence signs, never a forwarded one, on - /// an adapter that leaves forwarded host headers in place. - #[test] - fn the_json_address_never_names_a_forwarded_host() { - for (name, value) in [ - ("x-forwarded-host", "relay.example"), - ("forwarded", "host=relay.example"), - ] { - let mut req = request("/_ex/attestation"); - req.headers_mut() - .insert(name, HeaderValue::from_static(value)); - - let address = json_address(&req); - - assert!( - address.contains("://Test-Publisher.com:443/") && !address.contains("relay"), - "{name} should not reach the link: {address}" - ); - } - } - - #[test] - fn answers_are_never_cached_and_readable_from_any_origin() { - let settings = settings_with_attestation(); - for uri in ["/_ex/attestation", "/_ex/attestation.json"] { - let response = answer_at(&settings, uri, 1_500); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); - assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); - } - } - - #[test] - fn the_paths_follow_the_endpoint_and_default_to_ts() { - let mut settings = settings_with_attestation(); - assert_eq!( - attestation_paths(&settings), - Some([ - "/_ex/attestation".to_owned(), - "/_ex/attestation.json".to_owned() - ]) - ); - if let Some(config) = settings.attestation.as_mut() { - config.endpoint = DEFAULT_ENDPOINT.to_owned(); - } - assert_eq!( - attestation_paths(&settings), - Some([ - "/_ts/attestation".to_owned(), - "/_ts/attestation.json".to_owned() - ]) - ); - settings.attestation = None; - assert_eq!(attestation_paths(&settings), None); - } - - #[test] - fn the_endpoint_is_an_absolute_path_under_an_underscore_prefix() { - for good in [ - "/_ts/attestation", - "/_ex/attestation", - "/_a1/proof-of_operator", - "/_ex/pages/attestation", - ] { - assert!(validate_endpoint(good).is_ok(), "{good}"); - } - let too_long = format!("/_ex/{}", "a".repeat(60)); - for bad in [ - "", - "/", - "_ex/attestation", - "/attestation", - "/ex/attestation", - "/_/attestation", - "/_ex", - "/_ex/", - "/_ex//attestation", - "/_Ex/attestation", - "/_ex/attestation.json", - "/_ex/attestation/", - "/_ex/attestation?nonce=1", - too_long.as_str(), - ] { - assert!(validate_endpoint(bad).is_err(), "{bad}"); - } - } - - /// An endpoint owns its prefix and answers `404` for every other address - /// beneath it, so a prefix a framework serves its assets from is refused, - /// naming it. - #[test] - fn an_endpoint_under_a_framework_asset_prefix_is_refused_naming_it() { - for prefix in ORIGIN_ASSET_PREFIXES { - let endpoint = format!("/{prefix}/attestation"); - let error = validate_endpoint(&endpoint).expect_err("should refuse the endpoint"); - - assert_eq!(error.code, "origin_prefix", "{endpoint} should be refused"); - assert_eq!( - error.message.as_deref(), - Some(format!("/{prefix} is where a web framework serves its assets").as_str()), - "{endpoint} should name its prefix" - ); - } - } - - #[test] - fn the_section_loads_from_a_document_and_refuses_unknown_fields() { - let base = crate_test_settings_str(); - let with_section = format!( - "{base}\n[attestation]\n\ - endpoint = \"/_ex/attestation\"\n\ - operator = \"Example Operator\"\n\ - context = \"example-attestation:v1\"\n\ - verify_url = \"https://verifier.example/verify\"\n" - ); - let settings = Settings::from_toml(&with_section).expect("the section loads"); - let config = settings.attestation.as_ref().expect("the section is read"); - assert_eq!(config.endpoint, "/_ex/attestation"); - assert_eq!(config.operator, "Example Operator"); - - let without_endpoint = with_section.replace("endpoint = \"/_ex/attestation\"\n", ""); - let settings = Settings::from_toml(&without_endpoint).expect("the endpoint has a default"); - assert_eq!( - settings.attestation.as_ref().map(|a| a.endpoint.as_str()), - Some(DEFAULT_ENDPOINT) - ); - - let bad_endpoint = with_section.replace("/_ex/attestation", "/_ex/attestation.json"); - assert!( - Settings::from_toml(&bad_endpoint).is_err(), - "the JSON form is derived, so an endpoint ending in .json is refused", - ); - - let with_key_name = format!("{with_section}signing_key = \"a_key\"\n"); - assert!( - Settings::from_toml(&with_key_name).is_err(), - "keys are built in, so a document naming one is refused", - ); - - let plain_http = with_section.replace("https://verifier", "http://verifier"); - assert!(Settings::from_toml(&plain_http).is_err()); - } - - /// Every field the evidence depends on is checked when the settings load. - /// The context is signed ahead of a line feed, so one holding a control - /// character would make that framing ambiguous, and the operator and the - /// context are each 1 to 64 characters. - #[test] - fn each_field_the_evidence_depends_on_is_checked_at_load() { - let base = crate_test_settings_str(); - let document = |operator: Option<&str>, context: Option<&str>, verify_url: Option<&str>| { - let mut text = format!("{base}\n[attestation]\n"); - for (key, value) in [ - ("operator", operator), - ("context", context), - ("verify_url", verify_url), - ] { - if let Some(value) = value { - text.push_str(&format!("{key} = \"{value}\"\n")); - } - } - text - }; - let operator = Some("Example Operator"); - let context = Some("example-attestation:v1"); - let verify_url = Some("https://verifier.example/verify"); - let longest = "a".repeat(64); - let too_long = "a".repeat(65); - - Settings::from_toml(&document( - Some(longest.as_str()), - Some(longest.as_str()), - verify_url, - )) - .expect("an operator and a context of 64 characters load"); - for (case, (operator, context, verify_url), refusal) in [ - ( - "a context holding a line feed", - (operator, Some("example\\nattestation"), verify_url), - "attestation.context: control_character", - ), - ( - "an empty operator", - (Some(""), context, verify_url), - "attestation.operator: length", - ), - ( - "a 65 character operator", - (Some(too_long.as_str()), context, verify_url), - "attestation.operator: length", - ), - ( - "an empty context", - (operator, Some(""), verify_url), - "attestation.context: length", - ), - ( - "a 65 character context", - (operator, Some(too_long.as_str()), verify_url), - "attestation.context: length", - ), - ( - "no operator", - (None, context, verify_url), - "missing field `operator`", - ), - ( - "no context", - (operator, None, verify_url), - "missing field `context`", - ), - ( - "no verify_url", - (operator, context, None), - "missing field `verify_url`", - ), - ( - "a verify_url with no host", - (operator, context, Some("https://")), - "attestation.verify_url: invalid_https_url", - ), - ] { - let Err(error) = Settings::from_toml(&document(operator, context, verify_url)) else { - panic!("{case} should be refused"); - }; - let text = format!("{error:?}"); - assert!( - text.contains(refusal), - "{case} should be refused as {refusal}: {text}" - ); - } - } - - /// An endpoint whose page or JSON form is an address the deployment - /// answers itself is refused when the settings load, naming that address, - /// because a second route there stops the router being built. - #[test] - fn an_endpoint_at_a_reserved_address_is_refused_naming_it() { - let base = crate_test_settings_str(); - let document = |endpoint: &str| { - format!( - "{base}\n[attestation]\n\ - endpoint = \"{endpoint}\"\n\ - operator = \"Example Operator\"\n\ - context = \"example-attestation:v1\"\n\ - verify_url = \"https://verifier.example/verify\"\n" - ) - }; - for (endpoint, reserved) in [ - ("/_ts/config", "/_ts/config"), - ("/_ts/permissions", "/_ts/permissions"), - ("/_ts/set-tester", "/_ts/set-tester"), - ("/_ts/api/v1/identify", "/_ts/api/v1/identify"), - ("/__ts/page-bids", "/__ts/page-bids"), - ] { - let Err(error) = Settings::from_toml(&document(endpoint)) else { - panic!("{endpoint} should be refused"); - }; - let text = format!("{error:?}"); - assert!( - text.contains("attestation.endpoint: reserved_endpoint"), - "{endpoint} should be refused as reserved: {text}" - ); - assert!( - text.contains(&format!( - "({reserved} is answered by the deployment itself)" - )), - "{endpoint} should name {reserved}: {text}" - ); - } - for endpoint in [ - DEFAULT_ENDPOINT, - "/_ex/attestation", - "/_ts/proof", - "/_ts/configuration", - ] { - let settings = Settings::from_toml(&document(endpoint)) - .unwrap_or_else(|error| panic!("{endpoint} should load: {error:?}")); - assert_eq!( - settings.attestation.as_ref().map(|a| a.endpoint.as_str()), - Some(endpoint) - ); - } - } - - /// The JSON form is checked as well as the page, and a segment in braces - /// stands for any one segment. - #[test] - fn both_forms_are_checked_against_the_reserved_addresses() { - assert_eq!( - reserved_path_taken("/_ex/page", &["/_ex/page.json"]), - Some("/_ex/page.json"), - "the JSON form clashes" - ); - assert_eq!( - reserved_path_taken("/_ex/page", &["/_ex/{name}"]), - Some("/_ex/{name}"), - "a segment in braces answers the page" - ); - assert_eq!( - reserved_path_taken("/_ex/page", &["/_ex", "/_ex/page/more", "/_ex/other.json"]), - None, - "a parent, a child and another JSON form do not clash" - ); - } - - fn request_with(method: Method, uri: &str) -> Request { - Request::builder() - .method(method) - .uri(uri) - .header(header::HOST, "Test-Publisher.com:443") - .body(EdgeBody::empty()) - .expect("request") - } - - fn html(response: Response) -> String { - String::from_utf8(body_bytes(response)).expect("utf-8") - } - - /// The routes as (path, every method) pairs. - fn routes_of(settings: &Settings) -> Vec<(String, bool)> { - prefix_routes(settings) - .into_iter() - .map(|route| (route.path, route.every_method)) - .collect() - } - - /// A deployment whose endpoint sits under a prefix of its own owns all - /// of that prefix and answers the default address with a redirect. The - /// default prefix gives up only the pages themselves, because other - /// routes share it. - #[test] - fn an_own_prefix_is_owned_and_the_default_is_shared() { - let mut settings = settings_with_attestation(); - assert_eq!( - routes_of(&settings), - [ - ("/_ex/attestation".to_owned(), true), - ("/_ex/attestation.json".to_owned(), true), - ("/_ts/attestation".to_owned(), false), - ("/_ts/attestation.json".to_owned(), false), - ("/_ex".to_owned(), true), - ("/_ex/".to_owned(), true), - ("/_ex/{*rest}".to_owned(), true), - ] - ); - - if let Some(config) = settings.attestation.as_mut() { - config.endpoint = "/_ts/proof".to_owned(); - } - assert_eq!( - routes_of(&settings), - [ - ("/_ts/proof".to_owned(), false), - ("/_ts/proof.json".to_owned(), false), - ("/_ts/attestation".to_owned(), false), - ("/_ts/attestation.json".to_owned(), false), - ] - ); - - if let Some(config) = settings.attestation.as_mut() { - config.endpoint = DEFAULT_ENDPOINT.to_owned(); - } - assert_eq!( - routes_of(&settings), - [ - ("/_ts/attestation".to_owned(), false), - ("/_ts/attestation.json".to_owned(), false), - ] - ); - - settings.attestation = None; - assert!(prefix_routes(&settings).is_empty()); - } - - /// When the endpoint is not the default, a read of the default address - /// is sent to the endpoint, page to page and JSON to JSON, with the query - /// kept so a nonce survives. Other methods there are not found. - #[test] - fn reads_of_the_default_address_redirect_to_the_endpoint() { - let settings = settings_with_attestation(); - for (method, uri, location) in [ - (Method::GET, "/_ts/attestation", "/_ex/attestation"), - (Method::HEAD, "/_ts/attestation", "/_ex/attestation"), - ( - Method::GET, - "/_ts/attestation.json?nonce=abc123", - "/_ex/attestation.json?nonce=abc123", - ), - ( - Method::HEAD, - "/_ts/attestation.json", - "/_ex/attestation.json", - ), - ] { - let case = format!("{method} {uri}"); - let response = handle_prefix(&settings, &platform(), &request_with(method, uri)); - assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY, "{case}"); - assert_eq!(response.headers()[header::LOCATION], location, "{case}"); - assert_eq!( - response.headers()[header::CACHE_CONTROL], - "no-store", - "{case}" - ); - assert_eq!(json_body(response)["location"], location, "{case}"); - } - - let post = handle_prefix( - &settings, - &platform(), - &request_with(Method::POST, "/_ts/attestation"), - ); - assert_eq!(post.status(), StatusCode::NOT_FOUND); - } - - /// With the default endpoint the default address is the endpoint, so it - /// is answered there and nothing redirects. - #[test] - fn the_default_address_is_answered_directly_under_the_default_endpoint() { - let mut settings = settings_with_attestation(); - if let Some(config) = settings.attestation.as_mut() { - config.endpoint = DEFAULT_ENDPOINT.to_owned(); - } - for uri in ["/_ts/attestation", "/_ts/attestation.json"] { - let response = handle_prefix(&settings, &platform(), &request(uri)); - assert_eq!( - response.status(), - StatusCode::SERVICE_UNAVAILABLE, - "{uri} reaches the handler, which has no key built in", - ); - assert!(response.headers().get(header::LOCATION).is_none(), "{uri}"); - } - let response = handle_prefix(&settings, &platform(), &request("/_ex/attestation")); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - /// With no `[attestation]` section nothing is signed. The prefix handler - /// and the page handler both answer every address not found, in JSON, - /// even with a key in force. - #[test] - fn without_a_section_every_address_is_not_found_in_json() { - let mut settings = settings_with_attestation(); - settings.attestation = None; - for uri in [ - "/_ts/attestation", - "/_ts/attestation.json?nonce=abc123", - "/_ex/attestation", - ] { - for (handler, response) in [ - ( - "prefix", - handle_prefix(&settings, &platform(), &request(uri)), - ), - ("page", answer_at(&settings, uri, 1_500)), - ] { - let case = format!("{handler} {uri}"); - assert_eq!(response.status(), StatusCode::NOT_FOUND, "{case}"); - assert_eq!( - response.headers()[header::CONTENT_TYPE], - "application/json", - "{case}" - ); - assert_eq!( - json_body(response)["error"], - "This deployment serves no attestation.", - "{case}" - ); - } - } - } - - /// The prefix itself lists the pages beneath it. - #[test] - fn the_prefix_lists_its_pages() { - let settings = settings_with_attestation(); - for uri in ["/_ex", "/_ex/"] { - let response = handle_prefix(&settings, &platform(), &request(uri)); - assert_eq!(response.status(), StatusCode::OK, "{uri}"); - let page = html(response); - assert!(page.contains("href=\"/_ex/attestation\""), "{uri}"); - assert!(page.contains("href=\"/_ex/attestation.json\""), "{uri}"); - } - } - - /// Any other address beneath an owned prefix is not found, and says where - /// the pages are. - #[test] - fn any_other_address_beneath_the_prefix_is_not_found() { - let settings = settings_with_attestation(); - let response = handle_prefix(&settings, &platform(), &request("/_ex/nothing/here")); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(html(response).contains("href=\"/_ex\"")); - - let post = handle_prefix( - &settings, - &platform(), - &request_with(Method::POST, "/_ex/attestation"), - ); - assert_eq!( - post.status(), - StatusCode::NOT_FOUND, - "only reads are answered" - ); - } - - /// A read of an attestation address reaches the attestation handler, - /// which with no key built in answers that it cannot sign. - #[test] - fn a_read_of_an_attestation_address_reaches_the_attestation_handler() { - let settings = settings_with_attestation(); - for method in [Method::GET, Method::HEAD] { - let response = handle_prefix( - &settings, - &platform(), - &request_with(method.clone(), "/_ex/attestation.json"), - ); - assert_eq!( - response.status(), - StatusCode::SERVICE_UNAVAILABLE, - "{method}" - ); - } - } - - /// The evidence signed for a request whose Host header is `host`, with - /// any further headers given. - fn evidence_for(host: &str, headers: &[(&str, &str)]) -> Value { - let mut builder = Request::builder() - .uri("/_ex/attestation.json") - .header(header::HOST, host); - for (name, value) in headers { - builder = builder.header(*name, *value); - } - let req = builder.body(EdgeBody::empty()).expect("request"); - let first = key_text(&FIRST); - let schedule = [("example-first", FIRST_STARTS, first.as_str())]; - let envelope = json_body(answer( - &settings_with_attestation(), - &platform(), - &req, - &schedule, - Some(1_500), - )); - let (payload, _) = payload_and_signature(&envelope); - serde_json::from_slice(&payload).expect("JSON payload") - } - - /// The signed host is the Host header the request arrived with. A - /// forwarded host header, which a relay can set, never replaces it. - #[test] - fn forwarded_host_headers_never_become_the_signed_host() { - let evidence = evidence_for( - "test-publisher.com", - &[ - ("x-forwarded-host", "relay.example"), - ("forwarded", "host=relay.example;proto=https"), - ("x-original-host", "relay.example"), - ], - ); - assert_eq!(evidence["host"], "test-publisher.com"); - } - - /// The signed publisher comes from the settings, so a request under - /// another name changes the signed host and never the publisher. - #[test] - fn the_signed_publisher_comes_from_the_settings() { - let settings = settings_with_attestation(); - let evidence = evidence_for("www.relay.example", &[]); - assert_eq!(evidence["host"], "www.relay.example"); - assert_eq!(evidence["publisher"], settings.publisher.domain.as_str()); - } - - /// Without a Host header the host comes from the request address, lower - /// cased and without its port. An IPv6 literal keeps its brackets. - #[test] - fn the_host_falls_back_to_the_request_address() { - let absolute = Request::builder() - .uri("https://Test-Publisher.com:8443/_ex/attestation.json") - .body(EdgeBody::empty()) - .expect("request"); - assert_eq!(request_host(&absolute), "test-publisher.com"); - let literal = Request::builder() - .uri("/_ex/attestation.json") - .header(header::HOST, "[::1]:8080") - .body(EdgeBody::empty()) - .expect("request"); - assert_eq!(request_host(&literal), "[::1]"); - } - - /// Changing a signed byte, here the host, stops the signature verifying, - /// so a copy cannot be edited to name another site. - #[test] - fn an_altered_payload_fails_verification() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json?nonce=abc123", - 1_500, - )); - let (payload, signature) = payload_and_signature(&envelope); - let text = String::from_utf8(payload).expect("utf-8"); - let altered = text.replacen( - "\"host\":\"test-publisher.com\"", - "\"host\":\"relay.example\"", - 1, - ); - assert_ne!(altered, text, "the host is in the payload"); - assert!( - verifying_key(&FIRST) - .verify(&signed_bytes(CONTEXT, altered.as_bytes()), &signature) - .is_err(), - "an altered payload must not verify", - ); - } - - /// Signing is deterministic, as RFC 6979 describes, so the same evidence - /// always carries the same signature. - #[test] - fn the_same_evidence_carries_the_same_signature() { - let settings = settings_with_attestation(); - let uri = "/_ex/attestation.json?nonce=abc123"; - let first = json_body(answer_at(&settings, uri, 1_500)); - let second = json_body(answer_at(&settings, uri, 1_500)); - assert_eq!(first["payload"], second["payload"]); - assert_eq!(first["signature"]["value"], second["signature"]["value"]); - } - - /// The envelope carries exactly the documented fields. - #[test] - fn the_envelope_carries_exactly_the_documented_fields() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json", - 1_500, - )); - let names = |value: &Value| { - let mut names: Vec = value - .as_object() - .expect("an object") - .keys() - .cloned() - .collect(); - names.sort(); - names - }; - assert_eq!( - names(&envelope), - ["evidence", "payload", "signature", "verify"] - ); - assert_eq!(names(&envelope["signature"]), ["alg", "kid", "value"]); - } - - /// The evidence fields are signed in the documented order. - #[test] - fn the_evidence_fields_are_signed_in_the_documented_order() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json?nonce=abc123", - 1_500, - )); - let (payload, _) = payload_and_signature(&envelope); - let text = String::from_utf8(payload).expect("utf-8"); - let order = [ - "\"v\":", - "\"operator\":", - "\"publisher\":", - "\"host\":", - "\"platform\":", - "\"name\":", - "\"serviceId\":", - "\"serviceVersion\":", - "\"staging\":", - "\"pop\":", - "\"build\":", - "\"version\":", - "\"builtAt\":", - "\"commit\":", - "\"run\":", - "\"nonce\":", - "\"issuedAt\":", - ]; - let positions: Vec = order - .iter() - .map(|field| { - text.find(field) - .unwrap_or_else(|| panic!("{field} is signed: {text}")) - }) - .collect(); - assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{text}"); - } - - /// The longest nonce allowed is signed back unchanged, and a nonce sent - /// percent-encoded is signed as its decoded value. - #[test] - fn a_nonce_is_signed_back_as_sent() { - let settings = settings_with_attestation(); - let longest = "a".repeat(64); - let envelope = json_body(answer_at( - &settings, - &format!("/_ex/attestation.json?nonce={longest}"), - 1_500, - )); - let (payload, _) = payload_and_signature(&envelope); - let evidence: Value = serde_json::from_slice(&payload).expect("JSON payload"); - assert_eq!(evidence["nonce"], longest.as_str()); - let encoded = json_body(answer_at( - &settings, - "/_ex/attestation.json?nonce=Ab_9%2Dz", - 1_500, - )); - assert_eq!(encoded["evidence"]["nonce"], "Ab_9-z"); - } - - /// Markup from the document reaches the page as text, in the claim and - /// in the JSON shown. - #[test] - fn the_page_escapes_what_it_shows() { - let mut settings = settings_with_attestation(); - if let Some(config) = settings.attestation.as_mut() { - config.operator = "Example & Co".to_owned(); - } - let page = html(answer_at(&settings, "/_ex/attestation", 1_500)); - assert!(!page.contains("Example"), "{page}"); - assert!(page.contains("<b>Example</b> & Co")); - } - - /// The evidence reports the build identity compiled in and the platform - /// as the adapter describes it, staging included. - #[test] - fn the_evidence_reports_the_build_and_the_platform() { - let envelope = json_body(answer_at( - &settings_with_attestation(), - "/_ex/attestation.json", - 1_500, - )); - let evidence = &envelope["evidence"]; - assert_eq!(evidence["build"]["commit"], crate::build_info::COMMIT); - assert_eq!(evidence["build"]["run"], crate::build_info::BUILD_RUN); - assert_eq!(evidence["build"]["tag"], crate::build_info::BUILD_TAG); - assert_eq!(evidence["build"]["builtAt"], crate::build_info::BUILT_AT); - assert_eq!(evidence["platform"]["name"], "example-platform"); - assert_eq!(evidence["platform"]["serviceId"], "svc-1"); - assert_eq!(evidence["platform"]["staging"], false); - assert_eq!(evidence["platform"]["pop"], "LHR"); - - let mut staged = platform(); - staged.staging = true; - let first = key_text(&FIRST); - let schedule = [("example-first", FIRST_STARTS, first.as_str())]; - let from_staging = json_body(answer( - &settings_with_attestation(), - &staged, - &request("/_ex/attestation.json"), - &schedule, - Some(1_500), - )); - assert_eq!(from_staging["evidence"]["platform"]["staging"], true); - } -} diff --git a/crates/trusted-server-core/src/build_info.rs b/crates/trusted-server-core/src/build_info.rs deleted file mode 100644 index 38466d2904..0000000000 --- a/crates/trusted-server-core/src/build_info.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! What this build reports about itself, fixed when it was compiled. -//! -//! `env!` is resolved by the compiler. Some hosts give a WebAssembly guest no -//! custom environment variable when it runs, so a fact read then would read -//! as absent there. -//! -//! | Constant | Build input | Without it | -//! | --- | --- | --- | -//! | [`BUILT_AT`] | `SOURCE_DATE_EPOCH`, in seconds | The time of the build | -//! | [`COMMIT`] | `TRUSTED_SERVER_COMMIT` | `unknown` | -//! | [`BUILD_RUN`] | `TRUSTED_SERVER_BUILD_RUN` | `unknown` | -//! | [`BUILD_TAG`] | `TRUSTED_SERVER_BUILD_TAG` | `unknown` | -//! -//! Each is the builder's own report. Nothing here measures the binary. - -/// The crate version. -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// When this build was made, RFC 3339 in UTC to the second. -pub const BUILT_AT: &str = env!("TRUSTED_SERVER_BUILT_AT"); - -/// The commit this build was made from, or `unknown`. -pub const COMMIT: &str = env!("TRUSTED_SERVER_COMMIT"); - -/// The build run that made this binary, or `unknown`. -pub const BUILD_RUN: &str = env!("TRUSTED_SERVER_BUILD_RUN"); - -/// The tag this build was made from, as the builder names it, or `unknown`. -pub const BUILD_TAG: &str = env!("TRUSTED_SERVER_BUILD_TAG"); - -/// The build script's date arithmetic, compiled here so a test can call it. -#[cfg(test)] -#[path = "../build_time.rs"] -mod build_time; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_build_time_is_rfc_3339_in_utc() { - let shape = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") - .expect("should compile the pattern"); - - assert!( - shape.is_match(BUILT_AT), - "should be RFC 3339 in UTC: {BUILT_AT}" - ); - } - - #[test] - fn the_commit_the_run_and_the_tag_are_never_empty() { - for (name, value) in [ - ("commit", COMMIT), - ("build run", BUILD_RUN), - ("build tag", BUILD_TAG), - ] { - assert!( - !value.is_empty() && !value.chars().any(char::is_control), - "the {name} should be the builder's value or `unknown`, got {value:?}" - ); - } - } - - #[test] - fn the_build_script_writes_known_instants_as_rfc_3339() { - for (seconds, expected, instant) in [ - (0, "1970-01-01T00:00:00Z", "the epoch"), - ( - 951_782_400, - "2000-02-29T00:00:00Z", - "the leap day of a 400th year", - ), - (1_000_000_000, "2001-09-09T01:46:40Z", "a time of day"), - (4_102_444_800, "2100-01-01T00:00:00Z", "the start of 2100"), - ( - 4_107_542_400, - "2100-03-01T00:00:00Z", - "the day after 28 February 2100", - ), - ] { - assert_eq!( - build_time::rfc3339_utc(seconds), - expected, - "should write {instant}, {seconds} seconds after the epoch" - ); - } - } -} diff --git a/crates/trusted-server-core/src/inspect.rs b/crates/trusted-server-core/src/inspect.rs index 11ff52fa73..702f117c30 100644 --- a/crates/trusted-server-core/src/inspect.rs +++ b/crates/trusted-server-core/src/inspect.rs @@ -81,7 +81,7 @@ p.lede{{color:#bbb}}pre{{background:#1a1c1d}}}}\n\ } /// Escapes `value` for HTML text content. -pub(crate) fn html_escape(value: &str) -> String { +fn html_escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index d468bfef59..d91b74f66e 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -30,11 +30,9 @@ )] pub(crate) mod asset_image_optimizer; -pub mod attestation; pub mod auction; pub mod auction_config_types; pub mod auth; -pub mod build_info; pub mod cache_policy; pub mod cache_purge; pub mod config; diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 11ac69b9c7..6e5d252226 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4063,15 +4063,6 @@ pub struct Settings { skip_serializing_if = "crate::inspect::config::InspectConfig::is_default" )] pub inspect: crate::inspect::config::InspectConfig, - /// The attestation endpoint, which serves evidence signed with the - /// operator's key at the address its `endpoint` names. See - /// [`crate::attestation`]. - /// - /// `None` leaves the address to the publisher's origin, and stays out of - /// serialized config blobs for the reason given on `trusted_client_ip`. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[validate(nested)] - pub attestation: Option, /// The page changes run on a document fetched from the origin, written /// as `[[fetch]]` entries. See [`crate::middleware`]. #[serde(default, skip_serializing_if = "PhaseEntries::is_empty")] @@ -4875,9 +4866,8 @@ fn validation_error_summary(errors: &validator::ValidationErrors) -> String { }; match kind { validator::ValidationErrorsKind::Field(validations) => { - // A validator's message, where it gives one, says what the - // code cannot, such as which address an endpoint clashed - // with. + // A validator's message, where it gives one, says why the + // value was refused, which the code alone does not. for validation in validations { messages.push(match &validation.message { Some(message) => format!("{path}: {} ({message})", validation.code), diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index ef37686928..d825c9608a 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -1325,176 +1325,3 @@ async fn adapter_buffers_nextjs_auction_output() { } } } - -/// The shared settings with an `[attestation]` section whose endpoint sits -/// under a prefix of its own, so the deployment owns the prefix. -fn attestation_settings() -> Settings { - let mut settings = test_settings(); - settings.attestation = Some(trusted_server_core::attestation::AttestationConfig { - endpoint: "/_ex/attestation".to_owned(), - operator: "Example Operator".to_owned(), - context: "example-attestation:v1".to_owned(), - verify_url: "https://verifier.example/verify".to_owned(), - }); - settings -} - -/// The value of header `name`, or an empty string when it is absent. -fn header_text(headers: &HeaderMap, name: &str) -> String { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_owned() -} - -/// One adapter's answer to a request. -struct Answer { - adapter: &'static str, - status: u16, - headers: HeaderMap, -} - -/// What each host-runnable adapter answers to `method` at `uri` under -/// `settings`. -async fn answers(settings: &Settings, method: &str, uri: &str) -> Vec { - let mut axum = EdgeZeroAxumService::new( - AxumApp::routes_with_settings(settings.clone()).expect("should build Axum router"), - ); - let request = AxumRequest::builder() - .method(method) - .uri(uri) - .body(AxumBody::empty()) - .expect("should build request"); - let response = axum - .ready() - .await - .expect("should be ready") - .call(request) - .await - .expect("should respond"); - let mut answers = vec![Answer { - adapter: "axum", - status: response.status().as_u16(), - headers: response.headers().clone(), - }]; - - for (adapter, router) in [ - ( - "cloudflare", - CloudflareApp::routes_with_settings(settings.clone()) - .expect("should build Cloudflare router"), - ), - ( - "spin", - SpinApp::routes_with_settings(settings.clone()).expect("should build Spin router"), - ), - ] { - let request = request_builder() - .method(method) - .uri(uri) - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let response = router.oneshot(request).await.expect("should respond"); - answers.push(Answer { - adapter, - status: response.status().as_u16(), - headers: response.headers().clone(), - }); - } - answers -} - -/// A deployment whose endpoint sits under a prefix of its own answers -/// everything beneath that prefix the same way on every adapter. Reads of the -/// two attestation addresses reach the handler, the prefix lists its pages, -/// and anything else is not found rather than sent to the publisher's origin. -/// Test builds carry no signing key, so the handler answers 503 in JSON. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attestation_prefix_parity() { - let cases = [ - ("GET", "/_ex", 200, "text/html"), - ("GET", "/_ex/", 200, "text/html"), - ("GET", "/_ex/attestation.json", 503, "application/json"), - ("HEAD", "/_ex/attestation.json", 503, "application/json"), - ("GET", "/_ex/attestation", 503, "application/json"), - ("POST", "/_ex/attestation", 404, "text/html"), - ("PUT", "/_ex/attestation.json", 404, "text/html"), - ("DELETE", "/_ex", 404, "text/html"), - ("GET", "/_ex/unknown/page", 404, "text/html"), - ]; - let settings = attestation_settings(); - for (method, uri, status, content_type) in cases { - for answer in answers(&settings, method, uri).await { - let case = format!("{} {method} {uri}", answer.adapter); - let got_type = header_text(&answer.headers, "content-type"); - assert_eq!(answer.status, status, "should answer {case} alike"); - assert!( - got_type.starts_with(content_type), - "should answer {case} as {content_type}, got {got_type}" - ); - assert_eq!( - header_text(&answer.headers, "cache-control"), - "no-store", - "should keep the answer to {case} out of every cache" - ); - } - } -} - -/// When the endpoint is not the default, every adapter sends a read of the -/// default address to the endpoint, page to page and JSON to JSON, keeping -/// the nonce. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attestation_default_address_redirect_parity() { - let cases = [ - ("GET", "/_ts/attestation", "/_ex/attestation"), - ("HEAD", "/_ts/attestation", "/_ex/attestation"), - ( - "GET", - "/_ts/attestation.json?nonce=abc123", - "/_ex/attestation.json?nonce=abc123", - ), - ]; - let settings = attestation_settings(); - for (method, uri, location) in cases { - for answer in answers(&settings, method, uri).await { - let case = format!("{} {method} {uri}", answer.adapter); - assert_eq!(answer.status, 301, "should redirect {case}"); - assert_eq!( - header_text(&answer.headers, "location"), - location, - "should send {case} to the endpoint with its query kept" - ); - assert_eq!( - header_text(&answer.headers, "cache-control"), - "no-store", - "should keep the redirect of {case} out of every cache" - ); - } - } -} - -/// Without the section no adapter takes the default address, so a read of it -/// reaches the publisher's origin as any other path does. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attestation_is_not_served_without_its_section_parity() { - let settings = test_settings(); - let unknown = answers(&settings, "GET", "/a-path-no-route-names").await; - let attestation = answers(&settings, "GET", "/_ts/attestation.json").await; - - for (unknown, attestation) in unknown.iter().zip(&attestation) { - assert_eq!( - attestation.status, unknown.status, - "{} should answer the attestation address as it answers any path of the origin", - attestation.adapter - ); - // The handler answers 404 where there is no section, so a 404 here - // would say a route had been registered for it. - assert_ne!( - attestation.status, 404, - "{} should not answer from the attestation handler", - attestation.adapter - ); - } -} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e4d327628b..11bcff0f5b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -132,7 +132,6 @@ export default withMermaid( { text: 'Request Signing', link: '/guide/request-signing' }, { text: 'Key Rotation', link: '/guide/key-rotation' }, { text: 'Proxy Signing', link: '/guide/proxy-signing' }, - { text: 'Attestation', link: '/guide/attestation' }, ], }, { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index b2d592490a..1ec3476e52 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -67,8 +67,6 @@ authority, scheme, and client-address headers in the innermost middleware. | normal | `/verify-signature` | `POST` | literal | `always` | real | real | real | real | | normal | `/{*rest}` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | template | `publisher_fallback` | publisher fallback | publisher fallback | publisher fallback | publisher fallback | | normal | `/` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `publisher_fallback` | publisher fallback | publisher fallback | publisher fallback | publisher fallback | -| normal | `.json` | `GET`, `HEAD` | config derived | `settings.attestation` | real | real | real | real | -| normal | `` | `GET`, `HEAD` | config derived | `settings.attestation` | real | real | real | real | | normal | `{*rest}` | `GET`, `HEAD` | config derived | `settings.proxy.asset_routes[]` | real | — | — | — | | startup | `/health` | `GET` | literal | `startup_error` | real | — | — | real | | startup | `/{*rest}` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | template | `startup_error` | startup error (`500`) | startup error (`500`) | startup error (`500`) | startup error (`503`) | @@ -302,24 +300,6 @@ not run for this request, so no cookie is set and no identity row is written. curl -s "https://edge.example.com/_ts/permissions.json" -H "Sec-GPC: 1" ``` -### GET `` and GET `.json` - -Signed evidence of who operates the deployment and which build it runs, as a -page and as data. [Attestation](/guide/attestation) gives the format and how a -relying party checks it. - -- **Configuration gate:** served only where the settings carry `[attestation]`. The address is `[attestation] endpoint`, which defaults to `/_ts/attestation`. Where the endpoint is not the default, a read of the default address answers `301` to the endpoint with the query kept. -- **Auth:** none. -- **Request:** `GET` or `HEAD`. An optional `nonce` query parameter of 1 to 64 characters from `A` to `Z`, `a` to `z`, `0` to `9`, `_` and `-` is signed back in the evidence. -- **Response:** `200` with the page, or with the envelope as `application/json` at the `.json` address. `400` for a malformed nonce. `503` where the build carries no signing key in force, the key cannot be read or the clock cannot be read. The `400` and `503` bodies are JSON with an `error` field. -- **Cache and CORS:** `Cache-Control: no-store` and `Access-Control-Allow-Origin: *` on every answer. -- **Rate limiting:** none. -- **Other methods:** reach the publisher's origin under the shared `/_ts` prefix. Under a prefix the endpoint has to itself, every other method and address beneath that prefix answers `404` from the deployment. - -```bash -curl "https://publisher.example/_ts/attestation.json?nonce=Docs-Example_0929" -``` - ## Utility Endpoints ### GET /\_ts/set-tester diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md deleted file mode 100644 index 4430c71097..0000000000 --- a/docs/guide/attestation.md +++ /dev/null @@ -1,523 +0,0 @@ -# Attestation - -A deployment can serve evidence of who operates it and which build it runs, -signed with a key that only the operator's build carries. A relying party, such -as a supply-side platform (SSP, which sells the publisher's advertising space), -a consent management platform (CMP) or an identity provider working with the -publisher, fetches the evidence from the publisher's own address and asks a -verifier whether it is genuine. - -This page is written for three readers. - -1. An operator deploying Trusted Server, who needs the configuration, the key - schedule and the checks to make after a release. -2. A relying party, who needs to know what the evidence proves and how to check - it, with a verifier or without one. -3. A reviewer, who needs the limits, being what the evidence does not prove. - -The mechanism is the same for every operator, and this page describes it -without naming one. - -## Terms - -The first four terms are the roles of the -[IETF Remote Attestation Procedures (RATS) Architecture, RFC 9334](https://www.rfc-editor.org/rfc/rfc9334). -The quotations are from its section 4. - -| Term | Meaning | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attester | "A role performed by an entity (typically a device) whose Evidence must be appraised in order to infer the extent to which the Attester is considered trustworthy". Here the attester is the deployment serving the publisher's site. | -| Evidence | "A set of Claims generated by an Attester to be appraised by a Verifier". Here the evidence is the signed JSON the deployment serves. | -| Verifier | "A role performed by an entity that appraises the validity of Evidence about an Attester and produces Attestation Results to be used by a Relying Party". Here the verifier is the service the settings' `verify_url` names. | -| Relying party | "A role performed by an entity that depends on the validity of information about an Attester for purposes of reliably applying application-specific actions". Here the relying party is a vendor working with the publisher. | -| Verdict | The verifier's answer, which RFC 9334 calls an attestation result. | -| Envelope | The JSON the endpoint answers with, carrying the evidence, the exact bytes that were signed and the signature. | -| Payload | The evidence's exact bytes as signed, carried in the envelope as base64url, which is base64 written with `-` and `_` in place of `+` and `/` and with no `=` padding. | -| Context | A short line of text signed ahead of the payload, naming what the signature is for, so a signature the same key makes for any other purpose can never pass as evidence. | -| Key id (`kid`) | The name of the signing key, which tells a verifier which public key to check the signature with. | -| Nonce | A random value the checker makes up and sends with its request. An answer that carries the value back must have been made after the request. | -| Replay | Serving a stored response somewhere other than where the response was made, for example saving `publisher.example`'s evidence and serving the copy from `bad-actor.example`. | -| Relay | Passing live requests through to a genuine deployment and passing its answers back, so another site answers every request with whatever the genuine site answers at that moment. | - -## What the evidence proves - -A signature that verifies proves that whoever made it held the operator's -signing key. The key is compiled into the build and the build stays in the -operator's hands, so a valid signature means that a deployment the operator -runs made the evidence. Under that signature the evidence states the host the -request arrived for, the publisher whose settings answered, the platform service -and the build that answered, the time, and the nonce when one was sent. - -The evidence does not prove these four things. - -1. **What code is running.** A deployment on a CDN cannot read its own code. - Fastly, for example, tells a deployment its service, version, staging flag - and location through - [environment variables](https://www.fastly.com/documentation/reference/compute/ecp-env/), - and none of them identifies the build's bytes. Whatever a build says about - itself, a changed build can say too, so the `build` fields are the operator's - own report of what it deployed. -2. **A hardware measurement.** Attestation in trusted hardware signs a hash of - the running code that the hardware itself took. Nothing here takes such a - measurement, and the trust rests on the operator's key alone. -3. **What the site serves.** The evidence says who operates the deployment - serving a host, not what pages the publisher's origin returns through it. -4. **That production answered.** The platform's staging environment signs with - the same key, and the evidence says so in `platform.staging`. - -## Addresses - -The deployment serves the endpoint at the address -[`[attestation] endpoint`](#attestation-section) sets, which defaults to -`/_ts/attestation`. - -| Address | Answer | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `{endpoint}` | A page stating the claim, linking to the verifier, giving the JSON address to copy and showing the envelope | -| `{endpoint}.json` | The envelope, as `application/json` | -| `/_ts/attestation` and its `.json` form | 301 to the endpoint, keeping the query, when the endpoint isn't the default | -| The endpoint's prefix, with and without `/` | A list of the two pages, served only when the endpoint sits under a prefix of its own | - -The routes exist only when the settings carry -[`[attestation]`](#attestation-section). Without that section every address -above reaches the publisher's origin. - -A read of the default address redirects so that a verifier or a link written -for `/_ts/attestation` still reaches the deployment's own address, with the -nonce kept. The test `reads_of_the_default_address_redirect_to_the_endpoint` -pins the redirect, and -`the_default_address_is_answered_directly_under_the_default_endpoint` pins -that a deployment using the default answers there with no redirect. - -Both attestation addresses answer with `Cache-Control: no-store`, because every -answer carries a time and often a nonce, so no cache may hand it out again. They -also answer with `Access-Control-Allow-Origin: *`, so a relying party's page can -read the JSON from a browser. The test -`answers_are_never_cached_and_readable_from_any_origin` fails without either -header. - -The page never says the evidence is valid, because only a verifier can. It -states the operator's claim, says "Do not take this page's word for it." and -links to `verify_url`. The test -`the_page_links_to_the_verifier_and_claims_nothing` pins that wording, and -`the_page_escapes_what_it_shows` pins that markup in the settings is shown as -text. - -### Methods - -An endpoint under a prefix of its own, such as `/_ex/attestation`, owns that -prefix. GET and HEAD reach the pages, the prefix itself lists them, and any -other address or method beneath the prefix answers 404 from the deployment -rather than reaching the publisher's origin. - -Choose a prefix the origin serves nothing beneath. An endpoint at -`/_next/attestation` would answer 404 for every asset a Next.js site serves -from `/_next`. The settings refuse an endpoint under a prefix a common -framework serves its assets from, being `_app`, `_astro`, `_content`, `_expo`, -`_framework`, `_next`, `_nuxt` and `_vercel`, and name the prefix. Any other -prefix the origin serves is the operator's to avoid. - -Under the default `_ts` prefix, which other routes share, only GET and HEAD on -the two attestation addresses are taken. Every other method on those addresses -reaches the publisher's origin. - -The tests are `an_own_prefix_is_owned_and_the_default_is_shared`, -`an_endpoint_under_a_framework_asset_prefix_is_refused_naming_it`, -`the_prefix_lists_its_pages` and -`any_other_address_beneath_the_prefix_is_not_found` in core, -`an_owned_prefix_is_answered_here_and_never_by_the_origin` in the Fastly adapter -and `attestation_prefix_parity`, which checks that the Axum, Cloudflare and Spin -adapters answer every case the same way. - -### The nonce - -A caller may add a `nonce` query parameter of 1 to 64 characters from `A` to -`Z`, `a` to `z`, `0` to `9`, `_` and `-`. The deployment decodes any -percent-encoding and signs the value back unchanged in the evidence's `nonce` -field. Any other value answers 400. Without a nonce the evidence carries no -`nonce` field. The tests are `a_nonce_is_signed_back_as_sent`, -`a_malformed_nonce_is_refused` and `no_nonce_is_signed_unless_asked_for`. - -### Answers - -| Status | When | Body | -| ------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| 200 | Evidence was signed | The page, or the envelope at the `.json` address | -| 400 | The nonce is malformed | `{"error":"A nonce is 1 to 64 characters from A to Z, a to z, 0 to 9, _ and -."}` | -| 404 | Any other address or method beneath a prefix the deployment owns | A page pointing to the list at the prefix | -| 503 | No key in the build has come into force, or the build carries no key at all | `{"error":"No signing key is built in for this time, so no evidence can be signed."}` | -| 503 | The key in force is not a P-256 private key in SEC1 or PKCS #8 form | `{"error":"The signing key could not be read, so no evidence can be signed."}` | -| 503 | The platform's clock cannot be read | `{"error":"The clock could not be read, so no evidence can be signed."}` | - -The 400 and 503 answers are JSON at both addresses. The tests are -`a_malformed_nonce_is_refused`, -`no_usable_key_in_force_answers_service_unavailable` and -`a_clock_that_cannot_be_read_signs_nothing`. - -### What each adapter reports - -The Fastly adapter fills the platform fields from the service's environment. -The Axum, Cloudflare and Spin adapters report only their own name, with -`unknown` as the service id, service version and location, and `staging` false. - -On Fastly the endpoint answers ahead of the Edge Cookie lifecycle and the -request filters, because the endpoint is read only and a verifier fetching it -from a server must not be turned away as a bot. The endpoint makes no outbound -request and reads no store. - -## Configuration - -### `[attestation]` {#attestation-section} - -| Field | Type | Rule | Meaning | -| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not under a prefix a framework serves its assets from, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | -| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | -| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | -| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | - -The section refuses any other field, so a document naming a signing key is -refused, because keys are compiled into the build and never configured. A -`verify_url` over plain `http` is refused too. The test -`the_section_loads_from_a_document_and_refuses_unknown_fields` pins both, and -`the_endpoint_is_an_absolute_path_under_an_underscore_prefix` pins the rule -for `endpoint`. - -Only the attestation pages move with `endpoint`. Every other route stays -beneath `/_ts/`. - -```toml -[attestation] -endpoint = "/_ex/attestation" -operator = "Example Operator" -context = "example-attestation:v1" -verify_url = "https://verifier.example/verify?host=publisher.example" -``` - -The page links to `verify_url` as written, and the deployment never calls it. A -verifier that fetches the host itself needs to know which host to fetch, so the -address normally names the publisher's domain, as here. - -## Signing keys - -Keys are compiled into the build, so no key is kept anywhere the running service -reads. The build input `TRUSTED_SERVER_ATTESTATION_KEYS` names a file, the key -schedule, which holds one key per line in three fields separated by tabs. - -1. The key id. -2. The Unix second the key comes into force. -3. The private key, ECDSA on the P-256 curve, as base64 of its SEC1 or PKCS #8 - DER encoding. - -Key ids and keys may hold only letters, digits and `-_.:+/=`, and a line of any -other shape fails the build. No two lines may share a key id or a start, so a -key id names one key and the key in force never depends on the order of the -lines. The test is `a_schedule_repeating_a_key_id_or_a_start_is_refused`. - -The build also reads each key the way the signer does, so a key that is not a -P-256 private key fails the build, however late it starts. The message names -the line and the key id but never the key. The test is -`a_schedule_key_that_cannot_sign_is_refused_without_showing_it`. - -The key in force is the latest to have started, and it stays in force until the -next one starts. While the key in force is the last one in the build, the -deployment logs the warning -``attestation key `` is the last one built in, so the build must be refreshed`` -once for each process and goes on signing with that key. The tests are -`the_key_in_force_is_the_latest_to_have_started` and -`the_last_key_warning_is_given_once`. - -Without the build input, or with an empty file, the schedule is empty and the -endpoint answers 503. That is every build its builder names no schedule for, -which the test `no_usable_key_in_force_answers_service_unavailable` pins. - -A Cloudflare build reads the input from the repository's root `.env`, which the -adapter's `build.sh` exports before it runs `worker-build`, and a Spin build -from the environment `spin build` runs in. Name the file by its absolute path, -because core's build script resolves a relative one from -`crates/trusted-server-core`. - -The build fields come from four more build inputs. `TRUSTED_SERVER_COMMIT`, -`TRUSTED_SERVER_BUILD_RUN` and `TRUSTED_SERVER_BUILD_TAG` become -`build.commit`, `build.run` and `build.tag`, or `unknown` when absent, and -`SOURCE_DATE_EPOCH`, when set, gives `build.builtAt` in place of the time of -the build. The tag is the release the build was made from, so a check that -reads a deployment after a release compares what is running with the tag -that was shipped. - -## Formats - -### The envelope - -This envelope shows the shape of an answer for a publisher at -`publisher.example`, asked for with the nonce `Docs-Example_0929`. The -payload, the signature, the service id, the commit, the run and the tag are -placeholders. - -```json -{ - "evidence": { - "build": { - "builtAt": "2026-09-29T09:18:54Z", - "commit": "", - "run": "", - "tag": "", - "version": "0.1.0" - }, - "host": "publisher.example", - "issuedAt": "2026-09-29T10:36:29Z", - "nonce": "Docs-Example_0929", - "operator": "Example Operator", - "platform": { - "name": "fastly", - "pop": "LCY", - "serviceId": "", - "serviceVersion": "45", - "staging": false - }, - "publisher": "publisher.example", - "v": 1 - }, - "payload": "", - "signature": { - "alg": "ES256", - "kid": "example-2026-09-28", - "value": "" - }, - "verify": "https://verifier.example/verify?host=publisher.example" -} -``` - -| Field | Meaning | -| ----------------- | ----------------------------------------------------------------------------------------------------- | -| `evidence` | A readable copy of the payload, for people, with its fields in alphabetical order. No check reads it. | -| `payload` | The evidence's exact bytes as signed, in base64url | -| `signature.kid` | The key id of the key in force when the evidence was signed | -| `signature.alg` | Always `ES256` | -| `signature.value` | The signature as the raw 64 byte `r` and `s` pair, in base64url | -| `verify` | The settings' `verify_url` | - -The tests are `the_envelope_carries_exactly_the_documented_fields` and -`the_evidence_shown_is_the_payload_signed`. - -### The evidence - -The payload holds these fields in this order. - -| Field | Type | Source | -| ------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `v` | Number | The format version, `1` | -| `operator` | String | `[attestation] operator` | -| `publisher` | String | `publisher.domain` in the settings | -| `host` | String | The request's `Host` header, lower cased, with any numeric port removed. The request address's host stands in only when there is no `Host` header. A forwarded host header is never read. | -| `platform.name` | String | The adapter, for example `fastly` | -| `platform.serviceId` | String | On Fastly, `FASTLY_SERVICE_ID` | -| `platform.serviceVersion` | String | On Fastly, `FASTLY_SERVICE_VERSION` | -| `platform.staging` | Boolean | On Fastly, true when `FASTLY_IS_STAGING` is `1` | -| `platform.pop` | String | On Fastly, `FASTLY_POP`, the location that answered | -| `build.version` | String | The crate version | -| `build.builtAt` | String | RFC 3339, from `SOURCE_DATE_EPOCH` when set, otherwise the time of the build | -| `build.commit` | String | `TRUSTED_SERVER_COMMIT`, or `unknown` | -| `build.tag` | String | `TRUSTED_SERVER_BUILD_TAG`, the release tag the build was made from, or `unknown` | -| `build.run` | String | `TRUSTED_SERVER_BUILD_RUN`, or `unknown` | -| `nonce` | String | The caller's nonce, present only when one was sent | -| `issuedAt` | String | The platform's clock, RFC 3339 in UTC to the second | - -A Fastly environment variable that is absent reads `unknown`. The tests are -`the_evidence_fields_are_signed_in_the_documented_order`, -`the_evidence_reports_the_build_and_the_platform`, -`forwarded_host_headers_never_become_the_signed_host`, -`the_signed_publisher_comes_from_the_settings` and -`the_host_falls_back_to_the_request_address`. - -### The signature - -The signed bytes are the settings' `context`, then one line feed (byte 10), -then the payload bytes. The signature is ECDSA on the P-256 curve with SHA-256, -made deterministically as [RFC 6979](https://www.rfc-editor.org/rfc/rfc6979) -describes, so the same evidence always carries the same signature. -[RFC 7518](https://www.rfc-editor.org/rfc/rfc7518) names this algorithm `ES256`. - -To check a signature, decode `payload`, put the context and a line feed in front -of it, and verify with the public key the `kid` names. Read the evidence only -after the signature verifies, and read it from the decoded payload, never from -the readable copy. No rule for writing JSON the same way twice is needed, -because the bytes are checked exactly as they arrived. - -The tests are -`the_signature_covers_the_context_a_line_feed_and_the_payload`, which also -shows the signature failing without the context, without its line feed and -under another context, `an_altered_payload_fails_verification` and -`the_same_evidence_carries_the_same_signature`. - -## Verifying evidence - -A verifier makes these checks, and the first four need no input from the -relying party. - -1. The envelope can be read, `alg` is `ES256` and the signature is 64 bytes. -2. The `kid` names a key the verifier holds for the operator. -3. The signature verifies over the context, a line feed and the payload. -4. The key's period covers `issuedAt`, and `issuedAt` is close to the - verifier's own clock. -5. The evidence carries the nonce the checker sent. -6. `host` equals the domain being asked about, and `publisher` is that domain - or a parent of it, so `www.publisher.example` passes for the publisher - `publisher.example`. -7. The operator, platform and service id are ones the operator lists as its - own. - -A verifier that fetches the host itself makes check 5 with a nonce it has just -made up and check 6 against the host it fetched from, so a stored or copied -answer cannot pass them. A verifier handed an envelope can make checks 5 and 6 -only against the values the relying party gives it. - -## Seeing it run - -`./scripts/attestation-local-test.sh` makes two signing keys, builds the -Fastly adapter with their schedule compiled in and runs it under Viceroy. It -then checks the evidence as a verifier would, with OpenSSL and the public key -alone. The signature verifies over the context, a line feed and the payload, -and fails with the other key, under another context and once the host in the -payload is changed. The script also checks every field the evidence carries, -that a forwarded host header never becomes the signed host, and the answers -to a malformed nonce and to another method. - -## Replay and relay - -Genuine evidence for one site must never pass as evidence for another. The -protection is that the host is inside the signed evidence, and the verifier -compares that host with the domain it was asked about. A relying party who -checks only the signature, or who follows the link on a copied page, can still -be fooled, so this section says what the checks cover and what a relying party -must do. [Replay and relay](#terms) are defined with the other terms. - -### What is signed - -Every field of [the evidence](#the-evidence) is signed, with the context in -front of it. Two fields decide which site the evidence speaks for, being `host`, -which comes from the request, and `publisher`, which comes from the settings. - -### Replay onto another domain - -Evidence copied from `publisher.example` and served from `bad-actor.example` still -says `"host": "publisher.example"`. A verifier asked about `bad-actor.example` -compares the two and refuses the evidence, because the hosts differ. Changing -the host in the copy changes the signed bytes, so the signature no longer -verifies. Changing only the readable `evidence` copy changes nothing, because -only the payload is read. - -The test `an_altered_payload_fails_verification` pins the signature failing -once the host changes. - -### Relay - -A relay can get fresh evidence from a genuine deployment, but the evidence still -names the genuine deployment's host, for three reasons. - -1. **The deployment signs the `Host` header the request arrived with.** A - forwarded host header such as `X-Forwarded-Host` or `Forwarded`, which a relay - can set, never becomes the signed host. On Fastly the adapter also removes - forwarded headers a client sent before core runs. The tests are - `forwarded_host_headers_never_become_the_signed_host` in core and - `sanitize_fastly_forwarded_headers_strips_spoofable` in the Fastly adapter. -2. **The platform passes a request to a service only under a name the service - serves.** Fastly answers any other name itself and the request never reaches - the deployment. To reach the deployment at all, a relay must send the genuine - host's name, and the evidence then names the genuine host. No test in this - repository covers this, because Fastly's routing does it, so an operator - makes the live check below against its own deployment. -3. **On a service that reads an app-config blob for each host, a host with no - blob of its own is answered `421`,** never from another publisher's blob. - See [One blob for each host](/guide/configuration#one-blob-for-each-host). - The test is `a_host_with_no_blob_is_refused_and_not_served_from_another_key` - in the Fastly adapter, and `scripts/config-by-host-local-test.sh` shows the - same on the running adapter. - -The live check asks one of the addresses a deployment's host resolves to for -the reserved name `bad-actor.example`, shown here for `publisher.example`. -Fastly answered these three requests itself. - -```text -curl -D - -o /dev/null -H "Host: bad-actor.example" http://publisher.example/_ts/attestation.json - HTTP/1.1 500 Domain Not Found - Server: Varnish - -curl -D - -H "Host: bad-actor.example" https://publisher.example/_ts/attestation.json - HTTP/1.1 421 Misdirected Request - Requested host does not match any Subject Alternative Names (SANs) on TLS certificate [...] in use with this connection. - -curl -k -D - -o /dev/null --resolve bad-actor.example:443: https://bad-actor.example/_ts/attestation.json - HTTP/1.1 421 Misdirected Request -``` - -A forwarded host header sent to the genuine host changes nothing either. With -`X-Forwarded-Host: bad-actor.example` and `Forwarded: host=bad-actor.example` -added, `https://publisher.example/_ts/attestation.json` still signs -`"host": "publisher.example"`. - -A verifier that holds the operator's list of services adds one more defense. -It refuses evidence whose operator, platform and service id are not ones the -operator lists, so a build carrying the key but running on a service the -operator does not list is reported even when its signature verifies. - -### Freshness - -`issuedAt` is the time of signing. A verifier refuses evidence whose `issuedAt` -is too far from its own clock in either direction. A nonce proves more than a -time can, because evidence carrying a nonce the checker has just made up -cannot have been made before the checker asked. A verifier that fetches the -host itself sends a fresh nonce every time, so a stored copy fails there. The -evidence is sent with `Cache-Control: no-store`, so no cache between the -parties hands back an old answer. - -### What a relying party must do - -1. **Build the verify address from the domain you are actually dealing with, - and never follow the link on the page being checked.** Whoever serves a page - writes its link, so a copied page can carry a link that asks about the - original host, and the verifier then answers valid about the original host. -2. **Accept only a verdict that says the evidence is valid for your own - domain with every check made.** A verdict that leaves a check out has not - told you the evidence is good. -3. **Send your own nonce whenever you fetch evidence yourself,** and pass the - same `nonce` and `host` when you hand the envelope to a verifier. -4. **Check `platform.staging`** if only answers from production are acceptable - to you. - -When checking the JSON without a verifier, check the host, the nonce, `issuedAt` -against a freshness window and the key's period against `issuedAt`, not only -the signature. The service check needs the operator's list of its services. - -## Security - -A key compiled into a binary is readable by whoever holds the binary. The -protection is that the binary never leaves the operator's hands, so the build -that carries keys must run only where the operator controls it, and the binary -must go only to the operator's own platform account. Anyone can copy the page, -its wording and its layout, and nobody without the key can make the signature. - -A build also leaves the private keys in clear in -`target/**/out/attestation_keys.rs`, the file the build script writes the -schedule into. The file survives the build, and a CI job that caches its target -directory keeps it in that cache. A build that carries keys must therefore use a -target directory and a cache that only the operator can read, or delete the -file once the binary is made. - -The context keeps signatures apart by purpose. A verifier puts the context in -front of the payload before checking, so a signature the same key made over any -other bytes never verifies as evidence, and evidence never verifies as anything -else. The test is -`the_signature_covers_the_context_a_line_feed_and_the_payload`. - -## Limits - -1. **The build fields are a report.** They say what the operator deployed, in - the operator's own words, as - [What the evidence proves](#what-the-evidence-proves) explains. -2. **A signature alone proves nothing about the site in front of you.** Genuine - evidence copied onto another site still verifies as a signature. Only the - host, nonce and time checks tie it to your request. -3. **The link on a page answers for the host written into the link,** which is - why a relying party builds its own verify address. -4. **Staging evidence verifies.** It says `"staging": true`. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 79ef1422b9..327ad7c837 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -708,37 +708,6 @@ show = ["publisher.origin_url"] hide = ["response_headers"] ``` -## Attestation Configuration - -A deployment can serve evidence of who operates it and which build it runs, -signed with a key that only the operator's build carries. Without this section -the deployment serves no evidence and the address belongs to the publisher's -origin. [Attestation](/guide/attestation) describes the evidence, the signing -keys and how a relying party checks it. - -### `[attestation]` - -| Field | Type | Required | Description | -| ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `endpoint` | String | No | Where the page answers, with the JSON form at the same path plus `.json`. Default `/_ts/attestation`. An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not ending `.json`, not an address the deployment already answers and not under a prefix a framework serves its assets from, such as `_next` | -| `operator` | String | Yes | Who the evidence says operates the deployment, 1 to 64 characters | -| `context` | String | Yes | The text signed ahead of the evidence, 1 to 64 characters with no control characters. A verifier must use exactly the same text | -| `verify_url` | String | Yes | An `https` address with a host, where the page sends a reader to check the claim | - -The section refuses any other field, so a configuration naming a signing key -is refused. Keys are compiled into the build from the file the build input -`TRUSTED_SERVER_ATTESTATION_KEYS` names, and a build given none answers `503` -at the endpoint and nothing else changes. - -**Example**: - -```toml -[attestation] -operator = "Example Operator" -context = "example-attestation:v1" -verify_url = "https://verifier.example/verify?host=publisher.example" -``` - ## EC Configuration Settings for generating privacy-preserving Edge Cookie identifiers. The `ec_store` KV store is the only KV-backed EC lifecycle store; it holds identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones. Live consent is interpreted from request cookies, headers, geolocation, and policy defaults, not separate KV persistence. diff --git a/scripts/README.md b/scripts/README.md index bae7fa0e5a..8182a61c2c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,6 @@ their documented orchestration handles a probe deliberately. | Script | Inputs and prerequisites | Side effects and cleanup | | ----------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `attestation-local-test.sh` | Viceroy, OpenSSL, `curl`, Python; optional ports | Makes signing keys, builds the adapter with them compiled in, checks the evidence it serves, stops owned servers, and removes the directory. | | `batch-sync.sh` | Endpoint, bearer token, EC ID, partner UID; `curl` and Python | Sends one authenticated batch-sync request. Its temporary response file is removed on exit. | | `benchmark.sh` | A running server; `curl`, `bc`, and optionally `hey` | May install `hey` through Homebrew. `--save` writes under `benchmark-results/`; it does not stop the server. | | `profile.sh` | Fastly CLI, Rust WASM target, `curl`; endpoint and request options | Builds and starts the Fastly app, stops the owned process, and retains a profile under `benchmark-results/profiles/`. `--open` launches the local viewer. | diff --git a/scripts/attestation-local-test.sh b/scripts/attestation-local-test.sh deleted file mode 100755 index 0b02f385d2..0000000000 --- a/scripts/attestation-local-test.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env bash -# -# Local harness for attestation. -# -# Builds the Fastly adapter with a key schedule made for this run, runs it -# under Viceroy against a stub origin, and checks the evidence the way a -# verifier would: with the public key alone, over the context, a line feed -# and the payload. Everything it needs is generated into a temp directory; -# your `trusted-server.toml` is never read and your tracked `fastly.toml` is -# never modified. The keys are made here, used here and deleted with the -# directory. -# -# The build is given `TRUSTED_SERVER_ATTESTATION_KEYS`, so core is rebuilt -# with the schedule in it, and rebuilt again by the next build that is not -# given one. Run this after the other harnesses. -# -# Usage: -# ./scripts/attestation-local-test.sh -# -# Environment: -# TS_BIN a built `ts`, in place of building one -# CARGO_TARGET_DIR where cargo builds, when it is not `target` -# ORIGIN_PORT the stub origin's port -# TS_PORT the port Viceroy listens on - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WORK="$(mktemp -d)" -ORIGIN_PORT="${ORIGIN_PORT:-9299}" -TS_PORT="${TS_PORT:-7899}" -HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" -TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" -PUBLISHER="publisher.example" -CONTEXT="harness-attestation:v1" -# 9 September 2001 at 01:46:40 UTC, so the build time the evidence reports -# is known before the build is made. -BUILD_EPOCH=1000000000 -BUILD_TIME="2001-09-09T01:46:40Z" -REQUEST_TIMEOUT_SECONDS="${REQUEST_TIMEOUT_SECONDS:-30}" - -PASS=0 -FAIL=0 -VICEROY_PID="" -ORIGIN_PID="" - -info() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } -ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS + 1)); } -bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL + 1)); } - -check() { # check - if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1: got '$2', want '$3'"; fi -} - -cleanup() { - local status=$? - if [ -n "$VICEROY_PID" ]; then - kill "$VICEROY_PID" 2>/dev/null || true - wait "$VICEROY_PID" 2>/dev/null || true - fi - if [ -n "$ORIGIN_PID" ]; then - # macOS may launch the framework Python process as a child of the shim. - pkill -TERM -P "$ORIGIN_PID" 2>/dev/null || true - kill "$ORIGIN_PID" 2>/dev/null || true - wait "$ORIGIN_PID" 2>/dev/null || true - fi - rm -rf "$WORK" - exit $status -} -trap cleanup EXIT INT TERM - -for tool in viceroy curl openssl; do - command -v "$tool" >/dev/null || { - echo "$tool not found. Viceroy installs with: cargo install viceroy --version 0.17.0 --locked" >&2 - exit 1 - } -done -PYTHON="$(command -v python3 || command -v python || true)" -[ -n "$PYTHON" ] || { - echo "python3 not found. The harness reads the evidence with it." >&2 - exit 1 -} - -for port in "$ORIGIN_PORT" "$TS_PORT"; do - if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then - echo "Port $port is already in use. Stop the process, or set" >&2 - echo "ORIGIN_PORT / TS_PORT to something free." >&2 - exit 1 - fi -done - -info "Making two signing keys and their schedule" -# The first key is in force from the epoch. The second starts in 2100, so -# the first signs everything this run asks for. -for name in first next; do - openssl ecparam -name prime256v1 -genkey -noout -outform DER -out "$WORK/$name.der" 2>/dev/null - openssl ec -inform DER -in "$WORK/$name.der" -pubout -out "$WORK/$name.pem" 2>/dev/null -done -printf 'harness-first\t0\t%s\nharness-next\t4102444800\t%s\n' \ - "$(openssl base64 -A -in "$WORK/first.der")" \ - "$(openssl base64 -A -in "$WORK/next.der")" > "$WORK/schedule.tsv" - -info "Building (debug wasm with the schedule compiled in, and the ts CLI)" -TRUSTED_SERVER_ATTESTATION_KEYS="$WORK/schedule.tsv" \ - TRUSTED_SERVER_COMMIT="harness-commit" \ - TRUSTED_SERVER_BUILD_RUN="harness-run" \ - TRUSTED_SERVER_BUILD_TAG="harness-tag" \ - SOURCE_DATE_EPOCH="$BUILD_EPOCH" \ - cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 >/dev/null -# Copied aside, because the next build that is given no schedule writes the -# same path. -cp "$TARGET_DIR/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm" "$WORK/app.wasm" -WASM="$WORK/app.wasm" -TS="${TS_BIN:-}" -if [ -z "$TS" ]; then - cargo build -p trusted-server-cli --target "$HOST_TRIPLE" >/dev/null - TS="$TARGET_DIR/$HOST_TRIPLE/debug/ts" -fi - -info "Starting a stub origin on :$ORIGIN_PORT" -mkdir -p "$WORK/origin" -printf 'ORIGIN_SENTINEL\n' > "$WORK/origin/index.html" -"$PYTHON" -m http.server "$ORIGIN_PORT" --bind 127.0.0.1 --directory "$WORK/origin" \ - > "$WORK/origin.log" 2>&1 & -ORIGIN_PID=$! -for _ in $(seq 1 40); do - curl -s -o /dev/null "http://127.0.0.1:$ORIGIN_PORT/" && break - sleep 0.25 -done - -info "Seeding an isolated config store (tracked fastly.toml remains untouched)" -cp "$REPO_ROOT/edgezero.toml" "$WORK/edgezero.toml" -cp "$REPO_ROOT/fastly.toml" "$WORK/fastly.toml" -ln -s "$REPO_ROOT/crates" "$WORK/crates" -cat >> "$WORK/fastly.toml" <<'SECRETSEOF' - -[[local_server.secret_stores.trusted_server_secrets]] -key = "publisher_proxy_secret" -data = "fictional-local-proxy-secret-for-this-harness" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "ec_passphrase" -data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "handler_password" -data = "fictional-local-handler-password-secret-value" -SECRETSEOF - -"$TS" config init --app-config "$WORK/app.toml" >/dev/null -cat >> "$WORK/app.toml" </dev/null -) - -info "Starting Trusted Server on :$TS_PORT" -RUST_LOG=info viceroy serve -C "$WORK/fastly.toml" \ - --addr "127.0.0.1:$TS_PORT" "$WASM" > "$WORK/viceroy.log" 2>&1 & -VICEROY_PID=$! -for _ in $(seq 1 40); do - grep -q "Listening on" "$WORK/viceroy.log" 2>/dev/null && break - sleep 0.5 -done -if ! grep -q "Listening on" "$WORK/viceroy.log" 2>/dev/null; then - echo "Trusted Server failed to start. Last log lines:" >&2 - tail -20 "$WORK/viceroy.log" >&2 - exit 1 -fi - -REQUESTS=0 -fetch() { # fetch [extra curl args...], sets STATUS, BODY and HEADERS - local target="$1" - shift - REQUESTS=$((REQUESTS + 1)) - BODY="$WORK/response-$REQUESTS.body" - HEADERS="$WORK/response-$REQUESTS.headers" - STATUS="$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D "$HEADERS" -o "$BODY" \ - -w '%{http_code}' -H "Host: Publisher.Example:8443" "$@" \ - "http://127.0.0.1:$TS_PORT$target" || true)" -} - -header() { # header , from the last response - tr -d '\r' < "$HEADERS" | awk -v name="$1:" 'tolower($1) == name { print $2 }' -} - -# Reads an envelope the way a verifier does. Writes the bytes the signature -# covers and the signature in the form openssl takes, and prints one field of -# the evidence, read from the payload and never from the readable copy. -cat > "$WORK/evidence.py" <<'PYEOF' -import base64 -import json -import sys - - -def b64url(text): - return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) - - -def der_integer(raw): - raw = raw.lstrip(b"\x00") or b"\x00" - if raw[0] & 0x80: - raw = b"\x00" + raw - return b"\x02" + bytes([len(raw)]) + raw - - -envelope = json.load(open(sys.argv[1], encoding="utf-8")) -command = sys.argv[2] -payload = b64url(envelope["payload"]) -if command == "field": - value = json.loads(payload) - for part in sys.argv[3].split("."): - value = value.get(part, "") if isinstance(value, dict) else "" - print(value) -elif command == "envelope": - value = envelope - for part in sys.argv[3].split("."): - value = value.get(part, "") if isinstance(value, dict) else "" - print(value) -elif command == "write": - context, signed_path, signature_path = sys.argv[3:6] - tamper = len(sys.argv) > 6 and sys.argv[6] == "tamper" - if tamper: - payload = payload.replace(b"publisher.example", b"bad-actor.example") - open(signed_path, "wb").write(context.encode() + b"\n" + payload) - raw = b64url(envelope["signature"]["value"]) - assert len(raw) == 64, "the signature is the raw r and s pair" - body = der_integer(raw[:32]) + der_integer(raw[32:]) - open(signature_path, "wb").write(b"\x30" + bytes([len(body)]) + body) -PYEOF - -field() { "$PYTHON" "$WORK/evidence.py" "$BODY" field "$1"; } -envelope_field() { "$PYTHON" "$WORK/evidence.py" "$BODY" envelope "$1"; } - -verifies() { # verifies [tamper], prints yes or no - "$PYTHON" "$WORK/evidence.py" "$BODY" write "$2" "$WORK/signed.bin" "$WORK/signature.der" "${3:-}" - if openssl dgst -sha256 -verify "$WORK/$1.pem" -signature "$WORK/signature.der" \ - "$WORK/signed.bin" >/dev/null 2>&1; then - echo yes - else - echo no - fi -} - -info "The evidence, asked for as data with a nonce" -fetch "/_ts/attestation.json?nonce=Harness_1" -check "the endpoint answers" "$STATUS" "200" -check "no cache may store the answer" "$(header cache-control)" "no-store" -check "a page of any origin may read it" "$(header access-control-allow-origin)" "*" -check "the signature names its algorithm" "$(envelope_field signature.alg)" "ES256" -check "the key in force signed, and not the one that starts later" \ - "$(envelope_field signature.kid)" "harness-first" -check "the signature verifies over the context, a line feed and the payload" \ - "$(verifies first "$CONTEXT")" "yes" -check "it does not verify with the other key of the schedule" \ - "$(verifies next "$CONTEXT")" "no" -check "it does not verify under another context" \ - "$(verifies first "another-purpose:v1")" "no" -check "it does not verify once the host in the payload is changed" \ - "$(verifies first "$CONTEXT" tamper)" "no" -check "the host is the one the request named, lower case and without its port" \ - "$(field host)" "$PUBLISHER" -check "the publisher is the settings' own" "$(field publisher)" "$PUBLISHER" -check "the nonce is signed back as sent" "$(field nonce)" "Harness_1" -check "the operator is the settings' own" "$(field operator)" "Harness Operator" -check "the platform names itself" "$(field platform.name)" "fastly" -check "the build reports the commit it was given" "$(field build.commit)" "harness-commit" -check "the build reports the run it was given" "$(field build.run)" "harness-run" -check "the build reports the tag it was given" "$(field build.tag)" "harness-tag" -check "the build reports the time it was given" "$(field build.builtAt)" "$BUILD_TIME" -ISSUED="$(field issuedAt)" -AGE="$("$PYTHON" -c 'import datetime, sys; t = datetime.datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc); print(int(abs((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) < 300))' "$ISSUED" 2>/dev/null || echo 0)" -check "the evidence was signed within the last five minutes" "$AGE" "1" - -info "What a relay or a careless caller cannot change" -fetch "/_ts/attestation.json?nonce=Harness_2" \ - -H "X-Forwarded-Host: bad-actor.example" -H "Forwarded: host=bad-actor.example" -check "a forwarded host header never becomes the signed host" "$(field host)" "$PUBLISHER" -check "and the evidence still verifies" "$(verifies first "$CONTEXT")" "yes" -fetch "/_ts/attestation.json" -if "$PYTHON" -c 'import base64, json, sys; e = json.load(open(sys.argv[1])); p = e["payload"]; sys.exit("nonce" in json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))))' "$BODY"; then - ok "no nonce is signed unless one is asked for" -else - bad "a nonce is signed although none was asked for" -fi -fetch "/_ts/attestation.json?nonce=not%20a%20nonce" -check "a malformed nonce is refused" "$STATUS" "400" -fetch "/_ts/attestation" -check "the page answers" "$STATUS" "200" -if grep -qF "Do not take this page" "$BODY"; then - ok "the page claims nothing and sends the reader to a verifier" -else - bad "the page does not tell the reader to check the claim" -fi -fetch "/_ts/attestation.json" -X POST -if [ "$STATUS" != "200" ] && ! grep -qF '"payload"' "$BODY"; then - ok "another method is not answered with evidence ($STATUS)" -else - bad "a POST was answered with evidence" -fi - -printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" -if [ "$FAIL" -ne 0 ]; then - echo "Viceroy log:" >&2 - tail -40 "$WORK/viceroy.log" >&2 - exit 1 -fi diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock index 8391023d81..1ea2997e59 100644 --- a/tools/permissions-inspector/wasm/Cargo.lock +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -510,20 +510,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - [[package]] name = "ed25519" version = "2.2.3" @@ -607,7 +593,6 @@ dependencies = [ "ff", "generic-array", "group", - "pkcs8", "rand_core", "sec1", "subtle", @@ -1310,10 +1295,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", "elliptic-curve", "primeorder", - "sha2", ] [[package]] @@ -1590,16 +1573,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "rsa" version = "0.9.10" @@ -1665,7 +1638,6 @@ dependencies = [ "base16ct", "der", "generic-array", - "pkcs8", "subtle", "zeroize", ] @@ -2089,7 +2061,6 @@ dependencies = [ "lol_html", "matchit", "mime", - "p256", "rand", "regex", "serde", diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 036f156973..6a25f291b1 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -881,17 +881,3 @@ auction_timeout_ms = 500 # provides. Find the ids by grepping your rendered HTML for `gtag/js?id=`. # allowed_tag_ids = ["G-EXAMPLE1", "AW-000000", "MC-EXAMPLE1"] -# Signed evidence of who operates this deployment and which build it runs, -# served at `endpoint` and at the same address with `.json`. Without this -# table the address belongs to the publisher's origin. The signing keys are -# compiled into the build from the file TRUSTED_SERVER_ATTESTATION_KEYS names -# and are never configured, so a build given none answers 503 there. -# See docs/guide/attestation.md. -# [attestation] -# Defaults to "/_ts/attestation". -# endpoint = "/_ts/attestation" -# operator = "Example Operator" -# The text signed ahead of the evidence. A verifier must use the same text. -# context = "example-attestation:v1" -# Where the page sends a reader to check the claim. Must be https. -# verify_url = "https://verifier.example/verify?host=example.com" From fef96ce9edaf0a74e4298f69233b5d7f70f8f8ed Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:07 +0000 Subject: [PATCH 17/25] Test that two routes on one method and path are refused The registry refuses a second route on one method and path when it is built, whether one module declares both or two modules declare one each, and the message names the method and the path. Nothing tested that before. Two methods on one path are accepted, each routed to the module that declares it. --- .../src/integrations/registry.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index f904b9fc35..d0bdaf996e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -4493,6 +4493,121 @@ mod tests { ); } + /// A path outside `/integrations/` that the route clash test declares. + const OWN_ENDPOINT_PATH: &str = "/own-endpoint"; + + /// A proxy that declares the routes it holds and answers each with an + /// empty body. + struct DeclaredRoutes { + name: &'static str, + routes: Vec, + } + + #[async_trait(?Send)] + impl IntegrationProxy for DeclaredRoutes { + fn integration_name(&self) -> &'static str { + self.name + } + + fn routes(&self) -> Vec { + self.routes.clone() + } + + async fn handle( + &self, + _call: crate::module_context::ModuleCall<'_>, + _req: Request, + ) -> Result, Report> { + Ok(Response::new(EdgeBody::empty())) + } + } + + /// A registration under `id` whose proxy declares `methods` on + /// [`OWN_ENDPOINT_PATH`], one route for each. + fn declaring(id: &'static str, methods: &[Method]) -> Option { + let routes = methods + .iter() + .map(|method| IntegrationEndpoint::new(method.clone(), OWN_ENDPOINT_PATH)) + .collect(); + Some( + IntegrationRegistration::builder(id) + .with_proxy(Arc::new(DeclaredRoutes { name: id, routes })) + .build(), + ) + } + + /// A test module, as its integration id, its module name and its build + /// function. + type RouteModule = ( + &'static str, + &'static str, + crate::integrations::IntegrationBuilderFn, + ); + + const GETS: RouteModule = ("gets", "testing.gets", |_| { + Ok(declaring("gets", &[Method::GET])) + }); + const ALSO_GETS: RouteModule = ("also-gets", "testing.also-gets", |_| { + Ok(declaring("also-gets", &[Method::GET])) + }); + const GETS_TWICE: RouteModule = ("gets-twice", "testing.gets-twice", |_| { + Ok(declaring("gets-twice", &[Method::GET, Method::GET])) + }); + const POSTS: RouteModule = ("posts", "testing.posts", |_| { + Ok(declaring("posts", &[Method::POST])) + }); + + /// Builds a registry from `modules`, with every one of them selected. + fn registry_selecting( + modules: &[RouteModule], + ) -> Result> { + let mut settings = crate::test_support::tests::create_test_settings(); + let mut builders = Vec::new(); + for &(id, module, register) in modules { + settings.select_module("testing", module); + builders.push( + crate::integrations::IntegrationBuilder::new( + id, + "route-clash-test", + register, + validate_nothing, + ) + .with_module_name(module), + ); + } + IntegrationRegistry::with_registrations(&settings, &builders) + } + + #[test] + fn two_routes_on_one_method_and_path_are_refused_when_the_registry_is_built() { + let expected = format!("Integration route registration failed for GET {OWN_ENDPOINT_PATH}"); + // Two modules declaring one route each, and one module declaring the + // route twice. + for modules in [&[GETS, ALSO_GETS][..], &[GETS_TWICE]] { + let Err(error) = registry_selecting(modules) else { + panic!("should refuse a second GET route on {OWN_ENDPOINT_PATH}"); + }; + let message = error.to_string(); + assert!( + message.contains(&expected), + "should name the method and the path: {message}" + ); + } + + let registry = + registry_selecting(&[GETS, POSTS]).expect("should accept two methods on one path"); + let routed_to = |method: &Method| { + registry + .find_route(method, OWN_ENDPOINT_PATH) + .map(|(_, integration)| *integration) + }; + assert_eq!( + (routed_to(&Method::GET), routed_to(&Method::POST)), + (Some("gets"), Some("posts")), + "should route each method to the module that declares it" + ); + } + /// Selects core's stand-in, which registers a deferred browser module. fn enable_deferred_module(settings: &mut Settings) { settings.select_module("testing", test_support::deferred_fixture::MODULE); From d2bdd3e8f01abc187f1de40c51f2a928e343554d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:08 +0000 Subject: [PATCH 18/25] Give the seam probe a route outside /integrations/ The probe declares GET and HEAD on /seam-probe/own-path as written, so a test can show a module outside core answering a path of its own. The handler answers a GET with SEAM_PROBE_OWN_BODY and a HEAD with the same headers and no body, which it leaves out itself because nothing in core, the adapters or EdgeZero strips one. It counts its runs against the token in SEAM_PROBE_COUNT_HEADER, which own_path_runs_for reads, through the counter the preparer uses. --- crates/testing/seam-probe/src/lib.rs | 91 ++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 820046b8c3..87c9fb99c4 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -27,7 +27,7 @@ use async_trait::async_trait; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::header::{self, HeaderValue}; -use http::{Request, Response}; +use http::{Method, Request, Response}; use serde::Deserialize; use serde_json::json; use trusted_server_core::auction::demand::{ @@ -79,6 +79,13 @@ pub const SEAM_PROBE_REPORT_PATH: &str = "/integrations/seam_probe/report"; /// Path suffix of the report route, relative to the proxy prefix. const REPORT_ROUTE_SUFFIX: &str = "/report"; +/// Path of the probe's route outside `/integrations/`, which the proxy +/// declares as written rather than beneath its prefix. +pub const SEAM_PROBE_OWN_PATH: &str = "/seam-probe/own-path"; + +/// Body the probe answers a `GET` of [`SEAM_PROBE_OWN_PATH`] with. +pub const SEAM_PROBE_OWN_BODY: &str = "answered by the seam probe"; + /// The browser module this registration carries, built outside /// `trusted-server-js`. pub const PROBE_JS: &str = include_str!("../js/probe.js"); @@ -97,7 +104,8 @@ pub const PROBE_JS_SHA256: &str = pub const SEAM_PROBE_COUNTRY_MESSAGE: &str = "`[testing.seam-probe] country` must be exactly two letters"; -/// Request header a caller sets to have the preparer count that request. +/// Request header a caller sets to have the preparer, and the handler of +/// [`SEAM_PROBE_OWN_PATH`], count that request. /// /// The header's value is a token the caller chooses, so tests running in /// parallel count into separate entries. @@ -112,6 +120,11 @@ pub const SEAM_PROBE_COUNT_HEADER: &str = "x-seam-probe-count"; static PREPARE_COUNTS: LazyLock>> = LazyLock::new(|| Mutex::new(BTreeMap::new())); +/// How many times the handler of [`SEAM_PROBE_OWN_PATH`] ran for each +/// counting token, so a caller can tell that a request never reached it. +static OWN_PATH_COUNTS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + /// How many times the preparer ran for requests carrying `token` in /// [`SEAM_PROBE_COUNT_HEADER`]. /// @@ -120,9 +133,25 @@ static PREPARE_COUNTS: LazyLock>> = /// Panics when the counter's lock is poisoned. #[must_use] pub fn prepare_runs_for(token: &str) -> usize { - PREPARE_COUNTS + runs_for(&PREPARE_COUNTS, token) +} + +/// How many times the handler of [`SEAM_PROBE_OWN_PATH`] ran for requests +/// carrying `token` in [`SEAM_PROBE_COUNT_HEADER`]. +/// +/// # Panics +/// +/// Panics when the counter's lock is poisoned. +#[must_use] +pub fn own_path_runs_for(token: &str) -> usize { + runs_for(&OWN_PATH_COUNTS, token) +} + +/// The runs `counts` holds for `token`. +fn runs_for(counts: &Mutex>, token: &str) -> usize { + counts .lock() - .expect("should lock the seam probe prepare counts") + .expect("should lock a seam probe counter") .get(token) .copied() .unwrap_or(0) @@ -130,6 +159,12 @@ pub fn prepare_runs_for(token: &str) -> usize { /// Records one preparer run against the token the request carries, if any. fn count_prepare_run(request: &Request) { + count_run(&PREPARE_COUNTS, request); +} + +/// Records one run in `counts` against the token the request carries, if +/// any. +fn count_run(counts: &Mutex>, request: &Request) { let Some(token) = request .headers() .get(SEAM_PROBE_COUNT_HEADER) @@ -137,9 +172,7 @@ fn count_prepare_run(request: &Request) { else { return; }; - let mut counts = PREPARE_COUNTS - .lock() - .expect("should lock the seam probe prepare counts"); + let mut counts = counts.lock().expect("should lock a seam probe counter"); *counts.entry(token.to_string()).or_insert(0) += 1; } @@ -557,6 +590,24 @@ impl SeamProbeProxy { ); Ok(response) } + + /// Answers [`SEAM_PROBE_OWN_PATH`] and counts the run against the + /// request's token. A `HEAD` answer carries the headers a `GET` answer + /// carries and no body, which the handler leaves out itself because + /// nothing in core, the adapters or `EdgeZero` strips one. + fn own_path(&self, req: &Request) -> Response { + count_run(&OWN_PATH_COUNTS, req); + let body = if req.method() == Method::HEAD { + EdgeBody::empty() + } else { + EdgeBody::from(SEAM_PROBE_OWN_BODY) + }; + let mut response = Response::new(body); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + response + } } #[async_trait(?Send)] @@ -566,7 +617,11 @@ impl IntegrationProxy for SeamProbeProxy { } fn routes(&self) -> Vec { - vec![self.get(REPORT_ROUTE_SUFFIX)] + vec![ + self.get(REPORT_ROUTE_SUFFIX), + IntegrationEndpoint::get(SEAM_PROBE_OWN_PATH), + IntegrationEndpoint::new(Method::HEAD, SEAM_PROBE_OWN_PATH), + ] } async fn handle( @@ -574,6 +629,9 @@ impl IntegrationProxy for SeamProbeProxy { call: trusted_server_core::module_context::ModuleCall<'_>, req: Request, ) -> Result, Report> { + if req.uri().path() == SEAM_PROBE_OWN_PATH { + return Ok(call.inject_with(self, &req, Self::own_path)?); + } call.inject_with(self, req, Self::route)?.await } } @@ -1018,12 +1076,21 @@ mod tests { #[test] fn report_route_is_the_documented_path() { - let routes = SeamProbeProxy.routes(); + let routes: Vec<(Method, String)> = SeamProbeProxy + .routes() + .into_iter() + .map(|route| (route.method, route.path)) + .collect(); - assert_eq!(routes.len(), 1, "should register exactly one route"); assert_eq!( - routes[0].path, SEAM_PROBE_REPORT_PATH, - "should route the documented report path" + routes, + [ + (Method::GET, SEAM_PROBE_REPORT_PATH.to_owned()), + (Method::GET, SEAM_PROBE_OWN_PATH.to_owned()), + (Method::HEAD, SEAM_PROBE_OWN_PATH.to_owned()), + ], + "should route the documented report path, and the probe's own path for GET and \ + HEAD" ); } From 2c7d9961f9aa98414e2618d403535d4eb4fb02e4 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:09 +0000 Subject: [PATCH 19/25] Reach the probe's own path through Fastly and Axum On each adapter a GET of /seam-probe/own-path answers from the probe, a HEAD answers with no body, and a POST, which the probe does not declare, never reaches its handler. A module of the test's own that also declares GET on that path is refused with the probe when the state is built. A module route on GET /_ts/config never runs, because the inspection page answers it, while the same module's route on POST /_ts/config does. --- .../tests/seam_probe.rs | 280 ++++++++++++++++++ .../src/app/seam_probe_tests.rs | 278 +++++++++++++++++ 2 files changed, 558 insertions(+) diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 7b4de0c8e9..c134acf476 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -831,6 +831,286 @@ async fn ec_module_generates_an_identifier_with_the_modules_prefix() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The probe's route at a path of its own, reached through this adapter's +/// router, and the two things a module route can meet there, being another +/// route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use axum::body::Body as AxumBody; + use axum::http::Request; + use edgezero_adapter_axum::service::EdgeZeroAxumService; + use error_stack::Report; + use tower::{Service as _, ServiceExt as _}; + use trusted_server_adapter_axum::app::build_state_with_registrations; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + + use super::{PROBE_BLOCK, body_text, seam_probe, service_with, settings_with}; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: edgezero_core::http::Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(edgezero_core::http::Response::new( + edgezero_core::body::Body::from(OWN_MODULE_BODY), + )) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// Sends one request with `method` to `uri`, carrying the probe's + /// counting header when `token` is given. + async fn send( + service: &mut EdgeZeroAxumService, + method: &str, + uri: &str, + token: Option<&str>, + ) -> axum::response::Response { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder + .body(AxumBody::empty()) + .expect("should build request"); + + service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should respond") + } + + /// The response's `Content-Type`. + fn content_type(response: &axum::response::Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send(&mut service, "GET", seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response).await, + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send(&mut service, "HEAD", seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response).await, "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "axum-own-path-post"; + let mut service = service_with(settings_with(PROBE_BLOCK), &[seam_probe::builder()]); + + let response = send( + &mut service, + "POST", + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response).await, + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let settings = settings_with( + r#" + [testing] + modules = ["own-module"] + "#, + ); + let mut service = service_with(settings, &[own_module(declaring_the_config_page)]); + + let page = send(&mut service, "GET", CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page).await, OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&mut service, "POST", CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted).await, OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// Config store that answers nothing, so a test can build [`RuntimeServices`] /// without a real platform. /// diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 3c8a9cd7da..e30c4390fe 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -399,6 +399,284 @@ fn duplicate_integration_id_is_rejected_naming_both_sources() { ); } +/// The probe's route at a path of its own, reached through this adapter's +/// router, and the two things a module route can meet there, being another +/// route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use futures::executor::block_on; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + + use super::{ + HMAC_BLOCK, PROBE_BLOCK, body_text, build_state_with_registrations, router_with, + seam_probe, settings_with, + }; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + fn send(router: &RouterService, method: Method, path: &str, token: Option<&str>) -> Response { + let mut builder = request_builder() + .method(method) + .uri(format!("https://test-publisher.com{path}")); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + block_on(router.oneshot(request)).expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// A router with the probe selected. + fn probe_router() -> RouterService { + router_with( + settings_with(&format!("{HMAC_BLOCK}{PROBE_BLOCK}")), + &[seam_probe::builder()], + ) + } + + #[test] + fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let response = send( + &probe_router(), + Method::GET, + seam_probe::SEAM_PROBE_OWN_PATH, + None, + ); + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[test] + fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let response = send( + &probe_router(), + Method::HEAD, + seam_probe::SEAM_PROBE_OWN_PATH, + None, + ); + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[test] + fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "fastly-own-path-post"; + + let response = send( + &probe_router(), + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ); + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with(&format!( + r#"{HMAC_BLOCK} + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "# + )); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[test] + fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + settings_with(&format!( + r#"{HMAC_BLOCK} + [testing] + modules = ["own-module"] + "# + )), + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None); + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None); + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// A demand source a crate outside core supplies builds this adapter's state, /// and the same settings are refused without that crate's builder. #[test] From 38befd43d0c1f0eb6e006da69bcbce6e3e12ed82 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:10 +0000 Subject: [PATCH 20/25] Reach the probe's own path through Cloudflare and Spin The route tests of Cloudflare and Spin run the same five checks, so a module route outside /integrations/ is shown on all four adapters. Both adapters take the probe as a development dependency, and the probe's own documentation now names all four. --- Cargo.lock | 2 + crates/testing/seam-probe/src/lib.rs | 3 +- .../Cargo.toml | 3 + .../tests/routes.rs | 320 ++++++++++++++++++ crates/trusted-server-adapter-spin/Cargo.toml | 3 + .../tests/routes.rs | 318 +++++++++++++++++ 6 files changed, 647 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 574da4cf65..a0e1340309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6616,6 +6616,7 @@ dependencies = [ "tokio", "toml", "trusted-server-core", + "trusted-server-integration-seam-probe", "trusted-server-js", "trusted-server-modules", "trusted-server-permission-signal-gpc", @@ -6681,6 +6682,7 @@ dependencies = [ "spin-sdk", "tokio", "trusted-server-core", + "trusted-server-integration-seam-probe", "trusted-server-js", "trusted-server-modules", "trusted-server-permission-signal-gpc", diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 87c9fb99c4..01b8076bdf 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -15,8 +15,7 @@ //! reports internal request state over an unauthenticated route, serves a //! browser module that does nothing useful, and resolves location from static //! configuration rather than from the request. It is a development dependency -//! of the Axum and Fastly adapters only, so no adapter's production build -//! reaches it. +//! of the four adapters only, so no adapter's production build reaches it. use std::collections::BTreeMap; use std::net::IpAddr; diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 6fc2220097..90145993da 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -51,3 +51,6 @@ base64 = { workspace = true } edgezero-core = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +# Test fixture only. It proves the integration seam from outside core and must +# never reach a production build, so it stays a dev-dependency. +trusted-server-integration-seam-probe = { path = "../testing/seam-probe" } diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index e817f10fcc..bab5035772 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -848,6 +848,326 @@ fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The seam probe's route at a path of its own, reached through this +/// adapter's router, and the two things a module route can meet there, being +/// another route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use trusted_server_adapter_cloudflare::app::{ + TrustedServerApp, build_state_with_registrations, + }; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + use trusted_server_integration_seam_probe as seam_probe; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// The probe's own section and table. + const PROBE_BLOCK: &str = r#" + [testing] + modules = ["seam-probe"] + + [testing.seam-probe] + country = "ZZ" + "#; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// The route tests' settings with `extra` appended. + fn settings_with(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "hmac" + + [ec.hmac] + passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true + + {extra} + "# + )) + .expect("should parse route test settings") + } + + /// The router with the `builders` composed in. + fn router_with(extra: &str, builders: &[IntegrationBuilder]) -> RouterService { + TrustedServerApp::routes_with_registrations(settings_with(extra), builders) + .expect("should build a router from the composed builders") + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + async fn send( + router: &RouterService, + method: Method, + path: &str, + token: Option<&str>, + ) -> Response { + let mut builder = request_builder().method(method).uri(path); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + router.oneshot(request).await.expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// The response's body as text. + fn body_text(response: Response) -> String { + let body = response.into_body().into_bytes().unwrap_or_default(); + String::from_utf8_lossy(&body).into_owned() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::GET, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::HEAD, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "cloudflare-own-path-post"; + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send( + &router, + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + r#" + [testing] + modules = ["own-module"] + "#, + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 2bdab4f9d5..8b5c6b95f8 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -49,3 +49,6 @@ base64 = { workspace = true } edgezero-core = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +# Test fixture only. It proves the integration seam from outside core and must +# never reach a production build, so it stays a dev-dependency. +trusted-server-integration-seam-probe = { path = "../testing/seam-probe" } diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 1403c11494..a5806c38c5 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -732,6 +732,324 @@ async fn first_party_proxy_rebuild_get_is_routed() { ); } +// --------------------------------------------------------------------------- +// A module's own endpoint, outside `/integrations/` +// --------------------------------------------------------------------------- + +/// The seam probe's route at a path of its own, reached through this +/// adapter's router, and the two things a module route can meet there, being +/// another route on its method and path and a route of the adapter's own. +mod module_endpoints { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Request, Response, request_builder}; + use edgezero_core::router::RouterService; + use error_stack::Report; + use trusted_server_adapter_spin::app::{TrustedServerApp, build_state_with_registrations}; + use trusted_server_core::error::TrustedServerError; + use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationBuilderFn, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, + }; + use trusted_server_core::module_context::ModuleCall; + use trusted_server_core::settings::Settings; + use trusted_server_integration_seam_probe as seam_probe; + + /// The inspection page's path, which this adapter answers for `GET`. + const CONFIG_PAGE_PATH: &str = "/_ts/config"; + + /// Integration id of the test's own module. + const OWN_MODULE_ID: &str = "own_module"; + + /// The name a section selects the test's own module by. + const OWN_MODULE_NAME: &str = "testing.own-module"; + + /// Body the test's own module answers every request with. + const OWN_MODULE_BODY: &str = "answered by the test's own module"; + + /// The probe's own section and table. + const PROBE_BLOCK: &str = r#" + [testing] + modules = ["seam-probe"] + + [testing.seam-probe] + country = "ZZ" + "#; + + /// How many requests the test's own module answered. + static OWN_MODULE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// The test's own module, which answers each route it declares with + /// [`OWN_MODULE_BODY`] and counts the runs. + struct OwnModule(fn() -> Vec); + + #[async_trait::async_trait(?Send)] + impl IntegrationProxy for OwnModule { + fn integration_name(&self) -> &'static str { + OWN_MODULE_ID + } + + fn routes(&self) -> Vec { + (self.0)() + } + + async fn handle( + &self, + _call: ModuleCall<'_>, + _req: Request, + ) -> Result> { + OWN_MODULE_RUNS.fetch_add(1, Ordering::SeqCst); + Ok(Response::new(Body::from(OWN_MODULE_BODY))) + } + } + + /// The registration of the test's own module, declaring `routes`. + fn declaring( + routes: fn() -> Vec, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder(OWN_MODULE_ID) + .with_proxy(Arc::new(OwnModule(routes))) + .build(), + )) + } + + /// The test's own module declaring `GET` on the probe's own path. + fn declaring_the_probe_s_path( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| vec![IntegrationEndpoint::get(seam_probe::SEAM_PROBE_OWN_PATH)]) + } + + /// The test's own module declaring `GET` and `POST` on the inspection + /// page's path. + fn declaring_the_config_page( + _settings: &Settings, + ) -> Result, Report> { + declaring(|| { + vec![ + IntegrationEndpoint::get(CONFIG_PAGE_PATH), + IntegrationEndpoint::post(CONFIG_PAGE_PATH), + ] + }) + } + + /// A builder for the test's own module, built by `register`. + fn own_module(register: IntegrationBuilderFn) -> IntegrationBuilder { + IntegrationBuilder::new(OWN_MODULE_ID, "own-module-test", register, |settings| { + Ok(settings.selects_module(OWN_MODULE_NAME)) + }) + .with_module_name(OWN_MODULE_NAME) + } + + /// The route tests' settings with `extra` appended. + fn settings_with(extra: &str) -> Settings { + Settings::from_toml(&format!( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "hmac" + + [ec.hmac] + passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true + + {extra} + "# + )) + .expect("should parse route test settings") + } + + /// The router with the `builders` composed in. + fn router_with(extra: &str, builders: &[IntegrationBuilder]) -> RouterService { + TrustedServerApp::routes_with_registrations(settings_with(extra), builders) + .expect("should build a router from the composed builders") + } + + /// Sends one request with `method` to `path`, carrying the probe's + /// counting header when `token` is given. + async fn send( + router: &RouterService, + method: Method, + path: &str, + token: Option<&str>, + ) -> Response { + let mut builder = request_builder().method(method).uri(path); + if let Some(token) = token { + builder = builder.header(seam_probe::SEAM_PROBE_COUNT_HEADER, token); + } + let request = builder.body(Body::empty()).expect("should build request"); + router.oneshot(request).await.expect("should route request") + } + + /// The response's `Content-Type`. + fn content_type(response: &Response) -> Option<&str> { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + } + + /// The response's body as text. + fn body_text(response: Response) -> String { + let body = response.into_body().into_bytes().unwrap_or_default(); + String::from_utf8_lossy(&body).into_owned() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_get_of_the_probe_s_own_path_answers_from_the_module() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::GET, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "should carry the module's body" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_head_of_the_probe_s_own_path_answers_with_no_body() { + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send(&router, Method::HEAD, seam_probe::SEAM_PROBE_OWN_PATH, None).await; + + assert_eq!( + response.status().as_u16(), + 200, + "the module's HEAD route should answer" + ); + assert_eq!(content_type(&response), Some("text/plain")); + assert_eq!(body_text(response), "", "should carry no body"); + } + + /// The probe declares no `POST` on its own path, so a `POST` there goes + /// to the publisher path and the module's handler never runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_post_of_the_probe_s_own_path_never_reaches_the_module() { + const TOKEN: &str = "spin-own-path-post"; + let router = router_with(PROBE_BLOCK, &[seam_probe::builder()]); + + let response = send( + &router, + Method::POST, + seam_probe::SEAM_PROBE_OWN_PATH, + Some(TOKEN), + ) + .await; + + assert_eq!( + seam_probe::prepare_runs_for(TOKEN), + 1, + "the preparers should run once, so the token reached the modules" + ); + assert_eq!( + seam_probe::own_path_runs_for(TOKEN), + 0, + "the module's handler should not run for a method it does not declare" + ); + assert_ne!( + body_text(response), + seam_probe::SEAM_PROBE_OWN_BODY, + "the answer should not be the module's" + ); + } + + #[test] + fn a_second_get_route_on_the_probe_s_own_path_is_refused() { + let settings = settings_with( + r#" + [testing] + modules = ["seam-probe", "own-module"] + + [testing.seam-probe] + country = "ZZ" + "#, + ); + + let error = build_state_with_registrations( + settings, + &[ + seam_probe::builder(), + own_module(declaring_the_probe_s_path), + ], + ) + .err() + .expect("should refuse a second GET route on the probe's own path"); + + let message = error.to_string(); + assert!( + message.contains(&format!( + "Integration route registration failed for GET {}", + seam_probe::SEAM_PROBE_OWN_PATH + )), + "should name the method and the path: {message}" + ); + } + + /// The adapter answers `GET /_ts/config` itself, so a module's route + /// there never runs, and it has no route of its own for `POST` there, so + /// the same module's route for that method runs. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_module_route_runs_only_where_the_adapter_answers_no_route_of_its_own() { + let router = router_with( + r#" + [testing] + modules = ["own-module"] + "#, + &[own_module(declaring_the_config_page)], + ); + + let page = send(&router, Method::GET, CONFIG_PAGE_PATH, None).await; + assert_eq!( + page.status().as_u16(), + 200, + "the inspection page should answer" + ); + assert_eq!(content_type(&page), Some("text/html; charset=utf-8")); + assert_ne!(body_text(page), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 0, + "the module's GET route should never run" + ); + + let posted = send(&router, Method::POST, CONFIG_PAGE_PATH, None).await; + assert_eq!( + posted.status().as_u16(), + 200, + "the module's POST route should answer" + ); + assert_eq!(body_text(posted), OWN_MODULE_BODY); + assert_eq!( + OWN_MODULE_RUNS.load(Ordering::SeqCst), + 1, + "the module's POST route should run once" + ); + } +} + // --------------------------------------------------------------------------- // First-party absolute-URI regression — Spin delivers a path-only request URI // (built from IncomingRequest::path_with_query), so the shared proxy/click/sign From 4158f9bcaed9a4680475e78e070c7f620985452a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:11 +0000 Subject: [PATCH 21/25] Say how a module serves an endpoint of its own The integration guide gains "Serving an endpoint from a module". It says that a module may declare any path, that two routes on one method and path are refused when the registry is built, that a module route is reached only where the adapter answers no route of its own for that method and path, and that the steps before it are the adapter's request cycle. It tells a module author that HEAD and OPTIONS each need a route of their own and that a HEAD route sends no body. The rule that proxy routes sit beneath the prefix, and the Id row of the builder table, now allow a path of the module's own. --- docs/guide/integration-guide.md | 52 +++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 5b47769c6d..86a65ed823 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -19,7 +19,10 @@ Axum, or Spin SDK types. Build one `IntegrationRegistration` with only the hooks the feature needs. Use `with_deferred_js()` only for a separately loaded integration module and `without_js()` when another asset path owns delivery. Proxy routes should be -namespaced and bounded; do not introduce a general outbound proxy. +bounded, and sit beneath the integration's prefix unless the module serves an +endpoint of its own, as +[Serving an endpoint from a module](#serving-an-endpoint-from-a-module) +describes. Do not introduce a general outbound proxy. ## Compiling core-neutral fixture @@ -220,6 +223,51 @@ Streaming support is an adapter capability. Request checked `supports_streaming_responses()`; unsupported adapters must reject the request instead of silently buffering it. +## Serving an endpoint from a module + +A module can answer a path of its own, outside `/integrations/`, with nothing +added to core. `IntegrationProxy::routes()` may declare any path. +`IntegrationEndpoint::new` and `IntegrationEndpoint::get`, `post`, `put`, +`delete` and `patch` keep the path as written, while the proxy's own `get`, +`post`, `put`, `delete` and `patch` put `proxy_prefix()` in front of it. A +path ending in `/*` covers every path beneath it. + +```rust +fn routes(&self) -> Vec { + vec![ + // Beneath the prefix, at /integrations/example/status. + self.get("/status"), + // As written. + IntegrationEndpoint::get("/example/status"), + IntegrationEndpoint::new(Method::HEAD, "/example/status"), + ] +} +``` + +A route answers only the method it declares. A `GET` route answers neither +`HEAD` nor `OPTIONS`, so a module that answers either declares a route for +that method too, and a request whose method has no module route on its path +is handled as if the module declared nothing there. Nothing in core, the +adapters or EdgeZero strips the body from an answer to `HEAD`, so the +handler of a `HEAD` route sends no body itself. + +Two routes on one method and path are refused when the registry is built, +whether one module declares both or two modules declare one each. The +message names the method and the path, as in +`Integration route registration failed for GET /example/status`. + +A module route is reached only when the adapter answers no route of its own +for that method and path. Every adapter answers `GET /_ts/config` itself, so +a module's `GET` route there never runs, while a `POST` of that path reaches +a module route as any other request does. The registry does not know the +adapter's routes, so it accepts a module route that can never run, and +nothing reports that it never runs. + +The steps that run before a module route are the adapter's request cycle, +the same steps a request for one of the publisher's own pages goes through. +A step that answers a request, such as a request filter turning it back, +answers in place of the route. + ## Browser and script guards Add a browser module only when browser state is required. Immediate modules @@ -251,7 +299,7 @@ points at the functions that do the work. | Part | Purpose | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Id | Names the integration in its routes under `/integrations//`, in its browser module and in diagnostics | +| Id | Names the integration in its browser module, in diagnostics and in the prefix `/integrations//` of its proxy routes, unless a route takes [a path of its own](#serving-an-endpoint-from-a-module) | | Source | The crate or package name, reported when two builders claim one id, so an operator can tell which crates collided | | Build function | Reads `Settings` and returns the registration. It is called only when a section selects the module | | Validate function | The integration's own deploy rules. It runs for every builder, selected or not | From a9b41432289acfcc6bba1093d62fe0823a7ded84 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:12 +0000 Subject: [PATCH 22/25] Add the module endpoints design The row carries its own copy of the design its tests and guide section follow, docs/superpowers/specs/2026-10-26-module-endpoints-design.md. --- .../2026-10-26-module-endpoints-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-26-module-endpoints-design.md diff --git a/docs/superpowers/specs/2026-10-26-module-endpoints-design.md b/docs/superpowers/specs/2026-10-26-module-endpoints-design.md new file mode 100644 index 0000000000..29279eba5d --- /dev/null +++ b/docs/superpowers/specs/2026-10-26-module-endpoints-design.md @@ -0,0 +1,91 @@ +# Design Spec: A module outside core serves an endpoint of its own + +**Status:** Proposed, 2026-10-26. For the pull request [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353), "Prove a module outside core can serve its own endpoint", row 12 of the stack in [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777), for Tech Lab review. One commit removes what the pull request's attestation commits changed, returning the row's files to those of row 11, and the tests and the guide section below come after that commit. [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) carries every design document of the stack in one place. +**Author:** 51Degrees (contributed), for Tech Lab review +**Related specs:** `2026-08-27-integration-provider-seam-design.md` (row 7, the integration seam), `2026-10-12-diagnostic-headers-design.md` (row 15, which keeps `PlatformIdentity` in core), `2026-10-17-request-filters-on-every-adapter-design.md` (row 52) and `2026-10-17-edge-cookie-on-every-adapter-design.md` (row 53), in [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) +**Related PRs:** [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353) (row 12), [IABTechLab/trusted-server#1348](https://github.com/IABTechLab/trusted-server/pull/1348) (row 7, which opens the integration seam so that a vendor module can live outside core), [IABTechLab/trusted-server#1354](https://github.com/IABTechLab/trusted-server/pull/1354) (row 13, which closes the administration paths), [IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356) (row 15), [IABTechLab/trusted-server#1410](https://github.com/IABTechLab/trusted-server/pull/1410) (row 52, the request filters on every adapter) and [IABTechLab/trusted-server#1411](https://github.com/IABTechLab/trusted-server/pull/1411) (row 53, the Edge Cookie on every adapter) +**Issues:** none. [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777) lists the stack +**Last updated:** 2026-10-26 + +> **Why this spec exists.** A module outside core can already answer a path of its own on every adapter, so a deployment that wants an endpoint core does not have, attestation among them, serves that endpoint from a module and core needs no interface for it. No test shows a module answering a path outside `/integrations/` on all four adapters, and no guide says how a module does so, what runs before its route or which paths it can never take. This change adds those tests and that guide section, and no production code in core or in any adapter changes. + +## 1. What already exists + +Each statement holds at this row and at the top of the stack, unless it names a row. + +**A module declares any path.** `IntegrationProxy::routes()` in `crates/trusted-server-core/src/integrations/registry.rs` returns the module's endpoints, each an `IntegrationEndpoint` holding a method and a path. `IntegrationEndpoint::new(method, path)` and the functions `IntegrationEndpoint::get`, `post`, `put`, `delete` and `patch` keep the path as written. The trait's own methods of the same five names put `proxy_prefix()` in front of the path, and `proxy_prefix()` is `/integrations/` unless the module overrides it. A path ending in `/*` covers every path beneath it. The registry keeps one router for each of `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD` and `OPTIONS`, and skips a route for any other method with a warning in the log. A `GET` route answers neither `HEAD` nor `OPTIONS`, so a module that answers either declares a route of its own for that method, as Sourcepoint declares `HEAD` and `OPTIONS` beside its `GET` and `POST` (`crates/cmp/sourcepoint/src/lib.rs`). A request whose method has no module route on its path is handled as if no module route existed. + +**Shipped modules already declare paths outside `/integrations/`.** Prebid declares a `GET` route for each of its `script_patterns`, which are `/prebid.js`, `/prebid.min.js`, `/prebidjs.js` and `/prebidjs.min.js` unless its settings name others (`crates/auction/prebid/src/lib.rs`). Didomi overrides `proxy_prefix()`, so its routes sit beneath the `proxy_path` its settings give in place of `/integrations/didomi/consent` (`crates/cmp/didomi/src/lib.rs`). Core's JS asset proxy declares a `GET` route at the first-party path of each asset that its settings proxy (`crates/trusted-server-core/src/integrations/js_asset_proxy.rs`). + +**A module outside core hands its routes to the adapter in its builder.** A crate outside core gives its `IntegrationBuilder` to the adapter. On Fastly it does so through `run_with` (`crates/trusted-server-adapter-fastly/src/lib.rs`), and from row 35 also through `run_with_addressing`. On Axum, Cloudflare and Spin it does so through `TrustedServerApp::routes_with_registrations`, or through `build_state_with_registrations` for a host that builds its own router around the state, and also through Axum's `routes_with_registrations_and_services` from row 25 and each adapter's `routes_with_addressing` from row 35. The integration guide shows `run_with`, `routes_with_registrations` and `build_state_with_registrations` under "How an adapter composes it in" (`docs/guide/integration-guide.md`), and the configuration guide names the two functions of row 35. A builder's registration, and so its routes, is built only when a section of the module's type names the module in its `module` or `modules` key, unless the builder registers from the auction plan, which runs whether or not a section selects the module (`IntegrationBuilder::with_plan_registration`). Prebid registers that way, whenever the settings carry Prebid's table. The module reads its own table at `[.]` with `Settings::module_config`. The settings read every top-level table they do not know as a module type's section (`TypeSections` in `crates/trusted-server-core/src/settings.rs`), so a module of a type core does not know needs nothing from core to be configured. + +**Two routes cannot share one method and path.** `IntegrationRegistry::with_plan_and_registrations` inserts each route into matchit's router for the route's method. The insert fails for a second route at the same method and path, from the same module or another, for a route matchit cannot tell apart from one already in the router, such as `/foo/{names}` beside `/foo/{name}`, and for a path matchit cannot parse, such as `/src/{*filepath}/x`, whose catch-all is not at the end. Each failure refuses the registry with the message `Integration route registration failed for `, followed by matchit's reason. A stock build can clash from its settings alone. Prebid's default `script_patterns` include `/prebid.js`, and core's JS asset proxy accepts an asset at that path, so when the settings carry Prebid's table and give the JS asset proxy an asset at `/prebid.js` to proxy, both modules declare `GET /prebid.js`. What a refusal does depends on how the builders arrive. + +- The stock entry points compose the shipped modules' builders, being `run` on Fastly and `Hooks::routes()` on Axum, Cloudflare and Spin, and Fastly's `run_with` adds a deployment's own builders to them. When the registry is refused, `startup_error_router` answers every request that reaches the router with the error. The adapter builds its state, and so its registry, for every request in a production Fastly build, a Cloudflare Worker and a Spin component, and once when the Axum dev server starts. A Fastly build with the `reusable-sandbox` feature builds once for each app-config key in each sandbox and keeps no failed build, so the next request for that key builds again. +- On Axum, Cloudflare and Spin a deployment's own builders arrive through `routes_with_registrations` and the other functions above, which return the error to the deployment's code and build no router. The integration guide's example passes the error on with `?`. + +**Where a module route runs.** Every adapter registers its own routes for the methods each serves, then a catch-all for `/` and every other path on the seven methods, whose handler is the publisher fallback. The module routes are not in the adapter's router. The fallback asks the registry for a route at the request's method and path (`IntegrationRegistry::has_route`) and hands the request to the module that declared the route (`IntegrationRegistry::handle_proxy`). At the top of the stack the fallback runs these steps in this order on all four adapters. + +1. The closed path check of row 13, which answers `404` for every path that begins with `/_ts/admin`, and for `/admin/keys` and the paths beneath it. +2. The modules' request preparers. +3. robots.txt, for a `GET` or `HEAD` of `/robots.txt` when `[robots-txt]` is present. +4. The Edge Cookie steps. Axum, Cloudflare and Spin classify the device here, inside `build_ec_context`, and on Fastly the entry point has classified the device before routing. The steps then read the request's Edge Cookie context and resolve the location and the permissions for it. They answer in place of the route when the request's `Cookie` header cannot be read as text, with `400`, or when the selected Edge Cookie module cannot be built for the request. +5. The request filters. A filter that answers stands in for the route. +6. tsjs, for a `GET` beneath `/static/tsjs=`. +7. The module route. +8. The asset routes, then the publisher's origin. + +At this row a module route meets four differences from that order, and the list covers only what differs for a module route. + +1. `deny_admin_diagnostic_fallback` answers a request beneath `/_ts/admin` or `/admin/keys` that reaches the fallback, and row 13 replaces that function with the closed path check. +2. No adapter has robots.txt code at this row. Rows 14 to 24 answer the file with a route of each adapter's own when `[robots-txt]` is present. The fallback answers the file ahead of the other steps from row 25 on Fastly and from row 52 on Axum, Cloudflare and Spin. +3. Axum, Cloudflare and Spin classify no device and run no request filter until row 52, so a module route there runs after the preparers, with an Edge Cookie context read from the request, and that context's errors answer in place of the route as in step 4. Fastly classifies the device at its entry point and runs the preparers, the Edge Cookie steps and the request filters before a module route, as at the top of the stack. +4. Until row 13 removes `[[handlers]]`, each adapter's router asks for the password of a `[[handlers]]` entry whose pattern covers the request's path before any route runs, a module route included. + +**What a module route is handed.** `handle_proxy` removes a caller's `x-ts-ec` header, creates an Edge Cookie identifier for a page navigation that needs one, and calls the module's `handle` with a module call. At this row the call carries the request's method, host, scheme, path and query, the settings, the services, the connection and, from the Edge Cookie context, the permissions, the consent, the location, the device signals and the Edge Cookie identifier. From row 39 the call also carries the request's evidence (`5494af9ca`). A gated value, being the location, the device signals or the Edge Cookie identifier, is passed only to a route that declares and is granted every permission the value's producer declares (`IntegrationProxy::required_permissions`, none by default), and a value whose producer declares none is passed to every route. Nothing gates the route itself, so a route whose use needs a permission checks that the permission is granted. On Fastly the module's response then goes through the Edge Cookie's finalize step, which can write the cookie, and on Axum, Cloudflare and Spin the response goes through that step from row 53. + +**Requests a module route never sees.** Steps 2, 4 and 5 above keep from every module route a request that a preparer refuses, whose Edge Cookie step fails or that a request filter answers. Beyond those, a module route for a method and path never runs when something ahead of it answers the request. + +1. A route of the adapter's own for the same method and path, because the adapter's own route wins over the catch-all. That covers the named routes for the methods each serves, such as `GET /_ts/config` on every adapter, and `GET /health` on Axum and Spin. +2. A request the Fastly entry point answers before its router, being `GET /health`, `GET /_ts/debug/ja4` and, in a build with the `reusable-sandbox` feature, `GET /_ts/debug/sandbox`. +3. A closed path, which the fallback answers first, through `deny_admin_diagnostic_fallback` at this row and the closed path check from row 13. +4. A `GET` or `HEAD` of `/robots.txt` when `[robots-txt]` is present, from row 14, except on Axum, Cloudflare and Spin between rows 25 and 51, where the publisher path answers the file after the module routes. +5. A `GET` beneath `/static/tsjs=`, which the fallback serves as tsjs. +6. From row 35, the path a reader sent, where the deployment's request addressing rewrites the path before routing, because a module route is matched against the path the request continues with. + +Another method on a path of item 1 reaches the fallback as any other request does, so a module route for `POST /_ts/config` runs. The registry does not know the adapter's routes, so the registry accepts a module route on such a path, and nothing reports that the route never runs. + +## 2. What this change adds + +**A route outside `/integrations/` in the seam probe.** The seam probe is the test module that proves the integration seam from outside core, `crates/testing/seam-probe` at this row and `crates/testing/iabtl` from row 43. The probe gains the constant `SEAM_PROBE_OWN_PATH`, which is `/seam-probe/own-path`, and its proxy declares a route at that path for `GET` with `IntegrationEndpoint::get` and for `HEAD` with `IntegrationEndpoint::new(Method::HEAD, ...)`. The proxy's `handle` sends a request for that path to a handler of its own, `SeamProbeProxy::own_path`. The handler answers a `GET` with `200`, `Content-Type: text/plain` and the body `SEAM_PROBE_OWN_BODY`, a constant of the probe, and a `HEAD` with `200`, the same headers and an empty body, which the handler sends itself because nothing in core, the adapters or EdgeZero strips a body from a `HEAD` answer. The handler counts its runs against the token a request carries in `SEAM_PROBE_COUNT_HEADER`, as the preparer counts its own, and `own_path_runs_for` reads that count as `prepare_runs_for` reads the preparer's. `report_route_is_the_documented_path` changes, because it asserts that the probe declares one route, and so does the crate's own doc, which names the Axum and Fastly adapters as the only ones that depend on the probe. + +**Tests on all four adapters.** Each test runs through the adapter's own router, or through the build of the adapter's state where the test expects a refusal. The tests sit beside the probe's other tests on Fastly (`crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs`) and Axum (`crates/trusted-server-adapter-axum/tests/seam_probe.rs`), and in the route tests of Cloudflare and Spin (`tests/routes.rs` in each adapter), which gain the probe as a development dependency. + +1. A `GET` of `/seam-probe/own-path` answers `200` with `SEAM_PROBE_OWN_BODY`. +2. A `HEAD` of the path answers `200` with no body. +3. A `POST` of the path, which the probe does not declare, sent with a counting token, leaves `own_path_runs_for` at `0` for that token, and the answer is not the probe's. +4. The probe and a builder of the test's own that also declares `GET` on `/seam-probe/own-path` are refused together, and the error holds `Integration route registration failed for GET /seam-probe/own-path`. +5. A builder of the test's own that declares `GET` and `POST` on `/_ts/config` never runs for the `GET`, which the inspection page answers, and runs for the `POST`. + +**A section of the integration guide.** "Serving an endpoint from a module", in `docs/guide/integration-guide.md`, states only what holds at every row. It says that a module may declare any path through `routes()`, that two routes on one method and path are refused when the registry is built, that a module route is reached only when the adapter answers no route of its own for that method and path, and that the steps that run before a module route are the adapter's request cycle, the same steps a request for one of the publisher's own pages goes through. It does not list those steps, so rows 13, 14, 25, 35, 36, 39, 52 and 53, which change them, leave the section as it is. It tells a module author that a `HEAD` route sends no body, because nothing in core, the adapters or EdgeZero strips one, and that `HEAD` and `OPTIONS` each need a route of their own. The section amends the two places in the guide that tie a module's routes to its prefix, being the rule under "Choose the narrowest hook" that proxy routes should be namespaced and bounded, which is main's text, and the row of the builder table under "What the crate provides" that says the id names the integration's routes under `/integrations//`, which row 7 added. + +## 3. What this change does not add + +- **An early answer.** A module route runs in the publisher fallback, after the Edge Cookie steps and the request filters on every adapter that runs them, and no module route is answered ahead of those steps as the inspection endpoints are. On every adapter that runs request filters, each filter sees the request for a module's endpoint, is told through `RequestFilterInput::is_integration_route` that a module route takes the request, and can answer in the route's place. The stack's own bot filter, DataDome's protection filter, lets every module route through, because `is_request_protected` in `crates/bot-protection/datadome/src/protection.rs` returns false when `is_integration_route` is set. +- **A description of each adapter's request cycle.** No adapter's guide sets out the steps the adapter runs before a module route, at this row or at the top of the stack, and this change adds none. Section 1 gives them. +- **Attestation.** Core and the adapters gain no type, setting or route for attestation. A deployment that wants attestation serves it from a module of its own, as section 1 describes. +- **A platform identity.** `PlatformIdentity` is what an adapter reports about the platform it runs on, being a name, a service id, a service version, a staging flag and the location that answered. The type stays in core. Row 15 ([IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356)) adds `PlatformIdentity` to the platform module, `crates/trusted-server-core/src/platform/identity.rs`, with `PlatformIdentity::named` and the `UNKNOWN` constant, and hands the identity to page middleware so that core's `service_version` middleware can write `X-TS-Service-Version`. Each adapter reports its platform with `IntegrationRegistry::with_platform` when it builds its state. Fastly reports through `fastly_platform_identity`, which reads `FASTLY_SERVICE_ID`, `FASTLY_SERVICE_VERSION`, `FASTLY_IS_STAGING` and `FASTLY_POP`, and the other three report their name only. + +## 4. Tests + +- **Added.** The five checks of section 2 on each of the four adapters, and `report_route_is_the_documented_path` amended for the probe's three routes. +- **Already in the stack.** `proxy_route_reports_the_modules_geo_and_that_the_preparer_ran`, on Fastly and Axum, shows a route of the probe beneath `/integrations/seam_probe` reached through the adapter's router after the preparers. From row 52, `a_filter_s_request_header_reaches_the_route_and_its_effect_is_written_last`, on Axum, Cloudflare and Spin, shows a module route seeing a request header that a filter added. +- **Not tested.** No test declares a module route beneath a closed path, at `/robots.txt` or beneath `/static/tsjs=`. + +## Revision record + +| Date | Change | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-10-26 | First draft. | +| 2026-10-26 | Fixes from review round 1. What each adapter does at startup when two routes clash, and the other routes matchit refuses, the shipped modules that declare paths outside their prefix, what a module call carries at this row and from row 39 and what a gated value needs, where each adapter classifies the device and when the Edge Cookie step answers, the requests a module route never sees, the request filters and DataDome, the guide section and the two places it amends, the probe's handler and counter with five tests, the platform identity and the header. | +| 2026-10-26 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. The empty review log is removed, and this record carries the review rounds. | From 972c73a64fe44f9a2c251b1e207a1beef56495f1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:39 +0000 Subject: [PATCH 23/25] The endpoint proof's Cloudflare and Spin settings carry the sample policy --- crates/trusted-server-adapter-cloudflare/tests/routes.rs | 2 ++ crates/trusted-server-adapter-spin/tests/routes.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 699d96c6ef..dac49ac4a3 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -995,6 +995,8 @@ mod module_endpoints { "# )) .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) } /// The router with the `builders` composed in. diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 98251f78f7..f575149dac 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -875,6 +875,8 @@ mod module_endpoints { "# )) .expect("should parse route test settings") + // A loaded domain carries its policy. + .with_permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) } /// The router with the `builders` composed in. From 8c901d2614d25277f457908ddd42de68cd4db649 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:25 +0000 Subject: [PATCH 24/25] Row 12 takes the final module endpoints design --- .../specs/2026-10-26-module-endpoints-design.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-26-module-endpoints-design.md b/docs/superpowers/specs/2026-10-26-module-endpoints-design.md index 29279eba5d..f44c1eb1d7 100644 --- a/docs/superpowers/specs/2026-10-26-module-endpoints-design.md +++ b/docs/superpowers/specs/2026-10-26-module-endpoints-design.md @@ -88,4 +88,10 @@ Another method on a path of item 1 reaches the fallback as any other request doe | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-10-26 | First draft. | | 2026-10-26 | Fixes from review round 1. What each adapter does at startup when two routes clash, and the other routes matchit refuses, the shipped modules that declare paths outside their prefix, what a module call carries at this row and from row 39 and what a gated value needs, where each adapter classifies the device and when the Edge Cookie step answers, the requests a module route never sees, the request filters and DataDome, the guide section and the two places it amends, the probe's handler and counter with five tests, the platform identity and the header. | -| 2026-10-26 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. The empty review log is removed, and this record carries the review rounds. | +| 2026-10-26 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. | + +## Review log + +1. **Round 1.** A review in a fresh session, against `split/12-attestation`. Issues found, seven of them blocking. The seven were that the design said an adapter whose registry is refused serves its startup error, when on Axum, Cloudflare and Spin a deployment's own builders arrive through `routes_with_registrations`, which hands the error back to the deployment's code, that it said a module call carries the request's evidence, which only row 39 adds, that the steps before a module route left out device classification and did not say that the Edge Cookie step can answer in the route's place, that it said a bot filter can answer for a module's endpoint without saying that the stack's own DataDome filter lets every module route through, that the new guide section would contradict the two places in the guide that tie a module's routes to its prefix, that the section would go out of date as later rows change those steps, and that the planned `POST` check could not be built where the design put it. This revision says what each adapter does when two routes clash, what a module call carries at this row and from row 39, where each adapter classifies the device and when the Edge Cookie step answers, which requests a module route never sees, and that every request filter is told when a module route takes the request. The guide section now states only what holds at every row and amends those two places, and the seam probe gains a handler that counts its runs, so the `POST` check asserts that the handler never ran. +2. **Round 2.** A review in a fresh session, against `split/12-attestation`, of the round 1 fixes and the new text. Issues found, two of them blocking. The two were that the design said the stock `Hooks::routes()` takes no builders from outside core, when the stock list holds builders from crates outside core, such as Prebid and Didomi, and did not say what a clash does there, and that the guide section said two modules on one path are refused, when the registry keeps one router for each method. This revision says that the stock entry points compose the shipped modules' builders and answer every request with the startup error when the registry is refused, gives a clash that a stock build reaches from its settings alone, at `/prebid.js`, and says that two routes on one method and path are refused. It also lists every function through which a deployment's own builders arrive, says when each adapter builds its state, and says that nothing in core, the adapters or EdgeZero strips the body from a `HEAD` answer. +3. **Round 3.** A review in a fresh session, against `split/12-attestation`, of the round 2 fixes and the whole text. None blocking. The stock entry points are now said to compose the shipped modules' builders and to answer every request with the startup error when the registry is refused, with a clash at `/prebid.js` that the settings alone reach, and the guide section says that two routes on one method and path are refused. The round read the row's code for the probe's own path, its handler and its counter, the five checks on each of the four adapters, the development dependencies that Cloudflare and Spin gain, the crate's own doc, the guide section and the two places it amends, the registry's one router for each of seven methods and its refusal, the plan registration, the Fastly entry point's early answers, the closed path check, Didomi's prefix, the functions through which a deployment's own builders arrive at the top of the stack, the two tests section 4 names there and row 15's platform identity, and found each as the design states it. From 11670648dd84adda4c56326d263642eaf33fac8f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:32:26 +0000 Subject: [PATCH 25/25] Row 12's design copy carries the date it is proposed, 2026-10-27 --- ...ign.md => 2026-10-27-module-endpoints-design.md} | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) rename docs/superpowers/specs/{2026-10-26-module-endpoints-design.md => 2026-10-27-module-endpoints-design.md} (95%) diff --git a/docs/superpowers/specs/2026-10-26-module-endpoints-design.md b/docs/superpowers/specs/2026-10-27-module-endpoints-design.md similarity index 95% rename from docs/superpowers/specs/2026-10-26-module-endpoints-design.md rename to docs/superpowers/specs/2026-10-27-module-endpoints-design.md index f44c1eb1d7..895a508d47 100644 --- a/docs/superpowers/specs/2026-10-26-module-endpoints-design.md +++ b/docs/superpowers/specs/2026-10-27-module-endpoints-design.md @@ -1,11 +1,11 @@ # Design Spec: A module outside core serves an endpoint of its own -**Status:** Proposed, 2026-10-26. For the pull request [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353), "Prove a module outside core can serve its own endpoint", row 12 of the stack in [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777), for Tech Lab review. One commit removes what the pull request's attestation commits changed, returning the row's files to those of row 11, and the tests and the guide section below come after that commit. [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) carries every design document of the stack in one place. +**Status:** Proposed, 2026-10-27. For the pull request [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353), "Prove a module outside core can serve its own endpoint", row 12 of the stack in [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777), for Tech Lab review. One commit removes what the pull request's attestation commits changed, returning the row's files to those of row 11, and the tests and the guide section below come after that commit. [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) carries every design document of the stack in one place. **Author:** 51Degrees (contributed), for Tech Lab review **Related specs:** `2026-08-27-integration-provider-seam-design.md` (row 7, the integration seam), `2026-10-12-diagnostic-headers-design.md` (row 15, which keeps `PlatformIdentity` in core), `2026-10-17-request-filters-on-every-adapter-design.md` (row 52) and `2026-10-17-edge-cookie-on-every-adapter-design.md` (row 53), in [IABTechLab/trusted-server#1342](https://github.com/IABTechLab/trusted-server/pull/1342) **Related PRs:** [IABTechLab/trusted-server#1353](https://github.com/IABTechLab/trusted-server/pull/1353) (row 12), [IABTechLab/trusted-server#1348](https://github.com/IABTechLab/trusted-server/pull/1348) (row 7, which opens the integration seam so that a vendor module can live outside core), [IABTechLab/trusted-server#1354](https://github.com/IABTechLab/trusted-server/pull/1354) (row 13, which closes the administration paths), [IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356) (row 15), [IABTechLab/trusted-server#1410](https://github.com/IABTechLab/trusted-server/pull/1410) (row 52, the request filters on every adapter) and [IABTechLab/trusted-server#1411](https://github.com/IABTechLab/trusted-server/pull/1411) (row 53, the Edge Cookie on every adapter) **Issues:** none. [IABTechLab/trusted-server#777](https://github.com/IABTechLab/trusted-server/issues/777) lists the stack -**Last updated:** 2026-10-26 +**Last updated:** 2026-10-27 > **Why this spec exists.** A module outside core can already answer a path of its own on every adapter, so a deployment that wants an endpoint core does not have, attestation among them, serves that endpoint from a module and core needs no interface for it. No test shows a module answering a path outside `/integrations/` on all four adapters, and no guide says how a module does so, what runs before its route or which paths it can never take. This change adds those tests and that guide section, and no production code in core or in any adapter changes. @@ -19,7 +19,7 @@ Each statement holds at this row and at the top of the stack, unless it names a **A module outside core hands its routes to the adapter in its builder.** A crate outside core gives its `IntegrationBuilder` to the adapter. On Fastly it does so through `run_with` (`crates/trusted-server-adapter-fastly/src/lib.rs`), and from row 35 also through `run_with_addressing`. On Axum, Cloudflare and Spin it does so through `TrustedServerApp::routes_with_registrations`, or through `build_state_with_registrations` for a host that builds its own router around the state, and also through Axum's `routes_with_registrations_and_services` from row 25 and each adapter's `routes_with_addressing` from row 35. The integration guide shows `run_with`, `routes_with_registrations` and `build_state_with_registrations` under "How an adapter composes it in" (`docs/guide/integration-guide.md`), and the configuration guide names the two functions of row 35. A builder's registration, and so its routes, is built only when a section of the module's type names the module in its `module` or `modules` key, unless the builder registers from the auction plan, which runs whether or not a section selects the module (`IntegrationBuilder::with_plan_registration`). Prebid registers that way, whenever the settings carry Prebid's table. The module reads its own table at `[.]` with `Settings::module_config`. The settings read every top-level table they do not know as a module type's section (`TypeSections` in `crates/trusted-server-core/src/settings.rs`), so a module of a type core does not know needs nothing from core to be configured. -**Two routes cannot share one method and path.** `IntegrationRegistry::with_plan_and_registrations` inserts each route into matchit's router for the route's method. The insert fails for a second route at the same method and path, from the same module or another, for a route matchit cannot tell apart from one already in the router, such as `/foo/{names}` beside `/foo/{name}`, and for a path matchit cannot parse, such as `/src/{*filepath}/x`, whose catch-all is not at the end. Each failure refuses the registry with the message `Integration route registration failed for `, followed by matchit's reason. A stock build can clash from its settings alone. Prebid's default `script_patterns` include `/prebid.js`, and core's JS asset proxy accepts an asset at that path, so when the settings carry Prebid's table and give the JS asset proxy an asset at `/prebid.js` to proxy, both modules declare `GET /prebid.js`. What a refusal does depends on how the builders arrive. +**Two routes cannot share one method and path.** `IntegrationRegistry::with_plan_and_registrations` inserts each route into matchit's router for the route's method. The insert fails for a second route at the same method and path, from the same module or another, for a route matchit cannot tell apart from one already in the router, such as `/foo/{names}` beside `/foo/{name}`, and for a path matchit cannot parse, such as `/src/{*filepath}/x`, whose catch-all is not at the end. Each failure refuses the registry with the message `Integration route registration failed for `, followed by matchit's reason. A stock build, being an adapter built from the repository as it ships with the builders its own list names, can clash from its settings alone. Prebid's default `script_patterns` include `/prebid.js`, and core's JS asset proxy accepts an asset at that path, so when the settings carry Prebid's table and give the JS asset proxy an asset at `/prebid.js` to proxy, both modules declare `GET /prebid.js`. What a refusal does depends on how the builders arrive. - The stock entry points compose the shipped modules' builders, being `run` on Fastly and `Hooks::routes()` on Axum, Cloudflare and Spin, and Fastly's `run_with` adds a deployment's own builders to them. When the registry is refused, `startup_error_router` answers every request that reaches the router with the error. The adapter builds its state, and so its registry, for every request in a production Fastly build, a Cloudflare Worker and a Spin component, and once when the Axum dev server starts. A Fastly build with the `reusable-sandbox` feature builds once for each app-config key in each sandbox and keeps no failed build, so the next request for that key builds again. - On Axum, Cloudflare and Spin a deployment's own builders arrive through `routes_with_registrations` and the other functions above, which return the error to the deployment's code and build no router. The integration guide's example passes the error on with `?`. @@ -73,6 +73,7 @@ Another method on a path of item 1 reaches the fallback as any other request doe - **An early answer.** A module route runs in the publisher fallback, after the Edge Cookie steps and the request filters on every adapter that runs them, and no module route is answered ahead of those steps as the inspection endpoints are. On every adapter that runs request filters, each filter sees the request for a module's endpoint, is told through `RequestFilterInput::is_integration_route` that a module route takes the request, and can answer in the route's place. The stack's own bot filter, DataDome's protection filter, lets every module route through, because `is_request_protected` in `crates/bot-protection/datadome/src/protection.rs` returns false when `is_integration_route` is set. - **A description of each adapter's request cycle.** No adapter's guide sets out the steps the adapter runs before a module route, at this row or at the top of the stack, and this change adds none. Section 1 gives them. +- **A change to consent or privacy.** No production code changes, so a module route is handed what section 1 says, a gated value still reaches only a route that declares and is granted every permission the value's producer declares, and the tests and the guide section add no new reader of a request. - **Attestation.** Core and the adapters gain no type, setting or route for attestation. A deployment that wants attestation serves it from a module of its own, as section 1 describes. - **A platform identity.** `PlatformIdentity` is what an adapter reports about the platform it runs on, being a name, a service id, a service version, a staging flag and the location that answered. The type stays in core. Row 15 ([IABTechLab/trusted-server#1356](https://github.com/IABTechLab/trusted-server/pull/1356)) adds `PlatformIdentity` to the platform module, `crates/trusted-server-core/src/platform/identity.rs`, with `PlatformIdentity::named` and the `UNKNOWN` constant, and hands the identity to page middleware so that core's `service_version` middleware can write `X-TS-Service-Version`. Each adapter reports its platform with `IntegrationRegistry::with_platform` when it builds its state. Fastly reports through `fastly_platform_identity`, which reads `FASTLY_SERVICE_ID`, `FASTLY_SERVICE_VERSION`, `FASTLY_IS_STAGING` and `FASTLY_POP`, and the other three report their name only. @@ -86,9 +87,9 @@ Another method on a path of item 1 reaches the fallback as any other request doe | Date | Change | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-10-26 | First draft. | -| 2026-10-26 | Fixes from review round 1. What each adapter does at startup when two routes clash, and the other routes matchit refuses, the shipped modules that declare paths outside their prefix, what a module call carries at this row and from row 39 and what a gated value needs, where each adapter classifies the device and when the Edge Cookie step answers, the requests a module route never sees, the request filters and DataDome, the guide section and the two places it amends, the probe's handler and counter with five tests, the platform identity and the header. | -| 2026-10-26 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. | +| 2026-10-27 | First draft. | +| 2026-10-27 | Fixes from review round 1. What each adapter does at startup when two routes clash, and the other routes matchit refuses, the shipped modules that declare paths outside their prefix, what a module call carries at this row and from row 39 and what a gated value needs, where each adapter classifies the device and when the Edge Cookie step answers, the requests a module route never sees, the request filters and DataDome, the guide section and the two places it amends, the probe's handler and counter with five tests, the platform identity and the header. | +| 2026-10-27 | Fixes from review round 2. What a refusal does on the stock entry points and on a deployment's own, with a clash from settings alone, two routes on one method and path in place of two modules on one path, every function that takes outside builders, when each adapter builds its state, the plan registration, the shipped modules' paths outside `/integrations/`, the steps that keep a request from a module route and the rows that change them, and `HEAD` bodies. | ## Review log