Files
electionvue/vendor/tecnickcom/tc-lib-pdf-encrypt/src/Decrypt.php
T
2026-08-30 22:02:02 +00:00

622 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Decrypt.php
*
* @since 2026-04-30
* @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\Type\AESnopad;
/**
* Com\Tecnick\Pdf\Encrypt\Decrypt
*
* Authenticates a password (or private key for public-key mode) against a PDF
* encryption dictionary and recovers the document file-encryption key.
*
* Usage:
* $dec = new Decrypt($encrypt->getEncryptionData());
* if ($dec->authenticate('userpass')) {
* $plaintext = $dec->decryptString($ciphertext, $objnum);
* }
*
* After successful authentication the derived key is stored internally and:
* - decryptString() decrypts PDF string/stream objects.
* - getObjectKey() returns the per-object key for AES-128 streams.
* - getDocumentKey() returns the raw 32-byte (or shorter) file key.
*
* @since 2026-04-30
* @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
*
* @phpstan-import-type TEncryptData from Output
*
* @phpstan-type TDecryptInput TEncryptData|array{
* 'V': int,
* 'Length': int,
* 'O': string,
* 'U': string,
* 'P': int,
* 'fileid': string,
* 'mode': int,
* 'OE'?: string,
* 'UE'?: string,
* 'EncryptMetadata'?: bool,
* 'pubkey'?: bool,
* 'Recipients'?: array<array-key, string>,
* }
*
* @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
*/
class Decrypt extends \Com\Tecnick\Pdf\Encrypt\Compute
{
/**
* Initialise the decryptor from an encryption dictionary.
*
* Accepts the array returned by Encrypt::getEncryptionData() or any array
* that satisfies the TDecryptInput shape. All fields not present in the
* input are filled with the defaults defined in Output::$encryptdata.
*
* @param TDecryptInput $input Encryption dictionary fields.
*/
public function __construct(array $input)
{
$this->encryptdata['V'] = (int) $input['V'];
$this->encryptdata['Length'] = (int) $input['Length'];
$this->encryptdata['O'] = $input['O'];
$this->encryptdata['U'] = $input['U'];
$this->encryptdata['P'] = (int) $input['P'];
$this->encryptdata['fileid'] = $input['fileid'];
$this->encryptdata['mode'] = (int) $input['mode'];
$this->encryptdata['EncryptMetadata'] = $input['EncryptMetadata'] ?? $this->encryptdata['EncryptMetadata'];
$this->encryptdata['pubkey'] = $input['pubkey'] ?? $this->encryptdata['pubkey'];
if (\array_key_exists('Recipients', $input)) {
$this->encryptdata['Recipients'] = $input['Recipients'];
}
if (\array_key_exists('OE', $input)) {
$this->encryptdata['OE'] = $input['OE'];
}
if (\array_key_exists('UE', $input)) {
$this->encryptdata['UE'] = $input['UE'];
}
// Ensure encrypt()-based primitives (RC4, MD5-16) are active for key derivation.
$this->encryptdata['encrypted'] = true;
// Clear the key: it must be recovered by a successful authenticate() call.
$this->encryptdata['key'] = '';
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Authenticate using a password and/or private key.
*
* Tries the supplied string first as the user password, then as the owner
* password. For public-key mode, $privkeyPath must be the path to a PEM
* file containing the recipient's certificate and private key; $password is
* ignored in that case.
*
* On success the derived file-encryption key is stored internally.
*
* @param string $password UTF-8 password to test (ignored for pubkey mode).
* @param string $privkeyPath Path to PEM file for public-key mode.
*
* @return bool True when authentication succeeds.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
public function authenticate(#[\SensitiveParameter] string $password, string $privkeyPath = ''): bool
{
if ($this->encryptdata['pubkey']) {
return $this->authenticatePublicKey($privkeyPath);
}
if ($this->encryptdata['mode'] >= 3) {
return $this->authenticatePasswordR5R6($password);
}
return $this->authenticatePasswordR24($password);
}
/**
* Decrypt a PDF string or stream object.
*
* Must be called after a successful authenticate() call.
*
* For RC4 modes (0, 1) the operation is symmetric: the same method that
* encrypts also decrypts. For AES modes (2, 3, 4) the first 16 bytes of
* $data are the random IV; the remainder is the ciphertext. The PKCS#7
* padding applied during encryption is stripped, so the exact original
* plaintext is returned.
*
* @param string $data Encrypted string/stream data.
* @param int $objnum PDF object number (used for per-object key derivation
* in RC4 and AES-128 modes).
*
* @return string Decrypted data.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
public function decryptString(string $data, int $objnum = 0): string
{
if (!$this->encryptdata['encrypted'] || $this->encryptdata['key'] === '') {
return $data;
}
$mode = $this->encryptdata['mode'];
if ($mode < 2) {
// RC4 is symmetric: the same encrypt() call decrypts.
return $this->encrypt($mode, $data, '', $objnum);
}
return $this->decryptAes($data, $objnum);
}
/**
* Return the recovered file-encryption key.
*
* @return string Raw binary key (empty string before authenticate() succeeds).
*/
public function getDocumentKey(): string
{
return $this->encryptdata['key'];
}
// -------------------------------------------------------------------------
// RC4 / R2R4 authentication (modes 0, 1, 2)
// -------------------------------------------------------------------------
/**
* Authenticate a password for R2R4 (RC4-40, RC4-128, AES-128).
*
* First tries the password as the user password; on failure tries it as the
* owner password.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticatePasswordR24(#[\SensitiveParameter] string $password): bool
{
$paddedPass = \substr($password . self::ENCPAD, 0, 32);
if ($this->authenticateUserR24($paddedPass)) {
return true;
}
return $this->authenticateOwnerR24($password);
}
/**
* Authenticate $paddedPass as the user password for R2R4.
*
* Derives a candidate encryption key from the padded password and verifies
* it against the stored U value using Algorithm 6 (PDF spec §7.6.3.4).
* On success the verified key remains stored in $encryptdata['key'].
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticateUserR24(string $paddedPass): bool
{
$savedKey = $this->encryptdata['key'];
$this->encryptdata['key'] = $this->deriveKeyR24($paddedPass);
$computedU = $this->getUValue();
if ($this->compareUserHashR24($computedU)) {
return true;
}
$this->encryptdata['key'] = $savedKey;
return false;
}
/**
* Authenticate $password as the owner password for R2R4.
*
* Derives the owner key, decrypts the O entry to recover the candidate user
* password, then delegates to authenticateUserR24.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticateOwnerR24(#[\SensitiveParameter] string $password): bool
{
$paddedOwner = \substr($password . self::ENCPAD, 0, 32);
$ownerKey = $this->deriveOwnerKeyR24($paddedOwner);
$candidateUserPass = $this->decryptOToUserPass($ownerKey);
return $this->authenticateUserR24($candidateUserPass);
}
/**
* Derive the file encryption key for R2R4 from a 32-byte padded user password.
*
* Implements Algorithm 2 from PDF spec §7.6.3.3.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function deriveKeyR24(string $paddedPass): string
{
$keybytelen = (int) ($this->encryptdata['Length'] / 8);
$permBytes = $this->getEncPermissionsString($this->encryptdata['P']);
$tmp = $this->encrypt(
'MD5-16',
$paddedPass . $this->encryptdata['O'] . $permBytes . $this->encryptdata['fileid'],
);
if ($this->encryptdata['mode'] > 0) {
for ($idx = 0; $idx < 50; ++$idx) {
$tmp = $this->encrypt('MD5-16', \substr($tmp, 0, $keybytelen));
}
}
return \substr($tmp, 0, $keybytelen);
}
/**
* Compare a freshly computed U value against the stored one for R2R4.
*
* R2 requires an exact 32-byte match; R3/R4 compare only the first 16 bytes.
*/
protected function compareUserHashR24(string $computedU): bool
{
if ($this->encryptdata['mode'] === 0) {
return $computedU === $this->encryptdata['U'];
}
return \substr($computedU, 0, 16) === \substr($this->encryptdata['U'], 0, 16);
}
/**
* Derive the owner key from a 32-byte padded owner password for R2R4.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function deriveOwnerKeyR24(string $paddedOwnerPass): string
{
$keybytelen = (int) ($this->encryptdata['Length'] / 8);
$tmp = $this->encrypt('MD5-16', $paddedOwnerPass);
if ($this->encryptdata['mode'] > 0) {
for ($idx = 0; $idx < 50; ++$idx) {
$tmp = $this->encrypt('MD5-16', \substr($tmp, 0, $keybytelen));
}
}
return \substr($tmp, 0, $keybytelen);
}
/**
* Decrypt the O entry using the owner key to recover the candidate user password.
*
* Reverses the iterative RC4 encryption applied by getOValue() in Compute.
*
* @SuppressWarnings("PHPMD.CyclomaticComplexity")
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function decryptOToUserPass(string $ownerKey): string
{
if ($this->encryptdata['mode'] === 0) {
return $this->encrypt('RC4', $this->encryptdata['O'], $ownerKey);
}
$keyLen = \strlen($ownerKey);
$candidate = $this->encryptdata['O'];
for ($idx = 19; $idx >= 1; --$idx) {
$xoredKey = '';
for ($jdx = 0; $jdx < $keyLen; ++$jdx) {
$xoredKey .= \chr((\ord($ownerKey[$jdx]) ^ $idx) & 0xFF);
}
$candidate = $this->encrypt('RC4', $candidate, $xoredKey);
}
return $this->encrypt('RC4', $candidate, $ownerKey);
}
// -------------------------------------------------------------------------
// AES-256 / R5R6 authentication (modes 3, 4)
// -------------------------------------------------------------------------
/**
* Authenticate a password for R5 (mode 3) or R6 (mode 4).
*
* Tries the password as the user password, then as the owner password.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticatePasswordR5R6(#[\SensitiveParameter] string $password): bool
{
if ($this->authenticateUserR5R6($password)) {
$this->recoverKeyFromUser($password);
return true;
}
if ($this->authenticateOwnerR5R6($password)) {
$this->recoverKeyFromOwner($password);
return true;
}
return false;
}
/**
* Verify $password as the user password for R5/R6 (Algorithm 11/13).
*
* Computes hash(password ∥ U[32..39]) and compares to U[0..32].
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticateUserR5R6(#[\SensitiveParameter] string $password): bool
{
$uvs = \substr($this->encryptdata['U'], 32, 8);
$expected = \substr($this->encryptdata['U'], 0, 32);
return $this->hashR5R6($password, $uvs) === $expected;
}
/**
* Verify $password as the owner password for R5/R6 (Algorithm 13/15).
*
* Computes hash(password ∥ O[32..39] ∥ U[0..48]) and compares to O[0..32].
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function authenticateOwnerR5R6(#[\SensitiveParameter] string $password): bool
{
$ovs = \substr($this->encryptdata['O'], 32, 8);
$userHash = \substr($this->encryptdata['U'], 0, 48);
$expected = \substr($this->encryptdata['O'], 0, 32);
return $this->hashR5R6($password, $ovs, $userHash) === $expected;
}
/**
* Recover the file encryption key using the verified user password (Algorithm 12/14).
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function recoverKeyFromUser(#[\SensitiveParameter] string $password): void
{
$uks = \substr($this->encryptdata['U'], 40, 8);
$hashkey = $this->hashR5R6($password, $uks);
$aesnopad = new AESnopad();
$this->encryptdata['key'] = $aesnopad->decrypt($this->encryptdata['UE'], $hashkey);
}
/**
* Recover the file encryption key using the verified owner password (Algorithm 14/16).
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function recoverKeyFromOwner(#[\SensitiveParameter] string $password): void
{
$oks = \substr($this->encryptdata['O'], 40, 8);
$userHash = \substr($this->encryptdata['U'], 0, 48);
$hashkey = $this->hashR5R6($password, $oks, $userHash);
$aesnopad = new AESnopad();
$this->encryptdata['key'] = $aesnopad->decrypt($this->encryptdata['OE'], $hashkey);
}
/**
* Compute the R5/R6 password hash.
*
* R5 (mode 3): SHA-256(password ∥ salt ∥ userHash).
* R6 (mode 4): Algorithm 2.B (ISO 32000-2 §7.6.4.3.4).
*
* @param string $password UTF-8 password (truncated to ≤ 127 bytes by caller).
* @param string $salt 8-byte validation or key salt.
* @param string $userHash 48-byte U value for owner-side; empty for user-side.
*
* @return string 32-byte binary hash.
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function hashR5R6(#[\SensitiveParameter] string $password, string $salt, string $userHash = ''): string
{
if ($this->encryptdata['mode'] === 4) {
return $this->hash2B($password, $salt, $userHash);
}
return \hash('sha256', $password . $salt . $userHash, true);
}
// -------------------------------------------------------------------------
// AES stream/string decryption helper
// -------------------------------------------------------------------------
/**
* Decrypt an AES-encrypted PDF string or stream (modes 2, 3, 4).
*
* The ciphertext is prefixed with a 16-byte random IV as required by the
* PDF specification (§7.6.3). Returns an empty string when the data is
* shorter than the IV length.
*
* The string/stream ciphertext is produced with PKCS#7 block padding
* (AESV2/AESV3 crypt filters, ISO 32000), so decryption is performed
* WITHOUT OPENSSL_ZERO_PADDING: OpenSSL validates and strips the PKCS#7
* padding, recovering the exact original plaintext. Returns an empty
* string when the padding is invalid (e.g. wrong key or corrupted data).
*
* @throws \Com\Tecnick\Pdf\Encrypt\Exception
*/
protected function decryptAes(string $data, int $objnum): string
{
if (\strlen($data) <= AESnopad::BLOCKSIZE) {
return '';
}
$ivect = \substr($data, 0, AESnopad::BLOCKSIZE);
$ciphertext = \substr($data, AESnopad::BLOCKSIZE);
$mode = $this->encryptdata['mode'];
$key = $mode < 3 ? $this->getObjectKey($objnum) : $this->encryptdata['key'];
$cipher = $mode === 2 ? 'aes-128-cbc' : 'aes-256-cbc';
$dec = \openssl_decrypt($ciphertext, $cipher, $key, OPENSSL_RAW_DATA, $ivect);
return $dec === false ? '' : $dec;
}
// -------------------------------------------------------------------------
// Public-key mode authentication
// -------------------------------------------------------------------------
/**
* Authenticate using a recipient's PEM private-key file (public-key mode).
*
* Iterates over all Recipients entries, tries to decrypt each PKCS#7 envelope
* with the supplied certificate/key, extracts the seed, and derives the
* document encryption key.
*
* @param string $privkeyPath Path to the recipient's PEM file (cert + key).
*
* @return bool True when a matching recipient is found and the key is derived.
*/
protected function authenticatePublicKey(string $privkeyPath): bool
{
if ($privkeyPath === '' || !\is_readable($privkeyPath)) {
return false;
}
$certPem = \file_get_contents($privkeyPath);
if ($certPem === false) {
return false;
}
$seed = $this->findDecryptedRecipientSeed($certPem);
if ($seed === null) {
return false;
}
$this->derivePublicKey($seed);
return true;
}
/**
* Iterate over all Recipients and return the 20-byte seed from the first
* envelope that can be decrypted with $certPem, or null on failure.
*
* @return string|null 20-byte seed, or null when no matching recipient found.
*/
protected function findDecryptedRecipientSeed(string $certPem): ?string
{
foreach ($this->encryptdata['Recipients'] as $hexRecipient) {
// Silence native warning noise for malformed recipient entries;
// false is handled explicitly by the guard below.
\set_error_handler(static fn(): bool => true);
try {
$derData = \hex2bin($hexRecipient);
} finally {
\restore_error_handler();
}
if ($derData === false) {
continue;
}
$envelope = $this->tryDecryptRecipient($derData, $certPem);
if ($envelope !== null && \strlen($envelope) >= 24) {
// envelope = seed (20 bytes) + permissions (4 bytes)
return \substr($envelope, 0, 20);
}
}
return null;
}
/**
* Attempt to decrypt a single DER-encoded PKCS#7 recipient envelope.
*
* Reconstructs the S/MIME message (as produced by openssl_pkcs7_encrypt),
* writes it to a temporary file, calls openssl_pkcs7_decrypt with the
* supplied certificate/key, and returns the raw decrypted bytes on success.
*
* @param string $derData Raw DER binary (one entry from Recipients[]).
* @param string $certPem PEM certificate + private key of the recipient.
*
* @return string|null Decrypted envelope bytes, or null on failure.
*/
protected function tryDecryptRecipient(string $derData, string $certPem): ?string
{
$smime =
"MIME-Version: 1.0\r\n"
. 'Content-Type: application/pkcs7-mime;'
. " smime-type=enveloped-data; name=\"smime.p7m\"\r\n"
. "Content-Transfer-Encoding: base64\r\n\r\n"
. \chunk_split(\base64_encode($derData));
$tmpIn = \tempnam(\sys_get_temp_dir(), '__tcpdf_dec_in_');
$tmpOut = \tempnam(\sys_get_temp_dir(), '__tcpdf_dec_out_');
if ($tmpIn === false || $tmpOut === false) {
return null;
}
if (\file_put_contents($tmpIn, $smime) === false) {
return null;
}
\set_error_handler(static fn(): bool => true);
try {
$decOk = \openssl_pkcs7_decrypt($tmpIn, $tmpOut, $certPem, $certPem);
} finally {
\restore_error_handler();
}
$result = $decOk ? \file_get_contents($tmpOut) : null;
\unlink($tmpIn);
\unlink($tmpOut);
return $result === false ? null : $result;
}
/**
* Derive the document encryption key from the recovered seed.
*
* Mirrors generatePublicEncryptionKey(): the key is SHA-256 (modes ≥ 3) or
* SHA-1 (modes 02) of the seed concatenated with all Recipients' raw bytes.
*
* @param string $seed 20-byte random seed extracted from the recipient envelope.
*/
protected function derivePublicKey(string $seed): void
{
$keybytelen = (int) ($this->encryptdata['Length'] / 8);
$recipientBytes = '';
foreach ($this->encryptdata['Recipients'] as $hexRecipient) {
$binary = \hex2bin($hexRecipient);
if ($binary !== false) {
$recipientBytes .= $binary;
}
}
if ($this->encryptdata['mode'] >= 3) {
$this->encryptdata['key'] = \substr(\hash('sha256', $seed . $recipientBytes, true), 0, $keybytelen);
return;
}
$this->encryptdata['key'] = \substr(\sha1($seed . $recipientBytes, true), 0, $keybytelen);
}
}