Created
April 13, 2024 12:49
-
-
Save peterafh/55b21beb58a91c3e13a2005902ad207b to your computer and use it in GitHub Desktop.
OpenXML Office Files Encrypter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Helpers; | |
/** | |
* Office Open XML Encrypter | |
* | |
* Ported to PHP from xlsx-populate package Encryptor class, written in JavaScript: | |
* https://github.com/dtjohnson/xlsx-populate/blob/master/lib/Encryptor.js | |
* | |
* Inspired by Secure Spreadsheet: | |
* https://github.com/ankane/secure-spreadsheet | |
* | |
* OOXML uses the CFB file format with Agile Encryption. The details of the encryption are here: | |
* https://msdn.microsoft.com/en-us/library/dd950165(v=office.12).aspx (broken link) | |
* | |
* Helpful guidance also take from this Github project: | |
* https://github.com/nolze/ms-offcrypto-tool | |
* | |
* Additional info: | |
* https://interoperability.blob.core.windows.net/files/MS-OFFCRYPTO/%5bMS-OFFCRYPTO%5d.pdf | |
* https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b | |
* | |
* Microsoft Excel Workbook Encryption - The CLI Guy | |
* https://www.thecliguy.co.uk/2021/11/24/microsoft-excel-workbook-encryption/ | |
* | |
* Decrypting Open Office documents - Dev Blog | |
* http://www.lyquidity.com/devblog/?p=35 | |
* This class uses the PhpOffice OLE implementation to write the output CFB file. | |
*/ | |
use Exception; | |
use PhpOffice\PhpSpreadsheet\Shared\OLE; | |
use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS\File; | |
use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS\Root; | |
use XMLWriter; | |
class OpenXmlEncrypter | |
{ | |
const PACKAGE_ENCRYPTION_CHUNK_SIZE = 4096; | |
// First 8 bytes are the size of the stream | |
const PACKAGE_OFFSET = 8; | |
/** | |
* Encrypt the data with the password. | |
* | |
* @param string $data The raw OpenXML document data to encrypt | |
* @param string $password | |
* | |
* @return string The encrypted data in CFB file format | |
*/ | |
public function encrypt($data, $password) | |
{ | |
$blockKeys = static::getBlockKeys(); | |
// Generate a random key to use to encrypt the document. Excel uses 32 bytes. We'll use the password to encrypt this key. | |
// N.B. The number of bits needs to correspond to an algorithm available in crypto (e.g. aes-256-cbc). | |
$packageKey = static::getRandomBytes(32); | |
// Create the encryption info. We'll use this for all of the encryption operations and for building the encryption info XML entry | |
// All values are set to what Excel uses | |
$encryptionInfo = (object) [ | |
"package" => (object) [ | |
"cipherAlgorithm" => 'AES', | |
"cipherChaining" => 'ChainingModeCBC', | |
"saltValue" => static::getRandomBytes(16), | |
"hashAlgorithm" => 'SHA512', | |
"hashSize" => 64, | |
"blockSize" => 16, | |
"keyBits" => strlen($packageKey) * 8 | |
], | |
"key" => (object) [ | |
"cipherAlgorithm" => 'AES', | |
"cipherChaining" => 'ChainingModeCBC', | |
"saltValue" => static::getRandomBytes(16), | |
"hashAlgorithm" => 'SHA512', | |
"hashSize" => 64, | |
"blockSize" => 16, | |
"spinCount" => 100000, // The number of times to iterate on a hash of a password. It MUST NOT be greater than 10,000,000. | |
"keyBits" => 256 // The length of the key to generate from the password. Must be a multiple of 8. | |
]]; | |
// Package Encryption | |
$encryptedPackage = $this->cryptPackage( | |
true, | |
$encryptionInfo->package->cipherAlgorithm, | |
$encryptionInfo->package->cipherChaining, | |
$encryptionInfo->package->hashAlgorithm, | |
$encryptionInfo->package->blockSize, | |
$encryptionInfo->package->saltValue, | |
$packageKey, | |
$data | |
); | |
// Data Integrity | |
// Create the data integrity fields used by clients for integrity checks. | |
// First generate a random array of bytes to use in HMAC. The docs say to use the same length as the key salt, but Excel seems to use 64. | |
$hmacKey = static::getRandomBytes(64); | |
// Then create an initialization vector using the package encryption info and the appropriate block key. | |
$hmacKeyIV = $this->createIV($encryptionInfo->package->hashAlgorithm, $encryptionInfo->package->saltValue, $encryptionInfo->package->blockSize, $blockKeys->dataIntegrity->hmacKey); | |
// Use the package key and the IV to encrypt the HMAC key | |
$encryptedHmacKey = $this->crypt( | |
true, | |
$encryptionInfo->package->cipherAlgorithm, | |
$encryptionInfo->package->cipherChaining, | |
$packageKey, | |
$hmacKeyIV, | |
$hmacKey | |
); | |
// Now create the HMAC | |
$hmacValue = $this->hmac($encryptionInfo->package->hashAlgorithm, $hmacKey, $encryptedPackage); | |
// Next generate an initialization vector for encrypting the resulting HMAC value. | |
$hmacValueIV = $this->createIV( | |
$encryptionInfo->package->hashAlgorithm, | |
$encryptionInfo->package->saltValue, | |
$encryptionInfo->package->blockSize, | |
$blockKeys->dataIntegrity->hmacValue | |
); | |
// Now encrypt the value | |
$encryptedHmacValue = $this->crypt( | |
true, | |
$encryptionInfo->package->cipherAlgorithm, | |
$encryptionInfo->package->cipherChaining, | |
$packageKey, | |
$hmacValueIV, | |
$hmacValue | |
); | |
// Put the encrypted key and value on the encryption info | |
$encryptionInfo->dataIntegrity = (object) [ | |
"encryptedHmacKey" => $encryptedHmacKey, | |
"encryptedHmacValue" => $encryptedHmacValue | |
]; | |
// Key Encryption | |
// Convert the password to an encryption key | |
$key = $this->convertPasswordToKey( | |
$password, | |
$encryptionInfo->key->hashAlgorithm, | |
$encryptionInfo->key->saltValue, | |
$encryptionInfo->key->spinCount, | |
$encryptionInfo->key->keyBits, | |
$blockKeys->key | |
); | |
// Encrypt the package key with the the encryption key | |
$encryptionInfo->key->encryptedKeyValue = $this->crypt( | |
true, | |
$encryptionInfo->key->cipherAlgorithm, | |
$encryptionInfo->key->cipherChaining, | |
$key, | |
$encryptionInfo->key->saltValue, | |
$packageKey | |
); | |
// Verifier hash | |
// Create a random byte array for hashing | |
$verifierHashInput = static::getRandomBytes(16); | |
// Create an encryption key from the password for the input | |
$verifierHashInputKey = $this->convertPasswordToKey( | |
$password, | |
$encryptionInfo->key->hashAlgorithm, | |
$encryptionInfo->key->saltValue, | |
$encryptionInfo->key->spinCount, | |
$encryptionInfo->key->keyBits, | |
$blockKeys->verifierHash->input | |
); | |
// Use the key to encrypt the verifier input | |
$encryptionInfo->key->encryptedVerifierHashInput = $this->crypt( | |
true, | |
$encryptionInfo->key->cipherAlgorithm, | |
$encryptionInfo->key->cipherChaining, | |
$verifierHashInputKey, | |
$encryptionInfo->key->saltValue, | |
$verifierHashInput | |
); | |
// Create a hash of the input | |
$verifierHashValue = $this->hash($encryptionInfo->key->hashAlgorithm, $verifierHashInput); | |
// Create an encryption key from the password for the hash | |
$verifierHashValueKey = $this->convertPasswordToKey( | |
$password, | |
$encryptionInfo->key->hashAlgorithm, | |
$encryptionInfo->key->saltValue, | |
$encryptionInfo->key->spinCount, | |
$encryptionInfo->key->keyBits, | |
$blockKeys->verifierHash->value | |
); | |
// Use the key to encrypt the hash value | |
$encryptionInfo->key->encryptedVerifierHashValue = $this->crypt( | |
true, | |
$encryptionInfo->key->cipherAlgorithm, | |
$encryptionInfo->key->cipherChaining, | |
$verifierHashValueKey, | |
$encryptionInfo->key->saltValue, | |
$verifierHashValue | |
); | |
// Build the encryption info buffer | |
$encryptionInfoBuffer = $this->buildEncryptionInfo($encryptionInfo); | |
return $this->buildCompoundFile([ | |
'EncryptionInfo' => $encryptionInfoBuffer, | |
'EncryptedPackage' => $encryptedPackage | |
]); | |
} | |
/** | |
* Build [MS-CFB] Compound File Binary file | |
* @param array $files | |
* | |
* @return string Raw data | |
*/ | |
private function buildCompoundFile($files) | |
{ | |
// Create the streams | |
$streams = []; | |
foreach ($files as $key => $file) { | |
$stream = new File(OLE::ascToUcs($key)); | |
$stream->append($file); | |
$streams[] = $stream; | |
} | |
// Add streams to root OLE container | |
$time = time(); | |
$root = new Root($time, $time, $streams); | |
// Save the OLE container to temporary file | |
$fileHandle = fopen('php://temp', 'wb'); | |
if ($fileHandle === false) { | |
throw new Exception("Could not create temp file for writing."); | |
} | |
$root->save($fileHandle); | |
// Get file contents | |
rewind($fileHandle); | |
$output = stream_get_contents($fileHandle); | |
if (!fclose($fileHandle)) { | |
throw new Exception('Could not close file after writing.'); | |
} | |
return $output; | |
} | |
/** | |
* Build the encryption info XML/buffer | |
* | |
* @param stdClass $encryptionInfo The encryption info object | |
* | |
* @return string The buffer | |
*/ | |
public function buildEncryptionInfo($encryptionInfo) | |
{ | |
$xml = new XMLWriter; | |
$xml->openMemory(); | |
$xml->startDocument('1.0', 'UTF-8', 'yes'); | |
// <encryption | |
$xml->startElement('encryption'); | |
$xml->writeAttribute('xmlns', 'http://schemas.microsoft.com/office/2006/encryption'); | |
$xml->writeAttribute('xmlns:p', 'http://schemas.microsoft.com/office/2006/keyEncryptor/password'); | |
$xml->writeAttribute('xmlns:c', 'http://schemas.microsoft.com/office/2006/keyEncryptor/certificate'); | |
// <keyData> | |
$xml->startElement('keyData'); | |
$xml->writeAttribute('saltSize', strlen($encryptionInfo->package->saltValue)); | |
$xml->writeAttribute('blockSize', $encryptionInfo->package->blockSize); | |
$xml->writeAttribute('keyBits', $encryptionInfo->package->keyBits); | |
$xml->writeAttribute('hashSize', $encryptionInfo->package->hashSize); | |
$xml->writeAttribute('cipherAlgorithm', $encryptionInfo->package->cipherAlgorithm); | |
$xml->writeAttribute('cipherChaining', $encryptionInfo->package->cipherChaining); | |
$xml->writeAttribute('hashAlgorithm', $encryptionInfo->package->hashAlgorithm); | |
$xml->writeAttribute('saltValue', base64_encode($encryptionInfo->package->saltValue)); | |
$xml->endElement(); | |
// <dataIntegrity></dataIntegrity> | |
$xml->startElement('dataIntegrity'); | |
$xml->writeAttribute('encryptedHmacKey', base64_encode($encryptionInfo->dataIntegrity->encryptedHmacKey)); | |
$xml->writeAttribute('encryptedHmacValue', base64_encode($encryptionInfo->dataIntegrity->encryptedHmacValue)); | |
$xml->endElement(); | |
// <keyEncryptors> | |
$xml->startElement('keyEncryptors'); | |
// <keyEncryptor> | |
$xml->startElement('keyEncryptor'); | |
$xml->writeAttribute('uri', 'http://schemas.microsoft.com/office/2006/keyEncryptor/password'); | |
// <encryptedKey></encryptedKey> | |
$xml->startElement('p:encryptedKey'); | |
$xml->writeAttribute('spinCount', $encryptionInfo->key->spinCount); | |
$xml->writeAttribute('saltSize', strlen($encryptionInfo->key->saltValue)); | |
$xml->writeAttribute('blockSize', $encryptionInfo->key->blockSize); | |
$xml->writeAttribute('keyBits', $encryptionInfo->key->keyBits); | |
$xml->writeAttribute('hashSize', $encryptionInfo->key->hashSize); | |
$xml->writeAttribute('cipherAlgorithm', $encryptionInfo->key->cipherAlgorithm); | |
$xml->writeAttribute('cipherChaining', $encryptionInfo->key->cipherChaining); | |
$xml->writeAttribute('hashAlgorithm', $encryptionInfo->key->hashAlgorithm); | |
$xml->writeAttribute('saltValue', base64_encode($encryptionInfo->key->saltValue)); | |
$xml->writeAttribute('encryptedVerifierHashInput', base64_encode($encryptionInfo->key->encryptedVerifierHashInput)); | |
$xml->writeAttribute('encryptedVerifierHashValue', base64_encode($encryptionInfo->key->encryptedVerifierHashValue)); | |
$xml->writeAttribute('encryptedKeyValue', base64_encode($encryptionInfo->key->encryptedKeyValue)); | |
$xml->endElement(); | |
// </keyEncryptor> | |
$xml->endElement(); | |
// </keyEncryptors> | |
$xml->endElement(); | |
// </encryption> | |
$xml->endElement(); | |
$encryptionInfoXml = $xml->outputMemory(); | |
return static::getEncryptionInfoPrefix().$encryptionInfoXml; | |
} | |
public static function getEncryptionInfoPrefix() | |
{ | |
// First 4 bytes are the version number, second 4 bytes are reserved. | |
return static::bufferFrom([0x04, 0x00, 0x04, 0x00, 0x40, 0x00, 0x00, 0x00]); | |
} | |
public static function getBlockKeys() | |
{ | |
// Block keys used for encryption | |
return (object) [ | |
"dataIntegrity" => (object) [ | |
"hmacKey" => static::bufferFrom([0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6]), | |
"hmacValue" => static::bufferFrom([0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33]) | |
], | |
"key" => static::bufferFrom([0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]), | |
"verifierHash" => (object) [ | |
"input" => static::bufferFrom([0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79]), | |
"value" => static::bufferFrom([0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e]) | |
] | |
]; | |
} | |
/** | |
* Calculate a hash of the concatenated buffers with the given algorithm. | |
* | |
* @param string $algorithm The hash algorithm | |
* @param array[string] $buffers The buffers to concat and hash | |
* | |
* @return string Hash | |
*/ | |
public function hash($algorithm, ...$buffers) | |
{ | |
$algorithm = strtolower($algorithm); | |
if (!in_array($algorithm, openssl_get_md_methods())) { | |
throw new Exception("Hash algorithm '$algorithm' not supported."); | |
} | |
return openssl_digest(implode('', $buffers), $algorithm, true); | |
} | |
/** | |
* Calculate an HMAC of the concatenated buffers with the given algorithm and key. | |
* | |
* @param string $algorithm The algorithm | |
* @param string $key The key | |
* @param array[string] $buffers The buffer to concat and HMAC | |
* | |
* @return string The HMAC | |
*/ | |
public function hmac($algorithm, $key, ...$buffers) | |
{ | |
$algorithm = strtolower($algorithm); | |
if (!in_array($algorithm, hash_hmac_algos())) { | |
throw new Exception("HMAC algorithm '$algorithm' not supported."); | |
} | |
return hash_hmac($algorithm, implode('', $buffers), $key, true); | |
} | |
/** | |
* Encrypt/decrypt input. | |
* | |
* @param boolean $encrypt True to encrypt, false to decrypt | |
* @param string $cipherAlgorithm The cipher algorithm | |
* @param string $cipherChaining The cipher chaining mode | |
* @param string $key The encryption key | |
* @param string $iv The initialization vector | |
* @param string $data The input data | |
* | |
* @return string | |
*/ | |
public function crypt($encrypt, $cipherAlgorithm, $cipherChaining, $key, $iv, $data) | |
{ | |
$algorithm = strtolower($cipherAlgorithm).'-'.(strlen($key) * 8); | |
if ($cipherChaining === 'ChainingModeCBC') { | |
$algorithm .= '-cbc'; | |
} else { | |
throw new Exception("Unknown cipher chaining mode '$cipherChaining'."); | |
} | |
if ($encrypt) { | |
return openssl_encrypt($data, $algorithm, $key, OPENSSL_RAW_DATA|OPENSSL_ZERO_PADDING, $iv); | |
} | |
return openssl_decrypt($data, $algorithm, $key, OPENSSL_RAW_DATA|OPENSSL_ZERO_PADDING, $iv); | |
} | |
/** | |
* Encrypt/decrypt the package. | |
* | |
* @param boolean $encrypt True to encrypt, false to decrypt. | |
* @param string $cipherAlgorithm The cipher algorithm. | |
* @param string $cipherChaining The cipher chaining mode. | |
* @param string $hashAlgorithm The hash algorithm. | |
* @param int $blockSize The IV block size. | |
* @param string $saltValue The salt. | |
* @param string $key The encryption key. | |
* @param string $input The package input. | |
* | |
* @return string | |
*/ | |
public function cryptPackage($encrypt, $cipherAlgorithm, $cipherChaining, $hashAlgorithm, $blockSize, $saltValue, $key, $input) | |
{ | |
// The first 8 bytes is supposed to be the length, but it seems like it is really the length - 4... | |
$offset = $encrypt ? 0 : static::PACKAGE_OFFSET; | |
// The package is encoded in chunks. Encrypt/decrypt each and concat. | |
$chunks = str_split(substr($input, $offset), static::PACKAGE_ENCRYPTION_CHUNK_SIZE); | |
foreach ($chunks as $index => $inputChunk) { | |
// Pad the chunk if it is not an integer multiple of the block size | |
$remainder = strlen($inputChunk) % $blockSize; | |
if ($remainder) { | |
$inputChunk = $inputChunk.static::bufferAlloc($blockSize - $remainder); | |
} | |
// Create the initialization vector | |
// The block key is computed from the current index | |
$blockKey = $this->createUInt32LEBuffer($index); | |
$iv = $this->createIV($hashAlgorithm, $saltValue, $blockSize, $blockKey); | |
// Encrypt/decrypt the current chunk | |
$chunks[$index] = $this->crypt($encrypt, $cipherAlgorithm, $cipherChaining, $key, $iv, $inputChunk); | |
} | |
// Concat all of the output chunks. | |
$output = implode('', $chunks); | |
if ($encrypt) { | |
// Put the length of the package in the first 8 bytes | |
$output = $this->createUInt32LEBuffer(strlen($input), static::PACKAGE_OFFSET).$output; | |
} else { | |
// Truncate the buffer to the size in the prefix | |
$length = static::readUInt32LE($input, 0); | |
$output = substr($output, 0, $length); | |
} | |
return $output; | |
} | |
/** | |
* Convert a password into an encryption key. | |
* | |
* @param string $password The password. | |
* @param string $hashAlgorithm The hash algoritm. | |
* @param string $saltValue The salt value. | |
* @param int $spinCount The spin count. | |
* @param int $keyBits The length of the key in bits. | |
* @param string $blockKey The block key. | |
* | |
* @return string The encryption key | |
*/ | |
public function convertPasswordToKey($password, $hashAlgorithm, $saltValue, $spinCount, $keyBits, $blockKey) | |
{ | |
// Password must be in unicode buffer | |
$passwordBuffer = iconv('UTF-8', 'UTF-16LE', $password); | |
// Generate the initial hash | |
$key = $this->hash($hashAlgorithm, $saltValue, $passwordBuffer); | |
// Now regenerate until spin count | |
for ($i = 0; $i < $spinCount; $i++) { | |
$iterator = $this->createUInt32LEBuffer($i); | |
$key = $this->hash($hashAlgorithm, $iterator, $key); | |
} | |
// Now generate the final hash | |
$key = $this->hash($hashAlgorithm, $key, $blockKey); | |
// Truncate or pad as needed to get to length of keyBits | |
$keyLength = strlen($key); | |
$keyBytes = $keyBits / 8; | |
if ($keyLength < $keyBytes) { | |
$key = $key.static::bufferAlloc($keyBytes - $keyLength, 0x36); | |
} else if ($keyLength > $keyBytes) { | |
$key = substr($key, 0, $keyBytes); | |
} | |
return $key; | |
} | |
/** | |
* Create an initialization vector (IV). | |
* | |
* @param string $hashAlgorithm The hash algorithm. | |
* @param string $saltValue The salt value. | |
* @param int $blockSize The size of the IV. | |
* @param string|int $blockKey The block key or an int to convert to a buffer. | |
* @return string | |
*/ | |
public function createIV($hashAlgorithm, $saltValue, $blockSize, $blockKey) | |
{ | |
// Create the initialization vector by hashing the salt with the block key. | |
$iv = $this->hash($hashAlgorithm, $saltValue, $blockKey); | |
$ivLength = strlen($iv); | |
// Truncate or pad as needed to meet the block size. | |
if ($ivLength < $blockSize) { | |
$iv = $iv.static::bufferAlloc($blockSize - $ivLength); | |
} elseif ($ivLength > $blockSize) { | |
$iv = substr($iv, 0, $blockSize); | |
} | |
return $iv; | |
} | |
public static function getRandomBytes($length) | |
{ | |
return openssl_random_pseudo_bytes($length); | |
} | |
/** | |
* Create a buffer of an integer encoded as a uint32le. | |
* | |
* @param int $value The integer to encode | |
* @param int $bufferSize The output buffer size in bytes | |
* | |
* @return string | |
*/ | |
public function createUInt32LEBuffer($value, $bufferSize = 4) | |
{ | |
// V: unsigned long (always 32 bit, little endian byte order) | |
$encodedInt = pack('V*', $value); | |
if ($bufferSize > 4) { | |
return $encodedInt.static::bufferAlloc($bufferSize - 4); | |
} | |
return $encodedInt; | |
} | |
/** | |
* Read an unsigned 32-bit little-endian integer from buffer at offset position. | |
* | |
* @return int | |
*/ | |
public static function readUInt32LE($input, $offset = 0) | |
{ | |
return unpack('V', $input, $offset); | |
} | |
private static function bufferFrom(array $data, $type = 'c') | |
{ | |
return pack("$type*", ...$data); | |
} | |
private static function bufferAlloc($blockSize, $fill = 0x00, $type = 'c') | |
{ | |
return pack("$type*", ...array_fill(0, $blockSize, $fill)); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment