mirror of
				https://github.com/noDRM/DeDRM_tools.git
				synced 2025-10-23 23:07:47 -04:00 
			
		
		
		
	 808dc7d29a
			
		
	
	
		808dc7d29a
		
			
		
	
	
	
	
		
			
			Fix #585. Use /sys/class/net/IFACE/address for the MAC address instead of the ip command.
		
			
				
	
	
		
			743 lines
		
	
	
	
		
			29 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			743 lines
		
	
	
	
		
			29 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/env python3
 | |
| # -*- coding: utf-8 -*-
 | |
| 
 | |
| # Version 10.0.3 July 2022
 | |
| # Fix Calibre 6
 | |
| #
 | |
| # Version 10.0.1 February 2022
 | |
| # Remove OpenSSL support to only support PyCryptodome; clean up the code.
 | |
| #
 | |
| # Version 10.0.0 November 2021
 | |
| # Merge https://github.com/apprenticeharper/DeDRM_tools/pull/1691 to fix
 | |
| # key fetch issues on some machines.
 | |
| #
 | |
| # Version 4.1.0 February 2021
 | |
| # Add detection for Kobo directory location on Linux
 | |
| #
 | |
| # Version 4.0.0 September 2020
 | |
| # Python 3.0
 | |
| #
 | |
| # Version 3.2.5 December 2016
 | |
| # Improve detection of good text decryption.
 | |
| #
 | |
| # Version 3.2.4 December 2016
 | |
| # Remove incorrect support for Kobo Desktop under Wine
 | |
| #
 | |
| # Version 3.2.3 October 2016
 | |
| # Fix for windows network user and more xml fixes
 | |
| #
 | |
| # Version 3.2.2 October 2016
 | |
| # Change to the way the new database version is handled.
 | |
| #
 | |
| # Version 3.2.1 September 2016
 | |
| # Update for v4.0 of Windows Desktop app.
 | |
| #
 | |
| # Version 3.2.0 January 2016
 | |
| # Update for latest version of Windows Desktop app.
 | |
| # Support Kobo devices in the command line version.
 | |
| #
 | |
| # Version 3.1.9 November 2015
 | |
| # Handle Kobo Desktop under wine on Linux
 | |
| #
 | |
| # Version 3.1.8 November 2015
 | |
| # Handle the case of Kobo Arc or Vox device (i.e. don't crash).
 | |
| #
 | |
| # Version 3.1.7 October 2015
 | |
| # Handle the case of no device or database more gracefully.
 | |
| #
 | |
| # Version 3.1.6 September 2015
 | |
| # Enable support for Kobo devices
 | |
| # More character encoding fixes (unicode strings)
 | |
| #
 | |
| # Version 3.1.5 September 2015
 | |
| # Removed requirement that a purchase has been made.
 | |
| # Also add in character encoding fixes
 | |
| #
 | |
| # Version 3.1.4 September 2015
 | |
| # Updated for version 3.17 of the Windows Desktop app.
 | |
| #
 | |
| # Version 3.1.3 August 2015
 | |
| # Add translations for Portuguese and Arabic
 | |
| #
 | |
| # Version 3.1.2 January 2015
 | |
| # Add coding, version number and version announcement
 | |
| #
 | |
| # Version 3.05 October 2014
 | |
| # Identifies DRM-free books in the dialog
 | |
| #
 | |
| # Version 3.04 September 2014
 | |
| # Handles DRM-free books as well (sometimes Kobo Library doesn't
 | |
| # show download link for DRM-free books)
 | |
| #
 | |
| # Version 3.03 August 2014
 | |
| # If PyCrypto is unavailable try to use libcrypto for AES_ECB.
 | |
| #
 | |
| # Version 3.02 August 2014
 | |
| # Relax checking of application/xhtml+xml  and image/jpeg content.
 | |
| #
 | |
| # Version 3.01 June 2014
 | |
| # Check image/jpeg as well as application/xhtml+xml content. Fix typo
 | |
| # in Windows ipconfig parsing.
 | |
| #
 | |
| # Version 3.0 June 2014
 | |
| # Made portable for Mac and Windows, and the only module dependency
 | |
| # not part of python core is PyCrypto. Major code cleanup/rewrite.
 | |
| # No longer tries the first MAC address; tries them all if it detects
 | |
| # the decryption failed.
 | |
| #
 | |
| # Updated September 2013 by Anon
 | |
| # Version 2.02
 | |
| # Incorporated minor fixes posted at Apprentice Alf's.
 | |
| #
 | |
| # Updates July 2012 by Michael Newton
 | |
| # PWSD ID is no longer a MAC address, but should always
 | |
| # be stored in the registry. Script now works with OS X
 | |
| # and checks plist for values instead of registry. Must
 | |
| # have biplist installed for OS X support.
 | |
| #
 | |
| # Original comments left below; note the "AUTOPSY" is inaccurate. See
 | |
| # KoboLibrary.userkeys and KoboFile.decrypt()
 | |
| #
 | |
| ##########################################################
 | |
| #                    KOBO DRM CRACK BY                   #
 | |
| #                      PHYSISTICATED                     #
 | |
| ##########################################################
 | |
| # This app was made for Python 2.7 on Windows 32-bit
 | |
| #
 | |
| # This app needs pycrypto - get from here:
 | |
| # http://www.voidspace.org.uk/python/modules.shtml
 | |
| #
 | |
| # Usage: obok.py
 | |
| # Choose the book you want to decrypt
 | |
| #
 | |
| # Shouts to my krew - you know who you are - and one in
 | |
| # particular who gave me a lot of help with this - thank
 | |
| # you so much!
 | |
| #
 | |
| # Kopimi /K\
 | |
| # Keep sharing, keep copying, but remember that nothing is
 | |
| # for free - make sure you compensate your favorite
 | |
| # authors - and cut out the middle man whenever possible
 | |
| # ;) ;) ;)
 | |
| #
 | |
| # DRM AUTOPSY
 | |
| # The Kobo DRM was incredibly easy to crack, but it took
 | |
| # me months to get around to making this. Here's the
 | |
| # basics of how it works:
 | |
| # 1: Get MAC address of first NIC in ipconfig (sometimes
 | |
| # stored in registry as pwsdid)
 | |
| # 2: Get user ID (stored in tons of places, this gets it
 | |
| # from HKEY_CURRENT_USER\Software\Kobo\Kobo Desktop
 | |
| # Edition\Browser\cookies)
 | |
| # 3: Concatenate and SHA256, take the second half - this
 | |
| # is your master key
 | |
| # 4: Open %LOCALAPPDATA%\Kobo Desktop Editions\Kobo.sqlite
 | |
| # and dump content_keys
 | |
| # 5: Unbase64 the keys, then decode these with the master
 | |
| # key - these are your page keys
 | |
| # 6: Unzip EPUB of your choice, decrypt each page with its
 | |
| # page key, then zip back up again
 | |
| #
 | |
| # WHY USE THIS WHEN INEPT WORKS FINE? (adobe DRM stripper)
 | |
| # Inept works very well, but authors on Kobo can choose
 | |
| # what DRM they want to use - and some have chosen not to
 | |
| # let people download them with Adobe Digital Editions -
 | |
| # they would rather lock you into a single platform.
 | |
| #
 | |
| # With Obok, you can sync Kobo Desktop, decrypt all your
 | |
| # ebooks, and then use them on whatever device you want
 | |
| # - you bought them, you own them, you can do what you
 | |
| # like with them.
 | |
| #
 | |
| # Obok is Kobo backwards, but it is also means "next to"
 | |
| # in Polish.
 | |
| # When you buy a real book, it is right next to you. You
 | |
| # can read it at home, at work, on a train, you can lend
 | |
| # it to a friend, you can scribble on it, and add your own
 | |
| # explanations/translations.
 | |
| #
 | |
| # Obok gives you this power over your ebooks - no longer
 | |
| # are you restricted to one device. This allows you to
 | |
| # embed foreign fonts into your books, as older Kobo's
 | |
| # can't display them properly. You can read your books
 | |
| # on your phones, in different PC readers, and different
 | |
| # ereader devices. You can share them with your friends
 | |
| # too, if you like - you can do that with a real book
 | |
| # after all.
 | |
| #
 | |
| """Manage all Kobo books, either encrypted or DRM-free."""
 | |
| from __future__ import print_function
 | |
| 
 | |
| __version__ = '10.0.9'
 | |
| __about__ =  "Obok v{0}\nCopyright © 2012-2023 Physisticated et al.".format(__version__)
 | |
| 
 | |
| import sys
 | |
| import os
 | |
| import subprocess
 | |
| import sqlite3
 | |
| import base64
 | |
| import binascii
 | |
| import re
 | |
| import zipfile
 | |
| import hashlib
 | |
| import xml.etree.ElementTree as ET
 | |
| import string
 | |
| import shutil
 | |
| import argparse
 | |
| import tempfile
 | |
| 
 | |
| try:
 | |
|     from Cryptodome.Cipher import AES
 | |
| except ImportError:
 | |
|     from Crypto.Cipher import AES
 | |
| 
 | |
| def unpad(data, padding=16):
 | |
|     if sys.version_info[0] == 2:
 | |
|         pad_len = ord(data[-1])
 | |
|     else:
 | |
|         pad_len = data[-1]
 | |
| 
 | |
|     return data[:-pad_len]
 | |
| 
 | |
| 
 | |
| can_parse_xml = True
 | |
| try:
 | |
|   from xml.etree import ElementTree as ET
 | |
|   # print "using xml.etree for xml parsing"
 | |
| except ImportError:
 | |
|   can_parse_xml = False
 | |
|   # print "Cannot find xml.etree, disabling extraction of serial numbers"
 | |
| 
 | |
| # List of all known hash keys
 | |
| KOBO_HASH_KEYS = ['88b3a2e13', 'XzUhGYdFp', 'NoCanLook','QJhwzAtXL']
 | |
| 
 | |
| class ENCRYPTIONError(Exception):
 | |
|     pass
 | |
| 
 | |
| # Wrap a stream so that output gets flushed immediately
 | |
| # and also make sure that any unicode strings get
 | |
| # encoded using "replace" before writing them.
 | |
| class SafeUnbuffered:
 | |
|     def __init__(self, stream):
 | |
|         self.stream = stream
 | |
|         self.encoding = stream.encoding
 | |
|         if self.encoding == None:
 | |
|             self.encoding = "utf-8"
 | |
|     def write(self, data):
 | |
|         if isinstance(data,str) or isinstance(data,unicode):
 | |
|             # str for Python3, unicode for Python2
 | |
|             data = data.encode(self.encoding,"replace")
 | |
|         try:
 | |
|             buffer = getattr(self.stream, 'buffer', self.stream)
 | |
|             # self.stream.buffer for Python3, self.stream for Python2
 | |
|             buffer.write(data)
 | |
|             buffer.flush()
 | |
|         except:
 | |
|             # We can do nothing if a write fails
 | |
|             raise
 | |
|     def __getattr__(self, attr):
 | |
|         return getattr(self.stream, attr)
 | |
| 
 | |
| 
 | |
| class KoboLibrary(object):
 | |
|     """The Kobo library.
 | |
| 
 | |
|     This class represents all the information available from the data
 | |
|     written by the Kobo Desktop Edition application, including the list
 | |
|     of books, their titles, and the user's encryption key(s)."""
 | |
| 
 | |
|     def __init__ (self, serials = [], device_path = None, desktopkobodir = u""):
 | |
|         print(__about__)
 | |
|         self.kobodir = u""
 | |
|         kobodb = u""
 | |
| 
 | |
|         # Order of checks
 | |
|         # 1. first check if a device_path has been passed in, and whether
 | |
|         #    we can find the sqlite db in the respective place
 | |
|         # 2. if 1., and we got some serials passed in (from saved
 | |
|         #    settings in calibre), just use it
 | |
|         # 3. if 1. worked, but we didn't get serials, try to parse them
 | |
|         #    from the device, if this didn't work, unset everything
 | |
|         # 4. if by now we don't have kobodir set, give up on device and
 | |
|         #    try to use the Desktop app.
 | |
| 
 | |
|         # step 1. check whether this looks like a real device
 | |
|         if (device_path):
 | |
|             # we got a device path
 | |
|             self.kobodir = os.path.join(device_path, ".kobo")
 | |
|             # devices use KoboReader.sqlite
 | |
|             kobodb  = os.path.join(self.kobodir, "KoboReader.sqlite")
 | |
|             if (not(os.path.isfile(kobodb))):
 | |
|                 # device path seems to be wrong, unset it
 | |
|                 device_path = u""
 | |
|                 self.kobodir = u""
 | |
|                 kobodb  = u""
 | |
| 
 | |
|         if (self.kobodir):
 | |
|             # step 3. we found a device but didn't get serials, try to get them
 | |
|             if (len(serials) == 0):
 | |
|                 # we got a device path but no saved serial
 | |
|                 # try to get the serial from the device
 | |
|                 # print "get_device_settings - device_path = {0}".format(device_path)
 | |
|                 # get serial from device_path/.adobe-digital-editions/device.xml
 | |
|                 if can_parse_xml:
 | |
|                     devicexml = os.path.join(device_path, '.adobe-digital-editions', 'device.xml')
 | |
|                     # print "trying to load {0}".format(devicexml)
 | |
|                     if (os.path.exists(devicexml)):
 | |
|                         # print "trying to parse {0}".format(devicexml)
 | |
|                         xmltree = ET.parse(devicexml)
 | |
|                         for node in xmltree.iter():
 | |
|                             if "deviceSerial" in node.tag:
 | |
|                                 serial = node.text
 | |
|                                 # print "found serial {0}".format(serial)
 | |
|                                 serials.append(serial)
 | |
|                                 break
 | |
|                     else:
 | |
|                         # print "cannot get serials from device."
 | |
|                         device_path = u""
 | |
|                         self.kobodir = u""
 | |
|                         kobodb  = u""
 | |
| 
 | |
|         if (self.kobodir == u""):
 | |
|             # step 4. we haven't found a device with serials, so try desktop apps
 | |
|             if desktopkobodir != u'':
 | |
|                 self.kobodir = desktopkobodir
 | |
| 
 | |
|             if (self.kobodir == u""):
 | |
|                 if sys.platform.startswith('win'):
 | |
|                     try:
 | |
|                         import winreg
 | |
|                     except ImportError:
 | |
|                         import _winreg as winreg
 | |
|                     if sys.getwindowsversion().major > 5:
 | |
|                         if 'LOCALAPPDATA' in os.environ.keys():
 | |
|                             # Python 2.x does not return unicode env. Use Python 3.x
 | |
|                             if sys.version_info[0] == 2:
 | |
|                                 self.kobodir = winreg.ExpandEnvironmentStrings(u"%LOCALAPPDATA%")
 | |
|                             else: 
 | |
|                                 self.kobodir = winreg.ExpandEnvironmentStrings("%LOCALAPPDATA%")
 | |
|                     if (self.kobodir == u""):
 | |
|                         if 'USERPROFILE' in os.environ.keys():
 | |
|                             # Python 2.x does not return unicode env. Use Python 3.x
 | |
|                             if sys.version_info[0] == 2:
 | |
|                                 self.kobodir = os.path.join(winreg.ExpandEnvironmentStrings(u"%USERPROFILE%"), "Local Settings", "Application Data")
 | |
|                             else: 
 | |
|                                 self.kobodir = os.path.join(winreg.ExpandEnvironmentStrings("%USERPROFILE%"), "Local Settings", "Application Data")
 | |
|                     self.kobodir = os.path.join(self.kobodir, "Kobo", "Kobo Desktop Edition")
 | |
|                 elif sys.platform.startswith('darwin'):
 | |
|                     self.kobodir = os.path.join(os.environ['HOME'], "Library", "Application Support", "Kobo", "Kobo Desktop Edition")
 | |
|                 elif sys.platform.startswith('linux'):
 | |
| 
 | |
|                     #sets ~/.config/calibre as the location to store the kobodir location info file and creates this directory if necessary
 | |
|                     kobodir_cache_dir = os.path.join(os.environ['HOME'], ".config", "calibre")
 | |
|                     if not os.path.isdir(kobodir_cache_dir):
 | |
|                         os.mkdir(kobodir_cache_dir)
 | |
|                     
 | |
|                     #appends the name of the file we're storing the kobodir location info to the above path
 | |
|                     kobodir_cache_file = str(kobodir_cache_dir) + "/" + "kobo location"
 | |
|                     
 | |
|                     """if the above file does not exist, recursively searches from the root
 | |
|                     of the filesystem until kobodir is found and stores the location of kobodir
 | |
|                     in that file so this loop can be skipped in the future"""
 | |
|                     original_stdout = sys.stdout
 | |
|                     if not os.path.isfile(kobodir_cache_file):
 | |
|                         for root, dirs, files in os.walk('/'):
 | |
|                             for file in files:
 | |
|                                 if file == 'Kobo.sqlite':
 | |
|                                     kobo_linux_path = str(root)
 | |
|                                     with open(kobodir_cache_file, 'w') as f:
 | |
|                                         sys.stdout = f
 | |
|                                         print(kobo_linux_path, end='')
 | |
|                                         sys.stdout = original_stdout
 | |
| 
 | |
|                     f = open(kobodir_cache_file, 'r' )
 | |
|                     self.kobodir = f.read()
 | |
| 
 | |
|             # desktop versions use Kobo.sqlite
 | |
|             kobodb = os.path.join(self.kobodir, "Kobo.sqlite")
 | |
|             # check for existence of file
 | |
|             if (not(os.path.isfile(kobodb))):
 | |
|                 # give up here, we haven't found anything useful
 | |
|                 self.kobodir = u""
 | |
|                 kobodb  = u""
 | |
| 
 | |
|         if (self.kobodir != u""):
 | |
|             self.bookdir = os.path.join(self.kobodir, "kepub")
 | |
|             # make a copy of the database in a temporary file
 | |
|             # so we can ensure it's not using WAL logging which sqlite3 can't do.
 | |
|             self.newdb = tempfile.NamedTemporaryFile(mode='wb', delete=False)
 | |
|             print(self.newdb.name)
 | |
|             olddb = open(kobodb, 'rb')
 | |
|             self.newdb.write(olddb.read(18))
 | |
|             self.newdb.write(b'\x01\x01')
 | |
|             olddb.read(2)
 | |
|             self.newdb.write(olddb.read())
 | |
|             olddb.close()
 | |
|             self.newdb.close()
 | |
|             self.__sqlite = sqlite3.connect(self.newdb.name)
 | |
|             self.__sqlite.text_factory = lambda b: b.decode("utf-8", errors="ignore")
 | |
|             self.__cursor = self.__sqlite.cursor()
 | |
|             self._userkeys = []
 | |
|             self._books = []
 | |
|             self._volumeID = []
 | |
|             self._serials = serials
 | |
| 
 | |
|     def close (self):
 | |
|         """Closes the database used by the library."""
 | |
|         self.__cursor.close()
 | |
|         self.__sqlite.close()
 | |
|         # delete the temporary copy of the database
 | |
|         os.remove(self.newdb.name)
 | |
| 
 | |
|     @property
 | |
|     def userkeys (self):
 | |
|         """The list of potential userkeys being used by this library.
 | |
|         Only one of these will be valid.
 | |
|         """
 | |
|         if len(self._userkeys) != 0:
 | |
|             return self._userkeys
 | |
|         for macaddr in self.__getmacaddrs():
 | |
|             self._userkeys.extend(self.__getuserkeys(macaddr))
 | |
|         return self._userkeys
 | |
| 
 | |
|     @property
 | |
|     def books (self):
 | |
|         """The list of KoboBook objects in the library."""
 | |
|         if len(self._books) != 0:
 | |
|             return self._books
 | |
|         """Drm-ed kepub"""
 | |
|         for row in self.__cursor.execute('SELECT DISTINCT volumeid, Title, Attribution, Series FROM content_keys, content WHERE contentid = volumeid'):
 | |
|             self._books.append(KoboBook(row[0], row[1], self.__bookfile(row[0]), 'kepub', self.__cursor, author=row[2], series=row[3]))
 | |
|             self._volumeID.append(row[0])
 | |
|         """Drm-free"""
 | |
|         for f in os.listdir(self.bookdir):
 | |
|             if(f not in self._volumeID):
 | |
|                 row = self.__cursor.execute("SELECT Title, Attribution, Series FROM content WHERE ContentID = '" + f + "'").fetchone()
 | |
|                 if row is not None:
 | |
|                     fTitle = row[0]
 | |
|                     self._books.append(KoboBook(f, fTitle, self.__bookfile(f), 'drm-free', self.__cursor, author=row[1], series=row[2]))
 | |
|                     self._volumeID.append(f)
 | |
|         """Sort"""
 | |
|         self._books.sort(key=lambda x: x.title)
 | |
|         return self._books
 | |
| 
 | |
|     def __bookfile (self, volumeid):
 | |
|         """The filename needed to open a given book."""
 | |
|         return os.path.join(self.kobodir, "kepub", volumeid)
 | |
| 
 | |
|     def __getmacaddrs (self):
 | |
|         """The list of all MAC addresses on this machine."""
 | |
|         macaddrs = []
 | |
|         if sys.platform.startswith('win'):
 | |
|             c = re.compile('\s?(' + '[0-9a-f]{2}[:\-]' * 5 + '[0-9a-f]{2})(\s|$)', re.IGNORECASE)
 | |
|             try: 
 | |
|                 output = subprocess.Popen('ipconfig /all', shell=True, stdout=subprocess.PIPE, text=True).stdout
 | |
|                 for line in output:
 | |
|                     m = c.search(line)
 | |
|                     if m:
 | |
|                         macaddrs.append(re.sub("-", ":", m.group(1)).upper())
 | |
|             except:
 | |
|                 output = subprocess.Popen('wmic nic where PhysicalAdapter=True get MACAddress', shell=True, stdout=subprocess.PIPE, text=True).stdout
 | |
|                 for line in output:
 | |
|                     m = c.search(line)
 | |
|                     if m:
 | |
|                         macaddrs.append(re.sub("-", ":", m.group(1)).upper())
 | |
|         elif sys.platform.startswith('darwin'):
 | |
|             c = re.compile('\s(' + '[0-9a-f]{2}:' * 5 + '[0-9a-f]{2})(\s|$)', re.IGNORECASE)
 | |
|             output = subprocess.check_output('/sbin/ifconfig -a', shell=True, encoding='utf-8')
 | |
|             matches = c.findall(output)
 | |
|             for m in matches:
 | |
|                 # print "m:{0}".format(m[0])
 | |
|                 macaddrs.append(m[0].upper())
 | |
|         elif sys.platform.startswith('linux'):
 | |
|             for interface in os.listdir('/sys/class/net'):
 | |
|                 with open('/sys/class/net/' + interface + '/address', 'r') as f:
 | |
|                     mac = f.read().strip().upper()
 | |
|                 # some interfaces, like Tailscale's VPN interface, do not have a MAC address
 | |
|                 if mac != '':
 | |
|                     macaddrs.append(mac)
 | |
|         else:
 | |
|             # final fallback
 | |
|             # let's try ip
 | |
|             c = re.compile('\s(' + '[0-9a-f]{2}:' * 5 + '[0-9a-f]{2})(\s|$)', re.IGNORECASE)
 | |
|             for line in os.popen('ip -br link'):
 | |
|                 m = c.search(line)
 | |
|                 if m:
 | |
|                     macaddrs.append(m.group(1).upper())
 | |
| 
 | |
|             # let's try ipconfig under wine
 | |
|             c = re.compile('\s(' + '[0-9a-f]{2}-' * 5 + '[0-9a-f]{2})(\s|$)', re.IGNORECASE)
 | |
|             for line in os.popen('ipconfig /all'):
 | |
|                 m = c.search(line)
 | |
|                 if m:
 | |
|                     macaddrs.append(re.sub("-", ":", m.group(1)).upper())
 | |
| 
 | |
|         # extend the list of macaddrs in any case with the serials
 | |
|         # cannot hurt ;-)
 | |
|         macaddrs.extend(self._serials)
 | |
| 
 | |
|         return macaddrs
 | |
| 
 | |
|     def __getuserids (self):
 | |
|         userids = []
 | |
|         cursor = self.__cursor.execute('SELECT UserID FROM user')
 | |
|         row = cursor.fetchone()
 | |
|         while row is not None:
 | |
|             try:
 | |
|                 userid = row[0]
 | |
|                 userids.append(userid)
 | |
|             except:
 | |
|                 pass
 | |
|             row = cursor.fetchone()
 | |
|         return userids
 | |
| 
 | |
|     def __getuserkeys (self, macaddr):
 | |
|         userids = self.__getuserids()
 | |
|         userkeys = []
 | |
|         for hash in KOBO_HASH_KEYS:
 | |
|             deviceid = hashlib.sha256((hash + macaddr).encode('ascii')).hexdigest()
 | |
|             for userid in userids:
 | |
|                 userkey = hashlib.sha256((deviceid + userid).encode('ascii')).hexdigest()
 | |
|                 userkeys.append(binascii.a2b_hex(userkey[32:]))
 | |
|         return userkeys
 | |
| 
 | |
| class KoboBook(object):
 | |
|     """A Kobo book.
 | |
| 
 | |
|     A Kobo book contains a number of unencrypted and encrypted files.
 | |
|     This class provides a list of the encrypted files.
 | |
| 
 | |
|     Each book has the following instance variables:
 | |
|     volumeid - a UUID which uniquely refers to the book in this library.
 | |
|     title - the human-readable book title.
 | |
|     filename - the complete path and filename of the book.
 | |
|     type - either kepub or drm-free"""
 | |
|     def __init__ (self, volumeid, title, filename, type, cursor, author=None, series=None):
 | |
|         self.volumeid = volumeid
 | |
|         self.title = title
 | |
|         self.author = author
 | |
|         self.series = series
 | |
|         self.series_index = None
 | |
|         self.filename = filename
 | |
|         self.type = type
 | |
|         self.__cursor = cursor
 | |
|         self._encryptedfiles = {}
 | |
| 
 | |
|     @property
 | |
|     def encryptedfiles (self):
 | |
|         """A dictionary of KoboFiles inside the book.
 | |
| 
 | |
|         The dictionary keys are the relative pathnames, which are
 | |
|         the same as the pathnames inside the book 'zip' file."""
 | |
|         if (self.type == 'drm-free'):
 | |
|             return self._encryptedfiles
 | |
|         if len(self._encryptedfiles) != 0:
 | |
|             return self._encryptedfiles
 | |
|         # Read the list of encrypted files from the DB
 | |
|         for row in self.__cursor.execute('SELECT elementid,elementkey FROM content_keys,content WHERE volumeid = ? AND volumeid = contentid',(self.volumeid,)):
 | |
|             self._encryptedfiles[row[0]] = KoboFile(row[0], None, base64.b64decode(row[1]))
 | |
| 
 | |
|         # Read the list of files from the kepub OPF manifest so that
 | |
|         # we can get their proper MIME type.
 | |
|         # NOTE: this requires that the OPF file is unencrypted!
 | |
|         zin = zipfile.ZipFile(self.filename, "r")
 | |
|         xmlns = {
 | |
|             'ocf': 'urn:oasis:names:tc:opendocument:xmlns:container',
 | |
|             'opf': 'http://www.idpf.org/2007/opf'
 | |
|         }
 | |
|         ocf = ET.fromstring(zin.read('META-INF/container.xml'))
 | |
|         opffile = ocf.find('.//ocf:rootfile', xmlns).attrib['full-path']
 | |
|         basedir = re.sub('[^/]+$', '', opffile)
 | |
|         opf = ET.fromstring(zin.read(opffile))
 | |
|         zin.close()
 | |
| 
 | |
|         c = re.compile('/')
 | |
|         for item in opf.findall('.//opf:item', xmlns):
 | |
|             mimetype = item.attrib['media-type']
 | |
| 
 | |
|             # Convert relative URIs
 | |
|             href = item.attrib['href']
 | |
|             if not c.match(href):
 | |
|                 href = ''.join((basedir, href))
 | |
| 
 | |
|             # Update books we've found from the DB.
 | |
|             if href in self._encryptedfiles:
 | |
|                 self._encryptedfiles[href].mimetype = mimetype
 | |
|         return self._encryptedfiles
 | |
| 
 | |
|     @property
 | |
|     def has_drm (self):
 | |
|         return not self.type == 'drm-free'
 | |
| 
 | |
| 
 | |
| class KoboFile(object):
 | |
|     """An encrypted file in a KoboBook.
 | |
| 
 | |
|     Each file has the following instance variables:
 | |
|     filename - the relative pathname inside the book zip file.
 | |
|     mimetype - the file's MIME type, e.g. 'image/jpeg'
 | |
|     key - the encrypted page key."""
 | |
| 
 | |
|     def __init__ (self, filename, mimetype, key):
 | |
|         self.filename = filename
 | |
|         self.mimetype = mimetype
 | |
|         self.key = key
 | |
|     def decrypt (self, userkey, contents):
 | |
|         """
 | |
|         Decrypt the contents using the provided user key and the
 | |
|         file page key. The caller must determine if the decrypted
 | |
|         data is correct."""
 | |
|         # The userkey decrypts the page key (self.key)
 | |
|         decryptedkey = AES.new(userkey, AES.MODE_ECB).decrypt(self.key)
 | |
|         # The decrypted page key decrypts the content. Padding is PKCS#7
 | |
|         return unpad(AES.new(decryptedkey, AES.MODE_ECB).decrypt(contents), 16)
 | |
| 
 | |
|     def check (self, contents):
 | |
|         """
 | |
|         If the contents uses some known MIME types, check if it
 | |
|         conforms to the type. Throw a ValueError exception if not.
 | |
|         If the contents uses an uncheckable MIME type, don't check
 | |
|         it and don't throw an exception.
 | |
|         Returns True if the content was checked, False if it was not
 | |
|         checked."""
 | |
|         if self.mimetype == 'application/xhtml+xml':
 | |
|             # assume utf-8 with no BOM
 | |
|             textoffset = 0
 | |
|             stride = 1
 | |
|             print("Checking text:{0}:".format(contents[:10]))
 | |
|             # check for byte order mark
 | |
|             if contents[:3]==b"\xef\xbb\xbf":
 | |
|                 # seems to be utf-8 with BOM
 | |
|                 print("Could be utf-8 with BOM")
 | |
|                 textoffset = 3
 | |
|             elif contents[:2]==b"\xfe\xff":
 | |
|                 # seems to be utf-16BE
 | |
|                 print("Could be  utf-16BE")
 | |
|                 textoffset = 3
 | |
|                 stride = 2
 | |
|             elif contents[:2]==b"\xff\xfe":
 | |
|                 # seems to be utf-16LE
 | |
|                 print("Could be  utf-16LE")
 | |
|                 textoffset = 2
 | |
|                 stride = 2
 | |
|             else:
 | |
|                 print("Perhaps utf-8 without BOM")
 | |
| 
 | |
|             # now check that the first few characters are in the ASCII range
 | |
|             for i in range(textoffset,textoffset+5*stride,stride):
 | |
|                 if contents[i]<32 or contents[i]>127:
 | |
|                     # Non-ascii, so decryption probably failed
 | |
|                     print("Bad character at {0}, value {1}".format(i,contents[i]))
 | |
|                     raise ValueError
 | |
|             print("Seems to be good text")
 | |
|             return True
 | |
|             if contents[:5]==b"<?xml" or contents[:8]==b"\xef\xbb\xbf<?xml":
 | |
|                 # utf-8
 | |
|                 return True
 | |
|             elif contents[:14]==b"\xfe\xff\x00<\x00?\x00x\x00m\x00l":
 | |
|                 # utf-16BE
 | |
|                 return True
 | |
|             elif contents[:14]==b"\xff\xfe<\x00?\x00x\x00m\x00l\x00":
 | |
|                 # utf-16LE
 | |
|                 return True
 | |
|             elif contents[:9]==b"<!DOCTYPE" or contents[:12]==b"\xef\xbb\xbf<!DOCTYPE":
 | |
|                 # utf-8 of weird <!DOCTYPE start
 | |
|                 return True
 | |
|             elif contents[:22]==b"\xfe\xff\x00<\x00!\x00D\x00O\x00C\x00T\x00Y\x00P\x00E":
 | |
|                 # utf-16BE of weird <!DOCTYPE start
 | |
|                 return True
 | |
|             elif contents[:22]==b"\xff\xfe<\x00!\x00D\x00O\x00C\x00T\x00Y\x00P\x00E\x00":
 | |
|                 # utf-16LE of weird <!DOCTYPE start
 | |
|                 return True
 | |
|             else:
 | |
|                 print("Bad XML: {0}".format(contents[:8]))
 | |
|                 raise ValueError
 | |
|         elif self.mimetype == 'image/jpeg':
 | |
|             if contents[:3] == b'\xff\xd8\xff':
 | |
|                 return True
 | |
|             else:
 | |
|                 print("Bad JPEG: {0}".format(contents[:3].hex()))
 | |
|                 raise ValueError()
 | |
|         return False
 | |
| 
 | |
| 
 | |
| def decrypt_book(book, lib):
 | |
|     print("Converting {0}".format(book.title))
 | |
|     zin = zipfile.ZipFile(book.filename, "r")
 | |
|     # make filename out of Unicode alphanumeric and whitespace equivalents from title
 | |
|     outname = "{0}.epub".format(re.sub('[^\s\w]', '_', book.title, 0, re.UNICODE))
 | |
|     if (book.type == 'drm-free'):
 | |
|         print("DRM-free book, conversion is not needed")
 | |
|         shutil.copyfile(book.filename, outname)
 | |
|         print("Book saved as {0}".format(os.path.join(os.getcwd(), outname)))
 | |
|         return 0
 | |
|     result = 1
 | |
|     for userkey in lib.userkeys:
 | |
|         print("Trying key: {0}".format(userkey.hex()))
 | |
|         try:
 | |
|             zout = zipfile.ZipFile(outname, "w", zipfile.ZIP_DEFLATED)
 | |
|             for filename in zin.namelist():
 | |
|                 contents = zin.read(filename)
 | |
|                 if filename in book.encryptedfiles:
 | |
|                     file = book.encryptedfiles[filename]
 | |
|                     contents = file.decrypt(userkey, contents)
 | |
|                     # Parse failures mean the key is probably wrong.
 | |
|                     file.check(contents)
 | |
|                 zout.writestr(filename, contents)
 | |
|             zout.close()
 | |
|             print("Decryption succeeded.")
 | |
|             print("Book saved as {0}".format(os.path.join(os.getcwd(), outname)))
 | |
|             result = 0
 | |
|             break
 | |
|         except ValueError:
 | |
|             print("Decryption failed.")
 | |
|             zout.close()
 | |
|             os.remove(outname)
 | |
|     zin.close()
 | |
|     return result
 | |
| 
 | |
| 
 | |
| def cli_main():
 | |
|     description = __about__
 | |
|     epilog = "Parsing of arguments failed."
 | |
|     parser = argparse.ArgumentParser(prog=sys.argv[0], description=description, epilog=epilog)
 | |
|     parser.add_argument('--devicedir', default='/media/KOBOeReader', help="directory of connected Kobo device")
 | |
|     parser.add_argument('--all', action='store_true', help="flag for converting all books on device")
 | |
|     args = vars(parser.parse_args())
 | |
|     serials = []
 | |
|     devicedir = u""
 | |
|     if args['devicedir']:
 | |
|         devicedir = args['devicedir']
 | |
| 
 | |
|     lib = KoboLibrary(serials, devicedir)
 | |
| 
 | |
|     if args['all']:
 | |
|         books = lib.books
 | |
|     else:
 | |
|         for i, book in enumerate(lib.books):
 | |
|             print("{0}: {1}".format(i + 1, book.title))
 | |
|         print("Or 'all'")
 | |
| 
 | |
|         choice = input("Convert book number... ")
 | |
|         if choice == "all":
 | |
|             books = list(lib.books)
 | |
|         else:
 | |
|             try:
 | |
|                 num = int(choice)
 | |
|                 books = [lib.books[num - 1]]
 | |
|             except (ValueError, IndexError):
 | |
|                 print("Invalid choice. Exiting...")
 | |
|                 sys.exit()
 | |
| 
 | |
|     results = [decrypt_book(book, lib) for book in books]
 | |
|     lib.close()
 | |
|     overall_result = all(result != 0 for result in results)
 | |
|     if overall_result != 0:
 | |
|         print("Could not decrypt book with any of the keys found.")
 | |
|     return overall_result
 | |
| 
 | |
| 
 | |
| if __name__ == '__main__':
 | |
|     sys.stdout=SafeUnbuffered(sys.stdout)
 | |
|     sys.stderr=SafeUnbuffered(sys.stderr)
 | |
|     sys.exit(cli_main())
 |