faucet controls its own wallet

This commit is contained in:
viraladmin 2026-07-27 14:36:33 -06:00
parent d059af2cea
commit defb1e3e95
6 changed files with 435 additions and 16 deletions

View File

@ -8,6 +8,7 @@ locally, and submits them without invoking Contractless CLI programs.
- 64-bit PHP 8.1 or newer
- Composer
- The PHP GD and OpenSSL extensions
- The Contractless PHP Skein and Falcon modules
- `contractless/contractless-php-rpc`
- Access to a compatible Contractless RPC node
@ -24,7 +25,7 @@ https://contractless.dev/contractless/Contractless-PHP-Modules
Install the RPC package in the Composer project containing the faucet:
```bash
composer require contractless/contractless-php-rpc:^0.2.0 -W
composer require contractless/contractless-php-rpc:^1.0.0 -W
```
The web-server account needs permission to read Composer's `vendor` directory
@ -84,10 +85,16 @@ CONTRACTLESS_WALLET_PATH=/var/www/project/faucet.wallet
CONTRACTLESS_WALLET_KEY=wallet-decryption-key
```
The RPC library reads the public key from the wallet, extracts the encrypted
private key from its image, verifies the image payload HMAC, decrypts the
private key, proves that the Falcon keypair matches, and verifies that the
wallet's short address is derived from that public key.
The faucet owns its dedicated hot wallet. Faucet code reads the public key,
extracts the encrypted private key from its image, verifies the image payload
HMAC, decrypts the private key, proves that the Falcon keypair matches, and
verifies that the wallet's short address is derived from that public key.
The faucet gives the RPC client only a public handshake proof. Its
faucet-owned signer implements the RPC package's narrow signing interface for
transaction construction, while retaining the private key entirely inside
faucet code. The wallet file, decryption key, and Falcon private key are never
passed to or stored by the RPC connection client.
`FAUCET_ADDRESS` must match the canonical `short_address` saved in this wallet.
Keep the wallet file and decryption key outside the public document root, but
@ -96,12 +103,11 @@ document root is `/var/www/project/public`, so `/var/www/project/faucet.wallet`
is private from HTTP while remaining available to PHP. Never expose the wallet
or key through HTML, JavaScript, logs, error responses, or source control.
Before enabling the faucet, verify the wallet through the installed RPC
package:
Before enabling the faucet, verify that the PHP process can load the required
native modules:
```bash
cd /path/to/Contractless-PHP-RPC
php examples/load_wallet.php /private/path/to/faucet.wallet 'wallet decryption key'
php -r 'var_dump(function_exists("skein_hash"), function_exists("contractless_shake256"), class_exists("OQS_SIGNATURE"));'
```
Configure the node used for RPC:

31
lib.php
View File

@ -3,9 +3,11 @@ declare(strict_types=1);
use Contractless\Rpc\Client;
use Contractless\Rpc\Crypto\NativeCrypto;
use Contractless\Rpc\Protocol\HandshakeProof;
use Contractless\Rpc\Transaction\TransferBuilder;
use Contractless\Rpc\Transport\StreamTransport;
use Contractless\Rpc\Wallet\Credentials;
use Contractless\Faucet\Wallet\FalconSigner;
use Contractless\Faucet\Wallet\WalletLoader;
function faucet_config(): array
{
@ -223,7 +225,11 @@ function composer_autoload_path(array $config): ?string
}
/**
* @return array{client: Client, crypto: NativeCrypto, credentials: Credentials}
* @return array{
* client: Client,
* crypto: NativeCrypto,
* signer: FalconSigner
* }
*/
function faucet_rpc(): array
{
@ -239,11 +245,22 @@ function faucet_rpc(): array
}
require_once $autoload;
$credentials = Credentials::fromWalletFile(
require_once __DIR__ . '/src/Wallet/FaucetWallet.php';
require_once __DIR__ . '/src/Wallet/FalconSigner.php';
require_once __DIR__ . '/src/Wallet/EncryptedImageDecoder.php';
require_once __DIR__ . '/src/Wallet/WalletLoader.php';
$crypto = new NativeCrypto();
$wallet = WalletLoader::load(
$config['wallet_path'],
$config['wallet_key'],
$crypto,
);
$faucetSigner = new FalconSigner($wallet->publicKey, $wallet->privateKey);
$handshakeProof = new HandshakeProof(
$wallet->publicKey,
$faucetSigner->sign($crypto->skein256('aced')),
);
$crypto = new NativeCrypto();
$client = new Client(
new StreamTransport(
$config['rpc_host'],
@ -251,13 +268,13 @@ function faucet_rpc(): array
$config['rpc_timeout_seconds'],
),
$crypto,
$credentials,
$handshakeProof,
);
$rpc = [
'client' => $client,
'crypto' => $crypto,
'credentials' => $credentials,
'signer' => $faucetSigner,
];
return $rpc;
}
@ -310,7 +327,7 @@ function create_faucet_transfer(string $receiver): array
throw new InvalidArgumentException('The receiving wallet is not registered.');
}
$transaction = (new TransferBuilder($rpc['crypto'], $rpc['credentials']))->create(
$transaction = (new TransferBuilder($rpc['crypto'], $rpc['signer']))->create(
sender: $config['faucet_address'],
receiver: $receiver,
coin: $network['symbol'],

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Contractless\Faucet\Wallet;
use RuntimeException;
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 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;
}
$text = self::extractFromOrientedImage($oriented);
if ($text !== null) {
return $text;
}
} finally {
if ($oriented !== null) {
imagedestroy($oriented);
} elseif ($image !== null) {
imagedestroy($image);
}
}
}
throw new RuntimeException('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;
}
$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;
}
/** @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;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Contractless\Faucet\Wallet;
use Contractless\Rpc\Signing\SignatureProviderInterface;
use RuntimeException;
final class FalconSigner implements SignatureProviderInterface
{
private const ALGORITHM = 'Falcon-padded-512';
/** @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 faucet contains an invalid Falcon keypair.');
}
$this->falcon = new \OQS_SIGNATURE(self::ALGORITHM);
}
public function sign(string $digest): string
{
if (strlen($digest) !== 32) {
throw new RuntimeException('Contractless signing requires a 32-byte digest.');
}
$signature = '';
$result = $this->falcon->sign(
$signature,
$this->adaptMessage($digest),
$this->privateKey,
);
if ($result !== 0 || strlen($signature) !== 666) {
throw new RuntimeException('Falcon signing failed.');
}
return $signature;
}
private function adaptMessage(string $message): string
{
$publicKeyHash = contractless_shake256($this->publicKey, 64);
if (!is_string($publicKeyHash) || strlen($publicKeyHash) !== 64) {
throw new RuntimeException('SHAKE256 public-key hashing failed.');
}
// Contractless FN-DSA uses DOMAIN_NONE and HASH_ID_RAW.
return $publicKeyHash . "\0\0" . $message;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Contractless\Faucet\Wallet;
use RuntimeException;
final class FaucetWallet
{
public function __construct(
public readonly string $address,
public readonly string $publicKey,
public readonly string $privateKey,
) {
if (strlen($publicKey) !== 897) {
throw new RuntimeException('The Falcon public key must contain exactly 897 bytes.');
}
if (strlen($privateKey) !== 1281) {
throw new RuntimeException('The Falcon private key must contain exactly 1,281 bytes.');
}
}
}

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

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Contractless\Faucet\Wallet;
use Contractless\Rpc\Crypto\CryptoInterface;
use RuntimeException;
final class WalletLoader
{
public static function load(
string $walletPath,
string $walletKey,
CryptoInterface $crypto,
): FaucetWallet {
if (!extension_loaded('openssl')) {
throw new RuntimeException('The OpenSSL PHP extension is required to decrypt wallets.');
}
if (!is_file($walletPath) || !is_readable($walletPath)) {
throw new RuntimeException('The Contractless wallet file is not readable.');
}
$contents = file_get_contents($walletPath);
if ($contents === false) {
throw new RuntimeException('The Contractless wallet file could not be read.');
}
try {
$wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new RuntimeException('The Contractless wallet file is not valid JSON.');
}
if (!is_array($wallet)) {
throw new RuntimeException('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 RuntimeException('The wallet contains an invalid short address.');
}
if ($encodedImage === '') {
throw new RuntimeException('The wallet does not contain a private-key image.');
}
$publicKey = self::normalizePublicKey($publicKeyHex, $match[1]);
$encryptedPrivateKey = EncryptedImageDecoder::extract($encodedImage);
$privateKeyHex = self::decrypt($encryptedPrivateKey, $walletKey);
$privateKey = hex2bin($privateKeyHex);
if ($privateKey === false) {
throw new RuntimeException('The decrypted Falcon private key could not be decoded.');
}
$loaded = new FaucetWallet($address, $publicKey, $privateKey);
self::validatePublicKey($loaded->publicKey, $crypto);
self::validateKeypair($loaded, $crypto);
self::validateAddress($loaded->address, $loaded->publicKey, $crypto);
return $loaded;
}
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);
$actualHmac = hash_hmac('sha256', $ciphertext, $key, true);
if (!hash_equals($expectedHmac, $actualHmac)) {
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);
}
private static function normalizePublicKey(string $publicKeyHex, string $network): string
{
if ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex)) {
throw new RuntimeException('The wallet contains an invalid Falcon public key.');
}
$publicKey = hex2bin($publicKeyHex);
if ($publicKey === false) {
throw new RuntimeException('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 RuntimeException('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 RuntimeException(
'The wallet public key does not satisfy the Contractless key rule.',
);
}
}
private static function validateKeypair(FaucetWallet $wallet, CryptoInterface $crypto): void
{
$challenge = $crypto->skein256('contractless-wallet-keypair-check');
$signature = (new FalconSigner($wallet->publicKey, $wallet->privateKey))->sign($challenge);
if (!$crypto->verify($challenge, $signature, $wallet->publicKey)) {
throw new RuntimeException(
'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';
$payload = hash('ripemd160', $crypto->skein256($publicKey));
if (!hash_equals($address, $payload . '.' . $suffix)) {
throw new RuntimeException(
'The wallet short address does not match its Falcon public key.',
);
}
}
}