返回正文
ACE-GCM加解密微信小程序
📋 背景
在开发微信小程序时,需要对敏感数据(如密码)进行 AES-GCM 加密后传输到后端。由于小程序环境的特殊性,许多
🔍 遇到的问题
crypto-js 不支持 GCM 模式
javascript
// ❌ 失败方案
import CryptoJS from 'crypto-js'
// crypto-js 在小程序中不支持 GCM 模式1
2
3
2
3
问题: crypto-js 库在小程序环境中无法使用 GCM 加密模式,会报错 Cannot read property 'createEncryptor' of undefined。
@noble/ciphers 导入路径问题
javascript
// ❌ 失败方案
import { gcm } from '@noble/ciphers/gcm' // 路径不存在
import { aes } from '@noble/ciphers/aes'1
2
3
2
3
问题: @noble/ciphers v2.x 不再通过 ./gcm 路径导出 GCM 模式,其 exports 中仅包含 ./aes.js 和基于 WebCrypto 的 ./webcrypto.js。
aes-js 不支持 GCM 模式
javascript
// ❌ 失败方案
import aesjs from 'aes-js'
const aesGcm = new aesjs.ModeOfOperation.gcm(key, iv) // gcm 是 undefined1
2
3
2
3
问题: aes-js v3.x 版本只支持 ECB、CBC、CFB 等模式,不支持 GCM 模式。
asmcrypto.js buffer 属性为 null
javascript
// ❌ 失败方案 - 直接使用 Uint8Array.buffer
const keyBytes = new Uint8Array(keyStr.length)
// asmcrypto.js 内部访问 key.buffer 时会报错1
2
3
2
3
问题: 在小程序环境中,某些情况下 Uint8Array 的 buffer 属性可能为 null,导致 TypeError: Cannot read property 'length' of null。
小程序环境限制
- ❌ 不支持
atob()/btoa()- 需要使用wx.base64ToArrayBuffer()/wx.arrayBufferToBase64()❌ 不支持TextDecoder/TextEncoder- 需要手动实现 UTF-8 编解码 ❌ 不支持 Node.jscrypto模块 ❌ 部分库的require()无法正确解析npm 模块
✅ 最终解决方案
技术选型
使用 asmcrypto.js - 一个纯
bash
pnpm add asmcrypto.js1
核心代码实现
创建加密工具文件 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 }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
创建统一入口 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 CryptoUtil1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🎯 关键要点
数据格式
加密后的数据格式必须与后端保持一致:
js
Base64(IV[12字节] + Ciphertext + Tag[16字节])1
小程序兼容性处理
| 功能 | 浏览器/H5 | 小程序 |
|---|---|---|
| Base64 编码 | btoa() | wx.arrayBufferToBase64() |
| Base64 解码 | atob() | wx.base64ToArrayBuffer() |
| UTF-8 编码 | TextEncoder | 手动实现 |
| UTF-8 解码 | TextDecoder | 手动实现 |
| 随机数生成 | crypto.randomBytes() | wx.getRandomValues() 或 Math.random() |
条件编译
使用 uni-app 的条件编译区分不同平台:
javascript
// #ifdef MP-WEIXIN
// 小程序专用代码
// #endif
// #ifndef MP-WEIXIN
// 其他平台代码
// #endif1
2
3
4
5
6
7
2
3
4
5
6
7
错误处理
添加完善的错误处理,确保加密失败时不阻断业务流程:
javascript
try {
encryptedPassword = cryptoUtil.encrypt(data.password) || data.password
} catch (error) {
console.warn('Failed to encrypt password:', error)
// 降级:使用原始密码
}1
2
3
4
5
6
2
3
4
5
6
📊 方案对比
| 方案 | 优点 | 缺点 | 结果 |
|---|---|---|---|
| crypto-js | 流行度高 | 不支持 GCM | ❌ |
| @noble/ciphers | 现代化设计 | 导入路径复杂,依赖 WebCrypto | ❌ |
| aes-js | 纯 JS 实现 | 不支持 GCM 模式 | ❌ |
| 微信原生 API | 性能好 | 需要基础库 ≥ 2.21.2 | ⚠️ 兼容性问题 |
| asmcrepo.js | 纯 JS,完全兼容 | 包体积稍大 | ✅ |
🔐 安全性说明
- 密钥管理: 密钥应妥善保管,不要硬编码在代码中,建议使用环境变量
- IV 生成: 每次加密都应生成新的随机 IV,确保安全性
- 降级策略: 如果加密失败,应有合理的降级方案,但生产环境应避免降级
💡 总结
经过多次尝试,最终选择 asmcrypto.js 作为微信小程序的 AES-GCM 加密方案,主要优势:
- ✅ 纯 JavaScript 实现,不依赖任何原生 API
- ✅ 完全兼容小程序环境
- ✅ 支持完整的 AES-GCM 功能
- ✅ 与后端
Java AES-GCM 完全兼容 - ✅ 有完善的错误处理和降级机制
这个方案已经在实际项目中稳定运行,希望能帮助到遇到同样问题的开发者!
V 0.13.11 |
基于 MIT Licensed版权所有 © 2009- 2026 CMONO.NET
本站访客数
--次 本站总访问量
--人次 