20、与服务器端结合:生成挑战值(challenge)、签名验证、安全传输
好,咱们终于聊到虹膜识别真正上战场的地方了——跟服务器打交道。
说实话,手机本地做虹膜识别其实不难,难的是怎么把识别结果安全地告诉服务器,并且让服务器相信这个结果是真的。我见过不少项目,本地识别做得漂漂亮亮,一到网络传输就翻车了。
为什么需要挑战值(Challenge)?
先问一个问题:如果客户端直接发「虹膜匹配成功」给服务器,服务器敢信吗?
当然不敢。万一有人抓包重放呢?万一客户端被篡改了呢?
所以我们需要一个机制——挑战值。说白了,就是服务器给客户端出一道随机的「考题」,客户端必须用虹膜识别结果加上这道考题一起签名,再发回去。
核心逻辑:挑战值是一次性的、随机的、不可预测的。每次请求都不一样。
我在项目中遇到过一种情况:有人直接把挑战值写死在客户端代码里。嗯,那跟没做没啥区别。挑战值必须由服务器实时生成,而且用完就废。
挑战值的生成流程
我习惯这样设计流程:
- 客户端发起请求:告诉服务器「我要做虹膜识别」
- 服务器生成挑战值:一个 32 字节的随机数,比如用 SecureRandom
- 服务器返回挑战值:同时把挑战值存到 Redis 里,设置 30 秒过期
- 客户端拿到挑战值:传给虹膜识别模块
- 虹膜识别成功后:用私钥对「挑战值 + 识别结果」签名
- 客户端把签名发回服务器:服务器验签通过,才算真正认证成功
签名验证:到底签什么?
很多人搞不清楚签名到底要签哪些数据。我直接说结论:签名的内容 = 挑战值 + 虹膜特征哈希 + 时间戳。
为什么加时间戳?防止签名被截获后在一定窗口期内重放。为什么加特征哈希?确保签名跟本次识别结果绑定。
我的习惯:签名前把所有字段按字典序拼接,避免字段顺序不一致导致签名失败。这个坑我踩过,当时排查了一下午才发现是字段顺序问题。
代码示例:Android 端签名
// 生成签名
fun generateSignature(challenge: ByteArray, irisHash: ByteArray): ByteArray {
val timestamp = System.currentTimeMillis().toString().toByteArray()
// 按字典序拼接
val dataToSign = challenge + irisHash + timestamp
// 使用 Android Keystore 中的私钥签名
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey)
signature.update(dataToSign)
return signature.sign()
}
服务器端验签
服务器收到签名后,需要做这几件事:
- 检查挑战值是否有效:去 Redis 查一下,有没有这个挑战值,过期了没
- 检查时间戳:跟服务器当前时间比,偏差超过 30 秒的直接拒绝
- 验签:用客户端公钥验证签名
- 比对虹膜特征:把签名里的特征哈希跟数据库里存的比对
注意:千万不要在服务器端存储完整的虹膜图像!只存特征哈希。我曾经见过一个项目把虹膜图片直接存数据库,这要是泄露了,用户连换密码的机会都没有。
安全传输:HTTPS 还不够吗?
够,但不够彻底。
HTTPS 能防中间人攻击,但防不了客户端被篡改。你想想看,如果攻击者逆向你的 App,把签名逻辑改了,HTTPS 也拦不住。
所以真正的安全传输是端到端的:
| 传输层 | 防护目标 | 实现方式 |
|---|---|---|
| 网络层 | 防窃听、防篡改 | HTTPS + 证书锁定 |
| 应用层 | 防重放、防伪造 | 挑战值 + 签名 |
| 数据层 | 防泄露 | 敏感数据加密存储 |
我个人建议三层都做,别偷懒。尤其是证书锁定,很多开发者嫌麻烦就不做,结果被中间人攻击搞得很惨。
避坑指南
我这些年踩过的坑,列几个典型的:
- 挑战值过期时间设太长:我曾经设了 5 分钟,结果被人利用窗口期重放攻击。现在一律 30 秒。
- 签名算法选错:ECDSA 比 RSA 快,但有些老旧服务器不支持。提前确认好。
- 公钥分发不安全:第一次注册时就把公钥传上去,但怎么保证公钥没被篡改?建议用证书链。
- 日志里打印了签名数据:嗯,这个我都不好意思说,线上环境千万别打敏感日志。
总结一句话:挑战值解决「是不是本人操作」,签名解决「数据有没有被改」,HTTPS 解决「路上有没有人偷看」。三者缺一不可。
好了,这一章的内容就到这儿。记住,安全没有银弹,每一层防护都是在增加攻击者的成本。做到让攻击者觉得「划不来」,你就赢了。
公众号:蓝海资料掘金营,微信deep3321