patched testing tool
This commit is contained in:
parent
e0caa14fc5
commit
8e26f51eb6
44
README.md
44
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue