146 lines
5.6 KiB
PHP
146 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Contractless\Faucet\Wallet;
|
|
|
|
use Contractless\Rpc\Crypto\CryptoInterface;
|
|
use RuntimeException;
|
|
|
|
final class WalletLoader
|
|
{
|
|
public static function load(
|
|
string $walletPath,
|
|
string $walletKey,
|
|
CryptoInterface $crypto,
|
|
): FaucetWallet {
|
|
if (!extension_loaded('openssl')) {
|
|
throw new RuntimeException('The OpenSSL PHP extension is required to decrypt wallets.');
|
|
}
|
|
if (!is_file($walletPath) || !is_readable($walletPath)) {
|
|
throw new RuntimeException('The Contractless wallet file is not readable.');
|
|
}
|
|
|
|
$contents = file_get_contents($walletPath);
|
|
if ($contents === false) {
|
|
throw new RuntimeException('The Contractless wallet file could not be read.');
|
|
}
|
|
|
|
try {
|
|
$wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException) {
|
|
throw new RuntimeException('The Contractless wallet file is not valid JSON.');
|
|
}
|
|
if (!is_array($wallet)) {
|
|
throw new RuntimeException('The Contractless wallet file has an invalid structure.');
|
|
}
|
|
|
|
$address = strtolower(trim((string) ($wallet['short_address'] ?? '')));
|
|
$publicKeyHex = trim((string) ($wallet['public_key'] ?? ''));
|
|
$encodedImage = trim((string) ($wallet['private_key'] ?? ''));
|
|
if (preg_match('/^[a-f0-9]{40}\.(clc|cltc)$/', $address, $match) !== 1) {
|
|
throw new RuntimeException('The wallet contains an invalid short address.');
|
|
}
|
|
if ($encodedImage === '') {
|
|
throw new RuntimeException('The wallet does not contain a private-key image.');
|
|
}
|
|
|
|
$publicKey = self::normalizePublicKey($publicKeyHex, $match[1]);
|
|
$encryptedPrivateKey = EncryptedImageDecoder::extract($encodedImage);
|
|
$privateKeyHex = self::decrypt($encryptedPrivateKey, $walletKey);
|
|
$privateKey = hex2bin($privateKeyHex);
|
|
if ($privateKey === false) {
|
|
throw new RuntimeException('The decrypted Falcon private key could not be decoded.');
|
|
}
|
|
$loaded = new FaucetWallet($address, $publicKey, $privateKey);
|
|
|
|
self::validatePublicKey($loaded->publicKey, $crypto);
|
|
self::validateKeypair($loaded, $crypto);
|
|
self::validateAddress($loaded->address, $loaded->publicKey, $crypto);
|
|
return $loaded;
|
|
}
|
|
|
|
private static function decrypt(string $encodedCiphertext, string $walletKey): string
|
|
{
|
|
$encrypted = base64_decode($encodedCiphertext, true);
|
|
if ($encrypted === false || strlen($encrypted) < 49) {
|
|
throw new RuntimeException('The encrypted wallet payload is invalid.');
|
|
}
|
|
|
|
$key = substr(str_pad($walletKey, 16, "\0"), 0, 16);
|
|
$iv = substr($encrypted, 0, 16);
|
|
$expectedHmac = substr($encrypted, 16, 32);
|
|
$ciphertext = substr($encrypted, 48);
|
|
$actualHmac = hash_hmac('sha256', $ciphertext, $key, true);
|
|
if (!hash_equals($expectedHmac, $actualHmac)) {
|
|
throw new RuntimeException('The wallet decryption key is incorrect.');
|
|
}
|
|
|
|
$privateKey = openssl_decrypt(
|
|
$ciphertext,
|
|
'aes-128-cbc',
|
|
$key,
|
|
OPENSSL_RAW_DATA,
|
|
$iv,
|
|
);
|
|
if ($privateKey === false || strlen($privateKey) !== 2562 || !ctype_xdigit($privateKey)) {
|
|
throw new RuntimeException('The decrypted Falcon private key is invalid.');
|
|
}
|
|
return strtolower($privateKey);
|
|
}
|
|
|
|
private static function normalizePublicKey(string $publicKeyHex, string $network): string
|
|
{
|
|
if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex)) {
|
|
throw new RuntimeException('The wallet contains an invalid Falcon public key.');
|
|
}
|
|
$publicKey = hex2bin($publicKeyHex);
|
|
if ($publicKey === false) {
|
|
throw new RuntimeException('The wallet public key could not be decoded.');
|
|
}
|
|
|
|
$networkByte = $network === 'clc' ? 1 : 2;
|
|
if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) {
|
|
$publicKey = substr($publicKey, 1);
|
|
}
|
|
if (strlen($publicKey) !== 897) {
|
|
throw new RuntimeException('The wallet Falcon public key has an invalid length.');
|
|
}
|
|
return $publicKey;
|
|
}
|
|
|
|
private static function validatePublicKey(string $publicKey, CryptoInterface $crypto): void
|
|
{
|
|
if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) {
|
|
throw new RuntimeException(
|
|
'The wallet public key does not satisfy the Contractless key rule.',
|
|
);
|
|
}
|
|
}
|
|
|
|
private static function validateKeypair(FaucetWallet $wallet, CryptoInterface $crypto): void
|
|
{
|
|
$challenge = $crypto->skein256('contractless-wallet-keypair-check');
|
|
$signature = (new FalconSigner($wallet->publicKey, $wallet->privateKey))->sign($challenge);
|
|
if (!$crypto->verify($challenge, $signature, $wallet->publicKey)) {
|
|
throw new RuntimeException(
|
|
'The wallet public key does not match the decrypted private key.',
|
|
);
|
|
}
|
|
}
|
|
|
|
private static function validateAddress(
|
|
string $address,
|
|
string $publicKey,
|
|
CryptoInterface $crypto,
|
|
): void {
|
|
$suffix = str_ends_with($address, '.clc') ? 'clc' : 'cltc';
|
|
$payload = hash('ripemd160', $crypto->skein256($publicKey));
|
|
if (!hash_equals($address, $payload . '.' . $suffix)) {
|
|
throw new RuntimeException(
|
|
'The wallet short address does not match its Falcon public key.',
|
|
);
|
|
}
|
|
}
|
|
}
|