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

View File

@ -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 <xnoguer@rezebra.com>.
*
* @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));
}
}

View File

@ -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;
}
/**

View File

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

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