patched testing tool

This commit is contained in:
viraladmin 2026-07-27 16:32:38 -06:00
parent e0caa14fc5
commit 8e26f51eb6
3 changed files with 335 additions and 65 deletions

View File

@ -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

279
tools/WalletSupport.php Normal file
View File

@ -0,0 +1,279 @@
<?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);
}
}

View File

@ -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);
function hiddenPrompt(string $prompt): string
{
fwrite(STDOUT, $prompt);
$isWindows = PHP_OS_FAMILY === 'Windows';
if (!$isWindows) {
shell_exec('stty -echo');
}
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);
try {
$value = fgets(STDIN);
} finally {
if (!$isWindows) {
shell_exec('stty echo');
}
if (preg_match('/^[a-f0-9]{1332}$/', $signature) !== 1) {
fwrite(
STDERR,
"Set CONTRACTLESS_TEST_SIGNATURE to the Falcon handshake signature hex.\n",
);
exit(1);
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);