Skip to content

Instantly share code, notes, and snippets.

@peterafh
Created April 13, 2024 12:49
Show Gist options
  • Save peterafh/55b21beb58a91c3e13a2005902ad207b to your computer and use it in GitHub Desktop.
Save peterafh/55b21beb58a91c3e13a2005902ad207b to your computer and use it in GitHub Desktop.
OpenXML Office Files Encrypter
<?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