383 lines
15 KiB
Python
383 lines
15 KiB
Python
#!/usr/bin/env python
|
|
|
|
# ignobleepub_plugin.py
|
|
# Released under the terms of the GNU General Public Licence, version 3 or
|
|
# later. <http://www.gnu.org/licenses/>
|
|
#
|
|
# Requires Calibre version 0.7.55 or higher.
|
|
#
|
|
# All credit given to I <3 Cabbages for the original standalone scripts.
|
|
# I had the much easier job of converting them to Calibre a plugin.
|
|
#
|
|
# This plugin is meant to decrypt Barnes & Noble Epubs that are protected
|
|
# with Adobe's Adept encryption. It is meant to function without having to install
|
|
# any dependencies... other than having Calibre installed, of course. It will still
|
|
# work if you have Python and PyCrypto already installed, but they aren't necessary.
|
|
#
|
|
# Configuration:
|
|
# 1) The easiest way to configure the plugin is to enter your name (Barnes & Noble account
|
|
# name) and credit card number (the one used to purchase the books) into the plugin's
|
|
# customization window. Highlight the plugin (Ignoble Epub DeDRM) and click the
|
|
# "Customize Plugin" button on Calibre's Preferences->Plugins page.
|
|
# Enter the name and credit card number separated by a comma: Your Name,1234123412341234
|
|
#
|
|
# If you've purchased books with more than one credit card, separate the info with
|
|
# a colon: Your Name,1234123412341234:Other Name,2345234523452345
|
|
#
|
|
# ** Method 1 is your only option if you don't have/can't run the original
|
|
# I <3 Cabbages scripts on your particular machine. **
|
|
#
|
|
# 2) If you already have keyfiles generated with I <3 Cabbages' ignoblekeygen.pyw
|
|
# script, you can put those keyfiles in Calibre's configuration directory. The easiest
|
|
# way to find the correct directory is to go to Calibre's Preferences page... click
|
|
# on the 'Miscellaneous' button (looks like a gear), and then click the 'Open Calibre
|
|
# configuration directory' button. Paste your keyfiles in there. Just make sure that
|
|
# they have different names and are saved with the '.b64' extension (like the ignoblekeygen
|
|
# script produces). This directory isn't touched when upgrading Calibre, so it's quite safe
|
|
# to leave then there.
|
|
#
|
|
# All keyfiles from option 2 and all data entered from option 1 will be used to attempt
|
|
# to decrypt a book. You can use option 1 or option 2, or a combination of both.
|
|
#
|
|
#
|
|
# Revision history:
|
|
# 0.1.0 - Initial release
|
|
# 0.1.1 - Allow Windows users to make use of openssl if they have it installed.
|
|
# - Incorporated SomeUpdates zipfix routine.
|
|
# 0.1.2 - bug fix for non-ascii file names in encryption.xml
|
|
# 0.1.3 - Try PyCrypto on Windows first
|
|
# 0.1.4 - update zipfix to deal with mimetype not in correct place
|
|
# 0.1.5 - update zipfix to deal with completely missing mimetype files
|
|
# 0.1.6 - update ot the new calibre plugin interface
|
|
|
|
"""
|
|
Decrypt Barnes & Noble ADEPT encrypted EPUB books.
|
|
"""
|
|
|
|
from __future__ import with_statement
|
|
|
|
__license__ = 'GPL v3'
|
|
|
|
import sys
|
|
import os
|
|
import hashlib
|
|
import zlib
|
|
import zipfile
|
|
import re
|
|
from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED
|
|
import xml.etree.ElementTree as etree
|
|
from contextlib import closing
|
|
|
|
global AES
|
|
global AES2
|
|
|
|
META_NAMES = ('mimetype', 'META-INF/rights.xml', 'META-INF/encryption.xml')
|
|
NSMAP = {'adept': 'http://ns.adobe.com/adept',
|
|
'enc': 'http://www.w3.org/2001/04/xmlenc#'}
|
|
|
|
class IGNOBLEError(Exception):
|
|
pass
|
|
|
|
def _load_crypto_libcrypto():
|
|
from ctypes import CDLL, POINTER, c_void_p, c_char_p, c_int, c_long, \
|
|
Structure, c_ulong, create_string_buffer, cast
|
|
from ctypes.util import find_library
|
|
|
|
if sys.platform.startswith('win'):
|
|
libcrypto = find_library('libeay32')
|
|
else:
|
|
libcrypto = find_library('crypto')
|
|
if libcrypto is None:
|
|
raise IGNOBLEError('libcrypto not found')
|
|
libcrypto = CDLL(libcrypto)
|
|
|
|
AES_MAXNR = 14
|
|
|
|
c_char_pp = POINTER(c_char_p)
|
|
c_int_p = POINTER(c_int)
|
|
|
|
class AES_KEY(Structure):
|
|
_fields_ = [('rd_key', c_long * (4 * (AES_MAXNR + 1))),
|
|
('rounds', c_int)]
|
|
AES_KEY_p = POINTER(AES_KEY)
|
|
|
|
def F(restype, name, argtypes):
|
|
func = getattr(libcrypto, name)
|
|
func.restype = restype
|
|
func.argtypes = argtypes
|
|
return func
|
|
|
|
AES_set_encrypt_key = F(c_int, 'AES_set_encrypt_key',
|
|
[c_char_p, c_int, AES_KEY_p])
|
|
AES_set_decrypt_key = F(c_int, 'AES_set_decrypt_key',
|
|
[c_char_p, c_int, AES_KEY_p])
|
|
AES_cbc_encrypt = F(None, 'AES_cbc_encrypt',
|
|
[c_char_p, c_char_p, c_ulong, AES_KEY_p, c_char_p,
|
|
c_int])
|
|
|
|
class AES(object):
|
|
def __init__(self, userkey):
|
|
self._blocksize = len(userkey)
|
|
if (self._blocksize != 16) and (self._blocksize != 24) and (self._blocksize != 32) :
|
|
raise IGNOBLEError('AES improper key used')
|
|
return
|
|
key = self._key = AES_KEY()
|
|
rv = AES_set_decrypt_key(userkey, len(userkey) * 8, key)
|
|
if rv < 0:
|
|
raise IGNOBLEError('Failed to initialize AES key')
|
|
|
|
def decrypt(self, data):
|
|
out = create_string_buffer(len(data))
|
|
iv = ("\x00" * self._blocksize)
|
|
rv = AES_cbc_encrypt(data, out, len(data), self._key, iv, 0)
|
|
if rv == 0:
|
|
raise IGNOBLEError('AES decryption failed')
|
|
return out.raw
|
|
|
|
class AES2(object):
|
|
def __init__(self, userkey, iv):
|
|
self._blocksize = len(userkey)
|
|
self._iv = iv
|
|
key = self._key = AES_KEY()
|
|
rv = AES_set_encrypt_key(userkey, len(userkey) * 8, key)
|
|
if rv < 0:
|
|
raise IGNOBLEError('Failed to initialize AES Encrypt key')
|
|
|
|
def encrypt(self, data):
|
|
out = create_string_buffer(len(data))
|
|
rv = AES_cbc_encrypt(data, out, len(data), self._key, self._iv, 1)
|
|
if rv == 0:
|
|
raise IGNOBLEError('AES encryption failed')
|
|
return out.raw
|
|
print 'IgnobleEpub: Using libcrypto.'
|
|
return (AES, AES2)
|
|
|
|
def _load_crypto_pycrypto():
|
|
from Crypto.Cipher import AES as _AES
|
|
|
|
class AES(object):
|
|
def __init__(self, key):
|
|
self._aes = _AES.new(key, _AES.MODE_CBC)
|
|
|
|
def decrypt(self, data):
|
|
return self._aes.decrypt(data)
|
|
|
|
class AES2(object):
|
|
def __init__(self, key, iv):
|
|
self._aes = _AES.new(key, _AES.MODE_CBC, iv)
|
|
|
|
def encrypt(self, data):
|
|
return self._aes.encrypt(data)
|
|
print 'IgnobleEpub: Using PyCrypto.'
|
|
return (AES, AES2)
|
|
|
|
def _load_crypto():
|
|
_aes = _aes2 = None
|
|
cryptolist = (_load_crypto_libcrypto, _load_crypto_pycrypto)
|
|
if sys.platform.startswith('win'):
|
|
cryptolist = (_load_crypto_pycrypto, _load_crypto_libcrypto)
|
|
for loader in cryptolist:
|
|
try:
|
|
_aes, _aes2 = loader()
|
|
break
|
|
except (ImportError, IGNOBLEError):
|
|
pass
|
|
return (_aes, _aes2)
|
|
|
|
def normalize_name(name): # Strip spaces and convert to lowercase.
|
|
return ''.join(x for x in name.lower() if x != ' ')
|
|
|
|
def generate_keyfile(name, ccn):
|
|
name = normalize_name(name) + '\x00'
|
|
ccn = ccn + '\x00'
|
|
name_sha = hashlib.sha1(name).digest()[:16]
|
|
ccn_sha = hashlib.sha1(ccn).digest()[:16]
|
|
both_sha = hashlib.sha1(name + ccn).digest()
|
|
aes = AES2(ccn_sha, name_sha)
|
|
crypt = aes.encrypt(both_sha + ('\x0c' * 0x0c))
|
|
userkey = hashlib.sha1(crypt).digest()
|
|
|
|
return userkey.encode('base64')
|
|
|
|
class ZipInfo(zipfile.ZipInfo):
|
|
def __init__(self, *args, **kwargs):
|
|
if 'compress_type' in kwargs:
|
|
compress_type = kwargs.pop('compress_type')
|
|
super(ZipInfo, self).__init__(*args, **kwargs)
|
|
self.compress_type = compress_type
|
|
|
|
class Decryptor(object):
|
|
def __init__(self, bookkey, encryption):
|
|
enc = lambda tag: '{%s}%s' % (NSMAP['enc'], tag)
|
|
self._aes = AES(bookkey)
|
|
encryption = etree.fromstring(encryption)
|
|
self._encrypted = encrypted = set()
|
|
expr = './%s/%s/%s' % (enc('EncryptedData'), enc('CipherData'),
|
|
enc('CipherReference'))
|
|
for elem in encryption.findall(expr):
|
|
path = elem.get('URI', None)
|
|
path = path.encode('utf-8')
|
|
if path is not None:
|
|
encrypted.add(path)
|
|
|
|
def decompress(self, bytes):
|
|
dc = zlib.decompressobj(-15)
|
|
bytes = dc.decompress(bytes)
|
|
ex = dc.decompress('Z') + dc.flush()
|
|
if ex:
|
|
bytes = bytes + ex
|
|
return bytes
|
|
|
|
def decrypt(self, path, data):
|
|
if path in self._encrypted:
|
|
data = self._aes.decrypt(data)[16:]
|
|
data = data[:-ord(data[-1])]
|
|
data = self.decompress(data)
|
|
return data
|
|
|
|
def plugin_main(userkey, inpath, outpath):
|
|
key = userkey.decode('base64')[:16]
|
|
aes = AES(key)
|
|
|
|
with closing(ZipFile(open(inpath, 'rb'))) as inf:
|
|
namelist = set(inf.namelist())
|
|
if 'META-INF/rights.xml' not in namelist or \
|
|
'META-INF/encryption.xml' not in namelist:
|
|
return 1
|
|
for name in META_NAMES:
|
|
namelist.remove(name)
|
|
try: # If the generated keyfile doesn't match the bookkey, this is where it's likely to blow up.
|
|
rights = etree.fromstring(inf.read('META-INF/rights.xml'))
|
|
adept = lambda tag: '{%s}%s' % (NSMAP['adept'], tag)
|
|
expr = './/%s' % (adept('encryptedKey'),)
|
|
bookkey = ''.join(rights.findtext(expr))
|
|
bookkey = aes.decrypt(bookkey.decode('base64'))
|
|
bookkey = bookkey[:-ord(bookkey[-1])]
|
|
encryption = inf.read('META-INF/encryption.xml')
|
|
decryptor = Decryptor(bookkey[-16:], encryption)
|
|
kwds = dict(compression=ZIP_DEFLATED, allowZip64=False)
|
|
with closing(ZipFile(open(outpath, 'wb'), 'w', **kwds)) as outf:
|
|
zi = ZipInfo('mimetype', compress_type=ZIP_STORED)
|
|
outf.writestr(zi, inf.read('mimetype'))
|
|
for path in namelist:
|
|
data = inf.read(path)
|
|
outf.writestr(path, decryptor.decrypt(path, data))
|
|
except:
|
|
return 2
|
|
return 0
|
|
|
|
from calibre.customize import FileTypePlugin
|
|
from calibre.constants import iswindows, isosx
|
|
|
|
class IgnobleDeDRM(FileTypePlugin):
|
|
name = 'Ignoble Epub DeDRM'
|
|
description = 'Removes DRM from secure Barnes & Noble epub files. \
|
|
Credit given to I <3 Cabbages for the original stand-alone scripts.'
|
|
supported_platforms = ['linux', 'osx', 'windows']
|
|
author = 'DiapDealer'
|
|
version = (0, 1, 6)
|
|
minimum_calibre_version = (0, 7, 55) # Compiled python libraries cannot be imported in earlier versions.
|
|
file_types = set(['epub'])
|
|
on_import = True
|
|
|
|
def run(self, path_to_ebook):
|
|
global AES
|
|
global AES2
|
|
|
|
AES, AES2 = _load_crypto()
|
|
|
|
if AES == None or AES2 == None:
|
|
# Failed to load libcrypto or PyCrypto... Adobe Epubs can't be decrypted.'
|
|
raise IGNOBLEError('IgnobleEpub - Failed to load crypto libs.')
|
|
return
|
|
|
|
# Load any keyfiles (*.b64) included Calibre's config directory.
|
|
userkeys = []
|
|
try:
|
|
# Find Calibre's configuration directory.
|
|
confpath = os.path.split(os.path.split(self.plugin_path)[0])[0]
|
|
print 'IgnobleEpub: Calibre configuration directory = %s' % confpath
|
|
files = os.listdir(confpath)
|
|
filefilter = re.compile("\.b64$", re.IGNORECASE)
|
|
files = filter(filefilter.search, files)
|
|
|
|
if files:
|
|
for filename in files:
|
|
fpath = os.path.join(confpath, filename)
|
|
with open(fpath, 'rb') as f:
|
|
userkeys.append(f.read())
|
|
print 'IgnobleEpub: Keyfile %s found in config folder.' % filename
|
|
else:
|
|
print 'IgnobleEpub: No keyfiles found. Checking plugin customization string.'
|
|
except IOError:
|
|
print 'IgnobleEpub: Error reading keyfiles from config directory.'
|
|
pass
|
|
|
|
# Get name and credit card number from Plugin Customization
|
|
if not userkeys and not self.site_customization:
|
|
# Plugin hasn't been configured... do nothing.
|
|
raise IGNOBLEError('IgnobleEpub - No keys found. Plugin not configured.')
|
|
return
|
|
|
|
if self.site_customization:
|
|
keystuff = self.site_customization
|
|
ar = keystuff.split(':')
|
|
keycount = 0
|
|
for i in ar:
|
|
try:
|
|
name, ccn = i.split(',')
|
|
keycount += 1
|
|
except ValueError:
|
|
raise IGNOBLEError('IgnobleEpub - Error parsing user supplied data.')
|
|
return
|
|
|
|
# Generate Barnes & Noble EPUB user key from name and credit card number.
|
|
userkeys.append( generate_keyfile(name, ccn) )
|
|
print 'IgnobleEpub: %d userkey(s) generated from customization data.' % keycount
|
|
|
|
# Attempt to decrypt epub with each encryption key (generated or provided).
|
|
for userkey in userkeys:
|
|
# Create a TemporaryPersistent file to work with.
|
|
# Check original epub archive for zip errors.
|
|
from calibre_plugins.ignobleepub import zipfix
|
|
inf = self.temporary_file('.epub')
|
|
try:
|
|
fr = zipfix.fixZip(path_to_ebook, inf.name)
|
|
fr.fix()
|
|
except Exception, e:
|
|
raise Exception(e)
|
|
return
|
|
of = self.temporary_file('.epub')
|
|
|
|
# Give the user key, ebook and TemporaryPersistent file to the Stripper function.
|
|
result = plugin_main(userkey, inf.name, of.name)
|
|
|
|
# Ebook is not a B&N Adept epub... do nothing and pass it on.
|
|
# This allows a non-encrypted epub to be imported without error messages.
|
|
if result == 1:
|
|
print 'IgnobleEpub: Not a B&N Adept Epub... punting.'
|
|
of.close()
|
|
return path_to_ebook
|
|
break
|
|
|
|
# Decryption was successful return the modified PersistentTemporary
|
|
# file to Calibre's import process.
|
|
if result == 0:
|
|
print 'IgnobleEpub: Encryption successfully removed.'
|
|
of.close()
|
|
return of.name
|
|
break
|
|
|
|
print 'IgnobleEpub: Encryption key invalid... trying others.'
|
|
of.close()
|
|
|
|
# Something went wrong with decryption.
|
|
# Import the original unmolested epub.
|
|
of.close
|
|
raise IGNOBLEError('IgnobleEpub - Ultimately failed to decrypt.')
|
|
return
|
|
|
|
|
|
def customization_help(self, gui=False):
|
|
return 'Enter B&N Account name and CC# (separate name and CC# with a comma)'
|