Skip to content

Commit ebdfee3

Browse files
committed
Utility to get an Office compatible hash of a password
1 parent 5aaa2f6 commit ebdfee3

File tree

2 files changed

+298
-0
lines changed

2 files changed

+298
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
<?php
2+
/**
3+
* This file is part of PHPWord - A pure PHP library for reading and writing
4+
* word processing documents.
5+
*
6+
* PHPWord is free software distributed under the terms of the GNU Lesser
7+
* General Public License version 3 as published by the Free Software Foundation.
8+
*
9+
* For the full copyright and license information, please read the LICENSE
10+
* file that was distributed with this source code. For the full list of
11+
* contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
12+
*
13+
* @see https://github.com/PHPOffice/PHPWord
14+
* @copyright 2010-2017 PHPWord contributors
15+
* @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3
16+
*/
17+
18+
namespace PhpOffice\Common\Microsoft;
19+
20+
/**
21+
* Password encoder for microsoft office applications
22+
*/
23+
class PasswordEncoder
24+
{
25+
private static $algorithmMapping = array(
26+
1 => 'md2',
27+
2 => 'md4',
28+
3 => 'md5',
29+
4 => 'sha1',
30+
5 => '', // 'mac' -> not possible with hash()
31+
6 => 'ripemd',
32+
7 => 'ripemd160',
33+
8 => '',
34+
9 => '', //'hmac' -> not possible with hash()
35+
10 => '',
36+
11 => '',
37+
12 => 'sha256',
38+
13 => 'sha384',
39+
14 => 'sha512',
40+
);
41+
42+
private static $initialCodeArray = array(
43+
0xE1F0,
44+
0x1D0F,
45+
0xCC9C,
46+
0x84C0,
47+
0x110C,
48+
0x0E10,
49+
0xF1CE,
50+
0x313E,
51+
0x1872,
52+
0xE139,
53+
0xD40F,
54+
0x84F9,
55+
0x280C,
56+
0xA96A,
57+
0x4EC3,
58+
);
59+
60+
private static $encryptionMatrix = array(
61+
array(0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09),
62+
array(0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF),
63+
array(0x4563, 0x8AC6, 0x05AD, 0x0B5A, 0x16B4, 0x2D68, 0x5AD0),
64+
array(0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40),
65+
array(0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5),
66+
array(0x6F45, 0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A),
67+
array(0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9),
68+
array(0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4, 0x7B68, 0xF6D0),
69+
array(0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC),
70+
array(0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10),
71+
array(0xAA51, 0x4483, 0x8906, 0x022D, 0x045A, 0x08B4, 0x1168),
72+
array(0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C),
73+
array(0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6, 0x1DAD),
74+
array(0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC),
75+
array(0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4),
76+
);
77+
78+
private static $passwordMaxLength = 15;
79+
80+
/**
81+
* Create a hashed password that MS Word will be able to work with
82+
* @see https://blogs.msdn.microsoft.com/vsod/2010/04/05/how-to-set-the-editing-restrictions-in-word-using-open-xml-sdk-2-0/
83+
*
84+
* @param string $password
85+
* @param number $algorithmSid
86+
* @param string $salt
87+
* @param number $spinCount
88+
* @return string
89+
*/
90+
public static function hashPassword($password, $algorithmSid = 4, $salt = null, $spinCount = 10000)
91+
{
92+
$origEncoding = mb_internal_encoding();
93+
mb_internal_encoding('UTF-8');
94+
95+
$password = mb_substr($password, 0, min(self::$passwordMaxLength, mb_strlen($password)));
96+
97+
// Get the single-byte values by iterating through the Unicode characters of the truncated password.
98+
// For each character, if the low byte is not equal to 0, take it. Otherwise, take the high byte.
99+
$passUtf8 = mb_convert_encoding($password, 'UCS-2LE', 'UTF-8');
100+
$byteChars = array();
101+
102+
for ($i = 0; $i < mb_strlen($password); $i++) {
103+
$byteChars[$i] = ord(substr($passUtf8, $i * 2, 1));
104+
105+
if ($byteChars[$i] == 0) {
106+
$byteChars[$i] = ord(substr($passUtf8, $i * 2 + 1, 1));
107+
}
108+
}
109+
110+
// build low-order word and hig-order word and combine them
111+
$combinedKey = self::buildCombinedKey($byteChars);
112+
// build reversed hexadecimal string
113+
$hex = str_pad(strtoupper(dechex($combinedKey & 0xFFFFFFFF)), 8, '0', \STR_PAD_LEFT);
114+
$reversedHex = $hex[6] . $hex[7] . $hex[4] . $hex[5] . $hex[2] . $hex[3] . $hex[0] . $hex[1];
115+
116+
$generatedKey = mb_convert_encoding($reversedHex, 'UCS-2LE', 'UTF-8');
117+
118+
// Implementation Notes List:
119+
// Word requires that the initial hash of the password with the salt not be considered in the count.
120+
// The initial hash of salt + key is not included in the iteration count.
121+
$algorithm = self::getAlgorithm($algorithmSid);
122+
$generatedKey = hash($algorithm, $salt . $generatedKey, true);
123+
124+
for ($i = 0; $i < $spinCount; $i++) {
125+
$generatedKey = hash($algorithm, $generatedKey . pack('CCCC', $i, $i >> 8, $i >> 16, $i >> 24), true);
126+
}
127+
$generatedKey = base64_encode($generatedKey);
128+
129+
mb_internal_encoding($origEncoding);
130+
131+
return $generatedKey;
132+
}
133+
134+
/**
135+
* Get algorithm from self::$algorithmMapping
136+
*
137+
* @param int $sid
138+
* @return string
139+
*/
140+
private static function getAlgorithm($sid)
141+
{
142+
$algorithm = self::$algorithmMapping[$sid];
143+
if ($algorithm == '') {
144+
$algorithm = 'sha1';
145+
}
146+
147+
return $algorithm;
148+
}
149+
150+
/**
151+
* Build combined key from low-order word and high-order word
152+
*
153+
* @param array $byteChars byte array representation of password
154+
* @return int
155+
*/
156+
private static function buildCombinedKey($byteChars)
157+
{
158+
// Compute the high-order word
159+
// Initialize from the initial code array (see above), depending on the passwords length.
160+
$highOrderWord = self::$initialCodeArray[count($byteChars) - 1];
161+
162+
// For each character in the password:
163+
// For every bit in the character, starting with the least significant and progressing to (but excluding)
164+
// the most significant, if the bit is set, XOR the key’s high-order word with the corresponding word from
165+
// the Encryption Matrix
166+
for ($i = 0; $i < count($byteChars); $i++) {
167+
$tmp = self::$passwordMaxLength - count($byteChars) + $i;
168+
$matrixRow = self::$encryptionMatrix[$tmp];
169+
for ($intBit = 0; $intBit < 7; $intBit++) {
170+
if (($byteChars[$i] & (0x0001 << $intBit)) != 0) {
171+
$highOrderWord = ($highOrderWord ^ $matrixRow[$intBit]);
172+
}
173+
}
174+
}
175+
176+
// Compute low-order word
177+
// Initialize with 0
178+
$lowOrderWord = 0;
179+
// For each character in the password, going backwards
180+
for ($i = count($byteChars) - 1; $i >= 0; $i--) {
181+
// low-order word = (((low-order word SHR 14) AND 0x0001) OR (low-order word SHL 1) AND 0x7FFF)) XOR character
182+
$lowOrderWord = (((($lowOrderWord >> 14) & 0x0001) | (($lowOrderWord << 1) & 0x7FFF)) ^ $byteChars[$i]);
183+
}
184+
// Lastly, low-order word = (((low-order word SHR 14) AND 0x0001) OR (low-order word SHL 1) AND 0x7FFF)) XOR strPassword length XOR 0xCE4B.
185+
$lowOrderWord = (((($lowOrderWord >> 14) & 0x0001) | (($lowOrderWord << 1) & 0x7FFF)) ^ count($byteChars) ^ 0xCE4B);
186+
187+
// Combine the Low and High Order Word
188+
return self::int32(($highOrderWord << 16) + $lowOrderWord);
189+
}
190+
191+
/**
192+
* Simulate behaviour of (signed) int32
193+
*
194+
* @codeCoverageIgnore
195+
* @param int $value
196+
* @return int
197+
*/
198+
private static function int32($value)
199+
{
200+
$value = ($value & 0xFFFFFFFF);
201+
202+
if ($value & 0x80000000) {
203+
$value = -((~$value & 0xFFFFFFFF) + 1);
204+
}
205+
206+
return $value;
207+
}
208+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
/**
3+
* This file is part of PHPWord - A pure PHP library for reading and writing
4+
* word processing documents.
5+
*
6+
* PHPWord is free software distributed under the terms of the GNU Lesser
7+
* General Public License version 3 as published by the Free Software Foundation.
8+
*
9+
* For the full copyright and license information, please read the LICENSE
10+
* file that was distributed with this source code. For the full list of
11+
* contributors, visit https://github.com/PHPOffice/PHPWord/contributors.
12+
*
13+
* @see https://github.com/PHPOffice/PHPWord
14+
* @copyright 2010-2017 PHPWord contributors
15+
* @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3
16+
*/
17+
18+
namespace PhpOffice\Common\Tests\Microsoft;
19+
20+
use PhpOffice\Common\Microsoft\PasswordEncoder;
21+
22+
/**
23+
* Test class for PhpOffice\Common\PasswordEncoder
24+
* @coversDefaultClass \PhpOffice\Common\PasswordEncoder
25+
*/
26+
class PasswordEncoderTest extends \PHPUnit_Framework_TestCase
27+
{
28+
/**
29+
* Test that a password can be hashed without specifying any additional parameters
30+
*/
31+
public function testEncodePassword()
32+
{
33+
//given
34+
$password = 'test';
35+
36+
//when
37+
$hashPassword = PasswordEncoder::hashPassword($password);
38+
39+
//then
40+
$this->assertEquals('M795/MAlmGU8RIsY9Q9uDLHC7bk=', $hashPassword);
41+
}
42+
43+
/**
44+
* Test that a password can be hashed with a custom salt
45+
*/
46+
public function testEncodePasswordWithSalt()
47+
{
48+
//given
49+
$password = 'test';
50+
$salt = base64_decode('uq81pJRRGFIY5U+E9gt8tA==');
51+
52+
//when
53+
$hashPassword = PasswordEncoder::hashPassword($password, 4, $salt);
54+
55+
//then
56+
$this->assertEquals('QiDOcpia1YzSVJPiKPwWebl9p/0=', $hashPassword);
57+
}
58+
59+
/**
60+
* Test that the encoder falls back on SHA-1 if a non supported algorithm is given
61+
*/
62+
public function testDafaultsToSha1IfUnsupportedAlgorithm()
63+
{
64+
//given
65+
$password = 'test';
66+
$salt = base64_decode('uq81pJRRGFIY5U+E9gt8tA==');
67+
68+
//when
69+
$hashPassword = PasswordEncoder::hashPassword($password, 5, $salt);
70+
71+
//then
72+
$this->assertEquals('QiDOcpia1YzSVJPiKPwWebl9p/0=', $hashPassword);
73+
}
74+
75+
/**
76+
* Test that the encoder falls back on SHA-1 if a non supported algorithm is given
77+
*/
78+
public function testEncodePasswordWithNullAsciiCodeInPassword()
79+
{
80+
//given
81+
$password = 'test' . chr(0);
82+
$salt = base64_decode('uq81pJRRGFIY5U+E9gt8tA==');
83+
84+
//when
85+
$hashPassword = PasswordEncoder::hashPassword($password, 5, $salt, 1);
86+
87+
//then
88+
$this->assertEquals('rDV9sgdDsztoCQlvRCb1lF2wxNg=', $hashPassword);
89+
}
90+
}

0 commit comments

Comments
 (0)