微信小程序 AES-GCM 加密完整解决方案
📋 背景
在开发微信小程序时,需要对敏感数据(如密码)进行 AES-GCM 加密后传输到后端。由于小程序环境的特殊性,许多 Node.js 加密库无法直接使用,需要经过多次尝试才找到最佳方案。
🔍 遇到的问题
1. crypto-js 不支持 GCM 模式
javascript
// ❌ 失败方案
import CryptoJS from 'crypto-js'
// crypto-js 在小程序中不支持 GCM 模式
问题 : crypto-js 库在小程序环境中无法使用 GCM 加密模式,会报错 Cannot read property 'createEncryptor' of undefined。
2. @noble/ciphers 导入路径问题
javascript
// ❌ 失败方案
import { gcm } from '@noble/ciphers/gcm' // 路径不存在
import { aes } from '@noble/ciphers/aes'
问题 : @noble/ciphers v2.x 不再通过 ./gcm 路径导出 GCM 模式,其 exports 中仅包含 ./aes.js 和基于 WebCrypto 的 ./webcrypto.js。
3. aes-js 不支持 GCM 模式
javascript
// ❌ 失败方案
import aesjs from 'aes-js'
const aesGcm = new aesjs.ModeOfOperation.gcm(key, iv) // gcm 是 undefined
问题 : aes-js v3.x 版本只支持 ECB、CBC、CFB 等模式,不支持 GCM 模式。
4. asmcrypto.js buffer 属性为 null
javascript
// ❌ 失败方案 - 直接使用 Uint8Array.buffer
const keyBytes = new Uint8Array(keyStr.length)
// asmcrypto.js 内部访问 key.buffer 时会报错
问题 : 在小程序环境中,某些情况下 Uint8Array 的 buffer 属性可能为 null,导致 TypeError: Cannot read property 'length' of null。
5. 小程序环境限制
- ❌ 不支持
atob()/btoa()- 需要使用wx.base64ToArrayBuffer()/wx.arrayBufferToBase64() - ❌ 不支持
TextDecoder/TextEncoder- 需要手动实现 UTF-8 编解码 - ❌ 不支持 Node.js
crypto模块 - ❌ 部分库的
require()无法正确解析 npm 模块
✅ 最终解决方案
技术选型
使用 asmcrypto.js - 一个纯 JavaScript 实现的加密库,完全兼容小程序环境。
bash
pnpm add asmcrypto.js
核心代码实现
1. 创建加密工具文件 src/utils/crypto.js
javascript
// asmcrypto.js 是纯 JavaScript 实现,兼容小程序环境
import * as AsmCrypto from 'asmcrypto.js/asmcrypto.all.es8.js'
// ⚠️ 必须与后端 KEY 完全一致
const KEY_STR = 'XXXXXXXXXXXXXX' // 替换为您的实际密钥
const IV_LENGTH = 12 // GCM 标准 IV 长度
const TAG_LENGTH = 16 // GCM 标签长度
/**
* 生成安全的随机字节(小程序环境兼容)
*/
function generateRandomBytes(length) {
const array = new Uint8Array(length)
// 尝试使用 wx.getRandomValues
if (typeof wx !== 'undefined' && typeof wx.getRandomValues === 'function') {
try {
const result = wx.getRandomValues(array)
if (result && result instanceof Uint8Array && result.length === length) {
return result
}
} catch (e) {
console.warn('wx.getRandomValues failed, using fallback:', e)
}
}
// 降级方案:使用 Math.random()
for (let i = 0; i < length; i++) {
array[i] = Math.floor(Math.random() * 256)
}
return array
}
/**
* 将字符串转换为 Uint8Array (UTF-8)
*/
function stringToUtf8Bytes(str) {
const utf8 = []
for (let i = 0; i < str.length; i++) {
let charcode = str.charCodeAt(i)
if (charcode < 0x80) {
utf8.push(charcode)
} else if (charcode < 0x800) {
utf8.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f))
} else if (charcode < 0xd800 || charcode >= 0xe000) {
utf8.push(
0xe0 | (charcode >> 12),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f)
)
} else {
// 处理代理对
i++
charcode = 0x10000 + (((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff))
utf8.push(
0xf0 | (charcode >> 18),
0x80 | ((charcode >> 12) & 0x3f),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f)
)
}
}
return new Uint8Array(utf8)
}
/**
* Uint8Array 转 UTF-8 字符串(小程序兼容,不使用 TextDecoder)
*/
function utf8BytesToString(bytes) {
let str = ''
let i = 0
while (i < bytes.length) {
const b1 = bytes[i++]
if (b1 < 0x80) {
str += String.fromCharCode(b1)
} else if (b1 < 0xe0) {
const b2 = bytes[i++]
str += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f))
} else if (b1 < 0xf0) {
const b2 = bytes[i++]
const b3 = bytes[i++]
str += String.fromCharCode(
((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f)
)
} else {
const b2 = bytes[i++]
const b3 = bytes[i++]
const b4 = bytes[i++]
let codepoint =
((b1 & 0x07) << 18) |
((b2 & 0x3f) << 12) |
((b3 & 0x3f) << 6) |
(b4 & 0x3f)
codepoint -= 0x10000
str += String.fromCharCode(
0xd800 + (codepoint >> 10),
0xdc00 + (codepoint & 0x3ff)
)
}
}
return str
}
/**
* Uint8Array 转 Base64(小程序兼容,不使用 btoa)
*/
function bytesToBase64(bytes) {
if (typeof wx !== 'undefined' && wx.arrayBufferToBase64) {
return wx.arrayBufferToBase64(bytes.buffer)
}
// 降级方案
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
/**
* Base64 转 Uint8Array(小程序兼容,不使用 atob)
*/
function base64ToBytes(base64) {
if (typeof wx !== 'undefined' && wx.base64ToArrayBuffer) {
const arrayBuffer = wx.base64ToArrayBuffer(base64)
return new Uint8Array(arrayBuffer)
}
// 降级方案
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* AES-GCM 加密
* @param {string} plaintext - 明文
* @returns {string} Base64 编码的密文 (IV + Ciphertext + Tag)
*/
export function encrypt(plaintext) {
if (!plaintext) return null
try {
// 将密钥和明文转换为字节数组
const keyBytes = stringToUtf8Bytes(KEY_STR)
const plaintextBytes = stringToUtf8Bytes(plaintext)
// 生成随机 IV
const iv = generateRandomBytes(IV_LENGTH)
// 使用 asmcrypto.js 进行 AES-GCM 加密
// 返回的是 Ciphertext + Tag 的组合
const encryptedWithTag = AsmCrypto.AES_GCM.encrypt(plaintextBytes, keyBytes, iv)
// 组合: IV + (Ciphertext + Tag)
const combined = new Uint8Array(IV_LENGTH + encryptedWithTag.length)
combined.set(iv, 0)
combined.set(encryptedWithTag, IV_LENGTH)
// 转换为 Base64
return bytesToBase64(combined)
} catch (error) {
console.error('AES-GCM encryption error:', error)
return plaintext
}
}
/**
* AES-GCM 解密
* @param {string} base64Ciphertext - Base64 编码的密文
* @returns {string} 解密后的明文
*/
export function decrypt(base64Ciphertext) {
if (!base64Ciphertext) return null
try {
// 从 Base64 解码
const combined = base64ToBytes(base64Ciphertext)
// 拆分: IV (12字节) + (Ciphertext + Tag)
const iv = combined.slice(0, IV_LENGTH)
const encryptedWithTag = combined.slice(IV_LENGTH)
// 将密钥转换为字节数组
const keyBytes = stringToUtf8Bytes(KEY_STR)
// 使用 asmcrypto.js 进行 AES-GCM 解密
const decryptedBytes = AsmCrypto.AES_GCM.decrypt(encryptedWithTag, keyBytes, iv)
// 转换为 UTF-8 字符串
return utf8BytesToString(decryptedBytes)
} catch (error) {
console.error('AES-GCM decryption error:', error)
return base64Ciphertext
}
}
export default { encrypt, decrypt }
2. 创建统一入口 src/utils/cryptoUtil.js
javascript
// #ifndef MP-WEIXIN
import forge from 'node-forge'
// #endif
// #ifdef MP-WEIXIN
// 微信小程序环境使用 asmcrypto.js 实现 AES-GCM
import { encrypt, decrypt } from './crypto'
// #endif
const IV_LEN = 12
const TAG_BIT = 128
// ⚠️ 必须与后端 KEY 完全一致
const getKEY = () => {
return 'XXXXXXXXXXXXXX' // 替换为您的实际密钥
}
const CryptoUtil = {
encrypt(content) {
if (!content) return null
// #ifdef MP-WEIXIN
// 微信小程序环境:使用 asmcrypto.js
try {
return encrypt(content)
} catch (error) {
console.error('AES-GCM encryption error in mini program:', error)
return content
}
// #endif
// #ifndef MP-WEIXIN
// H5/其他平台:使用 node-forge
const keyBuf = forge.util.encodeUtf8(getKEY())
const iv = forge.random.getBytesSync(IV_LEN)
const cipher = forge.cipher.createCipher('AES-GCM', forge.util.createBuffer(keyBuf))
cipher.start({ iv: iv, tagLength: TAG_BIT })
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(content)))
cipher.finish()
const allBin = iv + cipher.output.getBytes() + cipher.mode.tag.getBytes()
return forge.util.encode64(allBin)
// #endif
},
decrypt(base64Str) {
if (!base64Str) return null
// #ifdef MP-WEIXIN
// 微信小程序环境:使用 asmcrypto.js
try {
return decrypt(base64Str)
} catch (error) {
console.error('AES-GCM decryption error in mini program:', error)
return base64Str
}
// #endif
// #ifndef MP-WEIXIN
// H5/其他平台:使用 node-forge
const fullBin = forge.util.decode64(base64Str)
const tagBytes = TAG_BIT / 8
const iv = fullBin.substring(0, IV_LEN)
const tag = fullBin.substring(fullBin.length - tagBytes)
const cipherText = fullBin.substring(IV_LEN, fullBin.length - tagBytes)
const keyBuf = forge.util.encodeUtf8(getKEY())
const decipher = forge.cipher.createDecipher('AES-GCM', forge.util.createBuffer(keyBuf))
decipher.start({ iv: iv, tagLength: TAG_BIT, tag: forge.util.createBuffer(tag) })
decipher.update(forge.util.createBuffer(cipherText))
decipher.finish()
return forge.util.decodeUtf8(decipher.output.getBytes())
// #endif
}
}
export default CryptoUtil
3. 在 API 调用中使用
javascript
import cryptoUtil from '@/utils/cryptoUtil'
// 加密密码
let encryptedPassword = data.password
try {
encryptedPassword = cryptoUtil.encrypt(data.password) || data.password
} catch (error) {
console.warn('Failed to encrypt password:', error)
}
// 发送请求
return request({
url: '/auth/login',
method: 'post',
data: {
username: data.username,
password: encryptedPassword // 使用加密后的密码
}
})
🎯 关键要点
1. 数据格式
加密后的数据格式必须与后端保持一致:
css
Base64(IV[12字节] + Ciphertext + Tag[16字节])
2. 小程序兼容性处理
| 功能 | 浏览器/H5 | 小程序 |
|---|---|---|
| Base64 编码 | btoa() |
wx.arrayBufferToBase64() |
| Base64 解码 | atob() |
wx.base64ToArrayBuffer() |
| UTF-8 编码 | TextEncoder |
手动实现 |
| UTF-8 解码 | TextDecoder |
手动实现 |
| 随机数生成 | crypto.randomBytes() |
wx.getRandomValues() 或 Math.random() |
3. 条件编译
使用 uni-app 的条件编译区分不同平台:
javascript
// #ifdef MP-WEIXIN
// 小程序专用代码
// #endif
// #ifndef MP-WEIXIN
// 其他平台代码
// #endif
4. 错误处理
添加完善的错误处理,确保加密失败时不阻断业务流程:
javascript
try {
encryptedPassword = cryptoUtil.encrypt(data.password) || data.password
} catch (error) {
console.warn('Failed to encrypt password:', error)
// 降级:使用原始密码
}
📊 方案对比
| 方案 | 优点 | 缺点 | 结果 |
|---|---|---|---|
| crypto-js | 流行度高 | 不支持 GCM | ❌ |
| @noble/ciphers | 现代化设计 | 导入路径复杂,依赖 WebCrypto | ❌ |
| aes-js | 纯 JS 实现 | 不支持 GCM 模式 | ❌ |
| 微信原生 API | 性能好 | 需要基础库 ≥ 2.21.2 | ⚠️ 兼容性问题 |
| asmcrypto.js | 纯 JS,完全兼容 | 包体积稍大 | ✅ |
🔐 安全性说明
- 密钥管理: 密钥应妥善保管,不要硬编码在代码中,建议使用环境变量
- IV 生成: 每次加密都应生成新的随机 IV,确保安全性
- 降级策略: 如果加密失败,应有合理的降级方案,但生产环境应避免降级
💡 总结
经过多次尝试,最终选择 asmcrypto.js 作为微信小程序的 AES-GCM 加密方案,主要优势:
- ✅ 纯 JavaScript 实现,不依赖任何原生 API
- ✅ 完全兼容小程序环境
- ✅ 支持完整的 AES-GCM 功能
- ✅ 与后端 Java AES-GCM 完全兼容
- ✅ 有完善的错误处理和降级机制
这个方案已经在实际项目中稳定运行,希望能帮助到遇到同样问题的开发者!
参考链接: