Skip to content

Add ts keys list and ts keys verify commands - #1502

Open
dhruv8sh wants to merge 1 commit into
mainfrom
add-ts-keys-list-verify
Open

dhruv8sh wants to merge 1 commit into
mainfrom
add-ts-keys-list-verify

Conversation

@dhruv8sh

@dhruv8sh dhruv8sh commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add ts keys list [--json] so operators can confirm which signing keys a service publishes in /.well-known/trusted-server.json without the admin password or hand-written curl.
  • Add ts keys verify to check a signature end to end against a published kid; exits non-zero when it does not verify, so it works in rotation scripts.
  • Extract url_guard::service_endpoint from ts cache purge so every --service flag shares the same base-URL validation (HTTPS-only for non-loopback, no credentials/path/query/fragment).

Changes

File Change
crates/trusted-server-cli/src/url_guard.rs New shared service_endpoint helper
crates/trusted-server-cli/src/commands/cache/purge.rs Use shared service_endpoint instead of local validation
crates/trusted-server-cli/src/commands/keys/mod.rs New keys command group, discovery fetch helpers
crates/trusted-server-cli/src/commands/keys/list.rs ts keys list [--json]
crates/trusted-server-cli/src/commands/keys/verify.rs ts keys verify
crates/trusted-server-cli/src/commands/mod.rs, src/run.rs Register and dispatch keys
crates/trusted-server-cli/tests/keys_cli.rs CLI integration tests
docs/guide/key-rotation.md Use ts keys list / verify for confirming publication

Closes

Closes #1431

Overlap with #1430

This overlaps with the sibling PR for #1430 (ts keys rotate / deactivate, branch add-ts-keys-commands). Both add crates/trusted-server-cli/src/commands/keys/mod.rs, edit run.rs, and extract shared URL validation, so whichever merges second needs a rebase. Once #1430 lands, the key-rotation guide's curl examples for rotate/deactivate should switch to ts keys.

Open questions

  • Should verify accept --payload-file / stdin for large or binary payloads?
  • Should verify support --json output?

Spec

Spec: Scoped by issue #1431 (small CLI addition).

Test plan

  • cargo test-fastly && cargo test-fastly-reuse && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • All 8 clippy aliases
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run (1838 passed / 1 skipped, Node 24.20.0)
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format (prettier)
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: CLI tests (cargo test -p trusted-server-cli --target <host>), template_build_digest, parity

Checklist

  • Feature PRs: spec linked above, ending in a review log of two or more agent review rounds
  • Changes follow AGENTS.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

@dhruv8sh dhruv8sh self-assigned this Nov 5, 2026
@aram356 aram356 added this to the 202611 milestone Nov 5, 2026

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds ts keys list and ts keys verify, shared service-URL validation, local-fixture tests, and key-rotation guidance. No concrete correctness defects found across the nine changed files; the documented feature-spec requirement remains unmet.

Cross-cutting / body-level findings

  • 🔧 Missing required feature spec — The new CLI commands at crates/trusted-server-cli/src/commands/keys/mod.rs:19–25 meet CONTRIBUTING.md's definition of a feature. It requires a linked design spec with at least two recorded review rounds. The PR instead cites issue #1431 as its spec; neither that issue nor the changed files supplies the required design/review log.

    Proposed resolution: add and link a reviewed spec covering:

    # Signing-Key Inspection CLI
    
    ## Command and output contracts
    ## Adapter impact
    ## Configuration, consent, and privacy
    ## Failure handling
    ## Compatibility and rollout
    ## Testing
    ## Review log

    Apply manually — this requires a new document, actual review rounds, and a PR-description update, not a contiguous suggestion.

CI Status

  • integration tests: PASS
  • browser integration tests: PASS
  • trace runtime and browser acceptance: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • cargo test (axum native): PASS
  • CodeQL: PASS
  • Analyze (actions): PASS
  • CI Node pin guard: PASS
  • cargo test: PASS (required)
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • Analyze (rust): PASS
  • CLAUDE.md symlink guard: PASS
  • cargo fmt: PASS (required)
  • worker-deadlines (spin): PASS
  • cargo test (cross-adapter parity): PASS
  • ts dev lint domains (changed lines): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • format-docs: PASS (required)
  • HTML EOF runtime (Fastly): PASS
  • vitest: PASS
  • Analyze (javascript-typescript): PASS
  • docs-proposal scripts: PASS
  • worker-deadlines (cloudflare): PASS
  • format-typescript: PASS (required)
  • prepare integration artifacts: PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • Analyze (python): PASS
  • Analyze (javascript-typescript): PASS

Local git diff --check: PASS. Reviewer worktree clean at 8df9a40010c4279b6e067306994029dfe7db5847. Builds and tests were not rerun locally because remote CI passed; no suggestions required scratch verification.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds ts keys list and ts keys verify on a shared url_guard::service_endpoint. Three issues block merge:

  • Signatures that start with - are rejected by clap. This is about one in 64 signatures, since they are base64url.
  • The 404 hint misdirects, while the status a broken signing store actually returns (500) gets no hint.
  • Three behaviours the code promises have no test.

Both commands were exercised against the real Axum adapter seeded with Ed25519 keys. Every suggestion below passes cargo fmt, cargo clippy-cli and the full trusted-server-cli suite, both alone and applied together.

6 of the 8 inline comments below carry a one-click GitHub suggestion — use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches multiple files or lines outside the diff and can't be auto-applied.

Blocking

🔧 wrench

  • --signature and --payload reject values that start with - — see inline at crates/trusted-server-cli/src/commands/keys/mod.rs:55
  • The 404 hint points the wrong way; a 500 gets no hint — see inline at crates/trusted-server-cli/src/commands/keys/list.rs:66
  • Three behaviours the code promises have no test — see inline at crates/trusted-server-cli/tests/keys_cli.rs:181

Non-blocking

🤔 thinking / ⛏ nitpick

  • 🤔 The guide doesn't say what ts keys verify proves — see inline at docs/guide/key-rotation.md:328
  • 🤔 "internal verification error" reads like a bad signature — see inline at crates/trusted-server-cli/src/commands/keys/verify.rs:42
  • 🤔 Service-controlled text reaches the terminal unescaped — see inline at crates/trusted-server-cli/src/commands/keys/list.rs:42
  • ⛏ The HTTPS error claims to protect credentials ts keys never sends — see inline at crates/trusted-server-cli/src/url_guard.rs:56
  • ⛏ The "byte for byte" test trims the newline it checks — see inline at crates/trusted-server-cli/tests/keys_cli.rs:84

Cross-cutting / body-level findings

  • ♻️ Converge with #1500 before the second of the two PRs merges.

    • Command and dispatch: both PRs add commands/keys/mod.rs, with incompatible shapes. Here it is KeysCommand { List, Verify } with run(command, out) -> CliResult<RunOutcome>; in #1500 it is KeysCommand { Rotate, Deactivate } with run(command, confirm, out) -> CliResult<()>.
    • HTTP plumbing exists twice: send here and admin_client::post_admin_json there each build a runtime and a client with REQUEST_TIMEOUT and Policy::none(), then read the body.
    • URL checks exist twice: url_guard::service_endpoint and #1500's admin_endpoint are the same check, except for the dead userinfo test this PR correctly drops.
    • Proposed end state: whichever PR lands second should leave one KeysCommand (list, verify, rotate, deactivate) and one run that returns RunOutcome and takes the confirmer. It should also leave one transport with optional Basic auth, used by ts cache purge too. The Keys help line ("Inspect a deployed service's request-signing keys") should cover rotation as well.
  • 🏕 Other guides still teach curl for these two routes:

    • docs/guide/error-reference.md:475 (curl … | jq '.jwks.keys[].kid')
    • docs/guide/request-signing.md:118-180 and :336
    • docs/guide/api-reference.md:685 and :742
    • In this guide: the "Testing" list (key-rotation.md:620-622) and the "Signature Verification Fails After Rotation" and "Key Not in JWKS" troubleshooting entries (:649-672)

    Each one can switch to ts keys list or ts keys verify with a one-line edit.

  • 📝 Release notes and the CLI index don't mention ts keys. CHANGELOG.md ### Added has no entry for the two commands. The hand-maintained command index in docs/guide/cli.md has no ts keys, ts keys list or ts keys verify rows; it already lacks ts cache and ts origin too.

  • 📝 On the open questions:

    • --payload-file/stdin: /verify-signature caps the whole JSON body at 4096 bytes, and a 5000-byte --payload got 413 Payload Too Large from the Axum adapter. The payload is also a JSON string verified as its UTF-8 bytes. A file input therefore can't carry large or binary payloads. It would still help with quoting and with $(cat file) dropping trailing newlines, so it fits a follow-up rather than this PR.
    • --json for verify: printing the service's answer verbatim, with the same exit codes, would mirror list --json. Also a follow-up.
  • 📝 The earlier spec finding still applies: CONTRIBUTING.md ("What counts as a feature") lists a new CLI command as a feature that needs a linked, agent-reviewed spec.

CI Status

  • integration tests: PASS
  • browser integration tests: PASS
  • trace runtime and browser acceptance: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • cargo test (axum native): PASS
  • CodeQL: PASS
  • Analyze (actions): PASS
  • CI Node pin guard: PASS
  • cargo test: PASS (required)
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • Analyze (rust): PASS
  • CLAUDE.md symlink guard: PASS
  • cargo fmt: PASS (required)
  • worker-deadlines (spin): PASS
  • cargo test (cross-adapter parity): PASS
  • ts dev lint domains (changed lines): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • format-docs: PASS (required)
  • HTML EOF runtime (Fastly): PASS
  • vitest: PASS
  • Analyze (javascript-typescript): PASS
  • docs-proposal scripts: PASS
  • worker-deadlines (cloudflare): PASS
  • format-typescript: PASS (required)
  • prepare integration artifacts: PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • Analyze (python): PASS
  • Analyze (javascript-typescript): PASS

Comment on lines +51 to +55
#[arg(long)]
pub payload: String,

/// Base64-encoded signature to check.
#[arg(long)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — --signature and --payload reject values that start with -

Trusted Server signs with unpadded base64url (crates/trusted-server-core/src/request_signing/signing.rs:167), and - is in that alphabet, so about one signature in 64 starts with it. clap then reads the value as a flag. Against the Axum adapter seeded with a real key:

$ ts keys verify --service http://127.0.0.1:18980 --kid ts-2024-02-15 \
    --payload example-payload --signature -LRTQNDx-hNzROGcDj8yJhE-CU3nejfN4NSaacyH0vp8JKNxY5USfb72JchC4jUuLOv7BEjDoXq-Whovxhm6Cg
error: unexpected argument '-L' found

That exits 2, so a rotation script that passes a captured signature fails at random. The only workaround is --signature=-…, which neither --help nor the guide mentions. With the change below, signatures starting with - verify against the same service.

Suggested change
#[arg(long)]
pub payload: String,
/// Base64-encoded signature to check.
#[arg(long)]
#[arg(long, allow_hyphen_values = true)]
pub payload: String,
/// Base64-encoded signature to check.
#[arg(long, allow_hyphen_values = true)]

A regression test for the run.rs tests module (apply by hand, since it's outside this diff). Without the fix it fails at the parse step:

    #[test]
    fn keys_verify_accepts_values_that_start_with_a_hyphen() {
        // Trusted Server signs with unpadded base64url, whose alphabet includes `-`, so
        // about one signature in 64 starts with one.
        let args = parse(&[
            "ts",
            "keys",
            "verify",
            "--service",
            "https://edge.example.com",
            "--kid",
            "ts-2024-01-15",
            "--payload",
            "-example-payload",
            "--signature",
            "-LRTQNDx",
        ]);
        let Command::Keys(crate::commands::keys::KeysCommand::Verify(verify)) = args.command else {
            panic!("expected keys verify command");
        };
        assert_eq!(
            verify.payload, "-example-payload",
            "should keep the payload"
        );
        assert_eq!(verify.signature, "-LRTQNDx", "should keep the signature");
    }

Comment on lines +62 to +66
let hint = if status.as_u16() == 404 {
" — request signing may be disabled on this service"
} else {
""
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — The 404 hint points the wrong way, and the real "keys unreadable" status gets no hint

Every adapter registers GET /.well-known/trusted-server.json unconditionally (the route table in docs/guide/api-reference.md; crates/trusted-server-adapter-axum/tests/routes.rs says "5xx from missing signing keys is acceptable; 404 is not"). handle_trusted_server_discovery maps an unreadable jwks_store to TrustedServerError::Configuration, which answers 500. Against the real Axum adapter, and a plain host that answers 404:

$ ts keys list --service http://127.0.0.1:18990    # Axum, no signing stores
[ts] fetching the discovery document failed: 500 Internal Server Error
An internal error occurred

$ ts keys list --service http://127.0.0.1:18991    # not a Trusted Server
[ts] fetching the discovery document failed: 404 Not Found — request signing may be disabled on this service

An operator who mistyped --service is sent off to debug request signing, and one whose keys are actually unreadable gets no hint at all.

Suggested change
let hint = if status.as_u16() == 404 {
" — request signing may be disabled on this service"
} else {
""
};
// Every adapter registers the discovery route, so a 404 means `--service` names
// something other than a Trusted Server, while unreadable signing stores answer 500.
let hint = match status.as_u16() {
404 => " — check that --service names a Trusted Server deployment",
500 => " — the service could not read its signing keys; check its jwks_store",
_ => "",
};

To pin both hints, replace list_reports_a_failed_fetch_as_an_error in tests/keys_cli.rs (apply by hand; it fails without the change above):

#[test]
fn list_reports_a_failed_fetch_as_an_error() {
    for (status, hint) in [(404, "names a Trusted Server"), (500, "jwks_store")] {
        let server = FixtureServer::start(move |_request| {
            FixtureResponse::html("error").with_status(status)
        });

        let mut out = Vec::new();
        let error = run(KeysCommand::List(list_args(&server, false)), &mut out)
            .expect_err("should fail on an error status");

        assert!(
            error.contains(&status.to_string()),
            "should name the status: {error}"
        );
        assert!(
            error.contains(hint),
            "should point at the likely cause: {error}"
        );
        assert!(out.is_empty(), "should print no keys");
    }
}

"should explain the transport requirement: {error}"
);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Three behaviours the code promises have no test

Each of these mutants passes every trusted-server-cli test at this head:

  • M1: remove .redirect(reqwest::redirect::Policy::none()) from send (keys/mod.rs:97), the guarantee its doc comment spells out.
  • M2: let mut output = if args.json { body } else { render_keys(&body)? };, which drops the "Validated even for --json" check (list.rs:73-75).
  • M3: drop if !status.is_success() in run_verify (verify.rs:65), so a 413 reports as invalid verification response instead of naming the status.

tests/cache_purge.rs already has the redirect counterpart for purge. These three tests pass at this head and fail under M1, M2 and M3 respectively:

Suggested change
}
}
#[test]
fn list_does_not_follow_a_redirect() {
let server = FixtureServer::start(|request| {
if request.path == "/.well-known/trusted-server.json" {
FixtureResponse::html("")
.with_status(302)
.with_header("location", "/moved.json")
} else {
json_response(discovery_body())
}
});
let mut out = Vec::new();
let error = run(KeysCommand::List(list_args(&server, false)), &mut out)
.expect_err("should refuse a redirected discovery document");
assert!(error.contains("302"), "should name the redirect: {error}");
assert!(out.is_empty(), "should print no keys");
assert_eq!(server.request_count(), 1, "should not follow the redirect");
}
#[test]
fn list_json_rejects_a_page_that_is_not_a_discovery_document() {
let server = FixtureServer::start(|_request| FixtureResponse::html("<html>login</html>"));
let mut out = Vec::new();
run(KeysCommand::List(list_args(&server, true)), &mut out)
.expect_err("should validate the document even with --json");
assert!(out.is_empty(), "should not print the page as keys");
}
#[test]
fn verify_reports_a_rejected_request_as_an_error() {
let server = FixtureServer::start(|_request| {
FixtureResponse::html("payload too large").with_status(413)
});
let mut out = Vec::new();
let error =
run(KeysCommand::Verify(verify_args(&server)), &mut out).expect_err("should fail on a 413");
assert!(error.contains("413"), "should name the status: {error}");
assert!(out.is_empty(), "should print no verification result");
}

Comment on lines +318 to +328
Pass `--json` to print the discovery document exactly as the service returned it. To
check a signature end to end against the new key, use `ts keys verify`, which exits
non-zero when the signature does not verify:

```bash
ts keys verify --service https://edge.example.com \
--kid ts-2024-02-15 --payload '<payload>' --signature '<signature>'
```

Both commands require HTTPS, with plain HTTP allowed only for loopback development
services.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — The guide doesn't say what ts keys verify proves

/verify-signature looks up the JWK stored under the kid (signing.rs:198-203) and never checks active-kids. Deactivating without --delete leaves that JWK in the store (rotation.rs:247-264). Against the Axum adapter with active-kids = ts-2024-02-15 and a JWK still stored for ts-2024-01-15:

$ ts keys list --service http://127.0.0.1:18980
ts-2024-02-15	OKP	Ed25519
$ ts keys verify --service http://127.0.0.1:18980 --kid ts-2024-01-15 --payload example-payload --signature 8MhiV1yY…
verified: signature is valid for kid ts-2024-01-15

A passing verify therefore doesn't mean partners, who fetch the published JWKS, can verify. The PR description's "against a published kid" overstates it. The guide also says only "exits non-zero", while a script can tell a signature that didn't verify (1) from a request that failed (2).

Suggested change
Pass `--json` to print the discovery document exactly as the service returned it. To
check a signature end to end against the new key, use `ts keys verify`, which exits
non-zero when the signature does not verify:
```bash
ts keys verify --service https://edge.example.com \
--kid ts-2024-02-15 --payload '<payload>' --signature '<signature>'
```
Both commands require HTTPS, with plain HTTP allowed only for loopback development
services.
Pass `--json` to print the discovery document exactly as the service returned it. To
check a signature end to end against the new key, use `ts keys verify`:
```bash
ts keys verify --service https://edge.example.com \
--kid ts-2024-02-15 --payload '<payload>' --signature '<signature>'
```
It exits `0` when the service reports that the signature verified, `1` when the service
reports that it did not (an unknown kid reports this way too), and `2` when the check
could not be made, for example because the service was unreachable or answered with an
error status. The service checks the key stored under the kid whether or not that kid is
still active, so a deactivated key that was kept in storage still verifies. Use
`ts keys list` to see which keys partners can fetch.
Both commands require HTTPS, with plain HTTP allowed only for loopback development
services.

Comment on lines +39 to +42
let detail = response
.error
.map(|error| format!(" ({error})"))
.unwrap_or_default();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — "internal verification error" reads like a bad signature

The service folds an unknown kid, a malformed signature, and an unreadable signing store into one sanitized error value (endpoints.rs:143-151, documented under POST /verify-signature in api-reference.md). Against the Axum adapter, a kid that doesn't exist prints:

not verified: Verification error for kid ts-2099-01-01 (internal verification error)

That gives the operator nothing to act on. With the change below the same call prints the following, and still exits 1:

not verified: Verification error for kid ts-2099-01-01 (the service could not check it: the kid may not exist, the signature may not be 64 bytes of base64, or the service could not read its keys)
Suggested change
let detail = response
.error
.map(|error| format!(" ({error})"))
.unwrap_or_default();
// The service folds an unknown kid, a malformed signature, and an unreadable signing
// store into this one sanitized value, so name the possibilities instead of echoing it.
let detail = match response.error.as_deref() {
Some("internal verification error") => " (the service could not check it: the kid \
may not exist, the signature may not be 64 bytes of base64, or the service could \
not read its keys)"
.to_owned(),
Some(error) => format!(" ({error})"),
None => String::new(),
};

writeln!(
rendered,
"{}\t{}\t{}",
key.kid.as_deref().unwrap_or("<no kid>"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — Service-controlled text reaches the terminal unescaped

ts keys list needs no credentials, so pointing it at someone else's service is a natural use, and kid, kty and crv come straight from that service's document. Against the Axum adapter serving a JWK whose kid holds ESC[2K ESC[1G, a tab, and a fake column (shown with cat -v):

ts-2024-02-15	OKP	Ed25519
^[[2K^[[1Gts-2024-09-09	forged	OKP	Ed25519

On a terminal the escape sequence erases the line, and the tab adds a column for anything that splits the output on tabs. The CLI already escapes page-controlled text with escape_terminal_text (ad_templates/output.rs:17-41, used in commands/audit/page.rs:66-69). Apply by hand, since the import, the call sites and the test are separate ranges:

diff --git a/crates/trusted-server-cli/src/commands/keys/list.rs b/crates/trusted-server-cli/src/commands/keys/list.rs
--- a/crates/trusted-server-cli/src/commands/keys/list.rs
+++ b/crates/trusted-server-cli/src/commands/keys/list.rs
@@ -4,6 +4,7 @@ use core::fmt::Write as _;
 
 use serde::Deserialize;
 
+use crate::ad_templates::output::escape_terminal_text;
 use crate::commands::keys::{KeysRequest, ListArgs, send};
 use crate::error::{CliResult, cli_error};
 
@@ -39,9 +40,9 @@ fn render_keys(body: &str) -> CliResult<String> {
         writeln!(
             rendered,
             "{}\t{}\t{}",
-            key.kid.as_deref().unwrap_or("<no kid>"),
-            key.kty.as_deref().unwrap_or("-"),
-            key.crv.as_deref().unwrap_or("-"),
+            escape_terminal_text(key.kid.as_deref().unwrap_or("<no kid>")),
+            escape_terminal_text(key.kty.as_deref().unwrap_or("-")),
+            escape_terminal_text(key.crv.as_deref().unwrap_or("-")),
         )
         .expect("should write to a String");
     }
@@ -116,6 +117,24 @@ mod tests {
         );
     }
 
+    #[test]
+    fn service_controlled_fields_cannot_forge_lines_or_columns() {
+        let body = json!({
+            "version": "1.0",
+            "jwks": { "keys": [
+                { "kty": "OKP", "crv": "Ed25519", "kid": "\u{1b}[2Kts-2024-09-09\tforged" },
+            ]}
+        })
+        .to_string();
+
+        let rendered = render_keys(&body).expect("should render a valid document");
+
+        assert_eq!(
+            rendered, "\\u{001B}[2Kts-2024-09-09\\u{0009}forged\tOKP\tEd25519\n",
+            "should escape control characters, tabs included, in service-controlled fields"
+        );
+    }
+
     #[test]
     fn a_body_that_is_not_a_discovery_document_is_an_error() {
         assert!(render_keys("<html></html>").is_err(), "should reject HTML");

interpret in verify.rs prints the service's kid, message and error the same way, so it should get the same wrapping.

pub(crate) fn service_endpoint(service: &str, path: &str) -> CliResult<String> {
let mut url =
reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?;
require_credential_safe_transport(&url, "--service")?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ nitpick — The HTTPS error claims to protect credentials that ts keys never sends

$ ts keys list --service http://edge.example.com
[ts] --service requires HTTPS to protect the credentials sent with the request; HTTP is allowed only for loopback development services

keys/mod.rs:3-4 says neither subcommand sends credentials. HTTPS still matters here, since it keeps a forged JWKS or a forged "verified": true from being injected in transit, so only the reason is wrong. The wording lives in the shared require_credential_safe_transport (url_guard.rs:29-34, outside this diff), so apply by hand:

diff --git a/crates/trusted-server-cli/src/url_guard.rs b/crates/trusted-server-cli/src/url_guard.rs
--- a/crates/trusted-server-cli/src/url_guard.rs
+++ b/crates/trusted-server-cli/src/url_guard.rs
@@ -1,4 +1,5 @@
-//! Transport checks shared by the commands that send an operator's credentials to a URL.
+//! Transport checks shared by the commands that send an operator's credentials to a URL
+//! or report what a service answered.
 //!
 //! One implementation rather than one per command: a second copy is a second place for the
 //! loopback exemption to drift, and every command that gets this wrong sends a session
@@ -28,8 +29,8 @@ fn is_loopback(url: &reqwest::Url) -> bool {
 pub(crate) fn require_credential_safe_transport(url: &reqwest::Url, flag: &str) -> CliResult<()> {
     if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback(url)) {
         return cli_error(format!(
-            "{flag} requires HTTPS to protect the credentials sent with the request; HTTP is \
-             allowed only for loopback development services"
+            "{flag} requires HTTPS so the request and the service's answer cannot be read or \
+             altered in transit; HTTP is allowed only for loopback development services"
         ));
     }
     if !url.username().is_empty() || url.password().is_some() {

The new wording stays accurate for ts origin probe-shareability --url and ts cache purge, and the existing tests only match HTTPS.

Comment on lines +80 to +84
assert_eq!(
rendered.trim_end_matches('\n'),
body,
"should print the service's document byte for byte"
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏ nitpick — This "byte for byte" test trims the newline it says it checks

With trim_end_matches('\n'), the test still passes if the newline push in run_list (list.rs:76-78) is deleted. Asserting the exact output pins it:

Suggested change
assert_eq!(
rendered.trim_end_matches('\n'),
body,
"should print the service's document byte for byte"
);
assert_eq!(
rendered,
format!("{body}\n"),
"should print the service's document byte for byte, plus a final newline"
);

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.

Add ts keys list and ts keys verify commands

3 participants