# API安全

微信开放平台的接口通信鉴权体系,使用了公私钥的机制,开发者可在小程序管理后台 API安全中设置API密钥,为自身应用配置公钥防止数据篡改,以此来保障开发者应用和微信开放平台交互的安全性。

# 一、 签名与验签介绍

在请求API的过程中,为了保护数据的安全,开发者在应用中使用自己的私钥生成请求的签名,平台会在收到请求后使用应用的公钥进行签名的验证,开发者则使用平台证书中的公钥验证回调的签名,来保证互相请求的真实性和数据的完整性。

# 1.公钥

公钥是指应用公钥,开发者通过平台工具生成公钥信息,并保存在API安全配置中,用于平台进行接口调用时的验签。

# 2.私钥

私钥是指应用私钥,开发者可以通过平台工具生成私钥信息,用于生成请求的签名,请妥善保管好私钥文件,务必自行保障其安全性。

# 3.平台证书

平台证书是指包含微信开放平台公钥信息的证书,开发者在配置应用公钥后由平台生成,用于进行接口回调时的验签。

# 二、 接口内容加密介绍

平台支持对接口的请求内容和响应内容进行 AES256/SM4 加密(资源上传类API暂不支持加密),加密后,在网络上传输的接口报文内容将会由明文内容变为密文内容,可以大幅提升接口内容传输的安全性。 接口内容加密结合加签验签,可以在防止接口传输数据泄露的前提下,同时确认接口传输的内容没有被篡改,开发者可对请求参数先做内容加密,然后对密文进行签名。

# 三、 生成并配置密钥

开发者可根据本文指引,快速完成密钥生成、配置及平台证书获取。

# 1.进入配置页

开发者登录小程序管理后台,可在“开发 - 开发管理 - 开发设置 - API安全”进行API密钥的配置。

点击“开始配置”后,需使用管理员微信扫码验证身份,通过后进入配置页面。

# 2.生成接口内容加密密钥

若开发者需对接口的请求内容进行加密,可在API对称密钥处进行配置,点击“随机生成密钥”后再进行下载,复制生成的密钥至新密钥输入框中,验证无误后点击“确定”,即可完成对称密钥的上传。

# 3.生成应用私钥和公钥

若开发者需生成请求的签名,可在API非对称密钥处进行配置,点击“随机生成密钥”后再进行下载,复制生成的私钥信息,此信息无需上传,请妥善进行保管。再复制生成的公钥至新密钥输入框中,验证无误后点击“确定”,即可完成应用公钥的上传。

# 4.获取平台证书

开发者配置完应用公钥后,需下载开放平台证书,用于后续接口回调时的验签操作。在此页面,也可查看对应的API密钥及密钥编号。

# 四、开发指南

# 概述

在MP管理页开启加密模式后,开发者需要对原API的请求内容加密签名,同时API的回包内容需要开发者验签解密资源上传类API暂不支持加密

目前支持以下几种算法,可在MP管理页配置。

加密算法

  • AES256_GCM
  • SM4_GCM

签名算法

  • RSAwithSHA256
  • SM2withSM3

# API请求处理

#
加密

请求参数

参数 类型 默认值 必填 备注
iv string 初始向量,为16字节base64字符串(解码后为12字节随机字符串)
data string 加密后的密文,使用base64编码
authtag string GCM模式输出的认证信息,使用base64编码

GCM使用的认证数据

GCM分组模式需要设置额外认证数据(AAD)对密文进行认证。平台统一使用 urlpath|appid|timestamp|sn 格式,字段之间使用竖线符号|分隔。

参数 说明
urlpath 当前请求API的URL路径,包含URL协议信息,不包括URL参数(URL Query)
appid 当前小程序的Appid
timestamp 加密时的时间戳,需要与HTTP请求头Wechatmp-TimeStamp的时间戳一致
sn 使用的对称密钥编号,需要在MP平台密钥管理页面获取

data明文格式

data明文使用JSON格式,包含原API使用的URL参数与POST参数。需要额外增加三个安全字段_n,_appid,_timestamp,这些字段首字符均为下划线,与参数字段相互独立。

参数 类型 默认值 必填 备注
data 原字段类型 原请求字段,包含URL参数、POST参数,不包含AccessToken
_n string 随机字符串,推荐使用16-32字节非固定长度随机base64字符串
_appid string 当前小程序的Appid
_timestamp number 加密时的时间戳,需要与HTTP请求头Wechatmp-TimeStamp的时间戳一致

# 示例

风控接口为例, 对原请求数据加密。

# AES256_GCM

密钥信息

{
    "Sn": "fa05fe1e5bcc79b81ad5ad4b58acf787",
    "Key": "otUpngOjU+nVQaWJIC3D/yMLV17RKaP6t4Ot9tbnzLY="
}

原始数据

{
    "appid": "wxba6223c06417af7b",
    "openid": "oEWzBfmdLqhFS2mTXCo2E4Y9gJAM",
    "scene": 0,
    "client_ip": "127.0.0.1",
}

加密后请求

{
    "iv": "fmW/zNxXlytUZBgj",
    "data": "0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=",
    "authtag": "5qeM/2vZv+6KtScN94IpMg=="
}

加密过程数据

生成12字节随机字符串iv

base64_encode(iv) = fmW/zNxXlytUZBgj

拼接额外认证数据aad

aad = https://api.weixin.qq.com/wxa/getuserriskrank|wxba6223c06417af7b|1635927954|fa05fe1e5bcc79b81ad5ad4b58acf787

在原数据内添加安全字段,组成data

{
    "_n": "o89QaPVsRu1yppIZzvSZc4",
    "appid": "wxba6223c06417af7b",
    "openid": "oEWzBfmdLqhFS2mTXCo2E4Y9gJAM",
    "scene": 0,
    "client_ip": "127.0.0.1",
    "_appid": "wxba6223c06417af7b",
    "_timestamp": 1635927954
}

压缩data(可选)

{"_n":"o89QaPVsRu1yppIZzvSZc4","_appid":"wxba6223c06417af7b","_timestamp":1635927954,"appid":"wxba6223c06417af7b","openid":"oEWzBfmdLqhFS2mTXCo2E4Y9gJAM","scene":0,"client_ip":"127.0.0.1"}

计算密文enc_data与认证信息authtag

base64_encode(enc_data) = 0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=
base64_encode(authtag) = 5qeM/2vZv+6KtScN94IpMg==

示例代码

// AES256_GCM
const crypto = require("crypto")

// 仅做演示,敏感信息请勿硬编码
function getCtx() {
    let ctx = {
        local_sym_key: "otUpngOjU+nVQaWJIC3D/yMLV17RKaP6t4Ot9tbnzLY=",
        local_sym_sn: "fa05fe1e5bcc79b81ad5ad4b58acf787",
        local_appid: "wxba6223c06417af7b",
        url_path: "https://api.weixin.qq.com/wxa/getuserriskrank"
    }
    return ctx
}

function getRawReq() {
    let req = {
        appid: "wxba6223c06417af7b",
        openid: "oEWzBfmdLqhFS2mTXCo2E4Y9gJAM",
        scene: 0,
        client_ip: "127.0.0.1",
    }
    return req
}

function getNewReq(ctx, req) {
    const { local_sym_key, local_sym_sn, local_appid, url_path } = ctx // 开发者本地信息

    const local_ts = Math.floor(Date.now() / 1000) //加密签名使用的统一时间戳
    const nonce = crypto.randomBytes(16).toString('base64').replace(/=/g, '')

    const reqex = {
        _n: nonce,
        _appid: local_appid,
        _timestamp: local_ts
    }

    const real_req = Object.assign({}, reqex, req) // 生成并添加安全校验字段
    const plaintext = JSON.stringify(real_req)

    const aad = `${url_path}|${local_appid}|${local_ts}|${local_sym_sn}`

    const real_key = Buffer.from(local_sym_key, "base64")
    const real_iv = crypto.randomBytes(12)
    const real_aad = Buffer.from(aad, "utf-8")
    const real_plaintext = Buffer.from(plaintext, "utf-8")

    const cipher = crypto.createCipheriv("aes-256-gcm", real_key, real_iv)
    cipher.setAAD(real_aad)

    let cipher_update = cipher.update(real_plaintext)
    let cipher_final = cipher.final()
    const real_ciphertext = Buffer.concat([cipher_update, cipher_final])
    const real_authTag = cipher.getAuthTag()

    const iv = real_iv.toString("base64")
    const data = real_ciphertext.toString("base64")
    const authtag = real_authTag.toString("base64")

    const req_data = {
        iv,
        data,
        authtag,
    }

    const new_req = {
        req_ts: local_ts,
        req_data: JSON.stringify(req_data)
    }
    return new_req

}

const ctx = getCtx()
const req = getRawReq()

let res = getNewReq(ctx, req)
console.log(res)

#
签名

开发者需要对API的POST数据签名,由HTTP请求头传递。

请求参数

HEADER名 默认值 必填 备注
Wechatmp-Appid 当前小程序的Appid
Wechatmp-TimeStamp 签名时时间戳
Wechatmp-Signature 签名数据,使用base64编码

签名字段格式

开发者需先拼接待签名串,使用 urlpath\n appid\n sn\n timestamp\n postdata 格式,字段之间使用换行符\n做分隔符。

参数 说明
urlpath 当前请求API的URL,不包括URL参数(URL Query),需要带HTTP协议头
appid 当前小程序的Appid
sn 使用的证书或公钥编号,需要在MP平台密钥管理页面获取
timestamp 签名时的时间戳,即请求头Wechatmp-TimeStamp的值
postdata 当前请求的POST数据

# 示例

风控接口为例,对请求数据签名。

# RSAwithSHA256

签名使用PSS填充方式,需要指定salt长度为32。(PSS签名中包含随机因子,因此每次签名结果都会变化)

私钥信息

{
    "Sn": "97845f6ed842ea860df6fdf65941ff56",
    "PrivateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA3FoQOmOl5/CF5hF7ta4EzCy2LaU3Eu2k9DBwQ73J82I53Sx9\nLAgM1DH3IsYohRRx/BESfbdDI2powvr6QYKVIC+4Yavwg7gzhZRxWWmT1HruEADC\nZAgkUCu+9Il/9FPuitPSoIpBd07NqdkkRe82NBOfrKTdhge/5zd457fl7J81Q5VT\nIxO8vvq7FSw7k6Jtv+eOjR6SZOWbbUO7f9r4UuUkXmvdGv21qiqtaO1EMw4tUCEL\nzY73M7NpCH3RorlommYX3P6q0VrkDHrCE0/QMhmHsF+46E+IRcJ3wtEj3p/mO1Vo\nCpEhawC1U728ZUTwWNEii8hPEhcNAZTKaQMaTQIDAQABAoIBAQCXv5p/a5KcyYKc\n75tfgekh5wTLKIVmDqzT0evuauyCJTouO+4z/ZNAKuzEUO0kwPDCo8s1MpkU8boV\n1Ru1M8WZNePnt65aN+ebbaAl8FRzNvltoeg9VXIUmBvYcjzhOVAE4V2jW7M8A9QU\nzUpyswuED6OeFKfOHtYk2In2IipAqhfbyc6gn7uZSWTQsoO6hGBRQ7Ejx+vgwrbx\nZKVZ7UXbPHD0lOEPraA3PH/QUeUKpNwK2NXQoBxWcR283/HxFSAjjSSsGSBKsCnw\nDN55P2FQ0HNi5YrwUNT9190NIXSeygaRy1b+D+yBfm+yE7/qXwHLZCHsjO+2tMSS\n3KGjllTBAoGBAP9FPeYNKZuu5jt9RpZwXCc9E7Iz7bmM7zws6dun6dQH0xVVWFVm\niGIu07eqyB8HNagXseFzoXLV5EQx+3DaB0bAH+ZEpHGJJpAWSLusigssFUFuTvTF\nw+rC5hxOfidMa6+93SU5pWeJb0zJF8PRDaJ3UmwlwpYubF17sT4PD6p9AoGBANz7\nRlhRSFvggJjhEMpek3OIYWrrlRNO2MVcP7i/fGNTHhrw7OHcNGRof54QZ2Y0baL7\n1vHNokbK2mnT+cQXY/gXMmcE/eV4xyRGYiIL9nBdrkLerc43EYPv+evDvgyji6+y\n4np5cKqHrS8F+YzATk82Jt9HgdI2MvfbJTkSbmgRAoGAHNPL9rPb1An/VA6Ery6H\nKaM7Gy/EE+U3ixsjWbvvqxMrIkieDh7jHftdy2sM6Hwe8hmi6+vr+pTvD0h5tbfZ\nhILj11Q/Idc0NKdflVoZyMM0r0vuvLOsuVFDPUUb+AIoUxNk6vREmpmpqQk4ltN/\n763779yfyef6MuBqFrEKut0CgYB9FfsuuOv1nfINF7EybDCZAETsiee7ozEPHnWv\ndSzK6FytMV1VSBmcEI7UgUKWVu0MifOUsiq+WcsihmvmNLtQzoioSeoSP7ix7ulT\njmP0HQMsNPI7PW67uVZFv2pPqy/Bx8dtPlqpHN3KNV6Z7q0lJ2j/kHGK9UUKidDb\nKnS2kQKBgHZ0cYzwh9YnmfXx9mimF57aQQ8aFc9yaeD5/3G2+a/FZcHtYzUdHQ7P\nPS35blD17/NnhunHhuqakbgarH/LIFMHITCVuGQT4xS34kFVjFVhiT3cHfWyBbJ6\nGbQuzzFxz/UKDDKf3/ON41k8UP20Gdvmv/+c6qQjKPayME81elus\n-----END RSA PRIVATE KEY-----"
}

原始请求

原postdata总长度324,末尾无回车符\n

POST /wxa/getuserriskrank?access_token=ACCESS_TOKEN HTTP/1.1
Host: api.weixin.qq.com
...
Content-Length: 324

{"iv":"fmW/zNxXlytUZBgj","data":"0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=","authtag":"5qeM/2vZv+6KtScN94IpMg=="}

签名后请求

POST /wxa/getuserriskrank?access_token=ACCESS_TOKEN HTTP/1.1
Host: api.weixin.qq.com
...
Content-Length: 324
Wechatmp-Appid: wxba6223c06417af7b
Wechatmp-TimeStamp: 1635927954
Wechatmp-Signature: 1lv7HFA2IsFlc8xa+h7xcczfUT8+ccbYUy4KkROmdqj6s9iq6DQys9v3v6dzx8jfyorv/2XHUAphhFDOomqCOQbckMIUdzjsK5RheTL98OZm2CEQvPYv9vJfmvSejyICAUsiahLBgnt/TjRHRf1MfjeV0FDKxb17TVruQozW+VeO02/BGkvFxKroN+P87ugf6RhIX3EiyRSbDAO8fXTEPYaUxFLlfY5hcW8VEcjdz4GzKljKbF5nYzIQqEpp59XAbhW335uKBvpN0G5f9M4uMphdAjfpD+bhkg3fMuHp4Y5pZl9ThJrcipcQ74RV605qj7LbZw6LpF1BHhEk8STMBA==

{"iv":"fmW/zNxXlytUZBgj","data":"0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=","authtag":"5qeM/2vZv+6KtScN94IpMg=="}

签名过程数据

拼接待签名串M,末尾无额外回车符\n

https://api.weixin.qq.com/wxa/getuserriskrank
wxba6223c06417af7b
97845f6ed842ea860df6fdf65941ff56
1635927954
{"iv":"fmW/zNxXlytUZBgj","data":"0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=","authtag":"5qeM/2vZv+6KtScN94IpMg=="}

使用PSS填充方式计算签名S

base64_encode(S) = 1lv7HFA2IsFlc8xa+h7xcczfUT8+ccbYUy4KkROmdqj6s9iq6DQys9v3v6dzx8jfyorv/2XHUAphhFDOomqCOQbckMIUdzjsK5RheTL98OZm2CEQvPYv9vJfmvSejyICAUsiahLBgnt/TjRHRf1MfjeV0FDKxb17TVruQozW+VeO02/BGkvFxKroN+P87ugf6RhIX3EiyRSbDAO8fXTEPYaUxFLlfY5hcW8VEcjdz4GzKljKbF5nYzIQqEpp59XAbhW335uKBvpN0G5f9M4uMphdAjfpD+bhkg3fMuHp4Y5pZl9ThJrcipcQ74RV605qj7LbZw6LpF1BHhEk8STMBA==

示例代码

// RSAwithSHA256
const crypto = require("crypto")

// 仅做演示,敏感信息请勿硬编码
function getCtx() {
    let ctx = {
        local_private_key: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA3FoQOmOl5/CF5hF7ta4EzCy2LaU3Eu2k9DBwQ73J82I53Sx9\nLAgM1DH3IsYohRRx/BESfbdDI2powvr6QYKVIC+4Yavwg7gzhZRxWWmT1HruEADC\nZAgkUCu+9Il/9FPuitPSoIpBd07NqdkkRe82NBOfrKTdhge/5zd457fl7J81Q5VT\nIxO8vvq7FSw7k6Jtv+eOjR6SZOWbbUO7f9r4UuUkXmvdGv21qiqtaO1EMw4tUCEL\nzY73M7NpCH3RorlommYX3P6q0VrkDHrCE0/QMhmHsF+46E+IRcJ3wtEj3p/mO1Vo\nCpEhawC1U728ZUTwWNEii8hPEhcNAZTKaQMaTQIDAQABAoIBAQCXv5p/a5KcyYKc\n75tfgekh5wTLKIVmDqzT0evuauyCJTouO+4z/ZNAKuzEUO0kwPDCo8s1MpkU8boV\n1Ru1M8WZNePnt65aN+ebbaAl8FRzNvltoeg9VXIUmBvYcjzhOVAE4V2jW7M8A9QU\nzUpyswuED6OeFKfOHtYk2In2IipAqhfbyc6gn7uZSWTQsoO6hGBRQ7Ejx+vgwrbx\nZKVZ7UXbPHD0lOEPraA3PH/QUeUKpNwK2NXQoBxWcR283/HxFSAjjSSsGSBKsCnw\nDN55P2FQ0HNi5YrwUNT9190NIXSeygaRy1b+D+yBfm+yE7/qXwHLZCHsjO+2tMSS\n3KGjllTBAoGBAP9FPeYNKZuu5jt9RpZwXCc9E7Iz7bmM7zws6dun6dQH0xVVWFVm\niGIu07eqyB8HNagXseFzoXLV5EQx+3DaB0bAH+ZEpHGJJpAWSLusigssFUFuTvTF\nw+rC5hxOfidMa6+93SU5pWeJb0zJF8PRDaJ3UmwlwpYubF17sT4PD6p9AoGBANz7\nRlhRSFvggJjhEMpek3OIYWrrlRNO2MVcP7i/fGNTHhrw7OHcNGRof54QZ2Y0baL7\n1vHNokbK2mnT+cQXY/gXMmcE/eV4xyRGYiIL9nBdrkLerc43EYPv+evDvgyji6+y\n4np5cKqHrS8F+YzATk82Jt9HgdI2MvfbJTkSbmgRAoGAHNPL9rPb1An/VA6Ery6H\nKaM7Gy/EE+U3ixsjWbvvqxMrIkieDh7jHftdy2sM6Hwe8hmi6+vr+pTvD0h5tbfZ\nhILj11Q/Idc0NKdflVoZyMM0r0vuvLOsuVFDPUUb+AIoUxNk6vREmpmpqQk4ltN/\n763779yfyef6MuBqFrEKut0CgYB9FfsuuOv1nfINF7EybDCZAETsiee7ozEPHnWv\ndSzK6FytMV1VSBmcEI7UgUKWVu0MifOUsiq+WcsihmvmNLtQzoioSeoSP7ix7ulT\njmP0HQMsNPI7PW67uVZFv2pPqy/Bx8dtPlqpHN3KNV6Z7q0lJ2j/kHGK9UUKidDb\nKnS2kQKBgHZ0cYzwh9YnmfXx9mimF57aQQ8aFc9yaeD5/3G2+a/FZcHtYzUdHQ7P\nPS35blD17/NnhunHhuqakbgarH/LIFMHITCVuGQT4xS34kFVjFVhiT3cHfWyBbJ6\nGbQuzzFxz/UKDDKf3/ON41k8UP20Gdvmv/+c6qQjKPayME81elus\n-----END RSA PRIVATE KEY-----",
        local_sn: "97845f6ed842ea860df6fdf65941ff56",
        local_appid: "wxba6223c06417af7b",
        url_path: "https://api.weixin.qq.com/wxa/getuserriskrank"
    }
    return ctx
}

function getReq() {
    let req = {
        req_ts: 1635927954,
        req_data: '{"iv":"fmW/zNxXlytUZBgj","data":"0IDVdrPtSPF/Oe2CTXCV2vVNPbVJdJlP2WaTMQnoYLh5iCrrSNfQFh25EnStDMf0hLlVNBCZQtf9NaV0m4aRA4AAYIO7oR/Ge+4yY4EmZp5EVPB42xjScgMx5X3D4VdLCfynXIUKUtZHZvk1zmLVE3RauzJgiM1BB1CPmwcENo3MTJ0z8Vfkf5tMv54kOXobDLlV5rfqKdAX7gM/rP82DgZdt9vvZX44ipdbHIjJvw83ZXAFtvftdVw2Qd8=","authtag":"5qeM/2vZv+6KtScN94IpMg=="}'
    }
    return req
}

function getSignature(ctx, req) {
    const { local_private_key, local_sn, local_appid, url_path } = ctx // 开发者本地信息
    const { req_ts, req_data } = req // 待请求API数据

    const payload = `${url_path}\n${local_appid}\n${local_sn}\n${req_ts}\n${req_data}`
    const data_buffer = Buffer.from(payload, 'utf-8')
    const key_obj = {
        key: local_private_key,
        padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
        saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST // salt长度,需与SHA256结果长度(32)一致
    }

    const sig_buffer = ss_buffer = crypto.sign(
        'RSA-SHA256',
        data_buffer,
        key_obj
    )
    const sig = sig_buffer.toString('base64')
    return sig
    /*
    最终请求头字段
    {
        "Wechatmp-Appid": local_appid,
        "Wechatmp-TimeStamp": req_ts,
        "Wechatmp-Signature": sig,
    }
    */
}

const ctx = getCtx()
const req = getReq()

let res = getSignature(ctx, req)
console.log(res)

# API响应处理

API响应数据需要验签与解密。

响应内的签名算法为RSAwithSHA256,验签需要使用MP平台证书验证,可在MP管理页下载最新平台证书

响应内的加密算法、密钥与请求时一致。

#
验签

由于平台证书存在有效期,平台证书可能过期。在平台证书更换周期内,平台会同时带上最新证书与即将过期证书的签名结果。开发者需要根据已下载的平台证书编号找到对应的签名来验证。

若发现使用的平台证书编号与响应内的Wechatmp-Serial-Deprecated字段匹配(即当前证书即将过期),请尽快更新MP平台证书。

请求参数

HEADER名 默认值 必填 备注
Wechatmp-Appid 当前小程序的Appid
Wechatmp-TimeStamp 签名时时间戳
Wechatmp-Serial 平台证书编号,在MP管理页面获取,非证书内序列号
Wechatmp-Signature 平台证书签名数据,使用base64编码
Wechatmp-Serial-Deprecated 即将失效的平台证书编号,非证书内序列号,仅在证书更换周期内出现
Wechatmp-Signature-Deprecated 即将失效的平台证书签名数据,仅在证书更换周期内出现,使用base64编码

签名字段格式

开发者需先拼接待签名串,使用 urlpath\n appid\n sn\n timestamp\n postdata 格式,字段之间使用换行符\n做分隔符。

参数 说明
urlpath 当前请求API的URL,不包括URL参数(URL Query),需要带HTTP协议头
appid 当前小程序的Appid
sn 使用的平台证书编号,需要在MP平台密钥管理页面获取,非证书内序列号
timestamp 签名时的时间戳,即请求头Wechatmp-TimeStamp的值
postdata 当前请求的POST数据

# 示例

风控接口为例,对服务端回包内容验签。

# RSAwithSHA256

响应的签名也使用PSS填充方式,一般不需要指定salt长度。

证书信息

所有参与签名的编号都在MP密钥管理页面获取,非证书内置序列号

{
    "Sn": "79ba700ea147819f640941bceb38b1d1",
    "Certificate": "-----BEGIN CERTIFICATE-----\nMIID0jCCArqgAwIBAgIUeE+Yy7vM/o+eHHsfM+1bGJJEZTQwDQYJKoZIhvcNAQEL\nBQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT\nFFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg\nQ0EwHhcNMjIwOTA1MDgzOTIyWhcNMjcwOTA0MDgzOTIyWjBkMRswGQYDVQQDDBJ3\neGQ5MzBlYTVkNWEyNThmNGYxFTATBgNVBAoMDFRlbmNlbnQgSW5jLjEOMAwGA1UE\nCwwFV3hnTXAxCzAJBgNVBAYMAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJ\nKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM5D9qlkCmk1kr3FpF0e9pc3kGsvz5RA\n0/YRny9xPKIyV2UVMDZvRQ+mDHsiQQFE6etg457KFYSxTDKtItbdl6hJQVGeAvg0\nmqPYE9SkHRGTfL/AnXRbKBG2GC2OcaPSAprsLOersjay2me+9pF8VHybV8aox78A\nNsU75G/OO3V1iEE0s5Pmglqk8DEiw9gB/dGJzsNfXwzvyJyiUP9ZujYexyjsS+/Z\nGdSOUkqL/th+16yHj8alcdyga6YGfWEDyWkt/i/B28cwx4nzwk8xgrurifPaLuMk\n0+9wJQLCfAn/f7zyHrC8PcD1XvvRt9VBNMBASXs3710ODyyVf2lkMgkCAwEAAaOB\ngTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMGUGA1UdHwReMFwwWqBYoFaGVGh0\ndHA6Ly9ldmNhLml0cnVzLmNvbS5jbi9wdWJsaWMvaXRydXNjcmw/Q0E9MUJENDIy\nMEU1MERCQzA0QjA2QUQzOTc1NDk4NDZDMDFDM0U4RUJEMjANBgkqhkiG9w0BAQsF\nAAOCAQEAL2MK9tYu+ljLVBlSbfEeaKyF07TN+G31Ya5NBzeS1ZCx4joUEIyACWmG\nfUkKNKiKV+EMzxeEhKRso1Qif3E7Ipl+PQBoQw6OSR/jFHciYurnGR9CLkL03Zo1\nqw1Xetv9OipsvlpA0SOWc207e/XpGdm8C7FMXM6bzvVp8I/STTjC1vqjIZu9WavI\nRgGM4jyAPz2XogUq0BNijef8BXbbav9fAsXjHSwn5BQv4iLms3fiLm/eoyQ6dZ2R\noTudrlcyr1bG4vwETLmHF+3yfVp9dpvJ+lyfiviwDwyfa8t2WlJm27DuF4vWoxir\nmjgj9tDutIFqxLIovLyg3uiAYtSQ/Q==\n-----END CERTIFICATE-----"
}

原始响应

响应数据总长度292,末尾无回车符\n

HTTP/1.1 200 OK
...
Content-Length: 292
Wechatmp-Appid: wxba6223c06417af7b
Wechatmp-TimeStamp: 1635927956
Wechatmp-Serial: 79ba700ea147819f640941bceb38b1d1
Wechatmp-Signature: GsToqir0e4Vk2wDEGZ56rH8cQoE0BLSBJliweFmNdjSpb/5JWaNcW0AdaHi4gZKgmmE5TvM2as294HQwacn0DCkv3oX3gBSqZj/ZAlUKBiydTKOll1NR+p0FqI4lvPIWH//CAZRsHkKGlNPUIn5+UpqkEFctWM8N6PvPGgar397MiDrAqL9wfF62lPbhc8WUK6yEpE0ByQ69szU4uMYkBcbgZZ2T9vNQ6F5OrMJxfVYlI6FCu2ka/eo2oKFTYjKM0ayMsLIBFcDWO0lXXBoqoH28V7KwSlRvr2cWR6ChlYHeNzE80E4kWKGegkD5y/Loe+DemWUGxmj5uShPvZxjCg==
Wechatmp-Serial-Deprecated: 2171af9cdf1d7404423852e7e183d852
Wechatmp-Signature-Deprecated: Lu8OL0UiHRNYSH1z21O2ir634MiTL1HS9pjoGnunsF7d5IDbTueP54jtWfxjSxfEsqprapD1c9d4fA5GoIYl1ChYAsRY2PKW5uAFZQy2reGPKoHjx5wArqRr+BqpGvREKAZyiiOH9dvo04R9xJ9YRlpNHVl6I2GWRWzwchcx57R3WsuW6Ot+RMUcEzZjPaI+0Pq+LRMlotmSNv1a0W6aLcLKb/dfsXjAU0RZ6luZs7Qg7x6DEaUx2ytkyaX9iqhLOE47ck50lCg4bthNZUZnDtrpJPFu9QZ0T0vwbb6eQvHQ5fAq/dCYfDm2GrZ2MSawlJJi+BhrhpcMiocV4Ex/Yg==

{"iv":"r2WDQt56rEAmMuoR","data":"HExs66Ik3el+iM4IpeQ7SMEN934FRLFYOd3EmeaIrpP4EPTHckoco6O+PaoRZRa3lqaPRZT7r52f7LUok6gLxc6cdR8C4vpIIfh4xfLC4L7FNy9GbuMK1hcoi8b7gkWJcwZMkuCFNEDmqn3T49oWzAQOrY4LZnnnykv6oUJotdAsnKvmoJkLK7hRh7M2B1d2UnTnRuoIyarXc5Iojwoghx4BOvnV","authtag":"z2BFD8QctKXTuBlhICGOjQ=="}

验签过程数据

拼接待签名串M,末尾无额外回车符\n

https://api.weixin.qq.com/wxa/getuserriskrank
wxba6223c06417af7b
79ba700ea147819f640941bceb38b1d1
1635927956
{"iv":"r2WDQt56rEAmMuoR","data":"HExs66Ik3el+iM4IpeQ7SMEN934FRLFYOd3EmeaIrpP4EPTHckoco6O+PaoRZRa3lqaPRZT7r52f7LUok6gLxc6cdR8C4vpIIfh4xfLC4L7FNy9GbuMK1hcoi8b7gkWJcwZMkuCFNEDmqn3T49oWzAQOrY4LZnnnykv6oUJotdAsnKvmoJkLK7hRh7M2B1d2UnTnRuoIyarXc5Iojwoghx4BOvnV","authtag":"z2BFD8QctKXTuBlhICGOjQ=="}

计算签名串M原始哈希值H0

hex(H0) = 8e5e53475c7acdeec85b10cb50da8059dc2301ecd99f19d71fc6665b475c6cd4

根据平台证书编号获取签名数据,并使用验签接口校验签名

base64_encode(S) = GsToqir0e4Vk2wDEGZ56rH8cQoE0BLSBJliweFmNdjSpb/5JWaNcW0AdaHi4gZKgmmE5TvM2as294HQwacn0DCkv3oX3gBSqZj/ZAlUKBiydTKOll1NR+p0FqI4lvPIWH//CAZRsHkKGlNPUIn5+UpqkEFctWM8N6PvPGgar397MiDrAqL9wfF62lPbhc8WUK6yEpE0ByQ69szU4uMYkBcbgZZ2T9vNQ6F5OrMJxfVYlI6FCu2ka/eo2oKFTYjKM0ayMsLIBFcDWO0lXXBoqoH28V7KwSlRvr2cWR6ChlYHeNzE80E4kWKGegkD5y/Loe+DemWUGxmj5uShPvZxjCg==

示例代码

// RSAwithSHA256
const crypto = require("crypto")

// 仅做演示,敏感信息请勿硬编码
function getCtx() {
    let ctx = {
        local_certificate: "-----BEGIN CERTIFICATE-----\nMIID0jCCArqgAwIBAgIUeE+Yy7vM/o+eHHsfM+1bGJJEZTQwDQYJKoZIhvcNAQEL\nBQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT\nFFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg\nQ0EwHhcNMjIwOTA1MDgzOTIyWhcNMjcwOTA0MDgzOTIyWjBkMRswGQYDVQQDDBJ3\neGQ5MzBlYTVkNWEyNThmNGYxFTATBgNVBAoMDFRlbmNlbnQgSW5jLjEOMAwGA1UE\nCwwFV3hnTXAxCzAJBgNVBAYMAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJ\nKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM5D9qlkCmk1kr3FpF0e9pc3kGsvz5RA\n0/YRny9xPKIyV2UVMDZvRQ+mDHsiQQFE6etg457KFYSxTDKtItbdl6hJQVGeAvg0\nmqPYE9SkHRGTfL/AnXRbKBG2GC2OcaPSAprsLOersjay2me+9pF8VHybV8aox78A\nNsU75G/OO3V1iEE0s5Pmglqk8DEiw9gB/dGJzsNfXwzvyJyiUP9ZujYexyjsS+/Z\nGdSOUkqL/th+16yHj8alcdyga6YGfWEDyWkt/i/B28cwx4nzwk8xgrurifPaLuMk\n0+9wJQLCfAn/f7zyHrC8PcD1XvvRt9VBNMBASXs3710ODyyVf2lkMgkCAwEAAaOB\ngTB/MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgTwMGUGA1UdHwReMFwwWqBYoFaGVGh0\ndHA6Ly9ldmNhLml0cnVzLmNvbS5jbi9wdWJsaWMvaXRydXNjcmw/Q0E9MUJENDIy\nMEU1MERCQzA0QjA2QUQzOTc1NDk4NDZDMDFDM0U4RUJEMjANBgkqhkiG9w0BAQsF\nAAOCAQEAL2MK9tYu+ljLVBlSbfEeaKyF07TN+G31Ya5NBzeS1ZCx4joUEIyACWmG\nfUkKNKiKV+EMzxeEhKRso1Qif3E7Ipl+PQBoQw6OSR/jFHciYurnGR9CLkL03Zo1\nqw1Xetv9OipsvlpA0SOWc207e/XpGdm8C7FMXM6bzvVp8I/STTjC1vqjIZu9WavI\nRgGM4jyAPz2XogUq0BNijef8BXbbav9fAsXjHSwn5BQv4iLms3fiLm/eoyQ6dZ2R\noTudrlcyr1bG4vwETLmHF+3yfVp9dpvJ+lyfiviwDwyfa8t2WlJm27DuF4vWoxir\nmjgj9tDutIFqxLIovLyg3uiAYtSQ/Q==\n-----END CERTIFICATE-----",
        local_sn: "79ba700ea147819f640941bceb38b1d1",
        local_appid: "wxba6223c06417af7b",
        url_path: "https://api.weixin.qq.com/wxa/getuserriskrank"
    }
    return ctx
}

function getResp() {
    let resp = {
        resp_appid: "wxba6223c06417af7b",
        resp_ts: 1635927956,
        resp_sn: "79ba700ea147819f640941bceb38b1d1",
        resp_sig: "GsToqir0e4Vk2wDEGZ56rH8cQoE0BLSBJliweFmNdjSpb/5JWaNcW0AdaHi4gZKgmmE5TvM2as294HQwacn0DCkv3oX3gBSqZj/ZAlUKBiydTKOll1NR+p0FqI4lvPIWH//CAZRsHkKGlNPUIn5+UpqkEFctWM8N6PvPGgar397MiDrAqL9wfF62lPbhc8WUK6yEpE0ByQ69szU4uMYkBcbgZZ2T9vNQ6F5OrMJxfVYlI6FCu2ka/eo2oKFTYjKM0ayMsLIBFcDWO0lXXBoqoH28V7KwSlRvr2cWR6ChlYHeNzE80E4kWKGegkD5y/Loe+DemWUGxmj5uShPvZxjCg==",
        resp_deprecated_sn: "2171af9cdf1d7404423852e7e183d852",
        resp_deprecated_sig: "Lu8OL0UiHRNYSH1z21O2ir634MiTL1HS9pjoGnunsF7d5IDbTueP54jtWfxjSxfEsqprapD1c9d4fA5GoIYl1ChYAsRY2PKW5uAFZQy2reGPKoHjx5wArqRr+BqpGvREKAZyiiOH9dvo04R9xJ9YRlpNHVl6I2GWRWzwchcx57R3WsuW6Ot+RMUcEzZjPaI+0Pq+LRMlotmSNv1a0W6aLcLKb/dfsXjAU0RZ6luZs7Qg7x6DEaUx2ytkyaX9iqhLOE47ck50lCg4bthNZUZnDtrpJPFu9QZ0T0vwbb6eQvHQ5fAq/dCYfDm2GrZ2MSawlJJi+BhrhpcMiocV4Ex/Yg==",
        resp_data: '{"iv":"r2WDQt56rEAmMuoR","data":"HExs66Ik3el+iM4IpeQ7SMEN934FRLFYOd3EmeaIrpP4EPTHckoco6O+PaoRZRa3lqaPRZT7r52f7LUok6gLxc6cdR8C4vpIIfh4xfLC4L7FNy9GbuMK1hcoi8b7gkWJcwZMkuCFNEDmqn3T49oWzAQOrY4LZnnnykv6oUJotdAsnKvmoJkLK7hRh7M2B1d2UnTnRuoIyarXc5Iojwoghx4BOvnV","authtag":"z2BFD8QctKXTuBlhICGOjQ=="}'
    }
    return resp
}

function checkSignature(ctx, resp) {
    const { local_certificate, local_sn, local_appid, url_path } = ctx // 开发者本地信息
    const { resp_appid, resp_ts, resp_sn, resp_sig, resp_deprecated_sn, resp_deprecated_sig, resp_data } = resp // API响应数据,包括响应头与响应数据

    const local_ts = Math.floor(Date.now() / 1000)

    // 安全检查,根据业务实际需求判断
    if (local_appid != resp_appid || // 回包appid不正确
        local_ts - resp_ts > 300) { // 回包时间超过5分钟
        console.error("安全字段校验失败")
        return false
    }

    let signature = ''
    if (local_sn === resp_sn) {
        signature = resp_sig
    } else if (local_sn === resp_deprecated_sn) { // 本地证书编号与即将过期编号一致,需及时更换
        console.warn("平台证书即将过期,请及时更换")
        signature = resp_deprecated_sig
    } else {
        console.error("sn不匹配")
        return false
    }

    const payload = `${url_path}\n${resp_appid}\n${local_sn}\n${resp_ts}\n${resp_data}`
    const data_buffer = Buffer.from(payload, 'utf-8')
    const sig_buffer = Buffer.from(signature, 'base64')
    const key_obj = {
        key: local_certificate,
        padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
        saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST
    }

    const result = crypto.verify(
        'RSA-SHA256',
        data_buffer,
        key_obj,
        sig_buffer
    )

    return result
}


const ctx = getCtx()
const resp = getResp()

let res = checkSignature(ctx, resp)
console.log(res)

#
解密

响应数据的加密算法、格式与请求时一致,可参考请求加密。

# 示例

风控接口为例, 对原请求数据加密。

# AES256_GCM

密钥信息

{
    "Sn": "fa05fe1e5bcc79b81ad5ad4b58acf787",
    "Key": "otUpngOjU+nVQaWJIC3D/yMLV17RKaP6t4Ot9tbnzLY="
}

响应密文数据

{
    "iv": "r2WDQt56rEAmMuoR",
    "data": "HExs66Ik3el+iM4IpeQ7SMEN934FRLFYOd3EmeaIrpP4EPTHckoco6O+PaoRZRa3lqaPRZT7r52f7LUok6gLxc6cdR8C4vpIIfh4xfLC4L7FNy9GbuMK1hcoi8b7gkWJcwZMkuCFNEDmqn3T49oWzAQOrY4LZnnnykv6oUJotdAsnKvmoJkLK7hRh7M2B1d2UnTnRuoIyarXc5Iojwoghx4BOvnV",
    "authtag": "z2BFD8QctKXTuBlhICGOjQ=="
}

解密后数据

{
    "_n": "ShYZpqdVgY+yQVAxNSWhYg",
    "_appid": "wxba6223c06417af7b",
    "_timestamp": 1635927956,
    "errcode": 0,
    "errmsg": "getuserriskrank succ",
    "risk_rank": 0,
    "unoin_id": 2258658297
}

原响应数据

{
    "_timestamp": 1635927956,
    "errcode": 0,
    "errmsg": "getuserriskrank succ",
    "risk_rank": 0,
    "unoin_id": 2258658297
}

示例代码

// AES256_GCM
const crypto = require("crypto")

// 仅做演示,敏感信息请勿硬编码
function getCtx() {
    let ctx = {
        local_sym_key: "otUpngOjU+nVQaWJIC3D/yMLV17RKaP6t4Ot9tbnzLY=",
        local_sym_sn: "fa05fe1e5bcc79b81ad5ad4b58acf787",
        local_appid: "wxba6223c06417af7b",
        url_path: "https://api.weixin.qq.com/wxa/getuserriskrank"
    }
    return ctx
}

function getResp() {
    let resp = {
        resp_ts: 1635927956,
        resp_data: '{"iv":"r2WDQt56rEAmMuoR","data":"HExs66Ik3el+iM4IpeQ7SMEN934FRLFYOd3EmeaIrpP4EPTHckoco6O+PaoRZRa3lqaPRZT7r52f7LUok6gLxc6cdR8C4vpIIfh4xfLC4L7FNy9GbuMK1hcoi8b7gkWJcwZMkuCFNEDmqn3T49oWzAQOrY4LZnnnykv6oUJotdAsnKvmoJkLK7hRh7M2B1d2UnTnRuoIyarXc5Iojwoghx4BOvnV","authtag":"z2BFD8QctKXTuBlhICGOjQ=="}'
    }
    return resp
}

function getRealResp(ctx, resp) {
    const { local_sym_key, local_sym_sn, local_appid, url_path } = ctx // 开发者本地信息
    const { resp_ts, resp_data } = resp // API响应数据,解密只需要响应头时间戳与响应数据

    const { iv, data, authtag } = JSON.parse(resp_data)

    const aad = `${url_path}|${local_appid}|${resp_ts}|${local_sym_sn}`

    const real_aad = Buffer.from(aad, "utf-8")
    const real_key = Buffer.from(local_sym_key, "base64")
    const real_iv = Buffer.from(iv, "base64")
    const real_data = Buffer.from(data, "base64")
    const real_authtag = Buffer.from(authtag, "base64")

    const decipher = crypto.createDecipheriv("aes-256-gcm", real_key, real_iv)
    decipher.setAAD(real_aad)
    decipher.setAuthTag(real_authtag)

    let decipher_update = decipher.update(real_data)
    let decipher_final
    try {
        decipher_final = decipher.final()
    } catch (error) {
        console.error("auth tag验证失败")
        return {}
    }

    const real_deciphertext = Buffer.concat([decipher_update, decipher_final])

    const deciphertext = real_deciphertext.toString("utf-8")
    const real_resp = JSON.parse(deciphertext)

    if (
        // 安全检查,根据业务实际需求判断
        real_resp["_appid"] != local_appid || // appid不匹配
        real_resp["_timestamp"] != resp_ts || // timestamp与Wechatmp-TimeStamp不匹配
        resp_ts - real_resp["_timestamp"] > 300 // 响应数据的时候与当前时间超过5分钟
    ) {
        console.error("安全字段校验失败")
        return {}
    }

    return real_resp
}

const ctx = getCtx()
const resp = getResp()

let res = getRealResp(ctx, resp)
console.log(res)

# 其他常见问题

# Q1: 什么是对称密钥加密?

A1: 对称密钥加密指的是发送和接收数据的双方必须使用相同的密钥对明文进行加密和解密运算。当开发者对接口的请求内容进行对称密钥加密,针对平台回复报文,开发者需要使用对称密钥解密。

# Q2:什么是非对称密钥加签验签?

A2:非对称密钥指的是发送者和接收者都有一对唯一对应的密钥:公钥和私钥,公钥对外公开,私钥由个人秘密保存。开发者在应用中使用自己的私钥生成请求的签名,平台会在收到请求后使用应用的公钥进行签名的验证,开发者则使用平台证书中的公钥验证回调的签名。使用此方式,进行数据通信的双方可以安全地确认对方身份和公开密钥,提供通信的可鉴别性。

# Q3:新密钥配置好后,老密钥什么时候失效?

A3:平台设置的自动失效时间为24小时,成功配置的24小时后,老密钥将自动失效,若有需要,也可手动点击"立即失效"。

# Q4:新密钥配置好后,可以立即重新修改吗?

A4:配置后不支持立即修改,请在24小时后,重新进行配置。

# Q5:生成并配置好密钥后,可以取消吗?

A5:配置后不支持删除,只能更新修改。若不使用,可以不做相关操作,并不影响调用接口,后续需要的时候再使用即可。