diff options
author | Adrien Bustany <adrien@bustany.org> | 2022-05-31 23:34:54 +0200 |
---|---|---|
committer | Adrien Bustany <adrien@bustany.org> | 2022-05-31 23:34:54 +0200 |
commit | 2ecc51bd2edc47938432168572e6567f7a0c2abd (patch) | |
tree | 2b0b16a4f10170a0ce7120d97deff54f9a3f9a69 | |
parent | Root commit (diff) | |
download | lcp-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-- | .gitignore | 3 | ||||
-rw-r--r-- | LICENSE | 19 | ||||
-rw-r--r-- | README.md | 59 | ||||
-rw-r--r-- | go.mod | 3 | ||||
-rw-r--r-- | main.go | 291 |
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 @@ -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. @@ -0,0 +1,3 @@ +module github.com/abustany/lcp-decrypt + +go 1.16 @@ -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 +} |