diff --git a/README.md b/README.md index 8ea3a6b..502678d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Contractless PHP RPC +# PHP Contractless RPC `contractless-php-rpc` connects PHP applications directly to a Contractless node. It implements the authenticated binary RPC protocol without invoking @@ -21,6 +21,8 @@ exposed. ## Requirements - PHP 8.1 or newer, running as a 64-bit build +- The PHP GD extension +- The PHP OpenSSL extension - The `skein` module from `contractless-php-crypto` - The `oqsphp` module from `contractless-php-crypto` @@ -39,6 +41,22 @@ When this package is available through Packagist: composer require contractless/contractless-php-rpc ``` +Until the package is published, add it as a Composer path repository: + +```json +{ + "repositories": [ + { + "type": "path", + "url": "../contractless-php-rpc" + } + ], + "require": { + "contractless/contractless-php-rpc": "@dev" + } +} +``` + Then install it: ```bash @@ -127,9 +145,41 @@ integers or floating-point numbers. ## Wallet Material -The library accepts raw Falcon public and private key bytes. It does not read or -decrypt a Contractless private-key image. A server application should load keys -from protected secrets and must never place them in a public web directory. +The library can load a normal Contractless wallet file directly: + +```php +use Contractless\Rpc\Wallet\Credentials; + +$credentials = Credentials::fromWalletFile( + '/private/path/contractless.wallet', + 'wallet decryption key', +); +``` + +The loader decodes all supported Contractless private-key image orientations, +verifies the encrypted payload HMAC, decrypts the Falcon private key, proves +that the public and private keys match, and verifies the wallet's canonical +short address. + +Wallet files and decryption keys must remain outside public web directories, +logs, repositories, and client-visible configuration. + +Verify a wallet file and decryption key before using them in an application: + +```bash +php examples/load_wallet.php /private/path/contractless.wallet 'wallet decryption key' +``` + +Successful output confirms that the image was decoded, its encrypted payload +was authenticated and decrypted, the Falcon keypair matches, and the wallet +address is valid. + +Raw Falcon key bytes remain supported for applications that already manage +their own protected key storage: + +```php +$credentials = Credentials::fromHex($publicKeyHex, $privateKeyHex); +``` ## Testing diff --git a/composer.json b/composer.json index c5cdc4c..ecebdd7 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,9 @@ "license": "MIT", "require": { "php": ">=8.1", + "ext-gd": "*", "ext-json": "*", + "ext-openssl": "*", "ext-oqsphp": "*", "ext-skein": "*" }, diff --git a/examples/load_wallet.php b/examples/load_wallet.php new file mode 100644 index 0000000..f36c1c6 --- /dev/null +++ b/examples/load_wallet.php @@ -0,0 +1,27 @@ + \n"); + exit(1); +} + +try { + $credentials = Credentials::fromWalletFile($argv[1], $argv[2]); + echo "Contractless wallet loaded and verified.\n"; + echo 'Public key bytes: ' . strlen($credentials->publicKey) . "\n"; + echo 'Private key bytes: ' . strlen($credentials->privateKey) . "\n"; +} catch (Throwable $error) { + fwrite(STDERR, 'Wallet verification failed: ' . $error->getMessage() . "\n"); + exit(1); +} diff --git a/src/Wallet/Credentials.php b/src/Wallet/Credentials.php index d420eeb..2f88bd4 100644 --- a/src/Wallet/Credentials.php +++ b/src/Wallet/Credentials.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Contractless\Rpc\Wallet; +use Contractless\Rpc\Crypto\NativeCrypto; use Contractless\Rpc\Protocol\Binary; final class Credentials @@ -23,4 +24,9 @@ final class Credentials Binary::hex($privateKey, 1281, 'Falcon private key'), ); } + + public static function fromWalletFile(string $walletPath, string $walletKey): self + { + return WalletLoader::load($walletPath, $walletKey, new NativeCrypto()); + } } diff --git a/src/Wallet/EncryptedImageDecoder.php b/src/Wallet/EncryptedImageDecoder.php new file mode 100644 index 0000000..646c6d5 --- /dev/null +++ b/src/Wallet/EncryptedImageDecoder.php @@ -0,0 +1,171 @@ +|null */ + private static ?array $colorMap = null; + + public static function extract(string $encodedImage): string + { + if (!extension_loaded('gd')) { + throw new ContractlessException('The GD PHP extension is required to read wallet images.'); + } + + $png = base64_decode($encodedImage, true); + if ($png === false) { + throw new ContractlessException('The wallet private-key image is not valid Base64.'); + } + + foreach (['h', 'h2', 'v', 'v2'] as $style) { + $image = @imagecreatefromstring($png); + if ($image === false) { + throw new ContractlessException('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; + } + $text = self::extractFromOrientedImage($oriented); + if ($text !== null) { + return $text; + } + } finally { + if ($oriented !== null) { + imagedestroy($oriented); + } elseif ($image !== null) { + imagedestroy($image); + } + } + } + + throw new ContractlessException('The wallet private-key image could not be decoded.'); + } + + /** @param \GdImage $image */ + private static function orient(object $image, string $style): object + { + if ($style === 'h') { + return $image; + } + if ($style === 'h2') { + imageflip($image, IMG_FLIP_VERTICAL); + return $image; + } + + // PHP rotates counter-clockwise. These transformations mirror the + // inverse rotations used by encrypted_images during decoding. + $rotated = imagerotate($image, $style === 'v' ? 90 : -90, 0); + if ($rotated === false) { + throw new ContractlessException('The wallet image could not be rotated.'); + } + if ($style === 'v2') { + imageflip($rotated, IMG_FLIP_VERTICAL); + } + return $rotated; + } + + /** @param \GdImage $image */ + private static function extractFromOrientedImage(object $image): ?string + { + if (imagesx($image) !== self::IMAGE_WIDTH) { + return null; + } + + $characters = ''; + $map = self::colorMap(); + foreach (self::ANCHOR_ROWS as $row) { + for ($x = 0; $x < self::IMAGE_WIDTH; $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, self::LENGTH_PREFIX_BYTES); + if (strlen($prefix) !== self::LENGTH_PREFIX_BYTES || !ctype_digit($prefix)) { + return null; + } + $length = (int) $prefix; + $payload = substr($characters, self::LENGTH_PREFIX_BYTES, $length); + return strlen($payload) === $length ? $payload : null; + } + + /** @return array */ + private static function colorMap(): array + { + if (self::$colorMap !== null) { + return self::$colorMap; + } + + $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; + } + + self::$colorMap = $map; + return $map; + } +} diff --git a/src/Wallet/WalletLoader.php b/src/Wallet/WalletLoader.php new file mode 100644 index 0000000..2deb899 --- /dev/null +++ b/src/Wallet/WalletLoader.php @@ -0,0 +1,148 @@ +publicKey, $crypto); + self::validateKeypair($credentials, $crypto); + self::validateAddress($address, $credentials->publicKey, $crypto); + + return $credentials; + } + + private static function decrypt(string $encodedCiphertext, string $walletKey): string + { + $encrypted = base64_decode($encodedCiphertext, true); + if ($encrypted === false || strlen($encrypted) < 49) { + throw new ContractlessException('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); + $actualHmac = hash_hmac('sha256', $ciphertext, $key, true); + if (!hash_equals($expectedHmac, $actualHmac)) { + throw new ContractlessException('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 ContractlessException('The decrypted Falcon private key is invalid.'); + } + return strtolower($privateKey); + } + + private static function normalizePublicKey(string $publicKeyHex, string $network): string + { + if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex)) { + throw new ContractlessException('The wallet contains an invalid Falcon public key.'); + } + $publicKey = hex2bin($publicKeyHex); + if ($publicKey === false) { + throw new ContractlessException('The wallet public key could not be decoded.'); + } + + $networkByte = $network === 'clc' ? 1 : 2; + if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) { + $publicKey = substr($publicKey, 1); + } + if (strlen($publicKey) !== 897) { + throw new ContractlessException('The wallet Falcon public key has an invalid length.'); + } + return $publicKey; + } + + private static function validatePublicKey(string $publicKey, CryptoInterface $crypto): void + { + if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) { + throw new ContractlessException( + 'The wallet public key does not satisfy the Contractless key rule.', + ); + } + } + + private static function validateKeypair(Credentials $credentials, CryptoInterface $crypto): void + { + $challenge = $crypto->skein256('contractless-wallet-keypair-check'); + $signature = $crypto->sign( + $challenge, + $credentials->privateKey, + $credentials->publicKey, + ); + if (!$crypto->verify($challenge, $signature, $credentials->publicKey)) { + throw new ContractlessException( + 'The wallet public key does not match the decrypted private key.', + ); + } + } + + private static function validateAddress( + string $address, + string $publicKey, + CryptoInterface $crypto, + ): void { + $suffix = str_ends_with($address, '.clc') ? 'clc' : 'cltc'; + $skein = $crypto->skein256($publicKey); + $payload = hash('ripemd160', $skein); + $derived = $payload . '.' . $suffix; + if (!hash_equals($address, $derived)) { + throw new ContractlessException( + 'The wallet short address does not match its Falcon public key.', + ); + } + } +}