ACE-GCM加解密微信小程序

微信小程序 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 时会报错

问题 : 在小程序环境中,某些情况下 Uint8Arraybuffer 属性可能为 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,完全兼容 包体积稍大

🔐 安全性说明

  1. 密钥管理: 密钥应妥善保管,不要硬编码在代码中,建议使用环境变量
  2. IV 生成: 每次加密都应生成新的随机 IV,确保安全性
  3. 降级策略: 如果加密失败,应有合理的降级方案,但生产环境应避免降级

💡 总结

经过多次尝试,最终选择 asmcrypto.js 作为微信小程序的 AES-GCM 加密方案,主要优势:

  • ✅ 纯 JavaScript 实现,不依赖任何原生 API
  • ✅ 完全兼容小程序环境
  • ✅ 支持完整的 AES-GCM 功能
  • ✅ 与后端 Java AES-GCM 完全兼容
  • ✅ 有完善的错误处理和降级机制

这个方案已经在实际项目中稳定运行,希望能帮助到遇到同样问题的开发者!


参考链接:

相关推荐
春风得意之时2 小时前
前端安装项目出现代理问题和ssl认证问题
前端·网络协议·ssl
问心无愧05132 小时前
ctf show web入门109
android·前端·笔记
粉末的沉淀2 小时前
vue:Vite项目中高效管理纯色SVG图标的方案
前端·javascript·vue.js
dotnet902 小时前
PDF 页面尺寸上限是 14400。iText 直接加载成功的大图可能超过这个限制,需要在 setPageSize 之前等比缩放。
前端·javascript·html
threelab2 小时前
Three.js 几何图形变换 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器
道友可好2 小时前
写给 AI 的入职手册,AGENTS.md
前端·人工智能·后端
吠品3 小时前
处理 Python 类继承中那些变来变去的初始化参数
linux·前端·python
云水一下3 小时前
TypeScript 从零基础到精通(七):从配置到全栈项目落地
前端·javascript·typescript
秋天的一阵风3 小时前
✨ 代码秒跳转、自动补全?全靠 LSP 和 AST!
前端·后端·ai编程