280 lines
11 KiB
PHP
280 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Contractless\Api\Tools;
|
|
|
|
use Contractless\Rpc\Crypto\CryptoInterface;
|
|
use Contractless\Rpc\Signing\SignatureProviderInterface;
|
|
use RuntimeException;
|
|
|
|
final class FalconSigner implements SignatureProviderInterface
|
|
{
|
|
/** @var \OQS_SIGNATURE */
|
|
private readonly object $falcon;
|
|
|
|
public function __construct(
|
|
private readonly string $publicKey,
|
|
private readonly string $privateKey,
|
|
) {
|
|
if (!function_exists('contractless_shake256') || !class_exists('OQS_SIGNATURE')) {
|
|
throw new RuntimeException('The Contractless Falcon PHP module is not loaded.');
|
|
}
|
|
if (strlen($publicKey) !== 897 || strlen($privateKey) !== 1281) {
|
|
throw new RuntimeException('The wallet contains an invalid Falcon keypair.');
|
|
}
|
|
$this->falcon = new \OQS_SIGNATURE('Falcon-padded-512');
|
|
}
|
|
|
|
public function sign(string $digest): string
|
|
{
|
|
if (strlen($digest) !== 32) {
|
|
throw new RuntimeException('Contractless signing requires a 32-byte digest.');
|
|
}
|
|
$publicKeyHash = contractless_shake256($this->publicKey, 64);
|
|
if (!is_string($publicKeyHash) || strlen($publicKeyHash) !== 64) {
|
|
throw new RuntimeException('SHAKE256 public-key hashing failed.');
|
|
}
|
|
|
|
$signature = '';
|
|
$result = $this->falcon->sign(
|
|
$signature,
|
|
$publicKeyHash . "\0\0" . $digest,
|
|
$this->privateKey,
|
|
);
|
|
if ($result !== 0 || strlen($signature) !== 666) {
|
|
throw new RuntimeException('Falcon signing failed.');
|
|
}
|
|
return $signature;
|
|
}
|
|
}
|
|
|
|
final class WalletImageDecoder
|
|
{
|
|
private const ROWS = [0, 35, 70, 105, 140, 175, 210, 245, 280, 315, 349];
|
|
|
|
public static function extract(string $encodedImage): string
|
|
{
|
|
if (!extension_loaded('gd')) {
|
|
throw new RuntimeException('The GD PHP extension is required to read wallet images.');
|
|
}
|
|
$png = base64_decode($encodedImage, true);
|
|
if ($png === false) {
|
|
throw new RuntimeException('The wallet private-key image is not valid Base64.');
|
|
}
|
|
|
|
foreach (['h', 'h2', 'v', 'v2'] as $style) {
|
|
$image = @imagecreatefromstring($png);
|
|
if ($image === false) {
|
|
throw new RuntimeException('The wallet private-key image is not a valid PNG.');
|
|
}
|
|
$oriented = null;
|
|
try {
|
|
$oriented = self::orient($image, $style);
|
|
if ($oriented !== $image) {
|
|
imagedestroy($image);
|
|
$image = null;
|
|
}
|
|
$decoded = self::decode($oriented);
|
|
if ($decoded !== null) {
|
|
return $decoded;
|
|
}
|
|
} finally {
|
|
if ($oriented !== null) {
|
|
imagedestroy($oriented);
|
|
} elseif ($image !== null) {
|
|
imagedestroy($image);
|
|
}
|
|
}
|
|
}
|
|
throw new RuntimeException('The wallet private-key image could not be decoded.');
|
|
}
|
|
|
|
private static function orient(object $image, string $style): object
|
|
{
|
|
if ($style === 'h') {
|
|
return $image;
|
|
}
|
|
if ($style === 'h2') {
|
|
imageflip($image, IMG_FLIP_VERTICAL);
|
|
return $image;
|
|
}
|
|
$rotated = imagerotate($image, $style === 'v' ? 90 : -90, 0);
|
|
if ($rotated === false) {
|
|
throw new RuntimeException('The wallet image could not be rotated.');
|
|
}
|
|
if ($style === 'v2') {
|
|
imageflip($rotated, IMG_FLIP_VERTICAL);
|
|
}
|
|
return $rotated;
|
|
}
|
|
|
|
private static function decode(object $image): ?string
|
|
{
|
|
if (imagesx($image) !== 350) {
|
|
return null;
|
|
}
|
|
$characters = '';
|
|
$map = self::colorMap();
|
|
foreach (self::ROWS as $row) {
|
|
for ($x = 0; $x < 350; $x++) {
|
|
$index = imagecolorat($image, $x, $row);
|
|
if ($index === false) {
|
|
return null;
|
|
}
|
|
$rgba = imagecolorsforindex($image, $index);
|
|
$key = "{$rgba['red']},{$rgba['green']},{$rgba['blue']}";
|
|
if (!isset($map[$key])) {
|
|
return null;
|
|
}
|
|
$characters .= $map[$key];
|
|
}
|
|
}
|
|
$prefix = substr($characters, 0, 4);
|
|
if (strlen($prefix) !== 4 || !ctype_digit($prefix)) {
|
|
return null;
|
|
}
|
|
$length = (int) $prefix;
|
|
$payload = substr($characters, 4, $length);
|
|
return strlen($payload) === $length ? $payload : null;
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
private static function colorMap(): array
|
|
{
|
|
$base = [
|
|
'a'=>[204,180,194],'A'=>[255,255,255],'b'=>[197,186,201],'B'=>[221,206,212],
|
|
'c'=>[181,185,193],'C'=>[184,201,223],'d'=>[224,218,192],'D'=>[185,191,195],
|
|
'e'=>[181,197,198],'E'=>[193,206,255],'f'=>[252,193,211],'F'=>[183,192,229],
|
|
'g'=>[180,191,192],'G'=>[187,219,189],'h'=>[195,187,234],'H'=>[182,216,189],
|
|
'i'=>[197,183,248],'I'=>[200,182,204],'j'=>[255,235,196],'J'=>[194,186,228],
|
|
'k'=>[199,238,239],'K'=>[208,247,234],'l'=>[244,214,189],'L'=>[187,243,239],
|
|
'm'=>[188,231,238],'M'=>[187,197,227],'n'=>[186,240,191],'N'=>[187,198,206],
|
|
'o'=>[205,193,184],'O'=>[191,187,197],'p'=>[194,200,206],'P'=>[195,183,229],
|
|
'q'=>[182,219,196],'Q'=>[238,216,184],'r'=>[199,181,208],'R'=>[239,231,198],
|
|
's'=>[189,188,230],'S'=>[242,192,230],'t'=>[199,199,199],'T'=>[188,190,230],
|
|
'u'=>[230,180,253],'U'=>[241,247,247],'v'=>[242,190,199],'V'=>[230,247,234],
|
|
'w'=>[197,186,249],'W'=>[194,247,249],'x'=>[242,182,246],'X'=>[188,222,193],
|
|
'y'=>[188,194,183],'Y'=>[197,195,197],'z'=>[187,249,240],'Z'=>[233,231,242],
|
|
'0'=>[195,184,218],'1'=>[232,180,196],'2'=>[191,193,196],'3'=>[185,186,186],
|
|
'4'=>[191,247,180],'5'=>[187,199,248],'6'=>[248,198,184],'7'=>[243,195,184],
|
|
'8'=>[232,192,208],'9'=>[239,197,183],'/'=>[199,187,241],
|
|
'+'=>[195,216,223],'='=>[193,211,184],
|
|
];
|
|
$map = [];
|
|
foreach ($base as $character => [$red, $green, $blue]) {
|
|
$average = ($red + $green + $blue) / 3;
|
|
$vivid = static function (int $value) use ($average): int {
|
|
$saturated = $average + (($value - $average) * 2.35);
|
|
$contrasted = (($saturated - 128) * 1.12) + 128 - 18;
|
|
return (int) round(max(0, min(255, $contrasted)));
|
|
};
|
|
$map[$vivid($red) . ',' . $vivid($green) . ',' . $vivid($blue)] = $character;
|
|
}
|
|
return $map;
|
|
}
|
|
}
|
|
|
|
final class TestWalletLoader
|
|
{
|
|
/** @return array{address: string, public_key: string, private_key: string} */
|
|
public static function load(
|
|
string $walletPath,
|
|
string $walletKey,
|
|
CryptoInterface $crypto,
|
|
): array {
|
|
if (!extension_loaded('openssl')) {
|
|
throw new RuntimeException('The OpenSSL PHP extension is required.');
|
|
}
|
|
if (!is_file($walletPath) || !is_readable($walletPath)) {
|
|
throw new RuntimeException('The wallet file is not readable.');
|
|
}
|
|
$contents = file_get_contents($walletPath);
|
|
if ($contents === false) {
|
|
throw new RuntimeException('The wallet file could not be read.');
|
|
}
|
|
try {
|
|
$wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
|
|
} catch (\JsonException) {
|
|
throw new RuntimeException('The wallet file is not valid JSON.');
|
|
}
|
|
if (!is_array($wallet)) {
|
|
throw new RuntimeException('The 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 ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex) || $encodedImage === '') {
|
|
throw new RuntimeException('The wallet contains invalid key data.');
|
|
}
|
|
$publicKey = hex2bin($publicKeyHex);
|
|
if ($publicKey === false) {
|
|
throw new RuntimeException('The wallet public key could not be decoded.');
|
|
}
|
|
$networkByte = $match[1] === 'clc' ? 1 : 2;
|
|
if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) {
|
|
$publicKey = substr($publicKey, 1);
|
|
}
|
|
if (strlen($publicKey) !== 897) {
|
|
throw new RuntimeException('The wallet public key has an invalid length.');
|
|
}
|
|
|
|
$privateKeyHex = self::decrypt(
|
|
WalletImageDecoder::extract($encodedImage),
|
|
$walletKey,
|
|
);
|
|
$privateKey = hex2bin($privateKeyHex);
|
|
if ($privateKey === false || strlen($privateKey) !== 1281) {
|
|
throw new RuntimeException('The wallet private key could not be decoded.');
|
|
}
|
|
if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) {
|
|
throw new RuntimeException('The wallet public key is invalid.');
|
|
}
|
|
$derived = hash('ripemd160', $crypto->skein256($publicKey)) . '.' . $match[1];
|
|
if (!hash_equals($address, $derived)) {
|
|
throw new RuntimeException('The wallet address does not match its public key.');
|
|
}
|
|
|
|
$signer = new FalconSigner($publicKey, $privateKey);
|
|
$challenge = $crypto->skein256('contractless-wallet-keypair-check');
|
|
if (!$crypto->verify($challenge, $signer->sign($challenge), $publicKey)) {
|
|
throw new RuntimeException('The wallet public and private keys do not match.');
|
|
}
|
|
return [
|
|
'address' => $address,
|
|
'public_key' => $publicKey,
|
|
'private_key' => $privateKey,
|
|
];
|
|
}
|
|
|
|
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);
|
|
if (!hash_equals($expectedHmac, hash_hmac('sha256', $ciphertext, $key, true))) {
|
|
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);
|
|
}
|
|
}
|