Ported all NUS download functions from libWiiPy and corresponding CLI commands

Also adds the basics of U8 archive packing/unpacking, however they are not in a usable state yet and there are no working CLI commands associated with them.
This commit is contained in:
2025-04-08 20:47:35 -04:00
parent e55edc10fd
commit be9148fcfa
13 changed files with 2126 additions and 33 deletions

View File

@@ -6,6 +6,7 @@
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use sha1::{Sha1, Digest};
use thiserror::Error;
use crate::title::content::ContentError::MissingContents;
use crate::title::tmd::ContentRecord;
use crate::title::crypto;
@@ -13,6 +14,8 @@ use crate::title::crypto;
pub enum ContentError {
#[error("requested index {index} is out of range (must not exceed {max})")]
IndexOutOfRange { index: usize, max: usize },
#[error("expected {required} contents based on content records but found {found}")]
MissingContents { required: usize, found: usize },
#[error("content with requested Content ID {0} could not be found")]
CIDNotFound(u32),
#[error("content's hash did not match the expected value (was {hash}, expected {expected})")]
@@ -70,6 +73,19 @@ impl ContentRegion {
contents,
})
}
/// Creates a ContentRegion instance that can be used to parse and edit content stored in a
/// digital Wii title from a vector of contents and the ContentRecords from a TMD.
pub fn from_contents(contents: Vec<Vec<u8>>, content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
if contents.len() != content_records.len() {
return Err(MissingContents { required: content_records.len(), found: contents.len()});
}
let mut content_region = Self::new(content_records)?;
for i in 0..contents.len() {
content_region.load_enc_content(&contents[i], content_region.content_records[i].index as usize)?;
}
Ok(content_region)
}
/// Creates a ContentRegion instance from the ContentRecords of a TMD that contains no actual
/// content. This can be used to load existing content from files.
@@ -77,9 +93,8 @@ impl ContentRegion {
let content_region_size: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum();
let content_region_size = content_region_size as u32;
let num_contents = content_records.len() as u16;
let content_start_offsets: Vec<u64> = Vec::new();
let mut contents: Vec<Vec<u8>> = Vec::new();
contents.resize(num_contents as usize, Vec::new());
let content_start_offsets: Vec<u64> = vec![0; num_contents as usize];
let contents: Vec<Vec<u8>> = vec![Vec::new(); num_contents as usize];
Ok(ContentRegion {
content_records,
content_region_size,
@@ -143,6 +158,16 @@ impl ContentRegion {
Err(ContentError::CIDNotFound(cid))
}
}
/// Loads existing content into the specified index of a ContentRegion instance. This content
/// must be encrypted.
pub fn load_enc_content(&mut self, content: &[u8], index: usize) -> Result<(), ContentError> {
if index >= self.content_records.len() {
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 });
}
self.contents[index] = Vec::from(content);
Ok(())
}
/// Loads existing content into the specified index of a ContentRegion instance. This content
/// must be decrypted and needs to match the size and hash listed in the content record at that

View File

@@ -7,6 +7,7 @@ pub mod cert;
pub mod commonkeys;
pub mod content;
pub mod crypto;
pub mod nus;
pub mod ticket;
pub mod tmd;
pub mod versions;
@@ -52,15 +53,37 @@ impl Title {
let ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(TitleError::Ticket)?;
let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(TitleError::TMD)?;
let content = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records.clone()).map_err(TitleError::Content)?;
let title = Title {
Ok(Title {
cert_chain,
crl: wad.crl(),
ticket,
tmd,
content,
meta: wad.meta(),
})
}
/// Creates a new Title instance from all of its individual components.
pub fn from_parts(cert_chain: cert::CertificateChain, crl: Option<&[u8]>, ticket: ticket::Ticket, tmd: tmd::TMD,
content: content::ContentRegion, meta: Option<&[u8]>) -> Result<Title, TitleError> {
// Create empty vecs for the CRL and meta areas if we weren't supplied with any, as they're
// optional components.
let crl = match crl {
Some(crl) => crl.to_vec(),
None => Vec::new()
};
Ok(title)
let meta = match meta {
Some(meta) => meta.to_vec(),
None => Vec::new()
};
Ok(Title {
cert_chain,
crl,
ticket,
tmd,
content,
meta
})
}
/// Converts a Title instance into a WAD, which can be used to export the Title back to a file.

143
src/title/nus.rs Normal file
View File

@@ -0,0 +1,143 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Implements the functions required for downloading data from the NUS.
use std::str;
use std::io::Write;
use reqwest;
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/";
#[derive(Debug, Error)]
pub enum NUSError {
#[error("the data returned by the NUS is not valid")]
InvalidData,
#[error("the requested Title ID or version could not be found on the NUS")]
NotFound,
#[error("Certificate processing error")]
Certificate(#[from] cert::CertificateError),
#[error("TMD processing error")]
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("Content processing error")]
Content(#[from] content::ContentError),
#[error("an error occurred while assembling a Title from the downloaded data")]
Title(#[from] title::TitleError),
#[error("data could not be downloaded from the NUS")]
Request(#[from] reqwest::Error),
#[error("an error occurred writing NUS data")]
IO(#[from] std::io::Error),
}
/// Downloads the retail certificate chain from the NUS.
pub fn download_cert_chain(wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// To build the certificate chain, we need to download both the TMD and Ticket of a title. For
// the sake of simplicity, we'll use the Wii Menu 4.3U because I already found the required TMD
// and Ticket offsets for it.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = format!("{}0000000100000002/tmd.513", endpoint_url);
let tik_url = format!("{}0000000100000002/cetk", endpoint_url);
let client = reqwest::blocking::Client::new();
let tmd = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
let tik = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
// Assemble the certificate chain.
let mut cert_chain: Vec<u8> = Vec::new();
// Certificate Authority data.
cert_chain.write_all(&tik[0x2A4 + 768..])?;
// Certificate Policy (TMD certificate) data.
cert_chain.write_all(&tmd[0x328..0x328 + 768])?;
// XS (Ticket certificate) data.
cert_chain.write_all(&tik[0x2A4..0x2A4 + 768])?;
Ok(cert_chain)
}
/// Downloads a specified content file from the specified title from the NUS.
pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/<CID>
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let content_url = format!("{}{}/{:08X}", endpoint_url, &hex::encode(title_id), content_id);
let client = reqwest::blocking::Client::new();
let response = client.get(content_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
Ok(response.bytes()?.to_vec())
}
/// Downloads all contents from the specified title from the NUS.
pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<u8>>, NUSError> {
let content_ids: Vec<u32> = tmd.content_records.iter().map(|record| { record.content_id }).collect();
let mut contents: Vec<Vec<u8>> = Vec::new();
for id in content_ids {
contents.push(download_content(tmd.title_id, id, wiiu_endpoint)?);
}
Ok(contents)
}
/// Downloads the Ticket for a specified Title ID from the NUS, if it's available.
pub fn download_ticket(title_id: [u8; 8], wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/cetk.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tik_url = format!("{}{}/cetk", endpoint_url, &hex::encode(title_id));
let client = reqwest::blocking::Client::new();
let response = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tik = ticket::Ticket::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tik.to_bytes().map_err(|_| NUSError::InvalidData)
}
/// Downloads an entire title with all of its content from the NUS and returns a Title instance.
pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<title::Title, NUSError> {
// Download the individual components of a title and then build a title from them.
let cert_chain = cert::CertificateChain::from_bytes(&download_cert_chain(wiiu_endpoint)?)?;
let tmd = tmd::TMD::from_bytes(&download_tmd(title_id, title_version, wiiu_endpoint)?)?;
let tik = ticket::Ticket::from_bytes(&download_ticket(title_id, wiiu_endpoint)?)?;
let content_region = content::ContentRegion::from_contents(download_contents(&tmd, wiiu_endpoint)?, tmd.content_records.clone())?;
let title = title::Title::from_parts(cert_chain, None, tik, tmd, content_region, None)?;
Ok(title)
}
/// Downloads the TMD for a specified Title ID from the NUS.
pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/tmd for latest and
// download/<TID>/tmd.<version> for when a specific version is requested.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = if title_version.is_some() {
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version.unwrap())
} else {
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
};
let client = reqwest::blocking::Client::new();
let response = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tmd = tmd::TMD::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tmd.to_bytes().map_err(|_| NUSError::InvalidData)
}

View File

@@ -50,11 +50,11 @@ impl fmt::Display for TitleType {
#[derive(Debug, Clone)]
pub enum ContentType {
Normal,
Development,
HashTree,
DLC,
Shared,
Normal = 1,
Development = 2,
HashTree = 3,
DLC = 16385,
Shared = 32769,
}
impl fmt::Display for ContentType {
@@ -70,8 +70,8 @@ impl fmt::Display for ContentType {
}
pub enum AccessRight {
AHB,
DVDVideo,
AHB = 0,
DVDVideo = 1,
}
#[derive(Debug, Clone)]
@@ -332,10 +332,7 @@ impl TMD {
/// Gets whether a specified access right is enabled in a TMD.
pub fn check_access_right(&self, right: AccessRight) -> bool {
match right {
AccessRight::AHB => (self.access_rights & (1 << 0)) != 0,
AccessRight::DVDVideo => (self.access_rights & (1 << 1)) != 0,
}
self.access_rights & (1 << right as u8) != 0
}
/// Gets the name of the certificate used to sign a TMD as a string.