added encrypted image decoding for php

This commit is contained in:
viraladmin 2026-07-25 16:38:40 -06:00
parent 33718dcd81
commit 005f132758
6 changed files with 408 additions and 4 deletions

View File

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

View File

@ -5,7 +5,9 @@
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-gd": "*",
"ext-json": "*",
"ext-openssl": "*",
"ext-oqsphp": "*",
"ext-skein": "*"
},

27
examples/load_wallet.php Normal file
View File

@ -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);
}

View File

@ -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());
}
}

View File

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

148
src/Wallet/WalletLoader.php Normal file
View File

@ -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.',
);
}
}
}