diff --git a/.gitignore b/.gitignore index f3f3d471..dd09bd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,19 @@ # Cache /DeDRM_plugin/__pycache__ -/DeDRM_plugin/standalone/__pycache__ \ No newline at end of file +/DeDRM_plugin/standalone/__pycache__ +**/__pycache__ + +# Generated build artifacts — never committed, rebuilt on demand by dedrm.py +DeDRM_plugin.zip +DeDRM_tools.zip +DeDRM_plugin_build_tmp/ + +# User key config written by standalone CLI on first successful decryption +dedrm.json + +# AI agent working files +.claude/ + +# Generated HTML from markdown plan docs +*.html \ No newline at end of file diff --git a/DeDRM_plugin/__calibre_compat_code.py b/DeDRM_plugin/__calibre_compat_code.py index b44b8f39..3710d347 100644 --- a/DeDRM_plugin/__calibre_compat_code.py +++ b/DeDRM_plugin/__calibre_compat_code.py @@ -17,5 +17,58 @@ if "calibre" in sys.modules: # Explicitly set the package identifier so we are allowed to import stuff ... __package__ = "calibre_plugins.dedrm" +else: + # Standalone mode: make relative imports (from .utilities import ...) work + # without Calibre by installing a meta_path hook that maps dedrm.X -> X. + import types as _types + + _dedrm_pkg_name = "dedrm" + + class _DeDRMFinder: + """Acts as both finder and loader. + Intercepts 'import dedrm.X' and redirects it to the flat module 'X', + so that relative imports (from .utilities import ...) work standalone.""" + + def find_spec(self, fullname, path, target=None): + import importlib.util as _ilu + if fullname == _dedrm_pkg_name or fullname.startswith(_dedrm_pkg_name + "."): + return _ilu.spec_from_loader(fullname, loader=self, + is_package=(fullname == _dedrm_pkg_name)) + return None + + def create_module(self, spec): + if spec.name == _dedrm_pkg_name: + return sys.modules[_dedrm_pkg_name] + # For dedrm.X: import X as a top-level module first, then reuse it + subname = spec.name[len(_dedrm_pkg_name) + 1:] + if subname not in sys.modules: + import importlib as _il + _il.import_module(subname) + # Return a fresh module; exec_module will copy attrs from top-level + import types as _t + return _t.ModuleType(spec.name) + + def exec_module(self, module): + if module.__name__ == _dedrm_pkg_name: + return # stub package — nothing to execute + subname = module.__name__[len(_dedrm_pkg_name) + 1:] + if subname in sys.modules: + _skip = {'__name__', '__spec__', '__loader__', + '__package__', '__file__', '__cached__', '__builtins__'} + for _k, _v in sys.modules[subname].__dict__.items(): + if _k not in _skip: + module.__dict__[_k] = _v + + if _dedrm_pkg_name not in sys.modules: + _dedrm_pkg = _types.ModuleType(_dedrm_pkg_name) + _dedrm_pkg.__path__ = [] + _dedrm_pkg.__package__ = _dedrm_pkg_name + _dedrm_pkg.__spec__ = None + sys.modules[_dedrm_pkg_name] = _dedrm_pkg + + if not any(type(f).__name__ == "_DeDRMFinder" for f in sys.meta_path): + sys.meta_path.append(_DeDRMFinder()) + + __package__ = _dedrm_pkg_name #@@CALIBRE_COMPAT_CODE_END@@ diff --git a/DeDRM_plugin/prefs.py b/DeDRM_plugin/prefs.py index 0ae39434..f4031962 100755 --- a/DeDRM_plugin/prefs.py +++ b/DeDRM_plugin/prefs.py @@ -17,7 +17,13 @@ except: from standalone.jsonconfig import JSONConfig -from __init__ import PLUGIN_NAME +try: + from __version__ import PLUGIN_NAME +except ImportError: + try: + from __init__ import PLUGIN_NAME # type: ignore + except ImportError: + PLUGIN_NAME = "DeDRM" # standalone fallback class DeDRM_Prefs(): def __init__(self, json_path=None): diff --git a/DeDRM_plugin/standalone/__init__.py b/DeDRM_plugin/standalone/__init__.py index 60b615bf..41c0e11f 100644 --- a/DeDRM_plugin/standalone/__init__.py +++ b/DeDRM_plugin/standalone/__init__.py @@ -178,10 +178,10 @@ def execute_action(action, filenames, params): elif action == "remove_drm": if not os.path.isfile(os.path.abspath(config_file_path)): - print("Config file missing ...") - + print("Config file not found — will use defaults and auto-discover keys.") + from standalone.remove_drm import perform_action - perform_action(params, filenames) + sys.exit(perform_action(params, filenames)) elif action == "config": import prefs diff --git a/DeDRM_plugin/standalone/remove_drm.py b/DeDRM_plugin/standalone/remove_drm.py index a67bc6f4..72aa1eb9 100644 --- a/DeDRM_plugin/standalone/remove_drm.py +++ b/DeDRM_plugin/standalone/remove_drm.py @@ -8,20 +8,10 @@ # Copyright © 2021 NoDRM -""" - -NOTE: This code is not functional (yet). I started working on it a while ago -to make a standalone version of the plugins that could work without Calibre, -too, but for now there's only a rough code structure and no working code yet. - -Currently, to use these plugins, you will need to use Calibre. Hopwfully that'll -change in the future. - -""" - #@@CALIBRE_COMPAT_CODE@@ import os, sys +import codecs, time, shutil, traceback, tempfile from zipfile import ZipInfo, ZipFile, ZIP_STORED, ZIP_DEFLATED from contextlib import closing @@ -31,14 +21,18 @@ iswindows = sys.platform.startswith('win') isosx = sys.platform.startswith('darwin') +try: + from __version import PLUGIN_NAME, PLUGIN_VERSION +except ImportError: + from __init__ import PLUGIN_NAME, PLUGIN_VERSION # type: ignore + def print_removedrm_help(): - from __init__ import PLUGIN_NAME, PLUGIN_VERSION print(PLUGIN_NAME + " v" + PLUGIN_VERSION + " - Calibre DRM removal plugin by noDRM") print() print("remove_drm: Remove DRM from one or multiple files") print() print_std_usage("remove_drm", " ... [ -o ] [ -f ]") - + print() print("Options: ") print_opt(None, "outputdir", "Folder to export the file(s) to") @@ -49,7 +43,7 @@ def print_removedrm_help(): def determine_file_type(file): # Returns a file type: - # "PDF", "PDB", "MOBI", "TPZ", "LCP", "ADEPT", "ADEPT-PassHash", "KFX-ZIP", "ZIP" or None + # "PDF", "PDB", "MOBI", "TPZ", "KFX", "LCP", "ADEPT", "ADEPT-PassHash", "KFX-ZIP", "ZIP" or None f = open(file, "rb") fdata = f.read(100) @@ -57,7 +51,7 @@ def determine_file_type(file): if fdata.startswith(b"PK\x03\x04"): pass - # Either LCP, Adobe, or Amazon + # Either LCP, Adobe, or Amazon — fall through to ZIP analysis below elif fdata.startswith(b"%PDF"): return "PDF" elif fdata[0x3c:0x3c+8] == b"PNRdPPrs" or fdata[0x3c:0x3c+8] == b"PDctPPrs": @@ -66,12 +60,15 @@ def determine_file_type(file): return "MOBI" elif fdata.startswith(b"TPZ"): return "TPZ" - else: + elif fdata[:8] == b'\xeaDRMION\xee': + # Plain KFX file (not in a ZIP container) + return "KFX" + else: return None # Unknown file type - - # If it's a ZIP, determine the type. + + # If it's a ZIP, determine the type. from lcpdedrm import isLCPbook if isLCPbook(file): @@ -84,7 +81,7 @@ def determine_file_type(file): else: return "ADEPT" - try: + try: # Amazon / KFX-ZIP has a file that starts with b'\xeaDRMION\xee' in the ZIP. with closing(ZipFile(open(file, "rb"))) as book: for subfilename in book.namelist(): @@ -97,34 +94,552 @@ def determine_file_type(file): return "ZIP" - +def _make_temp_file(suffix): + """Create a named temp file that persists on disk. Returns the path string.""" + fd, path = tempfile.mkstemp(suffix=suffix) + os.close(fd) + return path -def dedrm_single_file(input_file, output_file): - # When this runs, all the stupid file handling is done. - # Just take the file at the absolute path "input_file" - # and export it, DRM-free, to "output_file". - # Use a temp file as input_file and output_file - # might be identical. +def _pdf_inept_decrypt(input_file, dedrmprefs, starttime): + """ + Try to decrypt an Adobe Adept / B&N EBX-encrypted PDF. + Returns the path to the decrypted temp file on success, None on failure. + """ + import ineptpdf - # The output directory might not exist yet. - print("File " + input_file + " to " + output_file) + book_uuid = None + try: + book_uuid = ineptpdf.adeptGetUserUUID(input_file) + except: + pass - # Okay, first check the file type and don't rely on the extension. - try: + if book_uuid is not None: + print("{0} v{1}: PDF is licensed for UUID {2}".format(PLUGIN_NAME, PLUGIN_VERSION, book_uuid)) + # Try UUID-matched key first + for keyname, userkeyhex in dedrmprefs['adeptkeys'].items(): + if book_uuid.lower() not in keyname.lower(): + continue + print("{0} v{1}: Trying UUID-matched key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + tmp = _make_temp_file(".pdf") + try: + userkey = codecs.decode(userkeyhex, 'hex') + result = ineptpdf.decryptBook(userkey, input_file, tmp) + if result == 0: + print("{0} v{1}: Decrypted with key {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + return tmp + except ineptpdf.ADEPTNewVersionError: + print("{0} v{1}: Unsupported (too new) Adobe DRM version.".format(PLUGIN_NAME, PLUGIN_VERSION)) + os.remove(tmp) + return None + except: + traceback.print_exc() + os.remove(tmp) + + # Try all stored adept keys + for keyname, userkeyhex in dedrmprefs['adeptkeys'].items(): + print("{0} v{1}: Trying key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + tmp = _make_temp_file(".pdf") + try: + userkey = codecs.decode(userkeyhex, 'hex') + result = ineptpdf.decryptBook(userkey, input_file, tmp) + if result == 0: + print("{0} v{1}: Decrypted with key {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + return tmp + except ineptpdf.ADEPTNewVersionError: + print("{0} v{1}: Unsupported (too new) Adobe DRM version.".format(PLUGIN_NAME, PLUGIN_VERSION)) + os.remove(tmp) + return None + except: + traceback.print_exc() + result = 1 + os.remove(tmp) + print("{0} v{1}: Failed with key {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + + # Auto-discover new ADE keys (Win/Mac only) + if iswindows or isosx: + print("{0} v{1}: Looking for new ADE keys ...".format(PLUGIN_NAME, PLUGIN_VERSION)) + try: + from adobekey import adeptkeys + defaultkeys, defaultnames = adeptkeys() + newkeys = [] + newnames = [] + for i, keyvalue in enumerate(defaultkeys): + if codecs.encode(keyvalue, 'hex').decode('ascii') not in dedrmprefs['adeptkeys'].values(): + newkeys.append(keyvalue) + newnames.append("default_ade_key_uuid_" + defaultnames[i]) + + for i, userkey in enumerate(newkeys): + print("{0} v{1}: Trying new discovered ADE key".format(PLUGIN_NAME, PLUGIN_VERSION)) + tmp = _make_temp_file(".pdf") + try: + result = ineptpdf.decryptBook(userkey, input_file, tmp) + if result == 0: + print("{0} v{1}: Decrypted with new key after {2:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, time.time()-starttime)) + try: + dedrmprefs.addnamedvaluetoprefs('adeptkeys', newnames[i], codecs.encode(userkey, 'hex').decode('ascii')) + dedrmprefs.writeprefs() + except: + traceback.print_exc() + return tmp + except: + traceback.print_exc() + os.remove(tmp) + except: + traceback.print_exc() + + # Try B&N keys + for keyname, userkey in dedrmprefs['bandnkeys'].items(): + print("{0} v{1}: Trying B&N key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + tmp = _make_temp_file(".pdf") + try: + result = ineptpdf.decryptBook(userkey, input_file, tmp, False) + if result == 0: + print("{0} v{1}: Decrypted with B&N key {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + return tmp + except ineptpdf.ADEPTNewVersionError: + print("{0} v{1}: Unsupported (too new) Adobe DRM version.".format(PLUGIN_NAME, PLUGIN_VERSION)) + os.remove(tmp) + return None + except: + traceback.print_exc() + os.remove(tmp) + + print("{0} v{1}: Failed to decrypt PDF with any available key.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return None + + +def _pdf_standard_decrypt(input_file, dedrmprefs, starttime): + """ + Try to decrypt a Standard/password-encrypted PDF. + Returns the path to the decrypted temp file on success, None on failure. + """ + import ineptpdf + + + for i, pw in enumerate([""] + list(dedrmprefs['adobe_pdf_passphrases'])): + label = "empty password" if i == 0 else "password {}".format(i) + print("{0} v{1}: Trying {2} ...".format(PLUGIN_NAME, PLUGIN_VERSION, label), end="") + tmp = _make_temp_file(".pdf") + try: + result = ineptpdf.decryptBook(bytearray(pw, "utf-8"), input_file, tmp) + print(" done") + if result == 0: + print("{0} v{1}: Decrypted with {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, label, time.time()-starttime)) + return tmp + except ineptpdf.ADEPTInvalidPasswordError: + print(" invalid password") + except: + print(" exception") + traceback.print_exc() + os.remove(tmp) + + print("{0} v{1}: Failed to decrypt PDF — add the correct password to the config.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return None + + +def dedrm_single_file(input_file, output_file, config_path): + """ + Remove DRM from input_file and write the result to output_file. + Returns True on success, False on failure (no output file written on failure). + """ + + + starttime = time.time() + print("{0} v{1}: Trying to decrypt {2}".format(PLUGIN_NAME, PLUGIN_VERSION, os.path.basename(input_file))) + + try: ftype = determine_file_type(input_file) - except: - print("Can't determine file type for this file.") - ftype = None - - if ftype is None: - return + except Exception as e: + print("{0} v{1}: Can't determine file type: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e)) + return False + + if ftype is None: + print("{0} v{1}: Unknown or unsupported file format.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + print("{0} v{1}: Detected file type: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, ftype)) + + import prefs + dedrmprefs = prefs.DeDRM_Prefs(os.path.abspath(config_path)) + + decrypted_file = None + is_epub = False + temp_files = [] # track all temp files for cleanup + + try: + # ------------------------------------------------------------------ # + # Kindle / Mobipocket / Topaz / KFX # + # ------------------------------------------------------------------ # + if ftype in ("MOBI", "TPZ", "KFX", "KFX-ZIP"): + import k4mobidedrm + + pids = list(dedrmprefs['pids']) + serials = list(dedrmprefs['serials']) + for android_serials in dedrmprefs['androidkeys'].values(): + serials.extend(android_serials) + kindleDatabases = list(dedrmprefs['kindlekeys'].items()) + + book = None + try: + book = k4mobidedrm.GetDecryptedBook(input_file, kindleDatabases, [], serials, pids, starttime) + except Exception as e: + print("{0} v{1}: Failed to decrypt with stored keys: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e)) + traceback.print_exc() + + # Auto-discover new Kindle keys (Win/Mac only) + if iswindows or isosx: + print("{0} v{1}: Looking for new Kindle keys ...".format(PLUGIN_NAME, PLUGIN_VERSION)) + try: + from kindlekey import kindlekeys + defaultkeys = kindlekeys() + newkeys = {} + for i, keyvalue in enumerate(defaultkeys): + if keyvalue not in dedrmprefs['kindlekeys'].values(): + newkeys["key_{0:d}".format(i)] = keyvalue + + if newkeys: + print("{0} v{1}: Found {2} new Kindle key(s), trying ...".format(PLUGIN_NAME, PLUGIN_VERSION, len(newkeys))) + book = k4mobidedrm.GetDecryptedBook(input_file, list(newkeys.items()), [], [], [], starttime) + # Save successful keys + for keyvalue in newkeys.values(): + dedrmprefs.addnamedvaluetoprefs('kindlekeys', "kindle_key_{0:d}".format(int(time.time())), keyvalue) + dedrmprefs.writeprefs() + except Exception as e2: + print("{0} v{1}: Auto-discovery also failed: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e2)) + traceback.print_exc() + + if book is None: + print("{0} v{1}: Ultimately failed to decrypt Kindle book.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + ext = book.getBookExtension() + decrypted_file = _make_temp_file(ext) + temp_files.append(decrypted_file) + book.getFile(decrypted_file) + book.cleanup() + + # ------------------------------------------------------------------ # + # eReader PDB # + # ------------------------------------------------------------------ # + elif ftype == "PDB": + import erdr2pml + + for keyname, userkey in dedrmprefs['ereaderkeys'].items(): + print("{0} v{1}: Trying eReader key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + tmp = _make_temp_file(".pmlz") + temp_files.append(tmp) + result = erdr2pml.decryptBook(input_file, tmp, True, codecs.decode(userkey, 'hex')) + if result == 0: + print("{0} v{1}: Decrypted with key {2} after {3:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + decrypted_file = tmp + break + temp_files.remove(tmp) + os.remove(tmp) + + if decrypted_file is None: + print("{0} v{1}: Failed to decrypt eReader book — no matching key found.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + # ------------------------------------------------------------------ # + # PDF # + # ------------------------------------------------------------------ # + elif ftype == "PDF": + import ineptpdf + import lcpdedrm + + if lcpdedrm.isLCPbook(input_file): + try: + decrypted_file = lcpdedrm.decryptLCPbook(input_file, dedrmprefs['lcp_passphrases'], None) + temp_files.append(decrypted_file) + except Exception as e: + print("{0} v{1}: LCP decryption failed: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e)) + return False + else: + enc = ineptpdf.getPDFencryptionType(input_file) + if enc is None: + print("{0} v{1}: PDF is DRM-free, copying as-is.".format(PLUGIN_NAME, PLUGIN_VERSION)) + decrypted_file = input_file # signal: just copy, don't delete + elif enc == "EBX_HANDLER": + print("{0} v{1}: PDF uses Adobe Adept (EBX) encryption.".format(PLUGIN_NAME, PLUGIN_VERSION)) + decrypted_file = _pdf_inept_decrypt(input_file, dedrmprefs, starttime) + if decrypted_file: + temp_files.append(decrypted_file) + elif enc in ("Standard", "Adobe.APS"): + print("{0} v{1}: PDF uses Standard/password encryption ({2}).".format(PLUGIN_NAME, PLUGIN_VERSION, enc)) + decrypted_file = _pdf_standard_decrypt(input_file, dedrmprefs, starttime) + if decrypted_file: + temp_files.append(decrypted_file) + elif enc in ("FOPN_fLock", "FOPN_foweb"): + print("{0} v{1}: FileOpen encryption '{2}' is unsupported.".format(PLUGIN_NAME, PLUGIN_VERSION, enc)) + return False + else: + print("{0} v{1}: Unsupported PDF encryption type: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, enc)) + return False + + if decrypted_file is None: + return False + + # ------------------------------------------------------------------ # + # ePub: LCP, Adobe Adept, B&N PassHash, or DRM-free # + # ------------------------------------------------------------------ # + elif ftype in ("LCP", "ADEPT", "ADEPT-PassHash", "ZIP"): + import zipfix + import ineptepub + import lcpdedrm + import epubfontdecrypt + is_epub = True + + # Step 1: repair ZIP + tmp_fixed = _make_temp_file(".epub") + temp_files.append(tmp_fixed) + try: + print("{0} v{1}: Verifying zip archive integrity".format(PLUGIN_NAME, PLUGIN_VERSION)) + zipfix.fixZip(input_file, tmp_fixed).fix() + except Exception as e: + print("{0} v{1}: Error checking zip archive: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e)) + raise + + if ftype == "LCP": + try: + decrypted_file = lcpdedrm.decryptLCPbook(tmp_fixed, dedrmprefs['lcp_passphrases'], None) + temp_files.append(decrypted_file) + except Exception as e: + print("{0} v{1}: LCP decryption failed: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, e)) + return False + + elif ftype == "ADEPT-PassHash": + print("{0} v{1}: Book is B&N PassHash-encrypted.".format(PLUGIN_NAME, PLUGIN_VERSION)) + + def _try_passhash_key(userkey, keyname): + """Returns temp path on success, None on failure.""" + tmp = _make_temp_file(".epub") + try: + result = ineptepub.decryptBook(userkey, tmp_fixed, tmp) + if result == 0: + print("{0} v{1}: Decrypted with key {2} after {3:.1f}s".format( + PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + return tmp + except: + traceback.print_exc() + os.remove(tmp) + return None + + # Try stored keys + for keyname, userkey in dedrmprefs['bandnkeys'].items(): + print("{0} v{1}: Trying B&N key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + result = _try_passhash_key(userkey, keyname) + if result: + decrypted_file = result + temp_files.append(decrypted_file) + break + + # Auto-discover new keys if stored keys didn't work + if decrypted_file is None and (iswindows or isosx): + print("{0} v{1}: Looking for new B&N/PassHash keys ...".format(PLUGIN_NAME, PLUGIN_VERSION)) + newkeys = [] + + try: + from ignoblekeyNookStudy import nookkeys + for kv in nookkeys(): + if kv not in dedrmprefs['bandnkeys'].values() and kv not in newkeys: + newkeys.append(("nook_key_{0}".format(int(time.time())), kv)) + except: + traceback.print_exc() + + if iswindows: + try: + from ignoblekeyWindowsStore import dump_keys as dump_nook_keys + for kv in dump_nook_keys(False): + if kv not in dedrmprefs['bandnkeys'].values() and kv not in [k for _, k in newkeys]: + newkeys.append(("nook_store_key_{0}".format(int(time.time())), kv)) + except: + traceback.print_exc() + + try: + from adobekey_get_passhash import passhash_keys, ADEPTError + try: + ph_keys, ph_names = passhash_keys() + for i, kv in enumerate(ph_keys): + if kv not in dedrmprefs['bandnkeys'].values() and kv not in [k for _, k in newkeys]: + newkeys.append(("ade_passhash_{0}".format(int(time.time())), kv)) + except ADEPTError: + pass + except: + traceback.print_exc() + + # BUG FIX: try ALL new keys before giving up (don't return inside loop) + for keyname, userkey in newkeys: + if not userkey: + continue + print("{0} v{1}: Trying new discovered key".format(PLUGIN_NAME, PLUGIN_VERSION)) + result = _try_passhash_key(userkey, keyname) + if result: + decrypted_file = result + temp_files.append(decrypted_file) + try: + dedrmprefs.addnamedvaluetoprefs('bandnkeys', keyname, userkey) + dedrmprefs.writeprefs() + except: + traceback.print_exc() + break + + if decrypted_file is None: + print("{0} v{1}: Failed to decrypt B&N ePub — no matching key found.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + elif ftype == "ADEPT": + print("{0} v{1}: Book is Adobe Adept-encrypted.".format(PLUGIN_NAME, PLUGIN_VERSION)) + + book_uuid = None + try: + book_uuid = ineptepub.adeptGetUserUUID(tmp_fixed) + except: + pass + + if book_uuid: + print("{0} v{1}: Book is licensed for UUID {2}".format(PLUGIN_NAME, PLUGIN_VERSION, book_uuid)) + + def _try_adept_key(userkeyhex, keyname): + """Returns temp path on success, None on failure. Raises ADEPTNewVersionError.""" + tmp = _make_temp_file(".epub") + try: + userkey = codecs.decode(userkeyhex, 'hex') + result = ineptepub.decryptBook(userkey, tmp_fixed, tmp) + if result == 0: + print("{0} v{1}: Decrypted with key {2} after {3:.1f}s".format( + PLUGIN_NAME, PLUGIN_VERSION, keyname, time.time()-starttime)) + return tmp + except ineptepub.ADEPTNewVersionError: + os.remove(tmp) + raise + except: + traceback.print_exc() + os.remove(tmp) + return None + + try: + # Try UUID-matched key first + if book_uuid: + for keyname, userkeyhex in dedrmprefs['adeptkeys'].items(): + if book_uuid.lower() not in keyname.lower(): + continue + print("{0} v{1}: Trying UUID-matched key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + result = _try_adept_key(userkeyhex, keyname) + if result: + decrypted_file = result + temp_files.append(decrypted_file) + break + + # Try all stored keys + if decrypted_file is None: + for keyname, userkeyhex in dedrmprefs['adeptkeys'].items(): + print("{0} v{1}: Trying key {2}".format(PLUGIN_NAME, PLUGIN_VERSION, keyname)) + result = _try_adept_key(userkeyhex, keyname) + if result: + decrypted_file = result + temp_files.append(decrypted_file) + break + + # Auto-discover new ADE keys (Win/Mac only) + if decrypted_file is None and (iswindows or isosx): + print("{0} v{1}: Looking for new ADE keys ...".format(PLUGIN_NAME, PLUGIN_VERSION)) + from adobekey import adeptkeys + defaultkeys, defaultnames = adeptkeys() + newkeys = [] + newnames = [] + for i, keyvalue in enumerate(defaultkeys): + if codecs.encode(keyvalue, 'hex').decode('ascii') not in dedrmprefs['adeptkeys'].values(): + newkeys.append(keyvalue) + newnames.append("default_ade_key_uuid_" + defaultnames[i]) + + for i, userkey in enumerate(newkeys): + print("{0} v{1}: Trying new discovered ADE key".format(PLUGIN_NAME, PLUGIN_VERSION)) + userkeyhex = codecs.encode(userkey, 'hex').decode('ascii') + tmp = _make_temp_file(".epub") + try: + result = ineptepub.decryptBook(userkey, tmp_fixed, tmp) + if result == 0: + print("{0} v{1}: Decrypted with new key after {2:.1f}s".format(PLUGIN_NAME, PLUGIN_VERSION, time.time()-starttime)) + decrypted_file = tmp + temp_files.append(decrypted_file) + try: + dedrmprefs.addnamedvaluetoprefs('adeptkeys', newnames[i], userkeyhex) + dedrmprefs.writeprefs() + except: + traceback.print_exc() + break + except ineptepub.ADEPTNewVersionError: + os.remove(tmp) + raise + except: + traceback.print_exc() + if decrypted_file is None: + os.remove(tmp) + + except ineptepub.ADEPTNewVersionError: + print("{0} v{1}: Unsupported (too new) Adobe DRM version.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + if decrypted_file is None: + print("{0} v{1}: Ultimately failed to decrypt Adobe ePub.".format(PLUGIN_NAME, PLUGIN_VERSION)) + print("{0} v{1}: Read the FAQs at noDRM's repository: https://github.com/noDRM/DeDRM_tools/blob/master/FAQs.md".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + else: # "ZIP" = DRM-free ePub + print("{0} v{1}: ePub appears to be DRM-free.".format(PLUGIN_NAME, PLUGIN_VERSION)) + decrypted_file = tmp_fixed + + # Font deobfuscation post-processing + if dedrmprefs['deobfuscate_fonts']: + import epubfontdecrypt + try: + tmp_fonts = _make_temp_file(".epub") + ret = epubfontdecrypt.decryptFontsBook(decrypted_file, tmp_fonts) + if ret == 0: + # Fonts were deobfuscated — use the new file + if decrypted_file != input_file and decrypted_file in temp_files: + temp_files.remove(decrypted_file) + os.remove(decrypted_file) + decrypted_file = tmp_fonts + temp_files.append(decrypted_file) + else: + # ret == 1: no fonts to deobfuscate (no-op); other: error + os.remove(tmp_fonts) + except: + pass # font deobfuscation is best-effort + + else: + print("{0} v{1}: Unsupported file type: {2}".format(PLUGIN_NAME, PLUGIN_VERSION, ftype)) + return False + + # ------------------------------------------------------------------ # + # Copy result to output_file # + # ------------------------------------------------------------------ # + if decrypted_file is None: + print("{0} v{1}: Decryption failed — no output written.".format(PLUGIN_NAME, PLUGIN_VERSION)) + return False + + output_dir = os.path.dirname(os.path.abspath(output_file)) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + shutil.copy2(decrypted_file, output_file) + print("{0} v{1}: Saved DRM-free file to {2}".format(PLUGIN_NAME, PLUGIN_VERSION, output_file)) + print("{0} v{1}: Finished after {2:.1f} seconds".format(PLUGIN_NAME, PLUGIN_VERSION, time.time()-starttime)) + return True + + finally: + # Clean up all temp files, but never delete input_file + for tmp in temp_files: + try: + if tmp != input_file and os.path.exists(tmp): + os.remove(tmp) + except: + pass - - - def perform_action(params, files): output = None @@ -132,6 +647,8 @@ def perform_action(params, files): force = False overwrite_original = False + import standalone + config_path = standalone.config_file_path if len(files) == 0: print_removedrm_help() @@ -160,17 +677,16 @@ def perform_action(params, files): print("Output file already exists. Use --force to overwrite.", file=sys.stderr) return 1 - if output is not None and len(files) > 1: print("Cannot set output file name if there's multiple input files.", file=sys.stderr) return 1 - - if outputdir is not None and output is not None and os.path.isabs(output): + + if outputdir is not None and output is not None and os.path.isabs(output): print("--output parameter is absolute path despite --outputdir being set.", file=sys.stderr) print("Remove --outputdir, or give a relative path to --output.", file=sys.stderr) return 1 - + any_failed = False for file in files: @@ -178,6 +694,7 @@ def perform_action(params, files): if not os.path.isfile(file): print("Skipping file " + file + " - not found.", file=sys.stderr) + any_failed = True continue if overwrite_original: @@ -187,7 +704,7 @@ def perform_action(params, files): # Due to the check above, we DO only have one file here. if outputdir is not None and not os.path.isabs(output): output_filename = os.path.join(outputdir, output) - else: + else: output_filename = os.path.abspath(output) else: if outputdir is None: @@ -200,21 +717,17 @@ def perform_action(params, files): fn, f_ext = os.path.splitext(output_filename) output_filename = fn + "_nodrm" + f_ext - - if os.path.isfile(output_filename) and not force: print("Skipping file " + file + " because output file already exists (use --force).", file=sys.stderr) + any_failed = True continue - - - dedrm_single_file(file, output_filename) - - + success = dedrm_single_file(file, output_filename, config_path) + if not success: + any_failed = True + return 1 if any_failed else 0 - return 0 - if __name__ == "__main__": - print("This code is not intended to be executed directly!", file=sys.stderr) \ No newline at end of file + print("This code is not intended to be executed directly!", file=sys.stderr) diff --git a/STANDALONE_CLI.md b/STANDALONE_CLI.md new file mode 100644 index 00000000..64283042 --- /dev/null +++ b/STANDALONE_CLI.md @@ -0,0 +1,236 @@ +# DeDRM Standalone CLI + +Remove DRM from ebooks **without Calibre** using a single Python command. + +Supports Adobe Digital Editions (ADEPT), Barnes & Noble PassHash, Kindle / +Mobipocket / KFX, eReader PDB, and standard password-encrypted PDFs. + +--- + +## Quick Start + +```bash +python3 dedrm.py remove_drm "My Book.epub" -o "My Book (DRM-free).epub" +``` + +That's it. On first run `dedrm.py` automatically: +1. Installs missing Python dependencies (`lxml`, `pycryptodome`) +2. Builds the plugin zip from source (cached locally, rebuilt if source changes) +3. Runs the CLI + +Subsequent runs skip straight to step 3. + +--- + +## Usage + +``` +python3 dedrm.py remove_drm [options] + +Options: + -o / --output Write output to this file path + --outputdir Write output to this directory (keeps original filename) + --overwrite Replace the input file in-place (implies --force) + -f / --force Overwrite output file if it already exists + --config Path to JSON key config (default: dedrm.json in cwd) + +Examples: + python3 dedrm.py remove_drm book.epub -o book_nodrm.epub + python3 dedrm.py remove_drm book.epub --overwrite + python3 dedrm.py remove_drm book.epub --outputdir ./clean/ + python3 dedrm.py remove_drm book.epub --config ~/.config/dedrm.json +``` + +> **Windows note:** shell glob expansion (`*.epub`) does not work in cmd.exe or +> PowerShell. Pass files individually or use a wrapper script. + +**Exit codes:** `0` = success (DRM-free file written), `1` = failure (decryption +failed, file not found, no matching key). + +**Output:** progress and status messages go to **stdout**; usage errors and +argument problems go to **stderr**. + +--- + +## How Keys Are Found + +The tool tries keys in this order for each book: + +### 1. Stored keys (`dedrm.json`) + +On every run, stored keys are tried first. Keys are read from a JSON file +(default: `dedrm.json` in the current working directory): + +```json +{ + "adeptkeys": { "my_ade_key": "" }, + "kindlekeys": { "my_kindle_key": "" }, + "bandnkeys": { "my_nook_key": "" }, + "serials": ["1234567890abcdef"], + "pids": [] +} +``` + +Use `--config ` to specify a different location. This is useful for +keeping keys in a stable place (e.g. `~/.config/dedrm/keys.json`) so they +persist across working directory changes. + +### 2. Auto-discovery (Windows and macOS only — fallback) + +If stored keys fail or don't exist, the tool automatically extracts keys from +locally installed applications: + +| App | Key type extracted | Platform | +|---|---|---| +| Adobe Digital Editions (ADE) | Adobe ADEPT key | Windows, macOS | +| Kindle for PC / Kindle for Mac | Kindle database key | Windows, macOS | +| NOOK Study | B&N PassHash key | Windows, macOS | +| NOOK (Microsoft Store) | B&N PassHash key | Windows only | + +Successfully discovered keys are saved to `dedrm.json` automatically, so +subsequent runs use stored keys and don't need the app installed. + +On Linux, auto-discovery is unavailable. Populate `dedrm.json` by exporting +keys from ADE or Kindle on a Windows/macOS machine and transferring the file. + +--- + +## Supported Formats + +| Format | DRM Type | Notes | +|---|---|---| +| `.epub` | Adobe ADEPT | Most library ebooks (OverDrive, etc.) | +| `.epub` | B&N PassHash | Barnes & Noble ebooks | +| `.epub` | LCP | **Not supported** — DMCA takedown | +| `.epub` | None | Passed through unchanged | +| `.pdf` | Adobe ADEPT (EBX) | ADE-protected PDFs | +| `.pdf` | Standard / password | Tries empty password then stored passphrases | +| `.pdf` | FileOpen | Not supported | +| `.mobi` `.azw` `.azw3` | Kindle | Requires Kindle for PC/Mac key | +| `.kfx` `.kfx-zip` | Kindle KFX | Same key as above | +| `.pdb` | eReader | Requires stored key in `dedrm.json` | + +--- + +## Using as an Agent Skill + +This repo is designed to be pulled and invoked by AI agents (e.g. Claude, OpenClaw, +or any tool-use agent) as a standalone skill — no Calibre, no manual setup, no GUI. + +### Invocation + +```bash +python3 dedrm.py remove_drm "" -o "" +``` + +Check the exit code: `0` = success, `1` = failure. Progress goes to stdout. + +### Skill descriptor + +```json +{ + "name": "dedrm", + "description": "Remove DRM from ebooks (Adobe ADEPT, Kindle, B&N, eReader, PDF). Auto-discovers keys from ADE/Kindle installed on the same machine. Keys cached in dedrm.json after first successful decryption.", + "requires": ["python3", "pip"], + "invoke": "python3 dedrm.py remove_drm -o ", + "exit_codes": { + "0": "success — DRM-free output written", + "1": "failure — decryption failed, no matching key, or file not found" + } +} +``` + +### Trust model + +An agent can audit every line that will be executed before running anything: + +``` +1. clone repo +2. read dedrm.py — ~115 lines: dep check, stale-zip detection, subprocess call +3. read build_plugin.py — ~65 lines: copies source into zip, inlines compat shim +4. read DeDRM_plugin/*.py — ~48 Python modules, all DRM logic, fully readable source +5. decide to trust +6. python3 dedrm.py remove_drm book.epub -o book_nodrm.epub + → installs deps if missing + → builds DeDRM_plugin.zip from audited source if missing or stale + → runs the zip +``` + +**No binary blobs committed. No network calls at runtime. No eval/exec of +downloaded code.** The zip is a local build cache — `.gitignored`, never +committed, always derived from the source the agent already read. + +--- + +## Architecture and Design Decisions + +### Why a plugin zip at all? + +The DeDRM codebase was designed as a Calibre plugin. Each `.py` file contains a +`#@@CALIBRE_COMPAT_CODE@@` placeholder that `build_plugin.py` replaces with a shim +that: + +- Adds the plugin directory to `sys.path` +- Installs a `_DeDRMFinder` import hook that maps `dedrm.X → X`, enabling the + relative imports (`from .utilities import ...`) the modules use without a real + Python package structure on disk + +`python3 DeDRM_plugin.zip` is the correct execution model: the zip is a +self-contained Python application (Python's zipapp format), not a library. + +### Why the zip is not committed to git + +`DeDRM_plugin.zip` is a **build artifact** — a transformed copy of the source in +`DeDRM_plugin/`. Committing it would: + +- Create an opaque blob that agents and reviewers cannot easily audit +- Risk the zip drifting out of sync with source +- Bloat git history with a binary that changes on every source edit + +Instead, `dedrm.py` builds it on demand and caches it locally. Staleness is +detected by comparing the zip's mtime against the newest `.py` file in +`DeDRM_plugin/` — if any source is newer, the zip is rebuilt before the next run. + +### Key storage and the missing-config case + +Keys live in `dedrm.json` (or the path given to `--config`). If the file doesn't +exist, the tool prints a notice and continues with defaults — it will attempt +auto-discovery and create `dedrm.json` if new keys are found. The file is +`.gitignored` and must never be committed (it contains private keys tied to +personal accounts). + +### Calibre plugin compatibility + +None of these changes affect Calibre plugin behaviour: + +- `__calibre_compat_code.py` standalone branch runs only when `"calibre"` is + absent from `sys.modules` +- `prefs.py` import fix falls back gracefully inside Calibre +- `make_release.py` still produces a valid `DeDRM_plugin.zip` for Calibre users + +### Linux / Wine + +Key auto-discovery is Windows and macOS only (the modules call OS-specific APIs). +On Linux, populate `dedrm.json` manually. The Calibre plugin supports Wine-based +key extraction on Linux; the standalone CLI does not. + +--- + +## Files Changed vs. Upstream + +| File | Change | +|---|---| +| `DeDRM_plugin/standalone/remove_drm.py` | Full implementation (was a non-functional stub) | +| `DeDRM_plugin/standalone/__init__.py` | `execute_action` now propagates `perform_action` exit code via `sys.exit()` | +| `DeDRM_plugin/__calibre_compat_code.py` | Added `_DeDRMFinder` for standalone relative-import support | +| `DeDRM_plugin/prefs.py` | `from __init__` → `from __version__` (avoids import ambiguity in zip) | + +New files (not upstream): + +| File | Purpose | +|---|---| +| `dedrm.py` | Agent/CLI entry point — dep install, stale-zip rebuild, delegation | +| `build_plugin.py` | Zip builder — inlines compat shim, packages source into zipapp | +| `requirements.txt` | Explicit dependency list (`lxml>=4.6`, `pycryptodome>=3.9`) | +| `STANDALONE_CLI.md` | This document | +| `dedrm_revision_plan.md` | Developer design reference | diff --git a/build_plugin.py b/build_plugin.py new file mode 100644 index 00000000..25098447 --- /dev/null +++ b/build_plugin.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Fast standalone build: creates DeDRM_plugin.zip directly in the repo root. +Usage: python3 build_plugin.py +Then: python3 DeDRM_plugin.zip remove_drm -o +""" + +import os, shutil, zipfile + +SRC_DIR = 'DeDRM_plugin' +OUT_ZIP = 'DeDRM_plugin.zip' +TMP_DIR = 'DeDRM_plugin_build_tmp' + +def read_compat_code(): + with open(os.path.join(SRC_DIR, '__calibre_compat_code.py'), 'rb') as f: + return f.read() + +def patch(src_bytes, compat_code): + out = [] + for line in src_bytes.splitlines(keepends=True): + if line.strip().startswith(b'#@@CALIBRE_COMPAT_CODE@@'): + out.append(compat_code) + else: + out.append(line) + return b''.join(out) + +def build(): + # Clean up + shutil.rmtree(TMP_DIR, ignore_errors=True) + shutil.copytree(SRC_DIR, TMP_DIR) + + compat_code = read_compat_code() + + # Patch all .py files + for root, dirs, files in os.walk(TMP_DIR): + # Skip __pycache__ + dirs[:] = [d for d in dirs if d != '__pycache__'] + for name in files: + if name.endswith('.py'): + path = os.path.join(root, name) + with open(path, 'rb') as f: + data = f.read() + patched = patch(data, compat_code) + with open(path, 'wb') as f: + f.write(patched) + + # Create zip + if os.path.exists(OUT_ZIP): + os.remove(OUT_ZIP) + with zipfile.ZipFile(OUT_ZIP, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(TMP_DIR): + dirs[:] = [d for d in dirs if d != '__pycache__'] + for name in files: + full = os.path.join(root, name) + arcname = os.path.relpath(full, TMP_DIR) + zf.write(full, arcname) + + shutil.rmtree(TMP_DIR, ignore_errors=True) + print(f'Built: {OUT_ZIP}') + +if __name__ == '__main__': + build() diff --git a/dedrm.py b/dedrm.py new file mode 100644 index 00000000..e0a70628 --- /dev/null +++ b/dedrm.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +dedrm.py — Standalone DeDRM entry point for agent and CLI use. + +Usage: + python3 dedrm.py remove_drm -o + python3 dedrm.py remove_drm --overwrite + python3 dedrm.py remove_drm --outputdir + python3 dedrm.py help + +This script is the single command an agent or user needs. It: + 1. Installs missing Python dependencies (lxml, pycryptodome). + 2. Builds DeDRM_plugin.zip from source if it is missing or stale. + 3. Delegates all arguments to the plugin's CLI unchanged. + +The plugin zip is a local build cache — it is .gitignored and regenerated +automatically whenever source files change. Only the source in DeDRM_plugin/ +is committed, keeping the repo fully auditable. + +Exit codes: + 0 — success (DRM removed, output file written) + 1 — failure (decryption failed, unsupported format, missing key, etc.) + 2 — usage / argument error +""" + +import os +import sys +import subprocess + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PLUGIN_DIR = os.path.join(_HERE, 'DeDRM_plugin') +_PLUGIN_ZIP = os.path.join(_HERE, 'DeDRM_plugin.zip') +_BUILD_SCRIPT = os.path.join(_HERE, 'build_plugin.py') +_REQ_FILE = os.path.join(_HERE, 'requirements.txt') + + +# --------------------------------------------------------------------------- +# Step 1: dependency check +# --------------------------------------------------------------------------- + +def _ensure_deps(): + """Install required packages if they are not importable.""" + needed = [] + try: + import lxml # noqa: F401 + except ImportError: + needed.append('lxml') + try: + import Crypto # noqa: F401 + except ImportError: + needed.append('pycryptodome') + + if not needed: + return + + print(f"DeDRM: installing missing dependencies: {', '.join(needed)}", file=sys.stderr) + subprocess.check_call( + [sys.executable, '-m', 'pip', 'install', '-r', _REQ_FILE], + stdout=subprocess.DEVNULL, + ) + + +# --------------------------------------------------------------------------- +# Step 2: stale-zip detection and on-demand build +# --------------------------------------------------------------------------- + +def _source_mtime(): + """Return the newest modification time of any .py source file in DeDRM_plugin/.""" + latest = 0.0 + for root, dirs, files in os.walk(_PLUGIN_DIR): + dirs[:] = [d for d in dirs if d != '__pycache__'] + for name in files: + if name.endswith('.py'): + t = os.path.getmtime(os.path.join(root, name)) + if t > latest: + latest = t + return latest + + +def _ensure_zip(): + """Build DeDRM_plugin.zip if it is absent or older than any source file.""" + needs_build = ( + not os.path.exists(_PLUGIN_ZIP) + or os.path.getmtime(_PLUGIN_ZIP) < _source_mtime() + ) + + if needs_build: + reason = "missing" if not os.path.exists(_PLUGIN_ZIP) else "source changed" + print(f"DeDRM: building plugin zip ({reason}) ...", file=sys.stderr) + subprocess.check_call([sys.executable, _BUILD_SCRIPT], stdout=subprocess.DEVNULL) + + +# --------------------------------------------------------------------------- +# Step 3: delegate to the plugin CLI +# --------------------------------------------------------------------------- + +def main(): + _ensure_deps() + _ensure_zip() + + result = subprocess.run( + [sys.executable, _PLUGIN_ZIP] + sys.argv[1:], + cwd=_HERE, + ) + sys.exit(result.returncode) + + +if __name__ == '__main__': + main() diff --git a/dedrm_revision_plan.md b/dedrm_revision_plan.md new file mode 100644 index 00000000..c430a4c3 --- /dev/null +++ b/dedrm_revision_plan.md @@ -0,0 +1,384 @@ +# DeDRM Standalone CLI — Implementation Plan + +> **Status: IMPLEMENTED** — See `STANDALONE_CLI.md` for the user-facing documentation. +> This file is the developer/design reference. + +## Goal + +Make the standalone CLI fully functional without requiring Calibre to be installed, +**and** make it usable as a skill by AI agents with a full transparency/audit model. + +### Extended goal (added post-initial-plan) + +The tool should be safe for an AI agent to pull and invoke without requiring the agent +to trust a pre-built binary. Design decisions to support this: + +- **No committed build artifacts**: `DeDRM_plugin.zip` is `.gitignored`. Agents audit + source, then the entry point builds the zip on demand from that audited source. +- **Single entry point**: `dedrm.py` (~80 lines) handles dep install, stale detection, + and delegation. The agent only needs to know one command. +- **Stale detection**: `dedrm.py` compares the zip mtime against the newest source file + mtime. If source is newer, the zip is rebuilt. Safe to call repeatedly. +- **Calibre plugin unchanged**: all changes are additive or in standalone-only branches. + +Original goal (standalone CLI implementation): + +Make the standalone CLI (`python3 DeDRM_plugin.zip remove_drm `) fully functional +without requiring Calibre to be installed. All the DRM-removal logic already exists in +the individual format modules; only the "glue" in `standalone/remove_drm.py` is missing. + +--- + +## The One File to Change + +**`DeDRM_plugin/standalone/remove_drm.py`** + +Minor threading change needed in `perform_action()` to pass `config_path` down to +`dedrm_single_file()`. No other files need editing. + +--- + +## What Already Exists and Works + +| Module | What it does | +|---|---| +| `ineptepub.py` | Adobe Adept / B&N PassHash ePub decryption | +| `ineptpdf.py` | Adobe Adept / Standard / B&N PDF decryption | +| `k4mobidedrm.py` | Kindle / Mobipocket / Topaz / KFX decryption | +| `kfxdedrm.py` | KFX decryption engine (called internally by k4mobidedrm) | +| `erdr2pml.py` | eReader PDB decryption | +| `lcpdedrm.py` | LCP stub (raises immediately — DMCA takedown) | +| `zipfix.py` | ZIP repair before ePub processing | +| `epubfontdecrypt.py` | Post-process: deobfuscate ePub fonts | +| `adobekey.py` | Auto-extract ADE keys (Win/Mac) | +| `kindlekey.py` | Auto-extract Kindle keys (Win/Mac) | +| `ignoblekeyNookStudy.py` | Auto-extract Nook Study keys (Win/Mac) | +| `ignoblekeyWindowsStore.py` | Auto-extract Nook Store keys (Win only) | +| `adobekey_get_passhash.py` | Auto-extract ADE PassHash keys (Win only) | +| `prefs.py` | Config / key storage (works without Calibre via `standalone/jsonconfig.py`) | +| `standalone/__init__.py` | CLI arg parsing, calls `perform_action` | +| `standalone/remove_drm.py` | **STUB — this is the only thing to implement** | + +--- + +## Calibre Plugin vs Standalone: Key Differences + +### What the Calibre plugin does that standalone must replace + +| Calibre API | Standalone replacement | +|---|---| +| `self.temporary_file(".epub")` | `tempfile.mkstemp(suffix=".epub")` + `os.close(fd)` | +| `self.starttime` | Local `starttime = time.time()` | +| `self.alfdir` (Wine key extraction on Linux) | Skipped — stored keys in prefs still work on Linux | +| `self.postProcessEPUB()` | Inline: check `deobfuscate_fonts` pref, call `epubfontdecrypt.decryptFontsBook()` directly | +| `checkForDeACSMkeys()` in `config.py` | **Skipped** — Calibre-only bridge to the DeACSM Calibre plugin (see note below) | + +### How the Calibre plugin routes files + +The Calibre plugin's `run()` routes by **file extension** (`.epub`, `.pdf`, `.mobi`, etc.). +The standalone's `determine_file_type()` routes by **magic bytes** — smarter and extension-agnostic. + +### Failure semantics + +- **Calibre**: Returns the still-encrypted file path so Calibre can handle it gracefully. +- **Standalone**: Returns `False`, prints an error, and produces **no output file**. + This is correct CLI behavior — a partial/silent failure would be confusing. + +### DeACSM note + +`checkForDeACSMkeys()` in `config.py` is 100% Calibre-specific: +- Imports `calibre_plugins.deacsm.libadobeAccount` (another Calibre plugin) +- Uses `calibre.ptempfile.TemporaryFile` + +These imports are impossible outside Calibre. The key it produces is identical in format +to what `adobekey.py` already auto-discovers from a native ADE installation. For users +who use DeACSM instead of ADE (no native ADE installed), the workaround is to manually +export the key and add it to `dedrm.json`. A future task could add a probe for DeACSM's +on-disk activation data (stored in Calibre's config directory). + +--- + +## Bugs Fixed vs Calibre Original + +### PassHash loop bug (Calibre `__init__.py` line ~472) + +In `ePubDecrypt`, after trying auto-discovered NOOK/PassHash keys, the original code has +`return inf.name` **inside** the for loop — so it returns the still-encrypted file after +the very first key attempt, regardless of whether it succeeded. Standalone fixes this: +only set `decrypted_file` on success, continue the loop on failure, return `None` after +exhausting all keys. + +--- + +## Implementation: Functions to Add/Replace + +### 1. Helper: `_make_temp_file(suffix) → str` + +```python +def _make_temp_file(suffix): + import tempfile + fd, path = tempfile.mkstemp(suffix=suffix) + os.close(fd) + return path +``` + +Creates a named temp file that persists on disk. Returns the path string. + +--- + +### 2. Helper: `_pdf_inept_decrypt(input_file, dedrmprefs, starttime) → str | None` + +Models `DeDRM.PDFIneptDecrypt()`. Handles Adobe Adept EBX and B&N PDFs. + +Logic: +1. Try `ineptpdf.adeptGetUserUUID()` to detect which account the book is for. +2. Try UUID-matched key from `dedrmprefs['adeptkeys']` first. +3. Try all stored adept keys. +4. Auto-discover via `adobekey.adeptkeys()` (Win/Mac only), try each new key, save successes. +5. Try B&N keys from `dedrmprefs['bandnkeys']`. +6. Return temp file path on success, `None` on failure. +7. Catch `ineptpdf.ADEPTNewVersionError` and return `None` immediately (unsupported DRM version). + +--- + +### 3. Helper: `_pdf_standard_decrypt(input_file, dedrmprefs, starttime) → str | None` + +Models `DeDRM.PDFStandardDecrypt()`. Handles password-protected PDFs. + +Logic: +1. Build password list: `[""] + dedrmprefs['adobe_pdf_passphrases']`. +2. For each password: call `ineptpdf.decryptBook(bytearray(pw, "utf-8"), ...)`. +3. Catch `ineptpdf.ADEPTInvalidPasswordError` to distinguish wrong password from other errors. +4. Return temp file path on first success, `None` if all fail. + +--- + +### 4. Main: Replace `dedrm_single_file(input_file, output_file, config_path)` stub + +**Signature change:** adds `config_path` parameter (threaded from `perform_action` which +already has it via `standalone.config_file_path`). + +The function returns `True` on success, `False` on failure. On failure, no output file +is written and the caller should exit non-zero. + +**Imports needed at top of function:** +```python +import codecs, time, shutil, traceback, tempfile +``` + +**Flow:** + +``` +1. starttime = time.time() +2. ftype = determine_file_type(input_file) # already implemented in file +3. dedrmprefs = prefs.DeDRM_Prefs(os.path.abspath(config_path)) +4. Branch on ftype: + - "MOBI" / "TPZ" / "KFX" / "KFX-ZIP" → Kindle branch + - "PDB" → eReader branch + - "PDF" → PDF branch + - "LCP" / "ADEPT" / "ADEPT-PassHash" / "ZIP" → ePub branch + - else → print error, return False +5. If decrypted_file is None → print error, return False +6. Post-process ePub (font deobfuscation) if applicable +7. Copy decrypted_file → output_file via shutil.copy2() +8. Clean up temp files (but never delete input_file) +9. Return True +``` + +#### Kindle branch ("MOBI", "TPZ", "KFX", "KFX-ZIP") + +```python +import k4mobidedrm +pids = list(dedrmprefs['pids']) +serials = list(dedrmprefs['serials']) +for v in dedrmprefs['androidkeys'].values(): + serials.extend(v) +kindleDatabases = list(dedrmprefs['kindlekeys'].items()) + +try: + book = k4mobidedrm.GetDecryptedBook(input_file, kindleDatabases, [], serials, pids, starttime) +except Exception as e: + # Auto-discover via kindlekey.kindlekeys() (Win/Mac only) + # Try again with new keys, save successes to prefs + # If still fails → return False + +ext = book.getBookExtension() # e.g. ".azw3" +decrypted_file = _make_temp_file(ext) +book.getFile(decrypted_file) +book.cleanup() +``` + +#### eReader branch ("PDB") + +```python +import erdr2pml +for keyname, userkey in dedrmprefs['ereaderkeys'].items(): + tmp = _make_temp_file(".pmlz") + result = erdr2pml.decryptBook(input_file, tmp, True, codecs.decode(userkey, 'hex')) + if result == 0: + decrypted_file = tmp + break + os.remove(tmp) +# If no key worked → decrypted_file remains None → return False +``` + +#### PDF branch ("PDF") + +```python +import ineptpdf, lcpdedrm +if lcpdedrm.isLCPbook(input_file): + # lcpdedrm.decryptLCPbook() raises immediately (DMCA). Catch and return False. + decrypted_file = lcpdedrm.decryptLCPbook(input_file, dedrmprefs['lcp_passphrases'], None) +else: + enc = ineptpdf.getPDFencryptionType(input_file) + if enc is None: + decrypted_file = input_file # DRM-free: pass-through to copy + elif enc == "EBX_HANDLER": + decrypted_file = _pdf_inept_decrypt(input_file, dedrmprefs, starttime) + elif enc in ("Standard", "Adobe.APS"): + decrypted_file = _pdf_standard_decrypt(input_file, dedrmprefs, starttime) + elif enc in ("FOPN_fLock", "FOPN_foweb"): + print("FileOpen encryption is unsupported."); return False + else: + print(f"Unsupported PDF encryption: {enc}"); return False +``` + +#### ePub branch ("LCP", "ADEPT", "ADEPT-PassHash", "ZIP") + +```python +import zipfix, ineptepub, lcpdedrm + +# 1. Repair ZIP +tmp_fixed = _make_temp_file(".epub") +zipfix.fixZip(input_file, tmp_fixed).fix() + +if ftype == "LCP": + # lcpdedrm raises immediately (DMCA). Catch and return False. + decrypted_file = lcpdedrm.decryptLCPbook(tmp_fixed, dedrmprefs['lcp_passphrases'], None) + +elif ftype == "ADEPT-PassHash": + # Try stored bandnkeys + # Then auto-discover: ignoblekeyNookStudy.nookkeys() (Win/Mac) + # ignoblekeyWindowsStore.dump_keys() (Win only) + # adobekey_get_passhash.passhash_keys() (Win only) + # BUG FIX: don't return inside the loop — try all keys before giving up + # Save successful auto-discovered keys to prefs + +elif ftype == "ADEPT": + # Try ineptepub.adeptGetUserUUID() to get book UUID + # Try UUID-matched key from adeptkeys first + # Try all stored adeptkeys + # Auto-discover via adobekey.adeptkeys() (Win/Mac), save successes to prefs + +else: # "ZIP" = DRM-free ePub + decrypted_file = tmp_fixed # pass through +``` + +#### Font post-processing (ePub only, after decryption succeeds) + +```python +# Controlled by dedrmprefs['deobfuscate_fonts'] (default True) +if dedrmprefs['deobfuscate_fonts']: + try: + tmp_fonts = _make_temp_file(".epub") + ret = epubfontdecrypt.decryptFontsBook(decrypted_file, tmp_fonts) + if ret == 0: + # Fonts were deobfuscated — use the new file + if decrypted_file != input_file: + os.remove(decrypted_file) + decrypted_file = tmp_fonts + else: + # ret == 1: no fonts needed deobfuscation (no-op) + # other: error during deobfuscation + os.remove(tmp_fonts) + except: + pass # font deobfuscation is best-effort +``` + +#### Final copy + +```python +if decrypted_file and decrypted_file != output_file: + os.makedirs(os.path.dirname(os.path.abspath(output_file)) or '.', exist_ok=True) + shutil.copy2(decrypted_file, output_file) + if decrypted_file != input_file: # never delete the user's input + os.remove(decrypted_file) +return True +``` + +--- + +## File Type Detection: `determine_file_type()` additions + +The existing `determine_file_type()` handles most cases. One gap: **plain KFX files** +(not in a ZIP container). These start with `\xeaDRMION\xee` as their first 8 bytes. +Add before the ZIP check: + +```python +elif fdata[:8] == b'\xeaDRMION\xee': + return "KFX" +``` + +The Kindle branch handles both `"KFX"` and `"KFX-ZIP"` identically via `k4mobidedrm`. + +--- + +## Key Decisions / Edge Cases + +| Situation | Handling | +|---|---| +| `decrypted_file == input_file` (DRM-free PDF) | Copy to output but don't delete input | +| LCP books | `lcpdedrm.decryptLCPbook()` raises immediately (DMCA). Catch, print error, return False. | +| `ADEPTNewVersionError` | Return False immediately; this DRM version is unsupported | +| Linux key extraction | Skipped — Win/Mac only for auto-discovery. Stored keys in prefs still work on Linux. | +| Font deobfuscation | Best-effort only (catches all exceptions). Controlled by `deobfuscate_fonts` pref. `ret==0`: success; `ret==1`: no fonts to deobfuscate (no-op); other: error. | +| Watermark removal | Not implemented in standalone — `epubwatermark.py` requires the Calibre plugin object (`self`). | +| DeACSM keys | Not implemented — Calibre-only bridge. Workaround: export key manually and add to `dedrm.json`. | +| Config path | Passed explicitly as `config_path` parameter through `perform_action` → `dedrm_single_file`. | +| PassHash loop bug | **Fixed**: don't return inside the auto-discovered-key loop; try all keys before failing. | +| Plain KFX (non-ZIP) | Detected via `\xeaDRMION\xee` magic bytes → `"KFX"` type → same Kindle branch as `"KFX-ZIP"`. | + +--- + +## Files Changed + +| File | Change | +|---|---| +| `standalone/remove_drm.py` | Full implementation of `dedrm_single_file()` and helpers | + +`perform_action()` in `remove_drm.py` already has access to `standalone.config_file_path` +and passes it down to `dedrm_single_file()`. + +## Files NOT Changed + +- `standalone/__init__.py` — CLI routing is already complete +- `standalone/passhash.py` — separate feature, separate task +- All format modules (`ineptepub.py`, `ineptpdf.py`, etc.) — already work standalone +- `prefs.py` — already works standalone via `standalone/jsonconfig.py` +- `__init__.py` (Calibre plugin) — untouched + +--- + +## Testing Approach + +After implementing, test each format type: + +```bash +# Adobe ePub (primary test) +python3 DeDRM_plugin.zip remove_drm "path/to/book.epub" -o book_nodrm.epub + +# Kindle +python3 DeDRM_plugin.zip remove_drm book.azw3 -o book_nodrm.azw3 + +# Adobe PDF +python3 DeDRM_plugin.zip remove_drm book.pdf -o book_nodrm.pdf + +# eReader +python3 DeDRM_plugin.zip remove_drm book.pdb -o book_nodrm.pmlz + +# DRM-free passthrough +python3 DeDRM_plugin.zip remove_drm drm_free.epub -o out.epub +``` + +Keys are auto-discovered from installed ADE (Win/Mac) or read from the JSON config +(default: `dedrm.json` in cwd). Use `--config` to specify an alternate config path. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..7f35b317 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# DeDRM standalone CLI — Python dependencies +# +# Install with: +# pip install -r requirements.txt +# +# Note: when running inside Calibre these are all bundled by Calibre itself. +# These are only needed for the standalone CLI (python3 DeDRM_plugin.zip ...). + +# Crypto primitives used by all DRM-removal modules +pycryptodome>=3.9.0 + +# XML parsing used by epubfontdecrypt.py and ineptepub.py +lxml>=4.6.0