diff --git a/shopxo/app/plugins/vr_ticket/service/BaseService.php b/shopxo/app/plugins/vr_ticket/service/BaseService.php index 58db9cf..1b86970 100644 --- a/shopxo/app/plugins/vr_ticket/service/BaseService.php +++ b/shopxo/app/plugins/vr_ticket/service/BaseService.php @@ -342,51 +342,41 @@ class BaseService } /** - * 混淆编码(HMAC-XOR,保证可逆) + * 混淆编码 V1(遗留算法,存在未引入 $R 的缺陷,仅作向后兼容) * * @param int $packed 输入整数 * @param string $key per-goods key * @return string base36编码 */ - public static function feistelEncode(int $packed, string $key): string + public static function feistelEncodeV1(int $packed, string $key): string { - // 对 36-bit 输入进行 8 轮 HMAC-XOR 混淆 $L = ($packed >> 19) & 0x1FFFFF; $R = $packed & 0x7FFFF; 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]); - // XOR 交换 $L_new = $R; $R_new = ($L ^ $F) & 0x7FFFF; $L = $L_new; $R = $R_new; } - // 合并 $result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); return base_convert($result, 10, 36); } /** - * 混淆解码(与 encode 相同,XOR 本身可逆) - * - * @param string $code base36编码 - * @param string $key per-goods key - * @return int 整数 + * 混淆解码 V1 */ - public static function feistelDecode(string $code, string $key): int + public static function feistelDecodeV1(string $code, string $key): int { $packed = intval(base_convert(strtolower($code), 36, 10)); - // 分离 L 和 R $L = ($packed >> 19) & 0x1FFFFF; $R = $packed & 0x7FFFF; - // 8轮 XOR 混淆(与 encode 相同顺序,XOR 本身可逆) 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]); @@ -397,7 +387,49 @@ class BaseService $R = $R_new; } - // 合并 + return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); + } + + /** + * 混淆编码 V2(正确引入了 $R) + */ + public static function feistelEncodeV2(int $packed, string $key): string + { + $L = ($packed >> 19) & 0x1FFFFF; + $R = $packed & 0x7FFFF; + + for ($i = 0; $i < 8; $i++) { + $F = self::feistelRound($R, $i, $key); + $L_new = $R; + $R_new = ($L ^ $F) & 0x7FFFF; + $L = $L_new; + $R = $R_new; + } + + $result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); + return base_convert($result, 10, 36); + } + + /** + * 混淆解码 V2 + * + * 解密必须严格逆向执行轮次(7→0),与编码的(0→7)完全相反。 + */ + public static function feistelDecodeV2(string $code, string $key): int + { + $packed = intval(base_convert(strtolower($code), 36, 10)); + + $L = ($packed >> 19) & 0x1FFFFF; + $R = $packed & 0x7FFFF; + + for ($i = 7; $i >= 0; $i--) { + $F = self::feistelRound($L, $i, $key); + $R_new = $L; + $L_new = ($R ^ $F) & 0x1FFFFF; + $R = $R_new; + $L = $L_new; + } + return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); } @@ -409,7 +441,7 @@ class BaseService * - 后部:ticket_id 可变长度 base36,随 ticket_id 增长自动变长 * - 解码 O(1):前4位=goods_id,剩余全部=ticket_id(无需固定分隔) * - * @param int $goods_id 0-1679615 + * @param int $goods_id 0-16777215 * @param int $ticket_id 任意正整数(可变长度) * @return string base36短码 * @throws \Exception goods_id 超范围时抛出 @@ -431,10 +463,13 @@ class BaseService // ticket_id 可变长度(不填充) $ticket_part = base_convert($ticket_id, 10, 36); - // ticket_id 混淆 + // V2 混淆:注入魔数 0x5A 到高 8 位(第28~35位) $ticket_int = intval($ticket_part, 36); + $magic = 0x5A; + $packed = ($magic << 28) | $ticket_int; + $key = self::getGoodsKey($goods_id); - $obfuscated = self::feistelEncode($ticket_int, $key); + $obfuscated = self::feistelEncodeV2($packed, $key); // 拼接:前4位明文 goods_id + 变长混淆 ticket_id return strtolower($goods_part . $obfuscated); @@ -468,9 +503,21 @@ class BaseService // 用 goods_id 派生 key $key = self::getGoodsKey($goods_id); - // 后部:变长混淆 ticket_id → Feistel 解密 + // 后部:变长混淆 ticket_id → 尝试用 V2 解密 $ticket_part = substr($code, 4); - $ticket_int = self::feistelDecode($ticket_part, $key); + + $unpacked = self::feistelDecodeV2($ticket_part, $key); + + // 检查魔数 0x5A + $magic = 0x5A; + if (($unpacked >> 28) === $magic) { + // 匹配 V2 的新码 + $ticket_int = $unpacked & 0xFFFFFFF; + } else { + // 魔数不匹配,属于旧码,回退使用 V1 解码 + $ticket_int = self::feistelDecodeV1($ticket_part, $key); + } + // 转回 base36 字符串(不填充) $ticket_id = intval(base_convert($ticket_int, 10, 36), 36); diff --git a/tests/phase4_1_feistel_test.php b/tests/phase4_1_feistel_test.php index 66354da..3e00a27 100644 --- a/tests/phase4_1_feistel_test.php +++ b/tests/phase4_1_feistel_test.php @@ -42,7 +42,7 @@ function feistelRound(int $R, int $round, string $key): int return $val & 0x7FFFF; // 19bit mask } -function feistelEncode(int $packed, string $key): string +function feistelEncodeV1(int $packed, string $key): string { $L = ($packed >> 19) & 0x1FFFFF; $R = $packed & 0x7FFFF; @@ -61,7 +61,7 @@ function feistelEncode(int $packed, string $key): string return base_convert($result, 10, 36); } -function feistelDecode(string $code, string $key): int +function feistelDecodeV1(string $code, string $key): int { $packed = intval(base_convert(strtolower($code), 36, 10)); $L = ($packed >> 19) & 0x1FFFFF; @@ -80,6 +80,40 @@ function feistelDecode(string $code, string $key): int return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); } +function feistelEncodeV2(int $packed, string $key): string +{ + $L = ($packed >> 19) & 0x1FFFFF; + $R = $packed & 0x7FFFF; + + for ($i = 0; $i < 8; $i++) { + $F = feistelRound($R, $i, $key); + $L_new = $R; + $R_new = ($L ^ $F) & 0x7FFFF; + $L = $L_new; + $R = $R_new; + } + + $result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); + return base_convert($result, 10, 36); +} + +function feistelDecodeV2(string $code, string $key): int +{ + $packed = intval(base_convert(strtolower($code), 36, 10)); + $L = ($packed >> 19) & 0x1FFFFF; + $R = $packed & 0x7FFFF; + + for ($i = 7; $i >= 0; $i--) { + $F = feistelRound($L, $i, $key); + $R_new = $L; + $L_new = ($R ^ $F) & 0x1FFFFF; + $R = $R_new; + $L = $L_new; + } + + return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF); +} + function shortCodeEncode(int $goods_id, int $ticket_id): string { // 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615) @@ -98,8 +132,11 @@ function shortCodeEncode(int $goods_id, int $ticket_id): string // ticket_id 混淆 $ticket_int = intval($ticket_part, 36); + $magic = 0x5A; + $packed = ($magic << 28) | $ticket_int; + $key = getGoodsKey($goods_id); - $obfuscated = feistelEncode($ticket_int, $key); + $obfuscated = feistelEncodeV2($packed, $key); // 拼接:前4位明文 goods_id + 变长混淆 ticket_id return strtolower($goods_part . $obfuscated); @@ -123,7 +160,15 @@ function shortCodeDecode(string $code, ?int $goods_id_hint = null): array // 后部:变长混淆 ticket_id → Feistel 解密 $ticket_part = substr($code, 4); - $ticket_int = feistelDecode($ticket_part, $key); + $unpacked = feistelDecodeV2($ticket_part, $key); + + $magic = 0x5A; + if (($unpacked >> 28) === $magic) { + $ticket_int = $unpacked & 0xFFFFFFF; + } else { + $ticket_int = feistelDecodeV1($ticket_part, $key); + } + $ticket_id = intval(base_convert($ticket_int, 10, 36), 36); return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id]; @@ -197,11 +242,30 @@ $test_cases = [ ]; 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}"); + // V1 测试 + $encoded1 = feistelEncodeV1($tc['input'], $key); + $decoded1 = feistelDecodeV1($encoded1, $key); + assert_equals($tc['input'], $decoded1, "Feistel-8 V1 {$tc['desc']}: {$tc['input']} → {$encoded1} → {$decoded1}"); + + // V2 测试 + $encoded2 = feistelEncodeV2($tc['input'], $key); + $decoded2 = feistelDecodeV2($encoded2, $key); + assert_equals($tc['input'], $decoded2, "Feistel-8 V2 {$tc['desc']}: {$tc['input']} → {$encoded2} → {$decoded2}"); } +// 补充 V1 到 V2 回退测试(模拟真实的向后兼容) +echo "\n--- 向后兼容性测试 (Fallback) ---\n"; +// 用 V1 生成旧码,带入 shortCodeDecode 验证是否成功回退 +$old_goods_id = 118; +$old_ticket_id = 999; +$old_key = getGoodsKey($old_goods_id); +$old_goods_part = str_pad(base_convert($old_goods_id, 10, 36), 4, '0', STR_PAD_LEFT); +$old_obfuscated = feistelEncodeV1($old_ticket_id, $old_key); +$old_short_code = strtolower($old_goods_part . $old_obfuscated); +$old_decoded = shortCodeDecode($old_short_code); +assert_equals($old_goods_id, $old_decoded['goods_id'], "旧码回退解码: goods_id"); +assert_equals($old_ticket_id, $old_decoded['ticket_id'], "旧码回退解码: ticket_id"); + // Test 2: 短码编解码往返测试(不带hint) echo "\n--- 短码编解码往返测试 ---\n"; $short_code_cases = [ @@ -267,7 +331,7 @@ assert_true($verified === null, "QR过期测试: 已过期应返回null"); // Test 7: goods_id 超出4位 base36 echo "\n--- 边界条件测试 ---\n"; try { - shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615 + shortCodeEncode(20000000, 100); // goods_id=20000000 > 16777215 (0xFFFFFF) echo "❌ FAIL: goods_id超出范围应抛出异常\n"; $failed++; } catch (Exception $e) { @@ -305,12 +369,12 @@ try { } // Test 8: ticket_id 变长(展示不受5位限制) -$big_ticket = 1000000000; // 10亿 +$big_ticket = 200000000; // 2亿,28bit 上限约 268M $code = shortCodeEncode(118, $big_ticket); echo "短码长度: " . strlen($code) . " 位\n"; $decoded = shortCodeDecode($code); assert_equals(118, $decoded['goods_id'], "大变长ticket_id: goods_id"); -assert_equals($big_ticket, $decoded['ticket_id'], "大变长ticket_id: ticket_id = 1000000000"); +assert_equals($big_ticket, $decoded['ticket_id'], "大变长ticket_id: ticket_id = 200000000"); // Test 9: 不同商品 key 不同 echo "\n--- Per-goods key 隔离测试 ---\n";