第三十课:综合实战——构建一个安全的 Android 即时通讯 App
终于到了最后一章。说实话,写到这里我有点感慨。前面二十九章我们聊了证书链、TLS 握手、双向认证、证书锁定、ALPN、协议降级攻击…… 零零碎碎的知识点,今天我们要把它们全部串起来。
这一章,我会带你手搭一个即时通讯 App 的安全通信模块。不是 Demo,是真正能上线的架构。我在项目中踩过的坑,也会一并告诉你。
整体架构:安全通信的四层防线
先看整体设计。一个 IM App 的安全通信,不能只靠 TLS。我习惯把它拆成四层:
四层安全防线
- 传输层:TLS 1.3 + 证书锁定 + ALPN
- 会话层:端到端加密密钥协商(X3DH)
- 消息层:每条消息独立加密(AES-256-GCM)
- 应用层:防重放、防篡改、消息顺序校验
为什么要分四层?你想想看,如果只靠 TLS,服务端是可以看到明文消息的。对于 IM 来说,这显然不够。端到端加密必须自己做。
第一步:传输层——TLS 1.3 + 证书锁定
传输层是一切的基础。我个人习惯用 OkHttp 做网络层,配合自定义的 TrustManager 做证书锁定。注意,这里锁定的是公钥哈希,不是证书本身。为什么?因为证书会过期,公钥不会。
// 证书锁定——锁定服务端公钥哈希
class PinningTrustManager : X509ExtendedTrustManager() {
private val expectedPin = "sha256/47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?, socket: SSLSocket?) {
val cert = chain?.getOrNull(0) ?: throw SSLException("No certificate")
val publicKey = cert.publicKey.encoded
val digest = MessageDigest.getInstance("SHA-256").digest(publicKey)
val pin = Base64.encodeToString(digest, Base64.NO_WRAP)
if (pin != expectedPin) {
throw SSLException("Certificate pin mismatch")
}
}
// 其他方法省略...
}
我曾经踩过的坑:有一次线上事故,服务端换了硬件安全模块,公钥变了,但运维没通知客户端团队。结果所有用户连接失败。从那以后,我要求运维每次变更证书前,必须提前一周通知,并且客户端要保留旧 pin 做灰度过渡。
第二步:会话层——X3DH 密钥协商
传输层搞定后,接下来是端到端加密。我推荐 Signal 协议里的 X3DH(扩展的三方 Diffie-Hellman)。说白了,就是让两个用户在不安全的信道上,协商出一个只有他俩知道的共享密钥。
流程大致是这样:
- 每个用户注册时,上传自己的身份公钥、签名预密钥、一批一次性预密钥
- Alice 想给 Bob 发消息,先拉取 Bob 的密钥包
- Alice 用自己的身份密钥 + Bob 的身份密钥 + Bob 的签名预密钥 + 一个一次性预密钥,做四次 DH 运算
- 最终得到一个 32 字节的共享根密钥
// X3DH 密钥协商——简化示例
fun x3dhKeyExchange(
aliceIdentity: KeyPair,
bobIdentityKey: PublicKey,
bobSignedPreKey: PublicKey,
bobOneTimePreKey: PublicKey?
): ByteArray {
val dh1 = KeyAgreement.dh(aliceIdentity.private, bobIdentityKey)
val dh2 = KeyAgreement.dh(aliceIdentity.private, bobSignedPreKey)
val dh3 = KeyAgreement.dh(aliceIdentity.private, bobOneTimePreKey ?: return byteArrayOf())
// 将三个 DH 结果拼接后做 HKDF 派生
val sharedSecret = dh1 + dh2 + dh3
return HKDF.derive(sharedSecret, "X3DH_Shared_Secret".toByteArray(), 32)
}
一个小技巧:一次性预密钥用完后要及时从服务端删除。我见过有 App 忘了清理,导致同一个预密钥被重复使用,前向安全性直接归零。
第三步:消息层——AES-256-GCM 加密
会话密钥协商好了,接下来每条消息都要独立加密。我选 AES-256-GCM,因为它同时提供加密和完整性校验,而且支持随机 Nonce,不需要填充。
// 消息加密——每条消息使用随机 Nonce
fun encryptMessage(plaintext: ByteArray, sessionKey: ByteArray): EncryptedMessage {
val nonce = SecureRandom().nextBytes(12) // GCM 推荐 12 字节 Nonce
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, nonce)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sessionKey, "AES"), spec)
val ciphertext = cipher.doFinal(plaintext)
return EncryptedMessage(nonce, ciphertext)
}
这里要注意,Nonce 绝对不能重复。一旦重复,GCM 的认证标签就失效了。我在项目中用了一个计数器 + 随机数的组合,确保即使在同一毫秒内发送多条消息,Nonce 也不会碰撞。
第四步:应用层——防重放与消息顺序
加密只是第一步。攻击者虽然看不懂消息内容,但他可以把消息截获后重新发送。这就是重放攻击。
怎么防?我给每条消息加一个递增的序号,接收方维护一个滑动窗口,只接受序号大于窗口起始值的消息。同时,每条消息附带一个 HMAC,用会话密钥计算,防止篡改。
// 消息结构——包含序号和 HMAC
data class SecureMessage(
val sequenceNumber: Long,
val ciphertext: ByteArray,
val nonce: ByteArray,
val hmac: ByteArray
)
fun verifyMessage(msg: SecureMessage, sessionKey: ByteArray): Boolean {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(sessionKey, "HmacSHA256"))
mac.update(msg.sequenceNumber.toByteArray())
mac.update(msg.ciphertext)
mac.update(msg.nonce)
val expectedHmac = mac.doFinal()
return MessageDigest.isEqual(expectedHmac, msg.hmac)
}
核心要点总结:
- 传输层用 TLS 1.3 + 证书锁定,防中间人
- 会话层用 X3DH 协商密钥,保证前向安全
- 消息层用 AES-256-GCM 独立加密,每条消息随机 Nonce
- 应用层加序号和 HMAC,防重放防篡改
性能与安全的平衡
你可能会问:这么多层加密,性能扛得住吗?嗯,这里要注意。TLS 1.3 的握手只需要 1-RTT,X3DH 只在首次会话时做一次,后续的消息加密用的是对称加密,速度很快。我在项目中实测,AES-256-GCM 在手机上加密一条 1KB 的消息,耗时不到 0.1 毫秒。
真正耗性能的是密钥协商时的非对称运算。所以我的做法是:App 启动时预加载一批一次性预密钥,避免每次发消息都去服务端拉取。
写在最后
三十章的内容,从证书链到 TLS 握手,从双向认证到证书锁定,再到今天的综合实战。说实话,安全通信这条路没有终点。攻击者在进化,我们的防护手段也要跟着进化。
但我希望这套架构能给你一个扎实的起点。下次有人问你「Android 安全通信怎么做」,你可以自信地告诉他:四层防线,缺一不可。
公众号:蓝海资料掘金营,微信deep3321