From a30a0f2c5b742b919527f211778f867ebef01470 Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 29 Apr 2025 22:03:55 -0400 Subject: [PATCH] Added rustii CLI wad edit command and required library features This required a LOT more backend work than I expected. But hey, some of this stuff is being done better than it was in libWiiPy/WiiPy, so that's a win in my book. When changing both the Title ID and type of a WAD, the updated TID will only be written once (which also means the Title Key will only be re-encrypted once). This is an improvement over WiiPy where it will be updated as part of both changes. Some TMD fields have been made private and moved to getter/setter methods only as they are actually in use now and should only be set through the correct means. --- src/bin/playground/main.rs | 2 +- src/bin/rustii/info.rs | 28 ++++----- src/bin/rustii/main.rs | 3 + src/bin/rustii/title/nus.rs | 34 ++++------- src/bin/rustii/title/wad.rs | 117 ++++++++++++++++++++++++++++++++---- src/title/mod.rs | 20 +++--- src/title/nus.rs | 2 +- src/title/ticket.rs | 17 +++++- src/title/tmd.rs | 87 ++++++++++++++++++++------- src/title/wad.rs | 2 +- 10 files changed, 232 insertions(+), 80 deletions(-) diff --git a/src/bin/playground/main.rs b/src/bin/playground/main.rs index 553592f..ac22965 100644 --- a/src/bin/playground/main.rs +++ b/src/bin/playground/main.rs @@ -7,7 +7,7 @@ use rustii::title; fn main() { let data = fs::read("sm.wad").unwrap(); let title = title::Title::from_bytes(&data).unwrap(); - println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id)); + println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id())); let wad = wad::WAD::from_bytes(&data).unwrap(); println!("size of tmd: {:?}", wad.tmd().len()); diff --git a/src/bin/rustii/info.rs b/src/bin/rustii/info.rs index f987243..7318e36 100644 --- a/src/bin/rustii/info.rs +++ b/src/bin/rustii/info.rs @@ -44,14 +44,14 @@ fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) -> fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> { // Print all important keys from the TMD. println!("Title Info"); - print_tid(tmd.title_id)?; - print_title_version(tmd.title_version, tmd.title_id, tmd.is_vwii())?; + print_tid(tmd.title_id())?; + print_title_version(tmd.title_version, tmd.title_id(), tmd.is_vwii())?; println!(" TMD Version: {}", tmd.tmd_version); - if hex::encode(tmd.ios_tid).eq("0000000000000000") { + if hex::encode(tmd.ios_tid()).eq("0000000000000000") { println!(" Required IOS: N/A"); } - else if hex::encode(tmd.ios_tid).ne(&format!("{:016X}", tmd.title_version)) { - println!(" Required IOS: IOS{} ({})", tmd.ios_tid.last().unwrap(), hex::encode(tmd.ios_tid).to_uppercase()); + else if hex::encode(tmd.ios_tid()).ne(&format!("{:016X}", tmd.title_version)) { + println!(" Required IOS: IOS{} ({})", tmd.ios_tid().last().unwrap(), hex::encode(tmd.ios_tid()).to_uppercase()); } let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer)).unwrap_or_default(); if signature_issuer.contains("CP00000004") { @@ -73,8 +73,8 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> else { println!(" Certificate Info: {} (Unknown)", signature_issuer); } - let region = if hex::encode(tmd.title_id).eq("0000000100000002") { - match versions::dec_to_standard(tmd.title_version, &hex::encode(tmd.title_id), Some(tmd.is_vwii != 0)) + let region = if hex::encode(tmd.title_id()).eq("0000000100000002") { + match versions::dec_to_standard(tmd.title_version, &hex::encode(tmd.title_id()), Some(tmd.is_vwii != 0)) .unwrap_or_default().chars().last() { Some('U') => "USA", Some('E') => "EUR", @@ -82,13 +82,13 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> Some('K') => "KOR", _ => "None" } - } else if matches!(tmd.title_type(), tmd::TitleType::System) { + } else if matches!(tmd.title_type(), Ok(tmd::TitleType::System)) { "None" } else { tmd.region() }; println!(" Region: {}", region); - println!(" Title Type: {}", tmd.title_type()); + println!(" Title Type: {}", tmd.title_type()?); println!(" vWii Title: {}", tmd.is_vwii != 0); println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo)); println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB)); @@ -124,7 +124,7 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> println!(" Content Index: {}", content.index); println!(" Content ID: {:08X}", content.content_id); println!(" Content Type: {}", content.content_type); - println!(" Content Size: {} bytes", content.content_size); + println!(" Content Size: {} bytes ({} blocks)", content.content_size, title::bytes_to_blocks(content.content_size as usize)); println!(" Content Hash: {}", hex::encode(content.content_hash)); } Ok(()) @@ -133,8 +133,8 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option) -> Result<()> fn print_ticket_info(ticket: ticket::Ticket, cert: Option) -> Result<()> { // Print all important keys from the Ticket. println!("Ticket Info"); - print_tid(ticket.title_id)?; - print_title_version(ticket.title_version, ticket.title_id, ticket.common_key_index == 2)?; + print_tid(ticket.title_id())?; + print_title_version(ticket.title_version, ticket.title_id(), ticket.common_key_index == 2)?; println!(" Ticket Version: {}", ticket.ticket_version); let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer)).unwrap_or_default(); if signature_issuer.contains("XS00000003") { @@ -196,8 +196,8 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> { } // Create a Title for size info, signing info and TMD/Ticket info. let title = title::Title::from_wad(&wad).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?; - let min_size_blocks = title.title_size_blocks(None)?; - let max_size_blocks = title.title_size_blocks(Some(true))?; + let min_size_blocks = title::bytes_to_blocks(title.title_size(None)?); + let max_size_blocks = title::bytes_to_blocks(title.title_size(Some(true))?); if min_size_blocks == max_size_blocks { println!(" Installed Size: {} blocks", min_size_blocks); } else { diff --git a/src/bin/rustii/main.rs b/src/bin/rustii/main.rs index b5d6309..566791d 100644 --- a/src/bin/rustii/main.rs +++ b/src/bin/rustii/main.rs @@ -125,6 +125,9 @@ fn main() -> Result<()> { title::wad::Commands::Convert { input, target, output } => { title::wad::convert_wad(input, target, output)? }, + title::wad::Commands::Edit { input, output, edits } => { + title::wad::edit_wad(input, output, edits)? + }, title::wad::Commands::Pack { input, output} => { title::wad::pack_wad(input, output)? }, diff --git a/src/bin/rustii/title/nus.rs b/src/bin/rustii/title/nus.rs index 88f92ba..64ac00c 100644 --- a/src/bin/rustii/title/nus.rs +++ b/src/bin/rustii/title/nus.rs @@ -22,7 +22,7 @@ pub enum Commands { cid: String, /// The title version that the content belongs to (only required for decryption) #[arg(short, long)] - version: Option, + version: Option, /// An optional content file name; defaults to (.app) #[arg(short, long)] output: Option, @@ -44,7 +44,7 @@ pub enum Commands { tid: String, /// The version of the Title to download #[arg(short, long)] - version: Option, + version: Option, #[command(flatten)] output: TitleOutputType, }, @@ -54,7 +54,7 @@ pub enum Commands { tid: String, /// The version of the TMD to download #[arg(short, long)] - version: Option, + version: Option, /// An optional TMD name; defaults to .tmd #[arg(short, long)] output: Option, @@ -73,7 +73,7 @@ pub struct TitleOutputType { wad: Option, } -pub fn download_content(tid: &str, cid: &str, version: &Option, output: &Option, decrypt: &bool) -> Result<()> { +pub fn download_content(tid: &str, cid: &str, version: &Option, output: &Option, decrypt: &bool) -> Result<()> { println!("Downloading content with Content ID {cid}..."); if tid.len() != 16 { bail!("The specified Title ID is invalid!"); @@ -92,7 +92,7 @@ pub fn download_content(tid: &str, cid: &str, version: &Option, output: // We need the version to get the correct TMD because the content's index is the IV for // decryption. A Ticket also needs to be available, of course. let version: u16 = if version.is_some() { - version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")? + version.unwrap() } else { bail!("You must specify the title version that the requested content belongs to for decryption!"); }; @@ -159,7 +159,7 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> { } else { fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?; } - let tid = hex::encode(title.tmd.title_id); + let tid = hex::encode(title.tmd.title_id()); println!(" - Saving TMD..."); fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?; println!(" - Saving Ticket..."); @@ -186,7 +186,7 @@ fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, } else { fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?; } - let tid = hex::encode(tmd.title_id); + let tid = hex::encode(tmd.title_id()); println!(" - Saving TMD..."); fs::write(out_path.join(format!("{}.tmd", &tid)), tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?; println!(" - Saving certificate chain..."); @@ -205,11 +205,11 @@ fn download_title_wad(title: title::Title, output: String) -> Result<()> { println!(" - Packing WAD..."); let out_path = PathBuf::from(output).with_extension("wad"); fs::write(&out_path, title.to_wad().with_context(|| "A WAD could not be packed.")?.to_bytes()?).with_context(|| format!("Could not open WAD file \"{}\" for writing.", out_path.display()))?; - println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id), out_path.display()); + println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id()), out_path.display()); Ok(()) } -pub fn download_title(tid: &str, version: &Option, output: &TitleOutputType) -> Result<()> { +pub fn download_title(tid: &str, version: &Option, output: &TitleOutputType) -> Result<()> { if tid.len() != 16 { bail!("The specified Title ID is invalid!"); } @@ -218,14 +218,9 @@ pub fn download_title(tid: &str, version: &Option, output: &TitleOutputT } else { println!("Downloading title {} vLatest, please wait...", tid); } - let version: Option = if version.is_some() { - Some(version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")?) - } else { - None - }; let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap(); println!(" - Downloading and parsing TMD..."); - let tmd = tmd::TMD::from_bytes(&nus::download_tmd(tid, version, true).with_context(|| "TMD data could not be downloaded.")?)?; + let tmd = tmd::TMD::from_bytes(&nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?)?; println!(" - Downloading and parsing Ticket..."); let tik_res = &nus::download_ticket(tid, true); let tik = match tik_res { @@ -266,12 +261,7 @@ pub fn download_title(tid: &str, version: &Option, output: &TitleOutputT Ok(()) } -pub fn download_tmd(tid: &str, version: &Option, output: &Option) -> Result<()> { - let version: Option = if version.is_some() { - Some(version.clone().unwrap().parse().with_context(|| "The specified TMD version must be a valid integer!")?) - } else { - None - }; +pub fn download_tmd(tid: &str, version: &Option, output: &Option) -> Result<()> { println!("Downloading TMD for title {tid}..."); if tid.len() != 16 { bail!("The specified Title ID is invalid!"); @@ -284,7 +274,7 @@ pub fn download_tmd(tid: &str, version: &Option, output: &Option PathBuf::from(format!("{}.tmd", tid)) }; let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap(); - let tmd_data = nus::download_tmd(tid, version, true).with_context(|| "TMD data could not be downloaded.")?; + let tmd_data = nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?; fs::write(&out_path, tmd_data)?; println!("Successfully downloaded TMD to \"{}\"!", out_path.display()); Ok(()) diff --git a/src/bin/rustii/title/wad.rs b/src/bin/rustii/title/wad.rs index 6c217ad..f71cb6a 100644 --- a/src/bin/rustii/title/wad.rs +++ b/src/bin/rustii/title/wad.rs @@ -5,10 +5,13 @@ use std::{str, fs, fmt}; use std::path::{Path, PathBuf}; +use std::rc::Rc; use anyhow::{bail, Context, Result}; use clap::{Subcommand, Args}; use glob::glob; +use hex::FromHex; use rand::prelude::*; +use regex::RegexBuilder; use rustii::title::{cert, crypto, tmd, ticket, content, wad}; use rustii::title; @@ -42,6 +45,16 @@ pub enum Commands { #[command(flatten)] target: ConvertTargets, }, + /// Edit the properties of a WAD file + Edit { + /// The path to the WAD to modify + input: String, + /// An optional output path; defaults to overwriting input WAD file + #[arg(short, long)] + output: Option, + #[command(flatten)] + edits: WadModifications + }, /// Pack a directory into a WAD file Pack { /// The directory to pack into a WAD @@ -110,6 +123,23 @@ pub struct ContentIdentifier { cid: Option, } +#[derive(Args)] +#[clap(next_help_heading = "Possible Modifications")] +#[group(multiple = true, required = true)] +pub struct WadModifications { + /// A new IOS version for this WAD (formatted as the decimal IOS version, e.g. 58, with a valid + /// range of 3-255) + #[arg(long)] + ios: Option, + /// A new Title ID for this WAD (formatted as 4 ASCII characters, e.g. HADE) + #[arg(long)] + tid: Option, + /// A new type for this WAD (valid options are "System", "Channel", "SystemChannel", + /// "GameChannel", "DLC", "HiddenChannel") + #[arg(long)] + r#type: Option, +} + enum Target { Retail, Dev, @@ -148,7 +178,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option, cid: &Option "normal" => tmd::ContentType::Normal, "shared" => tmd::ContentType::Shared, "dlc" => tmd::ContentType::DLC, - _ => bail!("The specified content type \"{}\" is invalid!", ctype.clone().unwrap()), + _ => bail!("The specified content type \"{}\" is invalid! Try --help to see valid types.", ctype.clone().unwrap()), } } else { println!("Using default type \"Normal\" because no content type was specified."); @@ -226,21 +256,21 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option Target::Dev => { title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?; title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?; - title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id, true); + title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true); title.ticket.common_key_index = 0; title.tmd.is_vwii = 0; }, Target::Retail => { title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; - title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id, false); + title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false); title.ticket.common_key_index = 0; title.tmd.is_vwii = 0; }, Target::Vwii => { title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; - title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id, false); + title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false); title.ticket.common_key_index = 2; title.tmd.is_vwii = 1; } @@ -252,6 +282,70 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option Ok(()) } +pub fn edit_wad(input: &str, output: &Option, edits: &WadModifications) -> Result<()> { + let in_path = Path::new(input); + if !in_path.exists() { + bail!("Source directory \"{}\" does not exist.", in_path.display()); + } + let out_path = if output.is_some() { + PathBuf::from(output.clone().unwrap()).with_extension("wad") + } else { + in_path.to_path_buf() + }; + let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?; + // Parse possible edits and perform each one provided. Unlike WiiPy, I don't need a state bool + // here! Wow! + let mut changes_summary: Vec = Vec::new(); + // These are joined, because that way if both are selected we only need to set the TID (and by + // extension, re-encrypt the Title Key) a single time. + if edits.tid.is_some() || edits.r#type.is_some() { + let tid_high = if edits.r#type.is_some() { + let new_type = match edits.r#type.clone().unwrap().to_ascii_lowercase().as_str() { + "system" => tmd::TitleType::System, + "channel" => tmd::TitleType::Channel, + "systemchannel" => tmd::TitleType::SystemChannel, + "gamechannel" => tmd::TitleType::GameChannel, + "dlc" => tmd::TitleType::DLC, + "hiddenchannel" => tmd::TitleType::HiddenChannel, + _ => bail!("The specified title type \"{}\" is invalid! Try --help to see valid types.", edits.r#type.clone().unwrap()), + }; + changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd.title_type()?, new_type)); + Vec::from_hex(format!("{:08X}", new_type as u32))? + } else { + title.tmd.title_id()[0..4].to_vec() + }; + let tid_low = if edits.tid.is_some() { + let re = RegexBuilder::new(r"^[a-z0-9!@#$%^&*]{4}$").case_insensitive(true).build()?; + let new_tid_low = edits.tid.clone().unwrap().to_ascii_uppercase(); + if !re.is_match(&new_tid_low) { + bail!("The specified Title ID is not valid! The new Title ID must be 4 characters and include only letters, numbers, and the special characters \"!@#$%&*\"."); + } + changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid_low).to_ascii_uppercase())); + Vec::from_hex(hex::encode(new_tid_low))? + } else { + title.tmd.title_id()[4..8].to_vec() + }; + let new_tid: Vec = tid_high.iter().chain(&tid_low).copied().collect(); + title.set_title_id(new_tid.try_into().unwrap())?; + } + if edits.ios.is_some() { + let new_ios = edits.ios.unwrap(); + if new_ios < 3 { + bail!("The specified IOS version is not valid! The new IOS version must be between 3 and 255.") + } + let new_ios_tid = <[u8; 8]>::from_hex(format!("00000001{:08X}", new_ios))?; + changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd.ios_tid().last().unwrap(), new_ios)); + title.tmd.set_ios_tid(new_ios_tid)?; + } + title.fakesign()?; + fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?; + println!("Successfully edited WAD file \"{}\"!\nSummary of changes:", out_path.display()); + for change in &changes_summary { + println!(" - {}", change); + } + Ok(()) +} + pub fn pack_wad(input: &str, output: &str) -> Result<()> { let in_path = Path::new(input); if !in_path.exists() { @@ -295,10 +389,11 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> { footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?; } // Iterate over expected content and read it into a content region. - let mut content_region = content::ContentRegion::new(tmd.content_records.clone())?; - for content in tmd.content_records.borrow().iter() { - let data = fs::read(format!("{}/{:08X}.app", in_path.display(), content.index)).with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", content.index))?; - content_region.set_content(&data, content.index as usize, None, None, tik.dec_title_key()) + let mut content_region = content::ContentRegion::new(Rc::clone(&tmd.content_records))?; + let content_indexes: Vec = tmd.content_records.borrow().iter().map(|record| record.index).collect(); + for index in content_indexes { + let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index)).with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", index))?; + content_region.set_content(&data, index as usize, None, None, tik.dec_title_key()) .with_context(|| "Failed to load content into the ContentRegion.")?; } // Ensure that the TMD is modified with our potentially updated content records. @@ -317,7 +412,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> { } } fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?; - println!("WAD file packed!"); + println!("Successfully packed WAD file to \"{}\"!", out_path.display()); Ok(()) } @@ -414,7 +509,7 @@ pub fn unpack_wad(input: &str, output: &str) -> Result<()> { } let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?; let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?; - let tid = hex::encode(title.tmd.title_id); + let tid = hex::encode(title.tmd.title_id()); // Create output directory if it doesn't exist. let out_path = Path::new(output); if !out_path.exists() { @@ -435,6 +530,6 @@ pub fn unpack_wad(input: &str, output: &str) -> Result<()> { let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records.borrow()[i].content_id))?; fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.content.content_records.borrow()[i].content_id))?; } - println!("WAD file unpacked!"); + println!("Successfully unpacked WAD file to \"{}\"!", out_path.display()); Ok(()) } diff --git a/src/title/mod.rs b/src/title/mod.rs index 04ca5cc..ed8420c 100644 --- a/src/title/mod.rs +++ b/src/title/mod.rs @@ -171,13 +171,6 @@ impl Title { Ok(title_size) } - /// Gets the installed size of the title, in blocks. Use the optional parameter "absolute" to - /// set whether shared content should be included in this total or not. - pub fn title_size_blocks(&self, absolute: Option) -> Result { - let title_size_bytes = self.title_size(absolute)?; - Ok((title_size_bytes as f64 / 131072.0).ceil() as usize) - } - /// Verifies entire certificate chain, and then the TMD and Ticket. Returns true if the title /// is entirely valid, or false if any component of the verification fails. pub fn verify(&self) -> Result { @@ -194,6 +187,14 @@ impl Title { } Ok(true) } + + /// Sets a new Title ID for the Title. This will re-encrypt the Title Key in the Ticket, since + /// the Title ID is used as the IV for decrypting the Title Key. + pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TitleError> { + self.tmd.set_title_id(title_id)?; + self.ticket.set_title_id(title_id)?; + Ok(()) + } pub fn set_cert_chain(&mut self, cert_chain: cert::CertificateChain) { self.cert_chain = cert_chain; @@ -227,3 +228,8 @@ impl Title { self.meta = meta.to_vec(); } } + +/// Converts bytes to the Wii's storage unit, blocks. +pub fn bytes_to_blocks(size_bytes: usize) -> usize { + (size_bytes as f64 / 131072.0).ceil() as usize +} diff --git a/src/title/nus.rs b/src/title/nus.rs index d0c4f6f..67d9fa5 100644 --- a/src/title/nus.rs +++ b/src/title/nus.rs @@ -83,7 +83,7 @@ pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result = tmd.content_records.borrow().iter().map(|record| { record.content_id }).collect(); let mut contents: Vec> = Vec::new(); for id in content_ids { - contents.push(download_content(tmd.title_id, id, wiiu_endpoint)?); + contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?); } Ok(contents) } diff --git a/src/title/ticket.rs b/src/title/ticket.rs index 2ad84ac..5e7ef95 100644 --- a/src/title/ticket.rs +++ b/src/title/ticket.rs @@ -7,6 +7,7 @@ use std::io::{Cursor, Read, Write}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use sha1::{Sha1, Digest}; use thiserror::Error; +use crate::title::crypto; use crate::title::crypto::decrypt_title_key; #[derive(Debug, Error)] @@ -43,7 +44,7 @@ pub struct Ticket { unknown1: [u8; 1], pub ticket_id: [u8; 8], pub console_id: [u8; 4], - pub title_id: [u8; 8], + title_id: [u8; 8], unknown2: [u8; 2], pub title_version: u16, pub permitted_titles_mask: [u8; 4], @@ -232,4 +233,18 @@ impl Ticket { self.signature_issuer = issuer.try_into().unwrap(); Ok(()) } + + /// Gets the Title ID of the Ticket. + pub fn title_id(&self) -> [u8; 8] { + self.title_id + } + + /// Sets a new Title ID for the Ticket. This will re-encrypt the Title Key, since the Title ID + /// is used as the IV for decrypting the Title Key. + pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TicketError> { + let new_enc_title_key = crypto::encrypt_title_key(self.dec_title_key(), self.common_key_index, title_id, self.is_dev()); + self.title_key = new_enc_title_key; + self.title_id = title_id; + Ok(()) + } } diff --git a/src/title/tmd.rs b/src/title/tmd.rs index abe9a69..e11feda 100644 --- a/src/title/tmd.rs +++ b/src/title/tmd.rs @@ -18,21 +18,27 @@ pub enum TMDError { CannotFakesign, #[error("signature issuer string must not exceed 64 characters (was {0})")] IssuerTooLong(usize), + #[error("invalid IOS Title ID, IOSes must have a Title ID beginning with 00000001 (type 'System')")] + InvalidIOSTitleID, + #[error("invalid IOS version `{0}`, IOS version must be in the range 3-255")] + InvalidIOSVersion(u32), #[error("TMD data contains content record with invalid type `{0}`")] InvalidContentType(u16), + #[error("encountered unknown title type `{0}`")] + InvalidTitleType(String), #[error("TMD data is not in a valid format")] IO(#[from] std::io::Error), } +#[repr(u32)] pub enum TitleType { - System, - Game, - Channel, - SystemChannel, - GameChannel, - DLC, - HiddenChannel, - Unknown, + System = 0x00000001, + Game = 0x00010000, + Channel = 0x00010001, + SystemChannel = 0x00010002, + GameChannel = 0x00010004, + DLC = 0x00010005, + HiddenChannel = 0x00010008, } impl fmt::Display for TitleType { @@ -45,7 +51,6 @@ impl fmt::Display for TitleType { TitleType::GameChannel => write!(f, "GameChannel"), TitleType::DLC => write!(f, "DLC"), TitleType::HiddenChannel => write!(f, "HiddenChannel"), - TitleType::Unknown => write!(f, "Unknown"), } } } @@ -97,9 +102,9 @@ pub struct TMD { 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], + ios_tid: [u8; 8], + title_id: [u8; 8], + title_type: [u8; 4], pub group_id: u16, padding2: [u8; 2], region: u16, @@ -301,19 +306,26 @@ impl TMD { } /// Gets the type of title described by a TMD. - pub fn title_type(&self) -> TitleType { + pub fn title_type(&self) -> Result { match hex::encode(self.title_id)[..8].to_string().as_str() { - "00000001" => TitleType::System, - "00010000" => TitleType::Game, - "00010001" => TitleType::Channel, - "00010002" => TitleType::SystemChannel, - "00010004" => TitleType::GameChannel, - "00010005" => TitleType::DLC, - "00010008" => TitleType::HiddenChannel, - _ => TitleType::Unknown, + "00000001" => Ok(TitleType::System), + "00010000" => Ok(TitleType::Game), + "00010001" => Ok(TitleType::Channel), + "00010002" => Ok(TitleType::SystemChannel), + "00010004" => Ok(TitleType::GameChannel), + "00010005" => Ok(TitleType::DLC), + "00010008" => Ok(TitleType::HiddenChannel), + _ => Err(TMDError::InvalidTitleType(hex::encode(self.title_id)[..8].to_string())), } } + /// Sets the type of title described by a TMD. + pub fn set_title_type(&mut self, new_type: TitleType) -> Result<(), TMDError> { + let new_type: [u8; 4] = (new_type as u32).to_be_bytes(); + self.title_type = new_type; + Ok(()) + } + /// Gets the type of content described by a content record in a TMD. pub fn content_type(&self, index: usize) -> ContentType { // Find possible content indices, because the provided one could exist while the indices @@ -353,8 +365,39 @@ impl TMD { Ok(()) } - /// Gets whether this TMD describes a vWii title or not. + /// Gets whether a TMD describes a vWii title or not. pub fn is_vwii(&self) -> bool { self.is_vwii == 1 } + + /// Gets the Title ID of a TMD. + pub fn title_id(&self) -> [u8; 8] { + self.title_id + } + + /// Sets a new Title ID for a TMD. + pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TMDError> { + self.title_id = title_id; + Ok(()) + } + + /// Gets the Title ID of the IOS required by a TMD. + pub fn ios_tid(&self) -> [u8; 8] { + self.ios_tid + } + + /// Sets the Title ID of the IOS required by a TMD. The Title ID must be in the valid range of + /// IOS versions, from 0000000100000003 to 00000001000000FF. + pub fn set_ios_tid(&mut self, ios_tid: [u8; 8]) -> Result<(), TMDError> { + let tid_high = &ios_tid[0..4]; + if hex::encode(tid_high) != "00000001" { + return Err(TMDError::InvalidIOSTitleID); + } + let ios_version = u32::from_be_bytes(ios_tid[4..8].try_into().unwrap()); + if !(3..=255).contains(&ios_version) { + return Err(TMDError::InvalidIOSVersion(ios_version)); + } + self.ios_tid = ios_tid; + Ok(()) + } } diff --git a/src/title/wad.rs b/src/title/wad.rs index 22b4f06..c3a2761 100644 --- a/src/title/wad.rs +++ b/src/title/wad.rs @@ -68,7 +68,7 @@ impl WADHeader { // Generates a new WADHeader from a populated WADBody object. // Parse the TMD and use that to determine if this is a standard WAD or a boot2 WAD. let tmd = tmd::TMD::from_bytes(&body.tmd).map_err(WADError::TMD)?; - let wad_type = match hex::encode(tmd.title_id).as_str() { + let wad_type = match hex::encode(tmd.title_id()).as_str() { "0000000100000001" => WADType::ImportBoot, _ => WADType::Installable, };