Files

418 lines
17 KiB
PHP

<?php
/**
* DecryptTest.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 Test;
use Com\Tecnick\Pdf\Encrypt\Decrypt;
use Com\Tecnick\Pdf\Encrypt\Encrypt;
/**
* Decrypt test
*
* Coverage notes (unreachable / untestable defensive guards):
* - Decrypt::tryDecryptRecipient() `$tmpIn === false || $tmpOut === false`:
* tempnam() failure requires a filesystem-level fault; cannot be reliably
* induced in unit tests.
* - Decrypt::tryDecryptRecipient() `file_put_contents === false`:
* same as above.
* - AESnopad::decrypt() `$dec === false`:
* openssl_decrypt() cannot return false for well-formed AES-CBC ciphertext
* with a valid key; this guard protects against hypothetical extension failures.
* - Decrypt::decryptAes() `$dec === false` return '':
* same reasoning; openssl_decrypt is called with correct key/iv/cipher.
*
* @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
*/
class DecryptTest extends TestUtil
{
/** Build a Decrypt object from an Encrypt instance's encryption data. */
private function decryptFromEncrypt(Encrypt $enc): Decrypt
{
return new Decrypt($enc->getEncryptionData());
}
// -------------------------------------------------------------------------
// Mode 2 (AES-128) — standard password authentication
// -------------------------------------------------------------------------
public function testAuthenticateUserMode2(): void
{
$enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('userpass'));
$this->assertNotEmpty($dec->getDocumentKey());
}
public function testAuthenticateOwnerMode2(): void
{
$enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('ownerpass'));
$this->assertNotEmpty($dec->getDocumentKey());
}
public function testAuthenticateWrongPasswordMode2(): void
{
$enc = new Encrypt(true, \md5('file'), 2, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertFalse($dec->authenticate('wrongpassword'));
// Key must remain empty after failed authentication.
$this->assertSame('', $dec->getDocumentKey());
}
// -------------------------------------------------------------------------
// Mode 3 (AES-256 R5) — standard password authentication
// -------------------------------------------------------------------------
public function testAuthenticateUserMode3(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('userpass'));
$this->assertEquals(32, \strlen($dec->getDocumentKey()));
}
public function testAuthenticateOwnerMode3(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('ownerpass'));
$this->assertEquals(32, \strlen($dec->getDocumentKey()));
}
public function testAuthenticateWrongPasswordMode3(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertFalse($dec->authenticate('wrong'));
}
// -------------------------------------------------------------------------
// Mode 4 (AES-256 R6 / PDF 2.0) — standard password authentication
// -------------------------------------------------------------------------
public function testAuthenticateUserMode4(): void
{
$enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('userpass'));
$this->assertEquals(32, \strlen($dec->getDocumentKey()));
}
public function testAuthenticateOwnerMode4(): void
{
$enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('ownerpass'));
$this->assertEquals(32, \strlen($dec->getDocumentKey()));
}
public function testAuthenticateWrongPasswordMode4(): void
{
$enc = new Encrypt(true, \md5('file'), 4, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertFalse($dec->authenticate('wrong'));
}
// -------------------------------------------------------------------------
// Modes 0 and 1 (RC4 — deprecated but must still authenticate correctly)
// -------------------------------------------------------------------------
public function testAuthenticateUserMode0(): void
{
$this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
$enc = new Encrypt(true, \md5('file'), 0, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('userpass'));
$this->assertNotEmpty($dec->getDocumentKey());
});
}
public function testAuthenticateOwnerMode0(): void
{
$this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
$enc = new Encrypt(true, \md5('file'), 0, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('ownerpass'));
$this->assertNotEmpty($dec->getDocumentKey());
});
}
public function testAuthenticateUserMode1(): void
{
$this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
$enc = new Encrypt(true, \md5('file'), 1, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('userpass'));
$this->assertNotEmpty($dec->getDocumentKey());
});
}
public function testAuthenticateOwnerMode1(): void
{
$this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
$enc = new Encrypt(true, \md5('file'), 1, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('ownerpass'));
$this->assertNotEmpty($dec->getDocumentKey());
});
}
// -------------------------------------------------------------------------
// decryptString round-trips
// -------------------------------------------------------------------------
/**
* RC4 modes are symmetric: encrypt(encrypt(data, key)) = data.
* The plaintext is recovered exactly (no padding).
*/
public function testDecryptStringRoundtripMode0(): void
{
$this->bcAssertUserDeprecationMessageMatches('/RC4 encryption.*deprecated/i', function (): void {
$enc = new Encrypt(true, \md5('file'), 0, ['print'], 'alpha', 'beta');
$plaintext = 'hello world';
$ciphertext = $enc->encryptString($plaintext, 1);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
});
}
/**
* AES-128: IV-prefixed stream; PKCS#7 padding is stripped so the exact
* plaintext is recovered.
*/
public function testDecryptStringRoundtripMode2(): void
{
$enc = new Encrypt(true, \md5('file'), 2, ['print'], 'alpha', 'beta');
$plaintext = 'hello world';
$ciphertext = $enc->encryptString($plaintext, 1);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
}
/**
* AES-256 R5: full document key used; exact plaintext recovered.
*/
public function testDecryptStringRoundtripMode3(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'alpha', 'beta');
$plaintext = 'hello world';
$ciphertext = $enc->encryptString($plaintext, 1);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
}
/**
* AES-256 R6: same as R5 but with hash2B key derivation.
*/
public function testDecryptStringRoundtripMode4(): void
{
$enc = new Encrypt(true, \md5('file'), 4, ['print'], 'alpha', 'beta');
$plaintext = 'hello world';
$ciphertext = $enc->encryptString($plaintext, 1);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame($plaintext, $dec->decryptString($ciphertext, 1));
}
/**
* Block-aligned plaintext (exactly 16 bytes) must round-trip exactly: the
* PKCS#7 scheme appends a full extra padding block on encryption that must
* be removed on decryption.
*/
public function testDecryptStringRoundtripBlockAligned(): void
{
foreach ([2, 3, 4] as $mode) {
$enc = new Encrypt(true, \md5('file'), $mode, ['print'], 'alpha', 'beta');
$plaintext = \str_repeat('A', 16);
$ciphertext = $enc->encryptString($plaintext, 7);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame($plaintext, $dec->decryptString($ciphertext, 7), "mode {$mode}");
}
}
/**
* Empty plaintext must round-trip to an empty string for all AES modes.
*/
public function testDecryptStringRoundtripEmpty(): void
{
foreach ([2, 3, 4] as $mode) {
$enc = new Encrypt(true, \md5('file'), $mode, ['print'], 'alpha', 'beta');
$ciphertext = $enc->encryptString('', 3);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame('', $dec->decryptString($ciphertext, 3), "mode {$mode}");
}
}
/**
* Without authentication the key is empty; decryptString returns data that
* differs from the original plaintext (garbage decrypt, not the correct value).
*/
public function testDecryptStringWithoutAuthProducesGarbage(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
$dec = $this->decryptFromEncrypt($enc);
// Key is cleared in constructor; without authenticate(), key is empty.
$ciphertext = $enc->encryptString('hello world', 1);
$result = $dec->decryptString($ciphertext, 1);
// Without the correct key the output must differ from the plaintext.
$this->assertStringNotContainsString('hello world', $result);
}
/**
* decryptString with too-short AES data (≤ 16 bytes) returns empty string.
*/
public function testDecryptStringAesTooShortData(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'alpha', 'beta');
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('alpha'));
$this->assertSame('', $dec->decryptString(\str_repeat('x', 16), 0));
}
// -------------------------------------------------------------------------
// getDocumentKey after failed/successful authentication
// -------------------------------------------------------------------------
public function testGetDocumentKeyAfterFailedAuth(): void
{
$enc = new Encrypt(true, \md5('file'), 3, ['print'], 'userpass', 'ownerpass');
// Store the real key, then construct Decrypt with an overwritten empty key
$data = $enc->getEncryptionData();
$data['key'] = '';
$dec = new Decrypt($data);
$this->assertFalse($dec->authenticate('wrong'));
$this->assertSame('', $dec->getDocumentKey());
}
// -------------------------------------------------------------------------
// Public-key mode authentication
// -------------------------------------------------------------------------
public function testAuthenticatePublicKeyMode3(): void
{
$certPath = __DIR__ . '/data/cert.pem';
$pubkeys = [['c' => $certPath, 'p' => ['print']]];
$enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('', $certPath));
$this->assertEquals(32, \strlen($dec->getDocumentKey()));
}
public function testAuthenticatePublicKeyMode1(): void
{
$this->bcRunIgnoringUserDeprecations(function (): void {
// Mode 1 pubkey silently promotes mode 0 → 1 (covered elsewhere).
$certPath = __DIR__ . '/data/cert.pem';
$pubkeys = [['c' => $certPath, 'p' => ['print']]];
$enc = new Encrypt(true, \md5('file'), 1, ['print'], '', '', $pubkeys);
$dec = $this->decryptFromEncrypt($enc);
$this->assertTrue($dec->authenticate('', $certPath));
$this->assertNotEmpty($dec->getDocumentKey());
});
}
public function testAuthenticatePublicKeyEmptyPathReturnsFalse(): void
{
$certPath = __DIR__ . '/data/cert.pem';
$pubkeys = [['c' => $certPath, 'p' => ['print']]];
$enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
$dec = $this->decryptFromEncrypt($enc);
$this->assertFalse($dec->authenticate('', ''));
}
public function testAuthenticatePublicKeyWrongKeyReturnsFalse(): void
{
$certPath = __DIR__ . '/data/cert.pem';
$pubkeys = [['c' => $certPath, 'p' => ['print']]];
$enc = new Encrypt(true, \md5('file'), 3, ['print'], '', '', $pubkeys);
$dec = $this->decryptFromEncrypt($enc);
// Use the test PHP file as a "wrong" key — openssl_pkcs7_decrypt will fail.
$this->assertFalse($dec->authenticate('', __FILE__));
}
/**
* Cover the `hex2bin() === false` branch in findDecryptedRecipientSeed().
*
* When a Recipients entry contains non-hexadecimal characters, hex2bin()
* returns false and the entry is skipped via `continue`. With no valid
* entries the method returns null and authenticate() returns false.
*/
public function testAuthenticatePublicKeyInvalidHexRecipientReturnsFalse(): void
{
$certPath = __DIR__ . '/data/cert.pem';
// Manually build an encryptdata array in pubkey mode whose Recipients
// list contains only a string that is not valid hex (non-hex characters
// cause hex2bin() to return false).
$data = [
'V' => 6,
'Length' => 256,
'O' => \str_repeat('x', 32),
'U' => \str_repeat('x', 48),
'P' => 0,
'fileid' => \md5('test'),
'mode' => 3,
'pubkey' => true,
'Recipients' => ['ZZZZINVALID!!'], // hex2bin returns false for non-hex chars
];
$dec = new \Com\Tecnick\Pdf\Encrypt\Decrypt($data);
$this->assertFalse($dec->authenticate('', $certPath));
}
// -------------------------------------------------------------------------
// AESnopad::decrypt() direct tests
// -------------------------------------------------------------------------
public function testAesnopadDecryptRoundtrip32Bytes(): void
{
$aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
$key = \str_repeat('k', 32);
$plaintext = \str_repeat('p', 32); // exact 32-byte payload (e.g. file key)
$ciphertext = $aesnopad->encrypt($plaintext, $key);
$decrypted = $aesnopad->decrypt($ciphertext, $key);
$this->assertSame($plaintext, $decrypted);
}
public function testAesnopadDecryptRoundtripAes128(): void
{
$aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
$key = \str_repeat('k', 16);
$plaintext = \str_repeat('p', 16);
$ivect = \Com\Tecnick\Pdf\Encrypt\Type\AESnopad::IVECT;
$ciphertext = $aesnopad->encrypt($plaintext, $key, $ivect, 'aes-128-cbc');
$decrypted = $aesnopad->decrypt($ciphertext, $key, $ivect, 'aes-128-cbc');
$this->assertSame($plaintext, $decrypted);
}
public function testAesnopadDecryptInvalidCipherThrows(): void
{
$this->bcExpectException(\Com\Tecnick\Pdf\Encrypt\Exception::class);
$aesnopad = new \Com\Tecnick\Pdf\Encrypt\Type\AESnopad();
$aesnopad->decrypt('data', 'key', \Com\Tecnick\Pdf\Encrypt\Type\AESnopad::IVECT, 'des-cbc');
}
}