Add Windows Adobe key auto-extraction (phase 3, part 1)

On Windows, recover the Adobe ADEPT user key from an installed/activated
Adobe Digital Editions and use it automatically, so ADEPT EPUBs decrypt
with no --key argument. Ported from adobekey.py:

* Build the DPAPI entropy byte-for-byte: system-volume serial number,
  CPUID leaf-0 vendor (EBX|EDX|ECX) and leaf-1 signature, and the ADE
  username, packed as ">I12s3s13s".
* CryptUnprotectData (DPAPI) recovers the key-encryption-key from the
  HKCU\Software\Adobe\Adept\Device "key" value, then each per-credential
  privateLicenseKey under ...\Activation is AES-128-CBC decrypted and the
  DER RSA key taken from byte 26 onward.

Decryption now lazily auto-extracts and retries when no supplied key fits,
and newly found keys are written back to the TOML config so later runs are
offline. Uses the `windows` (DPAPI/volume info) and `winreg` crates behind
cfg(windows); pure-Rust crypto unchanged.

Verified end-to-end on a real ADEPT EPUB: the key was extracted from the
local ADE and the book decrypted to a valid, DRM-free EPUB (rights.xml and
encryption.xml removed, content readable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 18:48:37 -05:00
co-authored by Claude Opus 4.8
parent 409473b8f3
commit 2dcfcf5631
9 changed files with 591 additions and 22 deletions
+13 -1
View File
@@ -30,7 +30,7 @@ pub fn run(args: &RemoveArgs) -> i32 {
return 1;
}
let config = match Config::load(&args.common.config) {
let mut config = match Config::load(&args.common.config) {
Ok(c) => c,
Err(e) => {
eprintln!("Warning: could not read config ({e}); continuing without it.");
@@ -97,6 +97,18 @@ pub fn run(args: &RemoveArgs) -> i32 {
}
}
// Persist any keys that were auto-extracted from a local install, so future
// runs are fast and work offline.
if keys.has_new_keys() && keys.merge_into_config(&mut config) {
match config.save(&args.common.config) {
Ok(()) => println!(
"Saved newly found key(s) to {}",
args.common.config.display()
),
Err(e) => eprintln!("Warning: could not save keys to config: {e}"),
}
}
i32::from(had_error)
}
+10
View File
@@ -27,6 +27,16 @@ sha1.workspace = true
sha2.workspace = true
md-5.workspace = true
# Local key extraction from installed Adobe Digital Editions / Kindle (Windows).
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Security_Cryptography",
"Win32_Storage_FileSystem",
"Win32_System_Memory",
] }
winreg = "0.52"
[dev-dependencies]
tempfile = "3"
rand = "0.8"
+27 -5
View File
@@ -98,7 +98,12 @@ fn decrypt_adept_epub(
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.
// None of the supplied keys fit; try auto-extracting from a local install.
if keys.ensure_adobe_extracted() {
if let Some(outcome) = try_adept_keys(&keys.bundle, input, output, opts)? {
return Ok(outcome);
}
}
Err(Error::NoValidKey("Adobe", input.display().to_string()))
}
@@ -108,10 +113,12 @@ fn decrypt_passhash_epub(
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}"),
if let Some(outcome) = try_passhash_keys(&keys.bundle, input, output, opts)? {
return Ok(outcome);
}
if keys.ensure_adobe_extracted() {
if let Some(outcome) = try_passhash_keys(&keys.bundle, input, output, opts)? {
return Ok(outcome);
}
}
Err(Error::NoValidKey("PassHash", input.display().to_string()))
@@ -133,3 +140,18 @@ fn try_adept_keys(
}
Ok(None)
}
fn try_passhash_keys(
bundle: &KeyBundle,
input: &Path,
output: &Path,
opts: &Options,
) -> Result<Option<Outcome>> {
for pw in &bundle.bandn_userkeys {
match adept::decrypt_epub(input, output, UserKey::PassHash(pw), opts) {
Ok(outcome) => return Ok(Some(outcome)),
Err(e) => tracing::debug!("PassHash key did not fit: {e}"),
}
}
Ok(None)
}
+35
View File
@@ -0,0 +1,35 @@
//! Local key auto-extraction from an installed Adobe Digital Editions (and, in
//! later work, Kindle for PC). Platform-specific and best-effort: a failure
//! here is not fatal, it just means the user must supply a key.
//!
//! Windows is implemented (DPAPI + registry + CPUID, ported from
//! `adobekey.py`). macOS and Kindle `.kinf` extraction are still to come.
use crate::error::Result;
/// Keys recovered from a local install.
#[derive(Default, Debug)]
pub struct ExtractedKeys {
/// Adobe ADEPT RSA user keys: `(name, DER bytes)`.
pub adept: Vec<(String, Vec<u8>)>,
/// B&N / Adobe PassHash keys: `(name, base64)`.
pub passhash: Vec<(String, String)>,
}
#[cfg(windows)]
mod win;
/// Extract Adobe keys from the local Adobe Digital Editions activation.
#[cfg(windows)]
pub fn adobe_keys() -> Result<ExtractedKeys> {
win::adobe_keys()
}
#[cfg(not(windows))]
pub fn adobe_keys() -> Result<ExtractedKeys> {
Err(crate::error::Error::UnsupportedFormat(
"automatic Adobe key extraction is currently only implemented on Windows; \
pass a key with --key"
.into(),
))
}
+219
View File
@@ -0,0 +1,219 @@
//! Windows Adobe ADEPT key extraction — port of `adobekey.py` (Windows path).
//!
//! The device key is stored DPAPI-encrypted in the registry; decrypting it
//! requires a byte-exact "entropy" built from the system volume serial number,
//! the CPU vendor string and signature (CPUID), and the ADE username. The
//! recovered key-encryption-key then AES-128-CBC decrypts each per-credential
//! `privateLicenseKey`, whose tail (after a 26-byte prefix) is the DER RSA key.
use base64::Engine;
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{LocalFree, HLOCAL};
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
use windows::Win32::Storage::FileSystem::GetVolumeInformationW;
use super::ExtractedKeys;
use crate::crypto;
use crate::error::{Error, Result};
const DEVICE_KEY_PATH: &str = r"Software\Adobe\Adept\Device";
const ACTIVATION_PATH: &str = r"Software\Adobe\Adept\Activation";
pub fn adobe_keys() -> Result<ExtractedKeys> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let device = hkcu
.open_subkey(DEVICE_KEY_PATH)
.map_err(|_| Error::Decrypt("Adobe Digital Editions is not activated".into()))?;
let encrypted_device_key = device
.get_raw_value("key")
.map_err(|_| Error::Decrypt("ADE device key not found in registry".into()))?
.bytes;
let entropy = build_entropy(&device);
let keykey = crypt_unprotect(&encrypted_device_key, &entropy)?;
let activation = hkcu
.open_subkey(ACTIVATION_PATH)
.map_err(|_| Error::Decrypt("could not locate ADE activation".into()))?;
let mut adept = Vec::new();
let mut i = 0u32;
loop {
let parent = match activation.open_subkey(format!("{i:04}")) {
Ok(k) => k,
Err(_) => break, // no more credentials
};
i += 1;
let ktype: String = parent.get_value("").unwrap_or_default();
if ktype != "credentials" {
continue;
}
let mut uuid_name = String::new();
let mut found: Option<Vec<u8>> = None;
for j in 0..16 {
let sub = match parent.open_subkey(format!("{j:04}")) {
Ok(k) => k,
Err(_) => break,
};
let stype: String = sub.get_value("").unwrap_or_default();
match stype.as_str() {
"user" => {
if let Ok(v) = sub.get_value::<String, _>("value") {
// Strip the "urn:uuid:" prefix (9 chars).
uuid_name.push_str(v.get(9..).unwrap_or(""));
uuid_name.push('_');
}
}
"username" => {
if let Ok(m) = sub.get_value::<String, _>("method") {
uuid_name.push_str(&m);
uuid_name.push('_');
}
if let Ok(v) = sub.get_value::<String, _>("value") {
uuid_name.push_str(&v);
uuid_name.push('_');
}
}
"privateLicenseKey" => {
let v: String = sub
.get_value("value")
.map_err(|_| Error::Decrypt("privateLicenseKey has no value".into()))?;
let enc = base64::engine::general_purpose::STANDARD
.decode(v.trim())
.map_err(|e| Error::Decrypt(format!("bad privateLicenseKey: {e}")))?;
let dec = crypto::aes128_cbc_decrypt_pkcs7(&keykey, &[0u8; 16], &enc)?;
if dec.len() <= 26 {
return Err(Error::Decrypt("decrypted user key too short".into()));
}
found = Some(dec[26..].to_vec());
}
_ => {}
}
}
if let Some(key) = found {
let name = if uuid_name.is_empty() {
"Unknown".to_string()
} else {
uuid_name.trim_end_matches('_').to_string()
};
adept.push((name, key));
}
}
if adept.is_empty() {
return Err(Error::Decrypt(
"could not locate any ADE privateLicenseKey".into(),
));
}
tracing::info!("extracted {} Adobe key(s) from local ADE", adept.len());
Ok(ExtractedKeys {
adept,
passhash: Vec::new(),
})
}
/// Build the 32-byte DPAPI entropy: `serial(4 BE) | vendor(12) | signature(3) |
/// user(13)`, matching `struct.pack(">I12s3s13s", ...)` in adobekey.py.
fn build_entropy(device: &RegKey) -> Vec<u8> {
let mut e = Vec::with_capacity(32);
e.extend_from_slice(&volume_serial().to_be_bytes());
e.extend_from_slice(&cpuid_vendor());
e.extend_from_slice(&cpuid_signature());
e.extend_from_slice(&fixed_len(&username(device), 13));
e
}
fn fixed_len(s: &[u8], n: usize) -> Vec<u8> {
let mut v = s.to_vec();
v.truncate(n);
v.resize(n, 0);
v
}
/// CPUID leaf 0 vendor bytes: EBX | EDX | ECX, each little-endian.
fn cpuid_vendor() -> [u8; 12] {
let r = core::arch::x86_64::__cpuid(0);
let mut out = [0u8; 12];
out[0..4].copy_from_slice(&r.ebx.to_le_bytes());
out[4..8].copy_from_slice(&r.edx.to_le_bytes());
out[8..12].copy_from_slice(&r.ecx.to_le_bytes());
out
}
/// CPUID leaf 1 signature: the low 3 bytes of EAX, big-endian
/// (`pack(">I", eax)[1:]`).
fn cpuid_signature() -> [u8; 3] {
let eax = core::arch::x86_64::__cpuid(1).eax;
[(eax >> 16) as u8, (eax >> 8) as u8, eax as u8]
}
/// Volume serial number of the system drive root.
fn volume_serial() -> u32 {
let drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string());
let root: Vec<u16> = format!("{drive}\\")
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut serial: u32 = 0;
unsafe {
let _ = GetVolumeInformationW(
PCWSTR(root.as_ptr()),
None,
Some(&mut serial),
None,
None,
None,
);
}
serial
}
/// The ADE username: the registry `username` value ADE stored (preferred, since
/// that is what it encrypted with), else the OS username. Encoded as the low
/// byte of each UTF-16 code unit (the `[::2]` of utf-16-le).
fn username(device: &RegKey) -> Vec<u8> {
if let Ok(s) = device.get_value::<String, _>("username") {
return utf16_low_bytes(&s);
}
if let Ok(s) = std::env::var("USERNAME") {
return utf16_low_bytes(&s);
}
Vec::new()
}
fn utf16_low_bytes(s: &str) -> Vec<u8> {
s.encode_utf16().map(|u| (u & 0xFF) as u8).collect()
}
/// DPAPI `CryptUnprotectData` with the given optional entropy.
fn crypt_unprotect(data: &[u8], entropy: &[u8]) -> Result<Vec<u8>> {
let in_blob = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
};
let ent_blob = CRYPT_INTEGER_BLOB {
cbData: entropy.len() as u32,
pbData: entropy.as_ptr() as *mut u8,
};
let mut out = CRYPT_INTEGER_BLOB::default();
unsafe {
CryptUnprotectData(&in_blob, None, Some(&ent_blob), None, None, 0, &mut out).map_err(
|e| {
Error::Decrypt(format!(
"DPAPI CryptUnprotectData failed (entropy mismatch?): {e}"
))
},
)?;
let result = std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec();
let _ = LocalFree(HLOCAL(out.pbData as *mut core::ffi::c_void));
Ok(result)
}
}
+64 -5
View File
@@ -40,11 +40,70 @@ pub struct KeyInputs {
#[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,
// Guards so we only attempt each local extraction once per run.
adobe_extracted: bool,
#[allow(dead_code)] // wired with Kindle-for-PC extraction (later Phase 3 work)
kindle_extracted: bool,
/// Adobe keys discovered by auto-extraction, to persist: `(name, DER)`.
pub new_adept_keys: Vec<(String, Vec<u8>)>,
/// PassHash keys discovered by auto-extraction, to persist: `(name, base64)`.
pub new_passhash_keys: Vec<(String, String)>,
}
impl KeyStore {
/// Auto-extract Adobe keys from a local install (once). Returns whether any
/// new keys were added to the bundle. Mirrors `_extract_adobe_keys`.
pub fn ensure_adobe_extracted(&mut self) -> bool {
if self.adobe_extracted {
return false;
}
self.adobe_extracted = true;
match crate::extract::adobe_keys() {
Ok(found) => {
let mut added = false;
for (name, der) in found.adept {
self.bundle.adept_userkeys.push(der.clone());
self.new_adept_keys.push((name, der));
added = true;
}
for (name, b64) in found.passhash {
self.bundle.bandn_userkeys.push(b64.clone());
self.new_passhash_keys.push((name, b64));
added = true;
}
added
}
Err(e) => {
tracing::debug!("Adobe key auto-extraction unavailable: {e}");
false
}
}
}
/// Whether auto-extraction found keys that should be written back to config.
pub fn has_new_keys(&self) -> bool {
!self.new_adept_keys.is_empty() || !self.new_passhash_keys.is_empty()
}
/// Merge any auto-extracted keys into `config` (de-duplicated). Returns true
/// if the config changed and should be saved.
pub fn merge_into_config(&self, config: &mut Config) -> bool {
let mut changed = false;
for (_name, der) in &self.new_adept_keys {
let hexkey = hex::encode(der);
if !config.adept.keys.contains(&hexkey) {
config.adept.keys.push(hexkey);
changed = true;
}
}
for (_name, b64) in &self.new_passhash_keys {
if !config.passhash.keys.contains(b64) {
config.passhash.keys.push(b64.clone());
changed = true;
}
}
changed
}
}
impl KeyStore {
+1
View File
@@ -30,6 +30,7 @@ pub mod config;
mod crypto;
mod decrypt;
mod error;
mod extract;
pub mod format;
mod keys;
mod kindle;