Contractless-PHP-RPC/src/Wallet/EncryptedImageDecoder.php

172 lines
6.4 KiB
PHP
Raw Normal View History

2026-07-25 22:38:40 +00:00
<?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;
}
}