ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序数据加密实战:密钥分层、HMAC防重放与存储加固

微信小程序数据加密实战:密钥分层、HMAC防重放与存储加固

小程序上线前的那次安全评审,我印象很深。评审的人拿到安装包,用解包工具不到三分钟就把 wxapkg 拆成了可读的目录结构,页面逻辑、接口地址、写死在代码里的 appkey 全都摊在屏幕上;接着挂上抓包工具,登录接口的请求体里手机号和身份标识是干干净净的明文。整个过程没有任何"高深"的技术,全是常规手段的组合。这件事之后我把手上几个小程序的加密方案整体重做了一遍,也踩了不少坑——比如密钥无处安放、加了密反而把首屏拖慢、服务端和端侧签名串拼接不一致导致线上全量 401。这篇就把微信小程序开发中数据加密与保护这一套东西,按真实落地顺序拆开讲清楚:哪些数据真的需要保护、加密放在哪一层、密钥怎么管、签名怎么防重放、代码怎么防逆向,以及那些文档里不会写、只有真机跑过才知道的细节。适合已经写过小程序但没系统做过安全加固的开发者,也适合想给自己项目补一课的独立开发者。

1. 先搞清楚对手能拿到什么:小程序端的数据暴露面盘点

做加密之前不做资产盘点,几乎必然走向两个极端:要么什么都不加密,要么把所有字段都塞进 AES 里,结果性能掉了、调试废了、安全性一点没涨。我一般的做法是先坐下来列一张表,问自己三件事:这段数据如果被完整看到,会造成什么后果?它以什么形式存在于客户端?看到它的成本有多高?答案不同,处理方式完全不同。加密不是"越多越好",而是"按暴露面分级投入"。

1.1 一个 wxapkg 包里到底有什么

很多人对小程序包的安全性有一种误解,觉得"小程序是跑在平台里的,代码看不到"。实际上小程序的包体本质上是一个压缩打包文件,解包之后你能拿到app-service.js(业务逻辑)、page-frame.html及各类模板、app.json里的页面路由、静态资源,以及构建过程中被内联进去的各种常量。只要你的业务逻辑里出现了const APP_SECRET = "xxxx",它就在app-service.js里原样躺着,搜索关键字就能定位。

我实际拆过自己的包,最常见的泄露物有这么几类:写死的第三方服务密钥、写死的加盐值、测试环境的接口域名和账号、注释里没删干净的调试信息、以及被console.log打印出来的完整用户对象。它们的共同点是——藏在业务代码里,开发时觉得很方便,上线后就是敞开的门。

判断标准很简单:任何出现在小程序代码里的常量,都等同于公开信息。不要把"客户端"当成可信执行环境。

所以第一层防护其实不是加密,而是把不该放在客户端的秘密移走。第三方服务的调用一律走自己的服务端中转,由服务端持有密钥并做调用频次限制;加盐、签名密钥这类东西由服务端动态下发,不下发就取不到;测试环境的配置用构建变量注入,生产构建时直接剔除。

1.2 抓包与接口重放:比逆向更常见的攻击路径

逆向解包虽然直观,但门槛在"读得懂代码";而抓包重放几乎零门槛。把小程序的网络代理到本地工具上,但凡请求走的是明文 HTTP,或者虽然走了 HTTPS 但客户端不校验、服务端不做签名,那么登录接口、下单接口、查询接口的参数结构就一览无余。

重放攻击的实际危害比想象中大。我遇到过一个典型的场景:某个接口用来领取每日奖励,参数就是userId加activityId,只靠登录态 token 鉴权。只要把这一条请求复制下来,用一个脚本定时重放,就能刷出远超规则的数量。后来修补方案是给请求加了时间戳和一次性随机串,服务端维护一个短周期的去重集合,重放立刻失效。

这里要区分清楚两个概念:加密解决的是"看到内容"的问题,签名解决的是"伪造与重放"的问题。很多团队只做了前者,参数是密文了,但密文原封不动重发一次依然有效,因为服务端只校验解密成功与否,不校验这条请求是不是新的。两件事必须一起做。

1.3 本地缓存和日志:最容易被忽略的明文出口

wx.setStorageSync写进去的内容是明文的,这一点在开发者工具里看不到直观提示。只要有人拿到设备(哪怕是借用几分钟),或者通过某些调试手段读取到本地存储,缓存里的收件地址、手机号、历史订单、甚至完整的登录凭证都会暴露。日志同理:真机调试时的 vConsole、上报到监控平台的错误堆栈,经常连带把整个响应体带上去。

我一个比较笨但有效的自查方法:在上线前,把Object.keys(wx.getStorageInfoSync().keys)打印一遍,逐条问"这一项被看到会怎样";再把console.log全局搜索一遍,看有没有打印完整对象的地方。这两步花不了半小时,能捞出至少三四个问题。

把暴露面盘完,接下来才是分层处理:传输层做什么、存储层做什么、业务层做什么。下面按这个顺序展开。

2. 传输层不是打开 HTTPS 就完事

小程序默认要求业务域名使用 HTTPS,很多人就此认为传输安全已经解决了。这个认知只对了一半——HTTPS 保证了链路上的机密性和完整性,但它不保证请求的合法性和接口的调用顺序,也不防止有人在自己的设备上用调试手段拿到明文请求体(因为 TLS 是在客户端解密的)。传输层的加固目标应该是:让每条请求都携带"只有合法客户端才能生成"的凭据,并且这条凭据只能用一次。

2.1 域名白名单与请求收敛的真实操作

小程序要求把请求域名配置到后台白名单里,这个机制经常被当成"麻烦的流程",其实它是一个免费的收窄面:一旦白名单确定,客户端就不可能悄悄请求一个域外地址。实操上有两点值得注意。

第一,把请求入口收敛到单一模块。我所有项目里只允许存在一个request封装函数,业务层不许直接调用wx.request。这样加解密、签名、重试、错误码统一处理都只需要改这一处。有次接手一个项目,wx.request散落在四十多个文件里,加一个请求头花了整整两天。

第二,白名单只放必要的域名,并且区分上传、下载、socket。上传域名和下载域名是独立配置的,有些团队图省事把同一个域全配上,实际上没必要。请求域名越少,出问题时排查范围越小。

// utils/request.js —— 全局唯一出口 const BASE = 'https://api.example.com'; function buildHeaders(payloadStr) { const ts = Date.now(); const nonce = randomHex(16); return { 'content-type': 'application/json', 'x-ts': String(ts), 'x-nonce': nonce, 'x-sign': hmacSign(`${ts}\n${nonce}\n${payloadStr}`), }; } export function request({ url, method = 'GET', data = {} }) { const payloadStr = method === 'GET' ? '' : JSON.stringify(data); return new Promise((resolve, reject) => { wx.request({ url: BASE + url, method, data, header: buildHeaders(payloadStr), timeout: 10000, success(res) { if (res.statusCode === 200) resolve(res.data); else reject(new Error(`HTTP ${res.statusCode}`)); }, fail: reject, }); }); }

注意 GET 请求的签名串不要拼 query,因为参数被序列化成字符串的方式在不同基础库版本上不完全一致,很容易造成"客户端算出来和服务端算出来不一样"。GET 只签时间戳和随机串,业务参数靠登录态鉴权兜底。

2.2 请求体加密要不要做,什么时候不该做

这是被问得最多的一个问题。我的答案是有条件地做。判断依据是:这个接口的请求体或响应体里,是否存在"一旦被抓包看到就会造成实质损失"的字段。

典型的应该加密:实名信息、证件号、银行卡号、门禁或钥匙类凭据、医疗与健康数据、企业内部的业务单据明细。典型的不建议加密:普通的商品列表、文章内容、公开的配置项。给这些接口上加密,收益接近零,但会带来三个实际成本——服务端多一次加解密运算、客户端首屏多几十毫秒、排查线上问题时日志全是密文没法看。

我的折中方案是字段级加密,而不是整个报文加密。只把敏感字段替换成密文,其余字段保持明文可读。

{ "orderId": "20240101001", "receiver": "enc:v1:U2FsdGVkX1+...", "phone": "enc:v1:U2FsdGVkX1+...", "amount": 199 }

前缀enc:v1:的作用是版本标记,将来换算法或者换密钥,服务端按前缀分流即可,不用做全量数据迁移。这个小小的设计在后面救过我一次——某次密钥轮换,老数据的密钥已经被销毁,正是靠版本标记识别出哪些是历史密文,走了单独的补解密流程。

2.3 把 token 放在哪、怎么带

登录态 token 的存放位置有三个坑我踩过。

第一个坑是明文存 localStorage。前面说过本地存储是明文的,token 一旦泄露,等于账号被接管。我的做法是至少做一次对称加密再落盘,密钥不落在本地(详见第 3 节)。

第二个坑是token 放在 URL query 上。URL 会被完整记录进各种访问日志、代理日志,一旦某个环节的日志泄露,token 就跟着泄露。统一放 Header,这一点没有例外。

第三个坑是长期不过期。有些项目 token 有效期设成三十天,而且不支持主动失效。合理做法是 access token 短有效期(比如两小时),配一个较长的 refresh token,刷新时轮换。即使 access token 泄露,窗口期也有限。

3. 端侧存储加密:密钥放哪儿这个死结怎么解

这是整个端侧加密中最难、也最容易被糊弄过去的环节。目标很明确:本地缓存要加密,防止被直接读取。但新的问题立刻出现——解密用的密钥也存在客户端,那把密钥加密一下?加密密钥的密钥又存哪儿?这是个无限递归。很多团队在这个递归里绕一圈,最后写了个const KEY = "1234567890abcdef"硬编码在代码里,等于把锁和钥匙一起塞进了同一个盒子。

3.1 wx.setStorageSync 的明文困境与加密封装

先说结论:本地存储加密解决的不是"对抗拥有设备且具备调试能力的攻击者",而是"对抗随手可见的明文暴露"这个量级。认清这一点很重要,它决定了你该投入多少成本。一个能调试你小程序的人,理论上能拿到你运行时内存里的一切,包括解密后的明文和当时使用的会话密钥。加密能提高门槛,但不能做到绝对安全。

在这个前提下,加密封装的价值就体现出来了:它让批量导出用户数据变得困难,让本地文件被随手翻到时看不到有用信息,也让误把缓存文件打包进问题反馈的可能降到最低。

我一般的封装长这样:数据先JSON.stringify,再用 AES-GCM 加密(GCM 自带完整性校验,能防篡改,比 CBC 省掉一套 MAC 逻辑),最后把初始化向量、密文一起 base64 存进去。

3.2 密钥分层:服务端下发临时密钥的完整流程

解法的核心是让密钥不长期驻留在客户端。我用的是三层结构:

层级名称存放位置生命周期用途
L1主密钥 KEK只在服务端长期,定期轮换加密 L2,永不外发
L2会话密钥 DEK客户端内存 + 加密后落盘单次会话,随登录刷新加解密本地缓存
L3传输密钥仅内存单次请求敏感字段加密

流程上:登录成功后,服务端用 KEK 加密一份随机生成的 DEK,随登录响应下发;客户端收到后解密得到 DEK,存在内存变量里,同时把密文版本存到本地存储。下次启动时,先用本地存的那份"加密的 DEK"去换一个当前可用的 DEK(服务端校验登录态后重新下发)。这样 DEK 的明文只在内存中短暂停留,不会以明文形式落盘。

这么做有一个必须处理的问题:冷启动时内存里的 DEK 没了,本地缓存怎么读?我的处理是冷启动先走一次静默续期拿回 DEK,再读缓存;拿不到就把缓存视为无效,直接清掉重新拉数据。牺牲一点首屏速度,换取"缓存无法被离线解出明文"。

另外,DEK 一定要做轮换。我的做法是每次登录生成新的 DEK,旧的 DEK 服务端保留一段时间用于解密历史数据,同时后台任务把历史密文逐步重加密成新密钥的密文。不做轮换的话,密钥一旦泄露,所有历史数据全部可解。

3.3 一套可复用的存储加密封装(附代码)

下面是压缩过的实现,核心是用Buffer的等价操作在小程序里做 ArrayBuffer 与 base64 的互转。注意小程序里没有 Node 的 Buffer 和 Web Crypto,需要借助第三方库。

// utils/secure-storage.js import CryptoJS from 'crypto-js'; let sessionKey = null; // 明文 DEK,只存在于内存 let sessionKeyEncrypted = null; // 加密后的 DEK,可落盘 export function setSessionKey(dekHex) { sessionKey = dekHex; // 用平台下发的包装密钥再包一层,避免明文落盘 sessionKeyEncrypted = CryptoJS.AES.encrypt( dekHex, getWrapKey(), { mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ).toString(); wx.setStorageSync('__sk', sessionKeyEncrypted); } function getWrapKey() { // 由服务端在登录时下发,与设备标识绑定,不单独落盘 return wx.getStorageSync('__wk') || ''; } export function secureSet(key, value) { if (!sessionKey) throw new Error('session key not ready'); const iv = CryptoJS.lib.WordArray.random(12); const encrypted = CryptoJS.AES.encrypt(JSON.stringify(value), CryptoJS.enc.Hex.parse(sessionKey), { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7, }); wx.setStorageSync('s_' + key, { v: 1, iv: iv.toString(CryptoJS.enc.Base64), data: encrypted.ciphertext.toString(CryptoJS.enc.Base64), }); } export function secureGet(key, fallback = null) { const rec = wx.getStorageSync('s_' + key); if (!rec || !rec.data || !sessionKey) return fallback; try { const decrypted = CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(rec.data) }, CryptoJS.enc.Hex.parse(sessionKey), { iv: CryptoJS.enc.Base64.parse(rec.iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8)); } catch (e) { // 解不开就当作脏数据清掉,绝不让异常冒泡到页面 wx.removeStorageSync('s_' + key); return fallback; } }

有两个细节值得单独说。第一,解不开时务必静默清理。我见过线上因为一条历史缓存解密失败抛异常,导致整个首页白屏的案例,排查花了很久才发现是数据格式变更后没做兼容。第二,随机 IV 必须每次不同。用固定 IV 等于给相同明文生成相同密文,攻击者能据此推断出数据是否变化,CBC 模式下还会带来更严重的信息泄露。这一点上没有妥协空间。

4. 接口防重放与签名:用 HMAC 堵住伪造请求

加密保证了"看不懂",签名保证了"伪造不了"。这两个能力互补,缺一个防护就是不完整的。签名机制的设计难点不在于算法,HMAC-SHA256 足够用,难点在于签名串的拼接规则必须客户端和服务端逐字节一致,以及密钥既要能校验又不能落在客户端。

4.1 签名串怎么拼才不容易被绕过

我见过失败的签名实现,大多数栽在这几个地方:

  • 只签业务参数,不签时间戳和随机串 —— 攻击者可以原样重放,签名依然有效。
  • 参数字典序排序后拼接,但客户端和服务端对空值、数字类型的处理不一致 —— 导致偶发校验失败,最后被注释掉。
  • 签名串里用&连接,而参数值里可能包含&—— 存在拼接歧义,理论上可以构造出不同的参数组合得到同一个签名串。

我的做法是用换行符\n作为分隔符,并且对每个部分做长度前缀或者做一次整体哈希。参数值统一先JSON.stringify成字符串再参与签名,避免类型不一致。

function hmacSign(raw) { const secret = getSignSecret(); // 服务端下发,与登录态绑定 return CryptoJS.HmacSHA256(raw, secret).toString(CryptoJS.enc.Hex); }

签名密钥的安排上,我倾向于每次登录动态下发一个与当前会话绑定的签名密钥,而不是写死一个全局密钥。这样即使某个用户的密钥泄露,影响范围也只限于这个会话;注销或换设备后,旧密钥立即失效。

4.2 nonce 与时间窗的服务端校验

客户端的签名只是"声明",真正的判定在服务端。一个完整的校验链路包含四步,任何一步失败都应该直接拒绝,并且返回统一的错误码,不要告诉调用方具体是哪一步失败——那等于给攻击者做调试。

校验步骤判断内容常见失败原因
时间窗请求时间戳与服务端时间差是否在 ±300 秒内用户设备时间被手动修改
重放nonce 是否在有效期内被使用过网络重试导致同一 nonce 发送两次
签名重算的 HMAC 是否与请求头一致参与签名的字段有差异
权限当前登录态是否有权访问该资源token 过期或越权访问

时间窗设置需要权衡:太短(比如 60 秒)会让设备时间不准的用户频繁失败,太长(比如 30 分钟)等于放大了重放窗口。300 秒是我试下来比较稳妥的取值。nonce 的去重集合用带过期时间的缓存实现,过期时间略大于时间窗即可,避免无限增长。

关于网络重试:客户端的自动重试逻辑千万不要复用同一个 nonce,否则会被自己的服务端判定为重放。每次重试重新生成时间戳和 nonce,这是很容易忽略的一个细节。

4.3 签名密钥的轮换与降级

密钥轮换必须做成可平滑过渡的状态,不能"一刀切"。我的实现是服务端同时接受当前密钥和上一个密钥(有效期一天),客户端在登录响应里拿到新密钥后立即切换。这样即使某台设备长时间没登录,用旧密钥发的请求也不会立刻全部失败。

还有一种降级情况要处理:服务端下发的签名密钥为空。这种情况可能发生在接口异常、灰度阶段配置缺失等场景。此时应该让请求走"仅登录态鉴权"的降级路径,同时在客户端打点上报。我见过一个反面案例,密钥下发失败后客户端直接拒绝所有请求,导致整个功能不可用,用户只看到一直转圈。安全机制失效时,宁可降低强度也要保证可用性,前提是有监控能及时发现。

5. 敏感数据落地前的最后一道处理:脱敏与最小化

加密是"存储态"的保护,但数据在内存里、在视图里、在日志里、在分享卡片里都是明文。这一层的原则是能不拿就不拿,拿了就尽快丢,必须显示就脱敏。听起来很朴素,但落地时能解决的问题比加密还多。

5.1 展示层脱敏与内存中的临时明文

展示脱敏的规则要提前和服务端约定,别让客户端各写一套。手机号保留前三后四、证件号保留前四后四、姓名保留首字,这些是常见约定。我倾向于由服务端直接返回脱敏后的字符串,客户端根本拿不到完整值。只有在编辑场景(比如用户要修改收件地址)才单独请求一次完整值,用完立刻从页面数据里清掉。

页面数据的清理要具体到代码:在onUnload或表单提交成功后,把this.setData里对应的字段置空。不清理的话,这个明文会一直留在页面的 data 对象里,通过调试工具能直接看到,也会被某些数据上报逻辑顺带带出去。

另外提醒一个容易忽略的点:setData的数据量有上限,把一整条含敏感信息的记录塞进 data 里既浪费性能又增加暴露面,只传视图需要的字段就够了。

5.2 日志、错误上报、埋点里的隐性泄露

这三个地方是明文泄露的高发区,因为它们的初衷都是"尽量多记信息方便排查",而安全问题从来没被纳入过考量。

我一般加一个统一的日志脱敏函数,对常见字段名做匹配替换:

const SENSITIVE_KEYS = ['phone', 'mobile', 'idCard', 'bankCard', 'address', 'realName', 'token', 'password']; function maskLog(obj) { if (obj == null) return obj; if (Array.isArray(obj)) return obj.map(maskLog); if (typeof obj !== 'object') return obj; const out = {}; for (const k of Object.keys(obj)) { if (SENSITIVE_KEYS.includes(k)) { out[k] = '***'; } else if (typeof obj[k] === 'object') { out[k] = maskLog(obj[k]); } else { out[k] = obj[k]; } } return out; }

错误上报这一块要特别小心,因为很多监控 SDK 默认会上报完整的请求参数和响应体。接入第三方监控时,把"上报请求体"这个开关关掉,或者配置好过滤字段。我吃过一次亏:一个未捕获异常把整个用户对象带进了堆栈上下文,上报后储存在监控平台的日志里,属于典型的数据外流。

埋点同理,不要为了"以后可能有用"就把整个对象快照打上去。只打你明确知道要分析的那几个字段。

5.3 剪贴板、截图与分享卡片

wx.setClipboardData设置的内容在整个系统里可见,任何应用都能读。所以不要把敏感信息整段复制到剪贴板,如果是分享类的场景,只复制短链或口令,不能包含身份凭证。

分享卡片的imageUrl和path也会带上参数,path里的 query 在分享链路中是可见的。如果有需要传递的参数,做一次编码或只传一个短 ID,让接收方回源获取详情。

截图这一层小程序侧能做的有限,主要是避免在敏感页面出现"可被完整截图的高价值信息",比如完整的证件号、完整的银行卡号。这在社区类、金融类小程序里是硬要求。

6. 代码保护与工程化:混淆、分包与发布检查清单

前面几节做的都是数据层防护,但如果攻击者能直接读到你的签名算法、密钥下发流程和接口路径,很多防护会被绕过。代码保护的目的不是让逆向变得不可能,而是提高成本到不值得投入。

6.1 sourcemap 与调试开关的关闭

这条看起来是最基础的,但我见过不止一次生产包里带着 sourcemap,或者debug开关硬编码成true。发布前必须确认几件事:

  • 上传代码时不要勾选不必要的内容,构建产物里不包含 sourcemap 文件;
  • 全局搜索console.log、debugger,生产构建时通过构建工具自动移除;
  • 检查app.json里是否开启了不必要的调试配置;
  • 确认接口域名指向生产环境,测试环境的地址和账号不出现在包里。

国内几家云厂商的构建流水线都支持"生产构建剔除调试代码",配一次长期受益。我更建议把这个检查固化成脚本,构建后自动扫描包体里是否包含敏感关键字,扫到就中断发布。人肉检查一定会漏。

6.2 上线前的自查清单

下面这张表是我每次上线前都会过一遍的,比任何长篇规范都好用:

检查项具体动作不合格的典型表现
硬编码密钥全局搜索密钥关键字、第三方 SDK 配置代码里出现secret、appkey常量
存储明文遍历本地存储 key,逐条判断手机号、地址、token 明文可读
请求头检查是否所有请求走统一封装存在散落的wx.request调用
签名覆盖确认写操作接口全部启用签名只有登录接口有签名
日志泄露搜索console.log和上报配置上报完整响应体
调试残留检查 vConsole、debug 开关生产包可长按调出调试面板
域名白名单核对生产、测试域名测试域名仍在白名单

这份清单不是走过场,每一项都应该有人为结果负责。我一般把这张表贴在发布流程里,逐项打勾才能发版。

7. 踩过的坑:真机、性能与兼容性

方案设计得再完整,落不到真机上就是零。这一节记录几个我实际遇到的问题,都是文档里查不到、只能靠跑出来的经验。

7.1 crypto-js 在真机上的性能与包体积

引入 crypto-js 之后最直观的感受是包体积涨了。完整版的体积不适合塞进主包,尤其是主包已经接近限制的项目。我的处理是:只引入需要的那几个模块(aes、hmac-sha256、enc-base64),而不是整个库,体积能压下来一大截;如果加密逻辑只在个别页面用到,放到分包里按需加载。

性能方面,加解密是同步阻塞的操作,小数据量(几 KB 的 JSON)几乎无感,但如果有人试图把一个几 MB 的列表整体加密存本地,必然会卡顿。我的原则是:本地缓存只存必要的字段集合,把"整页数据"这种场景改成"只存首屏需要的那几条",既减小了体积也降低了加密耗时。实测下来,几 KB 级别的加解密在主流机型上基本感知不到,但大量小对象频繁调用secureSet会有累积开销,能合并成一次写入的就合并。

7.2 iOS 与 Android 的随机数与 Base64 差异

CryptoJS.lib.WordArray.random()在很多环境下是可用的,但如果你依赖更底层的随机源,需要注意基础库能力检测——部分低版本基础库不提供平台级的安全随机数接口,需要降级方案。降级时至少要把时间戳、设备信息和计数器混合进去,虽然强度不如真随机,但比直接用Math.random()好得多。

Base64 的差异更隐蔽。小程序里没有原生btoa/atob,需要用第三方实现,而不同实现对非 ASCII 字符的处理不一致,最容易出问题的是含中文的字符串。我的做法是:参与加解密和 base64 的一律是 UTF-8 编码的字节序列,不要在中途混入字符串操作。曾经有一次中文昵称加密后再解密出来是乱码,查了两小时才发现是加密时用了字符串、解密时按 UTF-8 解析,编码链路不一致。

还有一个真实的兼容性问题:iOS 和 Android 对ArrayBuffer转字符串的默认实现不同,某些 polyfill 在 iOS 上会把Uint8Array当成普通数组处理导致结果偏移。跨端验证时,涉及加解密的用例必须在 iOS 真机和 Android 真机上各跑一遍,开发者工具里正常不代表真机正常。

7.3 加解密失败时的排查链路

线上出现"数据解密失败"时,最忌讳的是直接把错误吞掉当作空数据返回,那会把问题掩盖到无法复现。我一般保留一条最小化的排查链路,按顺序确认:

  1. 密钥版本是否匹配:客户端的 DEK 和服务端的 KEK 版本对不对得上,尤其是灰度发布期间新旧版本并存的时候。
  2. 密文前缀是否被正确处理:enc:v1:这种前缀有没有在传输过程中被截断或转义。
  3. 编码链路是否一致:加密时输入是字节还是字符串,解密时是否用了同样的编码。
  4. IV 是否随密文一起传递:漏传 IV 是最常见的原因,表现是所有密文都解不开。
  5. 是否存在部分密文传输截断:大字段在网关层被截断,表现是偶发失败,且失败的总是长内容。

线上环境我保留一条脱敏后的错误日志:只记录 key 名、密文长度、密钥版本号,绝不记录密文原文和明文。这条日志在排查时非常管用——密文长度异常基本能定位到截断,密钥版本不符能定位到轮换逻辑。

最后分享一个小技巧:给加解密模块写一组自测用例,固定输入固定输出,每次构建自动跑。AES 的结果在相同密钥和 IV 下是确定的,所以可以做断言。这个自测在换库、升级基础库、调整编码时能第一时间发现问题,比等到线上报错强太多。我在一次基础库升级后就是靠它发现某个库的行为变了,提前两天修掉,避免了一次线上事故。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进