fix(ticket): Feistel V2 修复 - 正确引入 $R 使混淆非连续 + 魔数兼容旧码

council/FrontendDeveloper
Council 2026-06-04 11:32:52 +08:00
parent c5eb2e9996
commit f4d16aa1e0
2 changed files with 141 additions and 30 deletions

View File

@ -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);

View File

@ -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";