Decryption for aes256-cbc
authorChristian Weiske <[email protected]>
Thu, 30 Dec 2021 22:09:36 +0000 (23:09 +0100)
committerChristian Weiske <[email protected]>
Thu, 30 Dec 2021 22:09:36 +0000 (23:09 +0100)
.gitignore
README.rst
src/Cli.php
src/Command/Decrypt.php [new file with mode: 0644]
src/Command/ListContent.php
src/Crypt.php [new file with mode: 0644]
src/Logger.php
src/ZipWrapper.php [new file with mode: 0644]

index 57872d0f1e5f46731396e93c4e22b149809798f8..df2c4a821e966aa847cf27bf9cd505f70c418d5f 100644 (file)
@@ -1 +1,2 @@
+/epubs/
 /vendor/
index 6670f7d6e88af23828b6544b12675ed5985d0927..b4454475b2b2f4023a397327a5d0fa2ef118649c 100644 (file)
@@ -1,3 +1,17 @@
 ****************************************************
 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
index 916ebd17d6835b64e01cfa5b8194b0fb8e146ee2..4870468fba797c2321ce04800a7808040e277959 100644 (file)
@@ -17,9 +17,15 @@ class Cli
         }
 
         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:
@@ -34,7 +40,7 @@ class Cli
             $cmd->logger = $logger;
             $cmd->run();
         } catch (\Exception $e) {
-            fwrite(STDERR, $e->getMessage() . "\n");
+            fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n");
             exit(2);
         }
     }
@@ -69,10 +75,69 @@ class Cli
             ]
         );
         $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;
     }
 }
diff --git a/src/Command/Decrypt.php b/src/Command/Decrypt.php
new file mode 100644 (file)
index 0000000..3c950bd
--- /dev/null
@@ -0,0 +1,164 @@
+<?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.'
+            );
+        }
+    }
+}
index 44a10ebc3b44856c561f6566906d5249f76c57e8..d8f0ff9884c296d1d117ac8cc3431dc26bc991ad 100644 (file)
@@ -1,6 +1,8 @@
 <?php
 namespace Epubpypt\Command;
 
+use Epubpypt\ZipWrapper;
+
 /**
  * List the contents of the .epub file
  */
@@ -9,7 +11,7 @@ class ListContent
     /**
      * Path of the .epub file
      */
-    protected string $file;
+    protected string $epubFile;
 
     /**
      * Command line options
@@ -18,34 +20,17 @@ class ListContent
 
     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');
@@ -53,10 +38,9 @@ class ListContent
             $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";
         }
     }
 
diff --git a/src/Crypt.php b/src/Crypt.php
new file mode 100644 (file)
index 0000000..d37d314
--- /dev/null
@@ -0,0 +1,41 @@
+<?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;
+    }
+}
index 25cae8a29fa1f0260b93ee3e797b1af112207ec5..8226b55f8ae4685d3d5d588f0984164ec2cc1946 100644 (file)
@@ -10,6 +10,14 @@ class Logger
 {
     public bool $showInfo = false;
 
+    /**
+     * An error occured
+     */
+    public function error(string $msg): void
+    {
+        fwrite(STDERR, $msg . "\n");
+    }
+
     /**
      * Normal but significant events.
      */
diff --git a/src/ZipWrapper.php b/src/ZipWrapper.php
new file mode 100644 (file)
index 0000000..0438f62
--- /dev/null
@@ -0,0 +1,115 @@
+<?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();
+            }
+        }
+    }
+}