
小程序上线前的那次安全评审我印象很深。评审的人拿到安装包用解包工具不到三分钟就把 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 加解密失败时的排查链路线上出现数据解密失败时最忌讳的是直接把错误吞掉当作空数据返回那会把问题掩盖到无法复现。我一般保留一条最小化的排查链路按顺序确认密钥版本是否匹配客户端的 DEK 和服务端的 KEK 版本对不对得上尤其是灰度发布期间新旧版本并存的时候。密文前缀是否被正确处理enc:v1:这种前缀有没有在传输过程中被截断或转义。编码链路是否一致加密时输入是字节还是字符串解密时是否用了同样的编码。IV 是否随密文一起传递漏传 IV 是最常见的原因表现是所有密文都解不开。是否存在部分密文传输截断大字段在网关层被截断表现是偶发失败且失败的总是长内容。线上环境我保留一条脱敏后的错误日志只记录 key 名、密文长度、密钥版本号绝不记录密文原文和明文。这条日志在排查时非常管用——密文长度异常基本能定位到截断密钥版本不符能定位到轮换逻辑。最后分享一个小技巧给加解密模块写一组自测用例固定输入固定输出每次构建自动跑。AES 的结果在相同密钥和 IV 下是确定的所以可以做断言。这个自测在换库、升级基础库、调整编码时能第一时间发现问题比等到线上报错强太多。我在一次基础库升级后就是靠它发现某个库的行为变了提前两天修掉避免了一次线上事故。