基于证书的双向认证(mTLS)技术方案

X.509数字证书

X.509是一种数字证书,它使用X.509公钥基础设施标准来验证属于用户、服务或服务器的公钥是否包含在证书中,以及所述用户、服务或服务器的身份。

证书可以由受信任的证书颁发机构签名,也可以是自签名的。

SSL和TLS是最广为人知的协议,它们使用X.509格式。当每次打开浏览器并通过HTTPS访问网页时,它们通常被用来验证服务器的身份。

认证流程

为了保护和验证客户机和服务器之间的通信,它们都需要有有效的证书。当向HTTPS服务发送请求时,客户端将验证该服务是由可信的权威机构认证或基于可信根证书的。在这种情况下,不仅要验证服务器的身份,而且服务器也要验证客户机。

单向认证流程

tls_ssl_1

双向认证(mTLS)流程

mtls_ssl_2

主流数字证书的格式

主流Web服务软件

一般来说,主流的Web服务软件,通常都基于OpenSSL和Java两种基础密码库。

  • Tomcat、Weblogic、JBoss等Web服务软件,一般使用Java提供的密码库。通过Java Development Kit (JDK)工具包中的Keytool工具,生成Java Keystore(JKS)格式的证书文件。
  • Apache、Nginx等Web服务软件,一般使用OpenSSL工具提供的密码库,生成PEM、KEY、CRT等格式的证书文件。
  • IBM的Web服务产品,如Websphere、IBM Http Server(IHS)等,一般使用IBM产品自带的iKeyman工具,生成KDB格式的证书文件。
  • 微软Windows Server中的Internet Information Services(IIS)服务,使用Windows自带的证书库生成PFX格式的证书文件。

证书格式转换

以下证书格式之间是可以互相转换的。

cert_trans_zh-CN

可以使用JDK中自带的Keytool工具,将JKS格式证书文件转换成PFX格式。也可以将PFX格式证书文件转换成JKS格式。

可以使用OpenSSL工具,将KEY格式密钥文件和CRT格式公钥文件转换成PFX格式证书文件。也可以将PFX格式证书文件转化为KEY格式密钥文件和CRT格式公钥文件。

详细转换命令将在后面介绍。

证书签发

为了与受保护的服务通信,客户机必须做的第一件事是生成私钥和证书签名请求(CSR)。然后将此CSR发送给要签名的证书颁发机构(CA)。在用例中,由服务提供方代表服务器和CA,因为由服务提供方负责管理与服务对话的客户端。对CSR进行签名将生成客户端证书,然后将该证书发送回客户端。

client-crt

过程中会涉及以下一些密钥或证书文件:

  • 根证书:root.crt,在Java体系中叫truststore
  • 根证书密钥:root.key
  • 服务器端公钥证书:server.crt
  • 服务器端私钥文件:server.key
  • 客户端公钥证书:client.crt
  • 客户端私钥文件:client.key
  • 客户端集成证书(包括公钥和私钥,用于浏览器访问场景):client.p12

过程中会涉及csr文件,全称为:Certificate Signing Request,证书请求文件的缩写。,也就是证书申请者在申请数字证书时由CSP(加密服务提供者)在生成私钥的同时也生成证书请求文件,证书申请者只要把CSR文件提交给证书颁发机构后,证书颁发机构使用其根证书私钥签名就生成了证书公钥文件,也就是颁发给用户的证书。

如果是Java程序,需要使用keytool将证书转为JKS格式,JDK11开始建议使用标准PKS#12。

下面是证书生成的内在逻辑示意图:

gen-cert

服务提供商生成自签名根证书

(1)创建根证书私钥:
openssl genrsa -aes256 -out root.key 2048

(2)创建根证书请求文件:
openssl req -new -key root.key -out root.csr

后续参数请自行填写,下面是一个例子:
Country Name (2 letter code) [XX]:CN
State or Province Name (full name) []:Sichuan
Locality Name (eg, city) [Default City]:Chengdu
Organization Name (eg, company) [Default Company Ltd]:Bayconnect
Organizational Unit Name (eg, section) []:ITP
Common Name (eg, your name or your servers hostname) []:root
Email Address []:qiupc@bayconnect.com.cn
A challenge password []:
An optional company name []:

> 可以通过`-utf8 -subj '/C=XX/ST=XX/L=XX/O=XXXX/OU=XX/CN=XX/emailAddress=XX'`的格式直接设置证书的信息

(3)创建根证书:
openssl x509 -req -in root.csr -signkey root.key -CAcreateserial  -out root.crt -days 1024 -sha256

服务提供商生成自签名服务器端证书

(1)生成服务器端证书私钥:
openssl genrsa -aes256 -out server.key 2048

(2) 生成服务器证书请求文件,过程和注意事项参考根证书,本节不详述:
openssl req -new -out server.csr -key server.key

(3) 生成服务器端公钥证书
openssl x509 -req -in server.csr -CA root.crt -CAkey root.key -CAcreateserial -out server.crt -days 1024 -sha256

客户端生成证书请求文件

(1)生成客户端证书秘钥:
openssl genrsa -out client.key 2048

(2) 生成客户端证书请求文件,过程和注意事项参考根证书,本节不详述:
openssl req -new -out client.csr -key client.key

亦可使用jkstool生成证书请求文件

keytool -genkey -alias [$Alias] -keyalg RSA -keysize 2048 -keystore [$Keytool_Path]

keytool -certreq -sigalg SHA256withRSA -alias [$Alias] -keystore [$Keytool_Path] -file [$Keytool_CSR]

服务提供商颁发客户端证书

openssl x509 -req -in client.csr -out client.crt -CA root.crt -CAkey root.key -CAcreateserial -days 1024

openssl ca -in client.csr -out client.crt -cert root.crt -keyfile root.key -days 3650

证书格式转换

根证书转Java truststore JKS

keytool -import -file root.crt -alias rootCA -keystore truststore.jks

服务端证书转PKCS#12

openssl pkcs12 -export -in server.crt -inkey server.key -certfile server.crt -out keystore.p12

证书转PKCS#12

openssl pkcs12 -export -in server.crt -inkey server.key -out keystore.p12

openssl pkcs12 -export -clcerts -in client.crt -inkey client.key -out client.p12

PKCS#12转Java JKS

keytool -importkeystore -srckeystore keystore.p12 -srcstoretype pkcs12 -destkeystore keystore.jks -deststoretype JKS

注意:尽管keytool要求单独输入一个新的JKS的存储密码,但如果输入的JKS存储密码如果与PKCS#12不同,在Java中使用JKS时,出无法读取或校验失败,所以,JKS的存储密码需要与PKCS#12的存储密码相同

服务端配置

以下两种方式根据业务实际需要选择。

方式一:Nginx作为前置Web服务器

如果后端业务不需要标识请求方的身份,或只需要透传证书中的信息,不需要特殊处理,那么可以使用Nginx验证证书的合法性,然后将请求反向代理到后端HTTP服务,配置参考:

server {
    listen              443 ssl;
    server_name         www.example.com;
    ssl_certificate     server.crt;
    ssl_certificate_key server.key;
    # 如果server.key有密码,配置key.pass文件
    ssl_password_file   key.pass
    # 根证书
    ssl_client_certificate root.crt;
    ...
}

方式二:Tomcat(Spring Boot)作为Web服务器

如果拿到证书中的客户端信息后有自定义业务处理,那么可以使用Tomcat + Spring Security实现。配置参考:

server:
  port: 8443
  ssl:
    key-store: classpath:config/tls/keystore.jks
    key-store-password: changeit
    trust-store: classpath:config/tls/truststore.jks
    trust-store-password: changeit
    client-auth: NEED
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
	/*
	 * Enables x509 client authentication.
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// @formatter:off
        http
                .authorizeRequests()
                    .anyRequest()
                        .authenticated()
                .and()
                    .x509()
                .and()
                    .sessionManagement()
                        .sessionCreationPolicy(SessionCreationPolicy.NEVER)
                .and()
                    .csrf()
                        .disable();
        // @formatter:on
	}
 
	/*
	 * Create an in-memory authentication manager. We create 1 user (localhost which
	 * is the CN of the client certificate) which has a role of USER.
	 */
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.inMemoryAuthentication().withUser("localhost").password("none").roles("USER");
	}
}

使用此种方式时,前置网关需要支持L4级别的反向代理,或使用路由器、iptables等路由手段。

客户端测试和调用

方式一:curl测试HTTPS

curl -ik --cert client.crt --key clientprivate.key "https://localhost:8443/api/gateway/routes"

方式二:Nginx反向代理HTTP到HTTPS

使用Nginx反向代理业务发起的HTTP请求到服务提供商的HTTPS,可以方便的使用Nginx来请求HTTPS和验证其响应,减小业务调用的复杂度,减少流程。所以与HTTPS的操作由Nginx完成,业务调用只考虑HTTP。

    server {
        listen       8080;
        server_name  localhost;

        location / {
            proxy_pass https://registry.bayconnect.com.cn:18443/;
            proxy_ssl_certificate     /path/to/client.crt;
            proxy_ssl_certificate_key /path/to/client.key;
            proxy_ssl_protocols       TLSv1.2 TLSv1.3;
            proxy_ssl_ciphers         HIGH:!aNULL:!MD5;
            proxy_ssl_trusted_certificate /path/to/root.crt;
            # 如果开启了SSL校验,则proxy_pass地址需要使用域名
            proxy_ssl_verify              on;
            proxy_ssl_verify_depth        2;
            proxy_redirect off;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-Host $http_host;
            proxy_set_header X-Forwarded-Port $http_port;
        }
    }

方式二:Java测试调用HTTPS

使用Java直接调用HTTPS可以方便的实现自定义校验规则。

public class CertClientDemo {

    private final static String PFX_PATH = "/client.p12";              //客户端证书路径
    private final static String ROOT_PATH = "/truststore.jks";              //根证书路径
    private final static String PFX_PWD = "5Kz3iVHCYIW5io6u";         //客户端证书密码

    public static ResponseEntity<String> sslRequestGet() throws NoSuchAlgorithmException, CertificateException, IOException, KeyStoreException, KeyManagementException, UnrecoverableKeyException {
        KeyStore keyStore = KeyStore.getInstance("PKCS12");
        InputStream instream = CertClientDemo.class.getResourceAsStream(PFX_PATH);
        try {
            keyStore.load(instream, PFX_PWD.toCharArray());
        } finally {
            instream.close();
        }
        KeyStore rootKeyStore = KeyStore.getInstance("JKS");
        InputStream rootKeyInstream = CertClientDemo.class.getResourceAsStream(ROOT_PATH);
        try {
            rootKeyStore.load(rootKeyInstream, null);
        } finally {
            rootKeyInstream.close();
        }
        SSLContext sslContext = SSLContextBuilder.create()
                .loadKeyMaterial(keyStore, PFX_PWD.toCharArray())
                //.loadTrustMaterial(keyStore, TrustSelfSignedStrategy.INSTANCE)
                .loadTrustMaterial(rootKeyStore, TrustSelfSignedStrategy.INSTANCE)
                .setProtocol("TLSv1.2")
                .build();
        HttpClient httpClient = HttpClientBuilder.create()
                .setSSLContext(sslContext)
                // TODO: 当未使用域名(hostname)访问时,不校验host
                //.setHostnameVerifier(new AllowAllHostnameVerifier())
                // TODO:设置其它校验规则
                .build();
        ClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
        RestTemplate restTemplate = new RestTemplate(requestFactory);

        Map<String, String> parameter = new LinkedHashMap<>();
        parameter.put("key1", "val1");
        parameter.put("key2", "val2");
        parameter.put("key3", "val3");
        return restTemplate.postForEntity("https://registry.bayconnect.com.cn:8443/httpbin/anything", parameter, String.class);
    }

    public static void main(String[] args) throws Exception {
        System.out.println(sslRequestGet());
    }

}

附1:Openssl生成基于ECC算法的密钥

ECC椭圆加密算术相比普通的离散对数计算速度性能要强很多。对于RSA算法来讲,目前至少使用2048位以上的密钥长度才能保证安全性。ECC只需要使用224位长度的密钥就能实现RSA2048位长度的安全强度。在进行相同的模指数运算时速度显然要快很多。

openssl ecparam -name prime256v1 -genkey -out root.key

附2:Openssl生成SM2的密钥

OpenSSL在1.1.1+的版本开始支持SM2。

openssl ecparam -name SM2 -genkey -out root.key