****************************************************
epubpypt - epub ebook encryption and decryption tool
****************************************************
+
+
+
+Desired usage
+=============
+Encryption/decryption by default modifies the .epub files and replaces
+the files inside.
+
+Decrypt files::
+
+ $ epubpypt decrypt --key HEXKEY --all ebook.epub
+ $ epubpypt decrypt --keyfile myenc.key --all ebook.epub
+ $ epubpypt decrypt --key HEXKEY ebook.epub zip/file/path1 zip/file/path2
+ $ epubpypt decrypt --key HEXKEY ebook.epub zip/file/path1 --stdout
}
switch ($result->command_name) {
+ case 'decrypt':
+ $cmd = new Command\Decrypt(
+ $result->command->args['epub'], $result->command->args['path'],
+ $result->command->options
+ );
+ break;
case 'ls':
$cmd = new Command\ListContent(
- $result->command->args['file'], $result->command->options
+ $result->command->args['epub'], $result->command->options
);
break;
default:
$cmd->logger = $logger;
$cmd->run();
} catch (\Exception $e) {
- fwrite(STDERR, $e->getMessage() . "\n");
+ fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n");
exit(2);
}
}
]
);
$ls->addArgument(
- 'file',
+ 'epub',
['description' => '.epub file to inspect']
);
+ $decrypt = $parser->addCommand(
+ 'decrypt',
+ [
+ 'description' => 'Decrypt encrypted files inside the epub file',
+ ]
+ );
+ $decrypt->addOption(
+ 'key',
+ [
+ 'short_name' => '-k',
+ 'long_name' => '--key',
+ 'description' => 'Hex-encoded encryption key',
+ 'action' => 'StoreString',
+ 'default' => null,
+ ]
+ );
+ $decrypt->addOption(
+ 'keyfile',
+ [
+ 'short_name' => '-k',
+ 'long_name' => '--keyfile',
+ 'description' => 'File containing encryption key',
+ 'action' => 'StoreString',
+ 'default' => null,
+ ]
+ );
+ $decrypt->addOption(
+ 'all',
+ [
+ 'short_name' => '-a',
+ 'long_name' => '--all',
+ 'description' => 'Decrypt all encrypted files',
+ 'action' => 'StoreTrue',
+ 'default' => false,
+ ]
+ );
+ $decrypt->addOption(
+ 'stdout',
+ [
+ 'short_name' => '-c',
+ 'long_name' => '--stdout',
+ 'description' => 'Send decrypted contents to stdout. Do not modify epub file.',
+ 'action' => 'StoreTrue',
+ 'default' => false,
+ ]
+ );
+ $decrypt->addArgument(
+ 'epub',
+ ['description' => '.epub file']
+ );
+ $decrypt->addArgument(
+ 'path',
+ [
+ 'description' => 'File paths inside the epub file',
+ 'optional' => true,
+ 'multiple' => true,
+ ]
+ );
+
return $parser;
}
}
--- /dev/null
+<?php
+namespace Epubpypt\Command;
+
+use Epubpypt\Crypt;
+use Epubpypt\ZipWrapper;
+
+/**
+ * Decrypt files inside the epub file
+ */
+class Decrypt
+{
+ /**
+ * Path of the .epub file
+ */
+ protected string $epubFile;
+
+ /**
+ * Paths of files to decrypt
+ */
+ protected array $paths;
+
+ /**
+ * Command line options
+ */
+ protected array $options = [];
+
+ /**
+ * Binary encryption key
+ */
+ protected ?string $encryptionKey = null;
+
+ public function __construct(string $epubFile, array $paths, array $options)
+ {
+ $this->epubFile = $epubFile;
+ $this->paths = $paths;
+ $this->options = $options;
+ }
+
+ public function run(): void
+ {
+ $this->validateOptions();
+ $this->loadEncryptionKey();
+
+ $zw = new ZipWrapper($this->epubFile, false);
+
+ $encryptionXml = $zw->getFromName('META-INF/encryption.xml');
+ if ($encryptionXml === false) {
+ $this->logger->info('No encryption information file found');
+ return;
+ }
+ $sxEnc = simplexml_load_string($encryptionXml);
+
+ $decryptedFiles = [];
+ $encInfoMap = $this->loadEncryptionInfo($zw, $sxEnc);
+ foreach ($encInfoMap as $filePath => $algoUrl) {
+ $content = $zw->getFromName($filePath);
+ if ($algoUrl == 'http://www.w3.org/2001/04/xmlenc#aes256-cbc') {
+ $this->logger->info('Decrypting ' . $filePath);
+ $decryptedContent = Crypt::decryptAES256CBC(
+ $this->encryptionKey,
+ $content
+ );
+
+ } else {
+ $this->logger->error('Unsupported encryption algorithm: ' . $algoUrl);
+ continue;
+ }
+
+ if ($decryptedContent === null) {
+ $this->logger->error('Decryption failed for ' . $filePath);
+ continue;
+ }
+
+ $decryptedFiles[] = $filePath;
+ $zw->addFromString($filePath, $decryptedContent);
+ }
+
+ $this->removeDecryptedFromEncryptionXml($sxEnc, $decryptedFiles);
+ $zw->addFromString('META-INF/encryption.xml', $sxEnc->asXML());
+
+ if (!$zw->close()) {
+ $this->logger->error('Error while writing zip file contents');
+ }
+ }
+
+ protected function loadEncryptionKey(): void
+ {
+ if ($this->options['key'] !== null) {
+ $this->encryptionKey = @hex2bin($this->options['key']);
+ if ($this->encryptionKey == '') {
+ throw new \InvalidArgumentException('Invalid encryption key');
+ }
+
+ } else {
+ if (!is_readable($this->options['keyfile'])) {
+ throw new \InvalidArgumentException('Cannot open key file');
+ }
+ $this->encryptionKey = file_get_contents($this->options['keyfile']);
+ }
+ }
+
+ /**
+ * @return array Key is the file path, value the encryption algorithm URL.
+ */
+ protected function loadEncryptionInfo(ZipWrapper $zw, \SimpleXMLElement $sxEnc): array
+ {
+ $encInfoMap = [];
+ $invPaths = array_flip($this->paths);
+ foreach ($sxEnc as $sxEncData) {
+ $filePath = (string) $sxEncData->CipherData->CipherReference['URI'];
+ $algoUrl = (string) $sxEncData->EncryptionMethod['Algorithm'];
+ if ($this->options['all'] || isset($invPaths[$filePath])) {
+ $encInfoMap[$filePath] = $algoUrl;
+ unset($invPaths[$filePath]);
+ }
+ }
+
+ if (!$this->options['all'] && count($invPaths)) {
+ foreach ($invPaths as $filePath => $dummy) {
+ $this->logger->notice('File is not encrypted: ' . $filePath);
+ }
+ }
+
+ return $encInfoMap;
+ }
+
+ protected function removeDecryptedFromEncryptionXml(
+ \SimpleXMLElement $sxEnc, $decryptedFiles
+ ): void {
+ $decryptedFiles = array_flip($decryptedFiles);
+ $total = count($sxEnc->EncryptedData);
+ for ($n = $total - 1; $n >= 0; $n--) {
+ $filePath = (string) $sxEnc->EncryptedData[$n]->CipherData->CipherReference['URI'];
+ if (isset($decryptedFiles[$filePath])) {
+ unset($sxEnc->EncryptedData[$n]);
+ }
+ }
+ }
+
+ protected function validateOptions(): void
+ {
+ if ($this->options['all'] && count($this->paths)) {
+ throw new \DomainException(
+ 'Do not specify file paths with using option "--all".'
+ );
+ }
+
+ if ($this->options['key'] !== null
+ && $this->options['keyfile'] !== null
+ ) {
+ throw new \DomainException(
+ 'Specify either --key or --keyfile option, but not both.'
+ );
+ }
+
+ if ($this->options['key'] === null
+ && $this->options['keyfile'] === null
+ ) {
+ throw new \DomainException(
+ 'Specify one of --key or --keyfile options.'
+ );
+ }
+ }
+}
<?php
namespace Epubpypt\Command;
+use Epubpypt\ZipWrapper;
+
/**
* List the contents of the .epub file
*/
/**
* Path of the .epub file
*/
- protected string $file;
+ protected string $epubFile;
/**
* Command line options
public \Epubpypt\Logger $logger;
- public function __construct(string $file, array $options)
+ public function __construct(string $epubFile, array $options)
{
- $this->file = $file;
- $this->options = $options;
+ $this->epubFile = $epubFile;
+ $this->options = $options;
}
public function run(): void
{
- $zip = new \ZipArchive();
- $res = $zip->open($this->file, \ZipArchive::RDONLY);
- if ($res !== true) {
- $errorMap = [
- \ZipArchive::ER_EXISTS => 'File already exists',
- \ZipArchive::ER_INCONS => 'Zip archive inconsistent',
- \ZipArchive::ER_INVAL => 'Invalid argument',
- \ZipArchive::ER_MEMORY => 'Malloc failure',
- \ZipArchive::ER_NOENT => 'No such file',
- \ZipArchive::ER_NOZIP => 'Not a zip archive',
- \ZipArchive::ER_OPEN => 'Can\'t open file',
- \ZipArchive::ER_READ => 'Read error',
- \ZipArchive::ER_SEEK => 'Seek error',
- ];
- throw new \Exception(
- 'Error opening .epub file: ' . $errorMap[$res]
- );
- }
+ $zw = new ZipWrapper($this->epubFile, true);
- $encXml = $zip->getFromName('META-INF/encryption.xml');
+ $encXml = $zw->getFromName('META-INF/encryption.xml');
$encInfoMap = [];
if ($encXml === false) {
$this->logger->info('No encryption information file found');
$encInfoMap = $this->loadEncyptionInfo($encXml);
}
- for ($n = 0; $n < $zip->numFiles; $n++) {
- $info = $zip->statIndex($n);
- echo str_pad($encInfoMap[$info['name']] ?? '', 8);
- echo ' ' . $info['name'] . "\n";
+ foreach ($zw->filesIterator() as $filePath) {
+ echo str_pad($encInfoMap[$filePath] ?? '', 8);
+ echo ' ' . $filePath . "\n";
}
}
--- /dev/null
+<?php
+namespace Epubpypt;
+
+/**
+ * Encryption and decryption
+ */
+class Crypt
+{
+ /**
+ * https://www.w3.org/TR/xmlenc-core1/#sec-AES
+ *
+ * [AES] is used in the Cipher Block Chaining (CBC) mode with a 128 bit
+ * initialization vector (IV).
+ * The resulting cipher text is prefixed by the IV.
+ * If included in XML output, it is then base64 encoded.
+ *
+ * @param string $keyBytes Binary key
+ * @param string $encryptedBytes Binary data
+ *
+ * @return string|null NULL when decryption failed
+ */
+ public static function decryptAES256CBC($keyBytes, $encryptedBytes)
+ {
+ $initializationVector = substr($encryptedBytes, 0, 128 / 8);
+ $encryptedData = substr($encryptedBytes, 128 / 8);
+
+ $decrypted = openssl_decrypt(
+ $encryptedData, 'AES-256-CBC',
+ $keyBytes,
+ OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING,
+ $initializationVector
+ );
+ if ($decrypted === false) {
+ return null;
+ }
+ $padLength = unpack('C', substr($decrypted, -1))[1];
+ $decrypted = substr($decrypted, 0, -$padLength);
+
+ return $decrypted;
+ }
+}
{
public bool $showInfo = false;
+ /**
+ * An error occured
+ */
+ public function error(string $msg): void
+ {
+ fwrite(STDERR, $msg . "\n");
+ }
+
/**
* Normal but significant events.
*/
--- /dev/null
+<?php
+namespace Epubpypt;
+
+/**
+ * .zip file wrapper that works with directories (unzipped files)
+ */
+class ZipWrapper
+{
+ /**
+ * Path to .zip file (null when directory)
+ */
+ protected $zipPath;
+
+ /**
+ * Directory path with trailing slash (null when zip)
+ */
+ protected $dirPath;
+
+ /**
+ * @var ZipArchive
+ */
+ protected $zip;
+
+ public function __construct($path, $readOnly = true)
+ {
+ if (is_dir($path)) {
+ $this->dirPath = rtrim($path, '/') . '/';
+ } else {
+ $this->zipPath = $path;
+ $this->zip = new \ZipArchive();
+ $res = $this->zip->open(
+ $this->zipPath,
+ $readOnly ? \ZipArchive::RDONLY : 0
+ );
+ if ($res !== true) {
+ $errorMap = [
+ \ZipArchive::ER_EXISTS => 'File already exists',
+ \ZipArchive::ER_INCONS => 'Zip archive inconsistent',
+ \ZipArchive::ER_INVAL => 'Invalid argument',
+ \ZipArchive::ER_MEMORY => 'Malloc failure',
+ \ZipArchive::ER_NOENT => 'No such file',
+ \ZipArchive::ER_NOZIP => 'Not a zip archive',
+ \ZipArchive::ER_OPEN => 'Can\'t open file',
+ \ZipArchive::ER_READ => 'Read error',
+ \ZipArchive::ER_SEEK => 'Seek error',
+ ];
+ throw new \Exception(
+ 'Error opening .epub file: ' . $errorMap[$res]
+ );
+ }
+ }
+ }
+
+ /**
+ * (Over)write content of single file inside the zip archive
+ */
+ public function addFromString($filePath, $content): bool
+ {
+ if ($this->zip) {
+ return $this->zip->addFromString($filePath, $content);
+ } else {
+ return file_put_contents($this->dirPath . $filePath, $content) !== false;
+ }
+ }
+
+ /**
+ * Write ZIP archive contents
+ */
+ public function close(): bool
+ {
+ if ($this->zip) {
+ return $this->zip->close();
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Get content of given file path
+ */
+ public function getFromName($filePath): string
+ {
+ if ($this->zip) {
+ return $this->zip->getFromName($filePath);
+ } else {
+ return file_get_contents($this->dirPath . $filePath);
+ }
+ }
+
+ /**
+ * foreach() over all files
+ *
+ * @return array|Iterator
+ */
+ public function filesIterator()
+ {
+ if ($this->zip) {
+ for ($n = 0; $n < $this->zip->numFiles; $n++) {
+ $info = $this->zip->statIndex($n);
+ yield $info['name'];
+ }
+ } else {
+ $it = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($this->dirPath)
+ );
+ $it->rewind();
+ while($it->valid()) {
+ if ($it->isFile()) {
+ yield substr($it->getPathname(), strlen($this->dirPath));
+ }
+ $it->next();
+ }
+ }
+ }
+}