Xác thực chữ ký (Signature)

Nội dung Markdown đầy đủ của trang tài liệu.

# Xác thực chữ ký (Signature)

Collection Link sử dụng **HMAC-SHA256** với `secretKey` để xác thực chuỗi dữ liệu đã ký. Kết quả là chuỗi **hex viết thường, dài 64 ký tự**.

HMAC không mã hóa nội dung: dữ liệu vẫn đọc được. Chữ ký chỉ bảo vệ các trường nằm trong chuỗi ký, không tự động bảo vệ toàn bộ JSON. Gửi request qua HTTPS và giữ `secretKey` ở backend.

## Chọn đúng chuỗi ký

| Thao tác | Trường chữ ký | Hướng xử lý |
| --- | --- | --- |
| [Tạo đơn V1](/api/collection-link) | `signature` | Merchant ký request gửi Pay2S |
| [Tạo đơn V2](/api/collection-link-v2) | `signature` | Ký thêm `extraData` và `signatureVersion=2` |
| [Hủy đơn](/api/cancel-order) | `signature` | Ký riêng với `requestType=cancel` |
| [Mô phỏng thanh toán demo](/api/simulate-payment) | `signature` | Ký riêng với `requestType=simulatePayment` |
| [Nhận IPN](/api/instant-payment-notification) | `m2signature` | Merchant tính lại rồi so sánh |

Không sắp xếp và ký tất cả khóa JSON. **Dùng đúng danh sách và thứ tự trường của từng thao tác bên dưới.** Các API dùng Bearer token hoặc `pay2s-token` có cơ chế xác thực riêng.

## Quy tắc chung

1. Lấy `accessKey`, `partnerCode`, `secretKey` từ cùng bộ khóa của tài khoản hoặc cửa hàng.
2. Ghép `tênTrường=giáTrị` bằng dấu `&`, đúng thứ tự.
3. Tính HMAC-SHA256 trên chuỗi UTF-8 với `secretKey`, xuất hex viết thường.
4. Gửi kết quả trong trường chữ ký. Không gửi `secretKey` trong payload.

- Không URL-encode chuỗi ký, không ký toàn bộ JSON, không thêm dấu cách hoặc xuống dòng.
- Ký đúng giá trị gửi đi: URL, dấu `/` cuối URL, chữ hoa/thường và dấu tiếng Việt đều ảnh hưởng kết quả.
- Dùng số tiền dạng `2200`, không dùng `2.200`, `2,200` hoặc `2.200 đ`.
- Với create, `bankAccounts=Array` là **chuỗi cố định theo giao thức**, không phải JSON danh sách ngân hàng. Payload vẫn gửi `bankAccounts` dạng mảng.
- Không đưa `signature` / `m2signature` vào chuỗi ký của chính nó.

## Tạo đơn V1

V1 cũ không đưa `extraData` và `signatureVersion` vào chuỗi ký:

```text
accessKey={accessKey}&amount={amount}&bankAccounts=Array&ipnUrl={ipnUrl}&orderId={orderId}&orderInfo={orderInfo}&partnerCode={partnerCode}&redirectUrl={redirectUrl}&requestId={requestId}&requestType={requestType}
```

Payload có thể bỏ `signatureVersion` hoặc gửi `"1"`. **Không được bỏ `signature`.** Không cần thêm `extraData` cho luồng V1 cũ.

::: info Tương thích V1
Backend còn chấp nhận biến thể V1 có `extraData` không rỗng được ký theo cấu trúc V2 bên dưới nhưng kết thúc bằng `&signatureVersion=1`. Metadata hóa đơn trong V1 không được dùng để đăng ký xuất hóa đơn. Khi tích hợp dữ liệu HĐĐT, dùng V2.
:::

## Tạo đơn V2

Gửi `signatureVersion: "2"` và `extraData` không rỗng trong payload:

```text
accessKey={accessKey}&amount={amount}&bankAccounts=Array&extraData={extraData}&ipnUrl={ipnUrl}&orderId={orderId}&orderInfo={orderInfo}&partnerCode={partnerCode}&redirectUrl={redirectUrl}&requestId={requestId}&requestType={requestType}&signatureVersion=2
```

`extraData = Base64(JSON UTF-8 của metadata)`. Tạo giá trị này **một lần**, dùng cùng chuỗi để ký và gửi; không decode/re-encode sau khi ký. Xem schema tại [Collection Link V2](/api/collection-link-v2).

::: warning Các trường dễ nhầm
Tên đúng là `extraData` và `signatureVersion`, không phải `extractData`, `extraxdata` hay `signature_version`. Ký `signatureVersion=2` nhưng bỏ trường này trong payload sẽ khiến backend mặc định V1 và chữ ký không khớp.
:::

### Node.js: tạo chữ ký V1/V2

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

function signCreate(p, secretKey) {
  const version = String(p.signatureVersion ?? '1');
  if (!['1', '2'].includes(version)) throw new Error('Unsupported version');
  if (version === '2' && !p.extraData) throw new Error('V2 requires extraData');
  let raw = `accessKey=${p.accessKey}&amount=${p.amount}&bankAccounts=Array`;
  if (version === '2') raw += `&extraData=${p.extraData}`;
  for (const field of ['ipnUrl', 'orderId', 'orderInfo', 'partnerCode', 'redirectUrl', 'requestId', 'requestType']) {
    raw += `&${field}=${p[field]}`;
  }
  if (version === '2') raw += '&signatureVersion=2';
  return createHmac('sha256', secretKey).update(raw, 'utf8').digest('hex');
}

// payload là body create; metadata theo schema Collection Link V2.
// Với V2, thực hiện trước khi ký:
// payload.signatureVersion = '2';
// payload.extraData = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64');
// payload.signature = signCreate(payload, process.env.PAY2S_SECRET_KEY);
// Gửi chính payload này, không sửa các trường đã ký.
```

### PHP: tạo chữ ký V1/V2

```php
function signCreate(array $p, string $secretKey): string
{
    $version = (string)($p['signatureVersion'] ?? '1');
    if (!in_array($version, ['1', '2'], true)) {
        throw new InvalidArgumentException('Unsupported version');
    }
    if ($version === '2' && empty($p['extraData'])) {
        throw new InvalidArgumentException('V2 requires extraData');
    }
    $raw = 'accessKey=' . $p['accessKey'] . '&amount=' . $p['amount'] . '&bankAccounts=Array';
    if ($version === '2') $raw .= '&extraData=' . $p['extraData'];
    foreach (['ipnUrl', 'orderId', 'orderInfo', 'partnerCode', 'redirectUrl', 'requestId', 'requestType'] as $field) {
        $raw .= '&' . $field . '=' . $p[$field];
    }
    if ($version === '2') $raw .= '&signatureVersion=2';
    return hash_hmac('sha256', $raw, $secretKey);
}

// $payload['signatureVersion'] = '2';
// $payload['extraData'] = base64_encode(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
// $payload['signature'] = signCreate($payload, getenv('PAY2S_SECRET_KEY'));
```

Hai hàm trên dùng chuỗi V1 cũ cho version 1 và chuỗi đầy đủ cho version 2. Có thể dùng [SDK Node.js/PHP](/api/collection-link-v2-sdk) để tạo request V2 và xác thực IPN.

## Hủy đơn hàng

Hủy đơn dùng cùng bộ khóa của create nhưng có chuỗi ký riêng:

```text
accessKey={accessKey}&orderId={orderId}&partnerCode={partnerCode}&requestId={requestId}&requestType=cancel
```

`orderId` là mã gửi lúc tạo đơn; `requestId` là mã yêu cầu hủy. Không thêm `amount`, `extraData`, `signatureVersion` hoặc tái sử dụng chữ ký create.

Xem [API hủy đơn hàng](/api/cancel-order) để lấy payload, script Postman và quy tắc xử lý đơn đã thanh toán.

## Xác thực IPN

Để tự động kiểm thử với đơn demo, xem [API mô phỏng thanh toán](/api/simulate-payment). API đó ghi nhận đơn rồi để worker gửi IPN; khác request Postman giả lập callback trực tiếp tới hệ thống của bạn.

IPN sử dụng `m2signature`, với chuỗi khác request create:

```text
accessKey={accessKey}&amount={amount}&extraData={extraData}&message={message}&orderId={orderId}&orderInfo={orderInfo}&orderType={orderType}&partnerCode={partnerCode}&payType={payType}&requestId={requestId}&responseTime={responseTime}&resultCode={resultCode}&transId={transId}
```

Dùng `accessKey` cấu hình ở backend và giá trị **nhận trong IPN** cho các trường còn lại. Không thay `orderInfo` bằng giá trị từ request create: V2 có thể trả nội dung thanh toán do Pay2S sinh. Giữ đúng chuỗi `extraData` nhận được; trường rỗng vẫn phải có `&extraData=` trong chuỗi ký.

So sánh HMAC bằng `hash_equals()` (PHP) hoặc `timingSafeEqual()` (Node.js). Kiểm tra chữ ký nhận là 64 ký tự hex trước khi chuyển thành buffer. SDK có sẵn hàm xác thực IPN.

Chữ ký hợp lệ chưa đủ để giao hàng: đối chiếu `partnerCode`, `orderId`, số tiền với đơn đã lưu, kiểm tra `resultCode` và bảo đảm mỗi thanh toán chỉ xử lý một lần. Không xác nhận thanh toán chỉ dựa vào URL chuyển hướng của trình duyệt.

## Kiểm tra thư viện HMAC

Bộ dữ liệu công khai dưới đây chỉ kiểm tra thuật toán, **không phải request Pay2S hợp lệ**:

```text
secretKey: key
rawSignature: The quick brown fox jumps over the lazy dog
expectedSignature: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
```

```js
createHmac('sha256', 'key')
  .update('The quick brown fox jumps over the lazy dog', 'utf8')
  .digest('hex');
```

```php
hash_hmac('sha256', 'The quick brown fox jumps over the lazy dog', 'key');
```

## Chẩn đoán lỗi chữ ký

| Hiện tượng | Kiểm tra |
| --- | --- |
| V1 cũ ngừng khớp | Không tự thêm `extraData` / `signatureVersion` vào raw V1 cũ |
| V2 báo sai chữ ký | Payload có `signatureVersion: "2"`; ký cùng `extraData` đã gửi |
| V2 có `extraData: ""` | V2 yêu cầu metadata Base64 không rỗng |
| Sai khi thêm ngân hàng | Raw create dùng `bankAccounts=Array`, không JSON.stringify mảng |
| Cùng dữ liệu nhưng chữ ký khác | Kiểm tra thứ tự trường, UTF-8, URL, xuống dòng, khoảng trắng, định dạng số |
| Ký đúng nhưng không xác thực được | Bộ accessKey/secretKey có thuộc tài khoản hoặc cửa hàng tương ứng partnerCode không |
| IPN luôn không khớp | Dùng `m2signature` và chuỗi IPN; không thay bằng chuỗi create |
| Hủy đơn không khớp | Ký mới với `requestType=cancel` |

Debug bằng cách so sánh chuỗi trước HMAC ở môi trường kiểm thử. Không đưa secretKey, thông tin khách hàng hay request thật lên log công khai hoặc công cụ tạo HMAC bên ngoài.