Contractless-PHP-API/tools/WalletSupport.php

280 lines
11 KiB
PHP
Raw Permalink Normal View History

2026-07-27 22:32:38 +00:00
<?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);
}
}