mirror of
https://github.com/PHPOffice/PhpSpreadsheet.git
synced 2026-09-13 03:26:26 +00:00
359 lines
16 KiB
PHP
359 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;
|
|
|
|
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
|
|
use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader;
|
|
use PhpOffice\PhpSpreadsheet\Shared\File;
|
|
use PhpOffice\PhpSpreadsheet\Shared\OLE;
|
|
use PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class AgileEncryptionTest extends TestCase
|
|
{
|
|
public function testWriterUsesConfiguredEncryptionProfile(): void
|
|
{
|
|
$filename = File::temporaryFilename();
|
|
|
|
try {
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->setCellValue('A1', 'configured profile');
|
|
(new Xlsx($spreadsheet))
|
|
->setEncryptionPassword('writer-password')
|
|
->setEncryptionProfile(128, 'SHA1', 10)
|
|
->save($filename);
|
|
|
|
// CFB child entries are red-black trees; their left/right links
|
|
// must preserve name order for readers which do name lookup.
|
|
self::assertSame(10, self::directoryLinks($filename, 0)['child']);
|
|
self::assertSame(['color' => 1, 'left' => 2, 'right' => 1, 'child' => 0xFFFFFFFF], self::directoryLinks($filename, 10));
|
|
self::assertSame(['color' => 0, 'left' => 0xFFFFFFFF, 'right' => 0xFFFFFFFF, 'child' => 4], self::directoryLinks($filename, 2));
|
|
self::assertSame(['color' => 0, 'left' => 0xFFFFFFFF, 'right' => 0xFFFFFFFF, 'child' => 0xFFFFFFFF], self::directoryLinks($filename, 1));
|
|
self::assertSame(['color' => 1, 'left' => 3, 'right' => 5, 'child' => 0xFFFFFFFF], self::directoryLinks($filename, 4));
|
|
self::assertSame(['color' => 0, 'left' => 0xFFFFFFFF, 'right' => 0xFFFFFFFF, 'child' => 0xFFFFFFFF], self::directoryLinks($filename, 3));
|
|
self::assertSame(['color' => 0, 'left' => 0xFFFFFFFF, 'right' => 0xFFFFFFFF, 'child' => 7], self::directoryLinks($filename, 5));
|
|
self::assertSame(['color' => 1, 'left' => 6, 'right' => 0xFFFFFFFF, 'child' => 8], self::directoryLinks($filename, 7));
|
|
|
|
$ole = new OLE();
|
|
$ole->read($filename);
|
|
$encryptionInfo = $ole->getDataByName('EncryptionInfo');
|
|
$info = AgileEncryption::parse($encryptionInfo);
|
|
self::assertSame(128, $info['keyBits']);
|
|
self::assertSame('SHA1', $info['hashAlgorithm']);
|
|
self::assertSame(10, $info['spinCount']);
|
|
self::assertStringContainsString('hashAlgorithm="SHA1"', $encryptionInfo);
|
|
self::assertDataSpacesStreams($ole);
|
|
|
|
$loaded = (new XlsxReader())->setEncryptionPassword('writer-password')->load($filename);
|
|
self::assertSame('configured profile', $loaded->getActiveSheet()->getCell('A1')->getValue());
|
|
$loaded->disconnectWorksheets();
|
|
$spreadsheet->disconnectWorksheets();
|
|
} finally {
|
|
if (file_exists($filename)) {
|
|
unlink($filename);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return array{color: int, left: int, right: int, child: int} */
|
|
private static function directoryLinks(string $filename, int $entryId): array
|
|
{
|
|
$container = file_get_contents($filename);
|
|
self::assertNotFalse($container);
|
|
$directorySector = unpack('Vsector', substr($container, 48, 4));
|
|
self::assertNotFalse($directorySector);
|
|
$sector = $directorySector['sector'] ?? null;
|
|
self::assertIsInt($sector);
|
|
$offset = ($sector + 1) * 512 + $entryId * 128;
|
|
$links = unpack('Vleft/Vright/Vchild', substr($container, $offset + 68, 12));
|
|
self::assertNotFalse($links);
|
|
$left = $links['left'] ?? null;
|
|
$right = $links['right'] ?? null;
|
|
$child = $links['child'] ?? null;
|
|
self::assertIsInt($left);
|
|
self::assertIsInt($right);
|
|
self::assertIsInt($child);
|
|
|
|
return ['color' => ord($container[$offset + 67]), 'left' => $left, 'right' => $right, 'child' => $child];
|
|
}
|
|
|
|
private static function assertDataSpacesStreams(OLE $ole): void
|
|
{
|
|
$version = $ole->getDataByName('Version');
|
|
self::assertSame(pack('V', 60), substr($version, 0, 4));
|
|
self::assertSame(self::utf16le('Microsoft.Container.DataSpaces'), substr($version, 4, 60));
|
|
self::assertSame(pack('V3', 1, 1, 1), substr($version, 64));
|
|
|
|
$dataSpaceMap = $ole->getDataByName('DataSpaceMap');
|
|
self::assertStringContainsString(self::utf16le('EncryptedPackage'), $dataSpaceMap);
|
|
self::assertStringContainsString(self::utf16le('StrongEncryptionDataSpace'), $dataSpaceMap);
|
|
|
|
$dataSpace = $ole->getDataByName('StrongEncryptionDataSpace');
|
|
self::assertSame(pack('V3', 8, 1, 50), substr($dataSpace, 0, 12));
|
|
self::assertStringContainsString(self::utf16le('StrongEncryptionTransform'), $dataSpace);
|
|
|
|
$transform = $ole->getDataByName("\x06Primary");
|
|
self::assertSame(pack('V3', 88, 1, 76) . self::utf16le('{FF9A3F03-56EF-4613-BDD5-5A41C1D07246}'), substr($transform, 0, 88));
|
|
self::assertStringContainsString(self::utf16le('Microsoft.Container.EncryptionTransform'), $transform);
|
|
}
|
|
|
|
private static function utf16le(string $value): string
|
|
{
|
|
return mb_convert_encoding($value, 'UTF-16LE', 'UTF-8');
|
|
}
|
|
|
|
public function testContainerSupportsFatAndDifatSectors(): void
|
|
{
|
|
$encryptedPackageFilename = File::temporaryFilename();
|
|
$containerFilename = File::temporaryFilename();
|
|
|
|
try {
|
|
// This needs more than 109 FAT sectors, so the CFB DIFAT chain is required.
|
|
$plainPackage = str_repeat('x', 7 * 1024 * 1024);
|
|
$package = AgileEncryption::encrypt($plainPackage, 'writer-password');
|
|
file_put_contents($encryptedPackageFilename, $package['encryptedPackage']);
|
|
$container = fopen($containerFilename, 'w+b');
|
|
self::assertNotFalse($container);
|
|
AgileEncryption::writeContainerFromFile($container, $package['encryptionInfo'], $encryptedPackageFilename);
|
|
fclose($container);
|
|
|
|
$header = (string) file_get_contents($containerFilename, false, null, 0, 512);
|
|
$fatSectorCount = unpack('V', substr($header, 44, 4));
|
|
self::assertNotFalse($fatSectorCount);
|
|
self::assertGreaterThanOrEqual(2, $fatSectorCount[1]);
|
|
$difatSectorCount = unpack('V', substr($header, 72, 4));
|
|
self::assertNotFalse($difatSectorCount);
|
|
self::assertGreaterThanOrEqual(1, $difatSectorCount[1]);
|
|
|
|
$ole = new OLE();
|
|
$ole->read($containerFilename);
|
|
self::assertSame($package['encryptionInfo'], $ole->getDataByName('EncryptionInfo'));
|
|
self::assertSame($package['encryptedPackage'], $ole->getDataByName('EncryptedPackage'));
|
|
self::assertSame($plainPackage, AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $ole->getDataByName('EncryptedPackage'), 'writer-password'));
|
|
} finally {
|
|
if (file_exists($encryptedPackageFilename)) {
|
|
unlink($encryptedPackageFilename);
|
|
}
|
|
if (file_exists($containerFilename)) {
|
|
unlink($containerFilename);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testContainerRejectsLargeEncryptionInfoMiniStream(): void
|
|
{
|
|
$encryptedPackageFilename = File::temporaryFilename();
|
|
$containerFilename = File::temporaryFilename();
|
|
|
|
try {
|
|
$package = AgileEncryption::encrypt(str_repeat('x', 4096), 'writer-password');
|
|
file_put_contents($encryptedPackageFilename, $package['encryptedPackage']);
|
|
$container = fopen($containerFilename, 'w+b');
|
|
self::assertNotFalse($container);
|
|
|
|
$this->expectException(ReaderException::class);
|
|
$this->expectExceptionMessage('Malformed XLSX encryption information');
|
|
AgileEncryption::writeContainerFromFile($container, str_repeat('x', 4096), $encryptedPackageFilename);
|
|
} finally {
|
|
if (isset($container) && is_resource($container)) {
|
|
fclose($container);
|
|
}
|
|
if (file_exists($encryptedPackageFilename)) {
|
|
unlink($encryptedPackageFilename);
|
|
}
|
|
if (file_exists($containerFilename)) {
|
|
unlink($containerFilename);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testContainerRejectsSmallEncryptedPackage(): void
|
|
{
|
|
$encryptedPackageFilename = File::temporaryFilename();
|
|
$containerFilename = File::temporaryFilename();
|
|
|
|
try {
|
|
$package = AgileEncryption::encrypt('package', 'writer-password');
|
|
file_put_contents($encryptedPackageFilename, $package['encryptedPackage']);
|
|
$container = fopen($containerFilename, 'w+b');
|
|
self::assertNotFalse($container);
|
|
|
|
$this->expectException(ReaderException::class);
|
|
$this->expectExceptionMessage('Malformed encrypted XLSX package');
|
|
AgileEncryption::writeContainerFromFile($container, $package['encryptionInfo'], $encryptedPackageFilename);
|
|
} finally {
|
|
if (isset($container) && is_resource($container)) {
|
|
fclose($container);
|
|
}
|
|
if (file_exists($encryptedPackageFilename)) {
|
|
unlink($encryptedPackageFilename);
|
|
}
|
|
if (file_exists($containerFilename)) {
|
|
unlink($containerFilename);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testEncryptFileRequiresPassword(): void
|
|
{
|
|
$this->expectException(ReaderException::class);
|
|
$this->expectExceptionMessage('XLSX encryption password required');
|
|
AgileEncryption::encryptFile('unused.xlsx', '');
|
|
}
|
|
|
|
public function testEncryptFileRejectsUnsupportedProfile(): void
|
|
{
|
|
$this->expectException(ReaderException::class);
|
|
$this->expectExceptionMessage('Unsupported XLSX encryption profile');
|
|
AgileEncryption::encryptFile('unused.xlsx', 'writer-password', 64);
|
|
}
|
|
|
|
public function testEncryptFileRejectsMissingInput(): void
|
|
{
|
|
$this->expectException(ReaderException::class);
|
|
$this->expectExceptionMessage('Could not determine XLSX package size');
|
|
AgileEncryption::encryptFile('does-not-exist.xlsx', 'writer-password');
|
|
}
|
|
|
|
public function testWriterCleansEncryptedTemporaryPackageWhenOutputCannotOpen(): void
|
|
{
|
|
$temporaryFiles = self::spreadsheetTemporaryFiles();
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->setCellValue('A1', 'temporary package cleanup');
|
|
|
|
try {
|
|
$this->expectException(WriterException::class);
|
|
$this->expectExceptionMessage('Could not open file "" for writing');
|
|
(new Xlsx($spreadsheet))->setEncryptionPassword('writer-password')->save('');
|
|
} finally {
|
|
self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles());
|
|
$spreadsheet->disconnectWorksheets();
|
|
}
|
|
}
|
|
|
|
public function testWriterRetainsEncryptionSettingsAfterFailedSave(): void
|
|
{
|
|
$filename = File::temporaryFilename();
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->setCellValue('A1', 'retry encrypted save');
|
|
$writer = (new Xlsx($spreadsheet))
|
|
->setEncryptionPassword('writer-password')
|
|
->setEncryptionProfile(128, 'SHA1', 10);
|
|
|
|
try {
|
|
try {
|
|
$writer->save('');
|
|
self::fail('Saving to an empty filename should fail.');
|
|
} catch (WriterException $e) {
|
|
self::assertSame('Could not open file "" for writing.', $e->getMessage());
|
|
}
|
|
|
|
$writer->save($filename);
|
|
$ole = new OLE();
|
|
$ole->read($filename);
|
|
$info = AgileEncryption::parse($ole->getDataByName('EncryptionInfo'));
|
|
self::assertSame(128, $info['keyBits']);
|
|
self::assertSame('SHA1', $info['hashAlgorithm']);
|
|
self::assertSame(10, $info['spinCount']);
|
|
|
|
$loaded = (new XlsxReader())->setEncryptionPassword('writer-password')->load($filename);
|
|
self::assertSame('retry encrypted save', $loaded->getActiveSheet()->getCell('A1')->getValue());
|
|
$loaded->disconnectWorksheets();
|
|
} finally {
|
|
if (file_exists($filename)) {
|
|
unlink($filename);
|
|
}
|
|
$spreadsheet->disconnectWorksheets();
|
|
}
|
|
}
|
|
|
|
public function testWriterSupportsRemainingEncryptionProfiles(): void
|
|
{
|
|
foreach ([[192, 'SHA256'], [256, 'SHA384']] as [$keyBits, $hashAlgorithm]) {
|
|
$filename = File::temporaryFilename();
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->setCellValue('A1', "$keyBits $hashAlgorithm profile");
|
|
|
|
try {
|
|
(new Xlsx($spreadsheet))
|
|
->setEncryptionPassword('writer-password')
|
|
->setEncryptionProfile($keyBits, $hashAlgorithm, 10)
|
|
->save($filename);
|
|
|
|
$ole = new OLE();
|
|
$ole->read($filename);
|
|
$info = AgileEncryption::parse($ole->getDataByName('EncryptionInfo'));
|
|
self::assertSame($keyBits, $info['keyBits']);
|
|
self::assertSame($hashAlgorithm, $info['hashAlgorithm']);
|
|
|
|
$loaded = (new XlsxReader())->setEncryptionPassword('writer-password')->load($filename);
|
|
self::assertSame("$keyBits $hashAlgorithm profile", $loaded->getActiveSheet()->getCell('A1')->getValue());
|
|
$loaded->disconnectWorksheets();
|
|
} finally {
|
|
if (file_exists($filename)) {
|
|
unlink($filename);
|
|
}
|
|
$spreadsheet->disconnectWorksheets();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @return string[] */
|
|
private static function spreadsheetTemporaryFiles(): array
|
|
{
|
|
$temporaryFiles = glob(File::sysGetTempDir() . '/phpspreadsheet*') ?: [];
|
|
sort($temporaryFiles);
|
|
|
|
return $temporaryFiles;
|
|
}
|
|
|
|
public function testWriteAgileEncryptedWorkbook(): void
|
|
{
|
|
$filename = File::temporaryFilename();
|
|
|
|
try {
|
|
$spreadsheet = new Spreadsheet();
|
|
$spreadsheet->getActiveSheet()->setCellValue('A1', 'writer encryption fixture');
|
|
$spreadsheet->getActiveSheet()->setCellValue('A2', 21);
|
|
$spreadsheet->getActiveSheet()->setCellValue('B2', '=A2*2');
|
|
$spreadsheet->getActiveSheet()->getStyle('A1')->getFont()->setBold(true);
|
|
$spreadsheet->getProperties()->setTitle('Encrypted writer fixture');
|
|
$drawing = new Drawing();
|
|
$drawing->setPath('tests/data/Writer/XLSX/blue_square.png');
|
|
$drawing->setCoordinates('D1');
|
|
$drawing->setWorksheet($spreadsheet->getActiveSheet());
|
|
(new Xlsx($spreadsheet))->setEncryptionPassword('writer-password')->save($filename);
|
|
|
|
self::assertSame('d0cf11e0a1b11ae1', bin2hex((string) file_get_contents($filename, false, null, 0, 8)));
|
|
$ole = new OLE();
|
|
$ole->read($filename);
|
|
$encryptionInfo = $ole->getDataByName('EncryptionInfo');
|
|
$encryptedPackage = $ole->getDataByName('EncryptedPackage');
|
|
self::assertDataSpacesStreams($ole);
|
|
self::assertSame('0400040040000000', bin2hex(substr($encryptionInfo, 0, 8)));
|
|
self::assertGreaterThan(8, strlen($encryptedPackage));
|
|
self::assertSame(1, substr_count($encryptionInfo, 'spinCount="100000"'));
|
|
self::assertStringStartsWith("PK\x03\x04", AgileEncryption::decrypt(AgileEncryption::parse($encryptionInfo), $encryptedPackage, 'writer-password'));
|
|
|
|
$loaded = (new XlsxReader())->setEncryptionPassword('writer-password')->load($filename);
|
|
self::assertSame('writer encryption fixture', $loaded->getActiveSheet()->getCell('A1')->getValue());
|
|
self::assertSame('=A2*2', $loaded->getActiveSheet()->getCell('B2')->getValue());
|
|
self::assertTrue($loaded->getActiveSheet()->getStyle('A1')->getFont()->getBold());
|
|
self::assertSame('Encrypted writer fixture', $loaded->getProperties()->getTitle());
|
|
self::assertCount(1, $loaded->getActiveSheet()->getDrawingCollection());
|
|
$loaded->disconnectWorksheets();
|
|
$spreadsheet->disconnectWorksheets();
|
|
} finally {
|
|
if (file_exists($filename)) {
|
|
unlink($filename);
|
|
}
|
|
}
|
|
}
|
|
}
|