From 2dcfcf5631b521f9d759ac0210e80c8bd290be16 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 24 Jun 2026 18:48:37 -0500 Subject: [PATCH] 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 --- Cargo.lock | 218 ++++++++++++++++++++++- README.md | 15 +- crates/drmlibre-cli/src/remove.rs | 14 +- crates/drmlibre-core/Cargo.toml | 10 ++ crates/drmlibre-core/src/decrypt.rs | 32 +++- crates/drmlibre-core/src/extract/mod.rs | 35 ++++ crates/drmlibre-core/src/extract/win.rs | 219 ++++++++++++++++++++++++ crates/drmlibre-core/src/keys.rs | 69 +++++++- crates/drmlibre-core/src/lib.rs | 1 + 9 files changed, 591 insertions(+), 22 deletions(-) create mode 100644 crates/drmlibre-core/src/extract/mod.rs create mode 100644 crates/drmlibre-core/src/extract/win.rs diff --git a/Cargo.lock b/Cargo.lock index 61b0ad2..6f93968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -75,7 +75,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -320,6 +320,8 @@ dependencies = [ "thiserror", "toml", "tracing", + "windows", + "winreg", "zip", ] @@ -336,7 +338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -505,7 +507,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -721,7 +723,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -865,7 +867,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1035,12 +1037,85 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1050,6 +1125,127 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -1059,6 +1255,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "zerocopy" version = "0.8.52" diff --git a/README.md b/README.md index 2600b14..a795d43 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,19 @@ licensed **GPL-3.0-or-later**. See [CREDITS.md](CREDITS.md). Under active development. Supported / planned formats: -| Format | Status | +| Capability | 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 ADEPT EPUB (+ hardening, B&N PassHash, font de-obfuscation) | ✅ done | +| Kindle MOBI / AZW / AZW3 | ✅ done | +| Adobe key auto-extraction from installed ADE (Windows, DPAPI) | ✅ done | +| Kindle-for-PC key (`.kinf`) / Android / macOS auto-extraction | planned | +| Kindle KFX-ZIP | planned | | Adobe PDF, Kindle Topaz, eReader, Kobo, LCP | out of scope | +On Windows with Adobe Digital Editions activated, ADEPT EPUBs decrypt with no +extra arguments — the key is extracted from the local install and cached in the +config for later offline use. + ## Usage ``` diff --git a/crates/drmlibre-cli/src/remove.rs b/crates/drmlibre-cli/src/remove.rs index c9f68cc..781445d 100644 --- a/crates/drmlibre-cli/src/remove.rs +++ b/crates/drmlibre-cli/src/remove.rs @@ -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) } diff --git a/crates/drmlibre-core/Cargo.toml b/crates/drmlibre-core/Cargo.toml index 36b48ee..05dfc06 100644 --- a/crates/drmlibre-core/Cargo.toml +++ b/crates/drmlibre-core/Cargo.toml @@ -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" diff --git a/crates/drmlibre-core/src/decrypt.rs b/crates/drmlibre-core/src/decrypt.rs index d3ae0b0..a4211dc 100644 --- a/crates/drmlibre-core/src/decrypt.rs +++ b/crates/drmlibre-core/src/decrypt.rs @@ -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 { - 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> { + 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) +} diff --git a/crates/drmlibre-core/src/extract/mod.rs b/crates/drmlibre-core/src/extract/mod.rs new file mode 100644 index 0000000..e047a52 --- /dev/null +++ b/crates/drmlibre-core/src/extract/mod.rs @@ -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)>, + /// 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 { + win::adobe_keys() +} + +#[cfg(not(windows))] +pub fn adobe_keys() -> Result { + Err(crate::error::Error::UnsupportedFormat( + "automatic Adobe key extraction is currently only implemented on Windows; \ + pass a key with --key" + .into(), + )) +} diff --git a/crates/drmlibre-core/src/extract/win.rs b/crates/drmlibre-core/src/extract/win.rs new file mode 100644 index 0000000..b0913be --- /dev/null +++ b/crates/drmlibre-core/src/extract/win.rs @@ -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 { + 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> = 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::("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::("method") { + uuid_name.push_str(&m); + uuid_name.push('_'); + } + if let Ok(v) = sub.get_value::("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 { + 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 { + 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 = 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 { + if let Ok(s) = device.get_value::("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 { + 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> { + 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) + } +} diff --git a/crates/drmlibre-core/src/keys.rs b/crates/drmlibre-core/src/keys.rs index dc049df..f9f1cab 100644 --- a/crates/drmlibre-core/src/keys.rs +++ b/crates/drmlibre-core/src/keys.rs @@ -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)>, + /// 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 { diff --git a/crates/drmlibre-core/src/lib.rs b/crates/drmlibre-core/src/lib.rs index b492549..ea902c2 100644 --- a/crates/drmlibre-core/src/lib.rs +++ b/crates/drmlibre-core/src/lib.rs @@ -30,6 +30,7 @@ pub mod config; mod crypto; mod decrypt; mod error; +mod extract; pub mod format; mod keys; mod kindle;