https 协议是安全传输,为啥还要再加密?

背景

这几天,我们家娃吃奶粉的量嗷嗷的涨起来了。我这颗小鹿乱撞的心,忍不住去面了两家互联网金融公司。

因为没有准备,结果你懂的~

这两家共同都有一个共同点,特别关系安全问题,尤其是看到你用过 okhttp3,那不得给你撸脱毛了就不算完事儿。

协议

HTTP vs HTTPS

我们都知道,https 相比于之前的 http 多了一层, 如下:

HTTP是一个基于TCP/IP通信协议来传递数据的协议,TCP/IP通信协议只有四层,从上往下依次为:应用层、传输层、网络层、数据链路层这四层,大学课本上的计算机网络知识是不是来了。但是,HTTP 协议在在网上进行传输用的是明文,就像某鸟给你发的快递,你的手机号、姓名都是写的清清楚楚,用户看着都瑟瑟发抖。

后来京东和顺丰意识到了这一点,就对手机号中间四位做了加密处理,姓名中间那几个个字也看不到了,甚至快递小哥给你打电话都是虚拟号码,你自己的电话只有自己心里清楚。

HTTPS 也是这个路子,为了解决 HTTP 明文传输存在的安全问题,在应用层和传输层之间加了一层安全层:SSL/TLS。

SSL: Secure Socket Layer, 安全套接层

TLS: Transport Layer Security,传输层安全协议

关于 HTTP 和 HTTPS 的对比文章,知乎上有一大坨,这里贴几篇,喜欢的可以多撸几遍:

  1. www.zhihu.com/question/19...
  2. zhuanlan.zhihu.com/p/158593966
  3. 握手与挥手:zhuanlan.zhihu.com/p/78405711

HTTPS优缺点

  1. 使用 HTTP 明文传输存在被窃听风险,HTTPS 通过内容加密解决,注意这一步用的是对称加密哦
  2. 使用 HTTP 明文传输存在通信内容被篡改风险,HTTPS 通过校验机制解决
  3. 使用 HTTP 明文传输存在冒充服务风险,HTTPS 通过身份证书认证机制解决

通过以上三点,我们知道HTTPS 相对于 HTTP 有很强的安全感,但是对于一些app 这种安全级别还不够,如果出现以下意外咋整?

我干脆去问了讯飞星火,他是这么回答我的:

我又去问了 ChatGPT,他的回答是这样的

这俩货说的差不多,HTTPS 只能保证传输过程的安全性,不能保证全链路的安全性,更不能满足特定安全标准的规范诉求,所以我们需要根据安全的级别要求,采取不同加密策略。

既然如此,我们使用的三方网络库这里特指 okhttp 是怎么全链路传输安全的?我们接着往下唠:

OKhttp 库

Okhttp 库是基于HTTP和HTTPS协议封装的网络请求库,用于 Android 和 Java 应用中的网络通信。库在这儿:github.com/square/okht...

我们可以先了解一下okhttp 库是如何保证传输安全的~

问:OKhttp是如何支持https协议,以保证传输安全的,尤其是如何进行防止侦听、篡改、拦截信息?

AI 回答: OkHttp是一个流行的开源HTTP客户端库,由Square公司开发。它提供了对HTTP和HTTPS的支持,并通过内置的TLS(Transport Layer Security)来确保HTTPS连接的安全性。以下是OkHttp如何支持HTTPS协议以及保障传输安全的一些关键方面:

  1. TLS/SSL支持:

    • OkHttp使用Java的javax.net.ssl包来支持TLS/SSL协议。TLS是HTTP的安全版本,用于加密通信内容,防止中间人攻击、窃听和篡改。
    • OkHttp通过SSLSocketFactoryHostnameVerifier等类来处理TLS/SSL握手和验证服务器证书的过程。
  2. 证书验证:

    • OkHttp会验证服务器提供的SSL证书,确保通信双方的身份合法。如果服务器的证书无效或无法验证,OkHttp会拒绝连接,从而防止中间人攻击。
  3. 证书锁定(Certificate Pinning):

    • OkHttp支持证书锁定机制,即在应用中预先指定服务端的证书信息。这样可以防止使用伪造证书的攻击,即使系统受到信任的根证书被破坏,应用仍然可以保持安全。
  4. 连接池和持久连接:

    • OkHttp通过连接池(Connection Pool)来重用已建立的连接,从而减少握手次数,提高性能。持久连接也有助于减少建立连接时的潜在风险。
  5. 安全配置:

    • OkHttp允许开发者配置TLS版本、密码套件(Cipher Suites)等参数,以满足特定的安全要求。
  6. 拦截器(Interceptors):

    • OkHttp提供了拦截器机制,可以在HTTP请求和响应的过程中插入自定义的逻辑。这使得开发者可以在请求或响应阶段添加额外的安全检查或处理逻辑,从而增强安全性。

总的来说,OkHttp通过支持TLS/SSL、证书验证、证书锁定、连接池等安全机制,以及提供灵活的配置和拦截器功能,确保在HTTPS连接中防止侦听、篡改和拦截信息的风险。开发者在使用OkHttp时,应该合理配置相关参数,以满足应用的安全需求。

好家伙,okhttp 是支持合理配置安全参数的,而且其拦截器机制可以很方便的进行安全检查,那还等什么,走一波~

策略一:对称加密(加密内容)

这里主打一个,客户端不想让别人看到自己的传输内容,想当然对自己的请求内容进行加密处理。基于这种思路我们封装一个 EncryptedOkHttpClient,代码如下:

Java 复制代码
public static OkHttpClient createEncryptedOkHttpClient() {
    // 创建一个OkHttpClient.Builder
    OkHttpClient.Builder builder = new OkHttpClient.Builder();

    // 添加自定义拦截器,用于加密请求内容
    builder.addInterceptor(new EncryptionInterceptor());

    // 创建OkHttpClient实例
    return builder.build();
}

注释里已经写了,通过EncryptionInterceptor拦截器对请求进行加密处理,这里选择加密请求体 RequestBody 在encryptRequestBody方法中,RequestBody 依赖 okio 的 Buffer 类转换为ByteArray用于加密,加密算法选择对称加密算法 AES 加密字节数据,实现如下:

Java 复制代码
private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
    // 从原始RequestBody中读取字节数据
    // Read the byte data from the original RequestBody using Okio
    Buffer buffer = new Buffer();
    originalRequestBody.writeTo(buffer);
    byte[] bytes = buffer.readByteArray();

    // 使用对称加密算法(AES)加密字节数据
    byte[] encryptedBytes = encryptWithAES(bytes, SECRET_KEY);

    // 创建新的RequestBody
    return RequestBody.create(originalRequestBody.contentType(), encryptedBytes);
}

可以看到,AES 使用了encryptWithAES方法加密字节数据,同时传了SECRET_KEY这个密钥,那我们看看 AES 是怎么加密的:

Java 复制代码
private byte[] encryptWithAES(byte[] input, String key) {
    try {
        SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
        Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        return cipher.doFinal(input);
    } catch (Exception e) {
        e.printStackTrace();
        return new byte[0];
    }
}

四行代码搞定,首先通过SecretKeySpec类将SECRET_KEY字符串加密成 SecretKey 对象,然后Cipher以加密模式 对密钥进行初始化然后加密 input 也就是转换为字节数组的请求体。 加密完成了,服务器当然要进行解密,解密方法如下:

java 复制代码
public static String decrypt(String encryptedText) {
    try {
        byte[] encryptedData = Base64.decode(encryptedText,Base64.DEFAULT);

        SecretKey secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), AES_ALGORITHM);
        Cipher cipher = Cipher.getInstance(AES_ALGORITHM);
        cipher.init(Cipher.DECRYPT_MODE, secretKey);

        byte[] decryptedBytes = cipher.doFinal(encryptedData);

        return new String(decryptedBytes, StandardCharsets.UTF_8);
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

可以看到,解密过程使用了相同AES算法和密钥SECRET_KEY,这就是对称加密使用一把钥匙上锁和开锁。但是这种加密算法有很大的问题:

首先,这把钥匙如果想通过网络传输让服务端知道,传输过程中被劫持了密钥就会暴露。

另外,SECRET_KEY是硬编码在代码中的,这也不安全,这可咋整啊?

千里之堤,溃于hacker

为了防止这种中间人攻击的问题,非对称加密开始表演了~

策略二:非对称加密

非对称加密是一把锁两把钥匙:公钥和私钥。前者是给大家伙用的,谁都能够来配一把公钥进行数据加密,但是要对加密数据进行解密,只能使用私钥。

假设我们用公钥加密一份数据,就不怕拦截了。因为只有拿着私钥的服务端才能解密数据,我们拿着服务器提供的公钥把策略一中的对称密钥给加密了,那不就解决了网络传输密钥的问题了。对的,HTTPS 也是这么做的,按照这个思路我们再添加一个 MixtureEncryptionInterceptor 拦截器。

Java 复制代码
// 添加自定义拦截器,用服务器非对称加密的公钥加密对称加密的密钥,然后用对称加密密钥加密请求内容
builder.addInterceptor(new MixtureEncryptionInterceptor());

MixtureEncryptionInterceptor 拦截器同样实现 Interceptor 接口如下:

其 intercept 方法跟 EncryptionInterceptor 一模一样,具体的变化在 encryptRequestBody() 方法中。具体实现如下:

ini 复制代码
private RequestBody encryptRequestBody(RequestBody originalRequestBody) throws IOException {
    // 生成对称加密的密钥
    byte[] secretKeyBytes = generateSecretKey();
    SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, "AES");
    // 使用服务器的公钥加密对称加密的密钥
    byte[] encryptedSecretKey = encryptWithPublicKey(secretKeyBytes, SERVER_PUBLIC_KEY);
    // 从原始 RequestBody 中读取字节数据
    Buffer buffer = new Buffer();
    originalRequestBody.writeTo(buffer);
    byte[] bytes = buffer.readByteArray();

    // 使用对称加密算法(AES)加密请求体
    byte[] encryptedRequestBodyBytes = encryptWithAES(bytes, secretKeySpec);

    // 创建新的 RequestBody,将加密后的密钥和请求体一并传输
    return RequestBody.create(null, concatenateArrays(encryptedSecretKey, encryptedRequestBodyBytes));
}

如代码中注释,整个混合加密共 4 个步骤,依次是:

  1. 生成对称加密的密钥,用来加密传输内容。代码如下:
Java 复制代码
/**
 * try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥
 * catch block 里使用的是示范性的非安全密钥
 * @return
 */
private byte[] generateSecretKey() {
    // 生成对称加密的密钥
    try {
        // 创建KeyGenerator对象,指定使用AES算法
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");

        // 初始化KeyGenerator对象,设置密钥长度为128位
        keyGenerator.init(128, new SecureRandom());

        // 生成密钥
        SecretKey secretKey = keyGenerator.generateKey();

        // 获取密钥的字节数组表示形式
        byte[] keyBytes = secretKey.getEncoded();

        // 打印密钥的字节数组表示形式
        for (byte b : keyBytes) {
            Log.d(TAG,b + " ");
        }
        return keyBytes;
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        // 这里简单地示范了生成密钥的过程,实际上可以使用更复杂的方法来生成密钥
        return "YourSecretKey".getBytes(StandardCharsets.UTF_8);
    }

}

如注释所言,上面try block 里使用的是加密算法和随机数生成器,生成的较为复杂的密钥,catch block 里使用的是示范性的非安全密钥。这里主要是想说明生成对称密钥的方式有很多,但是硬编码生成密钥那是不推荐的,因为太不安全了,很容易被恶意用户获取到。

  1. 使用服务器的公钥加密对称加密的密钥,防止被破解
Java 复制代码
private byte[] encryptWithPublicKey(byte[] input, String publicKeyString) {
    try {
        // 封装 PublicKey
        byte[] keyBytes = Base64.decode(publicKeyString, Base64.DEFAULT);
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);

        Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);

        return cipher.doFinal(input);
    } catch (NoSuchAlgorithmException | InvalidKeySpecException | NoSuchPaddingException |
             InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
        e.printStackTrace();
        return new byte[0];
    }
}

将服务端提供的公钥字符串转化成字节数组,然后通过 RSA 非对称算法加密 input,也就是我们的对称密钥。

注意:Cipher.getInstance("RSA/ECB/PKCS1Padding") 表示获取一个Cipher对象,该对象使用RSA算法、ECB模式和PKCS1填充方式。

  1. 使用对称加密算法(AES)加密请求体,请求体仍然要用对称加密密钥加密,只是对称加密密钥用公钥保护起来
Java 复制代码
private byte[] encryptWithAES(byte[] input, SecretKeySpec secretKeySpec) {
    try {
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
        return cipher.doFinal(input);
    } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
             BadPaddingException | IllegalBlockSizeException e) {
        e.printStackTrace();
        return new byte[0];
    }

}

非对称加密加密内容,策略一已经实现了。

  1. 创建新的 RequestBody,将加密后的密钥和请求体一并传输,这样就算 hacker 拦截了请求解析出请求体的数据,也无法直接获取到原始对称密钥。 加密完成后,通过 concatenateArrays 方法将加密后的密钥和请求体,实现如下:
Java 复制代码
private byte[] concatenateArrays(byte[] a, byte[] b) {
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    try {
        outputStream.write(a);
        outputStream.write(b);
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        try {
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    return outputStream.toByteArray();
}

非对称加密解决了密钥网络传输的问题,但是 hacker 还是可以伪装成目标服务端,骗取客户端的密钥。在伪装成客户端,用服务端的公钥加密自己篡改的内容,目标服务端对此无法辨别真伪。这就需要证书校验。

策略三:证书校验(单向认证)

okhttp3 提供了CertificatePinner这个类用于证书校验,CertificatePinner 可以验证服务器返回的证书是否是预期的证书。在创建createEncryptedOkHttpClient()方法中,添加证书代码如下:

okhttp 会利用其内置的证书固定机制来校验服务器返回证书的有效性。如果证书匹配,请求会继续进行;如果不匹配,OkHttp会抛出一个异常,通常是一个SSLPeerUnverifiedException,表明证书验证失败。验证过程在CertificatePinner 类的check()方法中,如下:

Java 复制代码
/**
 * Confirms that at least one of the certificates pinned for {@code hostname} is in {@code
 * peerCertificates}. Does nothing if there are no certificates pinned for {@code hostname}.
 * OkHttp calls this after a successful TLS handshake, but before the connection is used.
 *
 * @throws SSLPeerUnverifiedException if {@code peerCertificates} don't match the certificates
 * pinned for {@code hostname}.
 */
public void check(String hostname, List<Certificate> peerCertificates)
    throws SSLPeerUnverifiedException {
  List<Pin> pins = findMatchingPins(hostname);
  if (pins.isEmpty()) return;

  if (certificateChainCleaner != null) {
    peerCertificates = certificateChainCleaner.clean(peerCertificates, hostname);
  }

  for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
    X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);

    // Lazily compute the hashes for each certificate.
    ByteString sha1 = null;
    ByteString sha256 = null;

    for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
      Pin pin = pins.get(p);
      if (pin.hashAlgorithm.equals("sha256/")) {
        if (sha256 == null) sha256 = sha256(x509Certificate);
        if (pin.hash.equals(sha256)) return; // Success!
      } else if (pin.hashAlgorithm.equals("sha1/")) {
        if (sha1 == null) sha1 = sha1(x509Certificate);
        if (pin.hash.equals(sha1)) return; // Success!
      } else {
        throw new AssertionError();
      }
    }
  }

  // If we couldn't find a matching pin, format a nice exception.
  StringBuilder message = new StringBuilder()
      .append("Certificate pinning failure!")
      .append("\n  Peer certificate chain:");
  for (int c = 0, certsSize = peerCertificates.size(); c < certsSize; c++) {
    X509Certificate x509Certificate = (X509Certificate) peerCertificates.get(c);
    message.append("\n    ").append(pin(x509Certificate))
        .append(": ").append(x509Certificate.getSubjectDN().getName());
  }
  message.append("\n  Pinned certificates for ").append(hostname).append(":");
  for (int p = 0, pinsSize = pins.size(); p < pinsSize; p++) {
    Pin pin = pins.get(p);
    message.append("\n    ").append(pin);
  }
  throw new SSLPeerUnverifiedException(message.toString());
}

从校验方法中得知,

  1. 可以没有固定证书
  2. 证书加密使用sha256/sha1
  3. 证书校验失败会抛出AssertionError错误
  4. 获取不到匹配的固定证书,会抛异常SSLPeerUnverifiedException

可以看到,使用相当方便。但是它有一个问题:请求之前需要预先知道服务端证书的 hash 值。就是说如果证书到期需要更换,老版本的应用就无法获取到更新的证书 hash 值了,老用户要统一升级。这~~~

策略四:创建SSLContext认证(客户端、服务端双向认证)

除了固定证书校验,还有一种基于 SSLContext 的校验方式。在建立HTTPS连接时,在客户端它依赖 SSLContext 和 TrustManager 来验证服务端证书。这里我们通过一createTwoWayAuthClient()方法实现如下:

Java 复制代码
private static OkHttpClient createTwoWayAuthClient() throws IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException, KeyManagementException {
    // 服务器证书
    InputStream serverCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/server_certificate.crt");
    X509Certificate serverCertificate = readCertificate(serverCertStream);
    if (serverCertStream != null) {
        serverCertStream.close();
    }

    // 客户端证书和私钥
    InputStream clientCertStream = TwoWayAuthHttpClient.class.getResourceAsStream("/client_centificate.p12");
    KeyStore clientKeyStore = KeyStore.getInstance("PKCS12");
    clientKeyStore.load(clientCertStream, "client_password".toCharArray());
    if (clientCertStream != null) {
        clientCertStream.close();
    }

    // 创建 KeyManagerFactory 和 TrustManagerFactory
    KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
    keyManagerFactory.init(clientKeyStore, "client_password".toCharArray());

    // 创建信任管理器,信任服务器证书
    TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
    trustStore.load(null, null);
    trustStore.setCertificateEntry("server", serverCertificate);
    trustManagerFactory.init(trustStore);

    // 初始化 SSL 上下文
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom());

    // 创建 OkHttpClient
    return new OkHttpClient.Builder()
            .sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustManagerFactory.getTrustManagers()[0])
            .build();
}

private static X509Certificate readCertificate(InputStream inputStream) throws CertificateException {
    CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
    return (X509Certificate) certificateFactory.generateCertificate(inputStream);
}
  1. 加载服务器证书

    • 使用getResourceAsStream从类路径中加载服务器证书文件(.crt格式)。
    • 通过readCertificate方法读取证书内容,并生成一个X509Certificate对象。
    • 关闭输入流以释放资源。

注意:/server_certificate.crt可以动态加载服务器自签名证书的办法避免更新旧版本应用

  1. 加载客户端证书和私钥

    • 同样使用getResourceAsStream从类路径中加载客户端证书和私钥文件(.p12格式,通常是PKCS#12格式的密钥库)。
    • 创建一个KeyStore实例,并使用PKCS12算法加载客户端证书和私钥。密码为"client_password"
    • 关闭输入流。
  2. 创建KeyManagerFactory和TrustManagerFactory

    • KeyManagerFactory用于管理客户端的私钥和证书,以便在建立SSL/TLS连接时使用。
    • TrustManagerFactory用于管理信任的证书,以便在建立SSL/TLS连接时验证服务器的证书。
    • 使用默认算法初始化这两个工厂,并分别加载客户端的密钥库和信任的服务器证书。
  3. 初始化SSLContext

    • 创建一个SSLContext实例,指定使用TLS协议。
    • 使用之前创建的KeyManagerFactoryTrustManagerFactory初始化SSLContext。这会将客户端的私钥和证书,以及信任的服务器证书整合到SSL/TLS握手过程中。
  4. 创建OkHttpClient

    • 使用OkHttpClient.Builder创建一个新的OkHttpClient实例。
    • 配置SSL套接字工厂和信任管理器,以确保在建立连接时使用两向认证。
    • 构建并返回配置好的OkHttpClient实例。

这样客户端发起请求时,会将客户端证书发送给服务端,同时会校验服务端握手时返回的证书。校验逻辑如下:

这样整个双向校验工作就完成了。

总结

okhttp 作为一个支持 HTTPS 协议的网络库,同时支持对称加密非对称加密客户端证书校验客户端、服务端双向证书校验等安全加密方式,足见其强大的功能。

此外,为了兼顾性能:它使用证书校验保证通信双方的合法性,使用对称加密加密传输内容保证性能,使用非对称加密加密对称密钥防止hacker 拦截,整体提高了网络通信的安全性。

AI 是个好东西~

参考文章

  1. TCP/IP 协议: zh.wikipedia.org/wiki/TCP/IP...
  2. okhttp库: github.com/square/okht...
  3. X.509
相关推荐
咖啡の猫2 天前
Vue 实例生命周期
前端·vue.js·okhttp
Jeled5 天前
Retrofit 与 OkHttp 全面解析与实战使用(含封装示例)
android·okhttp·android studio·retrofit
Jeled6 天前
Android 网络层最佳实践:Retrofit + OkHttp 封装与实战
android·okhttp·kotlin·android studio·retrofit
allk559 天前
OkHttp源码解析(一)
android·okhttp
allk559 天前
OkHttp源码解析(二)
android·okhttp
aFakeProgramer9 天前
拆分PDF.html 办公小工具
okhttp
一壶浊酒..10 天前
ajax局部更新
前端·ajax·okhttp
洛克大航海13 天前
Ajax基本使用
java·javascript·ajax·okhttp
whltaoin19 天前
Java 网络请求 Jar 包选型指南:从基础到实战
java·http·okhttp·网络请求·retrofit
华农第一蒟蒻20 天前
谈谈跨域问题
java·后端·nginx·安全·okhttp·c5全栈