feat: add optional native-tls support as alternative to rustls (#8037)

Signed-off-by: Rodolfo Olivieri <rolivier@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Rodolfo Olivieri
2026-03-25 17:46:50 -03:00
committed by GitHub
parent 3a9805e255
commit caef9f6466
17 changed files with 786 additions and 100 deletions
+4 -2
View File
@@ -16,15 +16,17 @@ name = "generate-acp-schema"
path = "src/bin/generate_acp_schema.rs"
[features]
default = ["code-mode"]
default = ["code-mode", "rustls-tls"]
code-mode = ["goose/code-mode"]
rustls-tls = ["goose/rustls-tls", "goose-mcp/rustls-tls"]
native-tls = ["goose/native-tls", "goose-mcp/native-tls"]
[lints]
workspace = true
[dependencies]
goose = { path = "../goose", default-features = false }
goose-mcp = { path = "../goose-mcp" }
goose-mcp = { path = "../goose-mcp", default-features = false }
rmcp = { workspace = true }
sacp = { workspace = true, features = ["unstable"] }
agent-client-protocol-schema = { workspace = true }
+15 -3
View File
@@ -49,7 +49,7 @@ async-trait = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
tar = "0.4.45"
reqwest = { workspace = true, features = ["blocking", "rustls"], default-features = false }
reqwest = { workspace = true, features = ["blocking"], default-features = false }
zip = { workspace = true }
bzip2 = "0.5"
webbrowser = { workspace = true }
@@ -62,19 +62,31 @@ urlencoding = { workspace = true }
clap_complete = "4.5.62"
comfy-table = "7.2.2"
sha2 = { workspace = true }
sigstore-verify = { version = "0.6", default-features = false, features = ["rustls"] }
sigstore-verify = { version = "0.6", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }
[features]
default = ["code-mode", "local-inference", "aws-providers"]
default = ["code-mode", "local-inference", "aws-providers", "rustls-tls"]
code-mode = ["goose/code-mode", "goose-acp/code-mode"]
local-inference = ["goose/local-inference"]
aws-providers = ["goose/aws-providers"]
cuda = ["goose/cuda", "local-inference"]
# disables the update command
disable-update = []
rustls-tls = [
"reqwest/rustls",
"sigstore-verify/rustls",
"goose/rustls-tls",
"goose-mcp/rustls-tls",
]
native-tls = [
"reqwest/native-tls",
"sigstore-verify/native-tls",
"goose/native-tls",
"goose-mcp/native-tls",
]
[dev-dependencies]
tempfile = { workspace = true }
+6
View File
@@ -1,3 +1,9 @@
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled");
#[cfg(all(feature = "rustls-tls", feature = "native-tls"))]
compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive");
pub mod cli;
pub mod commands;
pub mod logging;
+5 -1
View File
@@ -10,6 +10,10 @@ description.workspace = true
[lints]
workspace = true
[features]
rustls-tls = ["reqwest/rustls"]
native-tls = ["reqwest/native-tls"]
[dependencies]
rmcp = { workspace = true, features = ["server", "client", "transport-io", "macros"] }
anyhow = { workspace = true }
@@ -23,7 +27,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
indoc = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls", "system-proxy"], default-features = false }
reqwest = { workspace = true, features = ["json", "system-proxy"], default-features = false }
chrono = { workspace = true }
etcetera = { workspace = true }
tempfile = { workspace = true }
+24 -6
View File
@@ -11,11 +11,28 @@ description.workspace = true
workspace = true
[features]
default = ["code-mode", "local-inference", "aws-providers"]
default = ["code-mode", "local-inference", "aws-providers", "rustls-tls"]
code-mode = ["goose/code-mode"]
local-inference = ["goose/local-inference"]
aws-providers = ["goose/aws-providers"]
cuda = ["goose/cuda", "local-inference"]
rustls-tls = [
"reqwest/rustls",
"tokio-tungstenite/rustls-tls-native-roots",
"axum-server/tls-rustls",
"dep:rustls",
"dep:aws-lc-rs",
"goose/rustls-tls",
"goose-mcp/rustls-tls",
]
native-tls = [
"reqwest/native-tls",
"tokio-tungstenite/native-tls",
"axum-server/tls-openssl",
"dep:openssl",
"goose/native-tls",
"goose-mcp/native-tls",
]
[dependencies]
goose = { path = "../goose", default-features = false }
@@ -41,21 +58,22 @@ thiserror = { workspace = true }
clap = { workspace = true }
serde_yaml = { workspace = true }
utoipa = { workspace = true, features = ["axum_extras", "chrono"] }
reqwest = { workspace = true, features = ["json", "rustls", "blocking", "multipart", "system-proxy"], default-features = false }
reqwest = { workspace = true, features = ["json", "blocking", "multipart", "system-proxy"], default-features = false }
tokio-util = { workspace = true }
serde_path_to_error = "0.1.20"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
tokio-tungstenite = { version = "0.28.0" }
url = { workspace = true }
rand = { workspace = true }
hex = "0.4.3"
subtle = "2.6"
socket2 = "0.6.1"
fs2 = { workspace = true }
rustls = { version = "0.23", features = ["aws_lc_rs"] }
rustls = { version = "0.23", features = ["aws_lc_rs"], optional = true }
uuid = { workspace = true }
rcgen = "0.13"
axum-server = { version = "0.8.0", features = ["tls-rustls"] }
aws-lc-rs = "1.16.0"
axum-server = { version = "0.8.0" }
aws-lc-rs = { version = "1.16.0", optional = true }
openssl = { version = "0.10", optional = true }
[target.'cfg(windows)'.dependencies]
winreg = { version = "0.55.0" }
+32 -12
View File
@@ -4,6 +4,7 @@ use anyhow::Result;
use axum::middleware;
use axum_server::Handle;
use goose_server::auth::check_token;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
use goose_server::tls::self_signed_config;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
@@ -31,6 +32,7 @@ pub async fn run() -> Result<()> {
// gateways, etc.) try to open TLS connections. Both `ring` and `aws-lc-rs`
// features are enabled on rustls (via different transitive deps), so rustls
// cannot auto-detect a provider — we must pick one explicitly.
#[cfg(feature = "rustls-tls")]
let _ = rustls::crypto::ring::default_provider().install_default();
crate::logging::setup_logging(Some("goosed"))?;
@@ -74,21 +76,39 @@ pub async fn run() -> Result<()> {
});
if settings.tls {
let tls_setup = self_signed_config().await?;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
{
let tls_setup = self_signed_config().await?;
let handle = Handle::new();
let shutdown_handle = handle.clone();
tokio::spawn(async move {
shutdown_signal().await;
shutdown_handle.graceful_shutdown(None);
});
let handle = Handle::new();
let shutdown_handle = handle.clone();
tokio::spawn(async move {
shutdown_signal().await;
shutdown_handle.graceful_shutdown(None);
});
info!("listening on https://{}", addr);
info!("listening on https://{}", addr);
axum_server::bind_rustls(addr, tls_setup.config)
.handle(handle)
.serve(app.into_make_service())
.await?;
#[cfg(feature = "rustls-tls")]
axum_server::bind_rustls(addr, tls_setup.config)
.handle(handle)
.serve(app.into_make_service())
.await?;
#[cfg(feature = "native-tls")]
axum_server::bind_openssl(addr, tls_setup.config)
.handle(handle)
.serve(app.into_make_service())
.await?;
}
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
{
anyhow::bail!(
"TLS was requested but no TLS backend is enabled. \
Enable the `rustls-tls` or `native-tls` feature."
);
}
} else {
let listener = tokio::net::TcpListener::bind(addr).await?;
+7
View File
@@ -1,3 +1,9 @@
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled");
#[cfg(all(feature = "rustls-tls", feature = "native-tls"))]
compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive");
pub mod auth;
pub mod configuration;
pub mod error;
@@ -5,6 +11,7 @@ pub mod openapi;
pub mod routes;
pub mod session_event_bus;
pub mod state;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
pub mod tls;
pub mod tunnel;
+70 -21
View File
@@ -1,22 +1,31 @@
//! TLS configuration for the goose server.
//!
//! Two TLS backends are supported for the HTTPS listener via `axum-server`:
//!
//! - **`rustls-tls`** (enabled by default) uses `axum-server/tls-rustls` with
//! the `aws-lc-rs` crypto provider.
//! - **`native-tls`** uses `axum-server/tls-openssl`, which links against the
//! platform's OpenSSL (or a compatible fork such as LibreSSL / BoringSSL).
//! On Linux this *is* the platform-native TLS stack; on macOS/Windows the
//! `native-tls` crate used by the HTTP *client* delegates to Security.framework
//! / SChannel respectively, but `axum-server` does not offer those backends so
//! the server listener always uses OpenSSL when this feature is active.
use anyhow::Result;
use aws_lc_rs::digest;
use axum_server::tls_rustls::RustlsConfig;
use rcgen::{CertificateParams, DnType, KeyPair, SanType};
#[cfg(feature = "rustls-tls")]
pub type TlsConfig = axum_server::tls_rustls::RustlsConfig;
#[cfg(feature = "native-tls")]
pub type TlsConfig = axum_server::tls_openssl::OpenSSLConfig;
pub struct TlsSetup {
pub config: RustlsConfig,
pub config: TlsConfig,
pub fingerprint: String,
}
/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and
/// return a [`TlsSetup`] containing the rustls config and the SHA-256
/// fingerprint of the generated certificate (colon-separated hex).
///
/// The fingerprint is printed to stdout so the parent process (e.g. Electron)
/// can pin it and reject connections from any other certificate.
pub async fn self_signed_config() -> Result<TlsSetup> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
fn generate_self_signed_cert() -> Result<(rcgen::Certificate, KeyPair)> {
let mut params = CertificateParams::default();
params
.distinguished_name
@@ -28,22 +37,62 @@ pub async fn self_signed_config() -> Result<TlsSetup> {
let key_pair = KeyPair::generate()?;
let cert = params.self_signed(&key_pair)?;
Ok((cert, key_pair))
}
let cert_der = cert.der();
let sha256 = digest::digest(&digest::SHA256, cert_der);
let fingerprint = sha256
.as_ref()
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":");
fn sha256_fingerprint(der: &[u8]) -> String {
#[cfg(feature = "rustls-tls")]
{
let sha256 = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der);
sha256
.as_ref()
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":")
}
#[cfg(feature = "native-tls")]
{
use openssl::hash::MessageDigest;
let digest =
openssl::hash::hash(MessageDigest::sha256(), der).expect("SHA-256 hash failed");
digest
.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(":")
}
}
/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and
/// return a [`TlsSetup`] containing the server config and the SHA-256
/// fingerprint of the generated certificate (colon-separated hex).
///
/// The fingerprint is printed to stdout so the parent process (e.g. Electron)
/// can pin it and reject connections from any other certificate.
pub async fn self_signed_config() -> Result<TlsSetup> {
#[cfg(feature = "rustls-tls")]
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (cert, key_pair) = generate_self_signed_cert()?;
let fingerprint = sha256_fingerprint(cert.der());
println!("GOOSED_CERT_FINGERPRINT={fingerprint}");
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
let config = RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()).await?;
#[cfg(feature = "rustls-tls")]
let config = axum_server::tls_rustls::RustlsConfig::from_pem(
cert_pem.into_bytes(),
key_pem.into_bytes(),
)
.await?;
#[cfg(feature = "native-tls")]
let config =
axum_server::tls_openssl::OpenSSLConfig::from_pem(cert_pem.as_bytes(), key_pem.as_bytes())?;
Ok(TlsSetup {
config,
@@ -485,6 +485,7 @@ async fn run_single_connection(
scheme: String,
restart_tx: mpsc::Sender<()>,
) {
#[cfg(feature = "rustls-tls")]
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let worker_url = get_worker_url();
+109
View File
@@ -0,0 +1,109 @@
use goose_server::tls::{self_signed_config, TlsConfig};
#[cfg(not(feature = "native-tls"))]
#[test]
fn default_tls_config_is_rustls() {
fn assert_type<T>(_: &T) {}
let rt = tokio::runtime::Runtime::new().unwrap();
let setup = rt.block_on(self_signed_config()).unwrap();
// Proves TlsConfig resolves to RustlsConfig when native-tls is disabled.
let _: &axum_server::tls_rustls::RustlsConfig = &setup.config;
assert_type::<TlsConfig>(&setup.config);
}
#[cfg(feature = "native-tls")]
#[test]
fn native_tls_config_is_openssl() {
fn assert_type<T>(_: &T) {}
let rt = tokio::runtime::Runtime::new().unwrap();
let setup = rt.block_on(self_signed_config()).unwrap();
// Proves TlsConfig resolves to OpenSSLConfig when native-tls is enabled.
let _: &axum_server::tls_openssl::OpenSSLConfig = &setup.config;
assert_type::<TlsConfig>(&setup.config);
}
#[tokio::test]
async fn self_signed_config_produces_valid_fingerprint() {
let setup = self_signed_config().await.unwrap();
assert!(
!setup.fingerprint.is_empty(),
"fingerprint must not be empty"
);
let parts: Vec<&str> = setup.fingerprint.split(':').collect();
assert_eq!(
parts.len(),
32,
"SHA-256 fingerprint must have 32 hex pairs"
);
for part in &parts {
assert_eq!(
part.len(),
2,
"each fingerprint segment must be 2 hex chars"
);
assert!(
part.chars().all(|c| c.is_ascii_hexdigit()),
"fingerprint segment '{}' must be valid hex",
part
);
}
}
#[tokio::test]
async fn self_signed_config_returns_usable_tls_config() {
use axum::routing::get;
use std::net::SocketAddr;
let setup = self_signed_config().await.unwrap();
let app = axum::Router::new().route("/health", get(|| async { "ok" }));
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
#[cfg(not(feature = "native-tls"))]
let server = axum_server::bind_rustls(addr, setup.config);
#[cfg(feature = "native-tls")]
let server = axum_server::bind_openssl(addr, setup.config);
let handle = axum_server::Handle::new();
let shutdown_handle = handle.clone();
let server_handle = tokio::spawn({
let handle = handle.clone();
async move {
server
.handle(handle)
.serve(app.into_make_service())
.await
.unwrap();
}
});
// Wait for the server to start listening.
let listening_addr = loop {
if let Some(addr) = handle.listening().await {
break addr;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
};
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
let resp = client
.get(format!("https://{}/health", listening_addr))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.text().await.unwrap(), "ok");
shutdown_handle.graceful_shutdown(None);
let _ = server_handle.await;
}
+28 -6
View File
@@ -8,7 +8,7 @@ repository.workspace = true
description.workspace = true
[features]
default = ["code-mode", "local-inference", "aws-providers"]
default = ["code-mode", "local-inference", "aws-providers", "rustls-tls"]
code-mode = ["dep:pctx_code_mode"]
local-inference = [
"dep:candle-core",
@@ -27,6 +27,26 @@ aws-providers = [
"dep:aws-sdk-sagemakerruntime",
]
cuda = ["local-inference", "candle-core/cuda", "candle-nn/cuda", "llama-cpp-2/cuda"]
rustls-tls = [
"reqwest/rustls",
"rmcp/reqwest",
"sqlx/runtime-tokio-rustls",
"jsonwebtoken/aws_lc_rs",
"oauth2/reqwest",
"oauth2/rustls-tls",
]
native-tls = [
"dep:pem",
"dep:pkcs1",
"dep:pkcs8",
"dep:sec1",
"reqwest/native-tls",
"rmcp/reqwest-native-tls",
"sqlx/runtime-tokio-native-tls",
"jsonwebtoken/rust_crypto",
"oauth2/reqwest",
"oauth2/native-tls",
]
[lints]
workspace = true
@@ -35,17 +55,16 @@ workspace = true
lru = { workspace = true }
rmcp = { workspace = true, features = [
"client",
"reqwest",
"transport-child-process",
"transport-streamable-http-client",
"transport-streamable-http-client-reqwest",
] }
oauth2 = "5.0"
oauth2 = { version = "5.0", default-features = false }
anyhow = { workspace = true }
thiserror = { workspace = true }
futures = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true, features = ["rustls", "json", "cookies", "gzip", "brotli", "deflate", "zstd", "charset", "http2", "stream", "blocking", "multipart", "system-proxy"], default-features = false }
reqwest = { workspace = true, features = ["json", "cookies", "gzip", "brotli", "deflate", "zstd", "charset", "http2", "stream", "blocking", "multipart", "system-proxy"], default-features = false }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -94,7 +113,6 @@ tokio-cron-scheduler = "0.14.0"
urlencoding = { workspace = true }
v_htmlescape = "0.15"
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio-rustls",
"sqlite",
"chrono",
"json",
@@ -111,7 +129,7 @@ aws-sdk-bedrockruntime = { version = "=1.120.0", default-features = false, featu
aws-sdk-sagemakerruntime = { version = "1.62.0", default-features = false, features = ["default-https-client", "rt-tokio"], optional = true }
# For GCP Vertex AI provider auth
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
jsonwebtoken = { version = "10.3.0", default-features = false, features = ["use_pem"] }
blake3 = "1.5"
fs2 = { workspace = true }
@@ -160,6 +178,10 @@ llama-cpp-2 = { version = "0.1.137", features = ["sampler"], optional = true }
encoding_rs = "0.8.35"
pastey = "0.2.1"
shell-words = { workspace = true }
pem = { version = "3", optional = true }
pkcs1 = { version = "0.7", default-features = false, features = ["pkcs8"], optional = true }
pkcs8 = { version = "0.10", default-features = false, features = ["alloc"], optional = true }
sec1 = { version = "0.7", default-features = false, features = ["der", "pkcs8"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }
+6
View File
@@ -1,3 +1,9 @@
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled");
#[cfg(all(feature = "rustls-tls", feature = "native-tls"))]
compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive");
pub mod acp;
pub mod action_required_manager;
pub mod agents;
+200 -5
View File
@@ -119,12 +119,21 @@ impl TlsConfig {
let key_pem = read_to_string(&cert_key_pair.key_path)
.map_err(|e| anyhow::anyhow!("Failed to read client private key: {}", e))?;
// Create a combined PEM file with certificate and private key
let combined_pem = format!("{}\n{}", cert_pem, key_pem);
#[cfg(not(feature = "native-tls"))]
let identity = {
let combined_pem = format!("{}\n{}", cert_pem, key_pem);
Identity::from_pem(combined_pem.as_bytes()).map_err(|e| {
anyhow::anyhow!("Failed to create identity from cert and key: {}", e)
})?
};
let identity = Identity::from_pem(combined_pem.as_bytes()).map_err(|e| {
anyhow::anyhow!("Failed to create identity from cert and key: {}", e)
})?;
#[cfg(feature = "native-tls")]
let identity = {
let pkcs8_key_pem = convert_key_to_pkcs8_pem(&key_pem)?;
Identity::from_pkcs8_pem(cert_pem.as_bytes(), pkcs8_key_pem.as_bytes()).map_err(
|e| anyhow::anyhow!("Failed to create identity from cert and key: {}", e),
)?
};
Ok(Some(identity))
} else {
@@ -154,6 +163,71 @@ impl Default for TlsConfig {
}
}
/// Convert a PEM private key from any format (PKCS#1, SEC1, PKCS#8) to PKCS#8 PEM.
///
/// `reqwest::Identity::from_pkcs8_pem` (native-tls) only accepts PKCS#8
/// (`-----BEGIN PRIVATE KEY-----`), but private keys in the wild come in three formats:
///
/// - **PKCS#1** (`-----BEGIN RSA PRIVATE KEY-----`): Legacy RSA-specific format.
/// Generated by default with `openssl genrsa`. Very common in older setups, tutorials,
/// and CA-issued key files.
/// - **SEC1** (`-----BEGIN EC PRIVATE KEY-----`): Legacy EC-specific format.
/// Generated by default with `openssl ecparam -genkey`. Common with EC certificates.
/// - **PKCS#8** (`-----BEGIN PRIVATE KEY-----`): Generic, algorithm-agnostic wrapper.
/// This is the only format native-tls accepts.
///
/// Without this conversion, users with legacy-format keys (Kubernetes secrets, corporate
/// PKI, etc.) would get a cryptic "Failed to create identity" error and need to manually
/// run `openssl pkey -in key.pem -out key-pkcs8.pem`.
///
/// Note: the rustls code path (`Identity::from_pem`) accepts all formats natively,
/// so this conversion is only needed for native-tls.
#[cfg(feature = "native-tls")]
fn convert_key_to_pkcs8_pem(key_pem_str: &str) -> Result<String> {
use pkcs8::der::{Decode, Encode};
let parsed =
pem::parse(key_pem_str).map_err(|e| anyhow::anyhow!("Failed to parse PEM key: {}", e))?;
match parsed.tag() {
"PRIVATE KEY" => Ok(key_pem_str.to_string()),
"RSA PRIVATE KEY" => {
let info = pkcs8::PrivateKeyInfo::new(pkcs1::ALGORITHM_ID, parsed.contents());
let der_bytes = info
.to_der()
.map_err(|e| anyhow::anyhow!("Failed to encode PKCS#8: {}", e))?;
Ok(pem::encode(&pem::Pem::new("PRIVATE KEY", der_bytes)))
}
"EC PRIVATE KEY" => {
let ec_key = sec1::EcPrivateKey::from_der(parsed.contents())
.map_err(|e| anyhow::anyhow!("Failed to parse EC key: {}", e))?;
let curve_oid = ec_key
.parameters
.and_then(|p| p.named_curve())
.ok_or_else(|| {
anyhow::anyhow!(
"EC key missing curve parameters. Convert to PKCS#8: \
openssl pkey -in key.pem -out key-pkcs8.pem"
)
})?;
let algorithm = pkcs8::AlgorithmIdentifierRef {
oid: sec1::ALGORITHM_OID,
parameters: Some((&curve_oid).into()),
};
let info = pkcs8::PrivateKeyInfo::new(algorithm, parsed.contents());
let der_bytes = info
.to_der()
.map_err(|e| anyhow::anyhow!("Failed to encode PKCS#8: {}", e))?;
Ok(pem::encode(&pem::Pem::new("PRIVATE KEY", der_bytes)))
}
tag => Err(anyhow::anyhow!(
"Unsupported key format '{}'. Expected PKCS#8, PKCS#1, or SEC1. \
Convert with: openssl pkey -in key.pem -out key-pkcs8.pem",
tag
)),
}
}
pub struct OAuthConfig {
pub host: String,
pub client_id: String,
@@ -440,6 +514,127 @@ impl fmt::Debug for ApiClient {
}
}
#[cfg(all(test, feature = "native-tls"))]
mod native_tls_tests {
use super::convert_key_to_pkcs8_pem;
const PKCS8_RSA_KEY: &str = "\
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCzmc7SPaB07+gK
p/VETtmayzNASEaaGIEKO5eOrlrKfIMdmSyB38Ia4dCyxb8WKAROeBtaev8AXfKh
5sPRPi42SNF77d171QWKU4WKXRWWpO2UWwzrXc/BaCNr0Ey9S4ej3v0DmdPkW+pY
PZ5lzHUT6wTutGwJsceCN+/lgPmazaZvfnTdLlRChLyPFjwGkGFwmu7YZACdbIqd
CW5oIs+weAK2L9+HZaLi0TWcnJrnONs00GHTm2DwLkl8ulKRn6+XygWZLpY4owE6
/x4v4CrTKStvfJlPiErcR/t13vZ+kZq3XxcgIY5V0HEz+AT57/MvvGomQXyMmQnN
bQrmdbvRAgMBAAECggEABd6knqSmuDeogeOmHYzFqhxSwRqzmwDqw7i5ne9HFpfN
6bNaO+biF0KS0Wrq0Tiu1lkwzKrZJ2wKnF24P+AMPtgvp4EZRF0sjI4o/sIGzPj4
LklxAUa8Pd4PTUxeG7Aqn1GP+5SFEzEpVieyY/GUEHn1e+8v2jvCEViOabXmwndA
tGHScHwMA0qW1smCOLj12mfFJJjJjlj7QveJFSJ94wo8XjdZvzLDonQoAf4vN99f
rf40zgAx8VQk1UmOPYgc7mqN3JerjWX3rG820UWilLnAA35rrVxjzeV9yfAbgpVC
qHPaOaN4tBVTb3z9w94wK/uDjd4Px26Qq0Yipi4xGQKBgQD7RXE6hNlx1hW7pdWw
hrxdH9ofptBzJfQwYnO0cq6KFF6+QQYzJcMAZAKEytDzfjkH1iHURtBFA1ndKBSI
B1G8iR7Jie4szDghJ+zS9QHHhXLRprSqyamRefnLe/614YlaP7i9wq/sHg8KKm5b
g+S49EfuKctAenMx8sDPDG7Y2QKBgQC2+xOZTE5uPPitOyR8eMUF1ye9zeONzyJ2
l2rjEZuIOWd12wsrgGVTRXoecpqFNIxcmK2mnBtrWCJ/A5xFXjQjIZ1huJ/oNm+t
bI/GtlM5BknfEP6m8NVtMx5ubzqDIlsgRpXCWco3ytaKZJtdnJhZAOmBogAKUiPr
NL2afWbfuQKBgA722MJybPzBkjPFsY88xvUI05W0+o8RAJTtGBT0C37/rZDJEJR1
OELKsfe0mHMX3k+gKg2ZVgf2JiDspSRgwzZmOCYqh7u1QdZ/qTP7EWsPgIIJ2pKd
RfL6/6xlqwqr7uEoEFmEwbNfKughFMdweGunaK0/YfJqGHguC9uI0wUpAoGBAKDM
U5TLscC+Y+oXpyCbIMjZIltxqx7bf/Wnao7Q0lUL3Rd8vnwkAOcEjyRiodedLhvR
MAjR9maGtQnZKmLrPfYBfER78komTE2isVZ2svvRwuj8Dky2J1gnK/7wUAMdFedv
H/wC2+nbnl1PvBivnFHas1jv/AkV1erEFjrFKLpBAoGABQ29M3xsVv7++/ivl0JM
lNzUZwWbG4GI9Yx/zBMJpjiPi9Bkbe2Iu7EgLuiaTnktLSzm9hwVEIxd6a6X/wRA
cGr4L/FDExxjHQPzYpeGQ5fA9HlDWZzQ7Ou+nZ/O+OwXuvnmjXTXyB0djVJHOmLU
gOfZqLKYsL5zn6pefvXu0dU=
-----END PRIVATE KEY-----";
const PKCS1_RSA_KEY: &str = "\
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAs5nO0j2gdO/oCqf1RE7ZmsszQEhGmhiBCjuXjq5aynyDHZks
gd/CGuHQssW/FigETngbWnr/AF3yoebD0T4uNkjRe+3de9UFilOFil0VlqTtlFsM
613PwWgja9BMvUuHo979A5nT5FvqWD2eZcx1E+sE7rRsCbHHgjfv5YD5ms2mb350
3S5UQoS8jxY8BpBhcJru2GQAnWyKnQluaCLPsHgCti/fh2Wi4tE1nJya5zjbNNBh
05tg8C5JfLpSkZ+vl8oFmS6WOKMBOv8eL+Aq0ykrb3yZT4hK3Ef7dd72fpGat18X
ICGOVdBxM/gE+e/zL7xqJkF8jJkJzW0K5nW70QIDAQABAoIBAAXepJ6kprg3qIHj
ph2MxaocUsEas5sA6sO4uZ3vRxaXzemzWjvm4hdCktFq6tE4rtZZMMyq2SdsCpxd
uD/gDD7YL6eBGURdLIyOKP7CBsz4+C5JcQFGvD3eD01MXhuwKp9Rj/uUhRMxKVYn
smPxlBB59XvvL9o7whFYjmm15sJ3QLRh0nB8DANKltbJgji49dpnxSSYyY5Y+0L3
iRUifeMKPF43Wb8yw6J0KAH+LzffX63+NM4AMfFUJNVJjj2IHO5qjdyXq41l96xv
NtFFopS5wAN+a61cY83lfcnwG4KVQqhz2jmjeLQVU298/cPeMCv7g43eD8dukKtG
IqYuMRkCgYEA+0VxOoTZcdYVu6XVsIa8XR/aH6bQcyX0MGJztHKuihRevkEGMyXD
AGQChMrQ8345B9Yh1EbQRQNZ3SgUiAdRvIkeyYnuLMw4ISfs0vUBx4Vy0aa0qsmp
kXn5y3v+teGJWj+4vcKv7B4PCipuW4PkuPRH7inLQHpzMfLAzwxu2NkCgYEAtvsT
mUxObjz4rTskfHjFBdcnvc3jjc8idpdq4xGbiDlnddsLK4BlU0V6HnKahTSMXJit
ppwba1gifwOcRV40IyGdYbif6DZvrWyPxrZTOQZJ3xD+pvDVbTMebm86gyJbIEaV
wlnKN8rWimSbXZyYWQDpgaIAClIj6zS9mn1m37kCgYAO9tjCcmz8wZIzxbGPPMb1
CNOVtPqPEQCU7RgU9At+/62QyRCUdThCyrH3tJhzF95PoCoNmVYH9iYg7KUkYMM2
ZjgmKoe7tUHWf6kz+xFrD4CCCdqSnUXy+v+sZasKq+7hKBBZhMGzXyroIRTHcHhr
p2itP2Hyahh4LgvbiNMFKQKBgQCgzFOUy7HAvmPqF6cgmyDI2SJbcase23/1p2qO
0NJVC90XfL58JADnBI8kYqHXnS4b0TAI0fZmhrUJ2Spi6z32AXxEe/JKJkxNorFW
drL70cLo/A5MtidYJyv+8FADHRXnbx/8Atvp255dT7wYr5xR2rNY7/wJFdXqxBY6
xSi6QQKBgAUNvTN8bFb+/vv4r5dCTJTc1GcFmxuBiPWMf8wTCaY4j4vQZG3tiLux
IC7omk55LS0s5vYcFRCMXemul/8EQHBq+C/xQxMcYx0D82KXhkOXwPR5Q1mc0Ozr
vp2fzvjsF7r55o1018gdHY1SRzpi1IDn2aiymLC+c5+qXn717tHV
-----END RSA PRIVATE KEY-----";
const SEC1_EC_KEY: &str = "\
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIQyP9q8dmPTyzxgCAF22fS5w6GJP7lVrkq3gdG216TtoAoGCCqGSM49
AwEHoUQDQgAEAxC2ZMkrbwEImb3S2BD8qA9rquFbozW6d7VrhVk1tOqnIuM+vXkz
ShGoCNbfNS+COlPMRAujyDlATZcLs9p4tA==
-----END EC PRIVATE KEY-----";
#[test]
fn test_pkcs8_key_passthrough() {
let result = convert_key_to_pkcs8_pem(PKCS8_RSA_KEY).unwrap();
assert_eq!(result, PKCS8_RSA_KEY.to_string());
}
#[test]
fn test_pkcs1_rsa_key_conversion() {
let result = convert_key_to_pkcs8_pem(PKCS1_RSA_KEY).unwrap();
assert!(result.contains("-----BEGIN PRIVATE KEY-----"));
assert!(result.contains("-----END PRIVATE KEY-----"));
assert!(!result.contains("RSA PRIVATE KEY"));
// Verify the result is valid PKCS#8 by re-parsing
let re_parsed = pem::parse(&result).unwrap();
assert_eq!(re_parsed.tag(), "PRIVATE KEY");
use pkcs8::der::Decode;
pkcs8::PrivateKeyInfo::from_der(re_parsed.contents()).unwrap();
}
#[test]
fn test_sec1_ec_key_conversion() {
let result = convert_key_to_pkcs8_pem(SEC1_EC_KEY).unwrap();
assert!(result.contains("-----BEGIN PRIVATE KEY-----"));
assert!(result.contains("-----END PRIVATE KEY-----"));
assert!(!result.contains("EC PRIVATE KEY"));
// Verify the result is valid PKCS#8 by re-parsing
let re_parsed = pem::parse(&result).unwrap();
assert_eq!(re_parsed.tag(), "PRIVATE KEY");
use pkcs8::der::Decode;
pkcs8::PrivateKeyInfo::from_der(re_parsed.contents()).unwrap();
}
#[test]
fn test_unsupported_key_format() {
let bad_pem =
"-----BEGIN DSA PRIVATE KEY-----\nMIIBuwIBAAKB\n-----END DSA PRIVATE KEY-----";
let result = convert_key_to_pkcs8_pem(bad_pem);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported key format"),);
}
#[test]
fn test_invalid_pem() {
let result = convert_key_to_pkcs8_pem("not a pem");
assert!(result.is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;