235 lines
8.0 KiB
PHP
235 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Coruna reporting transport crypto (ParamsModel / TTNetwork).
|
|
*
|
|
* body = Base64(AES-256-ECB-PKCS7(SHA256(session_key||timestamp), timestamp||payload))
|
|
*/
|
|
class CorunaCrypto
|
|
{
|
|
private const KEY_STATE_HEX =
|
|
'f2e61e583b65753af05b8f6ec65a681fcc6f93d20cca9153ed13133c6c291565';
|
|
|
|
private const AES_SBOX_HEX =
|
|
'637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0'
|
|
.'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275'
|
|
.'09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf'
|
|
.'d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2'
|
|
.'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb'
|
|
.'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08'
|
|
.'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e'
|
|
.'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16';
|
|
|
|
private string $sessionKey;
|
|
|
|
private string $sbox;
|
|
|
|
public function __construct(?string $sessionKey = null)
|
|
{
|
|
$this->sbox = hex2bin(self::AES_SBOX_HEX);
|
|
$this->sessionKey = $sessionKey ?? $this->deriveSessionKey(0);
|
|
if (strlen($this->sessionKey) !== 16) {
|
|
throw new RuntimeException('session key must be 16 bytes');
|
|
}
|
|
}
|
|
|
|
public function sessionKey(): string
|
|
{
|
|
return $this->sessionKey;
|
|
}
|
|
|
|
public function deriveArchivePassword(int $seed = 0): string
|
|
{
|
|
$mask = 0xFFFFFFFF;
|
|
$state = hex2bin(self::KEY_STATE_HEX);
|
|
$words = array_values(unpack('V8', $state));
|
|
|
|
if ($seed !== 0) {
|
|
$counter = -35;
|
|
$accumulator = $seed & $mask;
|
|
for ($index = 0; $index < 8; $index++) {
|
|
$rotated = $this->ror32($seed, -38 - $counter);
|
|
$words[$index] = ($accumulator + ($words[$index] ^ $rotated)) & $mask;
|
|
if ($counter === 0) {
|
|
break;
|
|
}
|
|
$counter += 5;
|
|
$accumulator = ($accumulator + $seed) & $mask;
|
|
}
|
|
}
|
|
|
|
for ($roundIndex = 0; $roundIndex < 12; $roundIndex++) {
|
|
$roundNumber = $roundIndex + 1;
|
|
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16 — uint64 via BCMath
|
|
$roundValue = $this->mulU64Shift16($roundNumber);
|
|
for ($index = 0; $index < 8; $index++) {
|
|
$value = $words[$index];
|
|
$value =
|
|
ord($this->sbox[$value & 0xFF])
|
|
| (ord($this->sbox[($value >> 8) & 0xFF]) << 8)
|
|
| (ord($this->sbox[($value >> 16) & 0xFF]) << 16)
|
|
| (ord($this->sbox[($value >> 24) & 0xFF]) << 24);
|
|
$value = $this->ror32($value, -$words[($index + 1) & 7]);
|
|
$value ^= $this->ror32($words[($index + 3) & 7], 13);
|
|
$value = ($value + $roundValue) & $mask;
|
|
$words[$index] = $value;
|
|
if ($index & 1) {
|
|
$words[$index] = (
|
|
$this->ror32($words[$index - 1], -($value & 0xF)) ^ $value
|
|
) & $mask;
|
|
}
|
|
}
|
|
if ($roundIndex === 5) {
|
|
$words[2] ^= 0x7BD6C6C8;
|
|
$words[5] ^= 0x5ECAF26A;
|
|
} elseif ($roundIndex === 9) {
|
|
$previousZero = $words[0];
|
|
$words[0] = ($words[7] ^ $this->ror32($previousZero, 25)) & $mask;
|
|
$words[3] = ($words[3] + ($words[4] ^ 0xDEADBEEF)) & $mask;
|
|
}
|
|
}
|
|
|
|
$packed = pack('V8', ...$words);
|
|
$folded = '';
|
|
for ($i = 0; $i < 16; $i++) {
|
|
$folded .= chr(ord($packed[$i]) ^ ord($packed[$i + 16]));
|
|
}
|
|
$derived = '';
|
|
for ($i = 0; $i < 16; $i++) {
|
|
$derived .= $this->sbox[(ord($folded[$i]) + $i) & 0xFF];
|
|
}
|
|
|
|
return bin2hex($derived);
|
|
}
|
|
|
|
public function deriveSessionKey(int $seed = 0): string
|
|
{
|
|
$raw = hex2bin($this->deriveArchivePassword($seed));
|
|
$out = '';
|
|
for ($i = 0; $i < strlen($raw); $i++) {
|
|
$out .= chr((ord($raw[$i]) % 94) + 33);
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
public function decryptJsonBody(string $ciphertext, string $timestamp): mixed
|
|
{
|
|
$this->assertTimestamp($timestamp);
|
|
$encrypted = base64_decode($ciphertext, true);
|
|
if ($encrypted === false) {
|
|
$decoded = json_decode($ciphertext, true);
|
|
if (is_string($decoded)) {
|
|
$encrypted = base64_decode($decoded, true);
|
|
}
|
|
}
|
|
if ($encrypted === false || $encrypted === '') {
|
|
throw new RuntimeException('invalid base64 body');
|
|
}
|
|
|
|
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
|
$padded = openssl_decrypt($encrypted, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
|
if ($padded === false) {
|
|
throw new RuntimeException('AES decrypt failed');
|
|
}
|
|
$plaintext = $this->pkcs7Unpad($padded);
|
|
$prefix = $timestamp;
|
|
if (! str_starts_with($plaintext, $prefix)) {
|
|
throw new RuntimeException('timestamp prefix mismatch');
|
|
}
|
|
$json = substr($plaintext, strlen($prefix));
|
|
if ($json === 'null') {
|
|
return null;
|
|
}
|
|
|
|
return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
public function encryptPayload(string $payload, ?string $timestamp = null): array
|
|
{
|
|
$timestamp ??= (string) (int) round(microtime(true) * 1000);
|
|
$this->assertTimestamp($timestamp);
|
|
$key = hash('sha256', $this->sessionKey.$timestamp, true);
|
|
$plain = $timestamp.$payload;
|
|
$padded = $this->pkcs7Pad($plain);
|
|
$encrypted = openssl_encrypt($padded, 'AES-256-ECB', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
|
|
if ($encrypted === false) {
|
|
throw new RuntimeException('AES encrypt failed');
|
|
}
|
|
|
|
return [
|
|
'timestamp' => $timestamp,
|
|
'body' => base64_encode($encrypted),
|
|
];
|
|
}
|
|
|
|
public function encryptJson(mixed $data, ?string $timestamp = null): array
|
|
{
|
|
if ($data === null) {
|
|
$payload = 'null';
|
|
} else {
|
|
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
return $this->encryptPayload($payload, $timestamp);
|
|
}
|
|
|
|
public function archivePassword(string $batchBaseTimestamp = '0'): string
|
|
{
|
|
return $this->sessionKey.$batchBaseTimestamp;
|
|
}
|
|
|
|
private function assertTimestamp(string $timestamp): void
|
|
{
|
|
if (! preg_match('/^\d{13}$/', $timestamp)) {
|
|
throw new RuntimeException('timestamp must be 13 digits');
|
|
}
|
|
}
|
|
|
|
private function pkcs7Pad(string $data): string
|
|
{
|
|
$pad = 16 - (strlen($data) % 16);
|
|
|
|
return $data.str_repeat(chr($pad), $pad);
|
|
}
|
|
|
|
private function pkcs7Unpad(string $data): string
|
|
{
|
|
$len = strlen($data);
|
|
if ($len === 0 || ($len % 16) !== 0) {
|
|
throw new RuntimeException('invalid ciphertext length');
|
|
}
|
|
$pad = ord($data[$len - 1]);
|
|
if ($pad < 1 || $pad > 16 || substr($data, -$pad) !== str_repeat(chr($pad), $pad)) {
|
|
throw new RuntimeException('invalid PKCS#7 padding');
|
|
}
|
|
|
|
return substr($data, 0, -$pad);
|
|
}
|
|
|
|
private function mulU64Shift16(int $roundNumber): int
|
|
{
|
|
// ((n * 0xAC534878DC48202A) & 0xFFFFFFFFFFFFFFFF) >> 16
|
|
$product = gmp_mul((string) $roundNumber, '0xAC534878DC48202A');
|
|
$masked = gmp_and($product, '0xFFFFFFFFFFFFFFFF');
|
|
$shifted = gmp_div_q($masked, 65536);
|
|
|
|
return (int) gmp_intval($shifted);
|
|
}
|
|
|
|
private function ror32(int $value, int $amount): int
|
|
{
|
|
$value &= 0xFFFFFFFF;
|
|
$amount &= 31;
|
|
if ($amount === 0) {
|
|
return $value;
|
|
}
|
|
|
|
return (($value >> $amount) | (($value << (32 - $amount)) & 0xFFFFFFFF)) & 0xFFFFFFFF;
|
|
}
|
|
}
|