Obfuscation command master
authorChristian Weiske <cweiske@cweiske.de>
Sun, 30 Jan 2022 21:04:19 +0000 (22:04 +0100)
committerChristian Weiske <cweiske@cweiske.de>
Sun, 30 Jan 2022 21:04:19 +0000 (22:04 +0100)
README.rst
src/Cli.php
src/Command/Obfuscate.php [new file with mode: 0644]
src/Crypt.php
src/Logger.php
src/ZipWrapper.php

index acbf2e458e30fbbd74219e7a131da8b141426136..5076485c10c77ae169deb939ea25cf26ef045c09 100644 (file)
@@ -21,3 +21,10 @@ Deobfuscate files::
   $ epubpypt deobfuscate --all ebook.epub
   $ epubpypt deobfuscate --key HEXKEY ebook.epub zip/file/path1 zip/file/path2
   $ epubpypt deobfuscate ebook.epub zip/file/path1 --stdout
+
+Obfuscate files::
+
+  $ epubpypt obfuscate --allfonts ebook.epub
+  $ epubpypt obfuscate ebook.epub zip/file/path1 zip/file/path2
+  $ epubpypt obfuscate --key HEXKEY ebook.epub zip/file/path1 zip/file/path2
+  $ epubpypt obfuscate --key HEXKEY ebook.epub zip/file/path1 --stdout
index 6f3c758b03cb516e2c4aeff21fb8aedf82b9c435..f18a60cb3d869b0b50116ef036ed263d3d91c1af 100644 (file)
@@ -37,6 +37,13 @@ class Cli
             );
             break;
 
+        case 'obfuscate':
+            $cmd = new Command\Obfuscate(
+                $result->command->args['epub'], $result->command->args['path'],
+                $result->command->options
+            );
+            break;
+
         default:
             $parser->displayError('Command name missing');
             exit(1);
@@ -168,7 +175,7 @@ class Cli
             [
                 'short_name'  => '-k',
                 'long_name'   => '--keyfile',
-                'description' => 'File containing obfuscatino key',
+                'description' => 'File containing obfuscation key',
                 'action'      => 'StoreString',
                 'default'     => null,
             ]
@@ -206,6 +213,65 @@ class Cli
             ]
         );
 
+        $obfuscate = $parser->addCommand(
+            'obfuscate',
+            [
+                'description' => 'Obfuscate files inside the epub file',
+            ]
+        );
+        $obfuscate->addOption(
+            'key',
+            [
+                'short_name'  => '-k',
+                'long_name'   => '--key',
+                'description' => 'Hex-encoded obfuscation key',
+                'action'      => 'StoreString',
+                'default'     => null,
+            ]
+        );
+        $obfuscate->addOption(
+            'keyfile',
+            [
+                'short_name'  => '-k',
+                'long_name'   => '--keyfile',
+                'description' => 'File containing obfuscation key',
+                'action'      => 'StoreString',
+                'default'     => null,
+            ]
+        );
+        $obfuscate->addOption(
+            'allfonts',
+            [
+                'short_name'  => '-f',
+                'long_name'   => '--allfonts',
+                'description' => 'Obfuscate all font files',
+                'action'      => 'StoreTrue',
+                'default'     => false,
+            ]
+        );
+        $obfuscate->addOption(
+            'stdout',
+            [
+                'short_name'  => '-c',
+                'long_name'   => '--stdout',
+                'description' => 'Send obfuscated contents to stdout. Do not modify epub file.',
+                'action'      => 'StoreTrue',
+                'default'     => false,
+            ]
+        );
+        $obfuscate->addArgument(
+            'epub',
+            ['description' => '.epub file']
+        );
+        $obfuscate->addArgument(
+            'path',
+            [
+                'description' => 'File paths inside the epub file',
+                'optional'    => true,
+                'multiple'    => true,
+            ]
+        );
+
         return $parser;
     }
 }
diff --git a/src/Command/Obfuscate.php b/src/Command/Obfuscate.php
new file mode 100644 (file)
index 0000000..89aa9d8
--- /dev/null
@@ -0,0 +1,319 @@
+<?php
+namespace Epubpypt\Command;
+
+use Epubpypt\Crypt;
+use Epubpypt\ZipWrapper;
+
+/**
+ * Obfuscate files inside the epub file.
+ *
+ * Used for font files so that they cannot simply be copied from the epub zip
+ * file and used on the computer.
+ *
+ * Uses the http://www.idpf.org/2008/embedding algorithm
+ *
+ * @link http://idpf.org/epub/20/spec/FontManglingSpec_2.0.1_draft.htm
+ */
+class Obfuscate
+{
+    /**
+     * Path of the .epub file
+     */
+    protected string $epubFile;
+
+    /**
+     * Paths of files to obfuscate
+     */
+    protected array $paths;
+
+    /**
+     * Command line options
+     */
+    protected array $options = [];
+
+    /**
+     * Binary obfuscation key
+     */
+    protected ?string $obfuscationKey = null;
+
+    /**
+     * Package document path extracted from META-INF/container.xml
+     */
+    protected ?string $packagePath = null;
+
+    /**
+     * Files that may not be encrypted/obfuscated
+     */
+    protected static $doNotEncrypt = [
+        'mimetype' => true,
+        'META-INF/container.xml' => true,
+        'META-INF/encryption.xml' => true,
+        'META-INF/manifest.xml' => true,
+        'META-INF/metadata.xml' => true,
+        'META-INF/rights.xml' => true,
+        'META-INF/signatures.xml' => true,
+        /*Package document*/
+    ];
+
+
+    public function __construct(string $epubFile, array $paths, array $options)
+    {
+        $this->epubFile = $epubFile;
+        $this->paths    = $paths;
+        $this->options  = $options;
+    }
+
+    public function run(): void
+    {
+        $this->validateOptions();
+
+        $zw = new ZipWrapper($this->epubFile, $this->options['stdout']);
+        $this->loadPackagePath($zw);
+        $this->loadObfuscationKey($zw);
+
+        if ($this->options['allfonts']) {
+            $this->loadAllFonts($zw);
+        }
+        $this->validatePaths();
+
+        $obfuscatedFiles = [];
+        $encryptionXml = $zw->getFromName('META-INF/encryption.xml');
+        if ($encryptionXml === false) {
+            $encInfoMap = [];
+            $sxEnc = null;
+        } else {
+            $sxEnc = simplexml_load_string($encryptionXml);
+            $encStatusMap = $this->loadEncryptionStatus($zw, $sxEnc);
+        }
+        foreach ($this->paths as $filePath) {
+            $this->logger->info('Obfuscating ' . $filePath);
+            if (isset($encStatusMap[$filePath])) {
+                $this->logger->warning('File already encrypted/obfuscated, skipping: ' . $filePath);
+                continue;
+            }
+            $content = $zw->getFromName($filePath);
+            if ($content === false) {
+                $this->logger->error('Could not read contents of ' . $filePath);
+                return;
+            }
+
+            $compress = $this->shouldBeCompressed($filePath);
+            $origSize = strlen($content);
+            if ($compress)  {
+                $content = @gzdeflate($content);
+                if ($content === false) {
+                    $this->logger->error('Compression failed for ' . $filePath);
+                    return;
+                }
+            }
+
+            $obfuscatedContent = Crypt::obfuscate(
+                $this->obfuscationKey,
+                $content
+            );
+            if ($obfuscatedContent === null) {
+                $this->logger->error('Obfuscation failed for ' . $filePath);
+                return;
+            }
+
+            if ($this->options['stdout']) {
+                echo $obfuscatedContent;
+                $zw->close();
+                return;
+            }
+
+            $obfuscatedFiles[$filePath] = $compress ? $origSize : 0;
+            $zw->addFromString($filePath, $obfuscatedContent);
+        }
+
+        if (count($obfuscatedFiles)) {
+            if ($sxEnc === null) {
+                $sxEnc = simplexml_load_string(
+                    <<<XML
+<?xml version="1.0" encoding="utf-8"?>
+<encryption xmlns ="urn:oasis:names:tc:opendocument:xmlns:container">
+</encryption>
+XML
+                );
+            }
+            $this->addObfuscatedToEncryptionXml($sxEnc, $obfuscatedFiles);
+            $zw->addFromString('META-INF/encryption.xml', $sxEnc->asXML());
+        }
+
+        if (!$zw->close()) {
+            $this->logger->error('Error while writing zip file contents');
+        }
+    }
+
+    /**
+     * Fill $paths with all font file paths
+     *
+     * @link https://www.w3.org/publishing/epub3/epub-contentdocs.html#confreq-css-rs-fonts
+     */
+    protected function loadAllFonts(ZipWrapper $zw): void
+    {
+        foreach ($zw->filesIterator() as $filePath) {
+            $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
+            switch ($extension) {
+            case 'otf':
+            case 'ttf':
+            case 'woff':
+            case 'woff2':
+                $this->paths[] = $filePath;
+            }
+        }
+    }
+
+    protected function loadObfuscationKey(ZipWrapper $zw): void
+    {
+        if ($this->options['key'] !== null) {
+            $this->obfuscationKey = @hex2bin($this->options['key']);
+            if ($this->obfuscationKey == '') {
+                throw new \InvalidArgumentException('Invalid obfuscation key');
+            }
+            return;
+        }
+
+        if ($this->options['keyfile'] !== null) {
+            if (!is_readable($this->options['keyfile'])) {
+                throw new \InvalidArgumentException('Cannot open key file');
+            }
+            $this->obfuscationKey = file_get_contents($this->options['keyfile']);
+            return;
+
+        }
+
+        //load key from epub file
+        $packageXml = $zw->getFromName($this->packagePath);
+        $sxPackage = simplexml_load_string($packageXml);
+
+        $uniqueidId = (string) $sxPackage['unique-identifier'];
+        $uniqueid = (string) $sxPackage->xpath('//*[@id="' . $uniqueidId . '"]')[0];
+        $uniqueid = str_replace(" \r\n\t", '', $uniqueid);
+        if ($uniqueid === '') {
+            throw new \UnexpectedValueException('Unique identifier is empty');
+        }
+
+        $this->obfuscationKey = sha1($uniqueid, true);
+    }
+
+    protected function loadPackagePath(ZipWrapper $zw): void
+    {
+        $containerXml = $zw->getFromName('META-INF/container.xml');
+        $sxCont = simplexml_load_string($containerXml);
+        $this->packagePath = (string) $sxCont->rootfiles->rootfile[0]['full-path'];
+
+        static::$doNotEncrypt[$this->packagePath] = true;
+    }
+
+    /**
+     * Load array of files that appear in encryption.xml
+     *
+     * @return array Key is the file path, value is true.
+     */
+    protected function loadEncryptionStatus(ZipWrapper $zw, \SimpleXMLElement $sxEnc): array
+    {
+        $encStatusMap = [];
+        $invPaths = array_flip($this->paths);
+        foreach ($sxEnc as $sxEncData) {
+            $filePath = (string) $sxEncData->CipherData->CipherReference['URI'];
+            if (isset($invPaths[$filePath])) {
+                $encStatusMap[$filePath] = true;
+                unset($invPaths[$filePath]);
+            }
+        }
+
+        return $encStatusMap;
+    }
+
+    protected function addObfuscatedToEncryptionXml(
+        \SimpleXMLElement $sxEnc, $obfuscatedFiles
+    ): void {
+        $nsXmlEnc = 'http://www.w3.org/2001/04/xmlenc#';
+        $nsComp   = 'http://www.idpf.org/2016/encryption#compression';
+
+        foreach ($obfuscatedFiles as $filePath => $originalSize) {
+            $sxED = $sxEnc->addChild('EncryptedData', null, $nsXmlEnc);
+            $sxEM = $sxED->addChild('EncryptionMethod', null, $nsXmlEnc);
+            $sxEM['Algorithm'] = 'http://www.idpf.org/2008/embedding';
+
+            $sxCD = $sxED->addChild('CipherData', null, $nsXmlEnc);
+            $sxCR = $sxCD->addChild('CipherReference', null, $nsXmlEnc);
+            $sxCR['URI'] = $filePath;
+
+            if ($originalSize > 0) {
+                $sxEPs = $sxED->addChild('EncryptionProperties', null, $nsXmlEnc);
+                $sxEP = $sxEPs->addChild('EncryptionProperty', null, $nsXmlEnc);
+                $sxC = $sxEP->addChild('Compression', null, $nsComp);
+                $sxC['Method'] = 8;//deflate
+                $sxC['OriginalLength'] = $originalSize;
+            }
+        }
+    }
+
+    /**
+     * Detect if the given file should be compressed before obfuscation.
+     *
+     * Only plain text files should be compressed, but not binary files
+     * like images or videos
+     *
+     * @link https://www.w3.org/publishing/epub3/epub-ocf.html#sec-enc-compression
+     *
+     * @param string $filePath Path of file to check
+     */
+    protected function shouldBeCompressed($filePath): bool
+    {
+        $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
+        switch ($extension) {
+        case 'css':
+        case 'html':
+        case 'js':
+        case 'xhtml':
+            return true;
+        }
+        return false;
+    }
+
+    protected function validateOptions(): void
+    {
+        if ($this->options['allfonts'] && count($this->paths)) {
+            throw new \DomainException(
+                'Do not specify file paths with using option "--allfonts".'
+            );
+        }
+
+        if (!$this->options['allfonts'] && !count($this->paths)) {
+            throw new \DomainException(
+                'Use --allfonts or specify at least one file to obfuscate.'
+            );
+        }
+
+        if ($this->options['allfonts'] && $this->options['stdout']) {
+            throw new \DomainException(
+                '--allfonts cannot be used with --stdout.'
+            );
+        }
+
+        if ($this->options['key'] !== null
+            && $this->options['keyfile'] !== null
+        ) {
+            throw new \DomainException(
+                'Specify either --key or --keyfile option, but not both.'
+            );
+        }
+    }
+
+    protected function validatePaths()
+    {
+        if (!count($this->paths)) {
+            return;
+        }
+        foreach ($this->paths as $filePath) {
+            if (isset(static::$doNotEncrypt[$filePath])) {
+                throw new \DomainException(
+                    'File must not be obfuscated: ' . $filePath
+                );
+            }
+        }
+    }
+}
index d000257be17f6e25f1a4fb9400915a38b4c649b6..eb4800c85ce8ef742ea78904aa5e0e7e1bec2b1d 100644 (file)
@@ -50,7 +50,7 @@ class Crypt
      *
      * @link http://idpf.org/epub/20/spec/FontManglingSpec_2.0.1_draft.htm
      */
-    function deobfuscate($keyBytes, $encryptedBytes)
+    public static function deobfuscate($keyBytes, $encryptedBytes)
     {
         $obfuscated = substr($encryptedBytes, 0, 1040);
         $deobfuscated = '';
@@ -64,4 +64,20 @@ class Crypt
 
         return $deobfuscated . (string) substr($encryptedBytes, 1040);
     }
+
+    /**
+     * https://www.w3.org/publishing/epub3/epub-ocf.html#obfus-algorithm
+     *
+     * Obfuscate the first 1040 bytes of the content.
+     * The rest is plain text.
+     *
+     * @param string $keyBytes       Binary obfuscation key (20 bytes)
+     * @param string $encryptedBytes Binary data
+     *
+     * @link http://idpf.org/epub/20/spec/FontManglingSpec_2.0.1_draft.htm
+     */
+    public static function obfuscate($keyBytes, $encryptedBytes)
+    {
+        return static::deobfuscate($keyBytes, $encryptedBytes);
+    }
 }
index 8226b55f8ae4685d3d5d588f0984164ec2cc1946..759d96781d5fc2021cf75153e38aca99b2377420 100644 (file)
@@ -18,6 +18,14 @@ class Logger
         fwrite(STDERR, $msg . "\n");
     }
 
+    /**
+     * Something is wrong, but we can continue
+     */
+    public function warning(string $msg): void
+    {
+        echo fwrite(STDERR, $msg . "\n");
+    }
+
     /**
      * Normal but significant events.
      */
index d37fb1565e91846eacab2c749a425f80c8555601..5d6a3bb8b1d538a5457a4d795bfed104149ddd76 100644 (file)
@@ -111,7 +111,9 @@ class ZipWrapper
         if ($this->zip) {
             for ($n = 0; $n < $this->zip->numFiles; $n++) {
                 $info = $this->zip->statIndex($n);
-                yield $info['name'];
+                if ($info !== false) {
+                    yield $info['name'];
+                }
             }
         } else {
             $it = new \RecursiveIteratorIterator(