From b9a59660d01c83ff8cbb1667fd4fb3d98c47c36d Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Sun, 31 May 2020 20:22:23 +0900 Subject: [PATCH] Password and hash are exclusive As specified in https://docs.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/85f5567f-2599-41ad-ae26-8cfab23ce754 password and hashValue are exlusive and thus should be treated transparently with a single API in our model. --- docs/topics/recipes.md | 76 ++++++++--- src/PhpSpreadsheet/Reader/Xlsx.php | 38 ++++-- src/PhpSpreadsheet/Shared/PasswordHasher.php | 106 ++++++---------- src/PhpSpreadsheet/Worksheet/Protection.php | 120 ++++++++---------- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 61 ++++----- .../Worksheet/ProtectionTest.php | 39 ++++++ 6 files changed, 244 insertions(+), 196 deletions(-) create mode 100644 tests/PhpSpreadsheetTests/Worksheet/ProtectionTest.php diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index b0956b6e..9f8282b9 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -919,29 +919,53 @@ disallow inserting rows on a specific sheet, disallow sorting, ... - Cell: offers the option to lock/unlock a cell as well as show/hide the internal formula. +**Make sure you enable worksheet protection if you need any of the +worksheet or cell protection features!** This can be done using the following +code: + +``` php +$spreadsheet->getActiveSheet()->getProtection()->setSheet(true); +``` + +### Document + An example on setting document security: ``` php -$spreadsheet->getSecurity()->setLockWindows(true); -$spreadsheet->getSecurity()->setLockStructure(true); -$spreadsheet->getSecurity()->setWorkbookPassword("PhpSpreadsheet"); +$security = $spreadsheet->getSecurity(); +$security->setLockWindows(true); +$security->setLockStructure(true); +$security->setWorkbookPassword("PhpSpreadsheet"); ``` +### Worksheet + An example on setting worksheet security: ``` php -$spreadsheet->getActiveSheet() - ->getProtection()->setPassword('PhpSpreadsheet'); -$spreadsheet->getActiveSheet() - ->getProtection()->setSheet(true); -$spreadsheet->getActiveSheet() - ->getProtection()->setSort(true); -$spreadsheet->getActiveSheet() - ->getProtection()->setInsertRows(true); -$spreadsheet->getActiveSheet() - ->getProtection()->setFormatCells(true); +$protection = $spreadsheet->getActiveSheet()->getProtection(); +$protection->setPassword('PhpSpreadsheet'); +$protection->setSheet(true); +$protection->setSort(true); +$protection->setInsertRows(true); +$protection->setFormatCells(true); ``` +If writing Xlsx files you can specify the algorithm used to hash the password +before calling `setPassword()` like so: + +```php +$protection = $spreadsheet->getActiveSheet()->getProtection(); +$protection->setAlgorithm(Protection::ALGORITHM_SHA_512); +$protection->setSpinCount(20000); +$protection->setPassword('PhpSpreadsheet'); +``` + +The salt should **not** be set manually and will be automatically generated +when setting a new password. + +### Cell + An example on setting cell security: ``` php @@ -950,14 +974,30 @@ $spreadsheet->getActiveSheet()->getStyle('B1') ->setLocked(\PhpOffice\PhpSpreadsheet\Style\Protection::PROTECTION_UNPROTECTED); ``` -**Make sure you enable worksheet protection if you need any of the -worksheet protection features!** This can be done using the following -code: +## Reading protected spreadsheet -``` php -$spreadsheet->getActiveSheet()->getProtection()->setSheet(true); +Spreadsheets that are protected the as described above can always be read by +PhpSpreadsheet. There is no need to know the password or do anything special in +order to read a protected file. + +However if you need to implement a password verification mechanism, you can use the +following helper method: + + +```php +$protection = $spreadsheet->getActiveSheet()->getProtection(); +$allowed = $protection->verify('my password'); + +if ($allowed) { + doSomething(); +} else { + throw new Exception('Incorrect password'); +} ``` +If you need to completely prevent reading a file by any tool, including PhpSpreadsheet, +then you are looking for "encryption", not "protection". + ## Setting data validation on a cell Data validation is a powerful feature of Xlsx. It allows to specify an diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index f9f20bdd..fb7f0645 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -763,17 +763,8 @@ class Xlsx extends BaseReader } } - if (!$this->readDataOnly && $xmlSheet && $xmlSheet->sheetProtection) { - $docSheet->getProtection()->setPassword((string) $xmlSheet->sheetProtection['password'], true); - $docSheet->getProtection()->setAlgorithmName((string) $xmlSheet->sheetProtection['algorithmName']); - $docSheet->getProtection()->setHashValue((string) $xmlSheet->sheetProtection['hashValue']); - $docSheet->getProtection()->setSaltValue((string) $xmlSheet->sheetProtection['saltValue']); - $docSheet->getProtection()->setSpinCount((int) $xmlSheet->sheetProtection['spinCount']); - if ($xmlSheet->protectedRanges->protectedRange) { - foreach ($xmlSheet->protectedRanges->protectedRange as $protectedRange) { - $docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true); - } - } + if ($xmlSheet) { + $this->readSheetProtection($docSheet, $xmlSheet); } if ($xmlSheet && $xmlSheet->autoFilter && !$this->readDataOnly) { @@ -2035,4 +2026,29 @@ class Xlsx extends BaseReader return $workbookBasename; } + + private function readSheetProtection(Worksheet $docSheet, SimpleXMLElement $xmlSheet): void + { + if ($this->readDataOnly || !$xmlSheet->sheetProtection) { + return; + } + + $algorithmName = (string) $xmlSheet->sheetProtection['algorithmName']; + $protection = $docSheet->getProtection(); + $protection->setAlgorithm($algorithmName); + + if ($algorithmName) { + $protection->setPassword((string) $xmlSheet->sheetProtection['hashValue'], true); + $protection->setSalt((string) $xmlSheet->sheetProtection['saltValue']); + $protection->setSpinCount((int) $xmlSheet->sheetProtection['spinCount']); + } else { + $protection->setPassword((string) $xmlSheet->sheetProtection['password'], true); + } + + if ($xmlSheet->protectedRanges->protectedRange) { + foreach ($xmlSheet->protectedRanges->protectedRange as $protectedRange) { + $docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true); + } + } + } } diff --git a/src/PhpSpreadsheet/Shared/PasswordHasher.php b/src/PhpSpreadsheet/Shared/PasswordHasher.php index f3c2d3e1..9fefe88f 100644 --- a/src/PhpSpreadsheet/Shared/PasswordHasher.php +++ b/src/PhpSpreadsheet/Shared/PasswordHasher.php @@ -2,51 +2,39 @@ namespace PhpOffice\PhpSpreadsheet\Shared; +use PhpOffice\PhpSpreadsheet\Exception; +use PhpOffice\PhpSpreadsheet\Worksheet\Protection; + class PasswordHasher { - const ALGORITHM_MD2 = 'MD2'; - const ALGORITHM_MD4 = 'MD4'; - const ALGORITHM_MD5 = 'MD5'; - const ALGORITHM_SHA_1 = 'SHA-1'; - const ALGORITHM_SHA_256 = 'SHA-256'; - const ALGORITHM_SHA_384 = 'SHA-384'; - const ALGORITHM_SHA_512 = 'SHA-512'; - const ALGORITHM_RIPEMD_128 = 'RIPEMD-128'; - const ALGORITHM_RIPEMD_160 = 'RIPEMD-160'; - const ALGORITHM_WHIRLPOOL = 'WHIRLPOOL'; - /** - * Mapping between algorithm name in Excel and algorithm name in PHP. - * - * @var array + * Get algorithm name for PHP. */ - private static $algorithmArray = [ - self::ALGORITHM_MD2 => 'md2', - self::ALGORITHM_MD4 => 'md4', - self::ALGORITHM_MD5 => 'md5', - self::ALGORITHM_SHA_1 => 'sha1', - self::ALGORITHM_SHA_256 => 'sha256', - self::ALGORITHM_SHA_384 => 'sha384', - self::ALGORITHM_SHA_512 => 'sha512', - self::ALGORITHM_RIPEMD_128 => 'ripemd128', - self::ALGORITHM_RIPEMD_160 => 'ripemd160', - self::ALGORITHM_WHIRLPOOL => 'whirlpool', - ]; - - /** - * Get algorithm from self::$algorithmArray. - * - * @param string $pAlgorithmName - * - * @return string - */ - private static function getAlgorithm($pAlgorithmName) + private static function getAlgorithm(string $algorithmName): string { - if (array_key_exists($pAlgorithmName, self::$algorithmArray)) { - return self::$algorithmArray[$pAlgorithmName]; + if (!$algorithmName) { + return ''; } - return ''; + // Mapping between algorithm name in Excel and algorithm name in PHP + $mapping = [ + Protection::ALGORITHM_MD2 => 'md2', + Protection::ALGORITHM_MD4 => 'md4', + Protection::ALGORITHM_MD5 => 'md5', + Protection::ALGORITHM_SHA_1 => 'sha1', + Protection::ALGORITHM_SHA_256 => 'sha256', + Protection::ALGORITHM_SHA_384 => 'sha384', + Protection::ALGORITHM_SHA_512 => 'sha512', + Protection::ALGORITHM_RIPEMD_128 => 'ripemd128', + Protection::ALGORITHM_RIPEMD_160 => 'ripemd160', + Protection::ALGORITHM_WHIRLPOOL => 'whirlpool', + ]; + + if (array_key_exists($algorithmName, $mapping)) { + return $mapping[$algorithmName]; + } + + throw new Exception('Unsupported password algorithm: ' . $algorithmName); } /** @@ -57,10 +45,8 @@ class PasswordHasher * Spreadsheet_Excel_Writer by Xavier Noguer . * * @param string $pPassword Password to hash - * - * @return string Hashed password */ - public static function defaultHashPassword($pPassword) + private static function defaultHashPassword(string $pPassword): string { $password = 0x0000; $charPos = 1; // char position @@ -87,40 +73,28 @@ class PasswordHasher * * @see https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/1357ea58-646e-4483-92ef-95d718079d6f * - * @param string $pPassword Password to hash - * @param string $pAlgorithmName Hash algorithm used to compute the password hash value - * @param string $pSaltValue Pseudorandom string - * @param string $pSpinCount Number of times to iterate on a hash of a password + * @param string $password Password to hash + * @param string $algorithm Hash algorithm used to compute the password hash value + * @param string $salt Pseudorandom string + * @param int $spinCount Number of times to iterate on a hash of a password * * @return string Hashed password */ - public static function hashPassword($pPassword, $pAlgorithmName = '', $pSaltValue = '', $pSpinCount = 10000) + public static function hashPassword(string $password, string $algorithm = '', string $salt = '', int $spinCount = 10000): string { - $algorithmName = self::getAlgorithm($pAlgorithmName); - if (!$pAlgorithmName) { - return self::defaultHashPassword($pPassword); + $phpAlgorithm = self::getAlgorithm($algorithm); + if (!$phpAlgorithm) { + return self::defaultHashPassword($password); } - $saltValue = base64_decode($pSaltValue); - $password = mb_convert_encoding($pPassword, 'UCS-2LE', 'UTF-8'); + $saltValue = base64_decode($salt); + $encodedPassword = mb_convert_encoding($password, 'UCS-2LE', 'UTF-8'); - $hashValue = hash($algorithmName, $saltValue . $password, true); - for ($i = 0; $i < $pSpinCount; ++$i) { - $hashValue = hash($algorithmName, $hashValue . pack('L', $i), true); + $hashValue = hash($phpAlgorithm, $saltValue . $encodedPassword, true); + for ($i = 0; $i < $spinCount; ++$i) { + $hashValue = hash($phpAlgorithm, $hashValue . pack('L', $i), true); } return base64_encode($hashValue); } - - /** - * Create a pseudorandom string. - * - * @param int $pSize Length of the output string in bytes - * - * @return string Pseudorandom string - */ - public static function generateSalt($pSize = 16) - { - return base64_encode(random_bytes($pSize)); - } } diff --git a/src/PhpSpreadsheet/Worksheet/Protection.php b/src/PhpSpreadsheet/Worksheet/Protection.php index 3566e255..81abc0b7 100644 --- a/src/PhpSpreadsheet/Worksheet/Protection.php +++ b/src/PhpSpreadsheet/Worksheet/Protection.php @@ -6,6 +6,17 @@ use PhpOffice\PhpSpreadsheet\Shared\PasswordHasher; class Protection { + const ALGORITHM_MD2 = 'MD2'; + const ALGORITHM_MD4 = 'MD4'; + const ALGORITHM_MD5 = 'MD5'; + const ALGORITHM_SHA_1 = 'SHA-1'; + const ALGORITHM_SHA_256 = 'SHA-256'; + const ALGORITHM_SHA_384 = 'SHA-384'; + const ALGORITHM_SHA_512 = 'SHA-512'; + const ALGORITHM_RIPEMD_128 = 'RIPEMD-128'; + const ALGORITHM_RIPEMD_160 = 'RIPEMD-160'; + const ALGORITHM_WHIRLPOOL = 'WHIRLPOOL'; + /** * Sheet. * @@ -119,7 +130,7 @@ class Protection private $selectUnlockedCells = false; /** - * Password. + * Hashed password. * * @var string */ @@ -130,28 +141,28 @@ class Protection * * @var string */ - private $algorithmName = ''; + private $algorithm = ''; /** * Hash value. * * @var string */ - private $hashValue = ''; + private $hash = ''; /** * Salt value. * * @var string */ - private $saltValue = ''; + private $salt = ''; /** * Spin count. * * @var int */ - private $spinCount = ''; + private $spinCount = 10000; /** * Create a new Protection. @@ -570,7 +581,7 @@ class Protection } /** - * Get Password (hashed). + * Get hashed password. * * @return string */ @@ -590,107 +601,84 @@ class Protection public function setPassword($pValue, $pAlreadyHashed = false) { if (!$pAlreadyHashed) { - $pValue = PasswordHasher::hashPassword($pValue); + $salt = $this->generateSalt(); + $this->setSalt($salt); + $pValue = PasswordHasher::hashPassword($pValue, $this->getAlgorithm(), $this->getSalt(), $this->getSpinCount()); } + $this->password = $pValue; return $this; } /** - * Get AlgorithmName. - * - * @return string + * Create a pseudorandom string. */ - public function getAlgorithmName() + private function generateSalt(): string { - return $this->algorithmName; + return base64_encode(random_bytes(16)); } /** - * Set AlgorithmName. - * - * @param string $pValue - * - * @return $this + * Get algorithm name. */ - public function setAlgorithmName($pValue) + public function getAlgorithm(): string { - $this->algorithmName = $pValue; - - return $this; + return $this->algorithm; } /** - * Get HashValue. - * - * @return string + * Set algorithm name. */ - public function getHashValue() + public function setAlgorithm(string $algorithm): void { - return $this->hashValue; + $this->algorithm = $algorithm; } /** - * Set HashValue. - * - * @param string $pValue - * - * @return $this + * Get salt value. */ - public function setHashValue($pValue) + public function getSalt(): string { - $this->hashValue = $pValue; - - return $this; + return $this->salt; } /** - * Get SaltValue. - * - * @return string + * Set salt value. */ - public function getSaltValue() + public function setSalt(string $salt): void { - return $this->saltValue; + $this->salt = $salt; } /** - * Set SaltValue. - * - * @param string $pValue - * - * @return $this + * Get spin count. */ - public function setSaltValue($pValue) - { - $this->saltValue = $pValue; - - return $this; - } - - /** - * Get SpinCount. - * - * @return int - */ - public function getSpinCount() + public function getSpinCount(): int { return $this->spinCount; } /** - * Set SpinCount. - * - * @param int $pValue - * - * @return $this + * Set spin count. */ - public function setSpinCount($pValue) + public function setSpinCount(int $spinCount): void { - $this->spinCount = $pValue; + $this->spinCount = $spinCount; + } - return $this; + /** + * Verify that the given non-hashed password can "unlock" the protection. + */ + public function verify(string $password): bool + { + if (!$this->isProtectionEnabled()) { + return true; + } + + $hash = PasswordHasher::hashPassword($password, $this->getAlgorithm(), $this->getSalt(), $this->getSpinCount()); + + return $this->getPassword() === $hash; } /** diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 803ade8a..d101bb40 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -420,42 +420,33 @@ class Worksheet extends WriterPart // sheetProtection $objWriter->startElement('sheetProtection'); - if ($pSheet->getProtection()->getPassword() !== '') { - $objWriter->writeAttribute('password', $pSheet->getProtection()->getPassword()); + $protection = $pSheet->getProtection(); + + if ($protection->getAlgorithm()) { + $objWriter->writeAttribute('algorithmName', $protection->getAlgorithm()); + $objWriter->writeAttribute('hashValue', $protection->getPassword()); + $objWriter->writeAttribute('saltValue', $protection->getSalt()); + $objWriter->writeAttribute('spinCount', $protection->getSpinCount()); + } elseif ($protection->getPassword() !== '') { + $objWriter->writeAttribute('password', $protection->getPassword()); } - if ($pSheet->getProtection()->getHashValue() !== '') { - $objWriter->writeAttribute('hashValue', $pSheet->getProtection()->getHashValue()); - } - - if ($pSheet->getProtection()->getAlgorithmName() !== '') { - $objWriter->writeAttribute('algorithmName', $pSheet->getProtection()->getAlgorithmName()); - } - - if ($pSheet->getProtection()->getSaltValue() !== '') { - $objWriter->writeAttribute('saltValue', $pSheet->getProtection()->getSaltValue()); - } - - if ($pSheet->getProtection()->getSpinCount() !== '') { - $objWriter->writeAttribute('spinCount', $pSheet->getProtection()->getSpinCount()); - } - - $objWriter->writeAttribute('sheet', ($pSheet->getProtection()->getSheet() ? 'true' : 'false')); - $objWriter->writeAttribute('objects', ($pSheet->getProtection()->getObjects() ? 'true' : 'false')); - $objWriter->writeAttribute('scenarios', ($pSheet->getProtection()->getScenarios() ? 'true' : 'false')); - $objWriter->writeAttribute('formatCells', ($pSheet->getProtection()->getFormatCells() ? 'true' : 'false')); - $objWriter->writeAttribute('formatColumns', ($pSheet->getProtection()->getFormatColumns() ? 'true' : 'false')); - $objWriter->writeAttribute('formatRows', ($pSheet->getProtection()->getFormatRows() ? 'true' : 'false')); - $objWriter->writeAttribute('insertColumns', ($pSheet->getProtection()->getInsertColumns() ? 'true' : 'false')); - $objWriter->writeAttribute('insertRows', ($pSheet->getProtection()->getInsertRows() ? 'true' : 'false')); - $objWriter->writeAttribute('insertHyperlinks', ($pSheet->getProtection()->getInsertHyperlinks() ? 'true' : 'false')); - $objWriter->writeAttribute('deleteColumns', ($pSheet->getProtection()->getDeleteColumns() ? 'true' : 'false')); - $objWriter->writeAttribute('deleteRows', ($pSheet->getProtection()->getDeleteRows() ? 'true' : 'false')); - $objWriter->writeAttribute('selectLockedCells', ($pSheet->getProtection()->getSelectLockedCells() ? 'true' : 'false')); - $objWriter->writeAttribute('sort', ($pSheet->getProtection()->getSort() ? 'true' : 'false')); - $objWriter->writeAttribute('autoFilter', ($pSheet->getProtection()->getAutoFilter() ? 'true' : 'false')); - $objWriter->writeAttribute('pivotTables', ($pSheet->getProtection()->getPivotTables() ? 'true' : 'false')); - $objWriter->writeAttribute('selectUnlockedCells', ($pSheet->getProtection()->getSelectUnlockedCells() ? 'true' : 'false')); + $objWriter->writeAttribute('sheet', ($protection->getSheet() ? 'true' : 'false')); + $objWriter->writeAttribute('objects', ($protection->getObjects() ? 'true' : 'false')); + $objWriter->writeAttribute('scenarios', ($protection->getScenarios() ? 'true' : 'false')); + $objWriter->writeAttribute('formatCells', ($protection->getFormatCells() ? 'true' : 'false')); + $objWriter->writeAttribute('formatColumns', ($protection->getFormatColumns() ? 'true' : 'false')); + $objWriter->writeAttribute('formatRows', ($protection->getFormatRows() ? 'true' : 'false')); + $objWriter->writeAttribute('insertColumns', ($protection->getInsertColumns() ? 'true' : 'false')); + $objWriter->writeAttribute('insertRows', ($protection->getInsertRows() ? 'true' : 'false')); + $objWriter->writeAttribute('insertHyperlinks', ($protection->getInsertHyperlinks() ? 'true' : 'false')); + $objWriter->writeAttribute('deleteColumns', ($protection->getDeleteColumns() ? 'true' : 'false')); + $objWriter->writeAttribute('deleteRows', ($protection->getDeleteRows() ? 'true' : 'false')); + $objWriter->writeAttribute('selectLockedCells', ($protection->getSelectLockedCells() ? 'true' : 'false')); + $objWriter->writeAttribute('sort', ($protection->getSort() ? 'true' : 'false')); + $objWriter->writeAttribute('autoFilter', ($protection->getAutoFilter() ? 'true' : 'false')); + $objWriter->writeAttribute('pivotTables', ($protection->getPivotTables() ? 'true' : 'false')); + $objWriter->writeAttribute('selectUnlockedCells', ($protection->getSelectUnlockedCells() ? 'true' : 'false')); $objWriter->endElement(); } @@ -1149,7 +1140,7 @@ class Worksheet extends WriterPart $this->getParentWriter()->getOffice2003Compatibility() === false, 'v', ($this->getParentWriter()->getPreCalculateFormulas() && !is_array($calculatedValue) && substr($calculatedValue, 0, 1) !== '#') - ? StringHelper::formatNumber($calculatedValue) : '0' + ? StringHelper::formatNumber($calculatedValue) : '0' ); } diff --git a/tests/PhpSpreadsheetTests/Worksheet/ProtectionTest.php b/tests/PhpSpreadsheetTests/Worksheet/ProtectionTest.php new file mode 100644 index 00000000..1cc1ed32 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Worksheet/ProtectionTest.php @@ -0,0 +1,39 @@ +verify('foo'), 'non-protected always pass'); + + $protection->setSheet(true); + self::assertFalse($protection->verify('foo'), 'protected will fail'); + + $protection->setPassword('foo', true); + self::assertSame('foo', $protection->getPassword(), 'was not stored as-is, without hashing'); + self::assertFalse($protection->verify('foo'), 'setting already hashed password will not match'); + + $protection->setPassword('foo'); + self::assertSame('CC40', $protection->getPassword(), 'was hashed'); + self::assertTrue($protection->verify('foo'), 'setting non-hashed password will hash it and not match'); + + $protection->setAlgorithm(Protection::ALGORITHM_MD5); + self::assertFalse($protection->verify('foo'), 'changing algorithm will not match anymore'); + + $protection->setPassword('foo'); + $hash1 = $protection->getPassword(); + $protection->setPassword('foo'); + $hash2 = $protection->getPassword(); + + self::assertSame(24, mb_strlen($hash1)); + self::assertSame(24, mb_strlen($hash2)); + self::assertNotSame($hash1, $hash2, 'was hashed with automatic salt'); + self::assertTrue($protection->verify('foo'), 'setting password again, will hash with proper algorithm and will match'); + } +}