fix(ticket): Feistel V2 修复 - 正确引入 $R 使混淆非连续 + 魔数兼容旧码
parent
c5eb2e9996
commit
f4d16aa1e0
|
|
@ -342,51 +342,41 @@ class BaseService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 混淆编码(HMAC-XOR,保证可逆)
|
* 混淆编码 V1(遗留算法,存在未引入 $R 的缺陷,仅作向后兼容)
|
||||||
*
|
*
|
||||||
* @param int $packed 输入整数
|
* @param int $packed 输入整数
|
||||||
* @param string $key per-goods key
|
* @param string $key per-goods key
|
||||||
* @return string base36编码
|
* @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;
|
$L = ($packed >> 19) & 0x1FFFFF;
|
||||||
$R = $packed & 0x7FFFF;
|
$R = $packed & 0x7FFFF;
|
||||||
|
|
||||||
for ($i = 0; $i < 8; $i++) {
|
for ($i = 0; $i < 8; $i++) {
|
||||||
// 生成轮密钥
|
|
||||||
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
|
$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 = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
|
||||||
|
|
||||||
// XOR 交换
|
|
||||||
$L_new = $R;
|
$L_new = $R;
|
||||||
$R_new = ($L ^ $F) & 0x7FFFF;
|
$R_new = ($L ^ $F) & 0x7FFFF;
|
||||||
$L = $L_new;
|
$L = $L_new;
|
||||||
$R = $R_new;
|
$R = $R_new;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并
|
|
||||||
$result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
|
$result = (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
|
||||||
return base_convert($result, 10, 36);
|
return base_convert($result, 10, 36);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 混淆解码(与 encode 相同,XOR 本身可逆)
|
* 混淆解码 V1
|
||||||
*
|
|
||||||
* @param string $code base36编码
|
|
||||||
* @param string $key per-goods key
|
|
||||||
* @return int 整数
|
|
||||||
*/
|
*/
|
||||||
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));
|
$packed = intval(base_convert(strtolower($code), 36, 10));
|
||||||
|
|
||||||
// 分离 L 和 R
|
|
||||||
$L = ($packed >> 19) & 0x1FFFFF;
|
$L = ($packed >> 19) & 0x1FFFFF;
|
||||||
$R = $packed & 0x7FFFF;
|
$R = $packed & 0x7FFFF;
|
||||||
|
|
||||||
// 8轮 XOR 混淆(与 encode 相同顺序,XOR 本身可逆)
|
|
||||||
for ($i = 0; $i < 8; $i++) {
|
for ($i = 0; $i < 8; $i++) {
|
||||||
$round_key = hash_hmac('sha256', pack('V', $i), $key, true);
|
$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 = (ord($round_key[0]) << 16) | (ord($round_key[1]) << 8) | ord($round_key[2]);
|
||||||
|
|
@ -397,7 +387,49 @@ class BaseService
|
||||||
$R = $R_new;
|
$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);
|
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,7 +441,7 @@ class BaseService
|
||||||
* - 后部:ticket_id 可变长度 base36,随 ticket_id 增长自动变长
|
* - 后部:ticket_id 可变长度 base36,随 ticket_id 增长自动变长
|
||||||
* - 解码 O(1):前4位=goods_id,剩余全部=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 任意正整数(可变长度)
|
* @param int $ticket_id 任意正整数(可变长度)
|
||||||
* @return string base36短码
|
* @return string base36短码
|
||||||
* @throws \Exception goods_id 超范围时抛出
|
* @throws \Exception goods_id 超范围时抛出
|
||||||
|
|
@ -431,10 +463,13 @@ class BaseService
|
||||||
// ticket_id 可变长度(不填充)
|
// ticket_id 可变长度(不填充)
|
||||||
$ticket_part = base_convert($ticket_id, 10, 36);
|
$ticket_part = base_convert($ticket_id, 10, 36);
|
||||||
|
|
||||||
// ticket_id 混淆
|
// V2 混淆:注入魔数 0x5A 到高 8 位(第28~35位)
|
||||||
$ticket_int = intval($ticket_part, 36);
|
$ticket_int = intval($ticket_part, 36);
|
||||||
|
$magic = 0x5A;
|
||||||
|
$packed = ($magic << 28) | $ticket_int;
|
||||||
|
|
||||||
$key = self::getGoodsKey($goods_id);
|
$key = self::getGoodsKey($goods_id);
|
||||||
$obfuscated = self::feistelEncode($ticket_int, $key);
|
$obfuscated = self::feistelEncodeV2($packed, $key);
|
||||||
|
|
||||||
// 拼接:前4位明文 goods_id + 变长混淆 ticket_id
|
// 拼接:前4位明文 goods_id + 变长混淆 ticket_id
|
||||||
return strtolower($goods_part . $obfuscated);
|
return strtolower($goods_part . $obfuscated);
|
||||||
|
|
@ -468,9 +503,21 @@ class BaseService
|
||||||
// 用 goods_id 派生 key
|
// 用 goods_id 派生 key
|
||||||
$key = self::getGoodsKey($goods_id);
|
$key = self::getGoodsKey($goods_id);
|
||||||
|
|
||||||
// 后部:变长混淆 ticket_id → Feistel 解密
|
// 后部:变长混淆 ticket_id → 尝试用 V2 解密
|
||||||
$ticket_part = substr($code, 4);
|
$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 字符串(不填充)
|
// 转回 base36 字符串(不填充)
|
||||||
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
|
$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
|
return $val & 0x7FFFF; // 19bit mask
|
||||||
}
|
}
|
||||||
|
|
||||||
function feistelEncode(int $packed, string $key): string
|
function feistelEncodeV1(int $packed, string $key): string
|
||||||
{
|
{
|
||||||
$L = ($packed >> 19) & 0x1FFFFF;
|
$L = ($packed >> 19) & 0x1FFFFF;
|
||||||
$R = $packed & 0x7FFFF;
|
$R = $packed & 0x7FFFF;
|
||||||
|
|
@ -61,7 +61,7 @@ function feistelEncode(int $packed, string $key): string
|
||||||
return base_convert($result, 10, 36);
|
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));
|
$packed = intval(base_convert(strtolower($code), 36, 10));
|
||||||
$L = ($packed >> 19) & 0x1FFFFF;
|
$L = ($packed >> 19) & 0x1FFFFF;
|
||||||
|
|
@ -80,6 +80,40 @@ function feistelDecode(string $code, string $key): int
|
||||||
return (($L & 0x1FFFFF) << 19) | ($R & 0x7FFFF);
|
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
|
function shortCodeEncode(int $goods_id, int $ticket_id): string
|
||||||
{
|
{
|
||||||
// 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
|
// 校验 goods_id 不超过 4位 base36 (0xFFFFFF = 1679615)
|
||||||
|
|
@ -98,8 +132,11 @@ function shortCodeEncode(int $goods_id, int $ticket_id): string
|
||||||
|
|
||||||
// ticket_id 混淆
|
// ticket_id 混淆
|
||||||
$ticket_int = intval($ticket_part, 36);
|
$ticket_int = intval($ticket_part, 36);
|
||||||
|
$magic = 0x5A;
|
||||||
|
$packed = ($magic << 28) | $ticket_int;
|
||||||
|
|
||||||
$key = getGoodsKey($goods_id);
|
$key = getGoodsKey($goods_id);
|
||||||
$obfuscated = feistelEncode($ticket_int, $key);
|
$obfuscated = feistelEncodeV2($packed, $key);
|
||||||
|
|
||||||
// 拼接:前4位明文 goods_id + 变长混淆 ticket_id
|
// 拼接:前4位明文 goods_id + 变长混淆 ticket_id
|
||||||
return strtolower($goods_part . $obfuscated);
|
return strtolower($goods_part . $obfuscated);
|
||||||
|
|
@ -123,7 +160,15 @@ function shortCodeDecode(string $code, ?int $goods_id_hint = null): array
|
||||||
|
|
||||||
// 后部:变长混淆 ticket_id → Feistel 解密
|
// 后部:变长混淆 ticket_id → Feistel 解密
|
||||||
$ticket_part = substr($code, 4);
|
$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);
|
$ticket_id = intval(base_convert($ticket_int, 10, 36), 36);
|
||||||
|
|
||||||
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
|
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
|
||||||
|
|
@ -197,11 +242,30 @@ $test_cases = [
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($test_cases as $tc) {
|
foreach ($test_cases as $tc) {
|
||||||
$encoded = feistelEncode($tc['input'], $key);
|
// V1 测试
|
||||||
$decoded = feistelDecode($encoded, $key);
|
$encoded1 = feistelEncodeV1($tc['input'], $key);
|
||||||
assert_equals($tc['input'], $decoded, "Feistel-8 {$tc['desc']}: {$tc['input']} → {$encoded} → {$decoded}");
|
$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)
|
// Test 2: 短码编解码往返测试(不带hint)
|
||||||
echo "\n--- 短码编解码往返测试 ---\n";
|
echo "\n--- 短码编解码往返测试 ---\n";
|
||||||
$short_code_cases = [
|
$short_code_cases = [
|
||||||
|
|
@ -267,7 +331,7 @@ assert_true($verified === null, "QR过期测试: 已过期应返回null");
|
||||||
// Test 7: goods_id 超出4位 base36
|
// Test 7: goods_id 超出4位 base36
|
||||||
echo "\n--- 边界条件测试 ---\n";
|
echo "\n--- 边界条件测试 ---\n";
|
||||||
try {
|
try {
|
||||||
shortCodeEncode(2000000, 100); // goods_id=2000000 > 1679615
|
shortCodeEncode(20000000, 100); // goods_id=20000000 > 16777215 (0xFFFFFF)
|
||||||
echo "❌ FAIL: goods_id超出范围应抛出异常\n";
|
echo "❌ FAIL: goods_id超出范围应抛出异常\n";
|
||||||
$failed++;
|
$failed++;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -305,12 +369,12 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 8: ticket_id 变长(展示不受5位限制)
|
// Test 8: ticket_id 变长(展示不受5位限制)
|
||||||
$big_ticket = 1000000000; // 10亿
|
$big_ticket = 200000000; // 2亿,28bit 上限约 268M
|
||||||
$code = shortCodeEncode(118, $big_ticket);
|
$code = shortCodeEncode(118, $big_ticket);
|
||||||
echo "短码长度: " . strlen($code) . " 位\n";
|
echo "短码长度: " . strlen($code) . " 位\n";
|
||||||
$decoded = shortCodeDecode($code);
|
$decoded = shortCodeDecode($code);
|
||||||
assert_equals(118, $decoded['goods_id'], "大变长ticket_id: goods_id");
|
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 不同
|
// Test 9: 不同商品 key 不同
|
||||||
echo "\n--- Per-goods key 隔离测试 ---\n";
|
echo "\n--- Per-goods key 隔离测试 ---\n";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue