Basic TMD/Ticket structs, Title Key encryption handling

This commit is contained in:
Campbell 2025-03-17 20:05:01 -04:00
parent fcde6831de
commit 522e42a749
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
10 changed files with 613 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -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/

120
Cargo.lock generated Normal file
View File

@ -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"

18
Cargo.toml Normal file
View File

@ -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"

21
src/bin/rustii/main.rs Normal file
View File

@ -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");
}

5
src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
// lib.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii-lib
pub mod title;

49
src/title/commonkeys.rs Normal file
View File

@ -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<bool>) -> [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]);
}
}

35
src/title/crypto.rs Normal file
View File

@ -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<u8> = 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<aes::Aes128>;
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::<ZeroPadding>(&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<aes::Aes128>;
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::<ZeroPadding>(&mut title_key, 16).unwrap();
title_key
}

8
src/title/mod.rs Normal file
View File

@ -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;

155
src/title/ticket.rs Normal file
View File

@ -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<Self, std::io::Error> {
let mut buf = Cursor::new(data);
let signature_type = buf.read_u32::<BigEndian>()?;
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::<BigEndian>()?;
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<TitleLimit> = Vec::new();
for _ in 0..8 {
let limit_type = buf.read_u32::<BigEndian>()?;
let limit_max = buf.read_u32::<BigEndian>()?;
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<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
buf.write_u32::<BigEndian>(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::<BigEndian>(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::<BigEndian>(limit.limit_type)?;
buf.write_u32::<BigEndian>(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)
}
}

185
src/title/tmd.rs Normal file
View File

@ -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<ContentRecord>,
}
impl TMD {
pub fn from_bytes(data: &[u8]) -> Result<Self, std::io::Error> {
let mut buf = Cursor::new(data);
let signature_type = buf.read_u32::<BigEndian>()?;
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::<BigEndian>()?;
// Same here...
let mut padding2 = [0u8; 2];
buf.read_exact(&mut padding2)?;
let region = buf.read_u16::<BigEndian>()?;
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::<BigEndian>()?;
let title_version = buf.read_u16::<BigEndian>()?;
let num_contents = buf.read_u16::<BigEndian>()?;
let boot_index = buf.read_u16::<BigEndian>()?;
let minor_version = buf.read_u16::<BigEndian>()?;
// 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::<BigEndian>()?;
let index = buf.read_u16::<BigEndian>()?;
let content_type = buf.read_u16::<BigEndian>()?;
let content_size = buf.read_u64::<BigEndian>()?;
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<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
buf.write_u32::<BigEndian>(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::<BigEndian>(self.group_id)?;
buf.write_all(&self.padding2)?;
buf.write_u16::<BigEndian>(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::<BigEndian>(self.access_rights)?;
buf.write_u16::<BigEndian>(self.title_version)?;
buf.write_u16::<BigEndian>(self.num_contents)?;
buf.write_u16::<BigEndian>(self.boot_index)?;
buf.write_u16::<BigEndian>(self.minor_version)?;
// Iterate over content records and write out content record data.
for content in &self.content_records {
buf.write_u32::<BigEndian>(content.content_id)?;
buf.write_u16::<BigEndian>(content.index)?;
buf.write_u16::<BigEndian>(content.content_type)?;
buf.write_u64::<BigEndian>(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);
}
}