diff --git a/composer.json b/composer.json index 70b60b46..2774ad98 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,17 @@ "name": "Antoine de Troostembergh" } ], + "scripts": { + "check": [ + "./vendor/bin/php-cs-fixer fix --ansi --dry-run --diff", + "./vendor/bin/phpcs --report-width=200 --report-summary --report-full samples/ src/ tests/ --ignore=src/PhpWord/Shared/PCLZip --standard=PSR2 -n", + "./vendor/bin/phpmd src/,tests/ text ./phpmd.xml.dist --exclude pclzip.lib.php", + "./vendor/bin/phpunit --color=always" + ], + "fix": [ + "./vendor/bin/php-cs-fixer fix --ansi" + ] + }, "require": { "php": ">=5.3.3", "ext-xml": "*", diff --git a/docs/general.rst b/docs/general.rst index da80e5f9..ae090f2d 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -272,6 +272,17 @@ points to twips. // 2 cm right margin $sectionStyle->setMarginRight(\PhpOffice\PhpWord\Shared\Converter::cmToTwip(2)); +Document protection +------------------- + +The document (or parts of it) can be password protected. + +.. code-block:: php + + $documentProtection = $phpWord->getSettings()->getDocumentProtection(); + $documentProtection->setEditing(DocProtect::READ_ONLY); + $documentProtection->setPassword('myPassword'); + Automatically Recalculate Fields on Open ---------------------------------------- diff --git a/samples/Sample_38_Protection.php b/samples/Sample_38_Protection.php new file mode 100644 index 00000000..ee2b460b --- /dev/null +++ b/samples/Sample_38_Protection.php @@ -0,0 +1,21 @@ +getSettings()->getDocumentProtection(); +$documentProtection->setEditing(DocProtect::READ_ONLY); +$documentProtection->setPassword('myPassword'); + +$section = $phpWord->addSection(); +$section->addText('this document is password protected'); + +// Save file +echo write($phpWord, basename(__FILE__, '.php'), $writers); +if (!CLI) { + include_once 'Sample_Footer.php'; +} diff --git a/src/PhpWord/Metadata/Protection.php b/src/PhpWord/Metadata/Protection.php index ef5063f8..35391cb2 100644 --- a/src/PhpWord/Metadata/Protection.php +++ b/src/PhpWord/Metadata/Protection.php @@ -17,23 +17,53 @@ namespace PhpOffice\PhpWord\Metadata; +use PhpOffice\PhpWord\Shared\Microsoft\PasswordEncoder; +use PhpOffice\PhpWord\SimpleType\DocProtect; + /** * Document protection class * * @since 0.12.0 - * @see http://www.datypic.com/sc/ooxml/t-w_CT_DocProtect.html - * @todo Password! + * @see http://www.datypic.com/sc/ooxml/t-w_CT_DocProtect.html */ class Protection { /** - * Editing restriction readOnly|comments|trackedChanges|forms + * Editing restriction none|readOnly|comments|trackedChanges|forms * * @var string * @see http://www.datypic.com/sc/ooxml/a-w_edit-1.html */ private $editing; + /** + * password + * + * @var string + */ + private $password; + + /** + * Iterations to Run Hashing Algorithm + * + * @var int + */ + private $spinCount = 100000; + + /** + * Cryptographic Hashing Algorithm (see constants defined in \PhpOffice\PhpWord\Shared\Microsoft\PasswordEncoder) + * + * @var string + */ + private $algorithm = PasswordEncoder::ALGORITHM_SHA_1; + + /** + * Salt for Password Verifier + * + * @var string + */ + private $salt; + /** * Create a new instance * @@ -41,7 +71,9 @@ class Protection */ public function __construct($editing = null) { - $this->setEditing($editing); + if ($editing != null) { + $this->setEditing($editing); + } } /** @@ -57,13 +89,111 @@ class Protection /** * Set editing protection * - * @param string $editing + * @param string $editing Any value of \PhpOffice\PhpWord\SimpleType\DocProtect * @return self */ public function setEditing($editing = null) { + DocProtect::validate($editing); $this->editing = $editing; return $this; } + + /** + * Get password + * + * @return string + */ + public function getPassword() + { + return $this->password; + } + + /** + * Set password + * + * @param $password + * @return self + */ + public function setPassword($password) + { + $this->password = $password; + + return $this; + } + + /** + * Get count for hash iterations + * + * @return int + */ + public function getSpinCount() + { + return $this->spinCount; + } + + /** + * Set count for hash iterations + * + * @param $spinCount + * @return self + */ + public function setSpinCount($spinCount) + { + $this->spinCount = $spinCount; + + return $this; + } + + /** + * Get algorithm + * + * @return string + */ + public function getAlgorithm() + { + return $this->algorithm; + } + + /** + * Set algorithm + * + * @param $algorithm + * @return self + */ + public function setAlgorithm($algorithm) + { + $this->algorithm = $algorithm; + + return $this; + } + + /** + * Get salt + * + * @return string + */ + public function getSalt() + { + return $this->salt; + } + + /** + * Set salt. Salt HAS to be 16 characters, or an exception will be thrown. + * + * @param $salt + * @throws \InvalidArgumentException + * @return self + */ + public function setSalt($salt) + { + if ($salt !== null && strlen($salt) !== 16) { + throw new \InvalidArgumentException('salt has to be of exactly 16 bytes length'); + } + + $this->salt = $salt; + + return $this; + } } diff --git a/src/PhpWord/Shared/Microsoft/PasswordEncoder.php b/src/PhpWord/Shared/Microsoft/PasswordEncoder.php new file mode 100644 index 00000000..d3a03d97 --- /dev/null +++ b/src/PhpWord/Shared/Microsoft/PasswordEncoder.php @@ -0,0 +1,235 @@ + array(1, 'md2'), + self::ALGORITHM_MD4 => array(2, 'md4'), + self::ALGORITHM_MD5 => array(3, 'md5'), + self::ALGORITHM_SHA_1 => array(4, 'sha1'), + self::ALGORITHM_MAC => array(5, ''), // 'mac' -> not possible with hash() + self::ALGORITHM_RIPEMD => array(6, 'ripemd'), + self::ALGORITHM_RIPEMD_160 => array(7, 'ripemd160'), + self::ALGORITHM_HMAC => array(9, ''), //'hmac' -> not possible with hash() + self::ALGORITHM_SHA_256 => array(12, 'sha256'), + self::ALGORITHM_SHA_384 => array(13, 'sha384'), + self::ALGORITHM_SHA_512 => array(14, 'sha512'), + ); + + private static $initialCodeArray = array( + 0xE1F0, + 0x1D0F, + 0xCC9C, + 0x84C0, + 0x110C, + 0x0E10, + 0xF1CE, + 0x313E, + 0x1872, + 0xE139, + 0xD40F, + 0x84F9, + 0x280C, + 0xA96A, + 0x4EC3, + ); + + private static $encryptionMatrix = array( + array(0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09), + array(0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF), + array(0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, 0x5AD0), + array(0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40), + array(0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5), + array(0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A), + array(0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9), + array(0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, 0xF6D0), + array(0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC), + array(0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10), + array(0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168), + array(0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C), + array(0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, 0x1DAD), + array(0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC), + array(0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4), + ); + + private static $passwordMaxLength = 15; + + /** + * Create a hashed password that MS Word will be able to work with + * @see https://blogs.msdn.microsoft.com/vsod/2010/04/05/how-to-set-the-editing-restrictions-in-word-using-open-xml-sdk-2-0/ + * + * @param string $password + * @param string $algorithmName + * @param string $salt + * @param int $spinCount + * @return string + */ + public static function hashPassword($password, $algorithmName = self::ALGORITHM_SHA_1, $salt = null, $spinCount = 10000) + { + $origEncoding = mb_internal_encoding(); + mb_internal_encoding('UTF-8'); + + $password = mb_substr($password, 0, min(self::$passwordMaxLength, mb_strlen($password))); + + // Get the single-byte values by iterating through the Unicode characters of the truncated password. + // For each character, if the low byte is not equal to 0, take it. Otherwise, take the high byte. + $passUtf8 = mb_convert_encoding($password, 'UCS-2LE', 'UTF-8'); + $byteChars = array(); + + for ($i = 0; $i < mb_strlen($password); $i++) { + $byteChars[$i] = ord(substr($passUtf8, $i * 2, 1)); + + if ($byteChars[$i] == 0) { + $byteChars[$i] = ord(substr($passUtf8, $i * 2 + 1, 1)); + } + } + + // build low-order word and hig-order word and combine them + $combinedKey = self::buildCombinedKey($byteChars); + // build reversed hexadecimal string + $hex = str_pad(strtoupper(dechex($combinedKey & 0xFFFFFFFF)), 8, '0', \STR_PAD_LEFT); + $reversedHex = $hex[6] . $hex[7] . $hex[4] . $hex[5] . $hex[2] . $hex[3] . $hex[0] . $hex[1]; + + $generatedKey = mb_convert_encoding($reversedHex, 'UCS-2LE', 'UTF-8'); + + // Implementation Notes List: + // Word requires that the initial hash of the password with the salt not be considered in the count. + // The initial hash of salt + key is not included in the iteration count. + $algorithm = self::getAlgorithm($algorithmName); + $generatedKey = hash($algorithm, $salt . $generatedKey, true); + + for ($i = 0; $i < $spinCount; $i++) { + $generatedKey = hash($algorithm, $generatedKey . pack('CCCC', $i, $i >> 8, $i >> 16, $i >> 24), true); + } + $generatedKey = base64_encode($generatedKey); + + mb_internal_encoding($origEncoding); + + return $generatedKey; + } + + /** + * Get algorithm from self::$algorithmMapping + * + * @param string $algorithmName + * @return string + */ + private static function getAlgorithm($algorithmName) + { + $algorithm = self::$algorithmMapping[$algorithmName][1]; + if ($algorithm == '') { + $algorithm = 'sha1'; + } + + return $algorithm; + } + + /** + * Returns the algorithm ID + * + * @param sting $algorithmName + * @return int + */ + public static function getAlgorithmId($algorithmName) + { + return self::$algorithmMapping[$algorithmName][0]; + } + + /** + * Build combined key from low-order word and high-order word + * + * @param array $byteChars byte array representation of password + * @return int + */ + private static function buildCombinedKey($byteChars) + { + $byteCharsLength = count($byteChars); + // Compute the high-order word + // Initialize from the initial code array (see above), depending on the passwords length. + $highOrderWord = self::$initialCodeArray[$byteCharsLength - 1]; + + // For each character in the password: + // For every bit in the character, starting with the least significant and progressing to (but excluding) + // the most significant, if the bit is set, XOR the key’s high-order word with the corresponding word from + // the Encryption Matrix + for ($i = 0; $i < $byteCharsLength; $i++) { + $tmp = self::$passwordMaxLength - $byteCharsLength + $i; + $matrixRow = self::$encryptionMatrix[$tmp]; + for ($intBit = 0; $intBit < 7; $intBit++) { + if (($byteChars[$i] & (0x0001 << $intBit)) != 0) { + $highOrderWord = ($highOrderWord ^ $matrixRow[$intBit]); + } + } + } + + // Compute low-order word + // Initialize with 0 + $lowOrderWord = 0; + // For each character in the password, going backwards + for ($i = $byteCharsLength - 1; $i >= 0; $i--) { + // low-order word = (((low-order word SHR 14) AND 0x0001) OR (low-order word SHL 1) AND 0x7FFF)) XOR character + $lowOrderWord = (((($lowOrderWord >> 14) & 0x0001) | (($lowOrderWord << 1) & 0x7FFF)) ^ $byteChars[$i]); + } + // Lastly, low-order word = (((low-order word SHR 14) AND 0x0001) OR (low-order word SHL 1) AND 0x7FFF)) XOR strPassword length XOR 0xCE4B. + $lowOrderWord = (((($lowOrderWord >> 14) & 0x0001) | (($lowOrderWord << 1) & 0x7FFF)) ^ $byteCharsLength ^ 0xCE4B); + + // Combine the Low and High Order Word + return self::int32(($highOrderWord << 16) + $lowOrderWord); + } + + /** + * Simulate behaviour of (signed) int32 + * + * @codeCoverageIgnore + * @param int $value + * @return int + */ + private static function int32($value) + { + $value = ($value & 0xFFFFFFFF); + + if ($value & 0x80000000) { + $value = -((~$value & 0xFFFFFFFF) + 1); + } + + return $value; + } +} diff --git a/src/PhpWord/SimpleType/DocProtect.php b/src/PhpWord/SimpleType/DocProtect.php new file mode 100644 index 00000000..cffa0003 --- /dev/null +++ b/src/PhpWord/SimpleType/DocProtect.php @@ -0,0 +1,55 @@ +getEditing() !== null) { - $this->settings['w:documentProtection'] = array( - '@attributes' => array( - 'w:enforcement' => 1, - 'w:edit' => $documentProtection->getEditing(), - ), - ); + if ($documentProtection->getEditing() !== null) { + if ($documentProtection->getPassword() == null) { + $this->settings['w:documentProtection'] = array( + '@attributes' => array( + 'w:enforcement' => 1, + 'w:edit' => $documentProtection->getEditing(), + ), + ); + } else { + if ($documentProtection->getSalt() == null) { + $documentProtection->setSalt(openssl_random_pseudo_bytes(16)); + } + $passwordHash = PasswordEncoder::hashPassword($documentProtection->getPassword(), $documentProtection->getAlgorithm(), $documentProtection->getSalt(), $documentProtection->getSpinCount()); + $this->settings['w:documentProtection'] = array( + '@attributes' => array( + 'w:enforcement' => 1, + 'w:edit' => $documentProtection->getEditing(), + 'w:cryptProviderType' => 'rsaFull', + 'w:cryptAlgorithmClass' => 'hash', + 'w:cryptAlgorithmType' => 'typeAny', + 'w:cryptAlgorithmSid' => PasswordEncoder::getAlgorithmId($documentProtection->getAlgorithm()), + 'w:cryptSpinCount' => $documentProtection->getSpinCount(), + 'w:hash' => $passwordHash, + 'w:salt' => base64_encode($documentProtection->getSalt()), + ), + ); + } } } @@ -264,11 +285,13 @@ class Settings extends AbstractPart { $compatibility = $this->getParentWriter()->getPhpWord()->getCompatibility(); if ($compatibility->getOoxmlVersion() !== null) { - $this->settings['w:compat']['w:compatSetting'] = array('@attributes' => array( - 'w:name' => 'compatibilityMode', - 'w:uri' => 'http://schemas.microsoft.com/office/word', - 'w:val' => $compatibility->getOoxmlVersion(), - )); + $this->settings['w:compat']['w:compatSetting'] = array( + '@attributes' => array( + 'w:name' => 'compatibilityMode', + 'w:uri' => 'http://schemas.microsoft.com/office/word', + 'w:val' => $compatibility->getOoxmlVersion(), + ), + ); } } } diff --git a/tests/PhpWord/Metadata/SettingsTest.php b/tests/PhpWord/Metadata/SettingsTest.php index e5b50cb7..50863561 100644 --- a/tests/PhpWord/Metadata/SettingsTest.php +++ b/tests/PhpWord/Metadata/SettingsTest.php @@ -63,13 +63,22 @@ class SettingsTest extends \PHPUnit\Framework\TestCase public function testDocumentProtection() { $oSettings = new Settings(); - $oSettings->setDocumentProtection(new Protection()); + $oSettings->setDocumentProtection(new Protection('trackedChanges')); $this->assertNotNull($oSettings->getDocumentProtection()); - $oSettings->getDocumentProtection()->setEditing('trackedChanges'); $this->assertEquals('trackedChanges', $oSettings->getDocumentProtection()->getEditing()); } + /** + * Test setting an invalid salt + * @expectedException \InvalidArgumentException + */ + public function testInvalidSalt() + { + $protection = new Protection(); + $protection->setSalt('123'); + } + /** * TrackRevistions */ diff --git a/tests/PhpWord/Shared/Microsoft/PasswordEncoderTest.php b/tests/PhpWord/Shared/Microsoft/PasswordEncoderTest.php new file mode 100644 index 00000000..c42a6eb4 --- /dev/null +++ b/tests/PhpWord/Shared/Microsoft/PasswordEncoderTest.php @@ -0,0 +1,91 @@ +assertTrue($doc->elementExists($path, $file)); } + /** + * Test document protection with password + */ + public function testDocumentProtectionWithPassword() + { + $phpWord = new PhpWord(); + $phpWord->getSettings()->getDocumentProtection()->setEditing('readOnly'); + $phpWord->getSettings()->getDocumentProtection()->setPassword('testÄö@€!$&'); + $phpWord->getSettings()->getDocumentProtection()->setSalt(base64_decode('uq81pJRRGFIY5U+E9gt8tA==')); + $phpWord->getSettings()->getDocumentProtection()->setAlgorithm(PasswordEncoder::ALGORITHM_MD2); + $phpWord->getSettings()->getDocumentProtection()->setSpinCount(10); + + $doc = TestHelperDOCX::getDocument($phpWord); + + $file = 'word/settings.xml'; + + $path = '/w:settings/w:documentProtection'; + $this->assertTrue($doc->elementExists($path, $file)); + $this->assertEquals('rUuJbk6LuN2/qFyp7IUPQA==', $doc->getElement($path, $file)->getAttribute('w:hash')); + $this->assertEquals('1', $doc->getElement($path, $file)->getAttribute('w:cryptAlgorithmSid')); + $this->assertEquals('10', $doc->getElement($path, $file)->getAttribute('w:cryptSpinCount')); + } + /** * Test compatibility */