From defb1e3e9538a85db734ed62325fef7eba9c9c87 Mon Sep 17 00:00:00 2001 From: viraladmin <00purple@gmail.com> Date: Mon, 27 Jul 2026 14:36:33 -0600 Subject: [PATCH] faucet controls its own wallet --- README.md | 24 ++-- lib.php | 31 +++-- src/Wallet/EncryptedImageDecoder.php | 169 +++++++++++++++++++++++++++ src/Wallet/FalconSigner.php | 59 ++++++++++ src/Wallet/FaucetWallet.php | 23 ++++ src/Wallet/WalletLoader.php | 145 +++++++++++++++++++++++ 6 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 src/Wallet/EncryptedImageDecoder.php create mode 100644 src/Wallet/FalconSigner.php create mode 100644 src/Wallet/FaucetWallet.php create mode 100644 src/Wallet/WalletLoader.php diff --git a/README.md b/README.md index 740235c..6afe657 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib.php b/lib.php index b0e1e05..252a715 100644 --- a/lib.php +++ b/lib.php @@ -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'], diff --git a/src/Wallet/EncryptedImageDecoder.php b/src/Wallet/EncryptedImageDecoder.php new file mode 100644 index 0000000..39fdb9e --- /dev/null +++ b/src/Wallet/EncryptedImageDecoder.php @@ -0,0 +1,169 @@ +|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 */ + 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/FalconSigner.php b/src/Wallet/FalconSigner.php new file mode 100644 index 0000000..1276601 --- /dev/null +++ b/src/Wallet/FalconSigner.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/src/Wallet/FaucetWallet.php b/src/Wallet/FaucetWallet.php new file mode 100644 index 0000000..b9c0dbe --- /dev/null +++ b/src/Wallet/FaucetWallet.php @@ -0,0 +1,23 @@ +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.', + ); + } + } +}