falcon = new \OQS_SIGNATURE('Falcon-padded-512'); } public function sign(string $digest): string { if (strlen($digest) !== 32) { throw new RuntimeException('Contractless signing requires a 32-byte digest.'); } $publicKeyHash = contractless_shake256($this->publicKey, 64); if (!is_string($publicKeyHash) || strlen($publicKeyHash) !== 64) { throw new RuntimeException('SHAKE256 public-key hashing failed.'); } $signature = ''; $result = $this->falcon->sign( $signature, $publicKeyHash . "\0\0" . $digest, $this->privateKey, ); if ($result !== 0 || strlen($signature) !== 666) { throw new RuntimeException('Falcon signing failed.'); } return $signature; } } final class WalletImageDecoder { private const ROWS = [0, 35, 70, 105, 140, 175, 210, 245, 280, 315, 349]; 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; } $decoded = self::decode($oriented); if ($decoded !== null) { return $decoded; } } finally { if ($oriented !== null) { imagedestroy($oriented); } elseif ($image !== null) { imagedestroy($image); } } } throw new RuntimeException('The wallet private-key image could not be decoded.'); } 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; } private static function decode(object $image): ?string { if (imagesx($image) !== 350) { return null; } $characters = ''; $map = self::colorMap(); foreach (self::ROWS as $row) { for ($x = 0; $x < 350; $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, 4); if (strlen($prefix) !== 4 || !ctype_digit($prefix)) { return null; } $length = (int) $prefix; $payload = substr($characters, 4, $length); return strlen($payload) === $length ? $payload : null; } /** @return array */ private static function colorMap(): array { $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; } return $map; } } final class TestWalletLoader { /** @return array{address: string, public_key: string, private_key: string} */ public static function load( string $walletPath, string $walletKey, CryptoInterface $crypto, ): array { if (!extension_loaded('openssl')) { throw new RuntimeException('The OpenSSL PHP extension is required.'); } if (!is_file($walletPath) || !is_readable($walletPath)) { throw new RuntimeException('The wallet file is not readable.'); } $contents = file_get_contents($walletPath); if ($contents === false) { throw new RuntimeException('The wallet file could not be read.'); } try { $wallet = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); } catch (\JsonException) { throw new RuntimeException('The wallet file is not valid JSON.'); } if (!is_array($wallet)) { throw new RuntimeException('The 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 ($publicKeyHex === '' || !ctype_xdigit($publicKeyHex) || $encodedImage === '') { throw new RuntimeException('The wallet contains invalid key data.'); } $publicKey = hex2bin($publicKeyHex); if ($publicKey === false) { throw new RuntimeException('The wallet public key could not be decoded.'); } $networkByte = $match[1] === 'clc' ? 1 : 2; if (strlen($publicKey) === 898 && ord($publicKey[0]) === $networkByte) { $publicKey = substr($publicKey, 1); } if (strlen($publicKey) !== 897) { throw new RuntimeException('The wallet public key has an invalid length.'); } $privateKeyHex = self::decrypt( WalletImageDecoder::extract($encodedImage), $walletKey, ); $privateKey = hex2bin($privateKeyHex); if ($privateKey === false || strlen($privateKey) !== 1281) { throw new RuntimeException('The wallet private key could not be decoded.'); } if (ord($publicKey[0]) !== 9 || ord($crypto->skein256($publicKey)[0]) !== 239) { throw new RuntimeException('The wallet public key is invalid.'); } $derived = hash('ripemd160', $crypto->skein256($publicKey)) . '.' . $match[1]; if (!hash_equals($address, $derived)) { throw new RuntimeException('The wallet address does not match its public key.'); } $signer = new FalconSigner($publicKey, $privateKey); $challenge = $crypto->skein256('contractless-wallet-keypair-check'); if (!$crypto->verify($challenge, $signer->sign($challenge), $publicKey)) { throw new RuntimeException('The wallet public and private keys do not match.'); } return [ 'address' => $address, 'public_key' => $publicKey, 'private_key' => $privateKey, ]; } 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); if (!hash_equals($expectedHmac, hash_hmac('sha256', $ciphertext, $key, true))) { 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); } }