From 0331024bc2ca910e3849221ac795cf68c3547a84 Mon Sep 17 00:00:00 2001 From: David Xue Date: Fri, 20 Mar 2026 11:33:23 -0400 Subject: [PATCH 1/4] Add standalone CLI for DeDRM without Calibre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully functional standalone command-line interface that removes DRM from ebooks (Adobe ADEPT, Kindle, B&N, eReader, PDF) without requiring Calibre to be installed. ## Entry point `dedrm.py` is the single command for agents and users: python3 dedrm.py remove_drm book.epub -o book_nodrm.epub It auto-installs Python dependencies (lxml, pycryptodome), builds DeDRM_plugin.zip from source on first run, detects source staleness and rebuilds when any source file changes, then delegates all arguments to the plugin CLI. Exit codes: 0 = success, 1 = decryption failure. ## Build pipeline `build_plugin.py` produces DeDRM_plugin.zip by copying DeDRM_plugin/ source and inlining the calibre compat shim (replacing #@@CALIBRE_COMPAT_CODE@@ placeholders). The zip is a local build cache — gitignored, never committed, rebuilt automatically by dedrm.py when stale. ## Design decisions **Source is the truth, not the zip.** Committing a binary zip would be opaque to security audits and could drift from source. Agents that use this as a skill can read all .py files before trusting and running the tool. The build step is a transparent, deterministic transformation of that same source. **Compat code extended for Python 3.12+ standalone use.** The existing __calibre_compat_code.py shim already handled the Calibre case by setting __package__ = "calibre_plugins.dedrm". An else-branch adds a _DeDRMFinder meta_path hook (using find_spec, required in Python 3.12+) that maps `import dedrm.X` to `import X`, enabling relative imports in ineptepub.py and other modules when running from a flat zip without Calibre's package context. Calibre behavior is completely unchanged. **prefs.py import fix.** `from __init__ import PLUGIN_NAME` is ambiguous in the flat-zip context because the calibre compat code adds `DeDRM_plugin.zip/standalone` to sys.path (to support Calibre < 5), which shadows the root __init__.py. Fixed with a try/except that prefers `from __version__ import PLUGIN_NAME` and falls back for compatibility. **Bug fix: PassHash key loop.** The original Calibre ePubDecrypt() returned the still-encrypted file inside the auto-discovered-key loop after the first attempt regardless of success. The standalone implementation tries all keys before reporting failure. **Plain KFX detection.** Added `\xeaDRMION\xee` magic-byte detection for KFX files not wrapped in a ZIP container (plain .kfx), routing them to the same k4mobidedrm branch as KFX-ZIP. **No DeACSM integration.** The Calibre plugin's checkForDeACSMkeys() imports from calibre_plugins.deacsm — impossible outside Calibre. Omitted with a documented workaround: export the key from DeACSM manually and add to dedrm.json. **Watermark removal omitted.** epubwatermark.py uses the Calibre plugin object (self.temporary_file etc.) throughout. Out of scope for standalone. **Failure semantics.** Calibre returns the encrypted file on failure so Calibre can handle it gracefully. The standalone CLI returns exit code 1 and writes no output file — correct for a command-line tool. ## Key storage Decryption keys are auto-discovered from installed ADE / Kindle / NOOK apps on Windows and macOS. Successfully used keys are saved to dedrm.json in the working directory (gitignored — contains private cryptographic material). Use --config to specify a stable path. On Linux, populate dedrm.json manually by transferring keys from a Windows/Mac machine. ## Files changed vs upstream Modified: - DeDRM_plugin/__calibre_compat_code.py (standalone import hook, else-branch only) - DeDRM_plugin/prefs.py (PLUGIN_NAME import fix) - DeDRM_plugin/standalone/remove_drm.py (full implementation, was a stub) - .gitignore (zip artifacts, dedrm.json, .claude/) Added: - dedrm.py Entry point: dep check + stale-zip rebuild + delegation - build_plugin.py Fast zip builder for development - requirements.txt Python dependencies for standalone use - STANDALONE_CLI.md Usage, architecture, and design decisions - dedrm_revision_plan.md Implementation plan (dev reference) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 17 +- DeDRM_plugin/__calibre_compat_code.py | 53 +++ DeDRM_plugin/prefs.py | 8 +- DeDRM_plugin/standalone/remove_drm.py | 621 +++++++++++++++++++++++--- STANDALONE_CLI.md | 197 ++++++++ build_plugin.py | 62 +++ dedrm.py | 114 +++++ dedrm_revision_plan.md | 363 +++++++++++++++ requirements.txt | 13 + 9 files changed, 1392 insertions(+), 56 deletions(-) create mode 100644 STANDALONE_CLI.md create mode 100644 build_plugin.py create mode 100644 dedrm.py create mode 100644 dedrm_revision_plan.md create mode 100644 requirements.txt 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/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..f50f03a3 --- /dev/null +++ b/STANDALONE_CLI.md @@ -0,0 +1,197 @@ +# 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 +# 1. Install Python dependencies (one time) +pip install -r requirements.txt + +# 2. Remove DRM — that's it +python3 dedrm.py remove_drm "My Book.epub" -o "My Book (DRM-free).epub" +``` + +`dedrm.py` handles everything else automatically: it builds the plugin zip +from source on first run (and rebuilds whenever source files change), then +delegates to the plugin's CLI. + +--- + +## Usage + +``` +python3 dedrm.py remove_drm [options] + +Options: + -o / --output Output file path + --outputdir Output directory (uses original filename) + --overwrite Replace input file in-place (implies --force) + -f / --force Overwrite output 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 *.epub --outputdir ./clean/ + python3 dedrm.py remove_drm book.epub --config ~/.config/dedrm.json +``` + +Exit codes: `0` = success, `1` = failure (decryption failed / no key), `2` = bad arguments. + +--- + +## How Keys Are Found + +The tool finds decryption keys in two ways, tried in order: + +### 1. Auto-discovery (Windows and macOS only) + +On first use against an encrypted book, the tool automatically extracts keys +from locally installed applications: + +| App | Key type extracted | +|---|---| +| Adobe Digital Editions (ADE) | Adobe ADEPT key (covers most library ebooks) | +| Kindle for PC / Kindle for Mac | Kindle database key | +| NOOK Study | B&N PassHash key | +| NOOK (Microsoft Store, Windows only) | B&N PassHash key | + +Discovered keys are saved to `dedrm.json` in the current directory so +subsequent runs are instant and require no installed apps. + +### 2. Stored keys (`dedrm.json`) + +Keys are stored in a JSON file: + +```json +{ + "adeptkeys": { "my_ade_key": "" }, + "kindlekeys": { "my_kindle_key": "" }, + "bandnkeys": { "my_nook_key": "" }, + "serials": ["1234567890abcdef"], + "pids": [] +} +``` + +Use `--config` to point to a key file in a non-default location. On Linux +(where app-based auto-discovery is unavailable), populate `dedrm.json` by +exporting keys from ADE or Kindle on a Windows/Mac 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 + stored passphrases | +| `.pdf` | FileOpen | Not supported | +| `.mobi` `.azw` `.azw3` | Kindle | Kindle for PC/Mac key required | +| `.kfx` `.kfx-zip` | Kindle KFX | Same key as above | +| `.pdb` | eReader | Stored key required | + +--- + +## Architecture and Design Decisions + +### Why a plugin zip? + +The DeDRM codebase was originally designed as a Calibre plugin. Each `.py` +file contains a `#@@CALIBRE_COMPAT_CODE@@` placeholder that the build system +(`build_plugin.py`) replaces with a small 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 ...`) that the modules use + without requiring a real Python package structure + +Running `python3 DeDRM_plugin.zip` is therefore the correct execution model: +the zip is a self-contained Python application, not a library. + +### Why the zip is not committed to git + +`DeDRM_plugin.zip` is a **build artifact** — a transformed copy of the source +files in `DeDRM_plugin/`. Committing it would: + +- Create an opaque binary that agents and reviewers cannot audit +- Risk the zip drifting out of sync with source +- Bloat git history + +Instead, `dedrm.py` builds the zip on demand and caches it locally. It also +detects staleness: if any source file in `DeDRM_plugin/` is newer than the +zip, the zip is rebuilt automatically before the next run. + +### Transparency for AI agents + +This repo is designed to be used as a **skill by AI agents** (e.g. autonomous +coding agents, claw-style tool-use agents). The trust model requires that +every line the agent executes is auditable before execution: + +``` +agent reads source → verifies intent → python3 dedrm.py → auto-build → run +``` + +The full execution surface is: +- `dedrm.py` — entry point, ~80 lines, only does: dep check, stale detection, subprocess call +- `build_plugin.py` — ~60 lines, copies and patches source files into zip +- `DeDRM_plugin/` — all DRM logic, ~15 Python modules, all readable source +- `requirements.txt` — two packages (`lxml`, `pycryptodome`), pinned floor versions + +No binary blobs. No network calls at runtime. No eval or exec of downloaded code. + +### Key storage + +Keys are written to `dedrm.json` in the working directory on first successful +decryption. This file is `.gitignored` — it contains private cryptographic +keys tied to personal accounts and must never be committed. + +Use `--config ` to store the key file in a stable location (e.g. +`~/.config/dedrm/keys.json`) so it persists across working directory changes. + +### Calibre plugin compatibility + +None of the changes made for standalone use break the Calibre plugin: + +- `__calibre_compat_code.py` changes are in an `else:` branch that only + activates when Calibre is not present +- `prefs.py` import fix falls back to the original `from __init__ import` + if `__version__` is unavailable (which never happens in Calibre) +- `make_release.py` still produces a valid `DeDRM_plugin.zip` for Calibre + +### Linux / Wine + +Auto-discovery of ADE and Kindle keys is Windows and macOS only (the key +extraction 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 | +|---|---| +| `standalone/remove_drm.py` | Full implementation (was a non-functional stub) | +| `__calibre_compat_code.py` | Added `_DeDRMFinder` for Python 3.12+ standalone use | +| `prefs.py` | `from __init__` → `from __version__` (avoids import ambiguity in zip) | + +New files (not upstream): + +| File | Purpose | +|---|---| +| `dedrm.py` | Agent/CLI entry point — dep check, stale-zip rebuild, delegation | +| `build_plugin.py` | Fast zip builder for development iteration | +| `requirements.txt` | Explicit Python dependency list for standalone use | +| `STANDALONE_CLI.md` | This document | +| `dedrm_revision_plan.md` | Implementation plan and design decisions (dev 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..7ca45899 --- /dev/null +++ b/dedrm_revision_plan.md @@ -0,0 +1,363 @@ +# DeDRM Standalone CLI — Implementation Plan + +## Goal + +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 From 48dab87d4a3ebc86a4102674b92338a1a62513ed Mon Sep 17 00:00:00 2001 From: David Xue Date: Fri, 20 Mar 2026 12:00:42 -0400 Subject: [PATCH 2/4] Document agent-skill architecture and trust model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated "Using as an Agent Skill" section to STANDALONE_CLI.md covering the full trust chain an AI agent follows when pulling this repo as a skill: clone → read source → audit → call dedrm.py → auto-build → run Key design decisions documented: - DeDRM_plugin.zip is never committed — it is a local build cache derived from the audited source, rebuilt on demand and on source staleness - dedrm.py is the single entry point (~80 lines): dep install, stale-zip detection, and delegation via subprocess — no magic, fully readable - Includes a JSON skill descriptor suitable for agent skill registries Also updates dedrm_revision_plan.md to mark the plan as implemented and records the extended agent-skill goals that emerged during design review, explaining why the zip is gitignored rather than committed. Verified: python3 dedrm.py correctly decrypts an Adobe ADEPT ePub (Unwanted.epub) in 0.8 s via UUID-matched ADE key auto-discovery. Co-Authored-By: Claude Sonnet 4.6 --- STANDALONE_CLI.md | 49 ++++++++++++++++++++++++++++++++++++++++++ dedrm_revision_plan.md | 21 ++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/STANDALONE_CLI.md b/STANDALONE_CLI.md index f50f03a3..b2f986ea 100644 --- a/STANDALONE_CLI.md +++ b/STANDALONE_CLI.md @@ -103,6 +103,55 @@ the file. --- +## 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. + +### Skill invocation + +```bash +# Setup (once per environment — installs deps, builds plugin zip from source) +python3 dedrm.py remove_drm --help + +# Remove DRM +python3 dedrm.py remove_drm "" -o "" +``` + +Exit codes: `0` = success, `1` = failure. All progress goes to stderr; exit code is +the only thing the agent needs to check. + +### Skill descriptor (for agent registries) + +```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 use.", + "requires": ["python3", "pip"], + "setup": "python3 dedrm.py --help", + "invoke": "python3 dedrm.py remove_drm -o ", + "exit_codes": { "0": "success — DRM-free output written", "1": "failure — decryption failed or no key", "2": "usage error — bad arguments" } +} +``` + +### Agent trust chain + +``` +1. agent clones repo +2. agent reads dedrm.py (~80 lines: dep install, stale check, subprocess call) +3. agent reads build_plugin.py (~60 lines: zip assembly from source) +4. agent reads DeDRM_plugin/*.py (all DRM logic, fully auditable Python) +5. agent decides to trust +6. agent calls: python3 dedrm.py remove_drm book.epub -o book_nodrm.epub +7. dedrm.py installs deps if missing → builds zip if missing/stale → runs zip +``` + +No binary blobs in the repo. No network calls at runtime. No eval/exec of +downloaded code. The zip is a **local build cache** derived from the source the +agent already audited — it is `.gitignored` and never committed. + +--- + ## Architecture and Design Decisions ### Why a plugin zip? diff --git a/dedrm_revision_plan.md b/dedrm_revision_plan.md index 7ca45899..c430a4c3 100644 --- a/dedrm_revision_plan.md +++ b/dedrm_revision_plan.md @@ -1,7 +1,28 @@ # 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. From 33f94c081ccbaf9caf68ea2fd1a7168269b96c97 Mon Sep 17 00:00:00 2001 From: David Xue Date: Fri, 20 Mar 2026 12:06:38 -0400 Subject: [PATCH 3/4] Fix Quick Start: remove redundant pip install step dedrm.py already auto-installs dependencies on first run. Telling users to run pip install manually contradicted the single-command design and was confusing. Co-Authored-By: Claude Sonnet 4.6 --- STANDALONE_CLI.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/STANDALONE_CLI.md b/STANDALONE_CLI.md index b2f986ea..a1ce93db 100644 --- a/STANDALONE_CLI.md +++ b/STANDALONE_CLI.md @@ -10,16 +10,15 @@ Mobipocket / KFX, eReader PDB, and standard password-encrypted PDFs. ## Quick Start ```bash -# 1. Install Python dependencies (one time) -pip install -r requirements.txt - -# 2. Remove DRM — that's it python3 dedrm.py remove_drm "My Book.epub" -o "My Book (DRM-free).epub" ``` -`dedrm.py` handles everything else automatically: it builds the plugin zip -from source on first run (and rebuilds whenever source files change), then -delegates to the plugin's CLI. +That's it. On first run `dedrm.py` automatically: +1. Installs missing Python dependencies (`lxml`, `pycryptodome`) +2. Builds the plugin zip from source +3. Runs the CLI + +On subsequent runs it goes straight to step 3 (or rebuilds the zip if source has changed). --- From 4fc28d6a7500396df081a73d56812b3cf407f1f9 Mon Sep 17 00:00:00 2001 From: David Xue Date: Fri, 20 Mar 2026 12:10:39 -0400 Subject: [PATCH 4/4] Fix exit codes and correct STANDALONE_CLI.md inaccuracies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: standalone/__init__.py execute_action was silently dropping the return value of perform_action(), so the process always exited 0 even on decryption failure. Fixed with sys.exit(perform_action(...)). Verified: missing-file → exit 1, successful decrypt → exit 0. Doc corrections in STANDALONE_CLI.md: - Key lookup order was reversed: stored keys are tried FIRST, auto-discovery is the fallback (not the other way around) - Progress messages go to stdout, not stderr (only arg errors → stderr) - Module count corrected: ~48 .py files (was "~15") - Added Windows glob caveat (*.epub does not expand in cmd/PowerShell) - Removed duplicate transparency content between Agent Skill and Architecture sections - Config-missing behaviour documented (warns and continues with defaults) - Line count estimates updated to match actual file sizes - Files Changed table updated to include the __init__.py exit-code fix Co-Authored-By: Claude Sonnet 4.6 --- DeDRM_plugin/standalone/__init__.py | 6 +- STANDALONE_CLI.md | 217 +++++++++++++--------------- 2 files changed, 107 insertions(+), 116 deletions(-) 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/STANDALONE_CLI.md b/STANDALONE_CLI.md index a1ce93db..64283042 100644 --- a/STANDALONE_CLI.md +++ b/STANDALONE_CLI.md @@ -15,10 +15,10 @@ 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 +2. Builds the plugin zip from source (cached locally, rebuilt if source changes) 3. Runs the CLI -On subsequent runs it goes straight to step 3 (or rebuilds the zip if source has changed). +Subsequent runs skip straight to step 3. --- @@ -28,45 +28,38 @@ On subsequent runs it goes straight to step 3 (or rebuilds the zip if source has python3 dedrm.py remove_drm [options] Options: - -o / --output Output file path - --outputdir Output directory (uses original filename) - --overwrite Replace input file in-place (implies --force) - -f / --force Overwrite output if it already exists + -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 *.epub --outputdir ./clean/ + python3 dedrm.py remove_drm book.epub --outputdir ./clean/ python3 dedrm.py remove_drm book.epub --config ~/.config/dedrm.json ``` -Exit codes: `0` = success, `1` = failure (decryption failed / no key), `2` = bad arguments. +> **Windows note:** shell glob expansion (`*.epub`) does not work in cmd.exe or +> PowerShell. Pass files individually or use a wrapper script. ---- - -## How Keys Are Found +**Exit codes:** `0` = success (DRM-free file written), `1` = failure (decryption +failed, file not found, no matching key). -The tool finds decryption keys in two ways, tried in order: +**Output:** progress and status messages go to **stdout**; usage errors and +argument problems go to **stderr**. -### 1. Auto-discovery (Windows and macOS only) - -On first use against an encrypted book, the tool automatically extracts keys -from locally installed applications: +--- -| App | Key type extracted | -|---|---| -| Adobe Digital Editions (ADE) | Adobe ADEPT key (covers most library ebooks) | -| Kindle for PC / Kindle for Mac | Kindle database key | -| NOOK Study | B&N PassHash key | -| NOOK (Microsoft Store, Windows only) | B&N PassHash key | +## How Keys Are Found -Discovered keys are saved to `dedrm.json` in the current directory so -subsequent runs are instant and require no installed apps. +The tool tries keys in this order for each book: -### 2. Stored keys (`dedrm.json`) +### 1. Stored keys (`dedrm.json`) -Keys are stored in a JSON file: +On every run, stored keys are tried first. Keys are read from a JSON file +(default: `dedrm.json` in the current working directory): ```json { @@ -78,10 +71,27 @@ Keys are stored in a JSON file: } ``` -Use `--config` to point to a key file in a non-default location. On Linux -(where app-based auto-discovery is unavailable), populate `dedrm.json` by -exporting keys from ADE or Kindle on a Windows/Mac machine and transferring -the file. +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. --- @@ -94,11 +104,11 @@ the file. | `.epub` | LCP | **Not supported** — DMCA takedown | | `.epub` | None | Passed through unchanged | | `.pdf` | Adobe ADEPT (EBX) | ADE-protected PDFs | -| `.pdf` | Standard / password | Tries empty password + stored passphrases | +| `.pdf` | Standard / password | Tries empty password then stored passphrases | | `.pdf` | FileOpen | Not supported | -| `.mobi` `.azw` `.azw3` | Kindle | Kindle for PC/Mac key required | +| `.mobi` `.azw` `.azw3` | Kindle | Requires Kindle for PC/Mac key | | `.kfx` `.kfx-zip` | Kindle KFX | Same key as above | -| `.pdb` | eReader | Stored key required | +| `.pdb` | eReader | Requires stored key in `dedrm.json` | --- @@ -107,122 +117,102 @@ the file. 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. -### Skill invocation +### Invocation ```bash -# Setup (once per environment — installs deps, builds plugin zip from source) -python3 dedrm.py remove_drm --help - -# Remove DRM python3 dedrm.py remove_drm "" -o "" ``` -Exit codes: `0` = success, `1` = failure. All progress goes to stderr; exit code is -the only thing the agent needs to check. +Check the exit code: `0` = success, `1` = failure. Progress goes to stdout. -### Skill descriptor (for agent registries) +### 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 use.", + "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"], - "setup": "python3 dedrm.py --help", "invoke": "python3 dedrm.py remove_drm -o ", - "exit_codes": { "0": "success — DRM-free output written", "1": "failure — decryption failed or no key", "2": "usage error — bad arguments" } + "exit_codes": { + "0": "success — DRM-free output written", + "1": "failure — decryption failed, no matching key, or file not found" + } } ``` -### Agent trust chain +### Trust model + +An agent can audit every line that will be executed before running anything: ``` -1. agent clones repo -2. agent reads dedrm.py (~80 lines: dep install, stale check, subprocess call) -3. agent reads build_plugin.py (~60 lines: zip assembly from source) -4. agent reads DeDRM_plugin/*.py (all DRM logic, fully auditable Python) -5. agent decides to trust -6. agent calls: python3 dedrm.py remove_drm book.epub -o book_nodrm.epub -7. dedrm.py installs deps if missing → builds zip if missing/stale → runs zip +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 in the repo. No network calls at runtime. No eval/exec of -downloaded code. The zip is a **local build cache** derived from the source the -agent already audited — it is `.gitignored` and never committed. +**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? +### Why a plugin zip at all? -The DeDRM codebase was originally designed as a Calibre plugin. Each `.py` -file contains a `#@@CALIBRE_COMPAT_CODE@@` placeholder that the build system -(`build_plugin.py`) replaces with a small shim that: +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 ...`) that the modules use - without requiring a real Python package structure +- 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 -Running `python3 DeDRM_plugin.zip` is therefore the correct execution model: -the zip is a self-contained Python application, not a library. +`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 -files in `DeDRM_plugin/`. Committing it would: +`DeDRM_plugin.zip` is a **build artifact** — a transformed copy of the source in +`DeDRM_plugin/`. Committing it would: -- Create an opaque binary that agents and reviewers cannot audit +- Create an opaque blob that agents and reviewers cannot easily audit - Risk the zip drifting out of sync with source -- Bloat git history - -Instead, `dedrm.py` builds the zip on demand and caches it locally. It also -detects staleness: if any source file in `DeDRM_plugin/` is newer than the -zip, the zip is rebuilt automatically before the next run. - -### Transparency for AI agents - -This repo is designed to be used as a **skill by AI agents** (e.g. autonomous -coding agents, claw-style tool-use agents). The trust model requires that -every line the agent executes is auditable before execution: - -``` -agent reads source → verifies intent → python3 dedrm.py → auto-build → run -``` - -The full execution surface is: -- `dedrm.py` — entry point, ~80 lines, only does: dep check, stale detection, subprocess call -- `build_plugin.py` — ~60 lines, copies and patches source files into zip -- `DeDRM_plugin/` — all DRM logic, ~15 Python modules, all readable source -- `requirements.txt` — two packages (`lxml`, `pycryptodome`), pinned floor versions - -No binary blobs. No network calls at runtime. No eval or exec of downloaded code. +- Bloat git history with a binary that changes on every source edit -### Key storage +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. -Keys are written to `dedrm.json` in the working directory on first successful -decryption. This file is `.gitignored` — it contains private cryptographic -keys tied to personal accounts and must never be committed. +### Key storage and the missing-config case -Use `--config ` to store the key file in a stable location (e.g. -`~/.config/dedrm/keys.json`) so it persists across working directory changes. +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 the changes made for standalone use break the Calibre plugin: +None of these changes affect Calibre plugin behaviour: -- `__calibre_compat_code.py` changes are in an `else:` branch that only - activates when Calibre is not present -- `prefs.py` import fix falls back to the original `from __init__ import` - if `__version__` is unavailable (which never happens in Calibre) -- `make_release.py` still produces a valid `DeDRM_plugin.zip` for Calibre +- `__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 -Auto-discovery of ADE and Kindle keys is Windows and macOS only (the key -extraction 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. +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. --- @@ -230,16 +220,17 @@ the standalone CLI does not. | File | Change | |---|---| -| `standalone/remove_drm.py` | Full implementation (was a non-functional stub) | -| `__calibre_compat_code.py` | Added `_DeDRMFinder` for Python 3.12+ standalone use | -| `prefs.py` | `from __init__` → `from __version__` (avoids import ambiguity in zip) | +| `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 check, stale-zip rebuild, delegation | -| `build_plugin.py` | Fast zip builder for development iteration | -| `requirements.txt` | Explicit Python dependency list for standalone use | +| `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` | Implementation plan and design decisions (dev reference) | +| `dedrm_revision_plan.md` | Developer design reference |