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
``` 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

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