feat: support client certificate authentication (related #363)

This commit is contained in:
Kevin Yue
2024-05-19 18:44:07 +08:00
parent 3bb115bd2d
commit 52b6fa6fbd
19 changed files with 374 additions and 22 deletions

View File

@@ -9,6 +9,8 @@ anyhow.workspace = true
base64.workspace = true
log.workspace = true
reqwest.workspace = true
openssl.workspace = true
pem.workspace = true
roxmltree.workspace = true
serde.workspace = true
specta.workspace = true

View File

@@ -156,11 +156,7 @@ fn build_csd_token(cookie: &str) -> anyhow::Result<String> {
}
pub async fn hip_report(gateway: &str, cookie: &str, csd_wrapper: &str, gp_params: &GpParams) -> anyhow::Result<()> {
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let client = Client::try_from(gp_params)?;
let md5 = build_csd_token(cookie)?;
info!("Submit HIP report md5: {}", md5);

View File

@@ -21,10 +21,7 @@ pub async fn gateway_login(gateway: &str, cred: &Credential, gp_params: &GpParam
let gateway = remove_url_scheme(&url);
let login_url = format!("{}/ssl-vpn/login.esp", url);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let client = Client::try_from(gp_params)?;
let mut params = cred.to_params();
let extra_params = gp_params.to_params();

View File

@@ -1,9 +1,13 @@
use std::collections::HashMap;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::GP_USER_AGENT;
use crate::{
utils::request::{create_identity_from_pem, create_identity_from_pkcs12},
GP_USER_AGENT,
};
#[derive(Debug, Serialize, Deserialize, Clone, Type, Default)]
pub enum ClientOs {
@@ -51,6 +55,9 @@ pub struct GpParams {
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
// Used for MFA
input_str: Option<String>,
otp: Option<String>,
@@ -142,6 +149,9 @@ pub struct GpParamsBuilder {
client_version: Option<String>,
computer: String,
ignore_tls_errors: bool,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
}
impl GpParamsBuilder {
@@ -156,6 +166,9 @@ impl GpParamsBuilder {
client_version: Default::default(),
computer,
ignore_tls_errors: false,
certificate: Default::default(),
sslkey: Default::default(),
key_password: Default::default(),
}
}
@@ -194,6 +207,21 @@ impl GpParamsBuilder {
self
}
pub fn certificate<T: Into<Option<String>>>(&mut self, certificate: T) -> &mut Self {
self.certificate = certificate.into();
self
}
pub fn sslkey<T: Into<Option<String>>>(&mut self, sslkey: T) -> &mut Self {
self.sslkey = sslkey.into();
self
}
pub fn key_password<T: Into<Option<String>>>(&mut self, password: T) -> &mut Self {
self.key_password = password.into();
self
}
pub fn build(&self) -> GpParams {
GpParams {
is_gateway: self.is_gateway,
@@ -203,6 +231,9 @@ impl GpParamsBuilder {
client_version: self.client_version.clone(),
computer: self.computer.clone(),
ignore_tls_errors: self.ignore_tls_errors,
certificate: self.certificate.clone(),
sslkey: self.sslkey.clone(),
key_password: self.key_password.clone(),
input_str: Default::default(),
otp: Default::default(),
}
@@ -214,3 +245,26 @@ impl Default for GpParamsBuilder {
Self::new()
}
}
impl TryFrom<&GpParams> for Client {
type Error = anyhow::Error;
fn try_from(value: &GpParams) -> Result<Self, Self::Error> {
let mut builder = Client::builder()
.danger_accept_invalid_certs(value.ignore_tls_errors)
.user_agent(&value.user_agent);
if let Some(cert) = value.certificate.as_deref() {
// .p12 or .pfx file
let identity = if cert.ends_with(".p12") || cert.ends_with(".pfx") {
create_identity_from_pkcs12(cert, value.key_password.as_deref())?
} else {
create_identity_from_pem(cert, value.sslkey.as_deref(), value.key_password.as_deref())?
};
builder = builder.identity(identity);
}
let client = builder.build()?;
Ok(client)
}
}

View File

@@ -88,10 +88,7 @@ pub async fn retrieve_config(portal: &str, cred: &Credential, gp_params: &GpPara
let server = remove_url_scheme(&portal);
let url = format!("{}/global-protect/getconfig.esp", portal);
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(gp_params.user_agent())
.build()?;
let client = Client::try_from(gp_params)?;
let mut params = cred.to_params();
let extra_params = gp_params.to_params();

View File

@@ -114,10 +114,7 @@ pub async fn prelogin(portal: &str, gp_params: &GpParams) -> anyhow::Result<Prel
params.retain(|k, _| REQUIRED_PARAMS.iter().any(|required_param| required_param == k));
let client = Client::builder()
.danger_accept_invalid_certs(gp_params.ignore_tls_errors())
.user_agent(user_agent)
.build()?;
let client = Client::try_from(gp_params)?;
let res = client
.post(&prelogin_url)

View File

@@ -33,6 +33,9 @@ pub struct ConnectArgs {
vpnc_script: Option<String>,
user_agent: Option<String>,
os: Option<ClientOs>,
certificate: Option<String>,
sslkey: Option<String>,
key_password: Option<String>,
csd_uid: u32,
csd_wrapper: Option<String>,
reconnect_timeout: u32,
@@ -47,6 +50,9 @@ impl ConnectArgs {
vpnc_script: None,
user_agent: None,
os: None,
certificate: None,
sslkey: None,
key_password: None,
csd_uid: 0,
csd_wrapper: None,
reconnect_timeout: 300,
@@ -71,6 +77,18 @@ impl ConnectArgs {
self.os.as_ref().map(|os| os.to_openconnect_os().to_string())
}
pub fn certificate(&self) -> Option<String> {
self.certificate.clone()
}
pub fn sslkey(&self) -> Option<String> {
self.sslkey.clone()
}
pub fn key_password(&self) -> Option<String> {
self.key_password.clone()
}
pub fn csd_uid(&self) -> u32 {
self.csd_uid
}
@@ -131,6 +149,21 @@ impl ConnectRequest {
self
}
pub fn with_certificate<T: Into<Option<String>>>(mut self, certificate: T) -> Self {
self.args.certificate = certificate.into();
self
}
pub fn with_sslkey<T: Into<Option<String>>>(mut self, sslkey: T) -> Self {
self.args.sslkey = sslkey.into();
self
}
pub fn with_key_password<T: Into<Option<String>>>(mut self, key_password: T) -> Self {
self.args.key_password = key_password.into();
self
}
pub fn with_reconnect_timeout(mut self, reconnect_timeout: u32) -> Self {
self.args.reconnect_timeout = reconnect_timeout;
self

View File

@@ -8,6 +8,7 @@ pub mod env_file;
pub mod lock_file;
pub mod openssl;
pub mod redact;
pub mod request;
#[cfg(feature = "tauri")]
pub mod window;

View File

@@ -0,0 +1,93 @@
use std::fs;
use anyhow::bail;
use log::warn;
use openssl::pkey::PKey;
use pem::parse_many;
use reqwest::Identity;
#[derive(Debug, thiserror::Error)]
pub enum RequestIdentityError {
#[error("Failed to find the private key")]
NoKey,
#[error("No passphrase provided")]
NoPassphrase(&'static str),
#[error("Failed to decrypt private key")]
DecryptError(&'static str),
}
/// Create an identity object from a certificate and key
pub(crate) fn create_identity_from_pem(
cert: &str,
key: Option<&str>,
passphrase: Option<&str>,
) -> anyhow::Result<Identity> {
let cert_pem = fs::read(cert)?;
// Get the private key pem
let key_pem = match key {
Some(key) => pem::parse(fs::read(key)?)?,
None => {
// If key is not provided, find the private key in the cert pem
parse_many(&cert_pem)?
.into_iter()
.find(|pem| pem.tag().ends_with("PRIVATE KEY"))
.ok_or(RequestIdentityError::NoKey)?
}
};
// The key pem could be encrypted, so we need to decrypt it
let decrypted_key_pem = if key_pem.tag().ends_with("ENCRYPTED PRIVATE KEY") {
let passphrase = passphrase.ok_or_else(|| {
warn!("Key is encrypted but no passphrase provided");
RequestIdentityError::NoPassphrase("PEM")
})?;
let pem_content = pem::encode(&key_pem);
let key = PKey::private_key_from_pem_passphrase(pem_content.as_bytes(), passphrase.as_bytes()).map_err(|err| {
warn!("Failed to decrypt key: {}", err);
RequestIdentityError::DecryptError("PEM")
})?;
key.private_key_to_pem_pkcs8()?
} else {
pem::encode(&key_pem).into()
};
let identity = Identity::from_pkcs8_pem(&cert_pem, &decrypted_key_pem)?;
Ok(identity)
}
pub(crate) fn create_identity_from_pkcs12(pkcs12: &str, passphrase: Option<&str>) -> anyhow::Result<Identity> {
let pkcs12 = fs::read(pkcs12)?;
let Some(passphrase) = passphrase else {
bail!(RequestIdentityError::NoPassphrase("PKCS#12"));
};
let identity = Identity::from_pkcs12_der(&pkcs12, passphrase)?;
Ok(identity)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_identity_from_pem_requires_passphrase() {
let cert = "tests/files/badssl.com-client.pem";
let identity = create_identity_from_pem(cert, None, None);
assert!(identity.is_err());
assert!(identity.unwrap_err().to_string().contains("No passphrase provided"));
}
#[test]
fn create_identity_from_pem_with_passphrase() {
let cert = "tests/files/badssl.com-client.pem";
let passphrase = "badssl.com";
let identity = create_identity_from_pem(cert, None, Some(passphrase));
assert!(identity.is_ok());
}
}

View File

@@ -0,0 +1,64 @@
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
subject=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Certificate
issuer=/C=US/ST=California/L=San Francisco/O=BadSSL/CN=BadSSL Client Root Certificate Authority
-----BEGIN CERTIFICATE-----
MIIEnTCCAoWgAwIBAgIJAPfJjkenM2ooMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNV
BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp
c2NvMQ8wDQYDVQQKDAZCYWRTU0wxMTAvBgNVBAMMKEJhZFNTTCBDbGllbnQgUm9v
dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMjQwNTE3MTc1OTMyWhcNMjYwNTE3
MTc1OTMyWjBvMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQG
A1UEBwwNU2FuIEZyYW5jaXNjbzEPMA0GA1UECgwGQmFkU1NMMSIwIAYDVQQDDBlC
YWRTU0wgQ2xpZW50IENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPoen08
utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPca8MR
WAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7AaCi
DeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct4MG8
w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrjQ7i/
s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABoy0wKzAJBgNVHRMEAjAAMBEG
CWCGSAGG+EIBAQQEAwIHgDALBgNVHQ8EBAMCBeAwDQYJKoZIhvcNAQELBQADggIB
AE6iDW5Lv5I0bJY6TGxJUoB4rcsbbtEP4O4MT14GP7j7I48V09VBG9yjskYze0Ls
Xb9mQpEpPyQLTDJIWu/ic/y5SMnelCjUxmfl37cfNLJajQZxc4FDEUSemrPKpEkB
UzHNkxw9LSzqsyxnQmMIGoN+ZNCFoV7s5pekzPfgZj5+s7a+oiF/AzhOWZzF7vaM
aclX7KCeENQV+q0giDjsGIHI6BevUHYkglocEqff+rIDHjjLxHLPooflV50M+ifc
4uJdHgG8hwKxd1uf3LImUsquiBrW5CO6KCgwLrtQNe11pQHpY0urZxK/tnAj7QtD
v/O1ryd/3+b0Gx14TyulMtcaLHsE94ppwjcxpYGNcyH+M39OMihuR2aqmkrqcZd/
VWop1cNwZgPtCNVvfivRpX52NLI5I0eMfs6jeTMr719hdAby3akoiNLN3YNKrdrp
pyRz/sUFGO8AHHECXA15KTeMBNfZnO32ZAZ4jHyyDBO1A5f9iDbErhXfIpeRCrCO
gM9MLuO4YEMG1Skp+qaw7SIaG+oi2t4lbVRr3LOv0Hfkjjb7bVjfWSwLBPH/gv0E
ZL6G0p7PjeoCh4obS3Y1yxfNlPR6RQwWl1wve+Nkmf5sDCmgr3P0512ZuvqkbKkB
/syiAWDsYzFuq2Ntv2ljTYPEPwXEIQcpsagDRL6WzoLR
-----END CERTIFICATE-----
Bag Attributes
localKeyID: AE DC 75 2E 97 28 71 D8 1E 9A 7F 1E 5A AA F4 2E D3 6D 2C 8B
Key Attributes: <No Attributes>
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIET6L0Ht/lYgCAggA
MBQGCCqGSIb3DQMHBAi1Xo+JdQ6XvwSCBMgX20Fk3/GzptJ0zjl7ZqX2G3J4LIkM
E5qJ4yv2WUkCCOWqz5DjlSrRz4kdCYHqnM1/qyrLa1UWWJlNQ9lBHTE+yp0vtAC/
ajQfKt3RFyGxblp6nEKJI7kvhmQHDbITilmVEpcZPbci3gi7asQI3bRSLaHwGtbH
DY+8hJ8lZQMRjYGDyGb99qEdYnMMRMW+b44lIRASe6W3EUfrvJlp+OUqRA7hJzn2
yha9Zo8KWo9fA9UZDFFKNlXakg76+1HymB+uqvZl14xHHfwhlKPaqzmCb8MUtt7e
YJDB9I3y8aHKExXPbRk04bbY5G9o6WdWslDUY4axOZuhUXyn0h6cTZn//qmsjcBH
499+j55vW6W7vkMfurt/pmLIBWC9kDWPZVLizbfXxWiWvRmQvKPfzO5TU8oObYyJ
qUVjb3Vpa/WPrF5APUVd/DDofurgzdOkmDGomONPvSHxahHSyEZsxpnl52GD6uU/
i3oa5qLE9uA1QjyX6wyN9SU5wE2FZKTJwwRJwW4+s4T/2eJjhuJez5q1xhSCes4A
A2pufAAY/ctQSmCCKCTW+EkrXtcezx66fkgPpNK/m6bz5KGJkA4QXjl8A05PDAFE
Z68VOX/T0IGfXc2BbPgP0u+WpCvvO2cW/pU4sjcwOMxFuT1Bn3TwmDLTZ+zba1rE
zFRMMCz/8SKq3I+VkzQ66ureEz0RLwk07JVzE9AJUEm+zCFUdoIaz09OMGVqtf4a
V+UgupH0QlffmRNJKQtXPuj6Wjfa43GLaCnN/cpXXq8+2o81dLTsCbEsYu+8DRjC
B0iyjzdqgjBBYurIEwEc4iGtPt4Y+4rgAJcpEUgwvWii37xyutOC9V7ansvd6zg3
WXiX5Ktj/qS0EzM33WtZfx7jygJIf1MvxrJU+D+HgGii1mHaZ6bHxMX3QGpRsEvh
IzBx16XvoHcXARZJG91bC+K1sJ6e05L1PevS7gj4heJTEhtmvABUrn9O1n5fZWPj
Q81zRDgplMO7r8aBW/pE+sj4VSTMg0Xu0nlqqvQoWxr9YFcJm0+I9fHQPxewnRus
sBZoiTqnWqbTr+uRATRUAp+hU03S4jGZwbzH4ylL2hr/TshGVJk/olBsULAfIiHa
dA5H258IEwAoFO6zgI9AvqmTFo3Mnpqb/AS/HuDmmS/3Ud1EF8hFsMLPcV0JdSTY
Dl4xgZ6j6jOUlTN5Yt6To2Zg3Q9Bm6qytFaffEP66Jl5aWhksI31Fz/ihzn5wfx9
xh91U8+kGVNrpYHlo5y3FR/ywSXynLkJffCbfUciEaTDv9i0JppoIVXyFqcMofHe
GUsWTCozAW3O8MwpLaJxcNcfRq0DWziIdiDgbF2tPoCqnNxXtLYSPpdt3jNDcPcx
U0Z6ep6FnAXiujtQRSRSP3Ssq23098BxDSM9+eashFOmSbSClAEEn/THRxTp/gMh
zmD8kpX1zN1Cm/lerTGjrGjnkXcQ7LY76/+C1uT+tQbw5LjmCfFEYTFtnFyYFlF1
GiXFokh9SdLaCzW4vmZok85Fe+7VZ7BAchBTfTIMKlXKmeouf3YVYJ8glPsinrjb
cB2pKv3tVrdQwo3moYDwSsDgkd7BNKKHDVdY2O6NgX4/Fyd6pZt7ZAphyC1giEqg
pPo=
-----END ENCRYPTED PRIVATE KEY-----