fix(ticket): Feistel V2 修复 - 正确引入 $R 使混淆非连续 + 魔数兼容旧码
parent
c5eb2e9996
commit
f4d16aa1e0
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue