);
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);
[
'short_name' => '-k',
'long_name' => '--keyfile',
- 'description' => 'File containing obfuscatino key',
+ 'description' => 'File containing obfuscation key',
'action' => 'StoreString',
'default' => null,
]
]
);
+ $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;
}
}
--- /dev/null
+<?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
+ );
+ }
+ }
+ }
+}