安全实践:HTTPS与证书锁定、防止中间人攻击、敏感信息加密

网络层安全,说白了就是你的App和服务器之间的对话不能被偷听、篡改或冒充。我见过太多团队把精力全放在业务逻辑上,结果上线第一天就被抓包工具扒了个底朝天。今天咱们就聊聊怎么把网络层真正锁死。

一、HTTPS不是终点,是起点

很多人觉得上了HTTPS就万事大吉了。嗯,这个想法很危险。HTTPS确实加密了传输通道,但中间人攻击(MITM)依然能绕过它。你想想看,如果用户手机上装了个伪造的CA证书,或者攻击者控制了WiFi热点,HTTPS照样能被解密。

我在项目中遇到过一件事:某金融App上线后,用户反馈登录异常。一查才发现,有第三方SDK偷偷替换了系统的证书信任链。从那以后,我对网络层的安全策略做了彻底重构。

二、证书锁定(Certificate Pinning)

证书锁定,说白了就是让你的App只信任你指定的那个证书,而不是系统里所有的CA证书。这样就算攻击者伪造了证书,只要不是你的那张,连接就会失败。

2.1 锁定方式对比

方式 原理 优点 缺点
公钥锁定 锁定服务器证书的公钥 证书更换时公钥不变,灵活性高 需要提前获取公钥指纹
证书锁定 锁定完整的证书内容 最严格,安全性最高 证书更换时必须更新App
Hash锁定 锁定证书或公钥的SHA-256哈希 存储空间小,比较速度快 需要定期更新哈希值

我个人习惯用公钥锁定。为什么呢?因为证书可能会过期、被吊销,但公钥一般不会变。你想想看,如果锁死了证书,哪天证书到期了,用户不更新App就用不了,这体验太差了。

2.2 OkHttp中的证书锁定实现

在Android里,我们用的Retrofit底层是OkHttp。OkHttp原生支持证书锁定,代码写起来很简洁。

// 1. 获取服务器证书的公钥哈希值
// 可以用openssl命令生成:
// openssl s_client -connect yourdomain.com:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64

// 2. 在OkHttp中配置CertificatePinner
val certificatePinner = CertificatePinner.Builder()
    .add("yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add("yourdomain.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // 备用证书
    .build()

val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

// 3. 创建Retrofit实例
val retrofit = Retrofit.Builder()
    .baseUrl("https://yourdomain.com/")
    .client(okHttpClient)
    .addConverterFactory(GsonConverterFactory.create())
    .build()
⚠️ 重要提醒: 一定要配置至少两个哈希值!一个是当前证书,一个是备用证书。我曾经见过有人只配了一个,结果证书更换时App直接崩了。备用证书可以是你的根证书或者中间证书的公钥。

2.3 调试与发布分离

调试阶段和发布阶段的证书锁定策略应该不同。调试时你可能需要抓包,锁死了反而麻烦。

// 调试模式:不锁定,方便抓包
// 发布模式:严格锁定
val certificatePinner = if (BuildConfig.DEBUG) {
    CertificatePinner.Builder().build() // 空锁,不校验
} else {
    CertificatePinner.Builder()
        .add("yourdomain.com", "sha256/生产环境的哈希值")
        .build()
}
💡 小技巧: 我习惯把证书哈希值放在BuildConfig字段里,或者用NDK加密后存到native层。别硬编码在Java/Kotlin代码里,反编译一下就全暴露了。

三、防止中间人攻击的完整方案

中间人攻击(MITM)是网络层最大的威胁之一。攻击者伪装成服务器,你的App把数据发给它,它再转发给真实服务器。整个过程你完全不知情。

3.1 多层防护策略

  • 证书锁定:上面已经讲过了,这是第一道防线
  • Hostname验证:确保连接的主机名和证书中的一致
  • SSL/TLS版本限制:禁用不安全的TLS 1.0/1.1,只允许TLS 1.2+
  • 加密协议协商:使用强密码套件,禁用RC4、DES等弱加密

3.2 OkHttp的安全配置

val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .hostnameVerifier { hostname, session ->
        // 自定义Hostname验证
        // 默认实现已经够用,除非你有特殊需求
        HttpsURLConnection.getDefaultHostnameVerifier()
            .verify(hostname, session)
    }
    .connectionSpecs(listOf(
        ConnectionSpec.MODERN_TLS, // 只使用现代TLS配置
        ConnectionSpec.COMPATIBLE_TLS // 兼容模式
    ))
    .build()

核心要点: 不要自己实现HostnameVerifier返回true!我见过有人图省事直接写return true,结果整个HTTPS防护形同虚设。除非你清楚自己在做什么,否则用默认实现就好。

四、敏感信息加密

网络传输加密了,但数据到了客户端怎么办?SharedPreferences明文存储?SQLite不加密?这些都是安全隐患。

4.1 本地存储加密

Android提供了EncryptedSharedPreferences和EncryptedFile,基于Android Keystore系统,密钥由硬件安全模块保护。

// 使用EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val sharedPreferences = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

// 存储敏感信息
sharedPreferences.edit()
    .putString("access_token", "your_token_here")
    .apply()

4.2 网络请求中的敏感数据加密

有些数据即使走HTTPS,我也建议额外加密。比如用户的身份证号、银行卡号。为什么?因为HTTPS只保护传输过程,不保护服务器端。万一服务器被拖库,至少数据是加密的。

// 使用AES-GCM对敏感字段加密
fun encryptSensitiveData(plainText: String, secretKey: SecretKey): String {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, secretKey)
    val iv = cipher.iv
    val encrypted = cipher.doFinal(plainText.toByteArray())
    
    // 将IV和密文拼接,方便解密
    val combined = ByteArray(iv.size + encrypted.size)
    System.arraycopy(iv, 0, combined, 0, iv.size)
    System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size)
    
    return Base64.encodeToString(combined, Base64.NO_WRAP)
}

// 在Retrofit的Converter或Interceptor中调用
class EncryptionInterceptor(
    private val secretKey: SecretKey
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val body = request.body
        
        // 对请求体中的敏感字段加密
        // 具体实现取决于你的数据格式
        
        return chain.proceed(request)
    }
}
⚠️ 密钥管理: 密钥不要硬编码!用Android Keystore或者远程配置服务下发。我曾经见过有人把密钥写在GitHub上公开的Demo项目里,那场面...嗯,不提了。

五、知识体系总览

下面这张图总结了网络层安全的核心逻辑,从传输层到存储层,每一层都不能漏。

Android网络层安全实践 传输层安全 HTTPS + TLS 1.2/1.3 + 强密码套件 证书验证层 证书锁定(公钥/证书/Hash) + Hostname验证 请求数据加密层 AES-GCM对敏感字段加密 + Interceptor统一处理 本地存储加密(EncryptedSharedPreferences / SQLCipher)

六、避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路。

  • 不要信任用户输入的任何证书:有些App允许用户导入自签名证书,这等于把安全大门打开了。除非是内部企业应用,否则别这么做。
  • 证书锁定要留后路:我建议至少锁定3个哈希值——当前证书、备用证书、根证书。万一主证书出问题,还有回旋余地。
  • 定期更新锁定配置:证书会过期,公钥也可能变更。我习惯在App启动时检查远程配置,如果服务器端更新了证书,客户端能及时同步。
  • 日志里别打印敏感信息:调试时打印请求体很常见,但记得在Release包中关闭。我曾经见过生产环境的日志里明文打印了用户密码,那画面太美不敢看。
📌 我的建议: 安全不是一次性工作,而是一个持续的过程。每次App更新时,都重新审视一下网络层的安全配置。特别是证书锁定,别忘了更新哈希值。

好了,这一章的内容就到这里。网络层安全说难不难,说简单也不简单。关键是要有「纵深防御」的意识——不要依赖单一防护手段,每一层都加固,才能真正挡住攻击者。

公众号:蓝海资料掘金营,微信deep3321