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 <noreply@anthropic.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# Local config / test artifacts
|
||||
/drmlibre.toml
|
||||
/*.epub
|
||||
/*.azw
|
||||
/*.azw3
|
||||
/*.mobi
|
||||
/*.kfx-zip
|
||||
*_nodrm.*
|
||||
+19
@@ -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)).
|
||||
Generated
+1115
File diff suppressed because it is too large
Load Diff
+53
@@ -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
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -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 <files...> [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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// Output file name (only valid with a single input).
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Folder to write the DRM-free file(s) to.
|
||||
#[arg(long)]
|
||||
outputdir: Option<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Mobipocket / Kindle PID (repeatable).
|
||||
#[arg(long)]
|
||||
pid: Vec<String>,
|
||||
|
||||
/// Adobe `.der` or Kindle `.k4i` key file (repeatable).
|
||||
#[arg(long)]
|
||||
key: Vec<PathBuf>,
|
||||
|
||||
/// Adobe PassHash / B&N passphrase (repeatable).
|
||||
#[arg(long)]
|
||||
passphrase: Vec<String>,
|
||||
|
||||
/// Android backup.ab / AmazonSecureStorage.xml / map_data_storage.db (repeatable).
|
||||
#[arg(long)]
|
||||
android_backup: Vec<PathBuf>,
|
||||
|
||||
#[command(flatten)]
|
||||
common: CommonArgs,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PasshashArgs {
|
||||
/// Generate a PassHash for this account name.
|
||||
#[arg(short, long)]
|
||||
username: Option<String>,
|
||||
/// Generate a PassHash with this password/credit-card number.
|
||||
#[arg(short, long)]
|
||||
password: Option<String>,
|
||||
/// 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();
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Parsing and rewriting of `META-INF/encryption.xml`.
|
||||
//!
|
||||
//! ADEPT's `encryption.xml` lists each encrypted resource under an
|
||||
//! `<EncryptedData>` whose `<EncryptionMethod Algorithm>` says how it was
|
||||
//! encrypted and whose `<CipherReference URI>` 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 `<EncryptedData>` 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 `<EncryptedData>` entries from `encryption.xml`.
|
||||
pub fn parse_entries(xml: &[u8]) -> Result<Vec<Entry>> {
|
||||
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<String> = None;
|
||||
let mut cur_alg: Option<String> = 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<String>,
|
||||
cur_alg: &mut Option<String>,
|
||||
) -> 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<String> {
|
||||
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 `<EncryptedData>` 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<String>) -> Result<Option<Vec<u8>>> {
|
||||
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<String> = 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<R: BufRead>() {}
|
||||
_assert::<&[u8]>();
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &[u8] = br#"<?xml version="1.0"?>
|
||||
<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<enc:EncryptedData>
|
||||
<enc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
<enc:CipherData><enc:CipherReference URI="OEBPS/text.xhtml"/></enc:CipherData>
|
||||
</enc:EncryptedData>
|
||||
<enc:EncryptedData>
|
||||
<enc:EncryptionMethod Algorithm="http://www.idpf.org/2008/embedding"/>
|
||||
<enc:CipherData><enc:CipherReference URI="OEBPS/fonts/font.otf"/></enc:CipherData>
|
||||
</enc:EncryptedData>
|
||||
</encryption>"#;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let file = std::fs::File::open(input)?;
|
||||
let mut zip = zip::ZipArchive::new(file)?;
|
||||
|
||||
let names: Vec<String> = 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<String, bool> = HashMap::new();
|
||||
// Fonts we will de-obfuscate: uri -> (algorithm, key bytes).
|
||||
let mut fonts_to_deob: HashMap<String, (String, Vec<u8>)> = 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<String> = 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<Vec<u8>> {
|
||||
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 `<encryptedKey>` 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 <encryptedKey>".into()))?;
|
||||
let keytype = xmlutil::first_element_attr(rights, "encryptedKey", "keyType")
|
||||
.unwrap_or_else(|| "0".into());
|
||||
Ok((text, keytype))
|
||||
}
|
||||
|
||||
fn read_entry<R: Read + Seek>(zip: &mut zip::ZipArchive<R>, name: &str) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
zip.by_name(name)?.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn b64decode(s: &str) -> Result<Vec<u8>> {
|
||||
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"<html><body><p>Hello, DRM-free world!</p></body></html>";
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
format!(
|
||||
r#"<adept:rights xmlns:adept="http://ns.adobe.com/adept">
|
||||
<adept:licenseToken><adept:encryptedKey>{encrypted_key_b64}</adept:encryptedKey></adept:licenseToken>
|
||||
</adept:rights>"#
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
const ENCRYPTION_XML: &[u8] = br#"<encryption xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<enc:EncryptedData>
|
||||
<enc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
<enc:CipherData><enc:CipherReference URI="OEBPS/ch1.xhtml"/></enc:CipherData>
|
||||
</enc:EncryptedData>
|
||||
<enc:EncryptedData>
|
||||
<enc:EncryptionMethod Algorithm="http://ns.adobe.com/adept/xmlenc#aes128-cbc-uncompressed"/>
|
||||
<enc:CipherData><enc:CipherReference URI="OEBPS/raw.bin"/></enc:CipherData>
|
||||
</enc:EncryptedData>
|
||||
</encryption>"#;
|
||||
|
||||
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#"<?xml version="1.0"?><container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>"#).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<Vec<u8>> {
|
||||
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<u8>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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 `<dc:identifier>` from the OPF metadata.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Identifier {
|
||||
id: Option<String>,
|
||||
scheme: Option<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// Derive the font keys by reading `META-INF/container.xml` and the OPF.
|
||||
pub fn derive_keys<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> 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<String> = 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<u8> {
|
||||
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<R: Read + Seek>(zip: &mut zip::ZipArchive<R>) -> Option<String> {
|
||||
let xml = read_zip_entry(zip, "META-INF/container.xml")?;
|
||||
first_attr(&xml, "rootfile", "full-path")
|
||||
}
|
||||
|
||||
fn read_zip_entry<R: Read + Seek>(zip: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||
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<String> {
|
||||
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<String>, Vec<Identifier>) {
|
||||
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<Identifier> = 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<String> {
|
||||
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#"<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:identifier id="bookid">urn:uuid:12345678-1234-5678-1234-567812345678</dc:identifier>
|
||||
<dc:identifier opf:scheme="UUID">urn:uuid:00112233-4455-6677-8899-aabbccddeeff</dc:identifier>
|
||||
</metadata>
|
||||
</package>"#;
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
@@ -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 `<encryptedKey>` 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 `<encryptedKey>`.
|
||||
pub fn remove_hardening(rights_xml: &[u8], keytype: &str, keydata: &[u8]) -> Result<Vec<u8>> {
|
||||
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<String> {
|
||||
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
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<String> {
|
||||
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 `<encryptedKey>` using a
|
||||
/// base64 PassHash `userkey`. Mirrors the `len == 64` branch of `decryptBook`.
|
||||
pub fn unwrap_bookkey(userkey_b64: &str, encrypted_key_b64: &str) -> Result<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// Barnes & Noble / Adobe PassHash keys, base64-encoded.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PassHashSection {
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<KindleDatabase>,
|
||||
/// eInk Kindle device serial numbers.
|
||||
pub serials: Vec<String>,
|
||||
/// Mobipocket / Kindle PIDs.
|
||||
pub pids: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<Config> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>> {
|
||||
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::<Aes128>::new_from_slices(key, iv)
|
||||
.map_err(|_| Error::Decrypt("bad AES key/iv length".into()))?;
|
||||
let pt = dec
|
||||
.decrypt_padded_mut::<NoPadding>(&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<Vec<u8>> {
|
||||
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::<Aes128>::new_from_slices(key, iv)
|
||||
.map_err(|_| Error::Decrypt("bad AES key/iv length".into()))?;
|
||||
let ct = enc
|
||||
.encrypt_padded_mut::<NoPadding>(&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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<Aes128>::new_from_slices(&key, &iv)
|
||||
.unwrap()
|
||||
.encrypt_padded_mut::<NoPadding>(&mut buf, 32)
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
let back = aes128_cbc_decrypt_nopad(&key, &iv, &ct).unwrap();
|
||||
assert_eq!(back, pt);
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Outcome> {
|
||||
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<Option<Outcome>> {
|
||||
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)
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -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<Format> {
|
||||
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: Read>(r: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
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 `<encryptedKey>` *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<Format> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut zip = zip::ZipArchive::new(file)?;
|
||||
|
||||
let names: Vec<String> = 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
|
||||
// <encryptedKey> 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
|
||||
/// `<encryptedKey>` base64 *text*, if present. Upstream tests the string length,
|
||||
/// not the decoded byte count.
|
||||
fn adept_encrypted_key_str_len<R: Read + std::io::Seek>(
|
||||
zip: &mut zip::ZipArchive<R>,
|
||||
) -> Option<usize> {
|
||||
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<R: Read + std::io::Seek>(zip: &mut zip::ZipArchive<R>) -> 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 `<encryptedKey>` base64 text is exactly
|
||||
/// `b64_chars` characters long (must be a multiple of 4).
|
||||
fn rights_xml_with_b64_chars(b64_chars: usize) -> Vec<u8> {
|
||||
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#"<adept:rights xmlns:adept="http://ns.adobe.com/adept">
|
||||
<adept:licenseToken><adept:encryptedKey>{b64}</adept:encryptedKey></adept:licenseToken>
|
||||
</adept:rights>"#
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
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"<encryption/>"),
|
||||
]);
|
||||
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"<encryption/>"),
|
||||
]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>,
|
||||
/// Base64 PassHash strings.
|
||||
pub bandn_userkeys: Vec<String>,
|
||||
/// `(name, k4i-json)` Kindle key databases.
|
||||
pub kindle_databases: Vec<(String, String)>,
|
||||
/// eInk Kindle device serials.
|
||||
pub serials: Vec<String>,
|
||||
/// Mobipocket / Kindle PIDs.
|
||||
pub pids: Vec<String>,
|
||||
/// Android backup file paths (`backup.ab`, `AmazonSecureStorage.xml`, ...).
|
||||
pub android_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Explicit, non-config key inputs (typically from CLI flags).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct KeyInputs {
|
||||
pub serials: Vec<String>,
|
||||
pub pids: Vec<String>,
|
||||
pub passphrases: Vec<String>,
|
||||
pub key_files: Vec<PathBuf>,
|
||||
pub android_backups: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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]]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
section_offsets: Vec<usize>,
|
||||
magic: [u8; 8],
|
||||
sec0: usize,
|
||||
records: usize,
|
||||
compression: u16,
|
||||
mobi_length: usize,
|
||||
mobi_version: i64,
|
||||
extra_data_flags: u16,
|
||||
meta: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MobiBook {
|
||||
/// Parse a MOBI file from disk.
|
||||
pub fn load(path: &std::path::Path) -> Result<MobiBook> {
|
||||
let data = std::fs::read(path)?;
|
||||
MobiBook::from_bytes(data)
|
||||
}
|
||||
|
||||
fn from_bytes(data: Vec<u8>) -> Result<MobiBook> {
|
||||
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<u8>)> = 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<u8>, Vec<u8>) {
|
||||
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<Vec<u8>> {
|
||||
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<u8> = 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<Vec<u8>> {
|
||||
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<String> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
let temp_key_sum = (temp_key.iter().map(|&b| b as u32).sum::<u32>() & 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<u16> {
|
||||
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<u32> {
|
||||
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<u8> {
|
||||
// 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::<u32>() & 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"<html><body>Secret Kindle content here.</body></html>";
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let book = mobi::MobiBook::load(input)?;
|
||||
let (rec209, token) = book.pid_meta_info();
|
||||
|
||||
let mut pidlist: Vec<String> = 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)
|
||||
}
|
||||
@@ -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<Vec<u8>> {
|
||||
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<u8> = (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<u8> = (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());
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
(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<u8> {
|
||||
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<String> {
|
||||
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<_>>(),
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
adept::passhash::generate_key(name, ccn)
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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#"<adept:rights xmlns:adept="http://ns.adobe.com/adept">
|
||||
<adept:licenseToken>
|
||||
<adept:encryptedKey>QUJD</adept:encryptedKey>
|
||||
</adept:licenseToken>
|
||||
</adept:rights>"#;
|
||||
assert_eq!(
|
||||
first_element_text(xml, "encryptedKey"),
|
||||
Some("QUJD".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_absent() {
|
||||
let xml = br#"<root><child>x</child></root>"#;
|
||||
assert_eq!(first_element_text(xml, "encryptedKey"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user