Files

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);
}
}
}
}