<?php

declare(strict_types=1);

/**
 * Compute.php
 *
 * @since     2008-01-02
 * @category  Library
 * @package   PdfEncrypt
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-encrypt
 *
 * This file is part of tc-lib-pdf-encrypt software library.
 */

namespace Com\Tecnick\Pdf\Encrypt;

use Com\Tecnick\Pdf\Encrypt\Exception as EncException;

/**
 * Com\Tecnick\Pdf\Encrypt\Compute
 *
 * Computes the PDF encryption data.
 *
 * @since     2008-01-02
 * @category  Library
 * @package   PdfEncrypt
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-encrypt
 *
 * @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
 */
abstract class Compute extends \Com\Tecnick\Pdf\Encrypt\Data
{
    /**
     * Encrypt data using the specified encrypt type.
     *
     * @param int|string $type   Encrypt type.
     * @param string     $data   Data string to encrypt.
     * @param string     $key    Encryption key.
     * @param int        $objnum Object number.
     * @param int        $gennum Object generation number.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    public function encrypt(
        int|string $type,
        string $data = '',
        string $key = '',
        int $objnum = 0,
        int $gennum = 0,
    ): string {
        if (!$this->encryptdata['encrypted']) {
            return $data;
        }

        $encoder = self::ENCMAP[$type] ?? null;
        if ($encoder === null) {
            throw new EncException('unknown encryption type: ' . $type);
        }

        if ($key === '' && $type === $this->encryptdata['mode']) {
            if ($this->encryptdata['mode'] < 3) {
                $key = $this->getObjectKey($objnum, $gennum);
            }

            if ($this->encryptdata['mode'] >= 3) { // mode >= 3: AES-256 (R5 or R6), use the full document key
                $key = $this->encryptdata['key'];
            }
        }

        return $this->encryptWithEncoderName($encoder, $data, $key);
    }

    /**
     * Dispatch to a concrete encryptor class for static analyzers.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithEncoderName(string $encoder, string $data, string $key): string
    {
        return match ($encoder) {
            'RCFourFive' => $this->encryptWithRCFourFive($data, $key),
            'RCFourSixteen' => $this->encryptWithRCFourSixteen($data, $key),
            'AESSixteen' => $this->encryptWithAESSixteen($data, $key),
            'AESThirtytwo' => $this->encryptWithAESThirtytwo($data, $key),
            'RCFour' => $this->encryptWithRCFour($data, $key),
            'AES' => $this->encryptWithAES($data, $key),
            'AESnopad' => $this->encryptWithAESnopad($data, $key),
            'MDFiveSixteen' => $this->encryptWithMDFiveSixteen($data, $key),
            'Seed' => $this->encryptSeed($data, $key),
            default => throw new EncException('unknown encryption type: ' . $encoder),
        };
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithRCFourFive(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\RCFourFive();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithRCFourSixteen(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\RCFourSixteen();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithAESSixteen(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\AESSixteen();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithAESThirtytwo(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\AESThirtytwo();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithRCFour(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\RCFour();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithAES(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\AES();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithAESnopad(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
        return $enc->encrypt($data, $key);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptWithMDFiveSixteen(string $data, string $key): string
    {
        $enc = new \Com\Tecnick\Pdf\Encrypt\Type\MDFiveSixteen();
        return $enc->encrypt($data, $key);
    }

    /**
     * Return a random seed followed by the data and the key.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptSeed(string $data, string $key): string
    {
        return $this->randomSeed() . $data . $key;
    }

    /**
     * Return Seed::SEEDLEN raw random bytes.
     *
     * This is the single source of randomness in the class. It does not go
     * through encrypt(), which returns its input unchanged while encryption is
     * not enabled.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception When no secure source is available.
     */
    protected function randomSeed(): string
    {
        try {
            $enc = new \Com\Tecnick\Pdf\Encrypt\Type\Seed();
            return $enc->encrypt();
        } catch (\Random\RandomException $e) {
            throw new EncException('Unable to generate random seed', 0, $e);
        }
    }

    /**
     * Compute encryption key depending on object number where the encrypted data is stored.
     * This is used for all strings and streams without crypt filter specifier.
     *
     * @param int $objnum Object number.
     * @param int $gennum Object generation number.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception When either number is out of range.
     */
    public function getObjectKey(int $objnum, int $gennum = 0): string
    {
        if ($objnum < 0 || $objnum > 0xFF_FFFF) {
            // Algorithm 1 keeps the low-order three bytes of the object number only.
            throw new EncException('object number out of range: ' . $objnum);
        }

        if ($gennum < 0 || $gennum > 0xFFFF) {
            throw new EncException('generation number out of range: ' . $gennum);
        }

        // Algorithm 1 step (b): the low-order three bytes of the object number
        // followed by the low-order two bytes of the generation number.
        $objkey = $this->encryptdata['key'] . \substr(\pack('V', $objnum), 0, 3) . \pack('v', $gennum);
        if ($this->encryptdata['mode'] === 2) {
            // AES-128 padding
            $objkey .= "\x73\x41\x6C\x54"; // sAlT
        }

        $objkey = \substr($this->encrypt('MD5-16', $objkey, 'H*'), 0, (int) (($this->encryptdata['Length'] / 8) + 5));
        return \substr($objkey, 0, 16);
    }

    /**
     * Convert encryption P value to a string of bytes, low-order byte first.
     *
     * @param int $protection 32bit encryption permission value (P value).
     */
    public function getEncPermissionsString(int $protection): string
    {
        return \pack('V', $protection & 0xFFFF_FFFF);
    }

    /**
     * Convert encryption P value to the four bytes carried by a public-key
     * recipient envelope, high-order byte first.
     *
     * The byte order is the opposite of the /P key material of
     * getEncPermissionsString().
     *
     * @param int $protection 32bit encryption permission value (P value).
     */
    public function getPubKeyPermissionsString(int $protection): string
    {
        return \pack('N', $protection & 0xFFFF_FFFF);
    }

    /**
     * Return the four bytes appended to the key material when the document
     * metadata is not encrypted (ISO 32000-1 Algorithm 2 step (f) and
     * Algorithm 3 step (a) for public-key mode).
     *
     * @param int  $revision        Security handler revision (R), or V for public-key mode.
     * @param bool $encryptMetadata Whether metadata streams are encrypted.
     */
    protected function getMetadataKeyExtension(int $revision, bool $encryptMetadata): string
    {
        return $revision >= 4 && !$encryptMetadata ? "\xFF\xFF\xFF\xFF" : '';
    }

    /**
     * Return the permission code used on encryption (P value).
     *
     * The returned value is a signed 32-bit integer as required by Table 22:
     * reserved bits 7, 8 and 13 to 32 are set, bits 1 and 2 are clear, and every
     * granted permission bit is set.
     *
     * @param array<string> $permissions The set of permissions (specify the ones you want to block).
     * @param int   $mode        Encryption strength: 0 = RC4-40 (deprecated); 1 = RC4-128 (deprecated);
     *                           2 = AES-128; 3 = AES-256 R5; 4 = AES-256 R6 (PDF 2.0 / ISO 32000-2).
     *                           Revision 2 defines fewer permission bits than the others.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception When a permission name is not recognised.
     */
    public function getUserPermissionCode(array $permissions, int $mode): int
    {
        $protection = self::PERMDEFAULT;
        foreach ($permissions as $permission) {
            $permBit = self::PERMBITS[$permission] ?? throw new EncException('unknown permission: ' . $permission);

            // Revision 2 defines bits 3 to 6 only: the others stay granted.
            if ($mode <= 0 && $permBit > 32) {
                continue;
            }

            if ($permBit === 2) {
                // the logic for bit 2 is inverted (cleared by default)
                $protection |= $permBit;
                continue;
            }

            $protection &= ~$permBit;
        }

        return $protection;
    }

    /**
     * Compute UE value
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getUEValue(): string
    {
        if ($this->encryptdata['mode'] === 4) { // R6: use Algorithm 2.B
            return $this->encrypt(
                'AESnopad',
                $this->encryptdata['key'],
                $this->hash2B($this->encryptdata['user_password'], $this->encryptdata['UKS']),
            );
        }

        return $this->encrypt(
            'AESnopad',
            $this->encryptdata['key'],
            \hash('sha256', $this->encryptdata['user_password'] . $this->encryptdata['UKS'], true),
        );
    }

    /**
     * Compute OE value
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getOEValue(): string
    {
        if ($this->encryptdata['mode'] === 4) { // R6: use Algorithm 2.B
            return $this->encrypt(
                'AESnopad',
                $this->encryptdata['key'],
                $this->hash2B($this->encryptdata['owner_password'], $this->encryptdata['OKS'], $this->encryptdata['U']),
            );
        }

        return $this->encrypt(
            'AESnopad',
            $this->encryptdata['key'],
            \hash(
                'sha256',
                $this->encryptdata['owner_password'] . $this->encryptdata['OKS'] . $this->encryptdata['U'],
                true,
            ),
        );
    }

    /**
     * Compute U value
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getUValue(): string
    {
        if ($this->encryptdata['mode'] === 0) { // RC4-40
            return $this->encrypt('RC4', self::ENCPAD, $this->encryptdata['key']);
        }

        if ($this->encryptdata['mode'] < 3) { // RC4-128, AES-128
            $tmp = $this->encrypt('MD5-16', self::ENCPAD . $this->encryptdata['fileid'], 'H*');
            $enc = $this->encrypt('RC4', $tmp, $this->encryptdata['key']);
            // Algorithm 5 step (e) iterates over the bytes of the file key, whose
            // length is Length/8 and is not necessarily the 16-byte digest length.
            $len = \strlen($this->encryptdata['key']);
            for ($idx = 1; $idx <= 19; ++$idx) {
                $ekey = '';
                for ($jdx = 0; $jdx < $len; ++$jdx) {
                    $ekey .= \chr((\ord($this->encryptdata['key'][$jdx]) ^ $idx) & 0xFF);
                }

                $enc = $this->encrypt('RC4', $enc, $ekey);
            }

            $enc .= \str_repeat("\x00", 16);
            return \substr($enc, 0, 32);
        }

        // AES-256 ($this->encryptdata['mode'] >= 3)
        return $this->getUValueAes256();
    }

    /**
     * Compute O value
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getOValue(): string
    {
        if ($this->encryptdata['mode'] < 3) { // RC4-40, RC4-128, AES-128
            $tmp = $this->encrypt('MD5-16', $this->encryptdata['owner_password'], 'H*');
            if ($this->encryptdata['mode'] > 0) {
                for ($idx = 0; $idx < 50; ++$idx) {
                    $tmp = $this->encrypt('MD5-16', $tmp, 'H*');
                }
            }

            $owner_key = \substr($tmp, 0, (int) ($this->encryptdata['Length'] / 8));
            $enc = $this->encrypt('RC4', $this->encryptdata['user_password'], $owner_key);
            if ($this->encryptdata['mode'] > 0) {
                $len = \strlen($owner_key);
                for ($idx = 1; $idx <= 19; ++$idx) {
                    $ekey = '';
                    for ($jdx = 0; $jdx < $len; ++$jdx) {
                        $ekey .= \chr((\ord($owner_key[$jdx]) ^ $idx) & 0xFF);
                    }

                    $enc = $this->encrypt('RC4', $enc, $ekey);
                }
            }

            return $enc;
        }

        // AES-256 ($this->encryptdata['mode'] >= 3)
        return $this->getOValueAes256();
    }

    /**
     * Generate a fresh 8-byte validation salt and 8-byte key salt for AES-256
     * (R5/R6) password hashing (ISO 32000 Algorithm 8/9).
     *
     * @return array{0: string, 1: string} [validationSalt, keySalt]
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function generateAes256Salts(): array
    {
        $seed = $this->randomSeed();
        return [\substr($seed, 0, 8), \substr($seed, 8, 8)];
    }

    /**
     * Compute the U value for AES-256 (R5/R6).
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getUValueAes256(): string
    {
        [$this->encryptdata['UVS'], $this->encryptdata['UKS']] = $this->generateAes256Salts();
        $uHash = $this->encryptdata['mode'] === 4
            ? $this->hash2B($this->encryptdata['user_password'], $this->encryptdata['UVS']) // R6: Algorithm 2.B
            : \hash('sha256', $this->encryptdata['user_password'] . $this->encryptdata['UVS'], true); // R5: SHA-256

        return $uHash . $this->encryptdata['UVS'] . $this->encryptdata['UKS'];
    }

    /**
     * Compute the O value for AES-256 (R5/R6).
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getOValueAes256(): string
    {
        [$this->encryptdata['OVS'], $this->encryptdata['OKS']] = $this->generateAes256Salts();
        // R6 hashes the owner password with the U value through Algorithm 2.B;
        // R5 uses plain SHA-256 over the same input.
        $oHash = $this->encryptdata['mode'] === 4
            ? $this->hash2B($this->encryptdata['owner_password'], $this->encryptdata['OVS'], $this->encryptdata['U'])
            : \hash(
                'sha256',
                $this->encryptdata['owner_password'] . $this->encryptdata['OVS'] . $this->encryptdata['U'],
                true,
            );

        return $oHash . $this->encryptdata['OVS'] . $this->encryptdata['OKS'];
    }

    /**
     * Compute Algorithm 2.B hash (ISO 32000-2 / PDF 2.0) for AES-256 R6 key derivation.
     *
     * Implements the iterative SHA-256/384/512 + AES-128-CBC hash described in
     * ISO 32000-2 section 7.6.4.3.4 (Algorithm 2.B).
     *
     * @param string $password  UTF-8 password (already truncated to ≤ 127 bytes).
     * @param string $salt      8-byte validation or key salt.
     * @param string $userHash  48-byte U value for owner-side calculations; empty for user-side.
     *
     * @throws EncException When the internal AES-128-CBC step fails.
     */
    protected function hash2B(#[\SensitiveParameter] string $password, string $salt, string $userHash = ''): string
    {
        // Step 1: hash password || salt || userHash.
        $hashK = \hash('sha256', $password . $salt . $userHash, true);
        $round = 0;
        do {
            // inputBlock length is always 64 * (len(password) + 32 + len(userHash)).
            // 64 is a multiple of 16, so inputBlock is always AES-block-aligned (no padding needed).
            $inputBlock = \str_repeat($password . $hashK . $userHash, 64);
            $encrypted = \openssl_encrypt(
                $inputBlock,
                'aes-128-cbc',
                \substr($hashK, 0, 16),
                OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING,
                \substr($hashK, 16, 16),
            );
            if ($encrypted === false) {
                throw new EncException('AES-128-CBC encryption failed in hash2B');
            }

            $sum = 0;
            for ($idx = 0; $idx < 16; ++$idx) {
                $sum += \ord($encrypted[$idx]);
            }

            $algo = match ($sum % 3) {
                0 => 'sha256',
                1 => 'sha384',
                default => 'sha512',
            };
            $hashK = \hash($algo, $encrypted, true);
            ++$round;
            $lastByte = \ord($encrypted[\strlen($encrypted) - 1]);
        } while (!($round >= 64 && $lastByte <= ($round - 32)));

        return \substr($hashK, 0, 32);
    }

    /**
     * Compute encryption key
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function generateEncryptionKey(): void
    {
        if ($this->encryptdata['pubkey']) {
            $this->generatePublicEncryptionKey();
            return;
        }

        // standard mode
        $this->generateStandardEncryptionKey();
    }

    /**
     * Compute standard encryption key
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function generateStandardEncryptionKey(): void
    {
        $keybytelen = $this->encryptdata['Length'] / 8;
        if ($this->encryptdata['mode'] >= 3) { // AES-256 (R5 or R6)
            $this->generateAes256StandardEncryptionKey((int) $keybytelen);

            return;
        }

        $this->generateLegacyStandardEncryptionKey((int) $keybytelen);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function generateAes256StandardEncryptionKey(int $keybytelen): void
    {
        // generate 256 bit random key
        $this->encryptdata['key'] = \substr(\hash('sha256', $this->randomSeed(), true), 0, $keybytelen);
        // truncate passwords
        $this->encryptdata['user_password'] = \substr($this->encryptdata['user_password'], 0, 127);
        $this->encryptdata['owner_password'] = \substr($this->encryptdata['owner_password'], 0, 127);
        $this->encryptdata['U'] = $this->getUValue();
        $this->encryptdata['UE'] = $this->getUEValue();
        $this->encryptdata['O'] = $this->getOValue();
        $this->encryptdata['OE'] = $this->getOEValue();
        $this->encryptdata['P'] = $this->encryptdata['protection'];
        // Perms entry of the encryption dictionary.
        $perms = $this->getEncPermissionsString($this->encryptdata['protection']); // bytes 0-3
        $perms .= \chr(255) . \chr(255) . \chr(255) . \chr(255); // bytes 4-7
        $perms .= $this->encryptdata['EncryptMetadata'] ? 'T' : 'F'; // byte 8: EncryptMetadata flag
        $perms .= 'adb'; // bytes 9-11
        $perms .= \substr($this->randomSeed(), 0, 4); // bytes 12-15: random
        $this->encryptdata['perms'] = $this->encrypt('AESnopad', $perms, $this->encryptdata['key']);
    }

    /**
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function generateLegacyStandardEncryptionKey(int $keybytelen): void
    {
        // RC4-40, RC4-128, AES-128
        // Pad passwords
        $this->encryptdata['user_password'] = \substr($this->encryptdata['user_password'] . self::ENCPAD, 0, 32);
        $this->encryptdata['owner_password'] = \substr($this->encryptdata['owner_password'] . self::ENCPAD, 0, 32);
        $this->encryptdata['O'] = $this->getOValue();
        // get default permissions (reverse byte order)
        $permissions = $this->getEncPermissionsString($this->encryptdata['protection']);
        // Compute encryption key
        $tmp = $this->encrypt(
            'MD5-16',
            $this->encryptdata['user_password'] . $this->encryptdata['O'] . $permissions . $this->encryptdata['fileid']
                . $this->getMetadataKeyExtension($this->encryptdata['R'], $this->encryptdata['EncryptMetadata']),
            'H*',
        );
        if ($this->encryptdata['mode'] > 0) {
            for ($idx = 0; $idx < 50; ++$idx) {
                $tmp = $this->encrypt('MD5-16', \substr($tmp, 0, $keybytelen), 'H*');
            }
        }

        $this->encryptdata['key'] = \substr($tmp, 0, $keybytelen);
        $this->encryptdata['U'] = $this->getUValue();
        $this->encryptdata['P'] = $this->encryptdata['protection'];
    }

    /**
     * Compute public encryption key
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     *
     * @SuppressWarnings("PHPMD.CyclomaticComplexity")
     * @SuppressWarnings("PHPMD.NPathComplexity")
     * @SuppressWarnings("PHPMD.ExcessiveMethodLength")
     */
    protected function generatePublicEncryptionKey(): void
    {
        $keybytelen = (int) ($this->encryptdata['Length'] / 8);
        // random 20-byte seed
        $seed = \sha1($this->randomSeed(), true);
        $recipient_bytes = '';
        $pubkeys = $this->encryptdata['pubkeys'] ?? throw new EncException('Missing pubkeys');
        foreach ($pubkeys as $pubkey) {
            $recipient_bytes .= $this->getEncryptedRecipientBytes($pubkey, $seed);
        }

        // Algorithm 3 step (a): the four metadata bytes close the hash input.
        $recipient_bytes .= $this->getMetadataKeyExtension(
            $this->encryptdata['V'],
            $this->encryptdata['EncryptMetadata'],
        );

        // calculate encryption key
        if ($this->encryptdata['mode'] >= 3) { // AES-256 (R5/R6)
            $this->encryptdata['key'] = \substr(\hash('sha256', $seed . $recipient_bytes, true), 0, $keybytelen);
            return;
        }

        // RC4-40, RC4-128, AES-128
        $this->encryptdata['key'] = \substr(\sha1($seed . $recipient_bytes, true), 0, $keybytelen);
    }

    /**
     * @param array{c:string,p?:array<string>} $pubkey Certificate path and, optionally, the
     *                                                 permissions to block for that recipient.
     *                                                 Without 'p' every permission is granted.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function getEncryptedRecipientBytes(array $pubkey, string $seed): string
    {
        $pkprotection = $this->getUserPermissionCode($pubkey['p'] ?? [], $this->encryptdata['mode']);

        // Envelope: 20-byte seed followed by 4 permission bytes, high-order byte first.
        $envelope = $seed . $this->getPubKeyPermissionsString($pkprotection);

        // Write the envelope to a temporary file with a name that does not
        // derive from its content.
        $tempkeyfile = \tempnam(\sys_get_temp_dir(), '__tcpdf_key_');
        if ($tempkeyfile === false) {
            throw new EncException('Unable to generate temporary key file name');
        }

        if (\file_put_contents($tempkeyfile, $envelope) === false) {
            \unlink($tempkeyfile);
            throw new EncException('Unable to create temporary key file: ' . $tempkeyfile);
        }

        $tempencfile = \tempnam(\sys_get_temp_dir(), '__tcpdf_enc_');
        if ($tempencfile === false) {
            \unlink($tempkeyfile);
            throw new EncException('Unable to generate temporary key file name');
        }

        try {
            return $this->encryptRecipientEnvelope($pubkey, $tempkeyfile, $tempencfile);
        } finally {
            // $tempkeyfile holds the plaintext envelope: remove both files on every path.
            \unlink($tempkeyfile);
            \unlink($tempencfile);
        }
    }

    /**
     * Encrypt the envelope file to the recipient's certificate and extract the
     * resulting PKCS#7 signature bytes.
     *
     * $pubkey['c'] is read with file_get_contents() and no scheme restriction:
     * it is trusted caller input, not a value taken from a document.
     *
     * @param array{c:string,p?:array<string>} $pubkey      Recipient certificate/permissions.
     * @param string                          $tempkeyfile  Path to the plaintext envelope file.
     * @param string                          $tempencfile  Path to the PKCS#7 output file.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception
     */
    protected function encryptRecipientEnvelope(array $pubkey, string $tempkeyfile, string $tempencfile): string
    {
        if (!\function_exists('openssl_pkcs7_encrypt')) {
            throw new EncException(
                'Unable to encrypt the file: '
                . $tempkeyfile
                . "\n"
                . 'Public-Key Security requires openssl_pkcs7_encrypt.',
            );
        }

        $pkey = \file_get_contents($pubkey['c']);
        if ($pkey === false) {
            throw new EncException('Unable to read public key file: ' . $pubkey['c']);
        }

        // Match the envelope cipher to the document key strength.
        $cipher = $this->encryptdata['mode'] >= 3 ? OPENSSL_CIPHER_AES_256_CBC : OPENSSL_CIPHER_AES_128_CBC;

        if (!\openssl_pkcs7_encrypt($tempkeyfile, $tempencfile, $pkey, [], PKCS7_BINARY, $cipher)) {
            throw new EncException(
                'Unable to encrypt the file: '
                . $tempkeyfile
                . "\n"
                . 'OpenSSL error: '
                . (string) \openssl_error_string(),
            );
        }

        // read encryption signature
        $signature = \file_get_contents($tempencfile);
        if ($signature === false) {
            throw new EncException('Unable to read signature file: ' . $tempencfile);
        }

        // The body starts after the first blank line; the MIME header order and
        // line endings vary between OpenSSL builds.
        $tmparr = \preg_split('/\R\R/', $signature, 2);
        if ($tmparr === false || !isset($tmparr[1])) {
            throw new EncException('Unable to find signature content');
        }

        $signature = \trim($tmparr[1]);
        unset($tmparr);
        $signature = \base64_decode($signature, strict: true);
        if ($signature === false) {
            throw new EncException('Unable to decode signature: ' . $tempencfile);
        }

        $sigarr = \unpack('H*', $signature);
        if ($sigarr === false) {
            throw new EncException('Unable to unpack signature: ' . $tempencfile);
        }

        $hexsignature = (string) \current($sigarr);
        if ($hexsignature === '') {
            throw new EncException('Unable to convert signature: ' . $tempencfile);
        }

        $this->encryptdata['Recipients'][] = $hexsignature;

        // Raw bytes of this entry of the Recipients array of PKCS#7 objects.
        return $signature;
    }
}
