Files

461 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\Reader\Exception;
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
use PhpOffice\PhpSpreadsheet\Shared\File;
use PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
class AgileEncryptionTest extends TestCase
{
private const FIXTURE = 'tests/data/Reader/XLSX/agile-encrypted-excel.xlsx';
public function testReadExcelAgileEncryptionFixture(): void
{
// Generated by Microsoft Excel 16.111.2. Password-to-open: open.
$temporaryFiles = self::spreadsheetTemporaryFiles();
$reader = new Xlsx();
$reader->setEncryptionPassword('open');
$spreadsheet = $reader->load(self::FIXTURE);
self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue());
self::assertSame(42, $spreadsheet->getActiveSheet()->getCell('B1')->getValue());
$spreadsheet->disconnectWorksheets();
self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles());
}
public function testCanReadEncryptedWorkbook(): void
{
self::assertTrue((new Xlsx())->canRead(self::FIXTURE));
self::assertFalse((new Xlsx())->canRead('tests/data/Reader/XLS/sample.xls'));
}
public function testNonEncryptedOleFallsBackToNormalXlsxLoading(): void
{
$this->expectException(Exception::class);
(new Xlsx())->load('tests/data/Reader/XLS/sample.xls');
}
public function testListWorksheetMetadataForEncryptedWorkbook(): void
{
$reader = (new Xlsx())->setEncryptionPassword('open');
self::assertSame(['Sheet1'], $reader->listWorksheetNames(self::FIXTURE));
self::assertSame(
[[
'worksheetName' => 'Sheet1',
'lastColumnLetter' => 'B',
'lastColumnIndex' => 1,
'totalRows' => 1,
'totalColumns' => 2,
'sheetState' => 'visible',
]],
$reader->listWorksheetInfo(self::FIXTURE)
);
}
public function testEncryptedWorkbookRequiresPassword(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password required');
(new Xlsx())->load(self::FIXTURE);
}
public function testEncryptedWorkbookRejectsIncorrectPassword(): void
{
$temporaryFiles = self::spreadsheetTemporaryFiles();
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password is incorrect');
(new Xlsx())->setEncryptionPassword('wrong')->load(self::FIXTURE);
} finally {
self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles());
}
}
public function testEncryptedWorkbookRejectsSpinCountAboveConfiguredMaximum(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
(new Xlsx())
->setEncryptionPassword('open')
->setMaxEncryptionSpinCount(99999)
->load(self::FIXTURE);
}
public function testEncryptedWorkbookAcceptsSpinCountAtConfiguredMaximum(): void
{
$spreadsheet = (new Xlsx())
->setEncryptionPassword('open')
->setMaxEncryptionSpinCount(100000)
->load(self::FIXTURE);
self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue());
$spreadsheet->disconnectWorksheets();
}
public function testRejectsInvalidMaximumEncryptionSpinCount(): void
{
$this->expectException(InvalidArgumentException::class);
(new Xlsx())->setMaxEncryptionSpinCount(AgileEncryption::MAX_SPIN_COUNT + 1);
}
public function testRejectsNegativeMaximumEncryptionSpinCount(): void
{
$this->expectException(InvalidArgumentException::class);
(new Xlsx())->setMaxEncryptionSpinCount(-1);
}
public function testRejectsTamperedEncryptedPackage(): void
{
$package = AgileEncryption::encrypt("PK\x03\x04test", 'password');
$lastByte = strlen($package['encryptedPackage']) - 1;
$package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1);
$this->expectException(Exception::class);
$this->expectExceptionMessage('integrity check failed');
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password');
}
public function testRejectsMalformedEncryptedPackage(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed encrypted XLSX package');
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), substr($package['encryptedPackage'], 0, 7), 'password');
}
public function testRejectsMalformedDecryptedSecretKey(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$info = AgileEncryption::parse($package['encryptionInfo']);
$info['encryptedKey'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password');
}
public function testRejectsMalformedEncryptionInfo(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::parse("\x04\x00\x04\x00\x40\x00\x00\x00not XML");
}
public function testParserMaximumCannotExceedAbsoluteSpinCountCeiling(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$encryptionInfo = str_replace('spinCount="10"', 'spinCount="10000001"', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::parse($encryptionInfo, PHP_INT_MAX);
}
public function testRejectsEncryptionInfoWithUnexpectedNamespace(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('xmlns="http://schemas.microsoft.com/office/2006/encryption"', 'xmlns="urn:unexpected"', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::parse($encryptionInfo);
}
public function testAcceptsEncryptionInfoWithPrefixedEncryptionNamespace(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('<encryption ', '<e:encryption xmlns:e="http://schemas.microsoft.com/office/2006/encryption" ', $package['encryptionInfo']);
$encryptionInfo = str_replace('</encryption>', '</e:encryption>', $encryptionInfo);
self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($encryptionInfo), $package['encryptedPackage'], 'password'));
}
public function testRejectsEncryptionInfoWithUnsupportedCipherChaining(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('cipherChaining="ChainingModeCBC"', 'cipherChaining="ChainingModeCFB"', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::parse($encryptionInfo);
}
public function testRejectsEncryptionInfoWithInvalidBase64Data(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('saltValue="', 'saltValue="!', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::parse($encryptionInfo);
}
public function testRejectsEncryptionInfoWithDuplicateRequiredElement(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('</keyEncryptors>', '</keyEncryptors><dataIntegrity/>', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::parse($encryptionInfo);
}
public function testRejectsEncryptionInfoWithIncorrectDecodedValueLength(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = preg_replace('/saltValue="[^"]+"/', 'saltValue=""', $package['encryptionInfo'], 1);
self::assertIsString($encryptionInfo);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::parse($encryptionInfo);
}
public function testRejectsEncryptionInfoWithNonDecimalNumericAttribute(): void
{
$package = AgileEncryption::encrypt('package', 'password');
$encryptionInfo = str_replace('keyBits="256"', 'keyBits="256bits"', $package['encryptionInfo']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::parse($encryptionInfo);
}
public function testEncryptRequiresSupportedProfileAndPassword(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password required');
AgileEncryption::encrypt('package', '');
}
public function testDecryptRequiresPassword(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password required');
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], '');
}
public function testDecryptRejectsIncorrectPassword(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password is incorrect');
AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'wrong');
}
public function testDecryptsUnicodePassword(): void
{
$password = 'pāssw🔐rd';
$package = AgileEncryption::encrypt('package', $password, 128, 'SHA1', 10);
self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], $password));
}
public function testEncryptRejectsUnsupportedProfile(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::encrypt('package', 'password', 64, 'SHA512', 10);
}
public function testRejectsUnsupportedEncryptionInfo(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
AgileEncryption::parse("\x04\x00\x04\x00\x00\x00\x00\x00");
}
public function testPasswordDoesNotAffectNormalXlsx(): void
{
$reader = (new Xlsx())->setEncryptionPassword('unused');
$spreadsheet = $reader->load('tests/data/Reader/XLSX/threesheets.xlsx');
self::assertSame(3, $spreadsheet->getSheetCount());
$spreadsheet->disconnectWorksheets();
}
public function testSupportsApprovedAgileProfiles(): void
{
foreach ([[128, 'SHA1'], [192, 'SHA256'], [256, 'SHA384'], [256, 'SHA512']] as [$keyBits, $hashAlgorithm]) {
$package = AgileEncryption::encrypt('profile test', 'password', $keyBits, $hashAlgorithm, 10);
$info = AgileEncryption::parse($package['encryptionInfo']);
self::assertSame($keyBits, $info['keyBits']);
self::assertSame($hashAlgorithm, $info['hashAlgorithm']);
self::assertSame('profile test', AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password'));
}
}
public function testDecryptsMultipleEncryptedSegments(): void
{
$plain = str_repeat('x', 4097);
$package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10);
self::assertSame($plain, AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password'));
}
public function testDecryptFileStreamsMultipleEncryptedSegments(): void
{
$plain = str_repeat('x', 4097);
$package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10);
$encryptedFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-encrypted-');
$plainFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-plain-');
self::assertNotFalse($encryptedFilename);
self::assertNotFalse($plainFilename);
try {
file_put_contents($encryptedFilename, $package['encryptedPackage']);
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $encryptedFilename, $plainFilename, 'password');
self::assertSame($plain, file_get_contents($plainFilename));
} finally {
unlink($encryptedFilename);
unlink($plainFilename);
}
}
public function testDecryptFileDoesNotTruncateOutputWhenInputCannotBeOpened(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
self::assertNotFalse($outputFilename);
file_put_contents($outputFilename, 'preserve this content');
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('Could not open XLSX package for decryption');
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), 'does-not-exist.xlsx', $outputFilename, 'password');
} finally {
self::assertSame('preserve this content', file_get_contents($outputFilename));
unlink($outputFilename);
}
}
public function testDecryptFileDoesNotTruncateOutputWhenPasswordIsIncorrect(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
self::assertNotFalse($inputFilename);
self::assertNotFalse($outputFilename);
file_put_contents($inputFilename, $package['encryptedPackage']);
file_put_contents($outputFilename, 'preserve this content');
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('XLSX encryption password is incorrect');
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'wrong');
} finally {
self::assertSame('preserve this content', file_get_contents($outputFilename));
unlink($inputFilename);
unlink($outputFilename);
}
}
public function testDecryptFileClosesInputWhenOutputCannotBeOpened(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
$outputDirectory = sys_get_temp_dir() . '/phpspreadsheet-output-' . uniqid();
self::assertNotFalse($inputFilename);
mkdir($outputDirectory);
file_put_contents($inputFilename, $package['encryptedPackage']);
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('Could not open XLSX package for decryption');
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputDirectory, 'password');
} finally {
unlink($inputFilename);
rmdir($outputDirectory);
}
}
public function testDecryptFileRejectsTamperedEncryptedPackage(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$lastByte = strlen($package['encryptedPackage']) - 1;
$package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1);
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
self::assertNotFalse($inputFilename);
self::assertNotFalse($outputFilename);
file_put_contents($inputFilename, $package['encryptedPackage']);
file_put_contents($outputFilename, 'preserve this content');
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('integrity check failed');
AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'password');
} finally {
self::assertSame('preserve this content', file_get_contents($outputFilename));
unlink($inputFilename);
unlink($outputFilename);
}
}
public function testDecryptFileRejectsMalformedDecryptedSecretKey(): void
{
$package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10);
$info = AgileEncryption::parse($package['encryptionInfo']);
$info['encryptedKey'] = '';
$inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-');
$outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-');
self::assertNotFalse($inputFilename);
self::assertNotFalse($outputFilename);
file_put_contents($inputFilename, $package['encryptedPackage']);
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('Malformed XLSX encryption information');
AgileEncryption::decryptFile($info, $inputFilename, $outputFilename, 'password');
} finally {
unlink($inputFilename);
unlink($outputFilename);
}
}
public function testRejectsUnrepresentableEncryptedPackageSize(): void
{
$method = new ReflectionMethod(AgileEncryption::class, 'unpackSize');
$this->expectException(Exception::class);
$this->expectExceptionMessage('too large for this platform');
$high = PHP_INT_SIZE < 8 ? 1 : 0x80000000;
$method->invoke(null, pack('V2', 0, $high));
}
public function testRejectsUnrepresentableEncryptedPackageSizeOn32BitPlatform(): void
{
$method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords');
$this->expectException(Exception::class);
$this->expectExceptionMessage('too large for this platform');
$method->invoke(null, 0, 1, 4, 2147483647);
}
public function testAcceptsRepresentableEncryptedPackageSizeOn32BitPlatform(): void
{
$method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords');
self::assertSame(123, $method->invoke(null, 123, 0, 4, 2147483647));
}
/** @return string[] */
private static function spreadsheetTemporaryFiles(): array
{
$temporaryFiles = glob(File::sysGetTempDir() . '/phpspreadsheet*') ?: [];
sort($temporaryFiles);
return $temporaryFiles;
}
}