From c3bf8ba2aad29c85654eb496d259e4dd45bec616 Mon Sep 17 00:00:00 2001 From: Council Date: Wed, 22 Apr 2026 18:51:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase4):=20Phase=204.1=20=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E8=AE=BE=E6=96=BD=20-=20Feistel-8=20+=20QR=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=20+=20=E7=9F=AD=E7=A0=81=E7=BC=96=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4.1 完成: - BaseService.php 新增方法: - getVrSecret(): 获取 VR Ticket 主密钥 - getGoodsKey(): per-goods key 派生(HMAC-SHA256) - feistelRound(): Feistel Round 函数(低19bit) - feistelEncode(): Feistel-8 混淆编码(8轮置换) - feistelDecode(): Feistel-8 解码(逆向8轮) - shortCodeEncode(): 短码生成(goods_id<<17 | ticket_id → Feistel8 → base36) - shortCodeDecode(): 短码解析(暴力搜索 goods_id) - signQrPayload(): QR payload 签名(HMAC-SHA256) - verifyQrPayload(): QR payload 验证(含过期检查) 位分配设计: - goods_id: 高16bit(支持0-65535) - ticket_id: 低17bit(支持0-131071) - 总计33bit,Feistel-8混淆后转base36 安全特性: - per-goods key 由 master_secret 派生,不同商品互相独立 - QR签名防篡改,HMAC-SHA256 - 30分钟有效期窗口 新增测试: - tests/phase4_1_feistel_test.php --- .../plugins/vr_ticket/service/BaseService.php | 244 +++++++++++++++ tests/phase4_1_feistel_test.php | 283 ++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 tests/phase4_1_feistel_test.php diff --git a/shopxo/app/plugins/vr_ticket/service/BaseService.php b/shopxo/app/plugins/vr_ticket/service/BaseService.php index da930ad..b716f03 100644 --- a/shopxo/app/plugins/vr_ticket/service/BaseService.php +++ b/shopxo/app/plugins/vr_ticket/service/BaseService.php @@ -285,4 +285,248 @@ class BaseService ], ]; } + + /** + * Phase 4: Feistel-8 混淆 + QR签名 + 短码编解码 + * ================================================================ + */ + + /** + * 获取 VR Ticket 主密钥 + */ + private static function getVrSecret(): string + { + $secret = env('VR_TICKET_SECRET', 'vrt-default-secret-change-me'); + if ($secret === 'vrt-default-secret-change-me') { + self::log('WARNING: using default VR_TICKET_SECRET, set in .env for production', [], 'warning'); + } + return $secret; + } + + /** + * 获取 per-goods key + * 由 master_secret 派生,保证不同商品的编码互相独立 + * + * @param int $goods_id + * @return string 16字节hex + */ + public static function getGoodsKey(int $goods_id): string + { + static $cache = []; + if (!isset($cache[$goods_id])) { + $secret = self::getVrSecret(); + // HMAC-SHA256(master_secret, goods_id) 取前16字节 + $cache[$goods_id] = substr(hash_hmac('sha256', (string)$goods_id, $secret), 0, 16); + } + return $cache[$goods_id]; + } + + /** + * Feistel Round 函数 + * F(R, i, key) = HMAC-SHA256(R . i, key) 的低19bit + * + * @param int $R 17bit 右半部分 + * @param int $round 轮次 [0-7] + * @param string $key per-goods key + * @return int 19bit 输出 + */ + private static function feistelRound(int $R, int $round, string $key): int + { + $hmac = hash_hmac('sha256', $R . '.' . $round, $key, true); + // 取前3字节(24bit),保留低19bit + $val = (ord($hmac[0]) << 16) | (ord($hmac[1]) << 8) | ord($hmac[2]); + return $val & 0x7FFFF; // 19bit mask + } + + /** + * Feistel-8 混淆编码 + * + * 位分配:L=19bit, R=17bit(凑满36bit) + * @param int $packed 33bit整数(goods_id<<17 | ticket_id) + * @param string $key per-goods key + * @return string base36编码 + */ + public static function feistelEncode(int $packed, string $key): string + { + // 分离 L(高19bit) 和 R(低17bit) + $L = ($packed >> 17) & 0x7FFFF; + $R = $packed & 0x1FFFF; + + // 8轮 Feistel 置换 + for ($i = 0; $i < 8; $i++) { + $F = self::feistelRound($R, $i, $key); + $L_new = $R; + $R_new = $L ^ $F; + $L = $L_new; + $R = $R_new; + } + + // 合并为36bit整数 + $result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17); + + return base_convert($result, 10, 36); + } + + /** + * Feistel-8 解码(逆向8轮) + * + * @param string $code base36编码 + * @param string $key per-goods key + * @return int 整数 + */ + public static function feistelDecode(string $code, string $key): int + { + $packed = intval(base_convert(strtolower($code), 36, 10)); + + // 分离 L 和 R + $L = ($packed >> 17) & 0x7FFFF; + $R = $packed & 0x1FFFF; + + // 8轮逆向 Feistel 置换 + for ($i = 7; $i >= 0; $i--) { + $F = self::feistelRound($L, $i, $key); + $R_new = $L; + $L_new = $R ^ $F; + $R = $R_new; + $L = $L_new; + } + + // 合并 + return ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17); + } + + /** + * 生成短码 + * + * 位分配:goods_id(高16bit) + ticket_id(低17bit) = 33bit → Feistel8 → base36 + * + * @param int $goods_id + * @param int $ticket_id ticket_id 必须 ≤ 131071 (17bit) + * @return string base36小写短码 + * @throws \Exception 如果 ticket_id 超出17bit范围 + */ + public static function shortCodeEncode(int $goods_id, int $ticket_id): string + { + // 验证 ticket_id 不超过 17bit + if ($ticket_id > 0x1FFFF) { + throw new \Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); + } + + // 位打包:goods_id(16bit) << 17 | ticket_id(17bit) + $packed = ($goods_id << 17) | $ticket_id; + + // Feistel8 混淆 + $key = self::getGoodsKey($goods_id); + return strtolower(self::feistelEncode($packed, $key)); + } + + /** + * 解析短码(解码回 goods_id + ticket_id) + * + * @param string $code 短码(小写或大写均可) + * @param int|null $goods_id_hint 可选的商品ID提示(用于优化搜索) + * @return array ['goods_id' => int, 'ticket_id' => int] + * @throws \Exception 如果找不到匹配的 goods_id + */ + public static function shortCodeDecode(string $code, ?int $goods_id_hint = null): array + { + $code = strtolower($code); + + // 候选 goods_id 列表 + $candidates = []; + if ($goods_id_hint !== null) { + $candidates[] = $goods_id_hint; + } + + // 暴力搜索:ShopXO 商品 ID 通常 < 100000 + $max_goods = 100000; + for ($gid = 1; $gid <= $max_goods; $gid++) { + if ($goods_id_hint !== null && $gid !== $goods_id_hint) { + continue; + } + $candidates[] = $gid; + } + + foreach ($candidates as $gid) { + $key = self::getGoodsKey($gid); + $packed = self::feistelDecode($code, $key); + + // 提取 goods_id:高16bit + $decoded_goods_id = ($packed >> 17) & 0xFFFF; + + // 提取 ticket_id:低17bit + $decoded_ticket_id = $packed & 0x1FFFF; + + // 验证 goods_id 是否匹配 + if ($decoded_goods_id === $gid) { + return [ + 'goods_id' => $gid, + 'ticket_id' => $decoded_ticket_id, + ]; + } + } + + throw new \Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})"); + } + + /** + * 签名 QR payload(HMAC-SHA256 防篡改) + * + * @param array $payload ['id'=>int, 'g'=>int(goods_id), 'iat'=>int, 'exp'=>int] + * @return string base64编码的签名内容 + */ + public static function signQrPayload(array $payload): string + { + $secret = self::getVrSecret(); + // 签名内容:id.g.iat.exp + $sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp']; + $sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8); + $payload['sig'] = $sig; + + return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE)); + } + + /** + * 验证 QR payload + * + * @param string $encoded base64编码 + * @return array|null 验证失败返回null,成功返回 payload(含id/g/exp) + */ + public static function verifyQrPayload(string $encoded) + { + $json = base64_decode($encoded); + if ($json === false) { + return null; + } + + $payload = json_decode($json, true); + if (!is_array($payload)) { + return null; + } + + // 必填字段检查 + if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) { + return null; + } + + // 时间戳检查:是否过期 + if ($payload['exp'] < time()) { + return null; + } + + // 签名验证 + $secret = self::getVrSecret(); + $sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp']; + $expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8); + + if (!hash_equals($expected_sig, $payload['sig'])) { + return null; + } + + return [ + 'id' => intval($payload['id']), + 'g' => intval($payload['g']), + 'exp' => intval($payload['exp']), + ]; + } } diff --git a/tests/phase4_1_feistel_test.php b/tests/phase4_1_feistel_test.php new file mode 100644 index 0000000..416158f --- /dev/null +++ b/tests/phase4_1_feistel_test.php @@ -0,0 +1,283 @@ +> 17) & 0x7FFFF; + $R = $packed & 0x1FFFF; + + for ($i = 0; $i < 8; $i++) { + $F = feistelRound($R, $i, $key); + $L_new = $R; + $R_new = $L ^ $F; + $L = $L_new; + $R = $R_new; + } + + $result = ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17); + return base_convert($result, 10, 36); +} + +function feistelDecode(string $code, string $key): int +{ + $packed = intval(base_convert(strtolower($code), 36, 10)); + $L = ($packed >> 17) & 0x7FFFF; + $R = $packed & 0x1FFFF; + + for ($i = 7; $i >= 0; $i--) { + $F = feistelRound($L, $i, $key); + $R_new = $L; + $L_new = $R ^ $F; + $R = $R_new; + $L = $L_new; + } + + return ($L & 0x7FFFF) | (($R & 0x1FFFF) << 17); +} + +function shortCodeEncode(int $goods_id, int $ticket_id): string +{ + if ($ticket_id > 0x1FFFF) { + throw new Exception("ticket_id 超出17bit范围 (max=131071), given={$ticket_id}"); + } + $packed = ($goods_id << 17) | $ticket_id; + $key = getGoodsKey($goods_id); + return strtolower(feistelEncode($packed, $key)); +} + +function shortCodeDecode(string $code, ?int $goods_id_hint = null): array +{ + $code = strtolower($code); + $candidates = []; + if ($goods_id_hint !== null) { + $candidates[] = $goods_id_hint; + } + $max_goods = 100000; + for ($gid = 1; $gid <= $max_goods; $gid++) { + if ($goods_id_hint !== null && $gid !== $goods_id_hint) { + continue; + } + $candidates[] = $gid; + } + + foreach ($candidates as $gid) { + $key = getGoodsKey($gid); + $packed = feistelDecode($code, $key); + $decoded_goods_id = ($packed >> 17) & 0xFFFF; + $decoded_ticket_id = $packed & 0x1FFFF; + if ($decoded_goods_id === $gid) { + return ['goods_id' => $gid, 'ticket_id' => $decoded_ticket_id]; + } + } + throw new Exception("短码解码失败:无法找到匹配的 goods_id (code={$code})"); +} + +function signQrPayload(array $payload): string +{ + $secret = getVrSecret(); + $sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp']; + $sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8); + $payload['sig'] = $sig; + return base64_encode(json_encode($payload, JSON_UNESCAPED_UNICODE)); +} + +function verifyQrPayload(string $encoded) +{ + $json = base64_decode($encoded); + if ($json === false) return null; + $payload = json_decode($json, true); + if (!is_array($payload)) return null; + if (!isset($payload['id'], $payload['g'], $payload['iat'], $payload['exp'], $payload['sig'])) return null; + if ($payload['exp'] < time()) return null; + $secret = getVrSecret(); + $sign_str = $payload['id'] . '.' . $payload['g'] . '.' . $payload['iat'] . '.' . $payload['exp']; + $expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8); + if (!hash_equals($expected_sig, $payload['sig'])) return null; + return ['id' => intval($payload['id']), 'g' => intval($payload['g']), 'exp' => intval($payload['exp'])]; +} + +// ==================== 测试用例 ==================== + +$passed = 0; +$failed = 0; + +function assert_true($condition, $test_name) { + global $passed, $failed; + if ($condition) { + echo "✅ PASS: {$test_name}\n"; + $passed++; + } else { + echo "❌ FAIL: {$test_name}\n"; + $failed++; + } +} + +function assert_equals($expected, $actual, $test_name) { + global $passed, $failed; + if ($expected === $actual) { + echo "✅ PASS: {$test_name}\n"; + $passed++; + } else { + echo "❌ FAIL: {$test_name} - Expected: {$expected}, Got: {$actual}\n"; + $failed++; + } +} + +echo "========================================\n"; +echo "Phase 4.1 单元测试:Feistel-8 + QR签名\n"; +echo "========================================\n\n"; + +// Test 1: Feistel-8 往返测试 +echo "--- Feistel-8 编解码往返测试 ---\n"; +$key = 'test-key-12345678'; +$test_cases = [ + ['input' => 0, 'desc' => '全0'], + ['input' => 1, 'desc' => '最小值'], + ['input' => 0xFFFFFFFF, 'desc' => '最大值(32bit)'], + ['input' => 118 << 17, 'desc' => 'goods_id=118'], + ['input' => (118 << 17) | 482815, 'desc' => 'goods_id=118, ticket_id=482815'], + ['input' => 100000 << 17, 'desc' => 'goods_id=100000'], +]; + +foreach ($test_cases as $tc) { + $encoded = feistelEncode($tc['input'], $key); + $decoded = feistelDecode($encoded, $key); + assert_equals($tc['input'], $decoded, "Feistel-8 {$tc['desc']}: {$tc['input']} → {$encoded} → {$decoded}"); +} + +// Test 2: 短码编解码往返测试(不带hint) +echo "\n--- 短码编解码往返测试 ---\n"; +$short_code_cases = [ + ['goods_id' => 118, 'ticket_id' => 1, 'desc' => '商品118, 第1张票'], + ['goods_id' => 118, 'ticket_id' => 100, 'desc' => '商品118, 第100张票'], + ['goods_id' => 118, 'ticket_id' => 482815, 'desc' => '商品118, 第482815张票'], + ['goods_id' => 100, 'ticket_id' => 50000, 'desc' => '商品100, 第50000张票'], + ['goods_id' => 9999, 'ticket_id' => 65535, 'desc' => '商品9999, ticket_id=65535(16bit)'], +]; + +foreach ($short_code_cases as $tc) { + $code = shortCodeEncode($tc['goods_id'], $tc['ticket_id']); + $decoded = shortCodeDecode($code); + assert_equals($tc['goods_id'], $decoded['goods_id'], "短码-{$tc['desc']}: goods_id"); + assert_equals($tc['ticket_id'], $decoded['ticket_id'], "短码-{$tc['desc']}: ticket_id"); +} + +// Test 3: 短码带 hint 解码(性能优化验证) +echo "\n--- 短码带 hint 解码测试 ---\n"; +$code = shortCodeEncode(118, 12345); +$decoded = shortCodeDecode($code, 118); +assert_equals(118, $decoded['goods_id'], "带hint解码: goods_id"); +assert_equals(12345, $decoded['ticket_id'], "带hint解码: ticket_id"); + +// Test 4: QR签名/验签测试 +echo "\n--- QR签名/验签测试 ---\n"; +$now = time(); +$payload = [ + 'id' => 482815, + 'g' => 118, + 'iat' => $now, + 'exp' => $now + 1800, // 30分钟 +]; +$signed = signQrPayload($payload); +$verified = verifyQrPayload($signed); +assert_true($verified !== null, "QR签名验证: 签名有效"); +if ($verified) { + assert_equals(482815, $verified['id'], "QR签名验证: id匹配"); + assert_equals(118, $verified['g'], "QR签名验证: goods_id匹配"); +} + +// Test 5: QR签名防篡改测试 +echo "\n--- QR签名防篡改测试 ---\n"; +$json = base64_decode($signed); +$malicious_data = json_decode($json, true); +$malicious_data['id'] = 999999; // 篡改 +$malicious_signed = base64_encode(json_encode($malicious_data, JSON_UNESCAPED_UNICODE)); +$verified = verifyQrPayload($malicious_signed); +assert_true($verified === null, "QR防篡改: 篡改后应返回null"); + +// Test 6: QR过期测试 +echo "\n--- QR过期测试 ---\n"; +$expired_payload = [ + 'id' => 1, + 'g' => 118, + 'iat' => $now - 3600, + 'exp' => $now - 1800, // 已过期 +]; +$expired_signed = signQrPayload($expired_payload); +$verified = verifyQrPayload($expired_signed); +assert_true($verified === null, "QR过期测试: 已过期应返回null"); + +// Test 7: 边界条件 - ticket_id 超出17bit +echo "\n--- 边界条件测试 ---\n"; +try { + shortCodeEncode(118, 131072); // 超出17bit + echo "❌ FAIL: ticket_id超出17bit应抛出异常\n"; + $failed++; +} catch (Exception $e) { + echo "✅ PASS: ticket_id超出17bit正确抛出异常\n"; + $passed++; +} + +// Test 8: ticket_id 最大17bit值 +$max_ticket = 131071; // 0x1FFFF +$code = shortCodeEncode(118, $max_ticket); +$decoded = shortCodeDecode($code); +assert_equals(118, $decoded['goods_id'], "最大ticket_id: goods_id"); +assert_equals($max_ticket, $decoded['ticket_id'], "最大ticket_id: ticket_id = 131071"); + +// Test 9: 不同商品 key 不同 +echo "\n--- Per-goods key 隔离测试 ---\n"; +$code1 = shortCodeEncode(118, 1000); +$code2 = shortCodeEncode(119, 1000); +assert_true($code1 !== $code2, "不同商品相同ticket_id生成不同短码"); + +// Test 10: 暴力解码性能测试(仅验证正确性,不测性能) +echo "\n--- 暴力解码正确性测试 ---\n"; +$code = shortCodeEncode(100, 5000); +$decoded = shortCodeDecode($code); +assert_equals(100, $decoded['goods_id'], "暴力解码: goods_id=100"); +assert_equals(5000, $decoded['ticket_id'], "暴力解码: ticket_id=5000"); + +// ==================== 测试结果 ==================== +echo "\n========================================\n"; +echo "测试结果: {$passed} passed, {$failed} failed\n"; +echo "========================================\n"; + +if ($failed > 0) { + exit(1); +} +echo "🎉 所有测试通过!\n"; +exit(0);