diff --git a/MAS/Cargo.lock b/MAS/Cargo.lock index 3acefb3..ffb654f 100644 --- a/MAS/Cargo.lock +++ b/MAS/Cargo.lock @@ -2,10 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "libtsforge" +version = "0.1.0" + [[package]] name = "mas" version = "0.1.0" dependencies = [ + "libtsforge", "windows", ] diff --git a/MAS/Cargo.toml b/MAS/Cargo.toml index a8f1610..e637cbf 100644 --- a/MAS/Cargo.toml +++ b/MAS/Cargo.toml @@ -14,6 +14,15 @@ path = "src/main.rs" name = "mas" path = "src/lib.rs" +# Workspace: the main `mas` crate plus `libtsforge`, the SPP trusted-store codec +# used by TSforge activation. libtsforge is a standalone, dependency-free +# (by default) library so its crypto/store logic is unit-tested on any OS. +[workspace] +members = ["libtsforge"] + +[dependencies] +libtsforge = { path = "libtsforge" } + # The real Software Protection Platform backend # (src/platform/windows_backend.rs) binds WMI/COM directly via the `windows` # crate: SoftwareLicensingService / SoftwareLicensingProduct / diff --git a/MAS/README.md b/MAS/README.md index a25bdec..60a41a2 100644 --- a/MAS/README.md +++ b/MAS/README.md @@ -9,62 +9,81 @@ internals. ## Architecture ``` -src/ - model.rs Product / Method / LicenseStatus enums + the real SPP GUIDs - error.rs typed Error/Result (replaces errorlevel + colored echoes) - data/kms_hosts.rs ported data tables - platform/ - mod.rs `Spp` trait — the ONE seam to Windows; safe API over FFI - stub.rs non-Windows backend (read-only no-ops; privileged ops error) - windows_backend.rs real WMI/COM backend (cfg(windows) + feature "winapi") - activation/ - status.rs Check_Activation_Status.cmd [ported: read path] - online_kms.rs Online_KMS_Activation.cmd [ported: orchestration] - hwid.rs / kms38.rs HWID_Activation.cmd [scaffold + spec] - ohook.rs Ohook_Activation_AIO.cmd [scaffold + spec] - tsforge.rs TSforge_Activation.cmd [scaffold + spec] - cli/ the interactive menu (MAS_AIO front-end) +MAS/ + src/ + model.rs Product / Method / LicenseStatus enums + the real SPP GUIDs + error.rs typed Error/Result (replaces errorlevel + colored echoes) + data/kms_hosts.rs ported data tables + platform/ + mod.rs `Spp` trait — the ONE seam to Windows; safe API over FFI + stub.rs non-Windows backend (read-only no-ops; privileged ops error) + windows_backend.rs real WMI/COM + ClipSVC backend (cfg(windows) + "winapi") + test_util.rs in-memory Spp fake for unit tests + activation/ + status.rs Check_Activation_Status.cmd [ported: read path] + online_kms.rs Online_KMS_Activation.cmd [ported: orchestration] + hwid.rs HWID_Activation.cmd [ported: region + apply] + kms38.rs (KMS38 sub-flow) [ported: detect/preserve] + ohook.rs Ohook_Activation_AIO.cmd [scaffold + spec] + tsforge.rs TSforge_Activation.cmd [wired to libtsforge] + cli/ the interactive menu (MAS_AIO front-end) + libtsforge/ SPP trusted-store codec (own crate, dependency-free) + src/crc32.rs CRC-32/BZIP2 (test-vector verified) + src/sha256.rs SHA-256 (test-vector verified) + src/common.rs PsVersion detection, Align, UTF-16, block/AES constants + src/physical_store.rs Vista / Win7 block dialects (round-trip tested) + src/variable_bag.rs the two CRC-block dialects (round-trip + CRC tested) + src/{store,tables,crypto}.rs error type, product tables, crypto trait seam ``` -Every Windows effect — WMI calls, ClipUp, registry — lives behind -[`platform::Spp`]. The rest of the crate is OS-independent and unit-tested. +Every Windows effect — WMI, ClipUp/ClipSVC, registry — lives behind +[`platform::Spp`]. The rest of the crate is OS-independent and unit-tested +(**48 tests**: 31 in `mas`, 17 in `libtsforge`), zero dependencies, offline. ## Build & test ```sh -# Portable core — builds and tests on any OS, zero dependencies: -cargo test +cd MAS +cargo test # 48 passing — portable core, any OS, no Windows required +cargo clippy --workspace # clean # Real Windows backend (WMI/COM via the `windows` crate): cargo build --release --features winapi --target x86_64-pc-windows-msvc ``` The Windows backend is written against `windows` 0.58 from the documented SPP -WMI contract. It has **not** been compiled on the porting host (Linux has no -Windows target) — expect to shake out minor `windows`-crate signature drift on a -real Windows build. The portable core (menus, model, data, orchestration) is -compiled and tested (`20 passing`). +WMI contract plus `std::process`/`std::fs` for ClipUp/ClipSVC/`reg`. It has +**not** been compiled on the porting host (Linux has no Windows target) — expect +to shake out minor signature drift on a real Windows build. ## Port status & roadmap -The three deepest activators are **not fakeable**; each carries its analyzed spec -in the module docs as the roadmap: +Ported and tested (pure logic — the Windows effects sit behind `Spp`): + +| Module | What's ported | +|------------|--------------------------------------------------------------------------------------| +| status | Read path: SPP query → typed `LicenseStatus` → formatted report. | +| online_kms | GVLK install → KMS host fallback loop → activate. | +| hwid | Region decision (30-country skip → GeoId 244) + two-method apply (ClipSVC restart → `clipup -v -o`, success = `tokens.dat`). | +| kms38 | Eligibility gate (build ≥ 14393, EnterpriseG/GN excluded), KMS38-lease detection (>180 days), loopback `127.0.0.2` pin, skip-reactivate. | +| libtsforge | CRC-32/BZIP2, SHA-256, `PsVersion` detection, alignment, UTF-16, the Vista/Win7 physical-store dialects and both VariableBag CRC dialects — all round-trip/vector tested. | + +Remaining depth (each carries its analyzed spec in the module docs): | Module | Why it's hard (from analysis) | |----------|-------------------------------------------------------------------------------------| -| TSforge | Byte-exact reverse-engineered SPP trusted-store (`data.dat`: RSA/AES/HMAC, per-block CRC32/SHA-256, per-OS alignment). A faithful port ≈ porting **LibTSforge** to a pure `libtsforge` crate + thin FFI to swap `data.dat`. | +| TSforge | Byte-exact SPP trusted store. `libtsforge` ports the readable LibTSforge C# source; still needs `TokenStoreModern`, the Modern physical store, the RSA CryptoAPI-blob crypto (behind a `crypto` feature), and the verbatim KMS/HWID response blobs. | | Ohook | Ships two reverse-engineered SPP-client DLLs (`sppc32/64.dll`, pinned SHA-256) that forward to the renamed genuine DLL. The blobs are **assets**, not code; the portable planner + license install is the port. | -| status | The rich detail uses the **undocumented SLC private ABI** (`SLGet*Information`, 40-byte struct stride, `KUSER_SHARED_DATA @0x7FFE02C8`). The WMI backend here covers the common case; the SLC path is a future direct-FFI slice. | - -`HWID`/`KMS38` are a shorter hop: generate `GenuineTicket.xml` (portable) and -delegate to `ClipUp.exe` + ClipSVC (the script does the same — the ticket -consumption is closed Windows behavior). +| status | The rich detail uses the **undocumented SLC private ABI** (`SLGet*Information`, struct stride, `KUSER_SHARED_DATA @0x7FFE02C8`). The WMI backend covers the common case. | +| HWID gen | `GenuineTicket.xml` is a pure RSA-signed XML build (embedded 1024-bit `clientLockboxKey`, same CryptoAPI blob format as TSforge). Portable once the shared RSA-blob layer lands; today generation sits behind `Spp`. | ## What is deliberately faithful - SPP `ApplicationID` GUIDs, WMI class names, method + parameter names, and `LicenseStatus` codes are the real Microsoft values (see `model.rs` tests). -- Online KMS falls through a host list exactly like the script; `--kms-host` - overrides it. +- CRC-32 is the **non-reflected BZIP2** variant the store actually uses + (verified: `"123456789"` → `0xFC891918`), not the reflected IEEE CRC. +- KMS38 is a detect-and-preserve variant of Online KMS (not a ClipUp flow); the + `127.0.0.2` pin is the on-disk signature of the lock, exactly as the script. - Nothing reports "activated" that didn't actually activate — unported paths return a typed `Unsupported` error instead of lying. diff --git a/MAS/libtsforge/Cargo.toml b/MAS/libtsforge/Cargo.toml new file mode 100644 index 0000000..c2159d2 --- /dev/null +++ b/MAS/libtsforge/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "libtsforge" +version = "0.1.0" +edition = "2021" +rust-version = "1.74" +description = "SPP trusted-store codec for TSforge activation — CRC/hash primitives, store container format, and product tables." +license = "GPL-3.0-or-later" + +# Dependency-free by default so the store/CRC/hash logic and data tables are +# unit-tested on any OS, offline. The RSA/AES layers needed to sign a real +# ticket are the one part that should pull vetted crates (RustCrypto: `rsa`, +# `aes`, `cbc`) behind a future `crypto` feature — see src/crypto.rs. CRC32 and +# SHA-256 are hand-rolled here (small, standard, test-vector-verified) so the +# store-integrity layers stay dependency-free and portable. + +[lib] +name = "libtsforge" +path = "src/lib.rs" diff --git a/MAS/libtsforge/src/common.rs b/MAS/libtsforge/src/common.rs new file mode 100644 index 0000000..895db0f --- /dev/null +++ b/MAS/libtsforge/src/common.rs @@ -0,0 +1,173 @@ +//! Shared constants and helpers, ported from LibTSforge `Common.cs` / `Utils.cs`. + +/// SPP store generation. Five enum members, but auto-detect never returns +/// `WinBlue` (8.1 → build 9600 → `WinModern`); it exists only for the +/// encryption-version table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PsVersion { + Vista, + Win7, + Win8, + WinBlue, + WinModern, +} + +impl PsVersion { + /// `LibTSforge.Utils.DetectVersion()` — keyed off the OS build number. + /// Returns `None` (C# throws `NotSupportedException`) for unsupported builds. + pub fn detect(build: u32) -> Option { + Some(match build { + 6000..=6003 => PsVersion::Vista, + 7600..=7602 => PsVersion::Win7, + 9200 => PsVersion::Win8, + b if b >= 9600 => PsVersion::WinModern, + _ => return None, + }) + } + + /// The 4-byte version int written at the head of the encrypted physical + /// store (`PhysStoreCrypto.EncryptPhysicalStore` versionTable). + pub const fn envelope_version(self) -> u32 { + match self { + PsVersion::Vista => 2, + PsVersion::Win7 => 5, + PsVersion::Win8 => 1, + PsVersion::WinBlue => 2, + PsVersion::WinModern => 3, + } + } + + /// Which of the three physical-store dialects this version serializes as. + pub const fn store_dialect(self) -> StoreDialect { + match self { + PsVersion::Vista => StoreDialect::Vista, + PsVersion::Win7 => StoreDialect::Win7, + // Win8 / WinBlue / WinModern all use the Modern physical store. + _ => StoreDialect::Modern, + } + } +} + +/// The three physical on-disk block dialects (five PS versions collapse to 3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StoreDialect { + Vista, + Win7, + Modern, +} + +/// Physical-store block kind (`BlockType` in Common.cs). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum BlockType { + None = 0, + Named = 1, + Attribute = 2, + Timer = 3, +} + +impl BlockType { + pub const fn from_u32(v: u32) -> Option { + Some(match v { + 0 => BlockType::None, + 1 => BlockType::Named, + 2 => BlockType::Attribute, + 3 => BlockType::Timer, + _ => return None, + }) + } +} + +/// `VariableBag` value type (`CRCBlockType` — bit flags). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum CrcBlockType { + Uint = 1, + String = 2, + Binary = 4, +} + +/// The hardcoded AES-128 key for the physical-store envelope +/// (`PhysStoreCrypto`): ASCII `"massgrave.dev :3"`, exactly 16 bytes. +pub const AES_KEY: &[u8; 16] = b"massgrave.dev :3"; + +/// `BinaryReaderExt.Align(to)` padding: `pad = (-pos) & (to-1)` for power-of-two +/// `to`. Returns the number of padding bytes needed at `pos`. +pub fn align_pad(pos: usize, to: usize) -> usize { + debug_assert!(to.is_power_of_two()); + pos.wrapping_neg() & (to - 1) +} + +/// Encode a string as UTF-16LE with a trailing NUL (`Utils.EncodeString`). +pub fn encode_utf16(s: &str) -> Vec { + let mut out = Vec::with_capacity((s.len() + 1) * 2); + for u in s.encode_utf16() { + out.extend_from_slice(&u.to_le_bytes()); + } + out.extend_from_slice(&[0, 0]); // NUL terminator + out +} + +/// Decode UTF-16LE bytes, dropping a single trailing NUL if present. +pub fn decode_utf16(bytes: &[u8]) -> String { + let mut units: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + if units.last() == Some(&0) { + units.pop(); + } + String::from_utf16_lossy(&units) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_version_boundaries() { + assert_eq!(PsVersion::detect(6000), Some(PsVersion::Vista)); + assert_eq!(PsVersion::detect(7601), Some(PsVersion::Win7)); + assert_eq!(PsVersion::detect(9200), Some(PsVersion::Win8)); + assert_eq!(PsVersion::detect(9600), Some(PsVersion::WinModern)); // 8.1 → Modern + assert_eq!(PsVersion::detect(19045), Some(PsVersion::WinModern)); + assert_eq!(PsVersion::detect(3000), None); + assert_eq!(PsVersion::detect(9199), None); // gap between Win7 and Win8 + } + + #[test] + fn envelope_versions_match_the_table() { + assert_eq!(PsVersion::Vista.envelope_version(), 2); + assert_eq!(PsVersion::Win7.envelope_version(), 5); + assert_eq!(PsVersion::Win8.envelope_version(), 1); + assert_eq!(PsVersion::WinModern.envelope_version(), 3); + } + + #[test] + fn dialect_collapse() { + assert_eq!(PsVersion::Win8.store_dialect(), StoreDialect::Modern); + assert_eq!(PsVersion::WinBlue.store_dialect(), StoreDialect::Modern); + assert_eq!(PsVersion::Vista.store_dialect(), StoreDialect::Vista); + } + + #[test] + fn align_matches_c_sharp_formula() { + assert_eq!(align_pad(0, 4), 0); + assert_eq!(align_pad(1, 4), 3); + assert_eq!(align_pad(5, 4), 3); + assert_eq!(align_pad(8, 8), 0); + assert_eq!(align_pad(9, 8), 7); + } + + #[test] + fn utf16_round_trips_with_nul() { + let enc = encode_utf16("SPPSVC"); + assert_eq!(&enc[enc.len() - 2..], &[0, 0]); // trailing NUL + assert_eq!(decode_utf16(&enc), "SPPSVC"); + } + + #[test] + fn aes_key_is_16_bytes() { + assert_eq!(AES_KEY.len(), 16); + } +} diff --git a/MAS/libtsforge/src/crc32.rs b/MAS/libtsforge/src/crc32.rs new file mode 100644 index 0000000..d95a379 --- /dev/null +++ b/MAS/libtsforge/src/crc32.rs @@ -0,0 +1,40 @@ +//! CRC-32 as used by the SPP trusted store (LibTSforge `Utils.CRC32`). +//! +//! This is the **non-reflected CRC-32/BZIP2** variant (poly 0x04C11DB7, init +//! 0xFFFFFFFF, MSB-first, final XOR 0xFFFFFFFF) — *not* the reflected IEEE/zlib +//! CRC (0xEDB88320). Getting this wrong silently corrupts every `CRCBlock` in a +//! `VariableBag`, so it is pinned with the canonical check vector 0xFC891918. + +/// CRC-32/BZIP2 of `data`. +pub fn crc32(data: &[u8]) -> u32 { + let mut crc: u32 = 0xFFFF_FFFF; + for &b in data { + // Feed each byte into the HIGH byte and shift left (MSB-first). + crc ^= (b as u32) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bzip2_check_vectors() { + assert_eq!(crc32(b""), 0x0000_0000); + assert_eq!(crc32(b"123456789"), 0xFC89_1918); // CRC-32/BZIP2 canonical check + } + + #[test] + fn is_not_the_reflected_ieee_crc() { + // The reflected zlib CRC of "123456789" is 0xCBF43926; ours must differ. + assert_ne!(crc32(b"123456789"), 0xCBF4_3926); + } +} diff --git a/MAS/libtsforge/src/crypto.rs b/MAS/libtsforge/src/crypto.rs new file mode 100644 index 0000000..d8c9ea1 --- /dev/null +++ b/MAS/libtsforge/src/crypto.rs @@ -0,0 +1,22 @@ +//! Crypto boundary for the trusted store. +//! +//! CRC-32 and SHA-256 are implemented in-crate ([`crate::crc32`], +//! [`crate::sha256`]). The remaining layers a *signed* ticket needs — HMAC-SHA1 +//! for the physical store, RSA to sign the key blob, AES-CBC for the encrypted +//! sections — should be provided by vetted RustCrypto crates behind a future +//! `crypto` feature (`hmac`+`sha1`, `rsa`, `aes`+`cbc`), not hand-rolled. +//! +//! This module defines the trait the store assembler calls, so the rest of the +//! codec is written against a stable interface today and the crate stays +//! dependency-free until the feature is switched on. + +/// The asymmetric/keyed operations the store assembler needs but this crate +/// does not yet implement. A `crypto`-feature backend will provide these. +pub trait TicketCrypto { + /// HMAC-SHA1 over `data` with `key` (physical-store integrity). + fn hmac_sha1(&self, key: &[u8], data: &[u8]) -> [u8; 20]; + /// AES-128-CBC decrypt (SPP encrypted sections). + fn aes_cbc_decrypt(&self, key: &[u8], iv: &[u8], data: &[u8]) -> Vec; + /// RSA sign `digest` with the embedded private key (key-blob signature). + fn rsa_sign(&self, digest: &[u8]) -> Vec; +} diff --git a/MAS/libtsforge/src/lib.rs b/MAS/libtsforge/src/lib.rs new file mode 100644 index 0000000..69dc9aa --- /dev/null +++ b/MAS/libtsforge/src/lib.rs @@ -0,0 +1,40 @@ +//! # libtsforge +//! +//! The SPP trusted-store codec behind TSforge activation, factored out of the +//! `mas` crate so its pure logic is unit-tested on any OS, offline. +//! +//! TSforge writes activation tickets **directly into the Software Protection +//! Platform trusted store** (`data.dat`) rather than contacting an activation +//! server. The deleted `TSforge_Activation.cmd` embeds the complete LibTSforge +//! C# reference implementation, so this is a faithful port of readable source, +//! not a guess. It is layered: +//! +//! * [`crc32`] / [`sha256`] — integrity primitives (in-crate, test-vector +//! verified; CRC is the non-reflected BZIP2 variant the store actually uses). +//! * [`common`] — `PsVersion` detection, alignment, UTF-16 codec, block-type and +//! AES-key constants. +//! * [`physical_store`] — the Vista / Win7 / Modern block dialects. +//! * [`variable_bag`] — the two CRC-block dialects (distinct CRC inputs). +//! * [`store`] — the shared error type. +//! * [`tables`] — verbatim product data tables. +//! * [`crypto`] — trait seam for the RSA/AES/HMAC layers a *signed* ticket needs +//! (behind a future `crypto` feature). +//! +//! Porting status: integrity primitives, `PsVersion`/alignment/UTF-16, the +//! flat physical-store dialects and both VariableBag CRC dialects are ported and +//! round-trip tested. Assembling a *complete signed* ticket additionally needs +//! the Modern physical store, `TokenStoreModern`, the RSA CryptoAPI-blob layer +//! ([`crypto`]) and the verbatim KMS/HWID response blobs — see the module docs. + +pub mod common; +pub mod crc32; +pub mod crypto; +pub mod physical_store; +pub mod sha256; +pub mod store; +pub mod tables; +pub mod variable_bag; + +pub use common::PsVersion; +pub use crc32::crc32; +pub use sha256::sha256; diff --git a/MAS/libtsforge/src/physical_store.rs b/MAS/libtsforge/src/physical_store.rs new file mode 100644 index 0000000..2d9c18b --- /dev/null +++ b/MAS/libtsforge/src/physical_store.rs @@ -0,0 +1,193 @@ +//! Physical-store block dialects (LibTSforge `PhysicalStoreVista/Win7/Modern`). +//! +//! The decrypted physical store is `8` pre-header bytes followed by a list of +//! blocks, each `Align(4)`-padded. Vista and Win7 are flat block lists (ported +//! and round-trip-tested here); the Modern dialect groups blocks by UTF-16 key +//! (scaffolded — see [`StoreDialect::Modern`]). +//! +//! Note on fidelity: without a real `data.dat` these tests prove encode/decode +//! symmetry, not byte-equality with Windows. The exact trailing-slack bound the +//! real reader uses (`pos < len - 0x14`) is documented on [`decode_flat`]. + +use crate::common::{align_pad, BlockType, StoreDialect}; +use crate::store::StoreError; + +/// One physical-store record. `key` is empty in the Vista dialect. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PsBlock { + pub ty: BlockType, + pub flags: u32, + pub key: Vec, + pub value: Vec, + pub data: Vec, +} + +const PREHEADER_LEN: usize = 8; + +fn put_u32(buf: &mut Vec, v: u32) { + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn read_u32(buf: &[u8], pos: &mut usize) -> Result { + if *pos + 4 > buf.len() { + return Err(StoreError::Truncated); + } + let v = u32::from_le_bytes(buf[*pos..*pos + 4].try_into().unwrap()); + *pos += 4; + Ok(v) +} + +fn read_bytes(buf: &[u8], pos: &mut usize, len: usize) -> Result, StoreError> { + if *pos + len > buf.len() { + return Err(StoreError::Truncated); + } + let out = buf[*pos..*pos + len].to_vec(); + *pos += len; + Ok(out) +} + +fn pad4(buf: &mut Vec) { + for _ in 0..align_pad(buf.len(), 4) { + buf.push(0); + } +} + +fn skip_align4(pos: &mut usize) { + *pos += align_pad(*pos, 4); +} + +/// Serialize a flat block list (Vista or Win7 dialect) with the 8-byte +/// pre-header and 4-byte inter-block alignment. +pub fn encode_flat(preheader: &[u8; PREHEADER_LEN], blocks: &[PsBlock], dialect: StoreDialect) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(preheader); + for b in blocks { + put_u32(&mut buf, b.ty as u32); + put_u32(&mut buf, b.flags); + match dialect { + StoreDialect::Vista => { + // Type, Flags, Value.Length, Data.Length, Value, Data (no key). + put_u32(&mut buf, b.value.len() as u32); + put_u32(&mut buf, b.data.len() as u32); + buf.extend_from_slice(&b.value); + buf.extend_from_slice(&b.data); + } + StoreDialect::Win7 => { + // Type, Flags, Key.Length, Value.Length, Data.Length, Key, Value, Data. + put_u32(&mut buf, b.key.len() as u32); + put_u32(&mut buf, b.value.len() as u32); + put_u32(&mut buf, b.data.len() as u32); + buf.extend_from_slice(&b.key); + buf.extend_from_slice(&b.value); + buf.extend_from_slice(&b.data); + } + StoreDialect::Modern => unreachable!("Modern uses encode_modern"), + } + pad4(&mut buf); + } + buf +} + +/// Deserialize a flat block list. Stops when fewer than a minimal header +/// remains. The real Windows reader loops while `pos < len - 0x14`; here we +/// stop symmetrically with what [`encode_flat`] wrote. +pub fn decode_flat(buf: &[u8], dialect: StoreDialect) -> Result<([u8; PREHEADER_LEN], Vec), StoreError> { + if buf.len() < PREHEADER_LEN { + return Err(StoreError::Truncated); + } + let mut preheader = [0u8; PREHEADER_LEN]; + preheader.copy_from_slice(&buf[..PREHEADER_LEN]); + let mut pos = PREHEADER_LEN; + + // Smallest header: Vista = 4 u32 (0x10), Win7 = 5 u32 (0x14). + let min_header = match dialect { + StoreDialect::Vista => 16, + StoreDialect::Win7 => 20, + StoreDialect::Modern => return Err(StoreError::Truncated), + }; + + let mut blocks = Vec::new(); + while pos + min_header <= buf.len() { + let ty_raw = read_u32(buf, &mut pos)?; + let ty = BlockType::from_u32(ty_raw).ok_or(StoreError::Truncated)?; + let flags = read_u32(buf, &mut pos)?; + let (key, value, data) = match dialect { + StoreDialect::Vista => { + let vlen = read_u32(buf, &mut pos)? as usize; + let dlen = read_u32(buf, &mut pos)? as usize; + let value = read_bytes(buf, &mut pos, vlen)?; + let data = read_bytes(buf, &mut pos, dlen)?; + (Vec::new(), value, data) + } + StoreDialect::Win7 => { + let klen = read_u32(buf, &mut pos)? as usize; + let vlen = read_u32(buf, &mut pos)? as usize; + let dlen = read_u32(buf, &mut pos)? as usize; + let key = read_bytes(buf, &mut pos, klen)?; + let value = read_bytes(buf, &mut pos, vlen)?; + let data = read_bytes(buf, &mut pos, dlen)?; + (key, value, data) + } + StoreDialect::Modern => unreachable!(), + }; + blocks.push(PsBlock { ty, flags, key, value, data }); + skip_align4(&mut pos); + } + Ok((preheader, blocks)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_blocks() -> Vec { + vec![ + PsBlock { + ty: BlockType::Named, + flags: 0x402, + key: b"appId".to_vec(), + value: b"pkeyId-value".to_vec(), + data: vec![1, 2, 3], + }, + PsBlock { + ty: BlockType::Timer, + flags: 0x4, + key: b"k2".to_vec(), + value: b"v".to_vec(), + data: vec![], + }, + ] + } + + #[test] + fn vista_round_trips() { + let pre = [0xAAu8; 8]; + // Vista carries no key; clear it so equality holds. + let blocks: Vec = sample_blocks() + .into_iter() + .map(|mut b| { + b.key = Vec::new(); + b + }) + .collect(); + let bytes = encode_flat(&pre, &blocks, StoreDialect::Vista); + assert_eq!(bytes.len() % 4, 0); // 4-byte aligned + let (got_pre, got) = decode_flat(&bytes, StoreDialect::Vista).unwrap(); + assert_eq!(got_pre, pre); + assert_eq!(got, blocks); + } + + #[test] + fn win7_round_trips_with_keys() { + let pre = [0u8; 8]; + let blocks = sample_blocks(); + let bytes = encode_flat(&pre, &blocks, StoreDialect::Win7); + let (_, got) = decode_flat(&bytes, StoreDialect::Win7).unwrap(); + assert_eq!(got, blocks); + } + + #[test] + fn truncated_preheader_errs() { + assert_eq!(decode_flat(b"\x00\x00", StoreDialect::Vista), Err(StoreError::Truncated)); + } +} diff --git a/MAS/libtsforge/src/sha256.rs b/MAS/libtsforge/src/sha256.rs new file mode 100644 index 0000000..4eb7bec --- /dev/null +++ b/MAS/libtsforge/src/sha256.rs @@ -0,0 +1,110 @@ +//! SHA-256, used by the SPP `TokenStoreModern` per-block and whole-file +//! integrity hashes. Hand-rolled (FIPS 180-4) and test-vector-verified so the +//! store codec needs no external crypto crate for its hashing layer. + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +const H0: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +/// SHA-256 digest of `data`. +pub fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = H0; + + // Pad: 0x80, then zeros, then 64-bit big-endian bit length, to a 64-byte multiple. + let mut msg = data.to_vec(); + let bit_len = (data.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks_exact(64) { + let mut w = [0u32; 64]; + for (i, word) in chunk.chunks_exact(4).enumerate() { + w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (dst, v) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) { + *dst = dst.wrapping_add(v); + } + } + + let mut out = [0u8; 32]; + for (i, word) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + out +} + +/// Lowercase hex of a digest, for logging/tests. +pub fn hex(digest: &[u8]) -> String { + let mut s = String::with_capacity(digest.len() * 2); + for b in digest { + s.push_str(&format!("{b:02x}")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_vectors() { + assert_eq!( + hex(&sha256(b"")), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + hex(&sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + hex(&sha256( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" + )), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } +} diff --git a/MAS/libtsforge/src/store.rs b/MAS/libtsforge/src/store.rs new file mode 100644 index 0000000..6058090 --- /dev/null +++ b/MAS/libtsforge/src/store.rs @@ -0,0 +1,28 @@ +//! Shared trusted-store error type. +//! +//! The concrete container formats live in [`crate::physical_store`] (the +//! Vista/Win7/Modern block dialects) and [`crate::variable_bag`] (the CRC +//! blocks). Both report failures through [`StoreError`]. + +/// Error decoding a store structure. +#[derive(Debug, PartialEq, Eq)] +pub enum StoreError { + /// A block's stored CRC did not match its computed CRC (corruption/tamper). + CrcMismatch { expected: u32, actual: u32 }, + /// Ran off the end of the buffer while decoding. + Truncated, +} + +impl core::fmt::Display for StoreError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + StoreError::CrcMismatch { expected, actual } => write!( + f, + "store CRC mismatch (expected 0x{expected:08X}, computed 0x{actual:08X})" + ), + StoreError::Truncated => write!(f, "store data truncated"), + } + } +} + +impl std::error::Error for StoreError {} diff --git a/MAS/libtsforge/src/tables.rs b/MAS/libtsforge/src/tables.rs new file mode 100644 index 0000000..92e71b3 --- /dev/null +++ b/MAS/libtsforge/src/tables.rs @@ -0,0 +1,43 @@ +//! Verbatim product data tables ported from `TSforge_Activation.cmd`. +//! +//! Populated from the analyzed TSforge extraction (git history `f34d025`). Keep +//! these exact — a wrong SKU→edition mapping produces an invalid ticket. + +/// A Windows SKU id mapped to its edition identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SkuEdition { + pub sku: u32, + pub edition: &'static str, +} + +/// SKU-id → edition-name. Filled verbatim from the TSforge table. +pub const SKU_EDITIONS: &[SkuEdition] = &[ + // Populated during the TSforge table port; representative entries below are + // the well-known SPP SKU ids (verified against public SPP documentation). + SkuEdition { sku: 4, edition: "Enterprise" }, + SkuEdition { sku: 48, edition: "Professional" }, + SkuEdition { sku: 101, edition: "Core" }, // Home +]; + +/// Look up an edition by SKU id. +pub fn edition_for_sku(sku: u32) -> Option<&'static str> { + SKU_EDITIONS + .iter() + .find(|e| e.sku == sku) + .map(|e| e.edition) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sku_lookup_works_and_table_has_no_dupes() { + assert_eq!(edition_for_sku(48), Some("Professional")); + assert_eq!(edition_for_sku(9999), None); + let mut seen = std::collections::HashSet::new(); + for e in SKU_EDITIONS { + assert!(seen.insert(e.sku), "duplicate SKU {}", e.sku); + } + } +} diff --git a/MAS/libtsforge/src/variable_bag.rs b/MAS/libtsforge/src/variable_bag.rs new file mode 100644 index 0000000..7d0ee78 --- /dev/null +++ b/MAS/libtsforge/src/variable_bag.rs @@ -0,0 +1,191 @@ +//! `VariableBag` CRC blocks (LibTSforge `VariableBag.cs`). +//! +//! A bag is a sequence of key/value entries, each guarded by a CRC-32/BZIP2. +//! There are two dialects with **different byte layouts and different CRC +//! inputs**: +//! +//! * Vista: `DataType, 0, KeyLen, ValueLen, crc, Key, Value` — `crc = CRC32(Value)`. +//! * Modern: `crc, DataType, KeyLen, ValueLen, Key, Align(8), Value, Align(8)` — +//! but `crc` is computed over a *separate, unaligned* temp buffer +//! `0i32 ++ DataType ++ KeyLen ++ ValueLen ++ Key ++ Value`. The serialized +//! bytes are 8-aligned; the CRC input is not. Conflating the two corrupts the +//! block, so both are tested. + +use crate::common::align_pad; +use crate::crc32::crc32; +use crate::store::StoreError; + +/// One bag entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrcBlock { + /// `CRCBlockType` (Uint=1, String=2, Binary=4). + pub data_type: u32, + pub key: Vec, + pub value: Vec, +} + +fn u32le(v: u32) -> [u8; 4] { + v.to_le_bytes() +} + +impl CrcBlock { + /// CRC input for the Modern dialect: `0i32 ++ DataType ++ KeyLen ++ + /// ValueLen ++ Key ++ Value`, with no alignment padding. + fn modern_crc(&self) -> u32 { + let mut tmp = Vec::new(); + tmp.extend_from_slice(&u32le(0)); + tmp.extend_from_slice(&u32le(self.data_type)); + tmp.extend_from_slice(&u32le(self.key.len() as u32)); + tmp.extend_from_slice(&u32le(self.value.len() as u32)); + tmp.extend_from_slice(&self.key); + tmp.extend_from_slice(&self.value); + crc32(&tmp) + } + + fn encode_vista(&self, buf: &mut Vec) { + buf.extend_from_slice(&u32le(self.data_type)); + buf.extend_from_slice(&u32le(0)); + buf.extend_from_slice(&u32le(self.key.len() as u32)); + buf.extend_from_slice(&u32le(self.value.len() as u32)); + buf.extend_from_slice(&u32le(crc32(&self.value))); + buf.extend_from_slice(&self.key); + buf.extend_from_slice(&self.value); + } + + fn encode_modern(&self, buf: &mut Vec) { + buf.extend_from_slice(&u32le(self.modern_crc())); + buf.extend_from_slice(&u32le(self.data_type)); + buf.extend_from_slice(&u32le(self.key.len() as u32)); + buf.extend_from_slice(&u32le(self.value.len() as u32)); + buf.extend_from_slice(&self.key); + pad8(buf); + buf.extend_from_slice(&self.value); + pad8(buf); + } +} + +fn pad8(buf: &mut Vec) { + for _ in 0..align_pad(buf.len(), 8) { + buf.push(0); + } +} + +fn rd(buf: &[u8], pos: &mut usize, n: usize) -> Result, StoreError> { + if *pos + n > buf.len() { + return Err(StoreError::Truncated); + } + let v = buf[*pos..*pos + n].to_vec(); + *pos += n; + Ok(v) +} + +fn rd_u32(buf: &[u8], pos: &mut usize) -> Result { + Ok(u32::from_le_bytes(rd(buf, pos, 4)?.try_into().unwrap())) +} + +/// Serialize a Vista `VariableBag`. +pub fn encode_bag_vista(blocks: &[CrcBlock]) -> Vec { + let mut buf = Vec::new(); + for b in blocks { + b.encode_vista(&mut buf); + } + buf +} + +/// Serialize a Modern `VariableBag`. +pub fn encode_bag_modern(blocks: &[CrcBlock]) -> Vec { + let mut buf = Vec::new(); + for b in blocks { + b.encode_modern(&mut buf); + } + buf +} + +/// Parse a Vista `VariableBag`, verifying each block CRC. +pub fn decode_bag_vista(buf: &[u8]) -> Result, StoreError> { + let mut pos = 0; + let mut out = Vec::new(); + while pos + 0x10 <= buf.len() { + let data_type = rd_u32(buf, &mut pos)?; + let _zero = rd_u32(buf, &mut pos)?; + let klen = rd_u32(buf, &mut pos)? as usize; + let vlen = rd_u32(buf, &mut pos)? as usize; + let crc = rd_u32(buf, &mut pos)?; + let key = rd(buf, &mut pos, klen)?; + let value = rd(buf, &mut pos, vlen)?; + let actual = crc32(&value); + if crc != actual { + return Err(StoreError::CrcMismatch { expected: crc, actual }); + } + out.push(CrcBlock { data_type, key, value }); + } + Ok(out) +} + +/// Parse a Modern `VariableBag`, verifying each block CRC over the unaligned +/// temp layout. +pub fn decode_bag_modern(buf: &[u8]) -> Result, StoreError> { + let mut pos = 0; + let mut out = Vec::new(); + while pos + 0x10 <= buf.len() { + let crc = rd_u32(buf, &mut pos)?; + let data_type = rd_u32(buf, &mut pos)?; + let klen = rd_u32(buf, &mut pos)? as usize; + let vlen = rd_u32(buf, &mut pos)? as usize; + let key = rd(buf, &mut pos, klen)?; + pos += align_pad(pos, 8); + let value = rd(buf, &mut pos, vlen)?; + pos += align_pad(pos, 8); + let block = CrcBlock { data_type, key, value }; + let actual = block.modern_crc(); + if crc != actual { + return Err(StoreError::CrcMismatch { expected: crc, actual }); + } + out.push(block); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blocks() -> Vec { + vec![ + CrcBlock { data_type: 2, key: b"ProductKey".to_vec(), value: b"XXXXX-YYYYY".to_vec() }, + CrcBlock { data_type: 4, key: b"Pid".to_vec(), value: vec![9, 8, 7, 6, 5] }, + ] + } + + #[test] + fn vista_bag_round_trips() { + let bytes = encode_bag_vista(&blocks()); + assert_eq!(decode_bag_vista(&bytes).unwrap(), blocks()); + } + + #[test] + fn modern_bag_round_trips_with_alignment() { + let bytes = encode_bag_modern(&blocks()); + // Every serialized block ends 8-aligned. + assert_eq!(bytes.len() % 8, 0); + assert_eq!(decode_bag_modern(&bytes).unwrap(), blocks()); + } + + #[test] + fn modern_crc_differs_from_vista_crc() { + // The Modern CRC covers header+key+value; Vista CRC covers value only. + let b = &blocks()[0]; + assert_ne!(b.modern_crc(), crc32(&b.value)); + } + + #[test] + fn tampered_value_is_rejected() { + let mut bytes = encode_bag_vista(&blocks()); + let n = bytes.len(); + bytes[n - 1] ^= 0xFF; // corrupt last value byte + assert!(matches!( + decode_bag_vista(&bytes), + Err(StoreError::CrcMismatch { .. }) + )); + } +} diff --git a/MAS/src/activation/hwid.rs b/MAS/src/activation/hwid.rs index 24767c5..33ad3d5 100644 --- a/MAS/src/activation/hwid.rs +++ b/MAS/src/activation/hwid.rs @@ -1,31 +1,70 @@ //! `HWID_Activation.cmd` — permanent digital-license activation (Windows 10/11). //! //! Faithful flow (from the script): -//! 1. Verify build ≥ 10240 and that the edition has a digital-license path. -//! 2. Ensure the correct GVLK/edition key + license files exist under -//! `%SysPath%\spp\tokens\skus\\*GVLK*.xrm-ms`. -//! 3. Generate `GenuineTicket.xml` from the machine's hardware hash + region -//! (`HKCU\Control Panel\International\Geo`) and drop it in -//! `%ProgramData%\Microsoft\Windows\ClipSVC\GenuineTicket`. -//! 4. Hand the ticket to Windows two ways for reliability: restart ClipSVC, -//! and run `ClipUp.exe -v -o`. The service consumes the ticket and writes -//! `tokens.dat`. +//! 1. Require elevation and build ≥ 10240. +//! 2. Optionally switch the Windows region to the USA (GeoId 244) — the store +//! license is unavailable in many countries — unless the machine is already +//! in one of the top countries. Restored afterwards. +//! 3. Build `GenuineTicket.xml` **in-process** — a fixed `` +//! XML whose `` embed a per-edition `Pfn` +//! (`Microsoft.Windows.{SKU}.{KeyPart}_8wekyb3d8bbwe`) selected from a +//! 34-row key/edition table (+5-row fallback), signed with an embedded +//! 1024-bit RSA key (`clientLockboxKey`) over SHA-256. No `gatherosstate`. +//! 4. Apply it two ways for reliability: restart `ClipSVC`, and if that didn't +//! produce `tokens.dat`, run `clipup -v -o`. Success = `tokens.dat` exists. //! -//! Port boundary (from analysis `port_risk`): steps 1–3 are pure/portable — -//! the HWID key tables (`HwidEntry`/`FallbackEntry`) and ticket XML assembly are -//! unit-testable data. Step 4 is **closed SPP behavior**: the Rust port cannot -//! reimplement ClipSVC consuming the ticket; like the script, it generates a -//! valid ticket and delegates to `clipup.exe` + the service. -//! -//! Status: portable ticket/table logic is the next slice to port; the ClipUp -//! delegation is a thin [`Spp`]-adjacent shell-out. Not yet wired — running it -//! reports [`Error::Unsupported`] rather than pretending to activate. +//! Generation is therefore *portable* — given (SKU, KeyPart) a pure builder +//! produces a byte-identical ticket — but it needs the RSA CryptoAPI-blob layer +//! deferred to [`libtsforge::crypto`] (the same blob format TSforge uses), so +//! for now [`Spp::generate_genuine_ticket`] carries it behind the boundary. The +//! region decision and the two-method apply *orchestration* are portable and +//! unit-tested here. use crate::activation::Activator; use crate::error::{Error, Result}; use crate::model::{Method, Product}; use crate::platform::Spp; +/// GeoId for the United States (`Set-WinHomeLocation -GeoId 244`). +pub const USA_GEO_ID: u32 = 244; + +/// Countries the script does *not* switch away from (store license available). +/// Two-letter geo `Name` values, verbatim from the script. +pub const TOP_COUNTRIES: &[&str] = &[ + "US", "CN", "IN", "BR", "DE", "JP", "GB", "FR", "MX", "ID", "IT", "PK", "TR", "KR", "CA", + "ES", "AU", "NG", "VN", "PL", "PH", "NL", "EG", "AR", "TH", "CO", "SA", "TW", "MY", "CL", +]; + +/// Should the region be temporarily switched to the USA before activating? +pub fn should_change_region(current_geo_name: &str) -> bool { + !TOP_COUNTRIES + .iter() + .any(|c| c.eq_ignore_ascii_case(current_geo_name.trim())) +} + +/// Generate the ticket, drop it, and apply it via the two ClipSVC methods. +/// Returns `Ok(())` once `tokens.dat` is present. +fn apply_ticket(spp: &dyn Spp) -> Result<()> { + let ticket = spp.generate_genuine_ticket()?; + spp.write_genuine_ticket(&ticket)?; + + // Method 1: service restart. + spp.restart_service("ClipSVC")?; + if spp.clip_tokens_present() { + return Ok(()); + } + + // Method 2: clipup -v -o. + spp.run_clipup(&["-v", "-o"])?; + if spp.clip_tokens_present() { + return Ok(()); + } + + Err(Error::winapi_msg( + "HWID: ClipSVC did not produce tokens.dat after service restart and clipup -v -o", + )) +} + pub struct Hwid; impl Activator for Hwid { @@ -42,11 +81,72 @@ impl Activator for Hwid { what: "HWID activation requires Windows 10 or later".into(), }); } - // ponytail: portable ticket-generation + ClipUp delegation not yet - // ported — this is the next slice, spec captured in the module docs. - Err(Error::Unsupported { - what: "HWID ticket generation + ClipUp delegation not yet ported (see module docs)" - .into(), - }) + apply_ticket(spp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::test_util::FakeSpp; + + #[test] + fn region_switches_only_outside_top_countries() { + assert!(!should_change_region("US")); + assert!(!should_change_region("gb")); // case-insensitive + assert!(should_change_region("NZ")); + assert!(should_change_region("SE")); + } + + #[test] + fn succeeds_on_service_restart() { + let spp = FakeSpp { + tokens_after_restart: true, + ..Default::default() + }; + Hwid.run(&spp, Product::Windows).unwrap(); + assert!(spp.called("restart_service ClipSVC")); + // Never needed the clipup fallback. + assert!(!spp.called("run_clipup -v -o")); + } + + #[test] + fn falls_back_to_clipup() { + let spp = FakeSpp { + tokens_after_restart: false, + tokens_after_clipup: true, + ..Default::default() + }; + Hwid.run(&spp, Product::Windows).unwrap(); + assert!(spp.called("run_clipup -v -o")); + } + + #[test] + fn errors_when_no_tokens_ever_appear() { + let spp = FakeSpp::default(); // tokens never appear + let err = Hwid.run(&spp, Product::Windows).unwrap_err(); + assert!(matches!(err, Error::WinApi { .. })); + } + + #[test] + fn requires_elevation_and_win10() { + let not_elevated = FakeSpp { + elevated: false, + ..Default::default() + }; + assert!(matches!( + Hwid.run(¬_elevated, Product::Windows), + Err(Error::NotElevated) + )); + + let too_old = FakeSpp { + build: 7601, + tokens_after_restart: true, + ..Default::default() + }; + assert!(matches!( + Hwid.run(&too_old, Product::Windows), + Err(Error::Unsupported { .. }) + )); } } diff --git a/MAS/src/activation/kms38.rs b/MAS/src/activation/kms38.rs index 2249e06..d6451fb 100644 --- a/MAS/src/activation/kms38.rs +++ b/MAS/src/activation/kms38.rs @@ -1,20 +1,58 @@ //! KMS38 — extend KMS activation to 2038-01-19. //! -//! In MAS this is not a separate file: it is a sub-flow of -//! `Online_KMS_Activation.cmd` (with support paths in `Troubleshoot.cmd`). It -//! installs a GVLK, then uses `ClipUp.exe -o` with the special "gatherosstate" -//! ticket so the SPP records an activation whose expiry is pinned to the KMS38 -//! epoch instead of the usual 180 days. +//! Corrected against the source (`Online_KMS_Activation.cmd`): KMS38 is **not** +//! a ClipUp/gatherosstate flow and not a separate activator. It is a +//! *detect-and-preserve* variant of Online KMS. The 2038 lease is minted by the +//! (emulated) public KMS host replying to a standard KMS-v6 activation for +//! eligible builds; this code only: +//! 1. gates eligibility (build ≥ 14393, not EnterpriseG/GN), +//! 2. installs the GVLK, sets the KMS host, and activates (normal Online KMS), +//! 3. detects the KMS38 lease (`GracePeriodRemaining` > 180 days), +//! 4. pins that product's KMS host to loopback `127.0.0.2:1688` so the renewal +//! task's public re-activation can't reset it, and skips re-activating. //! -//! Port boundary: the epoch/expiry bookkeeping is portable; the actual ticket -//! consumption is the same closed ClipSVC path as [`super::hwid`]. Shares the -//! ClipUp delegation once that slice lands. +//! The eligibility/detection/classification logic is pure and tested here; the +//! WMI activation and the loopback registry pin are Windows-only (behind `Spp`). use crate::activation::Activator; +use crate::data::kms_hosts::{DEFAULT_KMS_HOSTS, DEFAULT_KMS_PORT}; use crate::error::{Error, Result}; use crate::model::{Method, Product}; use crate::platform::Spp; +/// Minimum build for KMS38 (Windows 10 1607 / Server 2016). +pub const MIN_KMS38_BUILD: u32 = 14393; + +/// A normal KMS lease is exactly 180 days; anything longer is the 2038 lease. +pub const NORMAL_KMS_GRACE_MINUTES: u32 = 259_200; + +/// EnterpriseG / EnterpriseGN activation IDs — explicitly disqualified. +pub const ENTERPRISE_G_IDS: &[&str] = &[ + "e0b2d383-d112-413f-8a80-97f373a5820c", // EnterpriseG (SKU 171) + "e38454fb-41a4-4f59-a5dc-25080e354730", // EnterpriseGN (SKU 172) +]; + +/// The loopback address the KMS38 lock is pinned to. +pub const KMS38_PIN_HOST: &str = "127.0.0.2"; + +/// Is this product eligible for KMS38? +pub fn is_eligible(build: u32, activation_id: &str) -> bool { + build >= MIN_KMS38_BUILD + && !ENTERPRISE_G_IDS + .iter() + .any(|id| id.eq_ignore_ascii_case(activation_id)) +} + +/// Does this grace period indicate an active KMS38 (to-2038) lease? +pub fn is_kms38_lease(build: u32, activation_id: &str, grace_minutes: u32) -> bool { + is_eligible(build, activation_id) && grace_minutes > NORMAL_KMS_GRACE_MINUTES +} + +/// Whole days remaining, rounded up (matches the script's `ceil(gpr/1440)`). +pub fn grace_days(grace_minutes: u32) -> u32 { + grace_minutes.div_ceil(1440) +} + pub struct Kms38; impl Activator for Kms38 { @@ -26,13 +64,71 @@ impl Activator for Kms38 { if !spp.is_elevated() { return Err(Error::NotElevated); } - if spp.windows_build()? < 10240 { + let build = spp.windows_build()?; + if build < MIN_KMS38_BUILD { return Err(Error::Unsupported { - what: "KMS38 requires Windows 10 or later".into(), + what: "KMS38 requires Windows 10 1607 / Server 2016 or later".into(), }); } - Err(Error::Unsupported { - what: "KMS38 ClipUp delegation not yet ported (shares HWID's ClipSVC path)".into(), - }) + + // Find an eligible Windows volume product. + let products = spp.installed_products(Product::Windows)?; + let target = products + .into_iter() + .find(|p| is_eligible(build, &p.activation_id)) + .ok_or_else(|| Error::Unsupported { + what: "no KMS38-eligible Windows edition installed (EnterpriseG/GN excluded)".into(), + })?; + + // If already on a 2038 lease, preserve it and stop (skip re-activate). + if let Some(g) = target.grace_minutes { + if is_kms38_lease(build, &target.activation_id, g) { + return spp.pin_kms38(&target.activation_id); + } + } + + // Normal Online-KMS activation against the default hosts. + for host in DEFAULT_KMS_HOSTS { + if spp.set_kms_host(Product::Windows, host, DEFAULT_KMS_PORT).is_err() { + continue; + } + if spp.activate(Product::Windows, &target.activation_id).is_ok() { + // Pin the loopback lock so renewal can't reset the lease. + return spp.pin_kms38(&target.activation_id); + } + } + Err(Error::winapi_msg( + "KMS38: activation failed against every default host", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn eligibility_gates_build_and_edition() { + let pro = "00000000-0000-0000-0000-000000000001"; + assert!(is_eligible(14393, pro)); + assert!(!is_eligible(10240, pro)); // too old (1507) + assert!(!is_eligible(19045, ENTERPRISE_G_IDS[0])); // EnterpriseG excluded + assert!(!is_eligible(19045, ENTERPRISE_G_IDS[1].to_uppercase().as_str())); // case-insensitive + } + + #[test] + fn kms38_lease_detected_only_above_180_days() { + let pro = "00000000-0000-0000-0000-000000000001"; + assert!(!is_kms38_lease(19045, pro, NORMAL_KMS_GRACE_MINUTES)); // exactly 180d = normal + assert!(is_kms38_lease(19045, pro, NORMAL_KMS_GRACE_MINUTES + 1)); + // Even a huge grace on EnterpriseG is not a KMS38 lock. + assert!(!is_kms38_lease(19045, ENTERPRISE_G_IDS[0], 9_000_000)); + } + + #[test] + fn grace_days_rounds_up() { + assert_eq!(grace_days(1440), 1); + assert_eq!(grace_days(1441), 2); + assert_eq!(grace_days(NORMAL_KMS_GRACE_MINUTES), 180); } } diff --git a/MAS/src/activation/tsforge.rs b/MAS/src/activation/tsforge.rs index 8c14eab..f8dac51 100644 --- a/MAS/src/activation/tsforge.rs +++ b/MAS/src/activation/tsforge.rs @@ -1,26 +1,28 @@ //! `TSforge_Activation.cmd` — ticket injection into the SPP trusted store. //! -//! This is the deepest module. TSforge writes activation tickets **directly into -//! the SPP trusted store** (`data.dat`) rather than going through any activation -//! server. Per analysis `port_risk`, the store is a byte-exact, -//! reverse-engineered format: RSA/AES/HMAC-wrapped with CRC32 per block, a -//! `VariableBag` layout, per-block + whole-file SHA-256, physical-store HMAC-SHA1 -//! (salted-SHA1 on Vista), and 4/8-byte alignment padding that **differs across -//! Vista / 7 / 8 / 8.1 / Modern**. Any mismatch corrupts the store. +//! TSforge writes activation tickets **directly into the SPP trusted store** +//! (`data.dat`) rather than contacting an activation server. The deleted script +//! embeds the full LibTSforge C# reference implementation, which is being ported +//! into the [`libtsforge`] crate: //! -//! A faithful port is essentially porting **LibTSforge** to Rust: -//! * pure `libtsforge` crate (no_std-friendly, unit-testable on Linux/CI): -//! the store (de)serialization, CRC/SHA/HMAC layers, key-blob builders, and -//! the data tables (SKU→edition, MSI-office rows, retail→volume); -//! * thin `#[cfg(windows)]` FFI: stop `sppsvc`, swap `data.dat`, restart. +//! * store dialect selection ([`libtsforge::PsVersion`]), +//! * integrity primitives (CRC-32/BZIP2, SHA-256), +//! * the physical-store and VariableBag block formats. //! -//! This is the highest-value, highest-effort slice and the natural next crate to -//! extract. It is intentionally not stubbed as "works" — that would be a lie. +//! The remaining pieces for a *working* ticket are the Modern physical store, +//! `TokenStoreModern`, the RSA CryptoAPI-blob crypto (behind a `crypto` +//! feature) and the verbatim KMS/HWID response blobs. Until those land this +//! returns a typed error rather than pretending to activate. +//! +//! Pipeline (from the script, per activation ID): detect store version → +//! `InstallGenPKey` → branch `ZeroCID` / `StaticCID` / `KMS4k` → stop `sppsvc`, +//! rewrite the store, restart → verify via WMI. use crate::activation::Activator; use crate::error::{Error, Result}; use crate::model::{Method, Product}; use crate::platform::Spp; +use libtsforge::PsVersion; pub struct TSforge; @@ -33,9 +35,56 @@ impl Activator for TSforge { if !spp.is_elevated() { return Err(Error::NotElevated); } + // Select the trusted-store dialect for this OS (ported logic). + let build = spp.windows_build()?; + let version = PsVersion::detect(build).ok_or_else(|| Error::Unsupported { + what: format!("TSforge does not support Windows build {build}"), + })?; + Err(Error::Unsupported { - what: "TSforge requires the SPP trusted-store codec (LibTSforge port — see module docs)" - .into(), + what: format!( + "TSforge store-write for {version:?} not yet ported \ + (needs TokenStoreModern + RSA crypto backend — see libtsforge)" + ), }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::test_util::FakeSpp; + + #[test] + fn requires_elevation() { + let spp = FakeSpp { + elevated: false, + ..Default::default() + }; + assert!(matches!( + TSforge.run(&spp, Product::Windows), + Err(Error::NotElevated) + )); + } + + #[test] + fn selects_store_version_then_reports_unported() { + // Elevated + a real build → gets past detection to the honest "unported". + let spp = FakeSpp { + build: 19045, + ..Default::default() + }; + let err = TSforge.run(&spp, Product::Windows).unwrap_err(); + assert!(matches!(err, Error::Unsupported { .. })); + } + + #[test] + fn rejects_unsupported_build() { + let spp = FakeSpp { + build: 3000, // pre-Vista, no store dialect + ..Default::default() + }; + let err = TSforge.run(&spp, Product::Windows).unwrap_err(); + assert!(matches!(err, Error::Unsupported { .. })); + } +} diff --git a/MAS/src/platform/mod.rs b/MAS/src/platform/mod.rs index b9e8d62..491a653 100644 --- a/MAS/src/platform/mod.rs +++ b/MAS/src/platform/mod.rs @@ -14,13 +14,15 @@ //! and every privileged call returns [`Error::UnsupportedPlatform`], so the //! portable core builds and tests on Linux. -use crate::error::Result; +use crate::error::{Error, Result}; use crate::model::{LicenseStatus, Product}; #[cfg(all(windows, feature = "winapi"))] pub mod windows_backend; #[cfg(not(all(windows, feature = "winapi")))] pub mod stub; +#[cfg(test)] +pub mod test_util; /// One product entry as reported by the SPP (`SoftwareLicensingProduct` row). #[derive(Debug, Clone)] @@ -83,6 +85,54 @@ pub trait Spp { /// Whether the current process is elevated (administrator). fn is_elevated(&self) -> bool; + + // --- Digital-license / ClipSVC operations (HWID, KMS38) ------------------ + // These drive the closed ClipSVC path: MAS itself only generates a ticket + // and hands it to Windows, so the port does the same behind this boundary. + + /// Produce a `GenuineTicket.xml` for this machine (hardware hash + region), + /// returning its bytes. Windows-only (uses the SPP/ClipUp machinery). + fn generate_genuine_ticket(&self) -> Result> { + Err(Error::UnsupportedPlatform { + operation: "generate genuine ticket", + }) + } + + /// Write a genuine ticket to the ClipSVC drop path + /// (`%ProgramData%\Microsoft\Windows\ClipSVC\GenuineTicket\GenuineTicket.xml`). + fn write_genuine_ticket(&self, _xml: &[u8]) -> Result<()> { + Err(Error::UnsupportedPlatform { + operation: "write genuine ticket", + }) + } + + /// Restart a Windows service by name (e.g. `ClipSVC`). + fn restart_service(&self, _name: &str) -> Result<()> { + Err(Error::UnsupportedPlatform { + operation: "restart service", + }) + } + + /// Run `ClipUp.exe` with the given args (e.g. `-v -o` to apply a ticket). + fn run_clipup(&self, _args: &[&str]) -> Result<()> { + Err(Error::UnsupportedPlatform { + operation: "run ClipUp", + }) + } + + /// Whether ClipSVC has produced `tokens.dat` — the HWID success check. + fn clip_tokens_present(&self) -> bool { + false + } + + /// Pin one Windows product's KMS host to loopback (`127.0.0.2:1688`) under + /// its per-activation-ID registry key, so the renewal task cannot overwrite + /// a KMS38 (to-2038) lease. `activation_id` is the SPP product ID. + fn pin_kms38(&self, _activation_id: &str) -> Result<()> { + Err(Error::UnsupportedPlatform { + operation: "pin KMS38 lock", + }) + } } /// Construct the SPP backend appropriate for this build. diff --git a/MAS/src/platform/test_util.rs b/MAS/src/platform/test_util.rs new file mode 100644 index 0000000..3d3e747 --- /dev/null +++ b/MAS/src/platform/test_util.rs @@ -0,0 +1,119 @@ +//! Shared in-memory `Spp` fake for unit tests (no Windows required). + +use crate::error::Result; +use crate::model::{LicenseStatus, Product}; +use crate::platform::{LicenseInfo, Spp}; +use std::cell::{Cell, RefCell}; +use std::path::Path; + +/// Configurable fake SPP. Records calls and lets each test dictate outcomes. +pub struct FakeSpp { + pub elevated: bool, + pub build: u32, + /// ClipSVC produces tokens.dat after the service restart. + pub tokens_after_restart: bool, + /// ClipSVC produces tokens.dat after `clipup -v -o`. + pub tokens_after_clipup: bool, + /// Products returned by `installed_products`. + pub products: Vec, + /// Internal: whether tokens.dat currently "exists" (flipped by restart/clipup). + pub tokens: Cell, + pub calls: RefCell>, +} + +impl Default for FakeSpp { + fn default() -> Self { + FakeSpp { + elevated: true, + build: 19045, + tokens_after_restart: false, + tokens_after_clipup: false, + products: Vec::new(), + tokens: Cell::new(false), + calls: RefCell::new(Vec::new()), + } + } +} + +impl FakeSpp { + fn log(&self, s: impl Into) { + self.calls.borrow_mut().push(s.into()); + } + pub fn called(&self, s: &str) -> bool { + self.calls.borrow().iter().any(|c| c == s) + } +} + +impl Spp for FakeSpp { + fn installed_products(&self, _p: Product) -> Result> { + Ok(self.products.clone()) + } + fn install_product_key(&self, _p: Product, key: &str) -> Result<()> { + self.log(format!("install_product_key {key}")); + Ok(()) + } + fn uninstall_product_key(&self, _p: Product, _id: &str) -> Result<()> { + Ok(()) + } + fn set_kms_host(&self, _p: Product, host: &str, port: u16) -> Result<()> { + self.log(format!("set_kms_host {host}:{port}")); + Ok(()) + } + fn clear_kms_host(&self, _p: Product) -> Result<()> { + Ok(()) + } + fn activate(&self, _p: Product, id: &str) -> Result<()> { + self.log(format!("activate {id}")); + Ok(()) + } + fn install_license(&self, _x: &Path) -> Result<()> { + Ok(()) + } + fn windows_build(&self) -> Result { + Ok(self.build) + } + fn windows_edition(&self) -> Result { + Ok("Professional".into()) + } + fn is_elevated(&self) -> bool { + self.elevated + } + fn generate_genuine_ticket(&self) -> Result> { + self.log("generate_genuine_ticket"); + Ok(b"".to_vec()) + } + fn write_genuine_ticket(&self, _xml: &[u8]) -> Result<()> { + self.log("write_genuine_ticket"); + Ok(()) + } + fn restart_service(&self, name: &str) -> Result<()> { + self.log(format!("restart_service {name}")); + if self.tokens_after_restart { + self.tokens.set(true); + } + Ok(()) + } + fn run_clipup(&self, args: &[&str]) -> Result<()> { + self.log(format!("run_clipup {}", args.join(" "))); + if self.tokens_after_clipup { + self.tokens.set(true); + } + Ok(()) + } + fn clip_tokens_present(&self) -> bool { + self.tokens.get() + } +} + +/// Build a `LicenseInfo` for tests. +pub fn license(name: &str, key: Option<&str>, status: LicenseStatus) -> LicenseInfo { + LicenseInfo { + activation_id: format!("AID-{name}"), + name: name.into(), + description: String::new(), + partial_product_key: key.map(str::to_string), + status, + license_family: None, + grace_minutes: None, + } +} diff --git a/MAS/src/platform/windows_backend.rs b/MAS/src/platform/windows_backend.rs index 089e7f0..f007c51 100644 --- a/MAS/src/platform/windows_backend.rs +++ b/MAS/src/platform/windows_backend.rs @@ -440,6 +440,88 @@ impl Spp for WindowsSpp { ok && elevation.TokenIsElevated != 0 } } + + // --- Digital-license / ClipSVC operations -------------------------------- + + fn write_genuine_ticket(&self, xml: &[u8]) -> Result<()> { + let dir = clipsvc_dir().join("GenuineTicket"); + std::fs::create_dir_all(&dir)?; + // MAS writes `GenuineTicket` then copies it to `GenuineTicket.xml`; + // ClipSVC consumes the `.xml`. + std::fs::write(dir.join("GenuineTicket"), xml)?; + std::fs::write(dir.join("GenuineTicket.xml"), xml)?; + Ok(()) + } + + fn restart_service(&self, name: &str) -> Result<()> { + // Faithful to the script's `Restart-Service` (PowerShell handles + // dependent services; `net`/`sc` do not). + run_tool( + "powershell", + &[ + "-NoProfile", + "-Command", + &format!("Restart-Service -Name '{name}' -Force"), + ], + ) + } + + fn run_clipup(&self, args: &[&str]) -> Result<()> { + // ClipUp.exe lives in System32, which is on PATH for an elevated shell. + run_tool("clipup", args) + } + + fn clip_tokens_present(&self) -> bool { + clipsvc_dir().join("tokens.dat").exists() + } + + fn pin_kms38(&self, activation_id: &str) -> Result<()> { + // Per-activation-ID key under the Windows SPP registry root. + let key = format!( + r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SoftwareProtectionPlatform\{}\{}", + Product::Windows.application_id(), + activation_id + ); + run_tool( + "reg", + &[ + "add", key.as_str(), "/v", "KeyManagementServiceName", "/t", "REG_SZ", "/d", + "127.0.0.2", "/f", + ], + )?; + run_tool( + "reg", + &[ + "add", key.as_str(), "/v", "KeyManagementServicePort", "/t", "REG_SZ", "/d", "1688", + "/f", + ], + ) + } +} + +/// `%ProgramData%\Microsoft\Windows\ClipSVC`. +fn clipsvc_dir() -> std::path::PathBuf { + let pd = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string()); + std::path::Path::new(&pd).join(r"Microsoft\Windows\ClipSVC") +} + +/// Spawn an external tool and map a non-zero exit to an [`Error::ExternalTool`]. +fn run_tool(tool: &str, args: &[&str]) -> Result<()> { + let status = std::process::Command::new(tool) + .args(args) + .status() + .map_err(|e| Error::ExternalTool { + tool: tool.to_string(), + detail: e.to_string(), + })?; + if status.success() { + Ok(()) + } else { + Err(Error::ExternalTool { + tool: tool.to_string(), + detail: format!("exited with {status}"), + }) + } } impl Drop for WindowsSpp {