From: Christian Weiske Date: Sun, 30 Jan 2022 21:04:19 +0000 (+0100) Subject: Obfuscation command X-Git-Url: https://git.cweiske.de/epubpypt.git/commitdiff_plain Obfuscation command --- diff --git a/README.rst b/README.rst index acbf2e4..5076485 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/src/Cli.php b/src/Cli.php index 6f3c758..f18a60c 100644 --- a/src/Cli.php +++ b/src/Cli.php @@ -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 index 0000000..89aa9d8 --- /dev/null +++ b/src/Command/Obfuscate.php @@ -0,0 +1,319 @@ + 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 + ); + } + $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 + ); + } + } + } +} diff --git a/src/Crypt.php b/src/Crypt.php index d000257..eb4800c 100644 --- a/src/Crypt.php +++ b/src/Crypt.php @@ -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); + } } diff --git a/src/Logger.php b/src/Logger.php index 8226b55..759d967 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -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. */ diff --git a/src/ZipWrapper.php b/src/ZipWrapper.php index d37fb15..5d6a3bb 100644 --- a/src/ZipWrapper.php +++ b/src/ZipWrapper.php @@ -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(