added encrypted image decoding for php
This commit is contained in:
parent
33718dcd81
commit
005f132758
58
README.md
58
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
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-oqsphp": "*",
|
||||
"ext-skein": "*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Contractless\Rpc\Wallet\Credentials;
|
||||
|
||||
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
|
||||
if (!is_file($autoload)) {
|
||||
fwrite(STDERR, "Composer dependencies are not installed.\n");
|
||||
exit(1);
|
||||
}
|
||||
require $autoload;
|
||||
|
||||
if ($argc !== 3) {
|
||||
fwrite(STDERR, "Usage: php examples/load_wallet.php <wallet-file> <wallet-key>\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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Rpc\Wallet;
|
||||
|
||||
use Contractless\Rpc\Exception\ContractlessException;
|
||||
|
||||
final class EncryptedImageDecoder
|
||||
{
|
||||
private const IMAGE_WIDTH = 350;
|
||||
private const LENGTH_PREFIX_BYTES = 4;
|
||||
private const ANCHOR_ROWS = [0, 35, 70, 105, 140, 175, 210, 245, 280, 315, 349];
|
||||
|
||||
/** @var array<string, string>|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<string, string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Contractless\Rpc\Wallet;
|
||||
|
||||
use Contractless\Rpc\Crypto\CryptoInterface;
|
||||
use Contractless\Rpc\Exception\ContractlessException;
|
||||
|
||||
final class WalletLoader
|
||||
{
|
||||
public static function load(
|
||||
string $walletPath,
|
||||
string $walletKey,
|
||||
CryptoInterface $crypto,
|
||||
): Credentials {
|
||||
if (!extension_loaded('openssl')) {
|
||||
throw new ContractlessException('The OpenSSL PHP extension is required to decrypt wallets.');
|
||||
}
|
||||
if (!is_file($walletPath) || !is_readable($walletPath)) {
|
||||
throw new ContractlessException('The Contractless wallet file is not readable.');
|
||||
}
|
||||
|
||||
$contents = file_get_contents($walletPath);
|
||||
if ($contents === false) {
|
||||
throw new ContractlessException('The Contractless wallet file could not be read.');
|
||||
}
|
||||
|
||||
try {
|
||||
$wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $error) {
|
||||
throw new ContractlessException('The Contractless wallet file is not valid JSON.');
|
||||
}
|
||||
if (!is_array($wallet)) {
|
||||
throw new ContractlessException('The Contractless 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 ContractlessException('The wallet contains an invalid short address.');
|
||||
}
|
||||
if ($encodedImage === '') {
|
||||
throw new ContractlessException('The wallet does not contain a private-key image.');
|
||||
}
|
||||
|
||||
$publicKey = self::normalizePublicKey($publicKeyHex, $match[1]);
|
||||
$encryptedPrivateKey = EncryptedImageDecoder::extract($encodedImage);
|
||||
$privateKeyHex = self::decrypt($encryptedPrivateKey, $walletKey);
|
||||
$credentials = Credentials::fromHex(bin2hex($publicKey), $privateKeyHex);
|
||||
|
||||
self::validatePublicKey($credentials->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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue