Contractless-Faucet/src/Wallet/WalletLoader.php

146 lines
5.6 KiB
PHP
Raw Normal View History

2026-07-27 20:36:33 +00:00
<?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.',
);
}
}
}