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.
This commit is contained in:
Adrien Crivelli 2020-05-31 20:22:23 +09:00
parent 1eaf40be69
commit b9a59660d0
No known key found for this signature in database
GPG Key ID: B182FD79DC6DE92E
6 changed files with 244 additions and 196 deletions

View File

@ -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 - Cell: offers the option to lock/unlock a cell as well as show/hide
the internal formula. 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: An example on setting document security:
``` php ``` php
$spreadsheet->getSecurity()->setLockWindows(true); $security = $spreadsheet->getSecurity();
$spreadsheet->getSecurity()->setLockStructure(true); $security->setLockWindows(true);
$spreadsheet->getSecurity()->setWorkbookPassword("PhpSpreadsheet"); $security->setLockStructure(true);
$security->setWorkbookPassword("PhpSpreadsheet");
``` ```
### Worksheet
An example on setting worksheet security: An example on setting worksheet security:
``` php ``` php
$spreadsheet->getActiveSheet() $protection = $spreadsheet->getActiveSheet()->getProtection();
->getProtection()->setPassword('PhpSpreadsheet'); $protection->setPassword('PhpSpreadsheet');
$spreadsheet->getActiveSheet() $protection->setSheet(true);
->getProtection()->setSheet(true); $protection->setSort(true);
$spreadsheet->getActiveSheet() $protection->setInsertRows(true);
->getProtection()->setSort(true); $protection->setFormatCells(true);
$spreadsheet->getActiveSheet()
->getProtection()->setInsertRows(true);
$spreadsheet->getActiveSheet()
->getProtection()->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: An example on setting cell security:
``` php ``` php
@ -950,14 +974,30 @@ $spreadsheet->getActiveSheet()->getStyle('B1')
->setLocked(\PhpOffice\PhpSpreadsheet\Style\Protection::PROTECTION_UNPROTECTED); ->setLocked(\PhpOffice\PhpSpreadsheet\Style\Protection::PROTECTION_UNPROTECTED);
``` ```
**Make sure you enable worksheet protection if you need any of the ## Reading protected spreadsheet
worksheet protection features!** This can be done using the following
code: 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 ```php
$spreadsheet->getActiveSheet()->getProtection()->setSheet(true); $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 ## Setting data validation on a cell
Data validation is a powerful feature of Xlsx. It allows to specify an Data validation is a powerful feature of Xlsx. It allows to specify an

View File

@ -763,17 +763,8 @@ class Xlsx extends BaseReader
} }
} }
if (!$this->readDataOnly && $xmlSheet && $xmlSheet->sheetProtection) { if ($xmlSheet) {
$docSheet->getProtection()->setPassword((string) $xmlSheet->sheetProtection['password'], true); $this->readSheetProtection($docSheet, $xmlSheet);
$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 && $xmlSheet->autoFilter && !$this->readDataOnly) { if ($xmlSheet && $xmlSheet->autoFilter && !$this->readDataOnly) {
@ -2035,4 +2026,29 @@ class Xlsx extends BaseReader
return $workbookBasename; 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);
}
}
}
} }

View File

@ -2,51 +2,39 @@
namespace PhpOffice\PhpSpreadsheet\Shared; namespace PhpOffice\PhpSpreadsheet\Shared;
use PhpOffice\PhpSpreadsheet\Exception;
use PhpOffice\PhpSpreadsheet\Worksheet\Protection;
class PasswordHasher 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. * Get algorithm name for PHP.
*
* @var array
*/ */
private static $algorithmArray = [ private static function getAlgorithm(string $algorithmName): string
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)
{ {
if (array_key_exists($pAlgorithmName, self::$algorithmArray)) { if (!$algorithmName) {
return self::$algorithmArray[$pAlgorithmName]; 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 <xnoguer@rezebra.com>. * Spreadsheet_Excel_Writer by Xavier Noguer <xnoguer@rezebra.com>.
* *
* @param string $pPassword Password to hash * @param string $pPassword Password to hash
*
* @return string Hashed password
*/ */
public static function defaultHashPassword($pPassword) private static function defaultHashPassword(string $pPassword): string
{ {
$password = 0x0000; $password = 0x0000;
$charPos = 1; // char position $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 * @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 $password Password to hash
* @param string $pAlgorithmName Hash algorithm used to compute the password hash value * @param string $algorithm Hash algorithm used to compute the password hash value
* @param string $pSaltValue Pseudorandom string * @param string $salt Pseudorandom string
* @param string $pSpinCount Number of times to iterate on a hash of a password * @param int $spinCount Number of times to iterate on a hash of a password
* *
* @return string Hashed 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); $phpAlgorithm = self::getAlgorithm($algorithm);
if (!$pAlgorithmName) { if (!$phpAlgorithm) {
return self::defaultHashPassword($pPassword); return self::defaultHashPassword($password);
} }
$saltValue = base64_decode($pSaltValue); $saltValue = base64_decode($salt);
$password = mb_convert_encoding($pPassword, 'UCS-2LE', 'UTF-8'); $encodedPassword = mb_convert_encoding($password, 'UCS-2LE', 'UTF-8');
$hashValue = hash($algorithmName, $saltValue . $password, true); $hashValue = hash($phpAlgorithm, $saltValue . $encodedPassword, true);
for ($i = 0; $i < $pSpinCount; ++$i) { for ($i = 0; $i < $spinCount; ++$i) {
$hashValue = hash($algorithmName, $hashValue . pack('L', $i), true); $hashValue = hash($phpAlgorithm, $hashValue . pack('L', $i), true);
} }
return base64_encode($hashValue); 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));
}
} }

View File

@ -6,6 +6,17 @@ use PhpOffice\PhpSpreadsheet\Shared\PasswordHasher;
class Protection 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. * Sheet.
* *
@ -119,7 +130,7 @@ class Protection
private $selectUnlockedCells = false; private $selectUnlockedCells = false;
/** /**
* Password. * Hashed password.
* *
* @var string * @var string
*/ */
@ -130,28 +141,28 @@ class Protection
* *
* @var string * @var string
*/ */
private $algorithmName = ''; private $algorithm = '';
/** /**
* Hash value. * Hash value.
* *
* @var string * @var string
*/ */
private $hashValue = ''; private $hash = '';
/** /**
* Salt value. * Salt value.
* *
* @var string * @var string
*/ */
private $saltValue = ''; private $salt = '';
/** /**
* Spin count. * Spin count.
* *
* @var int * @var int
*/ */
private $spinCount = ''; private $spinCount = 10000;
/** /**
* Create a new Protection. * Create a new Protection.
@ -570,7 +581,7 @@ class Protection
} }
/** /**
* Get Password (hashed). * Get hashed password.
* *
* @return string * @return string
*/ */
@ -590,107 +601,84 @@ class Protection
public function setPassword($pValue, $pAlreadyHashed = false) public function setPassword($pValue, $pAlreadyHashed = false)
{ {
if (!$pAlreadyHashed) { 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; $this->password = $pValue;
return $this; return $this;
} }
/** /**
* Get AlgorithmName. * Create a pseudorandom string.
*
* @return string
*/ */
public function getAlgorithmName() private function generateSalt(): string
{ {
return $this->algorithmName; return base64_encode(random_bytes(16));
} }
/** /**
* Set AlgorithmName. * Get algorithm name.
*
* @param string $pValue
*
* @return $this
*/ */
public function setAlgorithmName($pValue) public function getAlgorithm(): string
{ {
$this->algorithmName = $pValue; return $this->algorithm;
return $this;
} }
/** /**
* Get HashValue. * Set algorithm name.
*
* @return string
*/ */
public function getHashValue() public function setAlgorithm(string $algorithm): void
{ {
return $this->hashValue; $this->algorithm = $algorithm;
} }
/** /**
* Set HashValue. * Get salt value.
*
* @param string $pValue
*
* @return $this
*/ */
public function setHashValue($pValue) public function getSalt(): string
{ {
$this->hashValue = $pValue; return $this->salt;
return $this;
} }
/** /**
* Get SaltValue. * Set salt value.
*
* @return string
*/ */
public function getSaltValue() public function setSalt(string $salt): void
{ {
return $this->saltValue; $this->salt = $salt;
} }
/** /**
* Set SaltValue. * Get spin count.
*
* @param string $pValue
*
* @return $this
*/ */
public function setSaltValue($pValue) public function getSpinCount(): int
{
$this->saltValue = $pValue;
return $this;
}
/**
* Get SpinCount.
*
* @return int
*/
public function getSpinCount()
{ {
return $this->spinCount; return $this->spinCount;
} }
/** /**
* Set SpinCount. * Set spin count.
*
* @param int $pValue
*
* @return $this
*/ */
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;
} }
/** /**

View File

@ -420,42 +420,33 @@ class Worksheet extends WriterPart
// sheetProtection // sheetProtection
$objWriter->startElement('sheetProtection'); $objWriter->startElement('sheetProtection');
if ($pSheet->getProtection()->getPassword() !== '') { $protection = $pSheet->getProtection();
$objWriter->writeAttribute('password', $pSheet->getProtection()->getPassword());
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('sheet', ($protection->getSheet() ? 'true' : 'false'));
$objWriter->writeAttribute('hashValue', $pSheet->getProtection()->getHashValue()); $objWriter->writeAttribute('objects', ($protection->getObjects() ? 'true' : 'false'));
} $objWriter->writeAttribute('scenarios', ($protection->getScenarios() ? 'true' : 'false'));
$objWriter->writeAttribute('formatCells', ($protection->getFormatCells() ? 'true' : 'false'));
if ($pSheet->getProtection()->getAlgorithmName() !== '') { $objWriter->writeAttribute('formatColumns', ($protection->getFormatColumns() ? 'true' : 'false'));
$objWriter->writeAttribute('algorithmName', $pSheet->getProtection()->getAlgorithmName()); $objWriter->writeAttribute('formatRows', ($protection->getFormatRows() ? 'true' : 'false'));
} $objWriter->writeAttribute('insertColumns', ($protection->getInsertColumns() ? 'true' : 'false'));
$objWriter->writeAttribute('insertRows', ($protection->getInsertRows() ? 'true' : 'false'));
if ($pSheet->getProtection()->getSaltValue() !== '') { $objWriter->writeAttribute('insertHyperlinks', ($protection->getInsertHyperlinks() ? 'true' : 'false'));
$objWriter->writeAttribute('saltValue', $pSheet->getProtection()->getSaltValue()); $objWriter->writeAttribute('deleteColumns', ($protection->getDeleteColumns() ? 'true' : 'false'));
} $objWriter->writeAttribute('deleteRows', ($protection->getDeleteRows() ? 'true' : 'false'));
$objWriter->writeAttribute('selectLockedCells', ($protection->getSelectLockedCells() ? 'true' : 'false'));
if ($pSheet->getProtection()->getSpinCount() !== '') { $objWriter->writeAttribute('sort', ($protection->getSort() ? 'true' : 'false'));
$objWriter->writeAttribute('spinCount', $pSheet->getProtection()->getSpinCount()); $objWriter->writeAttribute('autoFilter', ($protection->getAutoFilter() ? 'true' : 'false'));
} $objWriter->writeAttribute('pivotTables', ($protection->getPivotTables() ? 'true' : 'false'));
$objWriter->writeAttribute('selectUnlockedCells', ($protection->getSelectUnlockedCells() ? 'true' : 'false'));
$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->endElement(); $objWriter->endElement();
} }

View File

@ -0,0 +1,39 @@
<?php
namespace PhpOffice\PhpSpreadsheetTests\Worksheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Protection;
use PHPUnit\Framework\TestCase;
class ProtectionTest extends TestCase
{
public function testVerifyPassword(): void
{
$protection = new Protection();
self::assertTrue($protection->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');
}
}