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; } }