HTTP协议以明文方式传输数据,存在信息安全的问题。
HTTPS (Hypertext Transfer Protocol Secure) 是基于 HTTP 扩展的一种互联网通信协议,可保护用户计算机与网站之间传输的数据的完整性和机密性。在 HTTPS 中,原有的 HTTP 协议会得到 TLS (安全传输层协议) 或其前辈 SSL (安全套接层) 的保护,因此 HTTPS 也常指 HTTP over TLS 或 HTTP over SSL,即HTTPS = HTTP + SSL/TLS。接下来重点介绍SSL/TLS。
1 SSL/TLS介绍
1.1 什么是SSL/TLS
SSL(Secure Sockets Layer:安全套接字层)是一种安全加密协议,它最初由 Netscape 于 1995 年开发,旨在确保 Internet 通信中的隐私、身份验证和数据完整性。SSL 自 1996 年推出 SSL 3.0 以来未曾更新过,现已弃用。SSL 协议中存在多个已知漏洞,目前大多数现代 Web 浏览器已彻底不再支持 SSL。
TLS(Transport Layer Security:传输层安全性)是SSL的演进版本,目前最新版本是2018发布的TLS 1.3。在 1999 年,互联网工程任务组(IETF)提出了对 SSL 的更新。由于此更新是由 IETF 开发的,不再牵涉到 Netscape,因此名称更改为 TLS。SSL 的最终版本(3.0)与 TLS 1.0之间并无明显差异,应用名称更改只是表示所有权改变。也由于TLS与SSL的一脉相承关系,目前仍然沿用很多SSL的术语,比如SSL证书实际上是指TLS证书。
使用 HTTPS 发送的数据可通过TLS得到保护,该协议可提供三重关键保护:
- 加密 - 对所交换的数据进行加密,使其免受窥探。这意味着,当用户浏览某个网站时,没有人能够“听到”其会话内容,也无法跟踪其在多个网页上的活动,或窃取其信息。
- 数据完整性 - 只要数据在传输期间被修改或损坏(无论有意或无意),都会被检测出来。
- 身份验证 - 证明用户是在与目标网站进行通信,从而保护用户免遭中间人攻击并建立用户信任,进而带来其他商业效益。
1.2 SSL/TLS握手
TLS 握手是启动 TLS 加密通信会话的过程。在 TLS 握手过程中,通信双方交换消息以相互确认,彼此验证,确立它们将使用的加密算法,并生成一致的会话密钥,并且在每一个新会话中使用不同的会话密钥来加密通信。TLS 握手是 HTTPS 工作原理的基础部分。
当触发HTTPS连接时,客户端和服务端之间会有多次的握手,除了TCP握手之外,还有SSL/TLS握手。由于多了SSL/TLS握手,HTTPS连接的延时肯定比HTTP多,但是经过不断的优化,时延已经降到可接受的范围内。
1.2.1 何时进行 TLS 握手
用户导航到一个使用 HTTPS 的网站,浏览器首先开始查询网站的原始服务器,这时就会发生 TLS 握手。
在任何其他通信使用 HTTPS 时(包括 API 调用和 DNS over HTTPS 查询),也会发生 TLS 握手。
通过 TCP 握手打开 TCP 连接后,将发生 TLS 握手。
1.2.2 TLS 握手期间会发生什么
在 TLS 握手过程中,客户端和服务器一同执行以下操作:
- 指定将要使用的 TLS 版本(TLS 1.0、1.2、1.3 等)。
- 决定将要使用加密算法,以便于生成对称加密的会话密钥。
- 通过服务器的公钥和 SSL 证书颁发机构的数字签名来验证服务器的身份。
- 生成会话密钥,以在握手完成后使用对称加密。
1.2.3 TLS握手步骤
TLS 握手是由客户端和服务器交换的一系列数据报或消息。TLS 握手涉及多个步骤,因为客户端和服务器要交换完成握手和进行进一步对话所需的信息。
TLS 握手的确切步骤将根据所使用的密钥交换算法的类型以及双方支持的密码套件而有所不同。RSA 密钥交换算法最为常用。具体如下:
图:TLS握手过程[4]
- 客户端问候(client hello)消息: 客户端通过向服务器发送“问候”消息来开始握手。该消息将包含客户端支持的 TLS 版本,支持的加密算法,以及称为一串称为“客户端随机数(client random)”的随机字节。
- 服务器问候(server hello)消息: 作为对 client hello 消息的回复,服务器发送一条消息,内含服务器的 SSL 证书、服务器选择的密码套件,以及由服务器生成的被称为“服务器随机数(server random)”的随机字节。
- 身份验证: 客户端借助CA公钥验证服务器的SSL证书,目的是确认服务器是其声称的身份,且客户端正在与该域的实际所有者进行交互。验证过程见1.3.3节。
- 预主密钥: 客户端再发送一串随机字节,即“预主密钥(premaster secret)”。预主密钥是使用服务器公钥加密的,只能使用服务器的私钥解密。(客户端从服务器的 SSL 证书中获得公钥)。
- 私钥被使用: 服务器使用服务器的私钥对预主密钥进行解密得到预主密钥。
- 生成会话密钥: 客户端和服务器均使用客户端随机数、服务器随机数和预主密钥生成会话密钥。双方应得到相同的结果。
- 客户端就绪: 客户端发送一条“已完成”消息,该消息用会话密钥加密。
- 服务器就绪: 服务器发送一条“已完成”消息,该消息用会话密钥加密。
- 实现安全对称加密: 已完成握手,并使用会话密钥继续进行通信
所有 TLS 握手均使用非对称加密(公钥和私钥),但并非全都会在生成会话密钥的过程中使用私钥,比如Diffie-Hellman握手。
1.3 SSL证书
在非对称加密通信过程中,服务器需要将公钥发送给客户端,在这一过程中,公钥如果被第三方拦截并替换,那第三方就可以冒充服务器与客户端进行通信,产生中间人攻击(man in the middle attack)。解决此问题的方法是通过受信任的第三方发布公钥,这个第三方就是可信任的数字证书认证机构(CA),而发布密钥的方式是通过SSL证书。
1.3.1 SSL证书内容
- 证书的使用域名
- 证书颁发给谁(证书申请者)
- 证书由哪一证书颁发机构颁发
- 证书有效期(颁发和到期日期)
- 证书申请者的公钥(私钥为保密状态)
- 证书颁发机构对该证书的数字签名(CA使用CA私钥对SSL证书摘要的加密结果)
- 生成SSL证书摘要的算法(比如MD5或SHA)
1.3.2 申请SSL证书
如果一个网站需要支持 HTTPS ,它就要一份证书来证明自己的身份,而这个证书必须从 CA 机构申请。
申请者将自己的公钥以及自身信息提交给CA,CA审核通过后把SSL证书颁布给申请者。(图片来自刘坤的技术博客)
1.3.3 怎么保证SSL证书不被伪造
TLS握手中的身份验证就是验证SSL证书确实来自被请求的服务器。具体验证过程如下:
- 客户端接收到SSL证书后,从SSL证书内容中获悉证书是哪个CA颁布的
- 如果该CA不在本地的信任CA清单(包含预置的权威CA和用户自行添加的CA)中,则发出告警信息由用户选择是否信任并跳过验证
- 如果该CA在本地的信任CA清单,那么本地就有该CA的公钥,用CA公钥解密SSL证书的数字签名得到摘要
abstract_original
- 使用SSL证书携带的摘要生成算法再次作用于SSL证书生成摘要
abstract_new
- 对比
abstract_original
是否等于abstract_new
,如果相等则说明证书完整且不是伪造的
上述验证过程需要使用CA公钥来解密数字签名,那么如何保证CA公钥的可靠性呢?这就是接下来的证书信任链会解答的问题。
1.3.4 证书信任链
CA证书可以具有层级结构,CA建立自上而下的信任链,下级CA信任上级CA,下级CA由上级CA颁发证书并认证。比如google.com.hk 的 SSL 证书由GTS CA 1C3这个CA来验证,而GTS CA 1C3由GTS Root R1验证,而GTS Root R1由根证书颁布机构GlobalSign Root CA - R1来验证。
带层级的SSL证书验证过程:
- 客户端得到服务端返回的证书,通过读取得到 服务端证书的发布机构(Issuer)
- 客户端去操作系统查找这个发布机构的的证书,如果是不是根证书就继续递归下去 直到拿到根证书。
- 用 根证书的公钥 去 解密验证 上一层证书的合法性,再拿上一层证书的公钥去验证更上层证书的合法性;递归回溯。
- 最后验证服务器端的证书是 可信任 的。
图:在信任链中,根证书(自签凭证)、中介凭证和终端实体(TLS服务器/客户端)凭证的关系[5]
根证书颁布机构(Root CA)的公钥是操作系统预置的,而且设置了权限控制,用于保证根证书颁布机构都是可信任且不被伪造。在window系统查询预置的Root CA:首先Win+R,然后输入certmgr.msc
即可。
1.3.5 证书格式
在 Java 平台下,证书常常被存储在 KeyStore 文件中,上面说的 cacerts 文件就是一个 KeyStore 文件,KeyStore 不仅可以存储数字证书,还可以存储密钥,存储在 KeyStore 文件中的对象有三种类型:Certificate、PrivateKey 和 SecretKey 。Certificate 就是证书,PrivateKey 是非对称加密中的私钥,SecretKey 用于对称加密,是对称加密中的密钥。KeyStore 文件根据用途,也有很多种不同的格式:JKS、JCEKS、PKCS12、DKS 等等。
- PKCS12: Public Key Cryptographic Standards,行业广泛采用的格式。
- JKS: Java KeyStore ,Java平台的专用格式。
1.3.6 证书生成工具
- Keytool:JRE自带的数字证书管理工具 ,位于java安装目录下的bin目录。
- OpenSSL:一套密码库工具,用以支持SSL/TLS 协议的实现,可以用它生成证书,进行数据加解密,计算消息摘要等等。通过它可以进行自签名证书(把自己当作CA机构),实现https访问。一般linux已自带安装,没有安装的需要下载安装。
使用Keytool生成PKCS12格式的KeyStore:生成时需要设置密码,该密码用于后续打开读写生成的KeyStore。
keytool -genkeypair -alias baeldung -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore baeldung.p12 -validity 3650
使用Keytool生成 JKS格式的KeyStore:
keytool -genkeypair -alias baeldung -keyalg RSA -keysize 2048 -keystore baeldung.jks -validity 3650
使用Keytool将 JKS格式转为PKCS12格式:
keytool -importkeystore -srckeystore baeldung.jks -destkeystore baeldung.p12 -deststoretype pkcs12
使用OpenSSL提取PKCS12格式KeyStore的私钥:
openssl pkcs12 -info -in baeldung.p12 -nodes -nocerts
使用OpenSSL提取PKCS12格式KeyStore的数字证书:
openssl pkcs12 -info -in baeldung.p12 -nokeys
参考资料:
2 SpringBoot应用配置使用HTTPS
2.1 生成证书和密钥
使用Keytool生成PKCS12格式的KeyStore:生成时需要设置密码,该密码用于后续打开读写生成的KeyStore。
keytool -genkeypair -alias baeldung -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore baeldung.p12 -validity 3650
2.2 服务端
搭建支持HTTPS协议的Rest服务。
-
将2.1节生成pkcs12格式的keystore文件放在src/main/resources目录下
-
在springboot应用的application.properties文件增加以下配置,关于配置参数中key-store与trust-store的区别参考Difference Between a Java Keystore and a Truststore
server: port: 1443 #https端口号. ssl: key-store: classpath:baeldung.p12 #证书的路径. key-store-password: *** #证书密码 key-store-type: PKCS12 #秘钥库类型 key-alias: baeldung #证书别名
- 启动应用,这时应用只支持https访问(浏览器显示不安全是因为自建CA不是可信任的CA),不支持http访问。
兼容http协议访问
在启动类中增加以下代码,实现http自动跳转https:
@Bean
public ServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
@Override
protected void postProcessContext(Context context) {
SecurityConstraint securityConstraint = new SecurityConstraint();
securityConstraint.setUserConstraint("CONFIDENTIAL");
SecurityCollection collection = new SecurityCollection();
collection.addPattern("/*");
securityConstraint.addCollection(collection);
context.addConstraint(securityConstraint);
}
};
tomcat.addAdditionalTomcatConnectors(redirectConnector());
return tomcat;
}
private Connector redirectConnector() {
Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
connector.setScheme("http");
connector.setPort(8808); // 原http端口
connector.setSecure(false);
connector.setRedirectPort(1443); // 跳转的https端口,也就是我们配置文件中配置的项目端口
return connector;
}
2.3 客户端
通过Spring提供的RestTemplate类调用服务的rest接口。
@Slf4j
@RestController
public class RestDemo {
public final String LOCAL_SERVICE_URL = "https://localhost:1443/api/anno/success";
public final String REMOTE_SERVICE_URL = "https://www.baidu.com/";
@GetMapping("/trigger")
public int triggerHttps() throws Exception {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(
REMOTE_SERVICE_URL, String.class, Collections.emptyMap());
log.info("response of {}: statusCode={}", REMOTE_SERVICE_URL, response.getStatusCode());
response = restTemplate.getForEntity(
LOCAL_SERVICE_URL, String.class, Collections.emptyMap());
log.info("response of {}: statusCode={}", LOCAL_SERVICE_URL, response.getStatusCode());
return response.getStatusCodeValue();
}
}
运行上述代码后发现,访问百度首页是正常的,但是访问本地搭建的服务端https接口抛出异常,提示找不到目标接口的有效SSL证书。
JRE安装完成后,在{java.home}/lib/security/cacerts
存有默认的TrustStore,也就是可信任的根证书颁布机构。由于百度的SSL证书是正规CA机构颁布的,所以RestTemplate发起请求时,SSL证书验证通过。而本地服务端使用的是自己生成的SSL证书,JRE不认为是可信任的,所以SSL证书验证不通过并抛出异常。
为了解决该异常,一种方法是将自己生成的SSL证书添加到JRE的TrustStore列表中,另一种是在代码中设置将跳过SSL证书验证。跳过证书认证的实现如下:
-
将2.1节生成pkcs12格式的keystore文件放在src/main/resources目录下
-
添加RestTemplate配置类
RestTemplateConfig
,通过Apache的httpclient
配置https访问的trustStore@Slf4j @Configuration public class RestTemplateConfig { // 证书文件路径 private final String KEY_STORE_FILE = "{file_path}/baeldung.p12"; // 证书访问密码 private final String KEY_STORE_PWD = "***"; @Bean("restTemplateNative") public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); return restTemplate; } @Bean("restTemplateTrustSelfCA") public RestTemplate restTemplate(ClientHttpRequestFactory httpComponentsClientHttpRequestFactory) { RestTemplate restTemplate = new RestTemplate(httpComponentsClientHttpRequestFactory); restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8)); log.info("loading restTemplate"); return restTemplate; } @Bean("httpComponentsClientHttpRequestFactory") public ClientHttpRequestFactory httpComponentsClientHttpRequestFactory() throws IOException, UnrecoverableKeyException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { //SSL证书验证总是通过, 等价于跳过证书验证 TrustStrategy acceptingTrustStrategy = (X509Certificate[] chain, String authType) -> true; KeyStore trustStore = KeyStore.getInstance("PKCS12"); trustStore.load(new FileInputStream(ResourceUtils.getFile(KEY_STORE_FILE)), KEY_STORE_PWD.toCharArray()); SSLContext sslContext = SSLContextBuilder.create() .loadTrustMaterial(trustStore, acceptingTrustStrategy).build(); HttpClient httpClient = HttpClients.custom().setSSLContext(sslContext).build(); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); return requestFactory; } }
-
使用RestTemplate调用服务端https接口
@RestController public class RestDemo { public final String LOCAL_SERVICE_URL = "https://localhost:1443/api/anno/success"; public final String REMOTE_SERVICE_URL = "https://www.baidu.com/"; @Autowired() @Qualifier("restTemplateTrustSelfCA") private RestTemplate restTemplate; @GetMapping("/trigger-remote") public int triggerHttpsRemote() throws Exception { ResponseEntity<String> response = restTemplate.getForEntity( REMOTE_SERVICE_URL, String.class, Collections.emptyMap()); log.info("response of {}: statusCode={}", REMOTE_SERVICE_URL, response.getStatusCode()); return response.getStatusCodeValue(); } @GetMapping("/trigger-local") public int triggerHttpsLocal() throws Exception { ResponseEntity<String> response = restTemplate.getForEntity( LOCAL_SERVICE_URL, String.class, Collections.emptyMap()); log.info("response of {}: statusCode={}", LOCAL_SERVICE_URL, response.getStatusCode()); return response.getStatusCodeValue(); } }
启动应用后,发现通过RestTemplate调用本地服务端的https接口报以下错误:
javax.net.ssl.SSLPeerUnverifiedException: Certificate for <localhost> doesn't match any of the subject alternative names: []
问题定位:在1.3.1节我们提到SSL证书包含证书的使用域名,TLS握手中也会验证SSL证书的域名与客户端请求的目的域名是否一致。由于2.1节生成SSL证书时没有设置证书的使用域名,导致验证未通过。
解决办法:有2种,一是重新生成包含localhost域名的SSL证书,二是配置https允许接收来自不同域名的SSL证书
-
keytool -genkeypair -alias baeldung -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore baeldung.p12 -validity 3650 -ext "SAN:c=DNS:localhost,IP:127.0.0.1"
服务端和客户端都配置新证书后,问题修复。通过浏览器访问服务端https接口时,可以看到新证书带了证书使用的域名信息。
-
再沿用原SSL证书条件下,按以下方式修改RestTemplate配置类
RestTemplateConfig
,添加NoopHostnameVerifier
配置参数。@Slf4j @Configuration public class RestTemplateConfig { // 证书文件路径 private final String KEY_STORE_FILE = "{file_path}/baeldung.p12"; // 证书访问密码 private final String KEY_STORE_PWD = "***"; @Bean("restTemplateNative") public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); return restTemplate; } @Bean("restTemplateTrustSelfCA") public RestTemplate restTemplate(ClientHttpRequestFactory httpComponentsClientHttpRequestFactory) { RestTemplate restTemplate = new RestTemplate(httpComponentsClientHttpRequestFactory); restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8)); log.info("loading restTemplate"); return restTemplate; } @Bean("httpComponentsClientHttpRequestFactory") public ClientHttpRequestFactory httpComponentsClientHttpRequestFactory() throws IOException, UnrecoverableKeyException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException { //SSL证书验证总是通过, 等价于跳过证书验证 TrustStrategy acceptingTrustStrategy = (X509Certificate[] chain, String authType) -> true; KeyStore trustStore = KeyStore.getInstance("PKCS12"); trustStore.load(new FileInputStream(ResourceUtils.getFile(KEY_STORE_FILE)), KEY_STORE_PWD.toCharArray()); SSLContext sslContext = SSLContextBuilder.create() .loadTrustMaterial(trustStore, acceptingTrustStrategy).build(); // HttpClient httpClient = HttpClients.custom().setSSLContext(sslContext).build(); // HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); CloseableHttpClient httpClient = HttpClients.custom().setSSLSocketFactory(csf).build(); HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); return requestFactory; } }
SSLContextBuilder#loadTrustMaterial(KeyStore truststore, TrustStrategy trustStrategy)
是设置跳过SSL证书验证的关键,下表给出了给loadTrustMaterial()
传递不同参数值时,访问百度首页的trigger-remote与访问本地服务端的trigger-local的响应。
KeyStore参数值 | TrustStrategy参数值 | 响应结果 |
---|---|---|
null | (X509Certificate[] chain, String authType) -> false | trigger-remote正常、trigger-local异常 |
null | (X509Certificate[] chain, String authType) -> true | trigger-remote正常、trigger-local正常 |
null | new TrustAllStrategy() | trigger-remote正常、trigger-local正常 |
null | new TrustSelfSignedStrategy() | trigger-remote正常、trigger-local正常 |
trustStore | (X509Certificate[] chain, String authType) -> false | trigger-remote异常、trigger-local正常 |
trustStore | (X509Certificate[] chain, String authType) -> true | trigger-remote正常、trigger-local正常 |
trustStore | new TrustAllStrategy() | trigger-remote正常、trigger-local正常 |
trustStore | new TrustSelfSignedStrategy() | trigger-remote异常、trigger-local正常 |
当KeyStore参数值为null时,默认取{java.home}/lib/security/jssecacerts
为TrustStore,如果不存在则取{java.home}/lib/security/cacerts
为TrustStore;当KeyStore传值时,则取传入值为TrustStore。
TrustStrategy参数中,从源码可知TrustAllStrategy()
等价于(X509Certificate[] chain, String authType) -> true
。
参考资料:
- HTTPS using Self-Signed Certificate in Spring Boot
- Common Application Properties
- Container Configuration in Spring Boot 2
- Difference Between a Java Keystore and a Truststore
3 总结
从HTTPS的含义引出加密通信协议SSL/TLS,然后重点介绍了SSL/TLS的内容,包括SSL/TLS的定义、SSL与TLS之间的关系,启动SSL加密通信的SSL握手的步骤、SSL证书的内容以及如何使用证书,最后给出在SpringBoot应用中配置使用HTTPS的简单例子,包括提供https接口的服务端和使用RestTemplate调用https接口的客户端。