# 签名认证及加密传输


本文档为小程序开发者与平台之间的通信提供两大安全能力：

签名认证：应用于支付接口，采用 ECC P-256 + ECDSA_SHA256 证书签名方案，通过 X-DB-AuthorizationHeader 携带签名，实现请求参数防伪造、防篡改、防重放，确保支付链路的双向身份可信。

加密传输：应用于手机号等敏感数据下发，采用 ECIES 混合加密（ECDH 密钥协商 + AES-256-GCM），平台使用开发者公钥封装对称密钥并加密数据，开发者使用私钥解密，保证手机号全程密文传输。

两者能力都依赖于开发者前置配置的开发者证书。

# 证书配置

在使用签名认证能力和加密传输之前需要先完成证书配置

小程序开发者侧（以下简称开发者）：

开发者生成一对 ECC P-256 密钥对：dev_priv / dev_pub

```sql
# 生成 P-256 私钥
openssl ecparam -genkey -name prime256v1 -noout -out dev.key
```

开发者用 dev_priv 生成 CSR，提交给平台

```sql
# 生成 CSR
openssl req -new -key dev.key -out dev.csr \
  -subj "/CN=XXXXX/O=XXXXX" # XXXXX 需要开发者根据情况自行确定
```

平台签发开发者证书 dev_cert，并将证书传递给开发者

> 图片说明：图片展示的是小程序开发者后台的证书配置界面。左侧导航栏中“开发”下“开发设置”被红色框突出显示。右侧“证书管理”页面显示小程序ID及App Secret，下方“证书管理”区域有“应用证书”和“平台证书”，其中“应用证书”下“生成证书”按钮被红色框突出显示。该图片与文档中证书配置部分内容相关，直观呈现了开发者生成应用证书的操作位置。

开发者云端保存：

- dev_priv（私钥，严格保护）
- dev_cert（开发者证书）

证书策略：

- 开发者证书有效期 1 年，到期前 30 天会通知进行更换。
- 平台证书暂定为长期有效，如果需要更换时会通知开发者侧。

得到证书后根据证书获取证书序列号有一下两种方法

本地命令行

```
openssl x509 -in ./app.pem -noout -text
```

> 图片说明：图片展示的是使用openssl x509命令解析开发者证书dev_cert时的输出结果。命令为“openssl x509 -in ./app.pem -noout -text”，其中“app.pem”为开发者证书文件。输出中关键信息为“Serial Number: 18:00:b4:fd:05:1e:6e:27:24:64:4a:9f”，该序列号用于获取证书序列号，是获取证书序列号的本地命令行方法之一，与文档中介绍的证书序列号获取方法相呼应。

让豆包解析

> 图片说明：图片展示的是字节跳动自研的ECC根CA证书解析界面。界面中显示了证书的序列号、格式、类型等基础信息，主体和颁发者完全一致，均为ByteDance Smart Device Root，组织为ByteDance，部门为ByteDance Smart Device。有效期方面，生效时间为2024年10月22日07:04:15，过期时间为2045年12月31日16:00:00，总有效期约7740天（超21年）。该图片与文档中证书配置部分内容相关，用于说明开发者获取证书序列号的一种方法。

# 签名算法

## 概述

- 签名算法：`ECDSA_SHA256`
- 椭圆曲线：P-256
- ECDSA 签名格式：ASN.1 DER；不得自行拼接固定长度的 `r || s`
- Signature 文本编码：RFC 4648 URL-safe Base64，**保留编码器生成的 `=` padding**

开发者签名使用的私钥必须与上传 CSR 中的公钥匹配，`cert_serial` 必须来自这套密钥对应的当前开发者证书。DER 签名长度可能变化，不要假设签名固定为 64、70、71 或 72 字节。

## 待签名字符串构造规则

```shell
sign_content = HTTP_METHOD + "\n" +
               REQUEST_PATH + "\n" +
               CANONICAL_QUERY + "\n" +
               BODY_SHA256_HEX + "\n" +
               TIMESTAMP + "\n" +
               NONCE
```

- `HTTP_METHOD`：大写，如 `POST`、`GET`。
- `REQUEST_PATH`：以 `/` 开头，不含域名和 query，例如 `/requestOrder`。
- `CANONICAL_QUERY`：解析 query 后按 key 字典序排序；同一 key 的多个 value 也按字典序排序，再按 `key=value` 用 `&` 连接。值使用 URL 解码后的原始值；没有 query 时为空字符串。
- `BODY_SHA256_HEX`：对实际发送的 Body 原始 UTF-8 字节计算 SHA-256，输出小写十六进制且不带前缀。
- `TIMESTAMP`：秒级 Unix 时间戳字符串，例如 `1720000000`，不是毫秒时间戳。
- `NONCE`：使用密码学安全随机源生成，每次请求必须重新生成并且不得复用；当前 SDK 使用 16 个 URL-safe 字符。

拼接结果固定包含五个 `\n`。Query 为空时仍要保留第三行空行，末尾不额外添加换行。Body 在计算 Hash 后不得重新序列化或修改。

示例：

```text
POST
/requestOrder

<body_sha256_hex>
1720000000
b0c9f1e2a3d4e5f6
```

上例第 3 行是空 Query，不能删除。

## ECDSA DER 与 Signature 编码

ECDSA 运算输出必须是 ASN.1 DER 字节。将 DER 字节编码为 Signature 时，使用 URL-safe 字符表 `-`、`_`，不能出现标准 Base64 的 `+`、`/`；同时保留编码器根据 DER 长度自动生成的零个、一个或两个 `=`。不要手工删除、猜测或补充 padding。

当前平台签名协议对应以下编码 API：

```go
signature := base64.URLEncoding.EncodeToString(derSignature)
```

```javascript
function toPaddedBase64URL(derSignature) {
  return Buffer.from(derSignature)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}
```

```java
String signature = Base64.getUrlEncoder().encodeToString(derSignature);
```

```python
signature = base64.urlsafe_b64encode(der_signature).decode("ascii")
```

不要使用 Go `base64.RawURLEncoding`、Java `.withoutPadding()` 或 Node.js `Buffer.toString("base64url")` 直接生成当前协议的 Signature，因为这些 API 会移除 padding。某些 DER 长度刚好不需要 padding，错误实现可能偶尔成功；这不代表编码符合协议。

## 避免重复 SHA-256

canonical request 只做一次 SHA-256。根据加密库 API 选择以下一种方式，不要混用：

- 高层 `SHA256withECDSA` API：把 canonical request 原文传给库，由库内部执行 SHA-256 和 ECDSA。
- 低层 ECDSA digest API：先计算 `SHA256(canonical_request)`，再把 32 字节 digest 交给 ECDSA 签名函数。

Go 的低层示例：

```go
digest := sha256.Sum256([]byte(canonicalRequest))
derSignature, err := ecdsa.SignASN1(rand.Reader, privateKey, digest[:])
```

Node.js 的高层示例：

```javascript
const derSignature = crypto.sign(
  'sha256',
  Buffer.from(canonicalRequest, 'utf8'),
  { key: privateKey, dsaEncoding: 'der' },
);
```

不要先手工计算 SHA-256，再把 digest 传给会内部执行 SHA-256 的 `SHA256withECDSA` API，否则会产生双重哈希并导致验签失败。

## 签名头字段规范

生成 `X-DB-Authorization` 时应严格采用以下单行格式和字段顺序，不插入额外空格、换行或折行。不要依赖某些接收端对字段顺序或空格的宽松解析：

```text
algorithm="ECDSA_SHA256",version="1",signature="<PADDED_BASE64URL_DER_SIGNATURE>",cert_serial="<CERT_SERIAL>",nonce="<NONCE>",timestamp="<UNIX_SECONDS>"
```

- `signature`：DER 签名的 padded Base64URL 字符串，末尾可能包含 `=`。
- `cert_serial`：十六进制证书序列号。平台 → 开发者时填平台证书序列号；开发者 → 平台时填开发者证书序列号。
- `nonce`、`timestamp`：必须和 canonical request 中使用的值完全相同。

前端支付 API 的 `dbAuthorization` 承载同样格式的授权串，但它是 API 参数而不是开发者自行发送的 HTTP Header。

## 签名生成步骤

1. 生成秒级 timestamp 和本次请求唯一的随机 nonce。
2. 使用实际 method、path、query 和最终 Body 字节构造 canonical request。
3. 根据所用加密 API 选择“原文 + SHA256withECDSA”或“SHA-256 digest + 低层 ECDSA”之一。
4. 使用自己的 ECC P-256 私钥生成 ASN.1 DER 签名。
5. 使用 padded Base64URL 编码 DER 字节，得到 `signature`。
6. 使用当前开发者证书序列号组装单行 `X-DB-Authorization` 或 `dbAuthorization`。
7. Body、nonce、timestamp 或路径发生变化时必须重新签名。

## 签名验证步骤

1. 从 `X-DB-Authorization` 获取 algorithm、version、cert_serial、timestamp、nonce 和 signature。开发者自实现验签时必须拒绝缺失字段，以及不支持的 algorithm 或 version；不要仅因为某个 SDK 解析器能够提取字段就跳过协议校验。
2. 根据 cert_serial 精确选择对应证书（platform_cert 或 dev_cert），并校验证书有效期。
3. 校验 timestamp 时间窗口和 nonce 防重放；多实例使用共享的原子去重存储。
4. 使用实际收到的 method、path、query 和原始 Body 字节重建 canonical request。
5. 使用 padded Base64URL 解码 signature，得到 ASN.1 DER 字节。
6. 按同一种哈希方式得到 digest，使用证书中的 P-256 公钥执行 ECDSA DER 验签。
7. 验签通过后才解析 Body 并进入业务处理。

## 常见错误

| 错误 | 典型结果 |
| --- | --- |
| 把 ECDSA 签名编码成固定长度 `r || s` | DER 解析或 ECDSA 验签失败 |
| 使用标准 Base64 的 `+`、`/` | Signature 解码失败 |
| 使用 Raw Base64URL 主动删除 `=` | 当前平台验签在需要 padding 的 DER 长度下解码失败 |
| 手工补固定数量的 `=` | DER 长度变化时出现偶发失败 |
| 对 Body 解析后重新序列化 | Body Hash 不一致 |
| Path 包含域名或 Query | canonical request 不一致 |
| Query key/value 排序不同或漏掉空 Query 行 | canonical request 不一致 |
| timestamp 使用毫秒 | 时效校验失败 |
| nonce 重复 | 防重放校验失败 |
| 私钥与 CSR / 开发者证书不匹配 | 平台验签失败 |
| 对 digest 再调用 SHA256withECDSA | 双重哈希导致验签失败 |
| Authorization 中增加换行或改变字段格式 | Header 解析失败 |

## 联调前本地自检

不要把“平台验签失败”作为第一层调试手段。发出请求前按以下顺序本地检查，可以更快定位问题：

1. **检查密钥关系**：分别从开发者私钥、CSR 和开发者证书导出公钥，确认三者一致；并确认 Authorization 中的 `cert_serial` 来自该证书，而不是平台证书。
2. **固定一组测试输入**：保存 method、path、原始 query、最终 Body 字节、timestamp 和 nonce。逐行打印 canonical request 的调试副本，并单独核对 Body SHA-256；空 Query 行也必须可见。
3. **检查签名容器**：对 Signature 做 padded Base64URL 解码，确认结果可按 ASN.1 DER 解析为两个整数 `r`、`s`，而不是 64 字节的裸 `r || s`。
4. **先做本地验签**：使用开发者证书中的公钥，对同一 canonical request 本地验签。只有本地验签通过后再调用平台。
5. **检查传输字节**：抓取或拦截实际请求，确认发送时的 path、query、Body、Authorization 与签名输入完全相同；特别检查 JSON 中间件是否重排字段，以及前端是否删除 Signature 末尾的 `=`。
6. **最后检查时效和环境**：确认使用秒级 timestamp、全新 nonce，以及当前环境对应的证书、域名和回调配置。沙箱中还要确认模拟器录入的 CSR 与服务端签名私钥配对。

生产日志不得记录私钥、完整 canonical request、完整 Body、完整 Signature 或完整 Authorization。建议只记录脱敏订单号、`cert_serial`、Body Hash、Signature 字节长度、是否带 padding、验签阶段和平台 log ID；完整材料只在不含真实用户数据的本地固定测试中临时使用。

## 支付调用认证流程

> 图片：原文此处包含图片或流程图。

签名方使用自己的私钥对请求签名，生成 X-DB-Authorization

签名方（请求方）在请求 Header 中附带 X-DB-Authorization

验证方：

- 从 X-DB-Authorization 解析 cert_serial、timestamp、nonce、signature
- 依据序列号获取签名方证书中的公钥
- 完成证书有效性检查
- 验签通过后将请求视为来自可信平台

# 敏感数据加密传输

对于需要加密传输的信息，采用ECC + 对称加密的混合加密机制（ECIES），可以保证：

返回结构稳定不变（只包含一个字段）

可平滑支持未来新增数据字段，无需变更协议

所有敏感数据始终处于加密状态，不暴露在 JSON 明文中

## 手机号加密下发算法

为确保返回给开发者的业务数据不会在传输或日志中泄露，平台采用 整体加密 data 结构 的模式：

- data 内部可以包含多个字段（如手机号、加密 token、时间戳等）
- 平台将 data 对象序列化为 JSON 字符串
- 使用随机生成的对称密钥（dataKey）对 data 进行 AES-256-GCM 加密
- 使用开发者证书中的 ECC P-256 公钥进行密钥封装（ECDH），对 dataKey 进行加密封装
- 最终将所有密文组件编码为一个字段：cipher_data

### 解密流程（开发者侧）

#### Step 1：解析返回字段

```
blob = Base64URL_Decode(cipher_data)
encrypted_data = TLV_Deserialize(blob)
```

解析得到：

- version
- ephemeral_pub_key
- data_key_cipher
- data_cipher

#### Step 2：解封装 dataKey

开发者使用自己的 ECC P-256 私钥 dev_priv：

计算共享密钥：

```
shared_secret = ECDH(dev_priv, ephemeral_pub_key)
```

对共享密钥进行 SHA-256 哈希，得到 KEK：

```
kek = SHA256(shared_secret)
```

使用 KEK 解密 data_key_cipher，得到 dataKey

#### Step 3：解密业务数据

```
data_plain = AES256_GCM_Decrypt(
    key = dataKey,
    cipher = data_cipher
)
```

#### Step 4：解析 data JSON（可选，有些加密数据可能不是 Json 格式）

```
data = JSON.parse(data_plain)
```

即可获得明文数据结构，例如：

```json
{
  "phoneNumber": "138xxxxxxxx", // 用户绑定的手机号（国外手机号会有区号）
  "purePhoneNumber": "138xxxxxxxx", // 没有区号的手机号
  "countryCode": "86", // 区号
  "token_info": {      // 平台 -> 三方的授权信息
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx",
    "expires_in": 7200,
    "refresh_token": "def50200a1b2c3d4e5f6xxxxx",
    "refresh_token_expires_in": 2592000,
    "token_type": "Bearer",
    "scope": ["user_info", "express_track"]
  },
  "watermark": {
      "appid": "ttxxxxxxxxxxxxxxxx",
      "timestamp": 15000000000000000
    }
}
```

### 解密代码示例（Java）

```java
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.AlgorithmParameters;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

/**
 * ECC + AES-GCM 解密工具类
 *
 * <p>
 * 与 Go 侧 EncryptWithCert 完全兼容：
 * <ul>
 *   <li>ECDH P-256（secp256r1）</li>
 *   <li>KEK = SHA256(sharedSecret)</li>
 *   <li>AES-256-GCM（iv || cipher || tag）</li>
 *   <li>TLV 编码</li>
 * </ul>
 *
 * <p><b>安全注意：</b>
 * <ul>
 *   <li>私钥必须安全保存（如 KMS / HSM / 加密文件系统）</li>
 *   <li>不要将私钥内容硬编码在代码中或提交到代码仓库</li>
 * </ul>
 */
public final class ECCDecryptor {

    /** TLV 类型定义（需与加密端一致） */
    private static final int TLV_VERSION = 0x0001;
    private static final int TLV_EPHEMERAL_PUB_KEY = 0x0002;
    private static final int TLV_DATA_KEY_CIPHER = 0x0003;
    private static final int TLV_DATA_CIPHER = 0x0004;

    private ECCDecryptor() {
    }

    /**
     * 解密接口
     *
     * @param cipherDataBase64Url 平台返回的 Base64URL 编码密文
     * @param privateKeyPemPath   开发者 ECDSA P-256 私钥（PKCS#8 PEM 文件路径）
     * @return 解密后的业务数据明文（JSON 字节）
     * @throws Exception 解密失败
     */
    public static byte[] decrypt(
            String cipherDataBase64Url,
            String privateKeyPemPath
    ) throws Exception {

        // 1. Base64URL 解码
        byte[] blob = Base64.getUrlDecoder().decode(cipherDataBase64Url);

        // 2. TLV 解析
        Map<Integer, byte[]> tlvMap = parseTLV(blob);

        // 3. 校验协议版本
        int version = ByteBuffer.wrap(tlvMap.get(TLV_VERSION))
                .order(ByteOrder.BIG_ENDIAN)
                .getShort();
        if (version != 1) {
            throw new IllegalArgumentException("Unsupported version: " + version);
        }

        byte[] ephemeralPubBytes = tlvMap.get(TLV_EPHEMERAL_PUB_KEY);
        byte[] dataKeyCipher = tlvMap.get(TLV_DATA_KEY_CIPHER);
        byte[] dataCipher = tlvMap.get(TLV_DATA_CIPHER);

        // 4. 加载开发者私钥（请确保私钥安全存储，不要硬编码）
        ECPrivateKey privateKey = loadECPrivateKey(privateKeyPemPath);

        // 5. 构造临时公钥
        PublicKey ephemeralPubKey = buildECPublicKey(ephemeralPubBytes);

        // 6. ECDH 计算共享密钥
        byte[] sharedSecret = ecdh(privateKey, ephemeralPubKey);

        // 7. KEK = SHA256(sharedSecret)
        MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
        byte[] kek = sha256.digest(sharedSecret);

        // 8. 解密 DataKey
        byte[] dataKey = aesGcmDecrypt(kek, dataKeyCipher);

        // 9. 解密业务数据
        return aesGcmDecrypt(dataKey, dataCipher);
    }

    /**
     * TLV 解析
     *
     * <pre>
     * Type   uint16 (BigEndian)
     * Length uint32 (BigEndian)
     * Value  bytes
     * </pre>
     */
    private static Map<Integer, byte[]> parseTLV(byte[] data) {
        Map<Integer, byte[]> map = new HashMap<>();
        ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN);

        while (buf.remaining() > 0) {
            int type = Short.toUnsignedInt(buf.getShort());
            int length = buf.getInt();
            byte[] value = new byte[length];
            buf.get(value);
            map.put(type, value);
        }
        return map;
    }

    /**
     * 从 PEM 文件加载 ECDSA P-256 私钥（PKCS#8）
     *
     * <p><b>安全提示：</b>
     * 私钥应存储在安全介质中（如 KMS / HSM / 加密文件系统），
     * 不应明文保存在代码仓库或配置文件中。
     */
    private static ECPrivateKey loadECPrivateKey(String pemPath) throws Exception {
        String pem = new String(Files.readAllBytes(Paths.get(pemPath)), StandardCharsets.UTF_8);
        pem = pem.replace("-----BEGIN PRIVATE KEY-----", "")
                 .replace("-----END PRIVATE KEY-----", "")
                 .replaceAll("\\s", "");

        byte[] der = Base64.getDecoder().decode(pem);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
        return (ECPrivateKey) KeyFactory.getInstance("EC").generatePrivate(spec);
    }

    /**
     * 构造 ECC P-256 公钥（X9.62 非压缩格式）
     *
     * <pre>
     * 04 || X(32 bytes) || Y(32 bytes)
     * </pre>
     */
    private static PublicKey buildECPublicKey(byte[] pubBytes) throws Exception {
        byte[] xBytes = Arrays.copyOfRange(pubBytes, 1, 33);
        byte[] yBytes = Arrays.copyOfRange(pubBytes, 33, 65);

        BigInteger x = new BigInteger(1, xBytes);
        BigInteger y = new BigInteger(1, yBytes);

        AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
        parameters.init(new ECGenParameterSpec("secp256r1"));
        ECParameterSpec ecSpec = parameters.getParameterSpec(ECParameterSpec.class);

        ECPublicKeySpec pubSpec = new ECPublicKeySpec(new ECPoint(x, y), ecSpec);
        return KeyFactory.getInstance("EC").generatePublic(pubSpec);
    }

    /**
     * 执行 ECDH 密钥协商
     */
    private static byte[] ecdh(PrivateKey privateKey, PublicKey publicKey) throws Exception {
        KeyAgreement ka = KeyAgreement.getInstance("ECDH");
        ka.init(privateKey);
        ka.doPhase(publicKey, true);
        return ka.generateSecret();
    }

    /**
     * AES-256-GCM 解密
     *
     * <pre>
     * 输入格式：iv(12 bytes) || cipher || tag
     * </pre>
     */
    private static byte[] aesGcmDecrypt(byte[] key, byte[] cipherData) throws Exception {
        byte[] iv = Arrays.copyOfRange(cipherData, 0, 12);
        byte[] cipherTextWithTag = Arrays.copyOfRange(cipherData, 12, cipherData.length);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        GCMParameterSpec spec = new GCMParameterSpec(128, iv);
        SecretKey aesKey = new SecretKeySpec(key, "AES");

        cipher.init(Cipher.DECRYPT_MODE, aesKey, spec);
        return cipher.doFinal(cipherTextWithTag);
    }
}
```
