From 8e26f51eb6ac1904f4b01220de705026f86952a1 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Mon, 27 Jul 2026 16:32:38 -0600 Subject: [PATCH] patched testing tool --- README.md | 44 ++----- tools/WalletSupport.php | 279 ++++++++++++++++++++++++++++++++++++++++ tools/test_api.php | 77 ++++++----- 3 files changed, 335 insertions(+), 65 deletions(-) create mode 100644 tools/WalletSupport.php diff --git a/README.md b/README.md index cf95655..a0a700d 100644 --- a/README.md +++ b/README.md @@ -108,35 +108,14 @@ for later API calls. It does not send its private key or wallet decryption key. ### Test Tool -The CLI-only test tool sends an existing wallet handshake proof through the -same headers a browser wallet will use. Set the credentials in the current -shell: +The CLI-only test tool loads a wallet locally, prompts privately for its +decryption key, generates the handshake proof, and tests the default +`/api/v1/height` route: ```bash -export CONTRACTLESS_TEST_ADDRESS="$(jq -r '.short_address' /path/to/wallet)" -export CONTRACTLESS_TEST_PUBLIC_KEY="$(jq -r '.public_key' /path/to/wallet)" -``` - -Generate the handshake signature with the Contractless `sign_message` tool: - -```bash -./sign_message "aced" -``` - -Enter the same wallet path and decryption key. Copy only the hexadecimal value -printed after `signature:`: - -```bash -export CONTRACTLESS_TEST_SIGNATURE='1332-character-signature-from-sign-message' -``` - -`sign_message` and the node handshake both sign the Skein-256 hash of the exact -text `aced`, so this produces the proof expected by the API. - -Then test the default `/api/v1/height` route: - -```bash -php tools/test_api.php https://api.contractless.dev +php tools/test_api.php \ + https://api.contractless.dev \ + /private/path/to/test.wallet ``` An alternative GET route may be provided: @@ -144,12 +123,17 @@ An alternative GET route may be provided: ```bash php tools/test_api.php \ https://api.contractless.dev \ + /private/path/to/test.wallet \ /api/v1/network ``` -The tool never accepts or loads a private key. It tests the exact public proof -that applications will submit to the API and returns a nonzero exit status -when the HTTP request fails. +Wallet decryption and Falcon signing happen only inside the CLI process. The +tool sends the address, public key, and handshake signature to the API. It +never sends the wallet file, private key, or decryption key and returns a +nonzero exit status when the HTTP request fails. + +The test tool requires the PHP GD and OpenSSL extensions in addition to the +Contractless Skein and Falcon modules. ## Initial Endpoints diff --git a/tools/WalletSupport.php b/tools/WalletSupport.php new file mode 100644 index 0000000..adb3b26 --- /dev/null +++ b/tools/WalletSupport.php @@ -0,0 +1,279 @@ +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 */ + 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); + } +} diff --git a/tools/test_api.php b/tools/test_api.php index 5fbe7e9..07e6642 100644 --- a/tools/test_api.php +++ b/tools/test_api.php @@ -2,31 +2,32 @@ declare(strict_types=1); +use Contractless\Api\Tools\FalconSigner; +use Contractless\Api\Tools\TestWalletLoader; +use Contractless\Rpc\Crypto\NativeCrypto; + if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } +$projectRoot = dirname(__DIR__); +require $projectRoot . '/vendor/autoload.php'; +require __DIR__ . '/WalletSupport.php'; + $baseUrl = rtrim((string) ($argv[1] ?? ''), '/'); -$address = strtolower(trim( - (string) (getenv('CONTRACTLESS_TEST_ADDRESS') ?: ''), -)); -$publicKey = strtolower(trim( - (string) (getenv('CONTRACTLESS_TEST_PUBLIC_KEY') ?: ''), -)); -$signature = strtolower(trim( - (string) (getenv('CONTRACTLESS_TEST_SIGNATURE') ?: ''), -)); -$route = (string) ($argv[2] ?? '/api/v1/height'); +$walletPath = (string) ($argv[2] ?? ''); +$route = (string) ($argv[3] ?? '/api/v1/height'); if ( $baseUrl === '' || filter_var($baseUrl, FILTER_VALIDATE_URL) === false || !str_starts_with($baseUrl, 'https://') + || $walletPath === '' ) { fwrite( STDERR, - "Usage: php tools/test_api.php https://api.example.com [/api/v1/route]\n", + "Usage: php tools/test_api.php https://api.example.com /path/to/wallet [/api/v1/route]\n", ); exit(1); } @@ -34,36 +35,44 @@ if (!str_starts_with($route, '/api/v1')) { fwrite(STDERR, "The test route must begin with /api/v1.\n"); exit(1); } -if (preg_match('/^[a-f0-9]{40}\.(clc|cltc)$/', $address) !== 1) { - fwrite(STDERR, "Set CONTRACTLESS_TEST_ADDRESS to a canonical wallet address.\n"); - exit(1); -} -if ( - preg_match('/^(?:[a-f0-9]{1794}|[a-f0-9]{1796})$/', $publicKey) !== 1 -) { - fwrite( - STDERR, - "Set CONTRACTLESS_TEST_PUBLIC_KEY to the wallet's Falcon public-key hex.\n", - ); - exit(1); -} -if (preg_match('/^[a-f0-9]{1332}$/', $signature) !== 1) { - fwrite( - STDERR, - "Set CONTRACTLESS_TEST_SIGNATURE to the Falcon handshake signature hex.\n", - ); - exit(1); + +function hiddenPrompt(string $prompt): string +{ + fwrite(STDOUT, $prompt); + $isWindows = PHP_OS_FAMILY === 'Windows'; + if (!$isWindows) { + shell_exec('stty -echo'); + } + try { + $value = fgets(STDIN); + } finally { + if (!$isWindows) { + shell_exec('stty echo'); + } + fwrite(STDOUT, PHP_EOL); + } + return trim((string) $value); } try { + $walletKey = hiddenPrompt('What is your wallet decryption key? '); + if ($walletKey === '') { + throw new RuntimeException('Wallet decryption key cannot be empty.'); + } + + $crypto = new NativeCrypto(); + $wallet = TestWalletLoader::load($walletPath, $walletKey, $crypto); + $signer = new FalconSigner($wallet['public_key'], $wallet['private_key']); + $signature = $signer->sign($crypto->skein256('aced')); + $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'header' => implode("\r\n", [ 'Accept: application/json', - 'X-Contractless-Address: ' . $address, - 'X-Contractless-Public-Key: ' . $publicKey, - 'X-Contractless-Signature: ' . $signature, + 'X-Contractless-Address: ' . $wallet['address'], + 'X-Contractless-Public-Key: ' . bin2hex($wallet['public_key']), + 'X-Contractless-Signature: ' . bin2hex($signature), ]), 'ignore_errors' => true, 'timeout' => 20, @@ -79,7 +88,6 @@ try { if (preg_match('/\s(\d{3})\s/', $statusLine, $match) !== 1) { throw new RuntimeException('The API returned an invalid HTTP response.'); } - $status = (int) $match[1]; echo "HTTP $status\n"; try { @@ -91,7 +99,6 @@ try { } catch (JsonException) { echo $body . PHP_EOL; } - exit($status >= 200 && $status < 300 ? 0 : 1); } catch (Throwable $error) { fwrite(STDERR, 'API test failed: ' . $error->getMessage() . PHP_EOL);