From 840157ca9d4fbbed0e7efc95db0504290041a82e Mon Sep 17 00:00:00 2001 From: Council Date: Thu, 23 Apr 2026 12:35:10 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20PHASE=5F4=5FPLAN.m?= =?UTF-8?q?d=20-=20=E8=AE=B0=E5=BD=95=20Feistel-8=E2=86=92HMAC-XOR=20?= =?UTF-8?q?=E7=AE=97=E6=B3=95=E5=8F=98=E6=9B=B4=20+=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PHASE_4_PLAN.md | 331 +++++++++++++++++++++++++++++++------------ 1 file changed, 242 insertions(+), 89 deletions(-) diff --git a/docs/PHASE_4_PLAN.md b/docs/PHASE_4_PLAN.md index a23f514..31e2897 100644 --- a/docs/PHASE_4_PLAN.md +++ b/docs/PHASE_4_PLAN.md @@ -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 | 12(4+8,HMAC-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_code(UUID) $ticket_code = BaseService::generateUuid(); + $now = BaseService::now(); - // 3. 生成 QR payload(JWT签名格式) - $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 payload(HMAC-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 | + +--- + +## 十三、重大变更记录 + +### 变更 #1:Feistel-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-XOR(XOR 本身对合,无需逆向轮) + +**新旧方案对比**: + +| 属性 | 旧 Feistel-8(废弃) | 新 HMAC-XOR(当前) | +|------|---------------------|-------------------| +| 轮函数 | `hash_hmac('sha256', R.'.'.round, key)` 字符串拼接 | `hash_hmac('sha256', pack('V', i), key)` 二进制 pack | +| 位分配 | L=19bit, R=17bit(36bit) | L=21bit, R=19bit(40bit,实际用36bit) | +| 逆向方式 | 8轮逆向(7→0),F 输入反复出错 | 相同顺序(0→7),XOR 对合无需逆 | +| 往返测试 | ❌ 全部失败(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_id(10亿,1/1) +5. Per-goods key 隔离(1/1) +6. 边界条件(goods_id 超出范围 / ticket_id=0,2/3) + +**⚠️ 注意**:测试使用 `test-key-12345678` 作为 feistel crypt key(与 per-goods key 派生逻辑分开),实际运行时 goods_id=118 对应的 per-goods key 由 `HMAC-SHA256(master_secret, 118)` 派生,两者不同。单元测试验证的是算法本身,不验证 key 派生。