diff --git a/.vscode/settings.json b/.vscode/settings.json index f2ec98e..03f2751 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,7 @@ "**/CVS": true, "**/.DS_Store": true, "**/Thumbs.db": true - } + }, + "rust-analyzer.cargo.features": "all", + "rust-analyzer.check.features": "all" } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f750430..93e90a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,6 @@ dependencies = [ "num-traits", "reqwest", "serde", - "serde_json", "sha2", "tokio", "x509-parser", diff --git a/Cargo.toml b/Cargo.toml index 99f848f..9a9fedf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,8 @@ anyhow = "1.0.90" der-parser = "9.0.0" nom = "7.1.3" num-traits = "0.2.19" -reqwest = { version = "0.12.8", features = ["json"] } -serde = { version = "1.0.210", features = ["derive"] } -serde_json = "1.0.132" +reqwest = { version = "0.12.8", features = ["json"], optional = true } +serde = { version = "1.0.210", features = ["derive"], optional = true } sha2 = "0.10.8" x509-parser = "0.16.0" @@ -19,3 +18,7 @@ base64ct = "1.6.0" reqwest = { version = "0.12.8", features = ["json"] } serde = { version = "1.0.210", features = ["derive"] } tokio = { version = "1.41.0", features = ["rt-multi-thread", "macros"] } + +[features] +default-features = [] +api = ["dep:reqwest", "dep:serde"] diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs new file mode 100644 index 0000000..31895c2 --- /dev/null +++ b/src/api/endpoints.rs @@ -0,0 +1,127 @@ +//! A module containing constants for each API endpoint, specifying the method +//! and path of each. This is primarily used for the [api module](`crate::api`) +//! internals, but could be used externally also. + +type Endpoint<'a> = (reqwest::Method, &'a str); + +/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.1 +/// ```txt +/// POST https:///ct/v1/add-chain +/// +/// Inputs: +/// +/// chain: An array of base64-encoded certificates. The first +/// element is the end-entity certificate; the second chains to the +/// first and so on to the last, which is either the root +/// certificate or a certificate that chains to a known root +/// certificate. +/// +/// Outputs: +/// +/// sct_version: The version of the SignedCertificateTimestamp +/// structure, in decimal. A compliant v1 implementation MUST NOT +/// expect this to be 0 (i.e., v1). +/// +/// id: The log ID, base64 encoded. Since log clients who request an +/// SCT for inclusion in TLS handshakes are not required to verify +/// it, we do not assume they know the ID of the log. +/// +/// timestamp: The SCT timestamp, in decimal. +/// +/// extensions: An opaque type for future expansion. It is likely +/// that not all participants will need to understand data in this +/// field. Logs should set this to the empty string. Clients +/// should decode the base64-encoded data and include it in the +/// SCT. +/// +/// signature: The SCT signature, base64 encoded. +/// +/// If the "sct_version" is not v1, then a v1 client may be unable to +/// verify the signature. It MUST NOT construe this as an error. (Note: +/// Log clients don't need to be able to verify this structure; only TLS +/// clients do. If we were to serve the structure as a binary blob, then +/// we could completely change it without requiring an upgrade to v1 +/// clients.) +/// ``` +pub const ADD_CHAIN: Endpoint = (reqwest::Method::POST, "/ct/v1/add-chain"); + +/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.2 +/// ```txt +/// POST https:///ct/v1/add-pre-chain +/// +/// Inputs: +/// +/// chain: An array of base64-encoded Precertificates. The first +/// element is the end-entity certificate; the second chains to the +/// first and so on to the last, which is either the root +/// certificate or a certificate that chains to a known root +/// certificate. +/// +/// Outputs are the same as in Section 4.1. +/// ``` +pub const ADD_PRE_CHAIN: Endpoint = (reqwest::Method::POST, "/ct/v1/add-pre-chain"); + +/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.3 +/// ```txt +/// GET https:///ct/v1/get-sth +/// +/// No inputs. +/// +/// Outputs: +/// +/// tree_size: The size of the tree, in entries, in decimal. +/// +/// timestamp: The timestamp, in decimal. +/// +/// sha256_root_hash: The Merkle Tree Hash of the tree, in base64. +/// +/// tree_head_signature: A TreeHeadSignature for the above data. +/// ``` +pub const GET_STH: Endpoint = (reqwest::Method::GET, "/ct/v1/get-sth"); + +/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.4 +/// ```txt +/// GET https:///ct/v1/get-sth-consistency +/// +/// Inputs: +/// +/// first: The tree_size of the first tree, in decimal. +/// +/// second: The tree_size of the second tree, in decimal. +/// +/// Both tree sizes must be from existing v1 STHs (Signed Tree Heads). +/// +/// Outputs: +/// +/// consistency: An array of Merkle Tree nodes, base64 encoded. +/// +/// Note that no signature is required on this data, as it is used to +/// verify an STH, which is signed. +/// ``` +pub const GET_STH_CONSISTENCY: Endpoint = + (reqwest::Method::GET, "/ct/v1/get-sth-consistency"); + +/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.4 +/// ```txt +/// GET https:///ct/v1/get-proof-by-hash +/// +/// Inputs: +/// +/// hash: A base64-encoded v1 leaf hash. +/// +/// tree_size: The tree_size of the tree on which to base the proof, +/// in decimal. +/// +/// The "hash" must be calculated as defined in Section 3.4. The +/// "tree_size" must designate an existing v1 STH. +/// +/// Outputs: +/// +/// leaf_index: The 0-based index of the end entity corresponding to +/// the "hash" parameter. +/// +/// audit_path: An array of base64-encoded Merkle Tree nodes proving +/// the inclusion of the chosen certificate. +/// ``` +pub const GET_PROOF_BY_HASH: Endpoint = + (reqwest::Method::GET, "/ct/v1/get-proof-by-hash"); diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..67c0968 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use reqwest::Url; +use responses::{ + AddChainRequest, + AddChainResponse, + GetProofByHashResponse, + GetSthConsistencyResponse, + GetSthResponse +}; + +pub mod endpoints; +pub mod responses; + +/// An API client used for interfacing with a specific CT log. This can be +/// constructed with [`CtApiClient::new`] to automatically create an inner +/// [`reqwest::Client`], or an [`Arc`] of one can be passed manually with +/// [`CtApiClient::new_with_client`] to re-use an existing client between code. +/// Re-using clients if one is already created is recommended, as it allows +/// connection-pools to be re-used and will have less overhead between requests. +pub struct CtApiClient { + inner_client: Arc, + log_url: Url +} + +impl CtApiClient { + /// Creates a new [`CtApiClient`] given a specific log URL. This log URL can + /// contain a subpath if the specific log uses one. Anything besides a + /// scheme, host information (ip/port), and a standard path is not supported + /// and will likely cause requests to fail. + /// + /// ## Errors + /// + /// As this automatically constructs a [`reqwest::Client`], this will error + /// if the client fails to be created for whatever reason, usually due to it + /// being unable to find TLS configuration and root store for the platform. + pub fn new(log_url: Url) -> reqwest::Result { + Ok(Self { + inner_client: Arc::new(reqwest::Client::builder().https_only(true).build()?), + log_url + }) + } + + /// Creates a new [`CtApiClient`] given a specific log URL and + /// [`reqwest::Client`]. This log URL can contain a subpath if the specific + /// log uses one. Anything besides a scheme, host information (ip/port), + /// and a standard path is not supported and will likely cause requests to + /// fail. + pub fn new_with_client(log_url: Url, inner_client: Arc) -> Self { + Self { + inner_client, + log_url + } + } + + /// Adds a standard x509 chain to the CT log. The log will then return + /// information needed to construct a valid SCT entry, including timestamp, + /// CT log signature, sct version, and any log operator extensions. + /// + /// See: [`endpoints::ADD_CHAIN`] + /// + /// ## Errors + /// + /// This may error if either the request failed (due to lack of internet or + /// invalid domain, for example), or if the CT log gave a 4xx/5xx response. + /// Specifically, compliant CT logs will reject chains that do not verify + /// sequentially from the first entry (end-user certificate) to the last + /// entry (a trusted root or an intermediate signed by the a trusted root). + pub async fn add_chain( + &self, + chain: Vec + ) -> reqwest::Result { + self.inner_client + .request( + endpoints::ADD_CHAIN.0, + self.log_url.to_string() + endpoints::ADD_CHAIN.1 + ) + .json(&AddChainRequest { chain }) + .send() + .await? + .error_for_status()? + .json() + .await + } + + /// Adds a precetificate chain to the CT log. This is largely the same as + /// [`CtApiClient::add_chain`], except is used specifically when the chain + /// starts with a precertificate rather than the final end-user certificate. + /// Response data is exactly the same as [`CtApiClient::add_chain`]. + /// + /// See: [`endpoints::ADD_PRE_CHAIN`] + /// + /// ## Errors + /// + /// This may error if either the request failed (due to lack of internet or + /// invalid domain, for example), or if the CT log gave a 4xx/5xx response. + /// Specifically, compliant CT logs will reject chains that do not verify + /// properly. For precertificates this will happen is the first entry is not + /// a precertificate, or if the precertificate is not directly signed by + /// a. The CA certificate signing the real certificate + /// b. A special-purpose Precertificate Signing Certificate which is + /// directly signed by the CA certificate signing the real certificate. + pub async fn add_pre_chain( + &self, + chain: Vec + ) -> reqwest::Result { + self.inner_client + .request( + endpoints::ADD_PRE_CHAIN.0, + self.log_url.to_string() + endpoints::ADD_PRE_CHAIN.1 + ) + .json(&AddChainRequest { chain }) + .send() + .await? + .error_for_status()? + .json() + .await + } + + /// Fetches the Signed Tree Head information for the current CT log tree. + /// The response contains the tree size, timestamp, root hash, and a + /// signature of all of the above from the CT log. This data can be used to + /// verify the integrity of the tree at any point, and verify inclusion of a + /// leaf in the tree. + /// + /// See: [`endpoints::GET_STH`] + /// + /// ## Errors + /// + /// This may error if either the request failed (due to lack of internet or + /// invalid domain, for example). + pub async fn get_signed_tree_head(&self) -> reqwest::Result { + self.inner_client + .request( + endpoints::GET_STH.0, + self.log_url.to_string() + endpoints::GET_STH.1 + ) + .send() + .await? + .error_for_status()? + .json() + .await + } + + /// Fetches the Signed Tree Head consistency proof for a specified start + /// tree_size and end tree_size. This consistency proof is simply a list of + /// each merkle tree node necessary to verify the append-only nature of the + /// log between the specified first and second tree sizes. + /// + /// See: [`endpoints::GET_STH_CONSISTENCY`] + /// + /// ## Errors + /// + /// This may error if either the request failed (due to lack of internet or + /// invalid domain, for example), or if the CT log gave a 4xx/5xx response. + /// The CT log may error the response if your first and second tree sizes + /// are invalid, for example if the second is smaller than the first or if + /// the tree has never contained a size specified in `first` or `second`. + pub async fn get_signed_tree_head_consistency( + &self, + first: u64, + second: u64 + ) -> reqwest::Result { + self.inner_client + .request( + endpoints::GET_STH_CONSISTENCY.0, + self.log_url.to_string() + endpoints::GET_STH_CONSISTENCY.1 + ) + .query(&[("first", first), ("second", second)]) + .send() + .await? + .error_for_status()? + .json() + .await + } + + /// Fetches a single merkle audit proof for a specific leaf node by hash + /// from the CT log. The response both includes the index of the hashed + /// leaf node and the list of Merkle Tree nodes required to verify proof of + /// existence of your specified node in the full tree. + /// + /// See: [`endpoints::GET_PROOF_BY_HASH`] + /// + /// ## Errors + /// + /// This may error if either the request failed (due to lack of internet or + /// invalid domain, for example), or if the CT log gave a 4xx/5xx response. + /// The CT log may error the response if your provided hash was not a hash + /// of a valid node at the tree size specified. + pub async fn get_merkle_audit_proof_by_hash( + &self, + hash: &str, + tree_size: u64 + ) -> reqwest::Result { + self.inner_client + .request( + endpoints::GET_PROOF_BY_HASH.0, + self.log_url.to_string() + endpoints::GET_PROOF_BY_HASH.1 + ) + .query(&[("hash", hash)]) + .query(&[("tree_size", tree_size)]) + .send() + .await? + .error_for_status()? + .json() + .await + } +} diff --git a/src/api/responses.rs b/src/api/responses.rs new file mode 100644 index 0000000..f8d7429 --- /dev/null +++ b/src/api/responses.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +/// A request payload for adding a chain to a CT log +/// +/// See: [`super::endpoints::ADD_CHAIN`] or +/// [`super::endpoints::ADD_PRE_CHAIN`] +#[derive(Debug, Serialize)] +pub struct AddChainRequest { + pub chain: Vec +} + +/// A response given when adding a chain to a CT log +/// +/// See: [`super::endpoints::ADD_CHAIN`] or +/// [`super::endpoints::ADD_PRE_CHAIN`] +#[derive(Debug, Deserialize)] +pub struct AddChainResponse { + pub sct_version: u8, + pub log_id: String, + pub timestamp: u64, + pub extensions: String, + pub signature: String +} + +/// A response given when fetching the Signed Tree Head of a CT log +/// +/// See: [`super::endpoints::GET_STH`] +#[derive(Debug, Deserialize)] +pub struct GetSthResponse { + pub tree_size: u64, + pub timestamp: u64, + pub sha256_root_hash: String, + pub tree_head_signature: String +} + +/// A response given when fetching the Signed Tree Head consistency proof of a +/// CT log +/// +/// See: [`super::endpoints::GET_STH_CONSISTENCY`] +#[derive(Debug, Deserialize)] +pub struct GetSthConsistencyResponse { + pub consistency: Vec +} + +/// A response given when fetching the Merkle Audit Proof from a CT log merkle +/// leaf. +/// +/// See: [`super::endpoints::GET_PROOF_BY_HASH`] +#[derive(Debug, Deserialize)] +pub struct GetProofByHashResponse { + pub leaf_index: u64, + pub audit_path: Vec +} diff --git a/src/lib.rs b/src/lib.rs index 2723638..bc075fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,5 +10,7 @@ //! //! [RFC6926]: https://datatracker.ietf.org/doc/html/rfc6962 +#[cfg(feature = "api")] +pub mod api; pub mod merkle; pub mod parsing;