Compare commits

...

13 commits

7 changed files with 585 additions and 5 deletions

View file

@ -18,5 +18,7 @@
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
}
},
"rust-analyzer.cargo.features": "all",
"rust-analyzer.check.features": "all"
}

48
Cargo.lock generated
View file

@ -62,6 +62,28 @@ dependencies = [
"syn",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -189,9 +211,9 @@ dependencies = [
"num-traits",
"reqwest",
"serde",
"serde_json",
"sha2",
"tokio",
"tokio-test",
"x509-parser",
]
@ -1236,6 +1258,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-test"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7"
dependencies = [
"async-stream",
"bytes",
"futures-core",
"tokio",
"tokio-stream",
]
[[package]]
name = "tokio-util"
version = "0.7.12"

View file

@ -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,8 @@ 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"] }
tokio-test = "0.4.4"
[features]
default-features = []
api = ["dep:reqwest", "dep:serde"]

173
src/api/endpoints.rs Normal file
View file

@ -0,0 +1,173 @@
//! 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://<log server>/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://<log server>/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://<log server>/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://<log server>/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://<log server>/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");
/// Reference: https://datatracker.ietf.org/doc/html/rfc6962#section-4.4
/// ```txt
/// GET https://<log server>/ct/v1/get-entries
///
/// Inputs:
///
/// start: 0-based index of first entry to retrieve, in decimal.
///
/// end: 0-based index of last entry to retrieve, in decimal.
///
/// Outputs:
///
/// entries: An array of objects, each consisting of
///
/// leaf_input: The base64-encoded MerkleTreeLeaf structure.
///
/// extra_data: The base64-encoded unsigned data pertaining to the
/// log entry. In the case of an X509ChainEntry, this is the
/// "certificate_chain". In the case of a PrecertChainEntry,
/// this is the whole "PrecertChainEntry".
///
/// Note that this message is not signed -- the retrieved data can be
/// verified by constructing the Merkle Tree Hash corresponding to a
/// retrieved STH. All leaves MUST be v1. However, a compliant v1
/// client MUST NOT construe an unrecognized MerkleLeafType or
/// LogEntryType value as an error. This means it may be unable to parse
/// some entries, but note that each client can inspect the entries it
/// does recognize as well as verify the integrity of the data by
/// treating unrecognized leaves as opaque input to the tree.
///
/// The "start" and "end" parameters SHOULD be within the range 0 <= x <
/// "tree_size" as returned by "get-sth" in Section 4.3.
///
/// Logs MAY honor requests where 0 <= "start" < "tree_size" and "end" >=
/// "tree_size" by returning a partial response covering only the valid
/// entries in the specified range. Note that the following restriction
/// may also apply:
///
/// Logs MAY restrict the number of entries that can be retrieved per
/// "get-entries" request. If a client requests more than the permitted
/// number of entries, the log SHALL return the maximum number of entries
/// permissible. These entries SHALL be sequential beginning with the
/// entry specified by "start".
/// ```
pub const GET_ENTRIES: Endpoint = (reqwest::Method::GET, "/ct/v1/get-entries");

285
src/api/mod.rs Normal file
View file

@ -0,0 +1,285 @@
use std::sync::Arc;
use responses::{
AddChainRequest,
AddChainResponse,
GetEntriesResponse,
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<reqwest::Client>,
log_url: String
}
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.
///
/// ## Example
/// ```
/// use ct::api::CtApiClient;
///
/// let client = CtApiClient::new("https://oak.ct.letsencrypt.org/2025h2")
/// .expect("Should construct properly");
///
/// // Use constructed client here
/// ```
pub fn new(log_url: &str) -> reqwest::Result<Self> {
Ok(Self::new_with_client(
log_url,
Arc::new(reqwest::Client::builder().https_only(true).build()?)
))
}
/// 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.
///
/// ## Example
/// ```
/// use std::sync::Arc;
///
/// use ct::api::CtApiClient;
///
/// let existing_client = reqwest::Client::new();
/// let client = CtApiClient::new_with_client(
/// "https://oak.ct.letsencrypt.org/2025h2",
/// Arc::new(existing_client)
/// );
///
/// // Use constructed client here
/// ```
pub fn new_with_client(log_url: &str, inner_client: Arc<reqwest::Client>) -> Self {
Self {
inner_client,
log_url: log_url.to_owned()
}
}
/// 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<String>
) -> reqwest::Result<AddChainResponse> {
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<String>
) -> reqwest::Result<AddChainResponse> {
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<GetSthResponse> {
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<GetSthConsistencyResponse> {
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<GetProofByHashResponse> {
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
}
/// Fetches the CT log entries within the range `[start, end]` where both
/// parameters are 0-indexed. The response contains both the MerkleTreeLeaf
/// structure and full certificate chain of each entry. Logs may or may not
/// properly honor requests in which the start parameter is less than 0 or
/// the end parameter is greater than the current tree size, to try to
/// ensure these bounds are correct. In addition, there is no guarantee that
/// the resulting response will have the list of entries exactly the size
/// requested, as CT logs can enforce limits on how much data is returned at
/// once.
///
/// See: [`endpoints::GET_ENTRIES`]
///
/// ## 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 it doesn't allow invalid bounds and
/// the start and end parameters were specified incorrectly.
///
/// ## Example
/// ```
/// use ct::api::CtApiClient;
///
/// let client = CtApiClient::new("https://oak.ct.letsencrypt.org/2025h2")
/// .expect("Should construct properly");
///
/// tokio_test::block_on(async move {
/// let result = client
/// .get_log_entries(0, 9)
/// .await
/// .expect("Request should succeed");
/// assert_eq!(result.entries.len(), 10, "Log should return 10 entries");
/// })
/// ```
pub async fn get_log_entries(
&self,
start: u64,
end: u64
) -> reqwest::Result<GetEntriesResponse> {
self.inner_client
.request(
endpoints::GET_ENTRIES.0,
self.log_url.to_string() + endpoints::GET_ENTRIES.1
)
.query(&[("start", start), ("end", end)])
.send()
.await?
.error_for_status()?
.json()
.await
}
}

68
src/api/responses.rs Normal file
View file

@ -0,0 +1,68 @@
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<String>
}
/// 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<String>
}
/// 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<String>
}
/// A response given when fetching CT log entries within a range
///
/// See: [`super::endpoints::GET_ENTRIES`]
#[derive(Debug, Deserialize)]
pub struct GetEntriesResponse {
pub entries: Vec<GetEntriesResponseEntry>
}
/// A specific entry in a [`GetEntriesResponse`]
#[derive(Debug, Deserialize)]
pub struct GetEntriesResponseEntry {
pub leaf_input: String,
pub extra_data: String
}

View file

@ -10,5 +10,7 @@
//!
//! [RFC6926]: https://datatracker.ietf.org/doc/html/rfc6962
#[cfg(feature = "api")]
pub mod api;
pub mod merkle;
pub mod parsing;