Repository navigation
Add ts keys list and ts keys verify commands #1502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dhruv8sh
wants to merge
6
commits into
main
Choose a base branch
from
add-ts-keys-list-verify
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8df9a40
Add ts keys list and ts keys verify commands
dhruv8sh d995804
Accept hyphen-leading signatures and escape service text in ts keys
dhruv8sh d5aa7ab
Point the signing guides at ts keys list and ts keys verify
dhruv8sh 1dcc999
Merge branch 'main' into add-ts-keys-list-verify
dhruv8sh 5d63ba0
Merge branch 'main' into add-ts-keys-list-verify
prk-Jr bf5c370
Escape error bodies and fix verify payload docs
dhruv8sh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| //! Listing the signing keys a service publishes at `/.well-known/trusted-server.json`. | ||
|
|
||
| use core::fmt::Write as _; | ||
|
|
||
| use serde::Deserialize; | ||
|
|
||
| use crate::ad_templates::output::escape_terminal_text; | ||
| use crate::commands::keys::{KeysRequest, ListArgs, send}; | ||
| use crate::error::{CliResult, cli_error}; | ||
|
|
||
| const DISCOVERY_PATH: &str = "/.well-known/trusted-server.json"; | ||
|
|
||
| /// The parts of the discovery document this command reads. | ||
| #[derive(Deserialize)] | ||
| struct Discovery { | ||
| jwks: Jwks, | ||
| } | ||
|
|
||
| #[derive(Deserialize)] | ||
| struct Jwks { | ||
| keys: Vec<Jwk>, | ||
| } | ||
|
|
||
| #[derive(Deserialize)] | ||
| struct Jwk { | ||
| kid: Option<String>, | ||
| kty: Option<String>, | ||
| crv: Option<String>, | ||
| } | ||
|
|
||
| /// Render one line per published key: kid, key type, and curve. | ||
| fn render_keys(body: &str) -> CliResult<String> { | ||
| let discovery: Discovery = serde_json::from_str(body) | ||
| .map_err(|error| format!("invalid discovery document: {error}"))?; | ||
| if discovery.jwks.keys.is_empty() { | ||
| return Ok("no active signing keys are published\n".to_owned()); | ||
| } | ||
| let mut rendered = String::new(); | ||
| for key in &discovery.jwks.keys { | ||
| writeln!( | ||
| rendered, | ||
| "{}\t{}\t{}", | ||
| escape_terminal_text(key.kid.as_deref().unwrap_or("<no kid>")), | ||
| escape_terminal_text(key.kty.as_deref().unwrap_or("-")), | ||
| escape_terminal_text(key.crv.as_deref().unwrap_or("-")), | ||
| ) | ||
| .expect("should write to a String"); | ||
| } | ||
| Ok(rendered) | ||
| } | ||
|
|
||
| /// Execute `ts keys list`. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error when the service URL is unsafe or malformed, the service cannot be | ||
| /// reached, or it answers with a non-success status or a body that is not a discovery | ||
| /// document. | ||
| pub fn run_list(args: &ListArgs, out: &mut impl std::io::Write) -> CliResult<()> { | ||
| let endpoint = crate::url_guard::service_endpoint(&args.service, DISCOVERY_PATH)?; | ||
| let (status, body) = send(&endpoint, &KeysRequest::Get)?; | ||
| if !status.is_success() { | ||
| // Every adapter registers the discovery route, so a 404 means `--service` names | ||
| // something other than a Trusted Server, while unreadable signing stores answer 500. | ||
| let hint = match status.as_u16() { | ||
| 404 => " — check that --service names a Trusted Server deployment", | ||
| 500 => " — the service could not read its signing keys; check its jwks_store", | ||
| _ => "", | ||
| }; | ||
| return cli_error(format!( | ||
| "fetching the discovery document failed: {status}{hint}\n{}", | ||
| body.trim() | ||
| )); | ||
| } | ||
|
|
||
| // Validated even for --json, so a script never mistakes an error page for keys. | ||
| let rendered = render_keys(&body)?; | ||
| let mut output = if args.json { body } else { rendered }; | ||
| if !output.ends_with('\n') { | ||
| output.push('\n'); | ||
| } | ||
| write!(out, "{output}").map_err(|error| format!("failed to write the key list: {error}")) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use serde_json::json; | ||
|
|
||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn each_key_renders_its_kid_type_and_curve() { | ||
| let body = json!({ | ||
| "version": "1.0", | ||
| "jwks": { "keys": [ | ||
| { "kty": "OKP", "crv": "Ed25519", "x": "example", "kid": "ts-2024-01-15" }, | ||
| { "kty": "OKP", "crv": "Ed25519", "x": "example", "kid": "ts-2024-02-15" }, | ||
| ]} | ||
| }) | ||
| .to_string(); | ||
|
|
||
| let rendered = render_keys(&body).expect("should render a valid document"); | ||
|
|
||
| assert_eq!( | ||
| rendered, "ts-2024-01-15\tOKP\tEd25519\nts-2024-02-15\tOKP\tEd25519\n", | ||
| "should print one line per key" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_empty_key_set_says_so() { | ||
| let body = json!({ "version": "1.0", "jwks": { "keys": [] } }).to_string(); | ||
|
|
||
| let rendered = render_keys(&body).expect("should render an empty set"); | ||
|
|
||
| assert!( | ||
| rendered.contains("no active signing keys"), | ||
| "should say no keys are published, got: {rendered}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn service_controlled_fields_cannot_forge_lines_or_columns() { | ||
| let body = json!({ | ||
| "version": "1.0", | ||
| "jwks": { "keys": [ | ||
| { | ||
| "kty": "OKP\u{7}", | ||
| "crv": "Ed25519\u{1b}[1G", | ||
| "kid": "\u{1b}[2Kts-2024-09-09\tforged" | ||
| }, | ||
| ]} | ||
| }) | ||
| .to_string(); | ||
|
|
||
| let rendered = render_keys(&body).expect("should render a valid document"); | ||
|
|
||
| assert_eq!( | ||
| rendered, | ||
| "\\u{001B}[2Kts-2024-09-09\\u{0009}forged\tOKP\\u{0007}\tEd25519\\u{001B}[1G\n", | ||
| "should escape control characters, tabs included, in service-controlled fields" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_body_that_is_not_a_discovery_document_is_an_error() { | ||
| assert!(render_keys("<html></html>").is_err(), "should reject HTML"); | ||
| assert!( | ||
| render_keys(&json!({ "keys": [] }).to_string()).is_err(), | ||
| "should reject a bare JWKS without the discovery wrapper" | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| //! `ts keys` — inspect a deployed service's request-signing keys. | ||
| //! | ||
| //! Both subcommands call routes that need no credentials, so neither asks for nor sends | ||
| //! the admin password. | ||
|
|
||
| pub mod list; | ||
| pub mod verify; | ||
|
|
||
| use std::time::Duration; | ||
|
|
||
| use clap::Subcommand; | ||
|
|
||
| use crate::ad_templates::output::escape_terminal_text; | ||
| use crate::error::CliResult; | ||
| use crate::run::RunOutcome; | ||
|
|
||
| const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); | ||
|
|
||
| /// Subcommands under `ts keys`. | ||
| #[derive(Debug, Subcommand)] | ||
| pub enum KeysCommand { | ||
| /// List the active signing keys a service publishes in its discovery document. | ||
| List(ListArgs), | ||
| /// Ask a service whether a signature over a payload verifies under a key. | ||
| Verify(VerifyArgs), | ||
| } | ||
|
|
||
| /// Arguments for `ts keys list`. | ||
| #[derive(Debug, clap::Args)] | ||
| pub struct ListArgs { | ||
| /// HTTPS base URL of the service; HTTP is permitted only for loopback development. | ||
| #[arg(long)] | ||
| pub service: String, | ||
|
|
||
| /// Print the discovery document exactly as the service returned it. | ||
| #[arg(long)] | ||
| pub json: bool, | ||
| } | ||
|
|
||
| /// Arguments for `ts keys verify`. | ||
| #[derive(Debug, clap::Args)] | ||
| pub struct VerifyArgs { | ||
| /// HTTPS base URL of the service; HTTP is permitted only for loopback development. | ||
| #[arg(long)] | ||
| pub service: String, | ||
|
|
||
| /// Key identifier the signature was made with. | ||
| #[arg(long)] | ||
| pub kid: String, | ||
|
|
||
| /// Canonical payload that was signed. | ||
| #[arg(long, allow_hyphen_values = true)] | ||
| pub payload: String, | ||
|
|
||
| /// Base64-encoded signature to check. | ||
| #[arg(long, allow_hyphen_values = true)] | ||
| pub signature: String, | ||
| } | ||
|
|
||
| /// Run a `ts keys` subcommand. | ||
| /// | ||
| /// `ts keys verify` returns [`RunOutcome::AssertionFailed`] when the signature does not | ||
| /// verify, so the process exits non-zero and a script can rely on the exit code. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error when the service URL is unsafe or malformed, the service cannot be | ||
| /// reached, or it answers with a non-success status or an unexpected body. | ||
| pub fn run(command: KeysCommand, out: &mut impl std::io::Write) -> CliResult<RunOutcome> { | ||
| match command { | ||
| KeysCommand::List(args) => list::run_list(&args, out).map(|()| RunOutcome::Success), | ||
| KeysCommand::Verify(args) => verify::run_verify(&args, out), | ||
| } | ||
| } | ||
|
|
||
| /// An unauthenticated request to send to the service. | ||
| enum KeysRequest<'body> { | ||
| Get, | ||
| PostJson(&'body str), | ||
| } | ||
|
|
||
| /// Send one request without credentials and return the status and body text. | ||
| /// | ||
| /// Redirects are not followed: a service that redirects these routes is misconfigured, | ||
| /// and following one would quietly report on a different host than the operator named. | ||
| fn send(endpoint: &str, request: &KeysRequest<'_>) -> CliResult<(reqwest::StatusCode, String)> { | ||
| let runtime = tokio::runtime::Builder::new_current_thread() | ||
| .enable_all() | ||
| .build() | ||
| .map_err(|error| format!("failed to start the HTTP runtime: {error}"))?; | ||
|
|
||
| // Without this the first HTTPS request panics with "No provider set". | ||
| crate::tls::install_crypto_provider(); | ||
|
|
||
| runtime.block_on(async { | ||
| let client = reqwest::Client::builder() | ||
| .timeout(REQUEST_TIMEOUT) | ||
| .redirect(reqwest::redirect::Policy::none()) | ||
| .build() | ||
| .map_err(|error| format!("failed to build the HTTP client: {error}"))?; | ||
| let builder = match request { | ||
| KeysRequest::Get => client.get(endpoint), | ||
| KeysRequest::PostJson(body) => client | ||
| .post(endpoint) | ||
| .header("content-type", "application/json") | ||
| .body((*body).to_owned()), | ||
| }; | ||
| let response = builder | ||
| .header("accept", "application/json") | ||
| .send() | ||
| .await | ||
| .map_err(|error| format!("could not reach {endpoint}: {error}"))?; | ||
| let status = response.status(); | ||
| let text = response | ||
| .text() | ||
| .await | ||
| .map_err(|error| format!("could not read the response from {endpoint}: {error}"))?; | ||
| // Error bodies are service-controlled and reach stderr, so escape them; | ||
| // successful bodies stay byte for byte for `--json` and parsing. | ||
| let text = if status.is_success() { | ||
| text | ||
| } else { | ||
| escape_terminal_text(text.trim()).into_owned() | ||
| }; | ||
| Ok((status, text)) | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.