172 lines
6.4 KiB
PHP
172 lines
6.4 KiB
PHP
<?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;
|
|
}
|
|
}
|