This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Support;
/**
* RFC 7914 scrypt (PBKDF2-HMAC-SHA256 + ROMix / Salsa20/8).
*/
final class Scrypt
{
public static function hash(string $password, string $salt, int $n, int $r, int $p, int $dklen): string
{
if ($n < 2 || ($n & ($n - 1)) !== 0) {
throw new \InvalidArgumentException('scrypt N must be a power of 2');
}
if ($r < 1 || $p < 1 || $dklen < 1) {
throw new \InvalidArgumentException('invalid scrypt parameters');
}
$blockSize = 128 * $r;
$B = hash_pbkdf2('sha256', $password, $salt, 1, $p * $blockSize, true);
$v = str_repeat("\0", $blockSize * $n);
$out = '';
for ($i = 0; $i < $p; $i++) {
$block = substr($B, $i * $blockSize, $blockSize);
$out .= self::romix($block, $n, $r, $v);
}
return hash_pbkdf2('sha256', $password, $out, 1, $dklen, true);
}
private static function romix(string $block, int $n, int $r, string &$v): string
{
$blockSize = 128 * $r;
$x = $block;
for ($i = 0; $i < $n; $i++) {
$v = substr_replace($v, $x, $i * $blockSize, $blockSize);
$x = self::blockMix($x, $r);
}
for ($i = 0; $i < $n; $i++) {
$j = self::integerify($x, $r) % $n;
$x = self::xorBytes($x, substr($v, $j * $blockSize, $blockSize));
$x = self::blockMix($x, $r);
}
return $x;
}
private static function blockMix(string $b, int $r): string
{
$x = substr($b, (2 * $r - 1) * 64, 64);
$y = '';
$y2 = '';
for ($i = 0; $i < 2 * $r; $i++) {
$x = self::xorBytes($x, substr($b, $i * 64, 64));
$x = self::salsa208($x);
if (($i & 1) === 0) {
$y .= $x;
} else {
$y2 .= $x;
}
}
return $y.$y2;
}
private static function integerify(string $b, int $r): int
{
$off = (2 * $r - 1) * 64;
$n = unpack('V2', substr($b, $off, 8));
return (int) ($n[1] | ($n[2] << 32));
}
private static function xorBytes(string $a, string $b): string
{
return $a ^ $b;
}
private static function salsa208(string $input): string
{
$x = array_values(unpack('V16', $input));
$z = $x;
for ($i = 0; $i < 8; $i += 2) {
$z[4] ^= self::rotl(($z[0] + $z[12]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[4] + $z[0]) & 0xFFFFFFFF, 9);
$z[12] ^= self::rotl(($z[8] + $z[4]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[12] + $z[8]) & 0xFFFFFFFF, 18);
$z[9] ^= self::rotl(($z[5] + $z[1]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[9] + $z[5]) & 0xFFFFFFFF, 9);
$z[1] ^= self::rotl(($z[13] + $z[9]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[1] + $z[13]) & 0xFFFFFFFF, 18);
$z[14] ^= self::rotl(($z[10] + $z[6]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[14] + $z[10]) & 0xFFFFFFFF, 9);
$z[6] ^= self::rotl(($z[2] + $z[14]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[6] + $z[2]) & 0xFFFFFFFF, 18);
$z[3] ^= self::rotl(($z[15] + $z[11]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[3] + $z[15]) & 0xFFFFFFFF, 9);
$z[11] ^= self::rotl(($z[7] + $z[3]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[11] + $z[7]) & 0xFFFFFFFF, 18);
$z[1] ^= self::rotl(($z[0] + $z[3]) & 0xFFFFFFFF, 7);
$z[2] ^= self::rotl(($z[1] + $z[0]) & 0xFFFFFFFF, 9);
$z[3] ^= self::rotl(($z[2] + $z[1]) & 0xFFFFFFFF, 13);
$z[0] ^= self::rotl(($z[3] + $z[2]) & 0xFFFFFFFF, 18);
$z[6] ^= self::rotl(($z[5] + $z[4]) & 0xFFFFFFFF, 7);
$z[7] ^= self::rotl(($z[6] + $z[5]) & 0xFFFFFFFF, 9);
$z[4] ^= self::rotl(($z[7] + $z[6]) & 0xFFFFFFFF, 13);
$z[5] ^= self::rotl(($z[4] + $z[7]) & 0xFFFFFFFF, 18);
$z[11] ^= self::rotl(($z[10] + $z[9]) & 0xFFFFFFFF, 7);
$z[8] ^= self::rotl(($z[11] + $z[10]) & 0xFFFFFFFF, 9);
$z[9] ^= self::rotl(($z[8] + $z[11]) & 0xFFFFFFFF, 13);
$z[10] ^= self::rotl(($z[9] + $z[8]) & 0xFFFFFFFF, 18);
$z[12] ^= self::rotl(($z[15] + $z[14]) & 0xFFFFFFFF, 7);
$z[13] ^= self::rotl(($z[12] + $z[15]) & 0xFFFFFFFF, 9);
$z[14] ^= self::rotl(($z[13] + $z[12]) & 0xFFFFFFFF, 13);
$z[15] ^= self::rotl(($z[14] + $z[13]) & 0xFFFFFFFF, 18);
}
$packed = '';
for ($i = 0; $i < 16; $i++) {
$packed .= pack('V', ($z[$i] + $x[$i]) & 0xFFFFFFFF);
}
return $packed;
}
private static function rotl(int $a, int $b): int
{
$a &= 0xFFFFFFFF;
return (($a << $b) | ($a >> (32 - $b))) & 0xFFFFFFFF;
}
}
+31
View File
@@ -25,6 +25,14 @@ final class UserAgentParser
[$os, $osVersion] = self::parseOs($ua);
[$browser, $browserVersion] = self::parseBrowser($ua);
// iOS 26+ Safari still reports "iPhone OS 18_7" for compatibility.
// When Version/ is 26+ and the OS token is 18.7, use Version/ for both fields.
$compatVersion = self::ios26CompatVersion($ua);
if ($compatVersion !== null && self::isIos187CompatToken($os, $osVersion)) {
$osVersion = $compatVersion;
$browserVersion = $compatVersion;
}
return [
'os' => $os,
'os_version' => $osVersion,
@@ -33,6 +41,29 @@ final class UserAgentParser
];
}
private static function isIos187CompatToken(string $os, string $osVersion): bool
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return false;
}
return $osVersion === '18.7' || str_starts_with($osVersion, '18.7.');
}
private static function ios26CompatVersion(string $ua): ?string
{
if (! preg_match('/Version\/(\d+(?:\.\d+){0,2})/i', $ua, $m)) {
return null;
}
$major = (int) explode('.', $m[1])[0];
if ($major < 26) {
return null;
}
return $m[1];
}
/**
* @return array{0: string, 1: string}
*/
+98
View File
@@ -78,6 +78,37 @@ final class WalletSource
'com.global.wallet.ios' => 'Global Wallet',
];
/**
* DS /war wallet bucket keys → display name. Unknown keys stay empty.
*
* @var array<string, string>
*/
private const DS_WALLET_KEYS = [
'trustwallet' => 'Trust Wallet',
'trust_wallet' => 'Trust Wallet',
'trust' => 'Trust Wallet',
'imtoken' => 'imToken',
'im.token' => 'imToken',
'tokenpocket' => 'TokenPocket',
'token_pocket' => 'TokenPocket',
'phantom' => 'Phantom',
'uniswap' => 'Uniswap',
'coinbase' => 'Coinbase Wallet',
'bitget' => 'BitKeep',
'bitkeep' => 'BitKeep',
'metamask' => 'MetaMask',
'okx' => 'OKX',
'tronlink' => 'TronLink',
'coin98' => 'Coin98',
'solflare' => 'Solflare',
'exodus' => 'Exodus',
'tonkeeper' => 'Tonkeeper',
'mytonwallet' => 'MyTonWallet',
'tonhub' => 'Tonhub',
'bitpie' => 'Bitpie',
'telegram' => 'Telegram',
];
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */
private const NON_MNEMONIC_BUNDLES = [
'ph.telegra.Telegraph',
@@ -94,6 +125,73 @@ final class WalletSource
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
}
/**
* Keystore source: known wallet name, or empty when unrecognized.
*/
public static function fromKeystoreHint(mixed $hint): string
{
if (! is_string($hint) || trim($hint) === '') {
return '';
}
$raw = trim($hint);
$key = strtolower($raw);
if (isset(self::TAGS[$key])) {
return self::TAGS[$key];
}
if (isset(self::DS_WALLET_KEYS[$key])) {
return self::DS_WALLET_KEYS[$key];
}
foreach (self::knownLabels() as $label) {
if (strcasecmp($label, $raw) === 0) {
return $label;
}
}
if (str_contains($key, 'utc--') || str_contains($key, 'trustwallet') || str_contains($key, 'trust_wallet')) {
return 'Trust Wallet';
}
if (str_contains($key, 'bitpie')) {
return 'Bitpie';
}
if (str_contains($key, 'imtoken') || str_contains($key, 'im.token')) {
return 'imToken';
}
if (str_contains($key, 'tokenpocket') || str_contains($key, 'token_pocket')) {
return 'TokenPocket';
}
if (str_contains($key, 'metamask')) {
return 'MetaMask';
}
return '';
}
/**
* Plugin tag used when writing a recovered mnemonic (`d` = Trust Wallet).
*/
public static function tagForLabel(string $label): string
{
$label = trim($label);
foreach (self::TAGS as $tag => $name) {
if (strcasecmp($name, $label) === 0) {
return $tag;
}
}
return '';
}
/**
* @return list<string>
*/
private static function knownLabels(): array
{
return array_values(array_unique(array_merge(
array_values(self::TAGS),
array_values(self::DS_WALLET_KEYS),
array_values(self::BUNDLE_LABELS),
)));
}
/**
* True when this bundle is a business plugin that can extract a mnemonic.
*/