Skip to content

Prove a module outside core can serve its own endpoint - #1353

Open
jwrosewell wants to merge 50 commits into
feature/11-config-by-hostfrom
feature/12-attestation
Open

jwrosewell wants to merge 50 commits into
feature/11-config-by-hostfrom
feature/12-attestation

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-adapter-cloudflare/tests 1 file, 322 lines added, 0 removed
crates/trusted-server-adapter-spin/tests 1 file, 320 lines added, 0 removed
crates/trusted-server-adapter-axum/tests 1 file, 280 lines added, 0 removed
crates/trusted-server-adapter-fastly/src 1 file, 278 lines added, 0 removed
crates/trusted-server-core/src 1 file, 115 lines added, 0 removed
docs/superpowers 1 file, 98 lines added, 0 removed
crates/testing/seam-probe 1 file, 80 lines added, 14 removed
docs/guide 1 file, 50 lines added, 2 removed
crates/trusted-server-adapter-cloudflare/Cargo.toml 1 file, 3 lines added, 0 removed
crates/trusted-server-adapter-spin/Cargo.toml 1 file, 3 lines added, 0 removed
Cargo.lock 1 file, 2 lines added, 0 removed

Closes

Part of #777

Spec

Spec: module-endpoints

Test plan

CI on this pull request at acd7975 passed all 20 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at acd7975

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on one app-config blob for each host (#1352).

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 serves it from a module, and core needs no interface for it. This pull request first added attestation to core. At the 51Degrees PR review on 23 October it was agreed with IAB Tech Lab that attestation leaves the stack, with no attestation interface in core, and it continues on 51Degrees' own deployment. As decided by 51Degrees after the review, this row keeps tests on all four adapters that prove a module outside core serves an endpoint of its own, and a guide section that says how. No production code in core or in any adapter changes.

What changed after the 51Degrees PR review

Agreed at the review on 23 October, point 4. Attestation leaves the stack, with no attestation interface in core.

  • Take attestation out of the stack. The first commit removes what this pull request's attestation commits added or changed, so its files equal those of Add one app-config blob for each host #1352 before anything new is added.
  • As decided by 51Degrees after the review, six commits prove that a module outside core serves an endpoint of its own on all four adapters, and say how in the integration guide and in the module endpoints design, as the sections below describe. They are "Test that two routes on one method and path are refused", "Give the seam probe a route outside /integrations/", "Reach the probe's own path through Fastly and Axum", "Reach the probe's own path through Cloudflare and Spin", "Say how a module serves an endpoint of its own" and "Add the module endpoints design".
  • This pull request's copy of the module endpoints design takes its final text from Add the integration seam design specs #1342, with its review log.

Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start. Since #1345 a loaded domain carries its permission policy and every services graph needs one, so the tests below give theirs the sample policy.

  • The endpoint proof's Cloudflare and Spin settings carry the sample policy.

Kept current

What this pull request does

  • Attestation leaves. The first commit removes what this row's attestation commits added or changed, so the row's files equal those of the row below (Add one app-config blob for each host #1352) before anything new is added. That takes out attestation.rs, the key schedule and build identity in core's build script, the route on each adapter, the attestation guide and its local test script.

  • A route outside /integrations/ in the seam probe. The seam probe, the test module in crates/testing/seam-probe through which tests drive the integration seam from outside core, declares GET and HEAD on /seam-probe/own-path, its constant SEAM_PROBE_OWN_PATH. A GET answers 200 with SEAM_PROBE_OWN_BODY, and a HEAD answers 200 with the same headers and no body, which the handler leaves out itself because nothing in core, the adapters or EdgeZero strips one. The handler counts its runs against the token a request carries in SEAM_PROBE_COUNT_HEADER, as the probe's request preparer counts its own.

  • The same five checks on all four adapters, each through the adapter's own router, or through the build of the adapter's state where the check expects a refusal.

    1. A GET of /seam-probe/own-path answers 200 with the probe's body.
    2. A HEAD of the path answers 200 with no body.
    3. A POST of the path, which the probe does not declare, never reaches the probe's handler, and the answer is not the probe's.
    4. A second module that also declares GET on the path is refused with the probe when the state is built, and the error holds Integration route registration failed for GET /seam-probe/own-path.
    5. A module route on GET /_ts/config never runs, because the inspection page answers it, and the same module's route on POST /_ts/config does.

    The checks sit beside the probe's other tests on Fastly and Axum, and in the route tests of Cloudflare and Spin, which take the probe as a development dependency.

  • A registry test. Core gains a test that a second route on one method and path is refused when the registry is built, whether one module declares both or two modules declare one each, and that two methods on one path are each routed to the module that declares them. Nothing tested that before.

  • A guide section. "Serving an endpoint from a module", in docs/guide/integration-guide.md, 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 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 module's prefix, and the id row of the builder table, now allow a path of the module's own.

Why

The agreement at the review rests on a module already being able to serve an endpoint such as an attestation page, so core needs no attestation interface. No test showed a module outside core answering a path outside /integrations/ on all four adapters, and no guide said how a module does so, so this row adds both. The design, 2026-10-27-module-endpoints-design.md, also sets out what runs before a module route on each adapter and which requests a module route never sees.

What does not change

How it was verified

Locally on Windows, on this pull request's tree, these passed: cargo fmt --all -- --check, clippy-fastly, clippy-axum, clippy-cli, clippy-cloudflare-wasm and clippy-spin-wasm, the core library's tests (2,797), cargo test-axum (960, 1 ignored), cargo test-cloudflare (63, 1 ignored), cargo test-spin (101, 1 ignored), the Fastly adapter's library tests under Viceroy (280), the seam probe's tests (14) and the docs checks.

The checks were shown to fail. With the probe's GET and HEAD routes moved beneath its prefix and a POST route added on the path, checks 1 to 4 failed on all four adapters, and check 5, which does not use the probe's path, passed.

References #777.

jwrosewell and others added 14 commits October 9, 2026 11:08
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.
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.
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.
`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.
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.
`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.
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.
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.
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.
The wasm clock lint reaches core's build script, which runs on the host.
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.
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.
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.
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.
# Conflicts:
#	crates/trusted-server-core/src/settings.rs
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23
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.
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.
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.
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.
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.
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.
@jwrosewell jwrosewell changed the title Add attestation of who operates a deployment and which build it runs Prove a module outside core can serve its own endpoint Oct 28, 2026
@jwrosewell
jwrosewell removed this pull request from stack #1422 October 28, 2026 11:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trusted Server attestation

2 participants