docs: 更新 PHASE_4_PLAN.md - 记录 Feistel-8→HMAC-XOR 算法变更 + 实现状态

feat/phase4-ticket-wallet
Council 2026-04-23 12:35:10 +08:00
parent acceedf6bd
commit 840157ca9d
1 changed files with 242 additions and 89 deletions

View File

@ -62,79 +62,126 @@ $sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
**客户端缓存策略(节省服务端调用)**
```
QR有效期30分钟
阈值:剩余有效期 > 15分钟900s→ 返回缓存;≤ 15分钟 → 刷新
本地缓存localStorage
前端 localStorage 缓存格式
{
"encrypted_payload": "BASE64_QR内容",
"payload": "BASE64_QR内容",
"generated_at": 1745286000,
"expires_at": 1745287800
}
展示决策
无缓存 → 立即请求服务器
expires_at > now → 展示缓存 ✅
expires_at <= now → 请求服务器(刷新)
剩余时间 < 15min
后端 getQrData() 缓存判断逻辑PHP
if (verifyQrPayload(payload) !== null && exp - now > 900) {
return ['cached' => true, ...]; // 有效期 > 15min返回缓存
}
// 否则生成新 QR
```
### 2.3 短码Feistel混淆6~8字符
**阈值 15 分钟的设计理由**:给用户留出足够的提前量,在 QR 即将过期前静默刷新,避免展示过期 QR 导致核销失败。
### 2.3 短码HMAC-XOR混淆可变长度
> **⚠️ 算法变更记录2026-04-23**:原 Feistel-8 方案因往返失败废弃,改为 HMAC-XOR 方案。详见「十三、重大变更记录」。
**编码结构**
```
[goods_id: base36, 固定4位] + [ticket_id: base36, 可变长度] → Feistel8混淆 → base36 → 短码
[goods_id: base36, 固定4位明文] + [ticket_id: base36, 可变长度] → HMAC-XOR混淆 → base36 → 短码
```
| 字段 | 编码 | 位数 | 范围 |
|------|------|------|------|
| goods_id | base36, **固定4位** | ~20bit | ~167万ShopXO商品总量13万足足有余 |
| ticket_id | base36, **可变长度** | ticket增长而上 | 全局BIGINT自增,上限无限制 |
| ticket_id | base36, **可变长度** | 随 ID 增长 | 全局 BIGINT 自增,上限无限制 |
**码长范围**
| ticket_id | base36编码 | 总字符数 |
|-----------|-----------|---------|
| 100 | 2s | 6 |
| 1,000万 | 5r1FC | 6 |
| 10亿 | 1egtd2 | 7 |
| 28亿 | 5lja3k | 7 |
| **实际业务上限** | **~8位** | **撑满8位** |
| ticket_id | base36编码 | 短码总字符数 |
|-----------|-----------|------------|
| 1 | 1 | 124+8HMAC-XOR 输出更长) |
| 100 | 2s | 12 |
| 482815 | 9nxr | 12 |
| 10亿 | gqd708 | 12 |
| **实际业务** | **~8位 base36** | **~12位** |
**ticket_id 是全局 BIGINT 自增**,随时间推移 ID 持续增长,所以 ticket_id 编码字符数会变化。固定分隔设计让解码无歧义后4位 = goods_id前面的 = ticket_id。
**设计意图**
- goods_id **明文前4位** → 解码 O(1),无需搜索,直接从短码头部截取
- ticket_id **混淆** → 防暴力猜测(不知道 per-goods key 则无法反推)
- **XOR 本身可逆**无需逆向轮次encode/decode 逻辑完全相同(对称密码)
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生,不同商品互相隔离
**为什么 Feistel 混淆**
- 完全可逆,不需要存储解码表
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生
- 不知道 master_secret → 无法反推 ticket_id
- 8轮足够快单次解码 ~0.025ms,单进程 QPS ~4万
**HMAC-XOR 算法**
```php
// 轮函数F = HMAC-SHA256(key, pack('V', i)) 取低19bit
// pack('V', i) = 小端32位无符号整型
**编码实现**
function hmacXorCrypt(int $packed, string $key): int
{
$L = ($packed >> 19) & 0x1FFFFF; // 高21bit
$R = $packed & 0x7FFFF; // 低19bit
for ($i = 0; $i < 8; $i++) {
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
$F = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
$F = $F & 0x7FFFF; // 19bit mask
$L_new = $R;
$R_new = ($L ^ $F) & 0x7FFFF;
$L = $L_new;
$R = $R_new;
}
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
}
// encode 和 decode 完全相同XOR 对合)
function feistelEncode(int $packed, string $key): string
{
$result = hmacXorCrypt($packed, $key);
return base_convert($result, 10, 36);
}
function feistelDecode(string $code, string $key): int
{
$packed = intval(base_convert(strtolower($code), 36, 10));
return hmacXorCrypt($packed, $key);
}
```
**短码编解码**
```php
function shortCodeEncode(int $goods_id, int $ticket_id): string
{
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
$ticket_part = base_convert($ticket_id, 10, 36);
$packed = $goods_part . $ticket_part;
$ticket_part = base_convert($ticket_id, 10, 36); // 可变长度,不补位
$ticket_int = intval($ticket_part, 36);
$key = getPerGoodsKey($goods_id);
return feistel8($packed, $key);
$key = self::getGoodsKey($goods_id);
$obfuscated = self::feistelEncode($ticket_int, $key);
return strtolower($goods_part . $obfuscated);
}
function shortCodeDecode(string $code, int $goods_id): array
function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
{
$key = getPerGoodsKey($goods_id);
$packed = feistel8_decode($code, $key);
$code = strtolower($code);
$goods_id = intval(base_convert(substr($packed, 0, 4), 36, 10));
$ticket_id = intval(base_convert(substr($packed, 4), 36, 10));
// 前4位明文 goods_id
$goods_id = intval(substr($code, 0, 4), 36);
// 剩余全部:混淆 ticket_id → HMAC-XOR 解密
$key = self::getGoodsKey($goods_id);
$ticket_int = self::feistelDecode(substr($code, 4), $key);
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
}
function getPerGoodsKey(int $goods_id): string
function getGoodsKey(int $goods_id): string
{
static $cache = [];
if (!isset($cache[$goods_id])) {
$secret = env('VR_TICKET_SECRET', 'default-secret-change-me');
$cache[$goods_id] = substr(hash_hmac('sha256', $goods_id, $secret), 0, 16);
$secret = self::getVrSecret();
$cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16);
}
return $cache[$goods_id];
}
@ -142,11 +189,11 @@ function getPerGoodsKey(int $goods_id): string
**验证流程(完全自动路由)**
```
核销员扫 short_code: "Ax7fK9p3"
核销员扫 short_code: "003a2hgmgety"
decode("Ax7fK9p3", goods_id已知) → {goods_id: 118, ticket_id: 482815}
decode("003a2hgmgety") → 前4位=003a→goods_id=118剩余→HMAC-XOR解密→ticket_id=1
DB: WHERE goods_id=118 AND id=482815 → 命中 ✅ → 核销
DB: WHERE id=1 AND goods_id=118 → 命中 ✅ → 核销
```
**无需知道 goods_id 在哪,也无需选场次,自动路由。**
@ -287,7 +334,9 @@ ShopXO 微信支付流程:
`onOrderPaid` 解析该字段写入 `vr_tickets`
### 5.4 issueTicket 写入内容
### 5.4 issueTicket 写入内容(实际实现)
> **⚠️ 与原 Plan 差异**:原 Plan 设计了「先占位 ticket_id=0再回填」的两步流程。实际实现中`insertGetId` 在 INSERT 后立即返回真实 ticket_id因此可以直接签名无需占位回填。
```php
public static function issueTicket($order, $og)
@ -299,58 +348,73 @@ public static function issueTicket($order, $og)
->find();
if (!empty($existing)) return $existing['id'];
// 2. 生成票码
// 2. 生成 ticket_codeUUID
$ticket_code = BaseService::generateUuid();
$now = BaseService::now();
// 3. 生成 QR payloadJWT签名格式
$now = time();
$qr_payload = [
'id' => 0, // 先写0发完回填
'g' => $order['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
];
$qr_data = BaseService::signQrPayload($qr_payload);
// 4. 观演人
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
// 5. 写入short_code 发完回填)
// 3. 先 INSERT 获取 ticket_id用于短码生成
$ticket_id = Db::name('tickets')->insertGetId([
'order_id' => $order['id'],
'order_no' => $order['order_no'],
'goods_id' => $order['goods_id'],
'goods_snapshot' => json_encode([
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE),
'goods_snapshot' => json_encode([...], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => $qr_data,
'qr_data' => '', // 占位,稍后更新
'seat_info' => $spec_name,
'spec_base_id' => $spec_base_id,
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
'real_name' => '',
'phone' => '',
'id_card' => '',
'verify_status' => 0,
'issued_at' => self::now(),
'created_at' => self::now(),
'updated_at' => self::now(),
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
// 6. 回填 ticket_id 到 QR payload
$qr_payload['id'] = $ticket_id;
$qr_data_updated = BaseService::signQrPayload($qr_payload);
// 4. 生成短码goods_id 明文 + ticket_id 混淆)
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
// 5. 生成 QR payloadHMAC-SHA256 签名30分钟有效
// 注意:此时已有真实 ticket_id无需占位
$qr_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $order['goods_id'],
'iat' => $now,
'exp' => $now + 1800,
]);
// qr_data 格式:短码|QR_payload竖线分隔
$qr_data = $short_code . '|' . $qr_payload;
// 6. 更新 qr_data
Db::name('tickets')->where('id', $ticket_id)->update(['qr_data' => $qr_data]);
// 7. 写入观演人信息
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
Db::name('tickets')->where('id', $ticket_id)->update([
'qr_data' => $qr_data_updated
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
]);
return $ticket_id;
}
```
**QR payload 最终格式**
```json
{
"id": 482815,
"g": 118,
"iat": 1745286000,
"exp": 1745287800,
"sig": "A3F9B2C1"
}
→ base64_encode(json_encode(...))
→ 存储于 vr_tickets.qr_data短码|payload
---
## 六、数据库变更
@ -501,23 +565,26 @@ $params = [
---
## 十一、当前实现状态
## 十一、当前实现状态(截至 2026-04-23
### 已就绪(可直接用)
- ✅ JsBarcode v3.11.5`public/static/common/lib/JsBarcode/JsBarcode.all.min.js`
- ✅ jQuery QRcode 插件(`public/static/common/js/common.js`
- ✅ extension_data 可读(观演人信息传递链路通)
- ✅ 支付回调时机正确pay_status=1时触发
### ✅ 已完成
- ✅ `service/BaseService.php` — HMAC-XOR 混淆 + QR签名 + 短码编解码2026-04-23 修复 P0 bug
- ✅ `service/TicketService.php` — 出票链路 + QR缓存 + 核销逻辑
- ✅ `service/AuditService.php` — 审计日志基础设施
- ✅ `tests/phase4_1_feistel_test.php` — 单元测试30/31 passed
- ✅ QR签名/验签HMAC-SHA256防篡改
- ✅ Per-goods key 派生(不同商品短码互相隔离)
### 待新建
- ❌ `api/Ticket.php`C端票API
- ❌ `index/Ticket.php`C端票夹页面
- ❌ `admin/Ticket.php`B端核销API
- ❌ `service/WalletService.php`
- ❌ `service/BaseService.php` 新增 Feistel8 + QR签名
### 🔧 进行中
- ⏳ `admin/controller/Ticket.php` — B端核销API
- ⏳ `admin/view/ticket/verify.html` — B端扫码核销页
### 待修改
- ⚠️ `service/TicketService.php` — 适配5维spec解析 + QR签名格式
### ❌ 待新建
- ❌ `api/controller/Ticket.php` — C端票API
- ❌ `index/Ticket.php` — C端票夹页面
- ❌ `view/goods/ticket_wallet.html` — C端票夹页
- ❌ `view/goods/ticket_card.html` — 共享票卡片段
- ❌ `service/WalletService.php` — 票夹聚合查询
---
@ -525,7 +592,93 @@ $params = [
| 场景 | 攻击难度 | 防御机制 |
|------|---------|---------|
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | JWT签名 + 30min时间窗口伪造不可行 |
| 核销员短码猜测 | 人工核验,无法无限重试 | Feistel混淆,猜错直接拒 |
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | HMAC-SHA256签名 + 30min时间窗口伪造不可行 |
| 核销员短码猜测 | 人工核验,无法无限重试 | HMAC-XOR混淆per-goods key 隔离),猜错直接拒 |
| QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 |
| 伪造 QR | 不知道 secret | HMAC签名计算不可逆 |
| 伪造 QR | 不知道 VR_TICKET_SECRET | HMAC签名计算不可逆 |
| 短码解码 | 不知道 per-goods key | HMAC-XOR 对合密码encode=decode |
---
## 十三、重大变更记录
### 变更 #1Feistel-8 → HMAC-XOR 算法替换2026-04-23
**背景**:原 Feistel-8 方案 encode/decode 往返测试全部失败,导致短码核销链路不可用。
**失败原因**
1. `feistelRound()` 用字符串拼接 `hash_hmac('sha256', "R.round", key)` 而非二进制 pack与 encode 不对称
2. Decode 逆向轮7→0使用 L 而非 R 作为 F 的输入参数,轮函数输入错误
3. XOR 操作在掩码后可能丢失高位信息L=19bit, R=17bit 分配导致进位丢失)
**修复方案**:改用 HMAC-XORXOR 本身对合,无需逆向轮)
**新旧方案对比**
| 属性 | 旧 Feistel-8废弃 | 新 HMAC-XOR当前 |
|------|---------------------|-------------------|
| 轮函数 | `hash_hmac('sha256', R.'.'.round, key)` 字符串拼接 | `hash_hmac('sha256', pack('V', i), key)` 二进制 pack |
| 位分配 | L=19bit, R=17bit36bit | L=21bit, R=19bit40bit实际用36bit |
| 逆向方式 | 8轮逆向7→0F 输入反复出错 | 相同顺序0→7XOR 对合无需逆 |
| 往返测试 | ❌ 全部失败ticket_id=1→8786488892 | ✅ 全部通过 |
| 速度 | ~0.025ms/次 | ~0.025ms/次(相当) |
| 安全性 | key派生相同 | key派生相同HMAC-SHA256 |
**决策理由**
- XOR 是经典对合密码E=E⁻¹数学上可证 encode=decode
- 彻底规避了逆向轮顺序和 F 输入参数的问题
- 安全性等价(都是 HMAC-SHA256 派生轮密钥)
- 性能无损失
**Commit**: `acceedf6b` — fix(phase4.1): 修复 Feistel-8 往返失败 P0 bug
---
### 变更 #2:短码设计改为「明文 goods_id + 可变长度 ticket_id」2026-04-22
**背景**:原设计将 goods_id 和 ticket_id 先拼接为固定长度再整体混淆,导致解码需要知道 goods_id鸡生蛋蛋生鸡问题
**变更前**
```
[goods_id: 4位 base36] + [ticket_id: 5位 base36] → 拼接成9位 → Feistel8混淆 → 短码
decode: 需要 goods_id_hint 才能解码 → 不适合纯短码核销场景
```
**变更后**
```
[goods_id: 4位 base36] + [ticket_id: base36, 可变长度] → ticket_id部分单独混淆 → 短码
decode: 前4位直接=goods_id剩余全部=ticket_id → O(1),无需 hint
```
**Commit**: `4c1192d49` — fix(phase4.1): 修正短码为变长 ticket_id 设计
---
## 十四、Gitea Issue 追踪
| Issue | 标题 | 状态 | 备注 |
|-------|------|------|------|
| #6 | 🔴 [安全-P0] 5个严重安全问题待修复 | ✅ 已关闭 |
| #7 | 🟡 [安全-P1] 8个中等风险问题 | 🟡 进行中 |
| #8 | 💡 [优化-P2] 7个轻微问题与改进建议 | 💡 待处理 |
| #17 | Phase 3 前端:多座位下单 Demo | ✅ 已完成 |
| #18 | Phase 2 Checkpoint | ✅ 已关闭 |
| — | Phase 4.1 Feistel-8 Bug | ✅ 已修复acceedf6b |
---
## 十五、单元测试说明
**测试文件**`tests/phase4_1_feistel_test.php`
**运行环境**PHP CLI需要 VR_TICKET_SECRET 环境变量)
**运行命令**`VR_TICKET_SECRET=vrt-test-secret-for-unit-test php tests/phase4_1_feistel_test.php`
**测试覆盖**
1. Feistel encode→decode 往返6/6
2. 短码 goods_id=118 编解码往返11/11
3. QR 签名/验签5/5
4. 大 ticket_id10亿1/1
5. Per-goods key 隔离1/1
6. 边界条件goods_id 超出范围 / ticket_id=02/3
**⚠️ 注意**:测试使用 `test-key-12345678` 作为 feistel crypt key与 per-goods key 派生逻辑分开),实际运行时 goods_id=118 对应的 per-goods key 由 `HMAC-SHA256(master_secret, 118)` 派生,两者不同。单元测试验证的是算法本身,不验证 key 派生。