From a8c91283dc4614d5a037143082451eda677ed5b8 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 14 Oct 2025 04:57:34 -0700 Subject: [PATCH] =?UTF-8?q?Programme=20pour=20creer=20les=20fichiers=20ePu?= =?UTF-8?q?b=20chiffr=C3=A9s,=20pour=20les=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 29 +- src/main/java/EncryptEPub.java | 532 +++++++++++++++++++++++++++ src/main/resources/EncryptEPub-fr.md | 101 +++++ 3 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 src/main/java/EncryptEPub.java create mode 100644 src/main/resources/EncryptEPub-fr.md diff --git a/build.gradle b/build.gradle index 4a6849f..c953d0c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group 'epub.jgourour' // Replace with your group ID -version '1.0-SNAPSHOT' // Replace with your version +version '0.1-SNAPSHOT' // Replace with your version repositories { mavenCentral() @@ -14,9 +14,7 @@ dependencies { implementation 'net.java.dev.jna:jna-platform:5.17.0' } -test { - useJUnitPlatform() -} +jar.enabled = false sourceSets { libraries { @@ -29,3 +27,26 @@ sourceSets { } } +tasks.register('EncryptEPub', Jar) { + archiveFileName = 'EncryptEPub.jar' + manifest { + attributes 'Main-Class': 'EncryptEPub' // Replace with your main class + } + from {sourceSets.main.output.classesDirs} + dependsOn configurations.runtimeClasspath + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + duplicatesStrategy = DuplicatesStrategy.INCLUDE // Or INCLUDE, EXCLUDE, WARN, INHERIT +} + +jar.enabled = false // instead build serverJar, fullJar, i18neditorJar + + +artifacts { + archives EncryptEPub +} + +test { + useJUnitPlatform() +} diff --git a/src/main/java/EncryptEPub.java b/src/main/java/EncryptEPub.java new file mode 100644 index 0000000..3db0445 --- /dev/null +++ b/src/main/java/EncryptEPub.java @@ -0,0 +1,532 @@ + +import java.nio.file.*; +import java.util.Iterator; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.xml.namespace.NamespaceContext; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.SecretKeySpec; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Enumeration; +import java.util.zip.*; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +import static java.lang.System.arraycopy; +import static java.util.zip.ZipEntry.STORED; + +@Command(name = "EncryptEPub", mixinStandardHelpOptions = true, + version = "0.1", + description = "Adds encryption to a TPM-free epub file." ) + +public class EncryptEPub implements Runnable +{ + @Parameters(index = "0", + description = "Name and path of the ePub file to process.") + private String inputFile = null; + + @Option(names = {"-a", "--activation-file"}, + description = "an xml file containing the user's UUID and public key certificate", + defaultValue = "activation.xml") + private String activationFileName = "activation.xml"; + + @Option(names = {"-D", "--adept-directory"}, + description = "directory that includes the activation file (default: current working directory)", + defaultValue = ".") + private String adeptDir = null; + + @Option(names = {"--win"}, description = "Read license certificate from the Windows registry in lieu of the activation file" ) + private boolean isWin = false; + + private final Logger LOGGER = Logger.getLogger( EncryptEPub.class.getName() ); + + Path activationPath; + + private final String resourceId = "urn:uuid:82919621-81bd-419a-880b-c52eabc22bf1"; + private final byte[] sessionKey; + private String uuid; + private String cert; + + public static class AdeptNamespaceContext implements NamespaceContext + { + private final Document adeptDoc; + + public AdeptNamespaceContext( Document doc ) + { + adeptDoc = doc; + } + + @Override + public String getNamespaceURI( String s ) + { + return "http://ns.adobe.com/adept"; + } + + @Override + public String getPrefix( String s ) + { + return adeptDoc.lookupPrefix( s ); + } + + @Override + public Iterator getPrefixes( String s ) + { + return null; + } + } + + /** + * CONSTRUCTOR + */ + EncryptEPub() + { + sessionKey = new byte[16]; + new SecureRandom().nextBytes( sessionKey ); + } + + private String readValue( BufferedReader in ) + throws IOException + { + String line; + do + { + line = in.readLine(); + } + while (line != null && !line.contains( "value" )); + if (null == line) + { + return null; + } + return line.substring( line.lastIndexOf( "REG_SZ" ) + 7 ).trim(); + } + + + private String getRegistryEntry( String key ) + throws IOException + { +// 0001\0002 LicenseCertificate +// 0001\0000 uuid + String command = "reg query HKEY_CURRENT_USER\\Software\\Adobe\\Adept\\Activation\\" + key + " /s /v *"; + Process process = Runtime.getRuntime().exec( command ); + BufferedReader reader = new BufferedReader( new InputStreamReader( process.getInputStream() ) ); + return readValue( reader ); + } + + @Override + public void run() + { + try + { + // an existing input file is a requirement + Path inputPath = Path.of( inputFile ); + if (!Files.exists( inputPath )) + { + String errMsg = "Error: cannot find" + inputPath.toFile().getCanonicalPath(); + System.out.println( errMsg ); + throw new RuntimeException( errMsg ); + } + + String osName = System.getProperty( "os.name" ); + if (!osName.toLowerCase().contains( "windows" )) + { + isWin = false; + } + if (isWin) + { + try + { + uuid = getRegistryEntry( "0001\\0000" ); + uuid = uuid.substring( 9 ); + cert = getRegistryEntry( "0001\\0002" ); + } + catch (IOException ioe) + { + System.out.println( "Error: Adobe adept registry keys not found" ); + throw ioe; + } + } + else + { + if (null == adeptDir) + { + adeptDir = "."; + } + if (null == activationFileName) + activationFileName = "activation.xml"; + activationPath = Paths.get( adeptDir, activationFileName ); + + // An activation file is required! If it doesn't exist we should throw an error here + if (!Files.exists( activationPath )) + { + String errMsg = "Error: cannot find" + activationPath.toFile().getCanonicalPath(); + System.out.println( errMsg ); + throw new RuntimeException( errMsg ); + } + Document activationDoc = parseActivationFile(); // Will throw a handled exception on failure. + try + { + //noinspection unused -- for debugging + String xmlString = docToString( activationDoc, true ); + } + catch (TransformerException ignore) {} + + XPathFactory xpf = XPathFactory.newInstance(); + XPath xpath = xpf.newXPath(); + xpath.setNamespaceContext( new AdeptNamespaceContext( activationDoc ) ); + Node node = activationDoc.getDocumentElement(); + cert = (String) xpath.evaluate("//adept:licenseCertificate", node, XPathConstants.STRING); + uuid = (String) xpath.evaluate( "//adept:user", node, XPathConstants.STRING); + } + encrypt( inputPath ); + } + catch (ParserConfigurationException | SAXException | IOException | CertificateException | + XPathExpressionException e) + { + throw new RuntimeException( e ); + } + } + + /** + * Compresses data using the Deflate algorithm. + * + * @param data The data to compress. + * @return The compressed data. + */ + public static byte[] deflate( byte[] data ) + throws IOException + { + Deflater deflater = new Deflater( Deflater.DEFAULT_COMPRESSION, true ); // true for no headers + deflater.setInput( data ); + deflater.finish(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream( data.length ); + byte[] buffer = new byte[1024]; + while (!deflater.finished()) + { + int count = deflater.deflate( buffer ); + baos.write( buffer, 0, count ); + } + baos.close(); + return baos.toByteArray(); + } + + private Document parseActivationFile() + throws ParserConfigurationException, IOException, SAXException + { + LOGGER.log( Level.FINE, "DEBUG: Parse activation file " + activationPath ); + + DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); + docFactory.setNamespaceAware( true ); // Important for XPath with namespaces + DocumentBuilder docBuilder; + + docBuilder = docFactory.newDocumentBuilder(); + Document doc = docBuilder.parse( activationPath.toFile() ); + doc.getDocumentElement().normalize(); + return doc; + } + + private Element createEncryptedDataNode( Document encryption, String URI ) + { + Element dataNode = encryption.createElement( "EncryptedData" ); + dataNode.setAttribute( "xmlns", "http://www.w3.org/2001/04/xmlenc#" ); + Element el = encryption.createElement( "EncryptionMethod" ); + el.setAttribute( "Algorithm", "http://www.w3.org/2001/04/xmlenc#aes128-cbc" ); + dataNode.appendChild( el ); + el = encryption.createElement( "KeyInfo" ); + el.setAttribute( "xmlns", "http://www.w3.org/2000/09/xmldsig#" ); + dataNode.appendChild( el ); + Element child = encryption.createElement( "resource" ); + child.setAttribute( "xmlns", "http://ns.adobe.com/adept" ); + child.setTextContent( resourceId ); + el.appendChild( child ); + el = encryption.createElement( "CipherData" ); + dataNode.appendChild( el ); + child = encryption.createElement( "CipherReference" ); + child.setAttribute( "URI", URI ); + el.appendChild( child ); + return dataNode; + } + + private boolean isInSpine( String idref, NodeList spine ) + { + if (null == idref || idref.isBlank()) + return false; + for (int i = 0; i < spine.getLength(); i++) + { + String attr = ((Element) spine.item( i )).getAttribute( "idref" ); + if (attr.equals( idref )) // attr will never be null + return true; + } + return false; + } + + private String getManifestedId( String name, NodeList manifest ) + { + for (int i = 0; i < manifest.getLength(); i++) + { + Node node = manifest.item( i ); + if (node instanceof Element) + { + String attr = ((Element) node).getAttribute( "href" ); + if (name.equals( attr )) + { + return ((Element) node).getAttribute( "id" ); + } + } + } + return null; + } + + private Document createRights() + throws ParserConfigurationException, NoSuchPaddingException, IllegalBlockSizeException, + CertificateException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException + { + DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + Document rights = documentBuilder.newDocument(); + Element docRoot = rights.createElement( "adept:rights" ); + docRoot.setAttribute( "xmlns:adept", "http://ns.adobe.com/adept" ); + rights.appendChild( docRoot ); + Element token = rights.createElement( "licenseToken" ); + token.setAttribute( "xmlns", "http://ns.adobe.com/adept" ); + docRoot.appendChild( token ); + Element el = rights.createElement( "user" ); + el.setTextContent( uuid ); + token.appendChild( el ); + el = rights.createElement( "resource" ); + el.setTextContent( resourceId ); + token.appendChild( el ); + el = rights.createElement( "encryptedKey" ); + token.appendChild( el ); + + // Encrypt the session key with the user's public key certificate + String encoded = encryptSessionKey( sessionKey, cert ); + el.setTextContent( encoded ); + el.setAttribute( "keyInfo", "user" ); + return rights; + } + + private void encrypt( Path inputFile ) + throws ParserConfigurationException, SAXException, IOException, CertificateException, + XPathExpressionException + { + String extPattern = "(? entries = zf.entries(); + while (entries.hasMoreElements()) + { + entry = entries.nextElement(); + // find the file in the manifest, get its id then check that id in the spine + String idref = getManifestedId( entry.getName(), itemList ); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + byte[] uncompressed; + try (InputStream is = zf.getInputStream( entry )) + { + int length; + while ((length = is.read( buffer )) >= 0) + { + os.write( buffer, 0, length ); + } + uncompressed = os.toByteArray(); + } + boolean shouldEncrypt = isInSpine( idref, refList ); + + if (!shouldEncrypt) + { + // not an encrypted file, just copy it to the output + newEntry = new ZipEntry( entry.getName() ); + zipOut.putNextEntry( newEntry ); + for (int length = uncompressed.length, offset = 0; + length > offset; + offset += 1024) + { + zipOut.write( uncompressed, offset, Math.min( 1024, length - offset ) ); + } + } + else // Encrypt the entry and STORE it in the new zip file + { + // Build the encryption and rights XML documents if they do not yet exist + if (null == encryption) + { + encryption = documentBuilder.newDocument(); + Element docRoot = encryption.createElement( "encryption" ); + docRoot.setAttribute( "xmlns", "urn:oasis:names:tc:opendocument:xmlns:container" ); + encryption.appendChild( docRoot ); + + // create rights document. + rights = createRights(); + } + byte[] compressedData = deflate( uncompressed ); + + // now encrypt the data with the aes session key + Cipher cipher = Cipher.getInstance( "AES/CBC/PKCS5Padding" ); + SecretKeySpec secretKeySpec = new SecretKeySpec( sessionKey, "AES" ); + cipher.init( Cipher.ENCRYPT_MODE, secretKeySpec ); // let cipher create the initialization vector for us + byte[] encryptedPayload = cipher.doFinal( compressedData ); + byte[] initVector = cipher.getIV(); + + // Combine IV and the actual encrypted payload + int encryptedLength = encryptedPayload.length; + byte[] result = new byte[16 + encryptedLength]; + arraycopy( initVector, 0, result, 0, 16 ); // Copy IV + arraycopy( encryptedPayload, 0, result, 16, encryptedLength ); // Copy encrypted payload + + // store the encrypted payload for testing purposes + String parent = outputFile.getCanonicalFile().getParent(); + Path file = Paths.get(null == parent ? "" : parent , entry.getName() + ".b64" ); + Files.write( file, Base64.getEncoder().encode( result ) ); + + // Store the result in the zip file + newEntry = new ZipEntry( entry.getName() ); + newEntry.setMethod( STORED ); + checksum.reset(); + checksum.update( result, 0, result.length ); + newEntry.setCrc( checksum.getValue() ); + newEntry.setSize( result.length ); + newEntry.setCompressedSize( result.length ); + zipOut.putNextEntry( newEntry ); + zipOut.write( result, 0, result.length ); + + // Add this entry to encryption.xml + Element dataNode = createEncryptedDataNode( encryption, newEntry.getName() ); + encryption.getDocumentElement().appendChild( dataNode ); + } + } // end while (entries.hasMoreElements()) + // if there's an encryption document, add it to the output file + if (null != encryption) + { + xmlString = docToString( encryption, true ); + newEntry = new ZipEntry( "META-INF/encryption.xml" ); + zipOut.putNextEntry( newEntry ); + zipOut.write( xmlString.getBytes( StandardCharsets.UTF_8 ) ); + } + // do the same thing with the abbreviated rights.xml. + if (null != rights) + { + xmlString = docToString( rights, false ); + newEntry = new ZipEntry( "META-INF/rights.xml" ); + zipOut.putNextEntry( newEntry ); + zipOut.write( xmlString.getBytes( StandardCharsets.UTF_8 )); + } + } // end try with resources. + catch (IOException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | + IllegalBlockSizeException | BadPaddingException | TransformerException e) + { + throw new RuntimeException( e ); + } + System.out.println( inputFile + " encrypted."); + } + + /** + * Converts an XML Document to a formatted String. + * + * @param doc The XML Document to convert. + * @param omitXmlDeclaration a flag to suppress the xml declaration at the beginning of the document + * @return A string representation of the XML. + */ + private static String docToString( Document doc, boolean omitXmlDeclaration ) + throws TransformerException + { + System.setProperty( "line.separator", "\n" ); + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty( OutputKeys.INDENT, "yes" ); + transformer.setOutputProperty( "{http://xml.apache.org/xslt}indent-amount", "2" ); + transformer.setOutputProperty( OutputKeys.ENCODING, "UTF-8" ); // Ensure UTF-8 output + if (omitXmlDeclaration) + transformer.setOutputProperty( OutputKeys.OMIT_XML_DECLARATION, "yes" ); + StringWriter writer = new StringWriter(); + transformer.transform( new DOMSource( doc ), new StreamResult( writer ) ); + @SuppressWarnings("UnnecessaryLocalVariable") + String xmlStr = writer.toString(); + // Java's XML Transformer has a tendency to create unnecessary blonk lines; use a regex function to remove them. +// return collapseLines( xmlStr ); + return xmlStr; + } + + + String encryptSessionKey( byte[] sessionKey, String cert ) + throws NoSuchPaddingException, NoSuchAlgorithmException, CertificateException, InvalidKeyException, + IllegalBlockSizeException, BadPaddingException + { + byte[] certBytes = Base64.getDecoder().decode( cert ); // my public key certificate + CertificateFactory cf = CertificateFactory.getInstance( "X.509" ); + X509Certificate x509AuthCertificate = (X509Certificate) cf.generateCertificate( + new ByteArrayInputStream( certBytes ) ); + + // encrypt with the certificate + Cipher cipher = Cipher.getInstance( "RSA/ECB/PKCS1Padding" ); + cipher.init( Cipher.ENCRYPT_MODE, x509AuthCertificate ); + byte[] enc = cipher.doFinal( sessionKey ); // The session key encrypted with the public key. Should be 128 bytes + return Base64.getEncoder().encodeToString( enc ); + } + + public static void main( String[] args ) + { + int exitCode = new CommandLine( new EncryptEPub() ).execute( args ); + System.exit( exitCode ); + } +} diff --git a/src/main/resources/EncryptEPub-fr.md b/src/main/resources/EncryptEPub-fr.md new file mode 100644 index 0000000..2e23b6f --- /dev/null +++ b/src/main/resources/EncryptEPub-fr.md @@ -0,0 +1,101 @@ +#### Aperçu + +Pendant la traduction de libjourou du C++ vers Java, j'ai réalisé que j'avais +besoin d'un ou de plusieurs fichiers ePub chiffrés pour mes tests. J'ai donc +écrit EncryptEPub pour accomplir cette tâche. EncryptEPub.jar prend un +fichier ePub non chiffré et le transforme en un fichier ePub chiffré. + +Il commence par créer une clé symétrique AES de 128 bits (16 octets). Ensuite, +en utilisant soit un fichier "activation.xml" compatible avec libgourou, soit +les données du registre Windows, il chiffre cette clé symétrique avec la clé +publique provenant du certificat de licence X509 de l'utilisateur +("adept:licenseCertificate"). Il encode ensuite cette clé en base64 et, avec +l'UUID de l'utilisateur et une UUID constante, il crée un document XML minimal +"rights". + +Il lit ensuite l'entrée '.opf' du fichier ePub original, telle qu'enregistrée +dans l'entrée ZIP "META-INF/container.xml", et analyse son nœud XML <spine>. +Il parcourt ensuite l'intégralité du fichier ePub original, copiant les entrées +du fichier d'origine vers un nouveau fichier d'archive ZIP de sortie. Il +commence par extraire chaque entrée et la décompresse en utilisant la méthode +'Inflate'. Si l'entrée n'est pas référencée dans le nœud du fichier +'.opf', il l'ajoute à la nouvelle archive en utilisant la méthode 'Deflate' +(compression). Si elle est référencée, il la compresse en utilisant la méthode +'Deflate' sans l'ajouter, chiffre les données compressées avec la clé symétrique +AES, puis ajoute les données chiffrées à la nouvelle archive ePub en utilisant +la méthode 'Store' (sans compression). Pour chaque fichier chiffré, il crée +également un élément dans un document XML d'encryption, +référençant le nom de l'entrée et spécifiant l'algorithme de chiffrement +(AES-128-CBC). + +#### Par example: + +`` + `` + `` +  `urn:uuid:82919621-81bd-419a-880b-c52eabc22bf1</resource>` + `` + `` +  `` + `` +`` + +Enfin, les documents XML "rights" et "encryption" sont convertis en fichiers +XML et ajoutés à l'ePub de sortie. Cette archive obtenue est le fichier ePub +fortement chiffré, complet avec les métadonnées TPM nécessaire, le rendant +prêt pour les tests. + +#### Format d'utilisation: + +> java -jar EncryptEPub.jar \[-hV\] \[--win\] \[-a=<nomFichierActivation>\] +> \[-D=<dossierAdept>\] <fichierEPub> + +#### Options de Ligne de Commande + +> ##### "-h" ou "--help" +> +> Affiche l'aide du programme (instructions d'utilisation) puis se termine. +> +> ##### "-V" ou "--version" +> +> Affiche les informations de version puis se termine. +> +> ##### "<fichierEPub>" +> +> Le nom et le chemin du fichier ePub à traiter. S'il n'est pas spécifié, ou +> s'il n'existe pas, le programme affichera un message d'erreur et se terminera. +> +> ##### "-a" ou "--activation-file" \[<nomFichierActivation>\] +> +> Spécifie le nom du fichier XML contenant l'UUID de l'utilisateur et son +> certificat de clé publique, dans le même format que celui utilisé par le +> fichier activation.xml de libgourou. Seuls les éléments <adept:user> et +> <adept:privateLicenseKey> sont requis. Si ce paramètre n'est pas fourni, +> le nom "activation.xml" sera utilisé par défaut. +> +> ##### "-D" ou "--adept-directory" \[<cheminDossier>\] +> +> Spécifie le répertoire qui contient le fichier d'activation. S'il n'est pas +> spécifié, le répertoire de travail actuel sera utilisé. +> +> ##### "--win" +> +> Lit le certificat de licence et l'UUID de l'utilisateur directement du +> Registre Windows au lieu d'utiliser le fichier d'activation. Si cette option +> est spécifiée _et_ que le programme est exécuté sur Windows, il tentera de +> lire l'UUID de l'utilisateur et le certificat de clé publique X509 à partir +> des clés de Registre : +> +> HKEY_CURRENT_USER\Software\Adobe\Adept\Activation\0001\0000, et +> HKEY_CURRENT_USER\Software\Adobe\Adept\Activation\0001\0002 +> +> Ces clés doivent exister si Adobe Digital Editions a été installé et activé +> sur la même machine que celle où EncryptEPub est exécuté. +> ##### Gestion des erreurs +> +> Si l'option "--win" est spécifiée, et que les clés de Registre ne peuvent +> être trouvées, le programme affichera un message d'erreur et se terminera. +> +> Si l'option "--win" n'est pas spécifiée, et que le fichier d'activation ne +> peut être trouvé, le programme affichera un message d'erreur et se +> terminera. \ No newline at end of file