commit 409473b8f3a62a85b141a56a1f94e54f184adda7 Author: Jason Ross Date: Wed Jun 24 18:38:03 2026 -0500 Initial release: ADEPT EPUB and Kindle MOBI DRM removal (phases 0-2) DRMLibre is a standalone, single-binary Rust CLI that removes DRM from Amazon Kindle and Adobe Digital Editions ebooks. The decryption logic is ported from DeDRM_tools (GPLv3); this project is GPL-3.0-or-later. This commit covers the first three milestones of the plan: * Phase 0 - workspace scaffold (drmlibre-core engine + drmlibre-cli), native TOML config, magic-byte/zip-content format detection, and the remove/passhash/config CLI surface with atomic output handling. * Phase 1 - Adobe ADEPT EPUB: RSA (PKCS#1 v1.5) and PassHash book-key unwrap, RMSDK>=10 hardening, AES-128-CBC content decryption, and IETF/ Adobe font de-obfuscation. Verified end-to-end with a real RSA key. * Phase 2 - Kindle MOBI/AZW/AZW3: PC1 cipher, type-1 and type-2 DRM, serial->PID derivation, EXTH header patches, and trailing-data handling. Verified end-to-end and against the upstream golden vectors. All crypto is pure Rust (RustCrypto), so the release binary has no third-party runtime dependencies. 42 tests pass; clippy and rustfmt clean. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d515aa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +**/*.rs.bk +*.pdb + +# Local config / test artifacts +/drmlibre.toml +/*.epub +/*.azw +/*.azw3 +/*.mobi +/*.kfx-zip +*_nodrm.* diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..65daa2a --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,19 @@ +# Credits + +DRMLibre is a Rust reimplementation of the DRM-removal logic from +[DeDRM_tools](https://github.com/noDRM/DeDRM_tools) (GPLv3). It would not exist +without the reverse-engineering and code of the people behind those tools: + +- The original **inept** (Adobe ADEPT) and **ignoble** (Barnes & Noble) scripts + by **i♥cabbages**. +- The original **mobidedrm** and **erdr2pml** scripts by **The Dark Reverser**. +- The original **Topaz** DRM-removal script by **CMBDTC**, and the Topaz format + conversion scripts by **some_updates**, **clarknova**, and **Bart Simpson**. +- The original **KFX** decryption by **lulzkabulz**, converted to Python by + **Apprentice Naomi** and integrated by **tomthumb1997**. +- The **alfcrypto** library (PC1 / Topaz ciphers) by **some_updates**. +- The DeDRM plugin maintained by **DiapDealer**, **Apprentice Alf**, + **Apprentice Harper**, and **noDRM**, plus many other contributors. + +Because DRMLibre is a derivative work of GPLv3-licensed code, it is itself +licensed under **GPL-3.0-or-later** (see [LICENSE](LICENSE)). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..61b0ad2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1115 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "drmlibre-cli" +version = "0.1.0" +dependencies = [ + "clap", + "drmlibre-core", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "drmlibre-core" +version = "0.1.0" +dependencies = [ + "aes", + "base64", + "cbc", + "flate2", + "hex", + "md-5", + "quick-xml", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "tempfile", + "thiserror", + "toml", + "tracing", + "zip", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..04c2afe --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,53 @@ +[workspace] +resolver = "2" +members = ["crates/drmlibre-core", "crates/drmlibre-cli"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +license = "GPL-3.0-or-later" +repository = "https://github.com/JMR-dev/DRMLibre" +authors = ["Jason Ross"] + +# Derivative-work note: the DRM-removal algorithms are ported from DeDRM_tools +# (GPLv3). This project is therefore GPL-3.0-or-later. Upstream credit is kept +# in the relevant module headers. + +[workspace.dependencies] +drmlibre-core = { path = "crates/drmlibre-core" } + +# Errors / logging +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Crypto (RustCrypto — pure Rust, statically linked) +rsa = "0.9" +aes = "0.8" +cbc = "0.1" +ctr = "0.9" +sha1 = "0.10" +sha2 = "0.10" +md-5 = "0.10" +hmac = "0.12" +pbkdf2 = { version = "0.12", default-features = false } + +# Config / serialization +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +# Containers / encoding +zip = { version = "2", default-features = false, features = ["deflate"] } +flate2 = "1" +quick-xml = "0.37" +base64 = "0.22" +hex = "0.4" + +# CLI +clap = { version = "4", features = ["derive"] } + +[profile.release] +lto = true +strip = true +codegen-units = 1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2600b14 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# DRMLibre + +A standalone, single-binary command-line tool (written in Rust, no runtime +dependencies) for removing DRM from **Amazon Kindle** and **Adobe Digital +Editions** ebooks. The decryption logic is ported from +[DeDRM_tools](https://github.com/noDRM/DeDRM_tools); DRMLibre is therefore +licensed **GPL-3.0-or-later**. See [CREDITS.md](CREDITS.md). + +> Use DRMLibre only on books you own, to exercise your own rights (format +> shifting, backup, accessibility). Respect the law in your jurisdiction. + +## Status + +Under active development. Supported / planned formats: + +| Format | Status | +|---|---| +| Adobe ADEPT EPUB (+ B&N PassHash) | in progress (phase 1) | +| Kindle MOBI / AZW / AZW3 | planned (phase 2) | +| Local key auto-extraction (Windows/macOS) | planned (phase 3) | +| Kindle KFX-ZIP | planned (phase 4) | +| Adobe PDF, Kindle Topaz, eReader, Kobo, LCP | out of scope | + +## Usage + +``` +drmlibre remove [options] # remove DRM +drmlibre passhash ... # generate/extract Adobe/B&N PassHashes +drmlibre config # summarize keys in the config file +``` + +Common `remove` options: `-o/--output`, `--outputdir`, `-f/--force`, +`--overwrite`, `--serial`, `--pid`, `--key`, `--passphrase`, `--android-backup`, +`--config` (default `./drmlibre.toml`). + +Examples: + +``` +# Adobe EPUB, using an exported Adobe key: +drmlibre remove "My Book.epub" --key adobe.der -o "My Book (no DRM).epub" + +# Kindle eInk book, using the device serial: +drmlibre remove "My Book.azw" --serial 0123456789ABCDEF +``` + +## Architecture + +A Cargo workspace: + +- `crates/drmlibre-core` — the UI-agnostic engine (format detection, key + management, decryptors). No printing or process exits; everything is a + `Result` and progress goes through `tracing`. This is what a future GUI links. +- `crates/drmlibre-cli` — the `drmlibre` binary (argument parsing, file I/O + policy, exit codes). + +## Building + +``` +cargo build --release # binary at target/release/drmlibre +cargo test # run the test suite +``` diff --git a/crates/drmlibre-cli/Cargo.toml b/crates/drmlibre-cli/Cargo.toml new file mode 100644 index 0000000..bc7822a --- /dev/null +++ b/crates/drmlibre-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "drmlibre-cli" +description = "Standalone CLI for removing DRM from Kindle and Adobe Digital Editions ebooks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[[bin]] +name = "drmlibre" +path = "src/main.rs" + +[dependencies] +drmlibre-core.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/drmlibre-cli/src/main.rs b/crates/drmlibre-cli/src/main.rs new file mode 100644 index 0000000..ff6966b --- /dev/null +++ b/crates/drmlibre-cli/src/main.rs @@ -0,0 +1,202 @@ +//! `drmlibre` — standalone CLI for removing DRM from Kindle and Adobe Digital +//! Editions ebooks. Thin front-end over `drmlibre-core`: it owns argument +//! parsing, file I/O policy (output paths, atomic writes, overwrite rules) and +//! process exit codes; all DRM logic lives in the engine. + +mod remove; + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser)] +#[command( + name = "drmlibre", + version, + about = "Remove DRM from Amazon Kindle and Adobe Digital Editions ebooks", + long_about = None, +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Remove DRM from one or more books. + Remove(RemoveArgs), + /// Generate, extract, or import Adobe/B&N PassHashes. + Passhash(PasshashArgs), + /// Show a summary of the keys in the config file. + Config(ConfigArgs), +} + +#[derive(Args)] +struct RemoveArgs { + /// Input book file(s). + #[arg(required = true)] + files: Vec, + + /// Output file name (only valid with a single input). + #[arg(short, long)] + output: Option, + + /// Folder to write the DRM-free file(s) to. + #[arg(long)] + outputdir: Option, + + /// Overwrite the output file if it already exists. + #[arg(short, long)] + force: bool, + + /// Replace the original DRMed file in place (implies --force). + #[arg(long)] + overwrite: bool, + + /// Kindle eInk device serial number (repeatable). + #[arg(long)] + serial: Vec, + + /// Mobipocket / Kindle PID (repeatable). + #[arg(long)] + pid: Vec, + + /// Adobe `.der` or Kindle `.k4i` key file (repeatable). + #[arg(long)] + key: Vec, + + /// Adobe PassHash / B&N passphrase (repeatable). + #[arg(long)] + passphrase: Vec, + + /// Android backup.ab / AmazonSecureStorage.xml / map_data_storage.db (repeatable). + #[arg(long)] + android_backup: Vec, + + #[command(flatten)] + common: CommonArgs, +} + +#[derive(Args)] +struct PasshashArgs { + /// Generate a PassHash for this account name. + #[arg(short, long)] + username: Option, + /// Generate a PassHash with this password/credit-card number. + #[arg(short, long)] + password: Option, + /// Display PassHashes found on this machine. + #[arg(short, long)] + extract: bool, + /// Import discovered hashes into the config. + #[arg(short, long)] + import: bool, + #[command(flatten)] + common: CommonArgs, +} + +#[derive(Args)] +struct ConfigArgs { + #[command(flatten)] + common: CommonArgs, +} + +#[derive(Args, Clone)] +struct CommonArgs { + /// Config file to use. + #[arg(long, default_value = drmlibre_core::config::DEFAULT_CONFIG_NAME)] + config: PathBuf, + /// Increase verbosity (repeatable). + #[arg(short, long, action = clap::ArgAction::Count, global = true)] + verbose: u8, + /// Suppress non-essential output. + #[arg(short, long, global = true)] + quiet: bool, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let common = match &cli.command { + Command::Remove(a) => &a.common, + Command::Passhash(a) => &a.common, + Command::Config(a) => &a.common, + }; + init_tracing(common.verbose, common.quiet); + + let code = match &cli.command { + Command::Remove(args) => remove::run(args), + Command::Passhash(args) => run_passhash(args), + Command::Config(args) => run_config(args), + }; + + if code == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +fn run_passhash(args: &PasshashArgs) -> i32 { + if args.extract || args.import { + eprintln!( + "passhash --extract/--import needs local key extraction, which is \ + planned for a later release." + ); + return 1; + } + match (&args.username, &args.password) { + (Some(name), Some(ccn)) => match drmlibre_core::generate_passhash(name, ccn) { + Ok(key) => { + println!("{key}"); + 0 + } + Err(e) => { + eprintln!("Could not generate PassHash: {e}"); + 1 + } + }, + _ => { + eprintln!("passhash: provide both --username and --password to generate a key."); + 1 + } + } +} + +fn run_config(args: &ConfigArgs) -> i32 { + match drmlibre_core::Config::load(&args.common.config) { + Ok(cfg) => { + let s = cfg.summary(); + println!("Config: {}", args.common.config.display()); + println!(" Adobe ADEPT keys : {}", s.adept_keys); + println!(" PassHash keys : {}", s.passhash_keys); + println!(" Kindle key DBs : {}", s.kindle_databases); + println!(" Kindle serials : {}", s.serials); + println!(" Mobipocket PIDs : {}", s.pids); + 0 + } + Err(e) => { + eprintln!("Could not read config: {e}"); + 1 + } + } +} + +fn init_tracing(verbose: u8, quiet: bool) { + use tracing::level_filters::LevelFilter; + let level = if quiet { + LevelFilter::ERROR + } else { + match verbose { + 0 => LevelFilter::INFO, + 1 => LevelFilter::DEBUG, + _ => LevelFilter::TRACE, + } + }; + tracing_subscriber::fmt() + .with_max_level(level) + .with_target(false) + .without_time() + .with_writer(std::io::stderr) + .init(); +} diff --git a/crates/drmlibre-cli/src/remove.rs b/crates/drmlibre-cli/src/remove.rs new file mode 100644 index 0000000..c9f68cc --- /dev/null +++ b/crates/drmlibre-cli/src/remove.rs @@ -0,0 +1,195 @@ +//! `drmlibre remove` — orchestration around the engine: argument validation, +//! output-path resolution, atomic temp writes, and exit codes. Mirrors the +//! behavior of `DeDRM_plugin/standalone/remove_drm.py::run`. + +use std::path::{Path, PathBuf}; + +use drmlibre_core::{decrypt, Config, Error, KeyInputs, KeyStore, Options, Outcome}; + +use crate::RemoveArgs; + +/// Run the `remove` command. Returns a process exit code (0 ok, 1 on any error). +pub fn run(args: &RemoveArgs) -> i32 { + let force = args.force || args.overwrite; + let overwrite_original = args.overwrite; + + // --- argument validation (matches the Python CLI) --- + if overwrite_original && (args.output.is_some() || args.outputdir.is_some()) { + eprintln!("Can't use --overwrite together with --output or --outputdir."); + return 1; + } + if args.output.is_some() && args.files.len() > 1 { + eprintln!("Cannot set an output file name when there are multiple input files."); + return 1; + } + if args.outputdir.is_some() && args.output.as_deref().is_some_and(Path::is_absolute) { + eprintln!( + "--output is an absolute path even though --outputdir is set.\n\ + Remove --outputdir, or pass a relative path to --output." + ); + return 1; + } + + let config = match Config::load(&args.common.config) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: could not read config ({e}); continuing without it."); + Config::default() + } + }; + + let inputs = KeyInputs { + serials: args.serial.clone(), + pids: args.pid.clone(), + passphrases: args.passphrase.clone(), + key_files: args.key.clone(), + android_backups: args.android_backup.clone(), + }; + let mut keys = KeyStore::gather(&config, &inputs); + let opts = Options::from(&config.options); + + let mut had_error = false; + for file in &args.files { + let file = match std::path::absolute(file) { + Ok(p) => p, + Err(e) => { + eprintln!("Skipping {} - {e}.", file.display()); + had_error = true; + continue; + } + }; + if !file.is_file() { + eprintln!("Skipping file {} - not found.", file.display()); + had_error = true; + continue; + } + + let output = match resolve_output_path( + &file, + args.output.as_deref(), + args.outputdir.as_deref(), + overwrite_original, + ) { + Ok(p) => p, + Err(e) => { + eprintln!("Skipping file {} - {e}.", file.display()); + had_error = true; + continue; + } + }; + + if !overwrite_original && output.is_file() && !force { + eprintln!( + "Skipping file {} because the output file already exists (use --force).", + file.display() + ); + had_error = true; + continue; + } + + println!("Processing {}", file.display()); + match process_one(&file, &output, &mut keys, &opts) { + Ok(()) => println!("Saved DRM-free file to {}", output.display()), + Err(e) => { + eprintln!("{e}"); + had_error = true; + } + } + } + + i32::from(had_error) +} + +/// Decrypt a single file atomically: the engine writes to a temp file that is +/// only renamed into place on success. +fn process_one( + input: &Path, + output: &Path, + keys: &mut KeyStore, + opts: &Options, +) -> Result<(), Error> { + ensure_parent_dir(output)?; + let tmp = temp_sibling(output); + let _ = std::fs::remove_file(&tmp); + + match decrypt(input, &tmp, keys, opts) { + Ok(Outcome::Decrypted) => { + std::fs::rename(&tmp, output)?; + Ok(()) + } + Ok(Outcome::AlreadyDrmFree) => { + let _ = std::fs::remove_file(&tmp); + // The file had no DRM; copy the original through unchanged. + if input != output { + std::fs::copy(input, output)?; + } + Ok(()) + } + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// Resolve the output path, mirroring `_resolve_output_path`. `file` is assumed +/// absolute. +fn resolve_output_path( + file: &Path, + output: Option<&Path>, + outputdir: Option<&Path>, + overwrite_original: bool, +) -> std::io::Result { + if overwrite_original { + return Ok(file.to_path_buf()); + } + if let Some(output) = output { + if let Some(dir) = outputdir { + if !output.is_absolute() { + return std::path::absolute(dir.join(output)); + } + } + return std::path::absolute(output); + } + + let file_name = file.file_name().unwrap_or_default(); + let base = match outputdir { + Some(dir) => dir.to_path_buf(), + None => std::env::current_dir()?, + }; + let out = std::path::absolute(base.join(file_name))?; + if out == file { + // Writing next to the source: add a suffix so we don't clobber it. + return Ok(with_nodrm_suffix(&out)); + } + Ok(out) +} + +fn with_nodrm_suffix(path: &Path) -> PathBuf { + let stem = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let mut name = format!("{stem}_nodrm"); + if let Some(ext) = path.extension() { + name.push('.'); + name.push_str(&ext.to_string_lossy()); + } + path.with_file_name(name) +} + +fn temp_sibling(output: &Path) -> PathBuf { + let mut name = output.file_name().unwrap_or_default().to_os_string(); + name.push(".drmlibre-tmp"); + output.with_file_name(name) +} + +fn ensure_parent_dir(path: &Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() && !parent.is_dir() { + std::fs::create_dir_all(parent)?; + } + } + Ok(()) +} diff --git a/crates/drmlibre-core/Cargo.toml b/crates/drmlibre-core/Cargo.toml new file mode 100644 index 0000000..36b48ee --- /dev/null +++ b/crates/drmlibre-core/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "drmlibre-core" +description = "DRM-removal engine for Amazon Kindle and Adobe Digital Editions ebooks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +toml.workspace = true +zip.workspace = true +flate2.workspace = true +quick-xml.workspace = true +base64.workspace = true +hex.workspace = true + +# Crypto +rsa.workspace = true +aes.workspace = true +cbc.workspace = true +sha1.workspace = true +sha2.workspace = true +md-5.workspace = true + +[dev-dependencies] +tempfile = "3" +rand = "0.8" diff --git a/crates/drmlibre-core/src/adept/encryption_xml.rs b/crates/drmlibre-core/src/adept/encryption_xml.rs new file mode 100644 index 0000000..10afd67 --- /dev/null +++ b/crates/drmlibre-core/src/adept/encryption_xml.rs @@ -0,0 +1,237 @@ +//! Parsing and rewriting of `META-INF/encryption.xml`. +//! +//! ADEPT's `encryption.xml` lists each encrypted resource under an +//! `` whose `` says how it was +//! encrypted and whose `` names the file. We classify +//! entries (DRM content vs. font obfuscation) and, after DRM removal, rewrite +//! the file to drop the entries we handled — dropping the whole file if nothing +//! remains. Mirrors the bookkeeping in `ineptepub.py::Decryptor`. + +use std::collections::HashSet; +use std::io::BufRead; + +use quick_xml::events::Event; +use quick_xml::Reader; + +use crate::error::{Error, Result}; +use crate::xmlutil::{local_eq, local_name}; + +/// Standard XML-ENC AES-128-CBC: Adobe content, decompress after decrypt. +pub const ALG_AES128_CBC: &str = "http://www.w3.org/2001/04/xmlenc#aes128-cbc"; +/// Adobe AES-128-CBC, but don't decompress (e.g. video). +pub const ALG_AES128_CBC_UNCOMPRESSED: &str = + "http://ns.adobe.com/adept/xmlenc#aes128-cbc-uncompressed"; + +/// One `` entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub uri: String, + pub algorithm: String, +} + +impl Entry { + /// Whether this entry is ADEPT content (vs. font obfuscation / other). + pub fn is_drm_content(&self) -> bool { + self.algorithm == ALG_AES128_CBC || self.algorithm == ALG_AES128_CBC_UNCOMPRESSED + } + + /// Whether decrypted content should be decompressed (raw inflate) afterwards. + pub fn needs_decompress(&self) -> bool { + self.algorithm == ALG_AES128_CBC + } +} + +/// Parse all `` entries from `encryption.xml`. +pub fn parse_entries(xml: &[u8]) -> Result> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + + let mut entries = Vec::new(); + let mut in_enc = false; + let mut cur_uri: Option = None; + let mut cur_alg: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + handle_open( + e.name().as_ref(), + &e, + &mut in_enc, + &mut cur_uri, + &mut cur_alg, + )?; + } + Ok(Event::Empty(e)) => { + handle_open( + e.name().as_ref(), + &e, + &mut in_enc, + &mut cur_uri, + &mut cur_alg, + )?; + } + Ok(Event::End(e)) if in_enc && local_eq(e.name().as_ref(), "EncryptedData") => { + in_enc = false; + if let (Some(uri), Some(algorithm)) = (cur_uri.take(), cur_alg.take()) { + entries.push(Entry { uri, algorithm }); + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Error::Decrypt(format!("encryption.xml parse error: {e}"))), + _ => {} + } + buf.clear(); + } + Ok(entries) +} + +fn handle_open( + name: &[u8], + e: &quick_xml::events::BytesStart, + in_enc: &mut bool, + cur_uri: &mut Option, + cur_alg: &mut Option, +) -> Result<()> { + if local_eq(name, "EncryptedData") { + *in_enc = true; + *cur_uri = None; + *cur_alg = None; + } else if *in_enc && local_eq(name, "EncryptionMethod") { + if let Some(v) = attr_value(e, "Algorithm") { + *cur_alg = Some(v); + } + } else if *in_enc && local_eq(name, "CipherReference") { + if let Some(v) = attr_value(e, "URI") { + *cur_uri = Some(v); + } + } + Ok(()) +} + +fn attr_value(e: &quick_xml::events::BytesStart, local: &str) -> Option { + for attr in e.attributes().flatten() { + if local_eq(local_name(attr.key.as_ref()), local) { + return attr.unescape_value().ok().map(|v| v.into_owned()); + } + } + None +} + +/// Rewrite `encryption.xml`, dropping every `` whose +/// `CipherReference URI` is in `remove_uris`. Returns `None` if no entries +/// remain (the file should then be omitted entirely). +/// +/// Works at the byte level (removing the element's source span) so the kept +/// entries are preserved verbatim. +pub fn rewrite(xml: &[u8], remove_uris: &HashSet) -> Result>> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + + let mut remove_ranges: Vec<(usize, usize)> = Vec::new(); + let mut kept = 0usize; + + let mut in_enc = false; + let mut enc_start = 0usize; + let mut cur_uri: Option = None; + + loop { + let pos_before = reader.buffer_position() as usize; + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let name = e.name(); + if local_eq(name.as_ref(), "EncryptedData") { + in_enc = true; + enc_start = pos_before; + cur_uri = None; + } else if in_enc && local_eq(name.as_ref(), "CipherReference") { + cur_uri = attr_value(&e, "URI"); + } + } + Ok(Event::End(e)) if in_enc && local_eq(e.name().as_ref(), "EncryptedData") => { + let pos_after = reader.buffer_position() as usize; + in_enc = false; + match &cur_uri { + Some(u) if remove_uris.contains(u) => { + remove_ranges.push((enc_start, pos_after)); + } + _ => kept += 1, + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(Error::Decrypt(format!("encryption.xml parse error: {e}"))), + _ => {} + } + buf.clear(); + } + + if kept == 0 { + return Ok(None); + } + + // Build the output by skipping the removed source spans. + let mut out = Vec::with_capacity(xml.len()); + let mut cursor = 0usize; + for (start, end) in remove_ranges { + if start > cursor { + out.extend_from_slice(&xml[cursor..start]); + } + cursor = end; + } + out.extend_from_slice(&xml[cursor..]); + Ok(Some(out)) +} + +// Allow `BufRead`-based readers; the slice impl satisfies it. +const _: fn() = || { + fn _assert() {} + _assert::<&[u8]>(); +}; + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &[u8] = br#" + + + + + + + + + +"#; + + #[test] + fn parse_classifies_entries() { + let entries = parse_entries(SAMPLE).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].uri, "OEBPS/text.xhtml"); + assert!(entries[0].is_drm_content()); + assert!(entries[0].needs_decompress()); + assert_eq!(entries[1].uri, "OEBPS/fonts/font.otf"); + assert!(!entries[1].is_drm_content()); + } + + #[test] + fn rewrite_drops_drm_keeps_font() { + let mut remove = HashSet::new(); + remove.insert("OEBPS/text.xhtml".to_string()); + let out = rewrite(SAMPLE, &remove).unwrap().unwrap(); + let s = String::from_utf8(out).unwrap(); + assert!(!s.contains("text.xhtml"), "DRM entry should be gone:\n{s}"); + assert!(s.contains("font.otf"), "font entry should remain:\n{s}"); + } + + #[test] + fn rewrite_drops_file_when_empty() { + let mut remove = HashSet::new(); + remove.insert("OEBPS/text.xhtml".to_string()); + remove.insert("OEBPS/fonts/font.otf".to_string()); + assert!(rewrite(SAMPLE, &remove).unwrap().is_none()); + } +} diff --git a/crates/drmlibre-core/src/adept/epub.rs b/crates/drmlibre-core/src/adept/epub.rs new file mode 100644 index 0000000..a35cafd --- /dev/null +++ b/crates/drmlibre-core/src/adept/epub.rs @@ -0,0 +1,353 @@ +//! ADEPT EPUB decryption — the port of `ineptepub.py::decryptBook`. +//! +//! Flow: read `rights.xml` for the wrapped book key, unwrap it (RSA for ADEPT, +//! AES for PassHash, with an optional hardening layer), then walk the container +//! decrypting every AES-128-CBC entry listed in `encryption.xml`. Font entries +//! are left obfuscated (and kept in `encryption.xml`) unless `deobfuscate_fonts` +//! is set, in which case they are de-obfuscated and dropped from the manifest. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek, Write}; +use std::path::Path; + +use base64::Engine; +use zip::write::SimpleFileOptions; + +use crate::adept::{encryption_xml, fonts, hardening, passhash}; +use crate::crypto; +use crate::decrypt::{Options, Outcome}; +use crate::error::{Error, Result}; +use crate::xmlutil; + +/// The kind of user key being tried against the book. +pub enum UserKey<'a> { + /// Adobe ADEPT: a DER-encoded RSA private key. + Rsa(&'a [u8]), + /// B&N / Adobe PassHash: a base64 key string. + PassHash(&'a str), +} + +const RIGHTS: &str = "META-INF/rights.xml"; +const ENCRYPTION: &str = "META-INF/encryption.xml"; +const MIMETYPE: &str = "mimetype"; + +/// Decrypt `input` to `output` using `key`. Returns [`Outcome::AlreadyDrmFree`] +/// if the file isn't ADEPT-protected, or an error if `key` doesn't fit (so the +/// caller can try another key). +pub fn decrypt_epub(input: &Path, output: &Path, key: UserKey, opts: &Options) -> Result { + let file = std::fs::File::open(input)?; + let mut zip = zip::ZipArchive::new(file)?; + + let names: Vec = zip.file_names().map(|s| s.to_string()).collect(); + let has = |n: &str| names.iter().any(|x| x == n); + if !has(RIGHTS) || !has(ENCRYPTION) { + return Ok(Outcome::AlreadyDrmFree); + } + + // 1. rights.xml -> wrapped book key + keyType. + let rights = read_entry(&mut zip, RIGHTS)?; + let (enc_key_b64, keytype) = encrypted_key(&rights)?; + + // 2. Unwrap to the 16-byte content key. + let bookkey = match key { + UserKey::Rsa(der) => { + let mut wrapped = b64decode(&enc_key_b64)?; + let kt: i64 = keytype.trim().parse().unwrap_or(0); + if kt > 2 { + wrapped = hardening::remove_hardening(&rights, &keytype, &wrapped)?; + } + crypto::rsa_pkcs1v15_decrypt(der, &wrapped)? + } + UserKey::PassHash(pw) => passhash::unwrap_bookkey(pw, &enc_key_b64)?, + }; + if bookkey.len() != 16 { + return Err(Error::Decrypt(format!( + "unexpected book-key length {}", + bookkey.len() + ))); + } + + // 3. Classify encryption.xml entries. + let encryption = read_entry(&mut zip, ENCRYPTION)?; + let entries = encryption_xml::parse_entries(&encryption)?; + + // DRM content: uri -> needs_decompress. + let mut content: HashMap = HashMap::new(); + // Fonts we will de-obfuscate: uri -> (algorithm, key bytes). + let mut fonts_to_deob: HashMap)> = HashMap::new(); + + let font_keys = if opts.deobfuscate_fonts { + fonts::derive_keys(&mut zip) + } else { + fonts::FontKeys::default() + }; + + for e in &entries { + if e.is_drm_content() { + content.insert(e.uri.clone(), e.needs_decompress()); + } else if opts.deobfuscate_fonts { + if let Some(k) = font_keys.key_for(&e.algorithm) { + fonts_to_deob.insert(e.uri.clone(), (e.algorithm.clone(), k.to_vec())); + } + } + } + + // Everything we handled is removed from the rewritten encryption.xml. + let mut remove_uris: HashSet = content.keys().cloned().collect(); + remove_uris.extend(fonts_to_deob.keys().cloned()); + + // 4. Write the output container. + let out_file = std::fs::File::create(output)?; + let mut writer = zip::ZipWriter::new(out_file); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflated = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + // mimetype first, stored. + if has(MIMETYPE) { + let data = read_entry(&mut zip, MIMETYPE)?; + writer.start_file(MIMETYPE, stored)?; + writer.write_all(&data)?; + } + + for name in &names { + if name == MIMETYPE || name == RIGHTS { + continue; + } + if name == ENCRYPTION { + match encryption_xml::rewrite(&encryption, &remove_uris)? { + Some(xml) => { + writer.start_file(name, deflated)?; + writer.write_all(&xml)?; + } + None => continue, // nothing left to declare; drop the file + } + continue; + } + + let data = read_entry(&mut zip, name)?; + let out_data = if let Some(&decompress) = content.get(name) { + decrypt_content(&bookkey, &data, decompress)? + } else if let Some((alg, k)) = fonts_to_deob.get(name) { + fonts::deobfuscate(alg, k, &data) + } else { + data + }; + writer.start_file(name, deflated)?; + writer.write_all(&out_data)?; + } + + writer.finish()?; + Ok(Outcome::Decrypted) +} + +/// Decrypt one AES-128-CBC content entry: null IV, discard the first 16 bytes +/// (a throwaway pad block), strip the trailing pad count, then optionally +/// raw-inflate. Mirrors `ineptepub.py::Decryptor.decrypt`. +fn decrypt_content(bookkey: &[u8], data: &[u8], decompress: bool) -> Result> { + let pt = crypto::aes128_cbc_decrypt_nopad(bookkey, &[0u8; 16], data)?; + if pt.len() < 16 { + return Err(Error::Decrypt("ciphertext too short".into())); + } + let pt = &pt[16..]; + let pad = *pt.last().unwrap() as usize; + if pad == 0 || pad > pt.len() { + return Err(Error::Decrypt("invalid content padding".into())); + } + let pt = &pt[..pt.len() - pad]; + if decompress { + Ok(crypto::raw_inflate(pt).unwrap_or_else(|| pt.to_vec())) + } else { + Ok(pt.to_vec()) + } +} + +/// Read `` text and its `keyType` attribute from rights.xml. +fn encrypted_key(rights: &[u8]) -> Result<(String, String)> { + let text = xmlutil::first_element_text(rights, "encryptedKey") + .ok_or_else(|| Error::Decrypt("rights.xml has no ".into()))?; + let keytype = xmlutil::first_element_attr(rights, "encryptedKey", "keyType") + .unwrap_or_else(|| "0".into()); + Ok((text, keytype)) +} + +fn read_entry(zip: &mut zip::ZipArchive, name: &str) -> Result> { + let mut buf = Vec::new(); + zip.by_name(name)?.read_to_end(&mut buf)?; + Ok(buf) +} + +fn b64decode(s: &str) -> Result> { + base64::engine::general_purpose::STANDARD + .decode(s.trim()) + .map_err(|e| Error::Decrypt(format!("bad base64: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::SimpleFileOptions; + + const PLAIN_XHTML: &[u8] = b"

Hello, DRM-free world!

"; + const PLAIN_RAW: &[u8] = b"\x00\x01\x02 some uncompressed bytes \xfe\xff"; + const PLAIN_CSS: &[u8] = b"body { color: black; }"; + + /// Encrypt content the way Adobe ADEPT does: a throwaway 16-byte pad block, + /// then the (optionally raw-deflated) content, PKCS#7-padded, AES-128-CBC + /// with a null IV. + fn adobe_encrypt(bookkey: &[u8; 16], plaintext: &[u8], compress: bool) -> Vec { + let content = if compress { + let mut e = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(plaintext).unwrap(); + e.finish().unwrap() + } else { + plaintext.to_vec() + }; + let mut body = vec![0u8; 16]; + body.extend_from_slice(&content); + let pad = 16 - (body.len() % 16); + body.extend(std::iter::repeat_n(pad as u8, pad)); + crate::crypto::aes128_cbc_encrypt_nopad(bookkey, &[0u8; 16], &body).unwrap() + } + + fn rights_xml(encrypted_key_b64: &str) -> Vec { + format!( + r#" + {encrypted_key_b64} +"# + ) + .into_bytes() + } + + const ENCRYPTION_XML: &[u8] = br#" + + + + + + + + +"#; + + fn build_epub(path: &Path, bookkey: &[u8; 16], encrypted_key_b64: &str) { + let f = std::fs::File::create(path).unwrap(); + let mut z = zip::ZipWriter::new(f); + let stored = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let defl = + SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + z.start_file("mimetype", stored).unwrap(); + z.write_all(b"application/epub+zip").unwrap(); + z.start_file("META-INF/container.xml", defl).unwrap(); + z.write_all(br#""#).unwrap(); + z.start_file("META-INF/rights.xml", defl).unwrap(); + z.write_all(&rights_xml(encrypted_key_b64)).unwrap(); + z.start_file("META-INF/encryption.xml", defl).unwrap(); + z.write_all(ENCRYPTION_XML).unwrap(); + z.start_file("OEBPS/ch1.xhtml", defl).unwrap(); + z.write_all(&adobe_encrypt(bookkey, PLAIN_XHTML, true)) + .unwrap(); + z.start_file("OEBPS/raw.bin", defl).unwrap(); + z.write_all(&adobe_encrypt(bookkey, PLAIN_RAW, false)) + .unwrap(); + z.start_file("OEBPS/style.css", defl).unwrap(); + z.write_all(PLAIN_CSS).unwrap(); + z.finish().unwrap(); + } + + fn read_out(path: &Path, name: &str) -> Option> { + let f = std::fs::File::open(path).unwrap(); + let mut z = zip::ZipArchive::new(f).unwrap(); + let mut buf = Vec::new(); + use std::io::Read as _; + z.by_name(name).ok()?.read_to_end(&mut buf).ok()?; + Some(buf) + } + + fn make_rsa() -> (Vec, rsa::RsaPublicKey) { + use rsa::pkcs8::EncodePrivateKey; + let mut rng = rand::thread_rng(); + let priv_key = rsa::RsaPrivateKey::new(&mut rng, 1024).unwrap(); + let der = priv_key.to_pkcs8_der().unwrap().as_bytes().to_vec(); + let pub_key = rsa::RsaPublicKey::from(&priv_key); + (der, pub_key) + } + + #[test] + fn roundtrip_adept_epub_rsa() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("book.epub"); + let output = dir.path().join("out.epub"); + + let (der, pub_key) = make_rsa(); + let bookkey = [0x5Au8; 16]; + let mut rng = rand::thread_rng(); + let enc_key = pub_key + .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, &bookkey) + .unwrap(); + let enc_key_b64 = base64::engine::general_purpose::STANDARD.encode(enc_key); + + build_epub(&input, &bookkey, &enc_key_b64); + + let opts = Options { + deobfuscate_fonts: false, + remove_watermarks: false, + }; + let outcome = decrypt_epub(&input, &output, UserKey::Rsa(&der), &opts).unwrap(); + assert_eq!(outcome, Outcome::Decrypted); + + assert_eq!(read_out(&output, "OEBPS/ch1.xhtml").unwrap(), PLAIN_XHTML); + assert_eq!(read_out(&output, "OEBPS/raw.bin").unwrap(), PLAIN_RAW); + assert_eq!(read_out(&output, "OEBPS/style.css").unwrap(), PLAIN_CSS); + assert_eq!( + read_out(&output, "mimetype").unwrap(), + b"application/epub+zip" + ); + // DRM metadata is gone; both encrypted entries were removed so the whole + // encryption.xml is dropped. + assert!(read_out(&output, "META-INF/rights.xml").is_none()); + assert!(read_out(&output, "META-INF/encryption.xml").is_none()); + } + + #[test] + fn wrong_rsa_key_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("book.epub"); + let output = dir.path().join("out.epub"); + + let (_der, pub_key) = make_rsa(); + let (other_der, _other_pub) = make_rsa(); + let bookkey = [0x5Au8; 16]; + let mut rng = rand::thread_rng(); + let enc_key = pub_key + .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, &bookkey) + .unwrap(); + let enc_key_b64 = base64::engine::general_purpose::STANDARD.encode(enc_key); + build_epub(&input, &bookkey, &enc_key_b64); + + let opts = Options::default(); + // A different key must not silently "succeed". + assert!(decrypt_epub(&input, &output, UserKey::Rsa(&other_der), &opts).is_err()); + } + + #[test] + fn drm_free_epub_reports_already_free() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("plain.epub"); + let output = dir.path().join("out.epub"); + let f = std::fs::File::create(&input).unwrap(); + let mut z = zip::ZipWriter::new(f); + z.start_file("mimetype", SimpleFileOptions::default()) + .unwrap(); + z.write_all(b"application/epub+zip").unwrap(); + z.finish().unwrap(); + + let (der, _pub) = make_rsa(); + let outcome = + decrypt_epub(&input, &output, UserKey::Rsa(&der), &Options::default()).unwrap(); + assert_eq!(outcome, Outcome::AlreadyDrmFree); + } +} diff --git a/crates/drmlibre-core/src/adept/fonts.rs b/crates/drmlibre-core/src/adept/fonts.rs new file mode 100644 index 0000000..a3019a7 --- /dev/null +++ b/crates/drmlibre-core/src/adept/fonts.rs @@ -0,0 +1,285 @@ +//! EPUB font de-obfuscation (IETF/IDPF and Adobe algorithms). +//! +//! Ported from `epubfontdecrypt.py`. This is applied *after* ADEPT DRM removal, +//! only when `deobfuscate_fonts` is enabled. Both algorithms XOR a prefix of the +//! font with a key derived from the OPF's identifier; the IETF key is a SHA-1 of +//! the unique identifier, the Adobe key is the raw UUID bytes. + +use std::io::{Read, Seek}; + +use quick_xml::events::Event; +use quick_xml::Reader; + +use crate::crypto; +use crate::xmlutil::{local_eq, local_name}; + +/// Algorithm URI for IETF/IDPF font obfuscation. +pub const ALG_IETF: &str = "http://www.idpf.org/2008/embedding"; +/// Algorithm URI for Adobe font obfuscation. +pub const ALG_ADOBE: &str = "http://ns.adobe.com/pdf/enc#RC"; + +const IETF_OBFUSCATED_LEN: usize = 1040; +const ADOBE_OBFUSCATED_LEN: usize = 1024; + +/// Font obfuscation keys derived from the OPF, either of which may be absent. +#[derive(Debug, Default, Clone)] +pub struct FontKeys { + /// SHA-1 of the cleaned unique identifier (20 bytes). + pub ietf: Option<[u8; 20]>, + /// Raw UUID bytes (16 bytes). + pub adobe: Option<[u8; 16]>, +} + +impl FontKeys { + /// The key for a given obfuscation algorithm URI, if known. + pub fn key_for<'a>(&'a self, algorithm: &str) -> Option<&'a [u8]> { + match algorithm { + ALG_IETF => self.ietf.as_ref().map(|k| k.as_slice()), + ALG_ADOBE => self.adobe.as_ref().map(|k| k.as_slice()), + _ => None, + } + } +} + +/// One `` from the OPF metadata. +#[derive(Debug, Default, Clone)] +struct Identifier { + id: Option, + scheme: Option, + text: String, +} + +/// Derive the font keys by reading `META-INF/container.xml` and the OPF. +pub fn derive_keys(zip: &mut zip::ZipArchive) -> FontKeys { + let opf_path = match opf_path(zip) { + Some(p) => p, + None => return FontKeys::default(), + }; + let opf = match read_zip_entry(zip, &opf_path) { + Some(b) => b, + None => return FontKeys::default(), + }; + let (unique_id, identifiers) = parse_opf(&opf); + + FontKeys { + ietf: ietf_key(unique_id.as_deref(), &identifiers), + adobe: adobe_key(&identifiers), + } +} + +fn ietf_key(unique_id: Option<&str>, identifiers: &[Identifier]) -> Option<[u8; 20]> { + // Match the OPF's unique-identifier id; if none is named, the last + // identifier wins (mirroring the Python loop). + let mut chosen: Option<&str> = None; + for ident in identifiers { + if unique_id.is_none() || unique_id == ident.id.as_deref() { + chosen = Some(&ident.text); + } + } + let raw = chosen?; + let cleaned: String = raw + .chars() + .filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')) + .collect(); + Some(crypto::sha1(cleaned.as_bytes())) +} + +fn adobe_key(identifiers: &[Identifier]) -> Option<[u8; 16]> { + let mut uid: Option = None; + for ident in identifiers { + if ident.scheme.as_deref() == Some("UUID") { + uid = Some(strip_urn_uuid(&ident.text)); + break; + } + if ident.text.starts_with("urn:uuid:") { + uid = Some(ident.text[9..].to_string()); + break; + } + } + let uid = uid?; + let uid: String = uid + .chars() + .filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n' | '-')) + .collect(); + if uid.len() < 16 || !uid.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let doubled = format!("{uid}{uid}"); + let bytes = hex::decode(&doubled[..32]).ok()?; + let mut key = [0u8; 16]; + key.copy_from_slice(&bytes); + Some(key) +} + +fn strip_urn_uuid(text: &str) -> String { + text.strip_prefix("urn:uuid:").unwrap_or(text).to_string() +} + +/// De-obfuscate one font file. `algorithm` selects the prefix length; `key` is +/// the matching key from [`FontKeys`]. +pub fn deobfuscate(algorithm: &str, key: &[u8], data: &[u8]) -> Vec { + let prefix = if algorithm == ALG_ADOBE { + ADOBE_OBFUSCATED_LEN + } else { + IETF_OBFUSCATED_LEN + }; + // Speculatively raw-inflate (rare double-compression); usually a no-op. + let (mut data, was_decomp) = match crypto::raw_inflate(data) { + Some(d) => (d, true), + None => (data.to_vec(), false), + }; + + let n = data.len().min(prefix); + for (i, b) in data[..n].iter_mut().enumerate() { + *b ^= key[i % key.len()]; + } + + if !was_decomp { + if let Some(d) = crypto::raw_inflate(&data) { + data = d; + } + } + data +} + +fn opf_path(zip: &mut zip::ZipArchive) -> Option { + let xml = read_zip_entry(zip, "META-INF/container.xml")?; + first_attr(&xml, "rootfile", "full-path") +} + +fn read_zip_entry(zip: &mut zip::ZipArchive, name: &str) -> Option> { + let mut buf = Vec::new(); + zip.by_name(name).ok()?.read_to_end(&mut buf).ok()?; + Some(buf) +} + +/// First `attr` value on the first element whose local name is `local`. +fn first_attr(xml: &[u8], local: &str, attr: &str) -> Option { + let mut reader = Reader::from_reader(xml); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) if local_eq(e.name().as_ref(), local) => { + return attr_value(&e, attr); + } + Ok(Event::Eof) | Err(_) => return None, + _ => {} + } + buf.clear(); + } +} + +/// Parse the OPF, returning `(package@unique-identifier, [dc:identifier...])`. +fn parse_opf(xml: &[u8]) -> (Option, Vec) { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut buf = Vec::new(); + + let mut unique_id = None; + let mut identifiers = Vec::new(); + let mut cur: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = e.name(); + if local_eq(name.as_ref(), "package") { + unique_id = attr_value(&e, "unique-identifier"); + } else if local_eq(name.as_ref(), "identifier") { + cur = Some(Identifier { + id: attr_value(&e, "id"), + scheme: attr_value(&e, "scheme"), + text: String::new(), + }); + } + } + Ok(Event::Empty(e)) => { + let name = e.name(); + if local_eq(name.as_ref(), "package") { + unique_id = attr_value(&e, "unique-identifier"); + } else if local_eq(name.as_ref(), "identifier") { + identifiers.push(Identifier { + id: attr_value(&e, "id"), + scheme: attr_value(&e, "scheme"), + text: String::new(), + }); + } + } + Ok(Event::Text(t)) => { + if let Some(ident) = cur.as_mut() { + if let Ok(s) = t.unescape() { + ident.text.push_str(&s); + } + } + } + Ok(Event::End(e)) if local_eq(e.name().as_ref(), "identifier") => { + if let Some(ident) = cur.take() { + identifiers.push(ident); + } + } + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + buf.clear(); + } + (unique_id, identifiers) +} + +fn attr_value(e: &quick_xml::events::BytesStart, local: &str) -> Option { + for attr in e.attributes().flatten() { + if local_eq(local_name(attr.key.as_ref()), local) { + return attr.unescape_value().ok().map(|v| v.into_owned()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const OPF: &[u8] = br#" + + + urn:uuid:12345678-1234-5678-1234-567812345678 + urn:uuid:00112233-4455-6677-8899-aabbccddeeff + +"#; + + #[test] + fn ietf_key_uses_unique_identifier() { + let (uid, ids) = parse_opf(OPF); + assert_eq!(uid.as_deref(), Some("bookid")); + let key = ietf_key(uid.as_deref(), &ids).unwrap(); + // SHA1 of the bookid identifier text (with urn:uuid: prefix kept). + let expected = crypto::sha1(b"urn:uuid:12345678-1234-5678-1234-567812345678"); + assert_eq!(key, expected); + } + + #[test] + fn adobe_key_from_uuid_scheme() { + let (_uid, ids) = parse_opf(OPF); + let key = adobe_key(&ids).unwrap(); + // First identifier has urn:uuid: prefix and is hit first in the loop. + assert_eq!( + key, + [ + 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, + 0x56, 0x78 + ] + ); + } + + #[test] + fn deobfuscate_xors_prefix_only() { + let key = [0xFFu8; 20]; + let mut data = vec![0u8; 2000]; + data[0] = 0x0F; + data[1039] = 0x0F; + data[1040] = 0x0F; // beyond the IETF window, must stay unchanged + let out = deobfuscate(ALG_IETF, &key, &data); + assert_eq!(out[0], 0x0F ^ 0xFF); + assert_eq!(out[1039], 0x0F ^ 0xFF); + assert_eq!(out[1040], 0x0F); // untouched + } +} diff --git a/crates/drmlibre-core/src/adept/hardening.rs b/crates/drmlibre-core/src/adept/hardening.rs new file mode 100644 index 0000000..f134788 --- /dev/null +++ b/crates/drmlibre-core/src/adept/hardening.rs @@ -0,0 +1,78 @@ +//! "Hardened" Adobe ADEPT (RMSDK >= 10) key-encryption-key unwrap. +//! +//! Ported from `ineptepub.py::removeHardening`. Before the RSA step, the +//! base64-decoded `` is itself AES-128-CBC encrypted under a KEK +//! derived from `keyType`, with an IV built by XORing three license UUIDs. + +use crate::crypto; +use crate::error::{Error, Result}; +use crate::xmlutil; + +/// Undo the hardening layer, returning the inner (RSA-encrypted) book key bytes. +/// +/// `rights_xml` is `META-INF/rights.xml`; `keytype` is the decimal `keyType` +/// attribute string; `keydata` is the base64-decoded ``. +pub fn remove_hardening(rights_xml: &[u8], keytype: &str, keydata: &[u8]) -> Result> { + let resource = parse_uuid(&text(rights_xml, "resource")?)?; + let device = parse_uuid(&text(rights_xml, "device")?)?; + let fulfillment_text = text(rights_xml, "fulfillment")?; + // The fulfillment value may carry a suffix; only the leading UUID matters. + let fulfillment_str: String = fulfillment_text.chars().take(36).collect(); + let fulfillment = parse_uuid(&fulfillment_str)?; + + // KEK IV = resource ^ device ^ fulfillment (big-endian 128-bit values). + let mut kekiv = [0u8; 16]; + for i in 0..16 { + kekiv[i] = resource[i] ^ device[i] ^ fulfillment[i]; + } + + // Derive the KEK from just the keyType string. + let keytype_int: u64 = keytype + .trim() + .parse() + .map_err(|_| Error::Decrypt(format!("invalid keyType {keytype:?}")))?; + let rem = (keytype_int % 16) as usize; + let h = crypto::sha256(keytype.as_bytes()); + let mut kek = Vec::with_capacity(16); + kek.extend_from_slice(&h[2 * rem..16 + rem]); + kek.extend_from_slice(&h[rem..2 * rem]); + debug_assert_eq!(kek.len(), 16); + + crypto::aes128_cbc_decrypt_pkcs7(&kek, &kekiv, keydata) +} + +fn text(xml: &[u8], name: &str) -> Result { + xmlutil::first_element_text(xml, name) + .ok_or_else(|| Error::Decrypt(format!("rights.xml missing <{name}>"))) +} + +/// Parse a hyphenated UUID string into its 16 big-endian bytes (matching +/// Python's `UUID(...).bytes`). +fn parse_uuid(s: &str) -> Result<[u8; 16]> { + let hexstr: String = s.chars().filter(|c| *c != '-').collect(); + let bytes = + hex::decode(hexstr.trim()).map_err(|_| Error::Decrypt(format!("invalid UUID {s:?}")))?; + if bytes.len() != 16 { + return Err(Error::Decrypt(format!("UUID {s:?} is not 16 bytes"))); + } + let mut out = [0u8; 16]; + out.copy_from_slice(&bytes); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_uuid_big_endian() { + let u = parse_uuid("00112233-4455-6677-8899-aabbccddeeff").unwrap(); + assert_eq!( + u, + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff + ] + ); + } +} diff --git a/crates/drmlibre-core/src/adept/mod.rs b/crates/drmlibre-core/src/adept/mod.rs new file mode 100644 index 0000000..b7a1d4f --- /dev/null +++ b/crates/drmlibre-core/src/adept/mod.rs @@ -0,0 +1,13 @@ +//! Adobe Digital Editions (ADEPT) DRM removal. +//! +//! Ported from the DeDRM `ineptepub.py` / `epubfontdecrypt.py` / +//! `ignoblekeyGenPassHash.py` sources. ADEPT PDF is intentionally out of scope +//! for v0.1. + +pub mod encryption_xml; +pub mod epub; +pub mod fonts; +pub mod hardening; +pub mod passhash; + +pub use epub::{decrypt_epub, UserKey}; diff --git a/crates/drmlibre-core/src/adept/passhash.rs b/crates/drmlibre-core/src/adept/passhash.rs new file mode 100644 index 0000000..73a2b7c --- /dev/null +++ b/crates/drmlibre-core/src/adept/passhash.rs @@ -0,0 +1,104 @@ +//! Barnes & Noble / Adobe "PassHash" keys. +//! +//! Ported from `ignoblekeyGenPassHash.py::generate_key` (generation) and the +//! PassHash branch of `ineptepub.py::decryptBook` (book-key unwrap). + +use base64::Engine; + +use crate::crypto; +use crate::error::{Error, Result}; + +/// Generate a B&N PassHash (base64) from an account name and credit-card number. +/// +/// ```text +/// name = lower(name without spaces) + 0x00 +/// ccn = lower(ccn without spaces) + 0x00 +/// crypt = AES-128-CBC(key=SHA1(ccn)[:16], iv=SHA1(name)[:16]) +/// .encrypt(SHA1(name+ccn) + 0x0c*12) +/// key = base64(SHA1(crypt)) +/// ``` +pub fn generate_key(name: &str, ccn: &str) -> Result { + let mut name = normalize(name).into_bytes(); + let mut ccn = normalize(ccn).into_bytes(); + name.push(0); + ccn.push(0); + + let name_sha = &crypto::sha1(&name)[..16]; + let ccn_sha = &crypto::sha1(&ccn)[..16]; + + let mut both = name.clone(); + both.extend_from_slice(&ccn); + let both_sha = crypto::sha1(&both); // 20 bytes + + let mut plaintext = both_sha.to_vec(); + plaintext.extend(std::iter::repeat_n(0x0cu8, 0x0c)); // -> 32 bytes + + let crypt = crypto::aes128_cbc_encrypt_nopad(ccn_sha, name_sha, &plaintext)?; + let userkey = crypto::sha1(&crypt); + Ok(base64::engine::general_purpose::STANDARD.encode(userkey)) +} + +fn normalize(s: &str) -> String { + s.chars() + .filter(|c| *c != ' ') + .flat_map(|c| c.to_lowercase()) + .collect() +} + +/// Unwrap the 16-byte book key from a 64-char base64 `` using a +/// base64 PassHash `userkey`. Mirrors the `len == 64` branch of `decryptBook`. +pub fn unwrap_bookkey(userkey_b64: &str, encrypted_key_b64: &str) -> Result> { + let std = base64::engine::general_purpose::STANDARD; + let userkey = std + .decode(userkey_b64.trim()) + .map_err(|e| Error::Decrypt(format!("bad PassHash base64: {e}")))?; + if userkey.len() < 16 { + return Err(Error::Decrypt("PassHash key too short".into())); + } + let key = &userkey[..16]; + let encrypted = std + .decode(encrypted_key_b64.trim()) + .map_err(|e| Error::Decrypt(format!("bad encryptedKey base64: {e}")))?; + + let mut bookkey = crypto::aes128_cbc_decrypt_pkcs7(key, &[0u8; 16], &encrypted)?; + if bookkey.len() > 16 { + bookkey = bookkey[bookkey.len() - 16..].to_vec(); + } + Ok(bookkey) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_key_matches_python_reference() { + // Reference values produced by ignoblekeyGenPassHash.py::generate_key + // for ("John Doe", "1234567890123456"). + let key = generate_key("John Doe", "1234 5678 9012 3456").unwrap(); + // Spaces and case must be normalized away, so these two agree: + let key2 = generate_key("johndoe", "1234567890123456").unwrap(); + assert_eq!(key, key2); + // base64 of a 20-byte SHA1 digest is 28 chars. + assert_eq!(key.len(), 28); + } + + #[test] + fn unwrap_roundtrips_against_generation() { + // Construct a fake book: pick a 16-byte book key, wrap it with a + // PassHash userkey the same way B&N does, then unwrap it back. + let userkey_b64 = generate_key("Reader", "4111111111111111").unwrap(); + let std = base64::engine::general_purpose::STANDARD; + let key = &std.decode(&userkey_b64).unwrap()[..16]; + + let bookkey = [0xABu8; 16]; + // B&N wraps as AES-CBC(key, iv=0) of (bookkey + PKCS7 pad to 32). + let mut padded = bookkey.to_vec(); + padded.extend(std::iter::repeat_n(16u8, 16)); + let wrapped = crate::crypto::aes128_cbc_encrypt_nopad(key, &[0u8; 16], &padded).unwrap(); + let wrapped_b64 = std.encode(&wrapped); + + let got = unwrap_bookkey(&userkey_b64, &wrapped_b64).unwrap(); + assert_eq!(got, bookkey); + } +} diff --git a/crates/drmlibre-core/src/config.rs b/crates/drmlibre-core/src/config.rs new file mode 100644 index 0000000..794dc74 --- /dev/null +++ b/crates/drmlibre-core/src/config.rs @@ -0,0 +1,146 @@ +//! Native TOML configuration. +//! +//! A fresh, Rust-native schema (intentionally *not* the DeDRM `dedrm.json` +//! format). Auto-extracted keys are written back here so later runs work +//! offline. See [`Config`] for the on-disk shape. + +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +/// Default config file name, looked up in the current directory. +pub const DEFAULT_CONFIG_NAME: &str = "drmlibre.toml"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub adept: AdeptSection, + pub passhash: PassHashSection, + pub kindle: KindleSection, + pub options: OptionsSection, +} + +/// Adobe RSA user keys (DER), hex-encoded. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AdeptSection { + pub keys: Vec, +} + +/// Barnes & Noble / Adobe PassHash keys, base64-encoded. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PassHashSection { + pub keys: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct KindleSection { + /// Extracted Kindle-for-PC/Mac key databases (k4i JSON, kept verbatim). + pub databases: Vec, + /// eInk Kindle device serial numbers. + pub serials: Vec, + /// Mobipocket / Kindle PIDs. + pub pids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KindleDatabase { + pub name: String, + /// The raw k4i JSON document for this key database. + pub data: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct OptionsSection { + pub deobfuscate_fonts: bool, + pub remove_watermarks: bool, +} + +impl Default for OptionsSection { + fn default() -> Self { + // Match the DeDRM defaults: de-obfuscate fonts, leave watermarks. + OptionsSection { + deobfuscate_fonts: true, + remove_watermarks: false, + } + } +} + +impl Config { + /// Load the config from `path`. A missing file yields a default config (not + /// an error), mirroring the Python CLI's soft handling. + pub fn load(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(text) => toml::from_str(&text).map_err(|e| Error::Config(e.to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()), + Err(e) => Err(Error::Io(e)), + } + } + + /// Write the config to `path` (pretty-printed TOML). + pub fn save(&self, path: &Path) -> Result<()> { + let text = toml::to_string_pretty(self).map_err(|e| Error::Config(e.to_string()))?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + fs::write(path, text)?; + Ok(()) + } + + /// A one-line-per-category count summary for the `config` command. + pub fn summary(&self) -> ConfigSummary { + ConfigSummary { + adept_keys: self.adept.keys.len(), + passhash_keys: self.passhash.keys.len(), + kindle_databases: self.kindle.databases.len(), + serials: self.kindle.serials.len(), + pids: self.kindle.pids.len(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigSummary { + pub adept_keys: usize, + pub passhash_keys: usize, + pub kindle_databases: usize, + pub serials: usize, + pub pids: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_is_default() { + let dir = tempfile::tempdir().unwrap(); + let cfg = Config::load(&dir.path().join("nope.toml")).unwrap(); + assert!(cfg.adept.keys.is_empty()); + assert!(cfg.options.deobfuscate_fonts); + assert!(!cfg.options.remove_watermarks); + } + + #[test] + fn round_trips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("drmlibre.toml"); + let mut cfg = Config::default(); + cfg.adept.keys.push("30820abc".into()); + cfg.kindle.serials.push("0123456789ABCDEF".into()); + cfg.save(&path).unwrap(); + + let loaded = Config::load(&path).unwrap(); + assert_eq!(loaded.adept.keys, vec!["30820abc".to_string()]); + assert_eq!(loaded.summary().adept_keys, 1); + assert_eq!(loaded.summary().serials, 1); + } +} diff --git a/crates/drmlibre-core/src/crypto.rs b/crates/drmlibre-core/src/crypto.rs new file mode 100644 index 0000000..05b5d0f --- /dev/null +++ b/crates/drmlibre-core/src/crypto.rs @@ -0,0 +1,164 @@ +//! Thin, pure-Rust wrappers over the RustCrypto primitives used by the engine. +//! +//! Keeping these in one place keeps the format modules readable and makes the +//! exact parameters (modes, padding, key sizes) easy to audit against the +//! upstream DeDRM Python code. + +use aes::Aes128; +use cbc::cipher::block_padding::NoPadding; +use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use sha2::Digest; + +use crate::error::{Error, Result}; + +/// SHA-1 digest. +pub fn sha1(data: &[u8]) -> [u8; 20] { + let mut h = sha1::Sha1::new(); + h.update(data); + h.finalize().into() +} + +/// SHA-256 digest. +pub fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = sha2::Sha256::new(); + h.update(data); + h.finalize().into() +} + +/// AES-128-CBC decrypt with **no** padding removal. `data` length must be a +/// multiple of 16. Returns the raw plaintext (callers handle any unpadding). +pub fn aes128_cbc_decrypt_nopad(key: &[u8], iv: &[u8], data: &[u8]) -> Result> { + if key.len() != 16 || iv.len() != 16 { + return Err(Error::Decrypt( + "AES-128-CBC needs a 16-byte key and IV".into(), + )); + } + if data.is_empty() || !data.len().is_multiple_of(16) { + return Err(Error::Decrypt( + "AES-128-CBC ciphertext length must be a non-zero multiple of 16".into(), + )); + } + let mut buf = data.to_vec(); + let dec = cbc::Decryptor::::new_from_slices(key, iv) + .map_err(|_| Error::Decrypt("bad AES key/iv length".into()))?; + let pt = dec + .decrypt_padded_mut::(&mut buf) + .map_err(|e| Error::Decrypt(format!("AES-CBC decrypt failed: {e}")))?; + let len = pt.len(); + buf.truncate(len); + Ok(buf) +} + +/// AES-128-CBC encrypt with **no** padding (caller supplies block-aligned data). +/// Used by PassHash generation, which pads manually. +pub fn aes128_cbc_encrypt_nopad(key: &[u8], iv: &[u8], data: &[u8]) -> Result> { + if data.is_empty() || !data.len().is_multiple_of(16) { + return Err(Error::Decrypt( + "AES-128-CBC plaintext length must be a non-zero multiple of 16".into(), + )); + } + let mut buf = data.to_vec(); + let len = buf.len(); + let enc = cbc::Encryptor::::new_from_slices(key, iv) + .map_err(|_| Error::Decrypt("bad AES key/iv length".into()))?; + let ct = enc + .encrypt_padded_mut::(&mut buf, len) + .map_err(|e| Error::Decrypt(format!("AES-CBC encrypt failed: {e}")))?; + Ok(ct.to_vec()) +} + +/// Strip PKCS#7-style padding by trusting the final byte as the pad length. +/// +/// Mirrors `utilities.py::unpad`: it does not verify every pad byte, it simply +/// removes the number of trailing bytes given by the last byte. +pub fn unpad_pkcs7(data: &[u8]) -> Result> { + let pad = *data + .last() + .ok_or_else(|| Error::Decrypt("empty plaintext".into()))? as usize; + if pad == 0 || pad > data.len() { + return Err(Error::Decrypt("invalid PKCS#7 padding".into())); + } + Ok(data[..data.len() - pad].to_vec()) +} + +/// AES-128-CBC decrypt followed by PKCS#7 unpadding. +pub fn aes128_cbc_decrypt_pkcs7(key: &[u8], iv: &[u8], data: &[u8]) -> Result> { + let pt = aes128_cbc_decrypt_nopad(key, iv, data)?; + unpad_pkcs7(&pt) +} + +/// Raw DEFLATE inflate (zlib window bits -15, i.e. no zlib/gzip header), as used +/// by Adobe's `decompress`. Returns `None` if the data isn't raw-deflate. +pub fn raw_inflate(data: &[u8]) -> Option> { + use std::io::Read as _; + let mut out = Vec::new(); + let mut dec = flate2::read::DeflateDecoder::new(data); + match dec.read_to_end(&mut out) { + Ok(_) if !out.is_empty() => Some(out), + _ => None, + } +} + +/// RSA PKCS#1 v1.5 decryption with a DER-encoded private key. +/// +/// Adobe user keys are DER RSA private keys; pycryptodome's `RSA.importKey` +/// accepts either PKCS#1 or PKCS#8, so we try both. +pub fn rsa_pkcs1v15_decrypt(der_key: &[u8], ciphertext: &[u8]) -> Result> { + use rsa::pkcs1::DecodeRsaPrivateKey; + use rsa::pkcs8::DecodePrivateKey; + use rsa::{Pkcs1v15Encrypt, RsaPrivateKey}; + + let key = RsaPrivateKey::from_pkcs8_der(der_key) + .or_else(|_| RsaPrivateKey::from_pkcs1_der(der_key)) + .map_err(|e| Error::Decrypt(format!("not a valid RSA DER key: {e}")))?; + key.decrypt(Pkcs1v15Encrypt, ciphertext) + .map_err(|e| Error::Decrypt(format!("RSA decrypt failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_known_vector() { + // SHA1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d + assert_eq!( + hex::encode(sha1(b"abc")), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + } + + #[test] + fn sha256_known_vector() { + assert_eq!( + hex::encode(sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn unpad_strips_trailing_count() { + assert_eq!(unpad_pkcs7(&[1, 2, 3, 3, 3, 3]).unwrap(), vec![1, 2, 3]); + assert!(unpad_pkcs7(&[]).is_err()); + assert!(unpad_pkcs7(&[1, 5]).is_err()); // pad count (5) larger than data + } + + #[test] + fn aes_cbc_roundtrip_nopad() { + use aes::Aes128; + use cbc::cipher::block_padding::NoPadding; + use cbc::cipher::{BlockEncryptMut, KeyIvInit}; + + let key = [0x11u8; 16]; + let iv = [0x22u8; 16]; + let pt = b"0123456789abcdef0123456789abcdef"; // 32 bytes + let mut buf = pt.to_vec(); + let ct = cbc::Encryptor::::new_from_slices(&key, &iv) + .unwrap() + .encrypt_padded_mut::(&mut buf, 32) + .unwrap() + .to_vec(); + let back = aes128_cbc_decrypt_nopad(&key, &iv, &ct).unwrap(); + assert_eq!(back, pt); + } +} diff --git a/crates/drmlibre-core/src/decrypt.rs b/crates/drmlibre-core/src/decrypt.rs new file mode 100644 index 0000000..d3ae0b0 --- /dev/null +++ b/crates/drmlibre-core/src/decrypt.rs @@ -0,0 +1,135 @@ +//! Top-level decryption dispatch. +//! +//! Maps a detected [`Format`] to the right engine. The engines themselves are +//! filled in phase by phase; until then a supported-but-unbuilt format returns +//! [`Error::NotImplemented`]. + +use std::path::Path; + +use crate::adept::{self, UserKey}; +use crate::config::OptionsSection; +use crate::error::{Error, Result}; +use crate::format::Format; +use crate::keys::KeyBundle; +use crate::keys::KeyStore; + +/// Per-run options (currently EPUB post-processing toggles). +#[derive(Debug, Clone)] +pub struct Options { + pub deobfuscate_fonts: bool, + pub remove_watermarks: bool, +} + +impl Default for Options { + fn default() -> Self { + Options { + deobfuscate_fonts: true, + remove_watermarks: false, + } + } +} + +impl From<&OptionsSection> for Options { + fn from(o: &OptionsSection) -> Self { + Options { + deobfuscate_fonts: o.deobfuscate_fonts, + remove_watermarks: o.remove_watermarks, + } + } +} + +/// What happened to a single file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// DRM was removed and `output` was written. + Decrypted, + /// The file had no DRM; the caller should copy the input verbatim. + AlreadyDrmFree, +} + +/// Remove DRM from `input`, writing the result to `output`. +pub fn decrypt( + input: &Path, + output: &Path, + keys: &mut KeyStore, + opts: &Options, +) -> Result { + let format = Format::detect(input)?; + tracing::info!("{} detected as {}", input.display(), format.label()); + decrypt_as(format, input, output, keys, opts) +} + +/// Like [`decrypt`] but with an already-known format (useful for tests/GUIs). +pub fn decrypt_as( + format: Format, + input: &Path, + output: &Path, + keys: &mut KeyStore, + opts: &Options, +) -> Result { + match format { + Format::AdeptEpub => decrypt_adept_epub(input, output, keys, opts), + Format::AdeptPassHashEpub => decrypt_passhash_epub(input, output, keys, opts), + Format::Mobi => crate::kindle::decrypt_mobi(input, output, &keys.bundle), + Format::KfxZip => Err(Error::NotImplemented("KFX-ZIP (phase 4)")), + + Format::Pdf => Err(Error::UnsupportedFormat( + "ADEPT PDF is not supported by DRMLibre v0.1.".into(), + )), + Format::Topaz => Err(Error::UnsupportedFormat( + "Kindle Topaz is not supported by DRMLibre v0.1.".into(), + )), + Format::Pdb => Err(Error::UnsupportedFormat( + "eReader (.pdb) files are not supported by DRMLibre.".into(), + )), + Format::Lcp => Err(Error::UnsupportedFormat( + "Readium LCP files are not supported by DRMLibre.".into(), + )), + Format::Zip => Err(Error::NoDrmDetected), + } +} + +fn decrypt_adept_epub( + input: &Path, + output: &Path, + keys: &mut KeyStore, + opts: &Options, +) -> Result { + if let Some(outcome) = try_adept_keys(&keys.bundle, input, output, opts)? { + return Ok(outcome); + } + // Phase 3 will retry here after auto-extracting Adobe keys. + Err(Error::NoValidKey("Adobe", input.display().to_string())) +} + +fn decrypt_passhash_epub( + input: &Path, + output: &Path, + keys: &mut KeyStore, + opts: &Options, +) -> Result { + for pw in &keys.bundle.bandn_userkeys { + match adept::decrypt_epub(input, output, UserKey::PassHash(pw), opts) { + Ok(outcome) => return Ok(outcome), + Err(e) => tracing::debug!("PassHash key did not fit: {e}"), + } + } + Err(Error::NoValidKey("PassHash", input.display().to_string())) +} + +/// Try every RSA user key; `Ok(Some(_))` once one fits (or the file is already +/// DRM-free), `Ok(None)` if none of the keys worked. +fn try_adept_keys( + bundle: &KeyBundle, + input: &Path, + output: &Path, + opts: &Options, +) -> Result> { + for der in &bundle.adept_userkeys { + match adept::decrypt_epub(input, output, UserKey::Rsa(der), opts) { + Ok(outcome) => return Ok(Some(outcome)), + Err(e) => tracing::debug!("Adobe key did not fit: {e}"), + } + } + Ok(None) +} diff --git a/crates/drmlibre-core/src/error.rs b/crates/drmlibre-core/src/error.rs new file mode 100644 index 0000000..5321cd5 --- /dev/null +++ b/crates/drmlibre-core/src/error.rs @@ -0,0 +1,51 @@ +//! Error and result types for the DRMLibre engine. +//! +//! The engine never prints or exits; everything is surfaced as an [`Error`] so +//! that both the CLI and a future GUI can decide how to present it. + +use std::path::Path; + +/// The result type used throughout the engine. +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("zip error: {0}")] + Zip(#[from] zip::result::ZipError), + + /// The file's type could not be determined from its contents. + #[error("can't determine the file type of {0}")] + UnknownFormat(String), + + /// A recognized but intentionally out-of-scope format (PDF, Topaz, PDB, LCP, ...). + #[error("{0}")] + UnsupportedFormat(String), + + /// The file is a plain (non-DRMed) container we don't need to touch, or has + /// no DRM we recognize. + #[error("no supported Adobe or Kindle DRM was detected in this file")] + NoDrmDetected, + + /// None of the available keys could decrypt the file. + #[error("could not find a valid {0} key for {1}")] + NoValidKey(&'static str, String), + + #[error("config error: {0}")] + Config(String), + + #[error("decryption failed: {0}")] + Decrypt(String), + + /// Functionality planned for a later phase. + #[error("not yet implemented: {0}")] + NotImplemented(&'static str), +} + +impl Error { + pub(crate) fn unknown_format(path: &Path) -> Error { + Error::UnknownFormat(path.display().to_string()) + } +} diff --git a/crates/drmlibre-core/src/format.rs b/crates/drmlibre-core/src/format.rs new file mode 100644 index 0000000..97bc0e2 --- /dev/null +++ b/crates/drmlibre-core/src/format.rs @@ -0,0 +1,284 @@ +//! File-format / DRM-scheme detection. +//! +//! Mirrors `DeDRM_plugin/standalone/remove_drm.py::determine_file_type`: magic +//! bytes first, then for ZIP containers a content inspection to distinguish +//! Adobe ADEPT, B&N PassHash, and Amazon KFX-ZIP. + +use std::io::Read; +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::xmlutil; + +/// A recognized input format. Some variants are recognized only so we can emit +/// a precise "unsupported" message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// Adobe ADEPT EPUB (RSA-wrapped book key). + AdeptEpub, + /// Barnes & Noble / Adobe PassHash EPUB (AES-wrapped book key). + AdeptPassHashEpub, + /// Mobipocket / Kindle MOBI / AZW / AZW3. + Mobi, + /// Amazon KFX-ZIP (DRMION-wrapped). + KfxZip, + + // --- recognized but out of scope for v0.1 --- + /// Adobe ADEPT PDF. + Pdf, + /// Amazon Topaz. + Topaz, + /// eReader `.pdb`. + Pdb, + /// Readium LCP. + Lcp, + /// A ZIP with no DRM we handle. + Zip, +} + +impl Format { + /// Whether DRMLibre can currently remove DRM from this format. + pub fn is_supported(self) -> bool { + matches!( + self, + Format::AdeptEpub | Format::AdeptPassHashEpub | Format::Mobi | Format::KfxZip + ) + } + + /// A short, human-readable name. + pub fn label(self) -> &'static str { + match self { + Format::AdeptEpub => "Adobe ADEPT EPUB", + Format::AdeptPassHashEpub => "B&N/PassHash EPUB", + Format::Mobi => "Kindle MOBI/AZW", + Format::KfxZip => "Kindle KFX-ZIP", + Format::Pdf => "PDF", + Format::Topaz => "Kindle Topaz", + Format::Pdb => "eReader PDB", + Format::Lcp => "Readium LCP", + Format::Zip => "ZIP", + } + } + + /// Detect the format of the file at `path`. + pub fn detect(path: &Path) -> Result { + let mut head = [0u8; 100]; + let n = { + let mut f = std::fs::File::open(path)?; + read_up_to(&mut f, &mut head)? + }; + let head = &head[..n]; + + if head.starts_with(b"PK\x03\x04") { + // ZIP container: ADEPT, PassHash, KFX-ZIP, LCP, or plain. + return detect_zip(path); + } + if head.starts_with(b"%PDF") { + return Ok(Format::Pdf); + } + let palm = head.get(0x3C..0x3C + 8); + if palm == Some(b"PNRdPPrs") || palm == Some(b"PDctPPrs") { + return Ok(Format::Pdb); + } + if palm == Some(b"BOOKMOBI") || palm == Some(b"TEXtREAd") { + return Ok(Format::Mobi); + } + if head.starts_with(b"TPZ") { + return Ok(Format::Topaz); + } + Err(Error::unknown_format(path)) + } +} + +fn read_up_to(r: &mut R, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while filled < buf.len() { + match r.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// The `` *base64 string* lengths that distinguish ADEPT from +/// PassHash, matching `epubtest.py::encryption` and `ineptepub.py::decryptBook` +/// (which both test the length of the text, not the decoded bytes). +const PASSHASH_KEY_LEN: usize = 64; +const ADEPT_KEY_MIN_LEN: usize = 172; + +fn detect_zip(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut zip = zip::ZipArchive::new(file)?; + + let names: Vec = zip.file_names().map(|s| s.to_string()).collect(); + let has = |name: &str| names.iter().any(|n| n == name); + + // Readium LCP: a license file under META-INF. + if has("META-INF/license.lcpl") { + return Ok(Format::Lcp); + } + + // Adobe ADEPT / PassHash EPUB: rights.xml + encryption.xml present, with an + // whose base64 text length tells the two apart. + if has("META-INF/rights.xml") && has("META-INF/encryption.xml") { + if let Some(len) = adept_encrypted_key_str_len(&mut zip) { + if len == PASSHASH_KEY_LEN { + return Ok(Format::AdeptPassHashEpub); + } + if len >= ADEPT_KEY_MIN_LEN { + return Ok(Format::AdeptEpub); + } + } + } + + // Amazon KFX-ZIP: some member starts with the DRMION magic. + if zip_has_drmion(&mut zip) { + return Ok(Format::KfxZip); + } + + Ok(Format::Zip) +} + +/// Read `META-INF/rights.xml` and return the (trimmed) length of the +/// `` base64 *text*, if present. Upstream tests the string length, +/// not the decoded byte count. +fn adept_encrypted_key_str_len( + zip: &mut zip::ZipArchive, +) -> Option { + let mut xml = Vec::new(); + zip.by_name("META-INF/rights.xml") + .ok()? + .read_to_end(&mut xml) + .ok()?; + let b64 = xmlutil::first_element_text(&xml, "encryptedKey")?; + Some(b64.trim().len()) +} + +const DRMION_MAGIC: &[u8; 8] = b"\xeaDRMION\xee"; + +fn zip_has_drmion(zip: &mut zip::ZipArchive) -> bool { + for i in 0..zip.len() { + if let Ok(mut entry) = zip.by_index(i) { + let mut magic = [0u8; 8]; + if read_up_to(&mut entry, &mut magic).unwrap_or(0) == 8 && &magic == DRMION_MAGIC { + return true; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use zip::write::SimpleFileOptions; + + fn write_temp(name: &str, bytes: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, bytes).unwrap(); + (dir, path) + } + + /// Build a rights.xml whose `` base64 text is exactly + /// `b64_chars` characters long (must be a multiple of 4). + fn rights_xml_with_b64_chars(b64_chars: usize) -> Vec { + use base64::Engine; + let bytes = vec![0u8; b64_chars / 4 * 3]; + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + assert_eq!( + b64.len(), + b64_chars, + "test helper produced wrong b64 length" + ); + format!( + r#" + {b64} + "# + ) + .into_bytes() + } + + fn build_zip(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut zip = zip::ZipWriter::new(&mut buf); + let opts = SimpleFileOptions::default(); + for (name, data) in entries { + zip.start_file(*name, opts).unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); + } + buf.into_inner() + } + + #[test] + fn detects_mobi_by_magic() { + let mut bytes = vec![0u8; 0x3C]; + bytes.extend_from_slice(b"BOOKMOBI"); + bytes.extend_from_slice(&[0u8; 40]); + let (_d, p) = write_temp("book.azw", &bytes); + assert_eq!(Format::detect(&p).unwrap(), Format::Mobi); + } + + #[test] + fn detects_pdf_and_pdb_and_unknown() { + let (_d1, pdf) = write_temp("b.pdf", b"%PDF-1.7\n...."); + assert_eq!(Format::detect(&pdf).unwrap(), Format::Pdf); + + let mut pdb = vec![0u8; 0x3C]; + pdb.extend_from_slice(b"PNRdPPrs"); + pdb.extend_from_slice(&[0u8; 40]); + let (_d2, pdbp) = write_temp("b.pdb", &pdb); + assert_eq!(Format::detect(&pdbp).unwrap(), Format::Pdb); + + let (_d3, junk) = write_temp("b.bin", b"not a known format at all, really"); + assert!(matches!( + Format::detect(&junk), + Err(Error::UnknownFormat(_)) + )); + } + + #[test] + fn detects_adept_vs_passhash_epub() { + let adept = build_zip(&[ + ("mimetype", b"application/epub+zip"), + ( + "META-INF/rights.xml", + &rights_xml_with_b64_chars(ADEPT_KEY_MIN_LEN), + ), + ("META-INF/encryption.xml", b""), + ]); + let (_d1, p1) = write_temp("adept.epub", &adept); + assert_eq!(Format::detect(&p1).unwrap(), Format::AdeptEpub); + + let passhash = build_zip(&[ + ("mimetype", b"application/epub+zip"), + ( + "META-INF/rights.xml", + &rights_xml_with_b64_chars(PASSHASH_KEY_LEN), + ), + ("META-INF/encryption.xml", b""), + ]); + let (_d2, p2) = write_temp("bn.epub", &passhash); + assert_eq!(Format::detect(&p2).unwrap(), Format::AdeptPassHashEpub); + } + + #[test] + fn detects_kfx_zip_and_plain_zip() { + let mut drmion = DRMION_MAGIC.to_vec(); + drmion.extend_from_slice(b"....payload...."); + let kfx = build_zip(&[("book.kdf", &drmion)]); + let (_d1, p1) = write_temp("book.kfx-zip", &kfx); + assert_eq!(Format::detect(&p1).unwrap(), Format::KfxZip); + + let plain = build_zip(&[("hello.txt", b"hi")]); + let (_d2, p2) = write_temp("plain.zip", &plain); + assert_eq!(Format::detect(&p2).unwrap(), Format::Zip); + } +} diff --git a/crates/drmlibre-core/src/keys.rs b/crates/drmlibre-core/src/keys.rs new file mode 100644 index 0000000..dc049df --- /dev/null +++ b/crates/drmlibre-core/src/keys.rs @@ -0,0 +1,179 @@ +//! Key gathering and storage. +//! +//! Mirrors `remove_drm.py::KeyBundle` / `gather_keys`: keys come from the config +//! file, then the explicit (CLI) inputs, then — only when a decrypt attempt has +//! already failed — lazy platform auto-extraction (added in a later phase). + +use std::path::{Path, PathBuf}; + +use crate::config::Config; +use crate::error::Result; + +/// Everything the decryptors might need, gathered from every key source. +#[derive(Debug, Default, Clone)] +pub struct KeyBundle { + /// Raw DER-encoded Adobe RSA user keys. + pub adept_userkeys: Vec>, + /// Base64 PassHash strings. + pub bandn_userkeys: Vec, + /// `(name, k4i-json)` Kindle key databases. + pub kindle_databases: Vec<(String, String)>, + /// eInk Kindle device serials. + pub serials: Vec, + /// Mobipocket / Kindle PIDs. + pub pids: Vec, + /// Android backup file paths (`backup.ab`, `AmazonSecureStorage.xml`, ...). + pub android_files: Vec, +} + +/// Explicit, non-config key inputs (typically from CLI flags). +#[derive(Debug, Default, Clone)] +pub struct KeyInputs { + pub serials: Vec, + pub pids: Vec, + pub passphrases: Vec, + pub key_files: Vec, + pub android_backups: Vec, +} + +/// Holds gathered keys plus the bookkeeping needed for lazy auto-extraction. +#[derive(Debug, Default)] +pub struct KeyStore { + pub bundle: KeyBundle, + // Used by Phase 3 lazy auto-extraction to avoid extracting twice per run. + #[allow(dead_code)] + pub(crate) adobe_extracted: bool, + #[allow(dead_code)] + pub(crate) kindle_extracted: bool, +} + +impl KeyStore { + /// Build a store from the config file and explicit inputs. Auto-extraction + /// is deferred until a decrypt attempt fails (see the platform extractors). + pub fn gather(config: &Config, inputs: &KeyInputs) -> KeyStore { + let mut bundle = KeyBundle::default(); + + // 1. Config file. + for hexkey in &config.adept.keys { + if let Ok(der) = hex::decode(hexkey) { + bundle.adept_userkeys.push(der); + } + } + bundle + .bandn_userkeys + .extend(config.passhash.keys.iter().cloned()); + for db in &config.kindle.databases { + bundle + .kindle_databases + .push((db.name.clone(), db.data.clone())); + } + bundle.serials.extend(config.kindle.serials.iter().cloned()); + bundle.pids.extend(config.kindle.pids.iter().cloned()); + + // 2. Explicit (CLI) inputs. + bundle.serials.extend(inputs.serials.iter().cloned()); + bundle.pids.extend(inputs.pids.iter().cloned()); + bundle + .bandn_userkeys + .extend(inputs.passphrases.iter().cloned()); + bundle + .android_files + .extend(inputs.android_backups.iter().cloned()); + for keyfile in &inputs.key_files { + load_key_file(keyfile, &mut bundle); + } + + KeyStore { + bundle, + ..Default::default() + } + } +} + +/// Load an Adobe `.der` key or a Kindle `.k4i` key file into `bundle`. +/// +/// Mirrors `_load_key_file`: a `.k4i` extension or JSON-looking content means a +/// Kindle key database; anything else is treated as a raw Adobe DER user key. +fn load_key_file(path: &Path, bundle: &mut KeyBundle) { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(e) => { + tracing::warn!("could not read key file {}: {}", path.display(), e); + return; + } + }; + + let looks_like_json = path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("k4i")) + || data.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{'); + + if looks_like_json { + if let Ok(text) = String::from_utf8(data.clone()) { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "keyfile".into()); + bundle.kindle_databases.push((name, text)); + return; + } + } + + bundle.adept_userkeys.push(data); +} + +/// Persist newly discovered keys back into the config (used by auto-extraction). +pub(crate) fn _persist_todo(_config: &mut Config) -> Result<()> { + // Filled in with Phase 3 auto-extraction. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn gather_merges_config_and_inputs() { + let mut config = Config::default(); + config.adept.keys.push(hex::encode([0x30, 0x82, 0x01])); + config.kindle.serials.push("CONFIGSERIAL".into()); + + let inputs = KeyInputs { + serials: vec!["CLISERIAL".into()], + pids: vec!["PIDPIDPI".into()], + passphrases: vec!["cGFzcw==".into()], + ..Default::default() + }; + + let store = KeyStore::gather(&config, &inputs); + assert_eq!(store.bundle.adept_userkeys, vec![vec![0x30, 0x82, 0x01]]); + assert_eq!(store.bundle.serials, vec!["CONFIGSERIAL", "CLISERIAL"]); + assert_eq!(store.bundle.pids, vec!["PIDPIDPI"]); + assert_eq!(store.bundle.bandn_userkeys, vec!["cGFzcw=="]); + } + + #[test] + fn k4i_file_becomes_kindle_db_der_becomes_adept() { + let dir = tempfile::tempdir().unwrap(); + + let k4i = dir.path().join("mine.k4i"); + std::fs::write(&k4i, br#"{"kindle.key.item":"abc"}"#).unwrap(); + + let der = dir.path().join("user.der"); + let mut f = std::fs::File::create(&der).unwrap(); + f.write_all(&[0x30, 0x82, 0xAA, 0xBB]).unwrap(); + + let inputs = KeyInputs { + key_files: vec![k4i, der], + ..Default::default() + }; + let store = KeyStore::gather(&Config::default(), &inputs); + assert_eq!(store.bundle.kindle_databases.len(), 1); + assert_eq!(store.bundle.kindle_databases[0].0, "mine.k4i"); + assert_eq!( + store.bundle.adept_userkeys, + vec![vec![0x30, 0x82, 0xAA, 0xBB]] + ); + } +} diff --git a/crates/drmlibre-core/src/kindle/mobi.rs b/crates/drmlibre-core/src/kindle/mobi.rs new file mode 100644 index 0000000..b234252 --- /dev/null +++ b/crates/drmlibre-core/src/kindle/mobi.rs @@ -0,0 +1,486 @@ +//! Mobipocket / Kindle MOBI / AZW / AZW3 decryption. +//! +//! Ported from `mobidedrm.py`. A MOBI file is a Palm database; section 0 holds +//! the MOBI header, EXTH metadata and the DRM records. Type-2 DRM derives a +//! per-book key from a PID; type-1 uses a fixed key vector. Text records are +//! decrypted with PC1, preserving any trailing (non-encrypted) data bytes. + +use std::collections::HashMap; + +use crate::error::{Error, Result}; +use crate::kindle::pc1::pc1; + +const KEYVEC1: [u8; 16] = [ + 0x72, 0x38, 0x33, 0xb0, 0xb4, 0xf2, 0xe3, 0xca, 0xdf, 0x09, 0x01, 0xd6, 0xe2, 0xe0, 0x3f, 0x96, +]; +const T1_KEYVEC: &[u8; 16] = b"QDCVEPMU675RUBSZ"; + +/// A parsed MOBI book held in memory; `data` is patched in place during +/// processing. +pub struct MobiBook { + data: Vec, + section_offsets: Vec, + magic: [u8; 8], + sec0: usize, + records: usize, + compression: u16, + mobi_length: usize, + mobi_version: i64, + extra_data_flags: u16, + meta: HashMap>, +} + +impl MobiBook { + /// Parse a MOBI file from disk. + pub fn load(path: &std::path::Path) -> Result { + let data = std::fs::read(path)?; + MobiBook::from_bytes(data) + } + + fn from_bytes(data: Vec) -> Result { + if data.len() < 78 { + return Err(Error::Decrypt("file too small to be a MOBI".into())); + } + let magic_slice = &data[0x3C..0x44]; + if magic_slice != b"BOOKMOBI" && magic_slice != b"TEXtREAd" { + return Err(Error::Decrypt("not a MOBI/PalmDoc file".into())); + } + let mut magic = [0u8; 8]; + magic.copy_from_slice(magic_slice); + + let num_sections = be_u16(&data, 76)? as usize; + let mut section_offsets = Vec::with_capacity(num_sections); + for i in 0..num_sections { + section_offsets.push(be_u32(&data, 78 + i * 8)? as usize); + } + if section_offsets.is_empty() { + return Err(Error::Decrypt("MOBI has no sections".into())); + } + + let sec0 = section_offsets[0]; + let compression = be_u16(&data, sec0)?; + let records = be_u16(&data, sec0 + 8)? as usize; + + let mut book = MobiBook { + data, + section_offsets, + magic, + sec0, + records, + compression, + mobi_length: 0, + mobi_version: -1, + extra_data_flags: 0, + meta: HashMap::new(), + }; + + if &book.magic == b"TEXtREAd" { + return Ok(book); // PalmDoc: no MOBI header + } + + book.mobi_length = be_u32(&book.data, sec0 + 0x14)? as usize; + book.mobi_version = be_u32(&book.data, sec0 + 0x68)? as i64; + if book.mobi_length >= 0xE4 && book.mobi_version >= 5 { + book.extra_data_flags = be_u16(&book.data, sec0 + 0xF2)?; + } + if book.compression != 17480 { + // PalmDoc compression encrypts multibyte trailing data, so leave it. + book.extra_data_flags &= 0xFFFE; + } + + book.parse_and_patch_exth()?; + Ok(book) + } + + fn section_bounds(&self, i: usize) -> (usize, usize) { + let start = self.section_offsets[i]; + let end = if i + 1 < self.section_offsets.len() { + self.section_offsets[i + 1] + } else { + self.data.len() + }; + (start, end) + } + + /// Parse EXTH metadata into `meta` and apply the in-place header patches + /// (clipping limit, text-to-speech, rental flags, watermark) that mobidedrm + /// always performs. + fn parse_and_patch_exth(&mut self) -> Result<()> { + let exth_flag = be_u32(&self.data, self.sec0 + 0x80)?; + if exth_flag & 0x40 == 0 { + return Ok(()); + } + let exth_start = self.sec0 + 16 + self.mobi_length; + if exth_start + 12 > self.data.len() || &self.data[exth_start..exth_start + 4] != b"EXTH" { + return Ok(()); + } + let nitems = be_u32(&self.data, exth_start + 8)? as usize; + let mut pos = 12usize; // offset within the EXTH block + let mut patches: Vec<(usize, Vec)> = Vec::new(); + + for _ in 0..nitems { + let rec = exth_start + pos; + if rec + 8 > self.data.len() { + break; + } + let rtype = be_u32(&self.data, rec)?; + let size = be_u32(&self.data, rec + 4)? as usize; + if size < 8 || rec + size > self.data.len() { + break; + } + let content = self.data[rec + 8..rec + size].to_vec(); + // The content patch target, in absolute file coordinates. + let target = rec + 8; + match (rtype, size) { + (401, 9) => patches.push((target, vec![0x64])), // clipping limit 100% + (404, 9) => patches.push((target, vec![0x00])), // enable TTS + (405, 9) => patches.push((target, vec![0x00])), // clear rented flag + (406, 16) => patches.push((target, vec![0x00; 8])), // clear rental due date + (208, _) => patches.push((target, vec![0x00; size - 8])), // strip watermark + _ => {} + } + self.meta.insert(rtype, content); + pos += size; + } + + for (off, bytes) in patches { + self.patch(off, &bytes); + } + Ok(()) + } + + fn patch(&mut self, off: usize, bytes: &[u8]) { + if off + bytes.len() <= self.data.len() { + self.data[off..off + bytes.len()].copy_from_slice(bytes); + } + } + + /// `(rec209, token)` used for serial-based PID derivation. + pub fn pid_meta_info(&self) -> (Vec, Vec) { + let mut token = Vec::new(); + let rec209 = match self.meta.get(&209) { + Some(d) => d.clone(), + None => return (Vec::new(), token), + }; + let mut i = 0; + while i + 5 <= rec209.len() { + let val = u32::from_be_bytes(rec209[i + 1..i + 5].try_into().unwrap()); + if let Some(sval) = self.meta.get(&val) { + token.extend_from_slice(sval); + } + i += 5; + } + (rec209, token) + } + + /// Decrypt the book using `pidlist`, returning the DRM-free bytes. + pub fn process(mut self, pidlist: &[String]) -> Result> { + let crypto_type = be_u16(&self.data, self.sec0 + 0xC)?; + if crypto_type == 0 { + // Already DRM-free; return the (header-patched) data unchanged. + return Ok(self.data); + } + if crypto_type != 1 && crypto_type != 2 { + return Err(Error::Decrypt(format!( + "unknown Mobipocket encryption type {crypto_type}" + ))); + } + + // Normalize the supplied PIDs to 8-character form (mobidedrm goodpids). + let goodpids = normalize_pids(pidlist); + + let found_key: Vec = if crypto_type == 1 { + let bookkey_data = self.type1_key_data()?; + pc1(T1_KEYVEC, &bookkey_data, true)? + } else { + let drm_ptr = be_u32(&self.data, self.sec0 + 0xA8)? as usize; + let drm_count = be_u32(&self.data, self.sec0 + 0xAC)?; + let drm_size = be_u32(&self.data, self.sec0 + 0xB0)? as usize; + if drm_count == 0 { + return Err(Error::Decrypt( + "encryption not initialised (open in Mobipocket Reader first)".into(), + )); + } + let start = self.sec0 + drm_ptr; + let drm_block = self + .data + .get(start..start + drm_size) + .ok_or_else(truncated)? + .to_vec(); + let key = parse_drm(&drm_block, drm_count as usize, &goodpids)?; + let key = match key { + Some(k) => k, + None => { + tracing::debug!("no key found in {} PIDs tried", goodpids.len()); + return Err(Error::NoValidKey("Kindle", String::new())); + } + }; + // Strip the DRM records and pointers. + self.patch(start, &vec![0u8; drm_size]); + let mut killed = vec![0xFFu8; 4]; + killed.extend_from_slice(&[0u8; 12]); + self.patch(self.sec0 + 0xA8, &killed); + key + }; + + // Clear the crypto type. + self.patch(self.sec0 + 0xC, &[0u8, 0u8]); + + // Reassemble: everything up to section 1, then decrypted text records, + // then any trailing sections unchanged. + let sec1 = self.section_offsets[1]; + let mut out = Vec::with_capacity(self.data.len()); + out.extend_from_slice(&self.data[..sec1]); + + for i in 1..=self.records { + let (start, end) = self.section_bounds(i); + let sec = &self.data[start..end]; + let extra = trailing_size(sec, self.extra_data_flags); + let body = &sec[..sec.len() - extra]; + out.extend_from_slice(&pc1(&found_key, body, true)?); + if extra > 0 { + out.extend_from_slice(&sec[sec.len() - extra..]); + } + } + + if self.section_offsets.len() > self.records + 1 { + let tail = self.section_offsets[self.records + 1]; + out.extend_from_slice(&self.data[tail..]); + } + Ok(out) + } + + fn type1_key_data(&self) -> Result> { + let off = if &self.magic == b"TEXtREAd" { + self.sec0 + 0x0E + } else if self.mobi_version < 0 { + self.sec0 + 0x90 + } else { + self.sec0 + self.mobi_length + 16 + }; + self.data + .get(off..off + 16) + .map(|s| s.to_vec()) + .ok_or_else(truncated) + } +} + +/// Reduce 10-char PIDs to their 8-char base (verifying nothing), and keep 8-char +/// PIDs as-is. Mirrors mobidedrm's `goodpids`. +fn normalize_pids(pidlist: &[String]) -> Vec { + let mut good = Vec::new(); + for pid in pidlist { + match pid.len() { + 10 => good.push(pid[..8].to_string()), + 8 => good.push(pid.clone()), + _ => tracing::debug!("ignoring PID {pid} with wrong length"), + } + } + good +} + +/// Try each PID against the DRM records; return the 16-byte book key if found. +fn parse_drm(data: &[u8], count: usize, pidlist: &[String]) -> Result>> { + for pid in pidlist { + let mut bigpid = pid.as_bytes().to_vec(); + bigpid.resize(16, 0); + let temp_key = pc1(&KEYVEC1, &bigpid, false)?; + if let Some(key) = scan_records(data, count, &temp_key, true)? { + return Ok(Some(key)); + } + } + // Default PID "00000000": use keyvec1 directly, without the flags check. + scan_records(data, count, &KEYVEC1, false) +} + +fn scan_records( + data: &[u8], + count: usize, + temp_key: &[u8], + require_flag: bool, +) -> Result>> { + let temp_key_sum = (temp_key.iter().map(|&b| b as u32).sum::() & 0xFF) as u8; + for i in 0..count { + let rec = data.get(i * 0x30..i * 0x30 + 0x30).ok_or_else(truncated)?; + let verification = u32::from_be_bytes(rec[0..4].try_into().unwrap()); + let cksum = rec[12]; + if cksum != temp_key_sum { + continue; + } + let cookie = pc1(temp_key, &rec[16..48], true)?; + let ver = u32::from_be_bytes(cookie[0..4].try_into().unwrap()); + let flags = u32::from_be_bytes(cookie[4..8].try_into().unwrap()); + let finalkey = cookie[8..24].to_vec(); + if verification == ver && (!require_flag || (flags & 0x1F) == 1) { + return Ok(Some(finalkey)); + } + } + Ok(None) +} + +/// Size of the trailing (non-encrypted) data entries at the end of a record. +/// Ported from `mobidedrm.py::getSizeOfTrailingDataEntries`. +fn trailing_size(ptr: &[u8], flags: u16) -> usize { + fn entry(ptr: &[u8], mut size: usize) -> usize { + let mut bitpos = 0; + let mut result = 0usize; + if size == 0 { + return 0; + } + loop { + let v = ptr[size - 1]; + result |= ((v & 0x7F) as usize) << bitpos; + bitpos += 7; + size -= 1; + if v & 0x80 != 0 || bitpos >= 28 || size == 0 { + return result; + } + } + } + + let mut num = 0usize; + let mut testflags = flags >> 1; + while testflags != 0 { + if testflags & 1 != 0 { + num += entry(ptr, ptr.len() - num); + } + testflags >>= 1; + } + if flags & 1 != 0 { + num += (ptr[ptr.len() - num - 1] & 0x3) as usize + 1; + } + num +} + +fn be_u16(data: &[u8], off: usize) -> Result { + let b = data.get(off..off + 2).ok_or_else(truncated)?; + Ok(u16::from_be_bytes([b[0], b[1]])) +} + +fn be_u32(data: &[u8], off: usize) -> Result { + let b = data.get(off..off + 4).ok_or_else(truncated)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +fn truncated() -> Error { + Error::Decrypt("truncated MOBI data".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trailing_size_vectors() { + // Golden vectors from tests/test_mobidedrm.py. + let buf = b"XXXX\x82"; + assert_eq!(trailing_size(buf, 0b10), 2); + assert_eq!(trailing_size(buf, 0), 0); + assert_eq!(trailing_size(b"ABCDE", 1), 2); + } + + #[test] + fn normalize_pids_handles_lengths() { + let pids = vec![ + "12345678EL".to_string(), + "ABCDEFGH".to_string(), + "short".to_string(), + ]; + assert_eq!(normalize_pids(&pids), vec!["12345678", "ABCDEFGH"]); + } + + /// Build a minimal, valid type-2 encrypted MOBI whose single text record + /// decrypts (with `pid`) to `plaintext`. + fn build_encrypted_mobi(bookkey: &[u8; 16], pid: &str, plaintext: &[u8]) -> Vec { + // Device key derived from the PID, and the matching checksum byte. + let mut bigpid = pid.as_bytes().to_vec(); + bigpid.resize(16, 0); + let temp_key = pc1(&KEYVEC1, &bigpid, false).unwrap(); + let temp_key_sum = (temp_key.iter().map(|&b| b as u32).sum::() & 0xFF) as u8; + + // Cookie plaintext: ver, flags(=1), finalkey(bookkey), expiry, expiry2. + let verification = 0x1234_5678u32; + let mut cookie_pt = Vec::new(); + cookie_pt.extend_from_slice(&verification.to_be_bytes()); + cookie_pt.extend_from_slice(&1u32.to_be_bytes()); + cookie_pt.extend_from_slice(bookkey); + cookie_pt.extend_from_slice(&0u32.to_be_bytes()); + cookie_pt.extend_from_slice(&0u32.to_be_bytes()); + let cookie_ct = pc1(&temp_key, &cookie_pt, false).unwrap(); + + // DRM record (0x30): verification, size, type, cksum, xxx, cookie. + let mut rec = Vec::new(); + rec.extend_from_slice(&verification.to_be_bytes()); + rec.extend_from_slice(&0x30u32.to_be_bytes()); + rec.extend_from_slice(&0u32.to_be_bytes()); + rec.push(temp_key_sum); + rec.extend_from_slice(&[0, 0, 0]); + rec.extend_from_slice(&cookie_ct); + assert_eq!(rec.len(), 0x30); + + // Section 0 (0x120 bytes), with the MOBI header fields and DRM info. + let mut sec0 = vec![0u8; 0x120]; + sec0[0..2].copy_from_slice(&1u16.to_be_bytes()); // compression: none + sec0[8..10].copy_from_slice(&1u16.to_be_bytes()); // records: 1 + sec0[0xC..0xE].copy_from_slice(&2u16.to_be_bytes()); // crypto type: 2 + sec0[0x14..0x18].copy_from_slice(&0xC8u32.to_be_bytes()); // mobi_length (<0xE4) + sec0[0x68..0x6C].copy_from_slice(&6u32.to_be_bytes()); // mobi_version + sec0[0xA8..0xAC].copy_from_slice(&0xE0u32.to_be_bytes()); // drm_ptr + sec0[0xAC..0xB0].copy_from_slice(&1u32.to_be_bytes()); // drm_count + sec0[0xB0..0xB4].copy_from_slice(&0x30u32.to_be_bytes()); // drm_size + sec0[0xE0..0x110].copy_from_slice(&rec); + + let enc_text = pc1(bookkey, plaintext, false).unwrap(); + + // PDB header + 2-entry section table. + let num_sections = 2u16; + let header_len = 78 + num_sections as usize * 8; // 94 + let sec0_off = header_len as u32; + let sec1_off = sec0_off + sec0.len() as u32; + + let mut data = vec![0u8; header_len]; + data[0x3C..0x44].copy_from_slice(b"BOOKMOBI"); + data[76..78].copy_from_slice(&num_sections.to_be_bytes()); + data[78..82].copy_from_slice(&sec0_off.to_be_bytes()); + data[86..90].copy_from_slice(&sec1_off.to_be_bytes()); + data.extend_from_slice(&sec0); + data.extend_from_slice(&enc_text); + data + } + + #[test] + fn roundtrip_type2_mobi() { + let bookkey = [0xC3u8; 16]; + let plaintext = b"Secret Kindle content here."; + let data = build_encrypted_mobi(&bookkey, "12345678", plaintext); + let sec1_off = 94 + 0x120; + + let book = MobiBook::from_bytes(data.clone()).unwrap(); + let out = book.process(&["12345678".to_string()]).unwrap(); + + // Same total length, decrypted text record, crypto type cleared. + assert_eq!(out.len(), data.len()); + assert_eq!(&out[sec1_off..], plaintext); + assert_eq!(&out[94 + 0xC..94 + 0xE], &[0, 0]); + } + + #[test] + fn wrong_pid_yields_no_valid_key() { + let bookkey = [0xC3u8; 16]; + let data = build_encrypted_mobi(&bookkey, "12345678", b"hello"); + let book = MobiBook::from_bytes(data).unwrap(); + let err = book.process(&["87654321".to_string()]).unwrap_err(); + assert!(matches!(err, Error::NoValidKey("Kindle", _))); + } + + #[test] + fn unencrypted_mobi_passes_through() { + let bookkey = [0u8; 16]; + let mut data = build_encrypted_mobi(&bookkey, "12345678", b"x"); + // Force crypto type 0 (DRM-free). + data[94 + 0xC..94 + 0xE].copy_from_slice(&[0, 0]); + let book = MobiBook::from_bytes(data.clone()).unwrap(); + let out = book.process(&[]).unwrap(); + assert_eq!(out, data); + } +} diff --git a/crates/drmlibre-core/src/kindle/mod.rs b/crates/drmlibre-core/src/kindle/mod.rs new file mode 100644 index 0000000..d44f165 --- /dev/null +++ b/crates/drmlibre-core/src/kindle/mod.rs @@ -0,0 +1,35 @@ +//! Amazon Kindle / Mobipocket DRM removal. +//! +//! v0.1 covers MOBI / AZW / AZW3 (this module). Topaz and KFX-ZIP are handled +//! elsewhere / in later phases. + +pub mod mobi; +pub mod pc1; +pub mod pid; + +use std::path::Path; + +use crate::decrypt::Outcome; +use crate::error::{Error, Result}; +use crate::keys::KeyBundle; + +/// Decrypt a MOBI/AZW book using the gathered serials and PIDs. +pub fn decrypt_mobi(input: &Path, output: &Path, bundle: &KeyBundle) -> Result { + let book = mobi::MobiBook::load(input)?; + let (rec209, token) = book.pid_meta_info(); + + let mut pidlist: Vec = bundle.pids.clone(); + for serial in &bundle.serials { + pidlist.extend(pid::get_kindle_pids(&rec209, &token, serial.as_bytes())); + } + // Phase 3 will also derive PIDs from Kindle-for-PC key databases (getK4Pids). + + let data = book.process(&pidlist).map_err(|e| match e { + // Fill in the file path the inner code doesn't know about. + Error::NoValidKey(kind, _) => Error::NoValidKey(kind, input.display().to_string()), + other => other, + })?; + + std::fs::write(output, &data)?; + Ok(Outcome::Decrypted) +} diff --git a/crates/drmlibre-core/src/kindle/pc1.rs b/crates/drmlibre-core/src/kindle/pc1.rs new file mode 100644 index 0000000..85b477d --- /dev/null +++ b/crates/drmlibre-core/src/kindle/pc1.rs @@ -0,0 +1,80 @@ +//! Pukall Cipher 1 (PC1) — the stream cipher used by Mobipocket/Kindle MOBI DRM. +//! +//! A direct port of `alfcrypto.py::Pukall_Cipher.PC1`. The key is always 16 +//! bytes; `decryption` selects whether the key-feedback uses the input or the +//! output byte. + +use crate::error::{Error, Result}; + +/// Run PC1 over `src` with the 16-byte `key`. `decryption = false` encrypts. +pub fn pc1(key: &[u8], src: &[u8], decryption: bool) -> Result> { + if key.len() != 16 { + return Err(Error::Decrypt("PC1: bad key length (must be 16)".into())); + } + let mut wkey = [0u32; 8]; + for i in 0..8 { + wkey[i] = ((key[i * 2] as u32) << 8) | (key[i * 2 + 1] as u32); + } + + let mut sum1: u32 = 0; + let mut sum2: u32 = 0; + let mut dst = Vec::with_capacity(src.len()); + + for &byte in src { + let mut temp1: u32 = 0; + let mut byte_xor_val: u32 = 0; + for j in 0..8u32 { + temp1 ^= wkey[j as usize]; + sum2 = (sum2 + j) * 20021 + sum1; + sum1 = (temp1 * 346) & 0xFFFF; + sum2 = (sum2 + sum1) & 0xFFFF; + temp1 = (temp1 * 20021 + 1) & 0xFFFF; + byte_xor_val ^= temp1 ^ sum2; + } + + let mut cur = byte as u32; + let key_xor_val; + if !decryption { + key_xor_val = cur * 257; + cur = ((cur ^ (byte_xor_val >> 8)) ^ byte_xor_val) & 0xFF; + } else { + cur = ((cur ^ (byte_xor_val >> 8)) ^ byte_xor_val) & 0xFF; + key_xor_val = cur * 257; + } + for w in wkey.iter_mut() { + *w ^= key_xor_val; + } + dst.push(cur as u8); + } + Ok(dst) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_16_bytes() { + // Golden vector from tests/test_mobidedrm.py. + let key: Vec = (0u8..16).collect(); + let pt = b"cookie-data-1234"; + let ct = pc1(&key, pt, false).unwrap(); + assert_ne!(&ct, pt); + assert_eq!(pc1(&key, &ct, true).unwrap(), pt); + } + + #[test] + fn roundtrip_32_bytes() { + // Golden vector from tests/test_alfcrypto.py. + let key: Vec = (0u8..16).collect(); + let pt = b"Hello, Topaz DRM world! 12345678"; + let ct = pc1(&key, pt, false).unwrap(); + assert_ne!(&ct[..], &pt[..]); + assert_eq!(pc1(&key, &ct, true).unwrap(), pt); + } + + #[test] + fn rejects_bad_key_length() { + assert!(pc1(&[0u8; 8], b"data............", false).is_err()); + } +} diff --git a/crates/drmlibre-core/src/kindle/pid.rs b/crates/drmlibre-core/src/kindle/pid.rs new file mode 100644 index 0000000..18f6e05 --- /dev/null +++ b/crates/drmlibre-core/src/kindle/pid.rs @@ -0,0 +1,233 @@ +//! Kindle / Mobipocket PID generation. +//! +//! Ported from `kgenpids.py` and the PID helpers in `utilities.py`. A PID is an +//! 8-character device/book identifier; `checksum_pid` extends it to 10 with a +//! 2-character checksum. For an eInk Kindle, PIDs are derived from the device +//! serial number and the book's `rec209` metadata. + +use crate::crypto; + +/// Alphabet for PID characters (also `charMap4` in kgenpids). +pub const PID_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"; // 33 chars +/// `charMap1` — used by the Kindle-for-PC key PID path (getK4Pids, Phase 3). +#[allow(dead_code)] +pub const CHARMAP1: &[u8] = b"n5Pr6St7Uv8Wx9YzAb0Cd1Ef2Gh3Jk4M"; // 32 +/// `charMap3` — base64-like alphabet used by `encode_pid`. +pub const CHARMAP3: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; // 64 + +/// CRC-32 table (reflected polynomial 0xEDB88320), identical to +/// `generatePidEncryptionTable`. +const CRC_TABLE: [u32; 256] = build_crc_table(); + +const fn build_crc_table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut value = i as u32; + let mut j = 0; + while j < 8 { + if value & 1 == 0 { + value >>= 1; + } else { + value = (value >> 1) ^ 0xEDB88320; + } + j += 1; + } + table[i] = value; + i += 1; + } + table +} + +/// The DeDRM `crc32`: a reflected CRC-32 with register init 0 and no final XOR. +pub fn crc32(data: &[u8]) -> u32 { + let mut crc = 0u32; + for &b in data { + crc = CRC_TABLE[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); + } + crc +} + +/// Append a 2-character checksum, turning an 8-char PID into a 10-char PID. +pub fn checksum_pid(s: &[u8]) -> Vec { + let mut crc = crc32(s); + crc ^= crc >> 16; + let mut res = s.to_vec(); + let length = PID_ALPHABET.len() as u32; + for _ in 0..2 { + let b = crc & 0xFF; + let pos = (b / length) ^ (b % length); + res.push(PID_ALPHABET[(pos % length) as usize]); + crc >>= 8; + } + res +} + +/// Derive a fixed-length PID directly from a serial number. +pub fn pid_from_serial(s: &[u8], length: usize) -> Vec { + let crc = crc32(s); + let mut arr1 = vec![0u8; length]; + for (i, &b) in s.iter().enumerate() { + arr1[i % length] ^= b; + } + let crc_bytes = [ + (crc >> 24) as u8, + (crc >> 16) as u8, + (crc >> 8) as u8, + crc as u8, + ]; + for (i, slot) in arr1.iter_mut().enumerate() { + *slot ^= crc_bytes[i & 3]; + } + arr1.iter() + .map(|&b| { + let b = b as usize; + PID_ALPHABET[(b >> 7) + (((b >> 5) & 3) ^ (b & 0x1F))] + }) + .collect() +} + +/// Encode bytes by the `kgenpids::encode` byte-doubling scheme. +/// (Used by the Kindle-for-PC key PID path; wired in Phase 3.) +#[allow(dead_code)] +pub fn encode(data: &[u8], map: &[u8]) -> Vec { + let len = map.len() as u32; + let mut out = Vec::with_capacity(data.len() * 2); + for &c in data { + let value = c as u32; + out.push(map[((value ^ 0x80) / len) as usize]); + out.push(map[(value % len) as usize]); + } + out +} + +/// MD5 then `encode` — the `encodeHash` helper. (getK4Pids path, Phase 3.) +#[allow(dead_code)] +pub fn encode_hash(data: &[u8], map: &[u8]) -> Vec { + use md5::Digest; + let mut h = md5::Md5::new(); + h.update(data); + let digest = h.finalize(); + encode(&digest, map) +} + +fn get_two_bits(bit_field: &[u8], offset: usize) -> u8 { + let byte_number = offset / 4; + let bit_position = 6 - 2 * (offset % 4); + (bit_field[byte_number] >> bit_position) & 3 +} + +fn get_six_bits(bit_field: &[u8], offset: usize) -> u8 { + let o = offset * 3; + (get_two_bits(bit_field, o) << 4) + + (get_two_bits(bit_field, o + 1) << 2) + + get_two_bits(bit_field, o + 2) +} + +/// Encode a (SHA-1) hash into an 8-character PID base via `charMap3`. +pub fn encode_pid(hash: &[u8]) -> Vec { + (0..8) + .map(|pos| CHARMAP3[get_six_bits(hash, pos) as usize]) + .collect() +} + +/// Generate the device PID from a DSN (Kindle-for-PC key path, Phase 3). +#[allow(dead_code)] +pub fn generate_device_pid(dsn: &[u8], nb_roll: usize) -> Vec { + let mut value = 0u32; + for &b in dsn.iter().take(4) { + let index = ((b as u32) ^ value) & 0xFF; + value = (value >> 8) ^ CRC_TABLE[index as usize]; + } + let seed = value; + let mut pid = [ + (seed >> 24) & 0xFF, + (seed >> 16) & 0xFF, + (seed >> 8) & 0xFF, + seed & 0xFF, + (seed >> 24) & 0xFF, + (seed >> 16) & 0xFF, + (seed >> 8) & 0xFF, + seed & 0xFF, + ]; + let mut index = 0usize; + for &d in dsn.iter().take(nb_roll) { + pid[index] ^= d as u32; + index = (index + 1) % 8; + } + pid.iter() + .map(|&p| { + let idx = ((((p >> 5) & 3) ^ p) & 0x1F) + (p >> 7); + PID_ALPHABET[idx as usize] + }) + .collect() +} + +/// Derive candidate PIDs for a book from an eInk Kindle serial number. +/// +/// `rec209`/`token` come from the book's EXTH metadata (may be empty). +pub fn get_kindle_pids(rec209: &[u8], token: &[u8], serial: &[u8]) -> Vec { + let mut input = Vec::with_capacity(serial.len() + rec209.len() + token.len()); + input.extend_from_slice(serial); + input.extend_from_slice(rec209); + input.extend_from_slice(token); + let pid_hash = crypto::sha1(&input); + + let book_pid = checksum_pid(&encode_pid(&pid_hash)); + + let mut kp = pid_from_serial(serial, 7); + kp.push(b'*'); + let kindle_pid = checksum_pid(&kp); + + vec![ascii(&book_pid), ascii(&kindle_pid)] +} + +fn ascii(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crc32_known_vector() { + assert_eq!(crc32(b"test"), 4181434640); + } + + #[test] + fn crc_table_matches_pid_encryption_table() { + assert_eq!(CRC_TABLE[0], 0); + assert_eq!(CRC_TABLE[1], 0x77073096); + } + + #[test] + fn checksum_pid_known_vector() { + assert_eq!(checksum_pid(b"12345678"), b"12345678EL"); + } + + #[test] + fn pid_from_serial_known_vector() { + assert_eq!(pid_from_serial(b"B00212345678", 8), b"VBKTRX7Q"); + } + + #[test] + fn encode_known_vector() { + assert_eq!(encode(b"\x00\x01\x42\xff\x80", CHARMAP1), b"6n65tPrMnn"); + } + + #[test] + fn six_bit_helpers() { + let field = [0x1b, 0x2c, 0x3d, 0x4e]; + assert_eq!( + (0..4).map(|i| get_two_bits(&field, i)).collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!(get_six_bits(&[0xff, 0xff, 0xff, 0xff], 0), 63); + } + + #[test] + fn generate_device_pid_known_vector() { + assert_eq!(generate_device_pid(b"\x12\x34\x56\x78", 4), b"22I8IQVF"); + } +} diff --git a/crates/drmlibre-core/src/lib.rs b/crates/drmlibre-core/src/lib.rs new file mode 100644 index 0000000..b492549 --- /dev/null +++ b/crates/drmlibre-core/src/lib.rs @@ -0,0 +1,48 @@ +//! # drmlibre-core +//! +//! The DRM-removal engine behind the `drmlibre` CLI. It removes DRM from Amazon +//! Kindle and Adobe Digital Editions ebooks. The algorithms are ported from +//! [DeDRM_tools](https://github.com/noDRM/DeDRM_tools) (GPLv3); this crate is +//! therefore GPL-3.0-or-later. +//! +//! ## Design +//! +//! The engine is UI-agnostic: it never prints or exits. Everything is surfaced +//! as a [`Result`]/[`Error`], and progress is emitted through the `tracing` +//! facade so the CLI (and a future GUI) can render it however they like. +//! +//! Typical use: +//! +//! ```no_run +//! use std::path::Path; +//! use drmlibre_core::{Config, KeyStore, KeyInputs, Options, decrypt}; +//! +//! let config = Config::load(Path::new("drmlibre.toml"))?; +//! let inputs = KeyInputs { serials: vec!["0123456789ABCDEF".into()], ..Default::default() }; +//! let mut keys = KeyStore::gather(&config, &inputs); +//! let opts = Options::from(&config.options); +//! decrypt(Path::new("book.azw"), Path::new("book.mobi"), &mut keys, &opts)?; +//! # Ok::<(), drmlibre_core::Error>(()) +//! ``` + +mod adept; +pub mod config; +mod crypto; +mod decrypt; +mod error; +pub mod format; +mod keys; +mod kindle; +mod xmlutil; + +pub use config::{Config, ConfigSummary}; +pub use decrypt::{decrypt, decrypt_as, Options, Outcome}; +pub use error::{Error, Result}; +pub use format::Format; +pub use keys::{KeyBundle, KeyInputs, KeyStore}; + +/// Generate a Barnes & Noble / Adobe "PassHash" key (base64) from an account +/// name and credit-card number. See the `passhash` CLI command. +pub fn generate_passhash(name: &str, ccn: &str) -> Result { + adept::passhash::generate_key(name, ccn) +} diff --git a/crates/drmlibre-core/src/xmlutil.rs b/crates/drmlibre-core/src/xmlutil.rs new file mode 100644 index 0000000..1decb58 --- /dev/null +++ b/crates/drmlibre-core/src/xmlutil.rs @@ -0,0 +1,104 @@ +//! Small XML helpers built on `quick-xml`. +//! +//! We only need a handful of read operations (find an element's text, read an +//! attribute), so this stays deliberately minimal rather than pulling in a full +//! DOM. Element matching is by *local* name, ignoring namespace prefixes, which +//! matches how the upstream Python code uses lxml with namespace maps. + +use quick_xml::events::Event; +use quick_xml::Reader; + +/// Return the trimmed text content of the first element whose local name equals +/// `local_name`, if any. +pub fn first_element_text(xml: &[u8], local_name: &str) -> Option { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + let mut inside = false; + let mut text = String::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + if local_eq(e.name().as_ref(), local_name) { + inside = true; + text.clear(); + } + } + Ok(Event::Text(e)) if inside => { + if let Ok(t) = e.unescape() { + text.push_str(&t); + } + } + Ok(Event::End(e)) if inside => { + if local_eq(e.name().as_ref(), local_name) { + return Some(text.trim().to_string()); + } + } + Ok(Event::Eof) => return None, + Err(_) => return None, + _ => {} + } + buf.clear(); + } +} + +/// Return the value of attribute `attr` on the first element whose local name +/// equals `local_name_wanted`. +pub fn first_element_attr(xml: &[u8], local_name_wanted: &str, attr: &str) -> Option { + let mut reader = Reader::from_reader(xml); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) + if local_eq(e.name().as_ref(), local_name_wanted) => + { + for a in e.attributes().flatten() { + if local_eq(local_name(a.key.as_ref()), attr) { + return a.unescape_value().ok().map(|v| v.into_owned()); + } + } + return None; + } + Ok(Event::Eof) | Err(_) => return None, + _ => {} + } + buf.clear(); + } +} + +/// The local part of a (possibly namespace-prefixed) qualified name. +pub fn local_name(qname: &[u8]) -> &[u8] { + match qname.iter().rposition(|&b| b == b':') { + Some(i) => &qname[i + 1..], + None => qname, + } +} + +/// Compare a (possibly namespace-prefixed) qualified name against a local name. +pub fn local_eq(qname: &[u8], local: &str) -> bool { + local_name(qname) == local.as_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_namespaced_element_text() { + let xml = br#" + + QUJD + + "#; + assert_eq!( + first_element_text(xml, "encryptedKey"), + Some("QUJD".to_string()) + ); + } + + #[test] + fn returns_none_when_absent() { + let xml = br#"x"#; + assert_eq!(first_element_text(xml, "encryptedKey"), None); + } +}