summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdrien Bustany <adrien@bustany.org>2022-05-31 23:34:54 +0200
committerAdrien Bustany <adrien@bustany.org>2022-05-31 23:34:54 +0200
commit2ecc51bd2edc47938432168572e6567f7a0c2abd (patch)
tree2b0b16a4f10170a0ce7120d97deff54f9a3f9a69
parentRoot commit (diff)
downloadlcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar.gz
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar.bz2
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar.lz
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar.xz
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.tar.zst
lcp-decrypt-2ecc51bd2edc47938432168572e6567f7a0c2abd.zip
-rw-r--r--.gitignore3
-rw-r--r--LICENSE19
-rw-r--r--README.md59
-rw-r--r--go.mod3
-rw-r--r--main.go291
5 files changed, 375 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d79895a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.*.swp
+*~
+/lcp-decrypt
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..190bf90
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright 2022 Adrien Bustany <adrien@bustany.org>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0c3045e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,59 @@
+# lcp-decrypt - a quick&dirty tool to remove LCP/CARE DRMs
+
+lcp-decrypt takes an ePUB file protected with a Readium LCP protection
+(sometimes also called CARE) and decrypts it into a regular ePUB file. The
+decryption requires the LCP user key. The retrieval of the key is not handled
+by this program, in other words, lcp-decrypt does not crack any DRM. It only
+makes a DRMed ePUB you already have legitimate access to usable on any ePUB
+compatible reader.
+
+## Building lcp-decrypt
+
+Until binaries are provided, you need to compile the tool yourself by running
+
+```
+go build -o lcp-decrypt .
+```
+
+## Running lcp-decrypt
+
+Once you have your user key (as a hex encoded string), getting a decoded ePUB is as simple as running
+
+```
+# decrypts ebook_with_drm.epub into ebook_without_drm.epub
+lcp-decrypt -userKey 012345 ebook_with_drm.epub ebook_without_drm.epub
+```
+
+## Retrieving the LCP user key
+
+The process to retrieve the user key depends on how you officially access the
+ebook you purchased.
+
+### Vivlio Reader
+
+I must give credits to Vivlio for providing a Linux version of their reader.
+The reader is an Electron application, which means it's easy to tell it to
+forward all requests through [mitmproxy](https://mitmproxy.org/). Assuming
+mitmproxy is running on port 8080, run `./Vivlio-3.3.0.AppImage
+--proxy-server=127.0.0.1:8080`. Log into your ebook reseller through the app
+and open the book. There should be one request in the mitmproxy console that
+looks like this:
+
+```
+GET https://api.your-book-store.com/v1/lcp/keys/user?device_id=XXX
+```
+
+and the response should look like
+
+```
+[{"user_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}]
+```
+
+The `0123...` string is the value you should pass to the `-userKey` command
+line flag.
+
+## Limitations
+
+As mentioned above, this is a quick&dirty tool. The ePUB parsing was tested
+on the one file I have access to, and I only checked that the resulting ePUB
+worked in Calibre and on a Kindle. Bug reports and contributions are welcome.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a3f8796
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/abustany/lcp-decrypt
+
+go 1.16
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2e2900a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,291 @@
+package main
+
+import (
+ "archive/zip"
+ "crypto/aes"
+ "crypto/cipher"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/xml"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "os"
+ "strings"
+)
+
+func main() {
+ if err := run(); err != nil {
+ log.Fatalf("error: %s", err)
+ }
+}
+
+func run() error {
+ flag.Usage = func() {
+ fmt.Fprintf(flag.CommandLine.Output(), `Usage: %s -userKey USER_KEY_HEX in.epub out.epub
+
+Decrypts the files of an EPUB book protected with Readium LCP (CARE) DRM. This
+program requires the "user key" to operate, in other words it does not "crack"
+any DRM. It only decrypts files for which you already have the decryption key.
+
+To obtain the user key, you can for example use mitmproxy with your EPUB reader
+application. The app should do a request that looks like
+
+GET https://api.your-book-store.com/v1/lcp/keys/user?device_id=XXX
+
+and the response should look like
+
+[{"user_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}]
+
+The 0123... string is the value you should pass in -userKey.
+`, os.Args[0])
+ flag.PrintDefaults()
+ }
+
+ userKeyHex := flag.String("userKey", "", "hex encoded LCP user key")
+
+ flag.Parse()
+
+ inFilename := flag.Arg(0)
+ if inFilename == "" {
+ return fmt.Errorf("no input file specified")
+ }
+
+ outFilename := flag.Arg(1)
+ if outFilename == "" {
+ return fmt.Errorf("no output file specified")
+ }
+
+ if *userKeyHex == "" {
+ return fmt.Errorf("user key not specified")
+ }
+
+ userKey, err := hex.DecodeString(*userKeyHex)
+ if err != nil {
+ return fmt.Errorf("error decoding user key: %w", err)
+ }
+
+ inFile, err := zip.OpenReader(inFilename)
+ if err != nil {
+ return fmt.Errorf("error opening input file: %w", err)
+ }
+
+ defer inFile.Close()
+
+ contentKey, err := getContentKey(inFile, userKey)
+ if err != nil {
+ return fmt.Errorf("error getting content key: %w", err)
+ }
+
+ encryptedFiles, err := listEncryptedFiles(inFile)
+ if err != nil {
+ return fmt.Errorf("error listing encrypted files: %w", err)
+ }
+
+ outFd, err := os.Create(outFilename)
+ if err != nil {
+ return fmt.Errorf("error creating output file: %w", err)
+ }
+
+ defer outFd.Close()
+
+ outZip := zip.NewWriter(outFd)
+
+ if err := outZip.SetComment(inFile.Comment); err != nil {
+ return fmt.Errorf("error setting output file comment: %w", err)
+ }
+
+ encryptedFilesSet := stringSet(encryptedFiles)
+
+ // According to the ePUB spec, the "mimetype" file must come first in the
+ // archive and not be compressed.
+ mimetypeFile, err := outZip.CreateHeader(&zip.FileHeader{
+ Name: "mimetype",
+ Method: zip.Store,
+ })
+
+ if _, err := io.WriteString(mimetypeFile, "application/epub+zip"); err != nil {
+ return fmt.Errorf("error appending mimetype file to output zip file: %w", err)
+ }
+
+ for _, f := range inFile.File {
+ switch f.Name {
+ case "META-INF/encryption.xml", "META-INF/license.lcpl", "mimetype":
+ continue // already written / not needed once content is decrypted
+ }
+
+ log.Printf("Processing file %s...", f.Name)
+
+ dstFile, err := outZip.Create(f.Name)
+ if err != nil {
+ return fmt.Errorf("error appending file %s to output zip file: %w", f.Name, err)
+ }
+
+ if strings.HasSuffix(f.Name, "/") {
+ continue // no need to copy any data for directories
+ }
+
+ srcFile, err := f.Open()
+ if err != nil {
+ return fmt.Errorf("error opening file %s from input zip file: %w", f.Name, err)
+ }
+
+ if _, ok := encryptedFilesSet[f.Name]; ok {
+ err = decryptFile(dstFile, srcFile, contentKey)
+ } else {
+ _, err = io.Copy(dstFile, srcFile)
+ }
+
+ if err != nil {
+ return fmt.Errorf("error copying data for file %s to output zip file: %w", f.Name, err)
+ }
+
+ if err := srcFile.Close(); err != nil {
+ return fmt.Errorf("error closing file %s from input zip file: %w", f.Name, err)
+ }
+ }
+
+ if err := outZip.Close(); err != nil {
+ return fmt.Errorf("error finalizing output zip file: %w", err)
+ }
+
+ log.Printf("Decrypted ePUB into %s", outFilename)
+
+ return nil
+}
+
+func listEncryptedFiles(epubRoot fs.FS) ([]string, error) {
+ encFile, err := epubRoot.Open("META-INF/encryption.xml")
+ if err != nil {
+ return nil, fmt.Errorf("error opening file: %w", err)
+ }
+
+ defer encFile.Close()
+
+ var encryption struct {
+ EncryptedData []struct {
+ CipherData struct {
+ CipherReference struct {
+ URI string `xml:"URI,attr"`
+ }
+ }
+ }
+ }
+
+ if err := xml.NewDecoder(encFile).Decode(&encryption); err != nil {
+ return nil, fmt.Errorf("error decoding file: %w", err)
+ }
+
+ var res []string
+
+ for _, d := range encryption.EncryptedData {
+ res = append(res, d.CipherData.CipherReference.URI)
+ }
+
+ return res, nil
+}
+
+func stringSet(strs []string) map[string]struct{} {
+ res := make(map[string]struct{}, len(strs))
+
+ for _, s := range strs {
+ res[s] = struct{}{}
+ }
+
+ return res
+}
+
+func getContentKey(epubRoot fs.FS, userKey []byte) ([]byte, error) {
+ var license struct {
+ ID string `json:"id"`
+ Encryption struct {
+ ContentKey struct {
+ EncryptedValue string `json:"encrypted_value"`
+ } `json:"content_key"`
+ UserKey struct {
+ KeyCheck string `json:"key_check"`
+ } `json:"user_key"`
+ }
+ }
+
+ licenseFile, err := epubRoot.Open("META-INF/license.lcpl")
+ if err != nil {
+ return nil, fmt.Errorf("error opening license file: %w", err)
+ }
+
+ if err := json.NewDecoder(licenseFile).Decode(&license); err != nil {
+ return nil, fmt.Errorf("error decoding json: %w", err)
+ }
+
+ encryptedKeyCheck, err := base64.StdEncoding.DecodeString(license.Encryption.UserKey.KeyCheck)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding key check: %w", err)
+ }
+
+ keyCheck, err := decipher(encryptedKeyCheck, userKey)
+ if err != nil {
+ return nil, fmt.Errorf("error decrypting key check")
+ }
+
+ if string(keyCheck) != license.ID {
+ return nil, fmt.Errorf("decrypted key check (%s) does not match license ID (%s)", keyCheck, license.ID)
+ }
+
+ encryptedContentKey, err := base64.StdEncoding.DecodeString(license.Encryption.ContentKey.EncryptedValue)
+ if err != nil {
+ return nil, fmt.Errorf("error decoding content key: %w", err)
+ }
+
+ contentKey, err := decipher(encryptedContentKey, userKey)
+ if err != nil {
+ return nil, fmt.Errorf("error decrypting content key: %w", err)
+ }
+
+ return contentKey, nil
+}
+
+func decipher(data, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("error creating cipher: %w", err)
+ }
+
+ iv, cipherData := data[:aes.BlockSize], data[aes.BlockSize:]
+
+ if len(data) == 0 {
+ return nil, nil
+ }
+
+ res := make([]byte, len(cipherData))
+ cipher.NewCBCDecrypter(block, iv).CryptBlocks(res, cipherData)
+
+ paddingLen := int(res[len(res)-1])
+ if paddingLen > len(res) {
+ return nil, fmt.Errorf("invalid padding length %d (data length is %d)", paddingLen, len(res))
+ }
+
+ res = res[:len(res)-paddingLen]
+
+ return res, nil
+}
+
+func decryptFile(dst io.Writer, src io.Reader, contentKey []byte) error {
+ encryptedData, err := io.ReadAll(src)
+ if err != nil {
+ return fmt.Errorf("error reading data: %w", err)
+ }
+
+ data, err := decipher(encryptedData, contentKey)
+ if err != nil {
+ return fmt.Errorf("error decrypting data: %w", err)
+ }
+
+ if _, err := dst.Write(data); err != nil {
+ return fmt.Errorf("error writing data: %w", err)
+ }
+
+ return nil
+}