From 884657268b8cca8d3ebf25e84cf97189ac99694e Mon Sep 17 00:00:00 2001 From: NinjaCheetah <58050615+NinjaCheetah@users.noreply.github.com> Date: Tue, 8 Apr 2025 22:27:42 -0400 Subject: [PATCH] Added single content download command to rustii CLI --- src/bin/rustii/main.rs | 3 ++ src/bin/rustii/title/nus.rs | 78 ++++++++++++++++++++++++++++++++++++- src/title/nus.rs | 2 - 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/bin/rustii/main.rs b/src/bin/rustii/main.rs index 9f83be5..fdd0519 100644 --- a/src/bin/rustii/main.rs +++ b/src/bin/rustii/main.rs @@ -93,6 +93,9 @@ fn main() -> Result<()> { }, Some(Commands::Nus { command }) => { match command { + title::nus::Commands::Content { tid, cid, version, output, decrypt} => { + title::nus::download_content(tid, cid, version, output, decrypt)? + }, title::nus::Commands::Ticket { tid, output } => { title::nus::download_ticket(tid, output)? }, diff --git a/src/bin/rustii/title/nus.rs b/src/bin/rustii/title/nus.rs index 0fe99db..274352e 100644 --- a/src/bin/rustii/title/nus.rs +++ b/src/bin/rustii/title/nus.rs @@ -7,12 +7,29 @@ use std::{str, fs}; use std::path::PathBuf; use anyhow::{bail, Context, Result}; use clap::{Subcommand, Args}; -use rustii::title::{cert, content, nus, ticket, tmd}; +use sha1::{Sha1, Digest}; +use rustii::title::{cert, content, crypto, nus, ticket, tmd}; use rustii::title; #[derive(Subcommand)] #[command(arg_required_else_help = true)] pub enum Commands { + /// Download specific content from the NUS + Content { + /// The Title ID that the content belongs to + tid: String, + /// The Content ID of the content (in hex format, like 000000xx) + cid: String, + /// The title version that the content belongs to (only required for decryption) + #[arg(short, long)] + version: Option, + /// An optional content file name; defaults to (.app) + #[arg(short, long)] + output: Option, + /// Decrypt the content + #[arg(short, long)] + decrypt: bool, + }, /// Download a Ticket from the NUS Ticket { /// The Title ID that the Ticket is for @@ -56,6 +73,65 @@ pub struct TitleOutputType { wad: Option, } +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!"); + } + let cid = u32::from_str_radix(cid, 16).with_context(|| "The specified Content ID is invalid!")?; + let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap(); + let content = nus::download_content(tid, cid, true).with_context(|| "Content data could not be downloaded.")?; + let out_path = if output.is_some() { + PathBuf::from(output.clone().unwrap()) + } else if *decrypt { + PathBuf::from(format!("{:08X}.app", cid)) + } else { + PathBuf::from(format!("{:08X}", cid)) + }; + if *decrypt { + // 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!")? + } else { + bail!("You must specify the title version that the requested content belongs to for decryption!"); + }; + let tmd_res = &nus::download_tmd(tid, Some(version), true); + println!(" - Downloading TMD..."); + let tmd = match tmd_res { + Ok(tmd) => tmd::TMD::from_bytes(tmd)?, + Err(_) => bail!("No TMD could be found for the specified version! Check the version and try again.") + }; + println!(" - Downloading Ticket..."); + let tik_res = &nus::download_ticket(tid, true); + let tik = match tik_res { + Ok(tik) => ticket::Ticket::from_bytes(tik)?, + Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.") + }; + println!(" - Decrypting content..."); + let (content_hash, content_size, content_index) = tmd.content_records.iter() + .find(|record| record.content_id == cid) + .map(|record| (record.content_hash, record.content_size, record.index)) + .with_context(|| "No matching content record could be found. Please make sure the requested content is from the specified title version.")?; + let mut content_dec = crypto::decrypt_content(&content, tik.dec_title_key(), content_index); + content_dec.resize(content_size as usize, 0); + // Verify the content's hash before saving it. + let mut hasher = Sha1::new(); + hasher.update(&content_dec); + let result = hasher.finalize(); + if result[..] != content_hash { + bail!("The content's hash did not match the expected value. (Hash was {}, but the expected hash is {}.)", + hex::encode(result), hex::encode(content_hash)); + } + fs::write(&out_path, content_dec).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?; + } else { + // If we're not decrypting, just write the file out and call it a day. + fs::write(&out_path, content).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))? + } + println!("Successfully downloaded content with Content ID {:08X} to file \"{}\"!", cid, out_path.display()); + Ok(()) +} + pub fn download_ticket(tid: &str, output: &Option) -> Result<()> { println!("Downloading Ticket for title {tid}..."); if tid.len() != 16 { diff --git a/src/title/nus.rs b/src/title/nus.rs index be284ab..7791199 100644 --- a/src/title/nus.rs +++ b/src/title/nus.rs @@ -10,8 +10,6 @@ use thiserror::Error; use crate::title::{cert, tmd, ticket, content}; use crate::title; -use sha1::{Sha1, Digest}; - const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/"; const WII_U_NUS_ENDPOINT: &str = "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/";