diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a8bfa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2aec9f7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,120 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "rustii" +version = "0.1.0" +dependencies = [ + "aes", + "byteorder", + "cbc", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..569cc8c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rustii" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "rustii" +path = "src/bin/rustii/main.rs" + +[lib] +path = "src/lib.rs" +test = true +doc = true + +[dependencies] +byteorder = "1" +cbc = "0" +aes = "0" diff --git a/src/bin/rustii/main.rs b/src/bin/rustii/main.rs new file mode 100644 index 0000000..40a97bd --- /dev/null +++ b/src/bin/rustii/main.rs @@ -0,0 +1,21 @@ +use std::fs; +use rustii::title::{tmd, ticket, crypto}; + +fn main() { + let data = fs::read("title.tmd").unwrap(); + let tmd = tmd::TMD::from_bytes(&data).unwrap(); + println!("num content records: {:?}", tmd.content_records.len()); + println!("first record data: {:?}", tmd.content_records.first().unwrap()); + assert_eq!(data, tmd.to_vec().unwrap()); + + let data = fs::read("tik").unwrap(); + let tik = ticket::Ticket::from_bytes(&data).unwrap(); + println!("title version from ticket is: {:?}", tik.title_version); + println!("title key (enc): {:?}", tik.title_key); + println!("title key (dec): {:?}", tik.dec_title_key()); + assert_eq!(data, tik.to_vec().unwrap()); + + + assert_eq!(tik.title_key, crypto::encrypt_title_key(tik.dec_title_key(), tik.common_key_index, tik.title_id)); + println!("re-encrypted key matched"); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e06eff8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +// lib.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib + + +pub mod title; diff --git a/src/title/commonkeys.rs b/src/title/commonkeys.rs new file mode 100644 index 0000000..11d5a21 --- /dev/null +++ b/src/title/commonkeys.rs @@ -0,0 +1,49 @@ +// title/commonkeys.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib + +const COMMON_KEY: [u8; 16] = [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]; +const KOREAN_KEY: [u8; 16] = [0x63, 0xb8, 0x2b, 0xb4, 0xf4, 0x61, 0x4e, 0x2e, 0x13, 0xf2, 0xfe, 0xfb, 0xba, 0x4c, 0x9b, 0x7e]; +const VWII_KEY: [u8; 16] = [0x30, 0xbf, 0xc7, 0x6e, 0x7c, 0x19, 0xaf, 0xbb, 0x23, 0x16, 0x33, 0x30, 0xce, 0xd7, 0xc2, 0x8d]; +const DEV_COMMON_KEY: [u8; 16] = [0xa1, 0x60, 0x4a, 0x6a, 0x71, 0x23, 0xb5, 0x29, 0xae, 0x8b, 0xec, 0x32, 0xc8, 0x16, 0xfc, 0xaa]; + +pub fn get_common_key(index: u8, is_dev: Option) -> [u8; 16] { + // Match the Korean and vWii keys, and if they don't match then fall back on the common key. + // The is_dev argument is an option, and if it's set to false or None, then the regular + // common key will be used. + match index { + 1 => KOREAN_KEY, + 2 => VWII_KEY, + _ => { + match is_dev { + Some(true) => DEV_COMMON_KEY, + _ => COMMON_KEY + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_common_key() { + assert_eq!(get_common_key(0, None), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]); + } + #[test] + fn test_get_invalid_index() { + assert_eq!(get_common_key(57, None), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]); + } + #[test] + fn test_get_korean_key() { + assert_eq!(get_common_key(1, None), [0x63, 0xb8, 0x2b, 0xb4, 0xf4, 0x61, 0x4e, 0x2e, 0x13, 0xf2, 0xfe, 0xfb, 0xba, 0x4c, 0x9b, 0x7e]); + } + #[test] + fn test_get_vwii_key() { + assert_eq!(get_common_key(2, None), [0x30, 0xbf, 0xc7, 0x6e, 0x7c, 0x19, 0xaf, 0xbb, 0x23, 0x16, 0x33, 0x30, 0xce, 0xd7, 0xc2, 0x8d]); + } + #[test] + fn test_get_dev_key() { + assert_eq!(get_common_key(0, Some(true)), [0xa1, 0x60, 0x4a, 0x6a, 0x71, 0x23, 0xb5, 0x29, 0xae, 0x8b, 0xec, 0x32, 0xc8, 0x16, 0xfc, 0xaa]); + } +} diff --git a/src/title/crypto.rs b/src/title/crypto.rs new file mode 100644 index 0000000..66aff20 --- /dev/null +++ b/src/title/crypto.rs @@ -0,0 +1,35 @@ +// title/crypto.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib +// +// Implements the common crypto functions required to handle Wii content encryption. + +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use aes::cipher::block_padding::ZeroPadding; +use crate::title::commonkeys::get_common_key; + +// Convert a Title ID into the format required for use as the Title Key decryption IV. +fn title_id_to_iv(title_id: [u8; 8]) -> [u8; 16] { + let mut iv: Vec = Vec::from(title_id); + iv.resize(16, 0); + iv.as_slice().try_into().unwrap() +} + +// Decrypt a Title Key using the specified common key. +pub fn decrypt_title_key(title_key_enc: [u8; 16], common_key_index: u8, title_id: [u8; 8]) -> [u8; 16] { + let iv = title_id_to_iv(title_id); + type Aes128CbcDec = cbc::Decryptor; + let decryptor = Aes128CbcDec::new(&get_common_key(common_key_index, None).into(), &iv.into()); + let mut title_key = title_key_enc; + decryptor.decrypt_padded_mut::(&mut title_key).unwrap(); + title_key +} + +// Encrypt a Title Key using the specified common key. +pub fn encrypt_title_key(title_key_dec: [u8; 16], common_key_index: u8, title_id: [u8; 8]) -> [u8; 16] { + let iv = title_id_to_iv(title_id); + type Aes128CbcEnc = cbc::Encryptor; + let encryptor = Aes128CbcEnc::new(&get_common_key(common_key_index, None).into(), &iv.into()); + let mut title_key = title_key_dec; + encryptor.encrypt_padded_mut::(&mut title_key, 16).unwrap(); + title_key +} diff --git a/src/title/mod.rs b/src/title/mod.rs new file mode 100644 index 0000000..e62913c --- /dev/null +++ b/src/title/mod.rs @@ -0,0 +1,8 @@ +// title/mod.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib + +pub mod commonkeys; +pub mod crypto; +pub mod ticket; +pub mod tmd; + diff --git a/src/title/ticket.rs b/src/title/ticket.rs new file mode 100644 index 0000000..e2416aa --- /dev/null +++ b/src/title/ticket.rs @@ -0,0 +1,155 @@ +// title/tik.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib +// +// Implements the structures and methods required for Ticket parsing and editing. + +use std::io::{Cursor, Read, Write}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use crate::title::crypto::decrypt_title_key; + +#[derive(Debug)] +#[derive(Copy)] +#[derive(Clone)] +pub struct TitleLimit { + // The type of limit being applied (time, launch count, etc.) + pub limit_type: u32, + // The maximum value for that limit (seconds, max launches, etc.) + pub limit_max: u32, +} + +#[derive(Debug)] +pub struct Ticket { + pub signature_type: u32, + pub signature: [u8; 256], + padding1: [u8; 60], + pub signature_issuer: [u8; 64], + pub ecdh_data: [u8; 60], + pub ticket_version: u8, + reserved1: [u8; 2], + pub title_key: [u8; 16], + unknown1: [u8; 1], + pub ticket_id: [u8; 8], + pub console_id: [u8; 4], + pub title_id: [u8; 8], + unknown2: [u8; 2], + pub title_version: u16, + pub permitted_titles_mask: [u8; 4], + pub permit_mask: [u8; 4], + pub title_export_allowed: u8, + pub common_key_index: u8, + unknown3: [u8; 48], + pub content_access_permission: [u8; 64], + padding2: [u8; 2], + pub title_limits: [TitleLimit; 8], +} + +impl Ticket { + pub fn from_bytes(data: &[u8]) -> Result { + let mut buf = Cursor::new(data); + let signature_type = buf.read_u32::()?; + let mut signature = [0u8; 256]; + buf.read_exact(&mut signature)?; + // Maybe this can be read differently? + let mut padding1 = [0u8; 60]; + buf.read_exact(&mut padding1)?; + let mut signature_issuer = [0u8; 64]; + buf.read_exact(&mut signature_issuer)?; + let mut ecdh_data = [0u8; 60]; + buf.read_exact(&mut ecdh_data)?; + let ticket_version = buf.read_u8()?; + let mut reserved1 = [0u8; 2]; + buf.read_exact(&mut reserved1)?; + let mut title_key = [0u8; 16]; + buf.read_exact(&mut title_key)?; + let mut unknown1 = [0u8; 1]; + buf.read_exact(&mut unknown1)?; + let mut ticket_id = [0u8; 8]; + buf.read_exact(&mut ticket_id)?; + let mut console_id = [0u8; 4]; + buf.read_exact(&mut console_id)?; + let mut title_id = [0u8; 8]; + buf.read_exact(&mut title_id)?; + let mut unknown2 = [0u8; 2]; + buf.read_exact(&mut unknown2)?; + let title_version = buf.read_u16::()?; + let mut permitted_titles_mask = [0u8; 4]; + buf.read_exact(&mut permitted_titles_mask)?; + let mut permit_mask = [0u8; 4]; + buf.read_exact(&mut permit_mask)?; + let title_export_allowed = buf.read_u8()?; + let common_key_index = buf.read_u8()?; + let mut unknown3 = [0u8; 48]; + buf.read_exact(&mut unknown3)?; + let mut content_access_permission = [0u8; 64]; + buf.read_exact(&mut content_access_permission)?; + let mut padding2 = [0u8; 2]; + buf.read_exact(&mut padding2)?; + // Build the array of title limits. + let mut title_limits: Vec = Vec::new(); + for _ in 0..8 { + let limit_type = buf.read_u32::()?; + let limit_max = buf.read_u32::()?; + title_limits.push(TitleLimit { limit_type, limit_max }); + } + let title_limits = title_limits.try_into().unwrap(); + Ok(Ticket { + signature_type, + signature, + padding1, + signature_issuer, + ecdh_data, + ticket_version, + reserved1, + title_key, + unknown1, + ticket_id, + console_id, + title_id, + unknown2, + title_version, + permitted_titles_mask, + permit_mask, + title_export_allowed, + common_key_index, + unknown3, + content_access_permission, + padding2, + title_limits, + }) + } + + pub fn to_vec(&self) -> Result, std::io::Error> { + let mut buf = Vec::new(); + buf.write_u32::(self.signature_type)?; + buf.write_all(&self.signature)?; + buf.write_all(&self.padding1)?; + buf.write_all(&self.signature_issuer)?; + buf.write_all(&self.ecdh_data)?; + buf.write_u8(self.ticket_version)?; + buf.write_all(&self.reserved1)?; + buf.write_all(&self.title_key)?; + buf.write_all(&self.unknown1)?; + buf.write_all(&self.ticket_id)?; + buf.write_all(&self.console_id)?; + buf.write_all(&self.title_id)?; + buf.write_all(&self.unknown2)?; + buf.write_u16::(self.title_version)?; + buf.write_all(&self.permitted_titles_mask)?; + buf.write_all(&self.permit_mask)?; + buf.write_u8(self.title_export_allowed)?; + buf.write_u8(self.common_key_index)?; + buf.write_all(&self.unknown3)?; + buf.write_all(&self.content_access_permission)?; + buf.write_all(&self.padding2)?; + // Iterate over title limits and write out their data. + for limit in &self.title_limits { + buf.write_u32::(limit.limit_type)?; + buf.write_u32::(limit.limit_max)?; + } + Ok(buf) + } + + pub fn dec_title_key(&self) -> [u8; 16] { + decrypt_title_key(self.title_key, self.common_key_index, self.title_id) + } +} diff --git a/src/title/tmd.rs b/src/title/tmd.rs new file mode 100644 index 0000000..a6f90bb --- /dev/null +++ b/src/title/tmd.rs @@ -0,0 +1,185 @@ +// title/tmd.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors +// https://github.com/NinjaCheetah/rustii-lib +// +// Implements the structures and methods required for TMD parsing and editing. + +use std::io::{Cursor, Read, Write}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; + +#[derive(Debug)] +pub struct ContentRecord { + pub content_id: u32, + pub index: u16, + pub content_type: u16, + pub content_size: u64, + pub content_hash: [u8; 20], +} + +#[derive(Debug)] +pub struct TMD { + pub signature_type: u32, + pub signature: [u8; 256], + padding1: [u8; 60], + pub signature_issuer: [u8; 64], + pub tmd_version: u8, + pub ca_crl_version: u8, + pub signer_crl_version: u8, + pub is_vwii: u8, + pub ios_tid: [u8; 8], + pub title_id: [u8; 8], + pub title_type: [u8; 4], + pub group_id: u16, + padding2: [u8; 2], + pub region: u16, + pub ratings: [u8; 16], + reserved1: [u8; 12], + pub ipc_mask: [u8; 12], + reserved2: [u8; 18], + pub access_rights: u32, + pub title_version: u16, + pub num_contents: u16, + pub boot_index: u16, + pub minor_version: u16, // Normally unused, but good for fakesigning! + pub content_records: Vec, +} + +impl TMD { + pub fn from_bytes(data: &[u8]) -> Result { + let mut buf = Cursor::new(data); + let signature_type = buf.read_u32::()?; + let mut signature = [0u8; 256]; + buf.read_exact(&mut signature)?; + // Maybe this can be read differently? + let mut padding1 = [0u8; 60]; + buf.read_exact(&mut padding1)?; + let mut signature_issuer = [0u8; 64]; + buf.read_exact(&mut signature_issuer)?; + let tmd_version = buf.read_u8()?; + let ca_crl_version = buf.read_u8()?; + let signer_crl_version = buf.read_u8()?; + let is_vwii = buf.read_u8()?; + let mut ios_tid = [0u8; 8]; + buf.read_exact(&mut ios_tid)?; + let mut title_id = [0u8; 8]; + buf.read_exact(&mut title_id)?; + let mut title_type = [0u8; 4]; + buf.read_exact(&mut title_type)?; + let group_id = buf.read_u16::()?; + // Same here... + let mut padding2 = [0u8; 2]; + buf.read_exact(&mut padding2)?; + let region = buf.read_u16::()?; + let mut ratings = [0u8; 16]; + buf.read_exact(&mut ratings)?; + // ...and here... + let mut reserved1 = [0u8; 12]; + buf.read_exact(&mut reserved1)?; + let mut ipc_mask = [0u8; 12]; + buf.read_exact(&mut ipc_mask)?; + // ...and here. + let mut reserved2 = [0u8; 18]; + buf.read_exact(&mut reserved2)?; + let access_rights = buf.read_u32::()?; + let title_version = buf.read_u16::()?; + let num_contents = buf.read_u16::()?; + let boot_index = buf.read_u16::()?; + let minor_version = buf.read_u16::()?; + // Build content records by iterating over the rest of the data num_contents times. + let mut content_records = Vec::with_capacity(num_contents as usize); + for _ in 0..num_contents { + let content_id = buf.read_u32::()?; + let index = buf.read_u16::()?; + let content_type = buf.read_u16::()?; + let content_size = buf.read_u64::()?; + let mut content_hash = [0u8; 20]; + buf.read_exact(&mut content_hash)?; + content_records.push(ContentRecord { + content_id, + index, + content_type, + content_size, + content_hash, + }); + } + Ok(TMD { + signature_type, + signature, + padding1, + signature_issuer, + tmd_version, + ca_crl_version, + signer_crl_version, + is_vwii, + ios_tid, + title_id, + title_type, + group_id, + padding2, + region, + ratings, + reserved1, + ipc_mask, + reserved2, + access_rights, + title_version, + num_contents, + boot_index, + minor_version, + content_records, + }) + } + + pub fn to_vec(&self) -> Result, std::io::Error> { + let mut buf = Vec::new(); + buf.write_u32::(self.signature_type)?; + buf.write_all(&self.signature)?; + buf.write_all(&self.padding1)?; + buf.write_all(&self.signature_issuer)?; + buf.write_u8(self.tmd_version)?; + buf.write_u8(self.ca_crl_version)?; + buf.write_u8(self.signer_crl_version)?; + buf.write_u8(self.is_vwii)?; + buf.write_all(&self.ios_tid)?; + buf.write_all(&self.title_id)?; + buf.write_all(&self.title_type)?; + buf.write_u16::(self.group_id)?; + buf.write_all(&self.padding2)?; + buf.write_u16::(self.region)?; + buf.write_all(&self.ratings)?; + buf.write_all(&self.reserved1)?; + buf.write_all(&self.ipc_mask)?; + buf.write_all(&self.reserved2)?; + buf.write_u32::(self.access_rights)?; + buf.write_u16::(self.title_version)?; + buf.write_u16::(self.num_contents)?; + buf.write_u16::(self.boot_index)?; + buf.write_u16::(self.minor_version)?; + // Iterate over content records and write out content record data. + for content in &self.content_records { + buf.write_u32::(content.content_id)?; + buf.write_u16::(content.index)?; + buf.write_u16::(content.content_type)?; + buf.write_u64::(content.content_size)?; + buf.write_all(&content.content_hash)?; + } + Ok(buf) + } + + pub fn title_version(&self) -> u16 { + self.title_version + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_load_tmd() { + let data = fs::read("title.tmd").unwrap(); + let tmd = TMD::from_bytes(&data).unwrap(); + assert_eq!(tmd.tmd_version, 1); + } +} +