# HTML 表单签名

买家可以直接在浏览器中改动表单的任何字段——例如把金额改小。因此收款单字段放在签名容器中传输：服务会重新计算签名，不相符就不创建收款单。

签名由店铺服务器计算。发送到浏览器的只有算好的 `data` 与 `signature` 这对值。

## 签名的对象

```
data      = base64url(json)
signature = HMAC-SHA256(data, formSecret)
```

HMAC 基于 `data` 字符串计算，而不是 JSON 的字节。接收端先按提交的字符串原样验证签名，之后才解码。

由此可知没有规范化处理：键顺序、JSON 缩进和 Unicode 转义风格都不影响签名。任何标准 JSON 编码器都可用。

## 参考数据

把这些值代入代码，对比输出。

示例密钥：

```
dvc3khto5WpjEPojZfYJsbJzFEPPVnRa
```

金额和支付期限以数字表示；字符串同样可以：

```json
{"projectId":"a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d","amountFiat":10.5,"currencyFiat":"USD","timeToPay":1,"description":"Order #7, delivery","serviceData":"order-7"}
```

`data` 字符串是上述字节的 base64url。这里去掉了填充；接收端也接受带填充的形式：

```
eyJwcm9qZWN0SWQiOiJhMWIyYzNkNC01ZTZmLTRhN2ItOGM5ZC0wZTFmMmEzYjRjNWQiLCJhbW91bnRGaWF0IjoxMC41LCJjdXJyZW5jeUZpYXQiOiJVU0QiLCJ0aW1lVG9QYXkiOjEsImRlc2NyaXB0aW9uIjoiT3JkZXIgIzcsIGRlbGl2ZXJ5Iiwic2VydmljZURhdGEiOiJvcmRlci03In0
```

用上面的密钥对该字符串的签名：

```
09628020c47400c9231420450af642adfdb3c246f33e3e12cb8e28f96c2a785e
```

使用这些值时，下面的示例会输出与此完全一致的 `data` 和签名。同一 JSON 在末尾加上 `"output":"errors"` 键后，`data` 字符串和签名都会不同。

如果 JSON 看起来相同而 `data` 不一致，原因在 JSON 编码器：键顺序不同，或分隔符后带空格。接收端会接受这样的容器，只是与参考值不再逐字一致。

## 示例

**PHP**

```php showLineNumbers
<?php

function signedContainer(array $fields, string $formSecret): array {
    $data = rtrim(strtr(base64_encode(json_encode($fields)), '+/', '-_'), '=');

    return [
        'data'      => $data,
        'signature' => hash_hmac('sha256', $data, $formSecret),
    ];
}

// Test values from the reference set. Before going live, 
// replace the secret with the form secret from your project settings
$container = signedContainer([
    'projectId'    => 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    'amountFiat'   => 10.5,
    'currencyFiat' => 'USD',
    'timeToPay'    => 1,
    'description'  => 'Order #7, delivery',
    'serviceData'  => 'order-7',
], 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');

echo $container['data'], "\n";
echo $container['signature'], "\n";
```

**TypeScript**

```typescript showLineNumbers
import { createHmac } from 'node:crypto';

// Values are JSON strings or numbers, optional keys may be omitted
export interface FormFields {
    projectId: string;
    amountFiat: string | number;
    currencyFiat: string;
    timeToPay: string | number;
    description?: string;
    serviceData?: string;
    output?: string;
}

export interface FormContainer {
    data: string;
    signature: string;
}

export const signedContainer = (fields: FormFields, formSecret: string): FormContainer => {
    const data = Buffer.from(JSON.stringify(fields), 'utf8').toString('base64url');

    return {
        data,
        signature: createHmac('sha256', formSecret).update(data, 'utf8').digest('hex'),
    };
};

// Test values from the reference set. Before going live, 
// replace the secret with the form secret from your project settings
const container = signedContainer({
    projectId:    'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    amountFiat:   10.5,
    currencyFiat: 'USD',
    timeToPay:    1,
    description:  'Order #7, delivery',
    serviceData:  'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');

console.log(container.data);
console.log(container.signature);
```

**JavaScript**

这是店铺服务器端的代码，不是浏览器端的：签名在存放密钥的地方计算。

```js showLineNumbers
import { createHmac } from 'node:crypto';

const signedContainer = (fields, formSecret) => {
    const data = Buffer.from(JSON.stringify(fields), 'utf8').toString('base64url');

    return {
        data,
        signature: createHmac('sha256', formSecret).update(data, 'utf8').digest('hex'),
    };
};

// Test values from the reference set. Before going live, 
// replace the secret with the form secret from your project settings
const container = signedContainer({
    projectId:    'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    amountFiat:   10.5,
    currencyFiat: 'USD',
    timeToPay:    1,
    description:  'Order #7, delivery',
    serviceData:  'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');

console.log(container.data);
console.log(container.signature);
```

**Python**

```python showLineNumbers
import base64
import hashlib
import hmac
import json

def signed_container(fields: dict, form_secret: str) -> dict:
    # compact separators: the default ones add spaces after ',' and ':'
    body = json.dumps(fields, separators=(',', ':'))
    data = base64.urlsafe_b64encode(body.encode('utf-8')).decode().rstrip('=')

    return {
        'data': data,
        'signature': hmac.new(
            form_secret.encode('utf-8'), data.encode('utf-8'), hashlib.sha256
        ).hexdigest(),
    }

# Test values from the reference set. Before going live, 
# replace the secret with the form secret from your project settings
container = signed_container({
    'projectId':    'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    'amountFiat':   10.5,
    'currencyFiat': 'USD',
    'timeToPay':    1,
    'description':  'Order #7, delivery',
    'serviceData':  'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa')

print(container['data'])
print(container['signature'])
```

`separators` 参数是为了与参考值逐字一致。接收端对默认的 JSON 设置同样接受。

示例值都是 ASCII，但代码无需改动即可处理任何 Unicode。PHP 和 Python 默认把非 ASCII 转义为 `\uXXXX`，JavaScript 发送原始 UTF-8——接收端两者都接受。

## 两个常见错误

**签的是 JSON 而不是容器**。HMAC 基于 `data` 字符串，也就是 base64url 计算。这种错误的表现是“一切都对，唯独签名不符”。

**签名后重新生成了容器**。`data` 哪怕只变动一个字节，签名就会失效。在代码中的同一处生成这对值。

## 签名不符时

沿着 JSON → `data` → 签名的链条逐环排查；最先与参考值出现分歧的环节就是原因。先检查 `data`：它不依赖密钥。
