cat_dev/mion/
firmware.rs

1//! APIs for interacting with MION Firmware Files.
2
3use aes::{Aes256, cipher::KeyInit};
4use cipher::{BlockDecryptMut, BlockEncryptMut, BlockSizeUser, block_padding::NoPadding};
5use ecb::{Decryptor, Encryptor};
6use miette::Diagnostic;
7use std::fmt::{Display, Formatter, Result as FmtResult};
8use thiserror::Error;
9
10type Aes256EcbEnc = Encryptor<Aes256>;
11type Aes256EcbDec = Decryptor<Aes256>;
12
13/// The derived AES key used for firmware encryption/decryption.
14const STOCK_FW_KEY: [u8; 32] = [
15	0xA9, 0xFE, 0x4F, 0x78, 0x26, 0x3A, 0xE0, 0xE0, 0xC8, 0xFF, 0x39, 0x95, 0xE4, 0x43, 0x1F, 0x74,
16	0x87, 0x9D, 0x1C, 0x67, 0x04, 0x29, 0xBC, 0x79, 0xA5, 0xE3, 0x35, 0x47, 0x8A, 0x60, 0x3B, 0x22,
17];
18
19/// The MION actually contains multiple types of firmware, this enum encodes
20/// those types.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub enum MionFirmwareType {
23	/// The MION has an FPGA array, these firmware files target those.
24	Fpga,
25	/// The MION has an IPL chip similar to the gamecube, these firmware files
26	/// target that chip.
27	Ipl,
28	/// The root firmware for the main MION board.
29	Mion,
30}
31impl Display for MionFirmwareType {
32	fn fmt(&self, fmt: &mut Formatter<'_>) -> FmtResult {
33		match *self {
34			Self::Fpga => write!(fmt, "fpga"),
35			Self::Ipl => write!(fmt, "ipl"),
36			Self::Mion => write!(fmt, "fw"),
37		}
38	}
39}
40
41/// A MION Firmware File.
42///
43/// The MION itself has 3 types of firmware:
44///
45/// - "fpga.${version}.bin" aka [`MionFirmwareType::Fpga`]
46/// - "fw.${version}.bin" aka [`MionFirmwareType::Ipl`]
47/// - "ipl.${version}.bin" aka [`MionFirmwareType::Mion`]
48///
49/// Each of these are used for separate part of firmware, but all follow the
50/// same format. An encrypted blob, followed by a 4 byte version string,
51/// followed by a checksum.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct MionFirmwareFile {
54	/// The actual firmware contents, this is *decrypted* when represented in
55	/// memory here.
56	data: Vec<u8>,
57	/// The type of firmware this is.
58	fw_type: MionFirmwareType,
59	/// The 4 bytes that represent versions.
60	///
61	/// Each firmware type treats these bytes _slightly_
62	/// differently. Thanks Nintendo.
63	version_bytes: [u8; 4],
64	/// I have no clue what this byte is.
65	unk_byte: u8,
66	/// The checksum byte of everything else in the file. Very last byte.
67	checksum: u8,
68}
69
70impl MionFirmwareFile {
71	/// Attempt to parse a firmware file that would be uploaded to the MION
72	/// board.
73	///
74	/// These packages would be manually uploaded to:
75	/// `http://<mionip>/update.cgi`.
76	///
77	/// ## Errors
78	///
79	/// This function will error if the original MION firmwares would throw an
80	/// error at this package, or would otherwise corrupt when installing it.
81	/// This consistetues the following checks:
82	///
83	/// - Validating the file is at least 0x26 (38) bytes long
84	///   (*note: the mion only checks for 0x16 but will fail because the AES
85	///   block size is 32 bytes.*)
86	/// - The file has an invalid checksum (the checksum is the last byte in
87	///   the file.)
88	/// - If uploading a firmware type of [`MionFirmwareType::Mion`], the last
89	///   byte in the version must be 0x00, if it's not the upload will corrupt
90	///   heavily as the programmers treated it as a load-bearing NUL terminator
91	///   which is required for strings in C (the language the FW was written
92	///   in).
93	/// - If the main contents of the file were not encrypted with the common AES
94	///   key.
95	/// - If the decrypted contents do not end with the correct signature, this is
96	///   `PWI-SS_FW_IMAGE` for [`MionFirmwareType::Mion`] &
97	///   [`MionFirmwareType::Ipl`], and `PWI-SS_FP_IMAGE` for
98	///   [`MionFirmwareType::FPGA`].
99	pub fn parse(
100		firmware: &[u8],
101		firmware_type: MionFirmwareType,
102	) -> Result<Self, MionFirmwareAPIError> {
103		let firmware_length = firmware.len();
104		if firmware_length < 0x26 {
105			return Err(MionFirmwareAPIError::TooSmall(firmware_length));
106		}
107		let chksum_in_file = firmware[firmware_length - 1];
108		let got_chksum = calculate_checksum(firmware);
109		if chksum_in_file != got_chksum {
110			return Err(MionFirmwareAPIError::BadChecksum(
111				chksum_in_file,
112				got_chksum,
113			));
114		}
115		// This check is uniquely ours, the MION does not have this.
116		//
117		// But this really fucks up a lot, because it's a load bearing NUL
118		// terminator.
119		if matches!(firmware_type, MionFirmwareType::Mion) && firmware[firmware_length - 3] != 0x00
120		{
121			return Err(MionFirmwareAPIError::MissingNULTerminator(
122				firmware[firmware_length - 3],
123			));
124		}
125		let decrypted = raw_decrypt(&firmware[..firmware_length - 6])?;
126
127		let expected_footer = match firmware_type {
128			MionFirmwareType::Fpga => b"PWI-SS_FP_IMAGE\0",
129			_ => b"PWI-SS_FW_IMAGE\0",
130		};
131		if !decrypted.ends_with(expected_footer) {
132			return Err(MionFirmwareAPIError::MissingSignature);
133		}
134
135		Ok(Self {
136			data: decrypted,
137			fw_type: firmware_type,
138			version_bytes: [
139				firmware[firmware_length - 6],
140				firmware[firmware_length - 5],
141				firmware[firmware_length - 4],
142				firmware[firmware_length - 3],
143			],
144			unk_byte: firmware[firmware_length - 2],
145			checksum: chksum_in_file,
146		})
147	}
148
149	/// Get the checksum for this firmware file.
150	#[must_use]
151	pub const fn checksum(&self) -> u8 {
152		self.checksum
153	}
154
155	/// Get the underlying decrypted contents of this firmware file.
156	#[must_use]
157	pub const fn contents(&self) -> &Vec<u8> {
158		&self.data
159	}
160
161	/// Get the type of firmware this is.
162	#[must_use]
163	pub const fn firmware_type(&self) -> MionFirmwareType {
164		MionFirmwareType::Mion
165	}
166
167	/// Get the bytes that would need to be written to a file, and uploaded to
168	/// `/update.cgi` on the MION to deploy this firmware file.
169	#[allow(
170		// This function can't actually panic, it's just raw_encrypt doesn't know
171		// we've pre-validated the length by decrypting it successfully and not
172		// offering mutable APIs.
173		clippy::missing_panics_doc,
174	)]
175	#[must_use]
176	pub fn get_deployable_firmware_data(&self) -> Vec<u8> {
177		let mut encrypted =
178			raw_encrypt(&self.data).expect("We validate the block size at parse time.");
179		encrypted.extend_from_slice(&self.version_bytes);
180		encrypted.push(self.unk_byte);
181		encrypted.push(self.checksum);
182		encrypted
183	}
184
185	/// Get the version for the actual firmware file.
186	///
187	/// Each firmware type has a slightly different format than the rest of the
188	/// firmware types. An example of known following firmware types are:
189	///
190	/// - [`MionFirmwareType::Mion`] -> `0.00.14.80`
191	/// - [`MionFirmwareType::Fpga`] -> `13052071`
192	/// - [`MionFirmwareType::Ipl`] -> `0.5`
193	#[must_use]
194	pub fn version(&self) -> String {
195		match self.fw_type {
196			MionFirmwareType::Mion => format!(
197				"0.{:02}.{}.{}",
198				self.version_bytes[0], self.version_bytes[1], self.version_bytes[2],
199			),
200			MionFirmwareType::Fpga => format!(
201				"{:02X}{:02X}{:02X}{:02X}",
202				self.version_bytes[3],
203				self.version_bytes[2],
204				self.version_bytes[1],
205				self.version_bytes[0],
206			),
207			MionFirmwareType::Ipl => format!(
208				"{}.{}",
209				u16::from_le_bytes([self.version_bytes[0], self.version_bytes[1]]),
210				u16::from_le_bytes([self.version_bytes[2], self.version_bytes[3]]),
211			),
212		}
213	}
214}
215
216/// Errors related to handling mion firmwares.
217#[derive(Error, Diagnostic, Debug, PartialEq, Eq)]
218pub enum MionFirmwareAPIError {
219	/// You attempted to encrypt data that we could not encrypt.
220	#[error(
221		"We could not encrypt your data, because it was not padded to the correct length, expected a block size of: {0}"
222	)]
223	#[diagnostic(code(cat_dev::api::mion::firmware::bad_decrypted_data_length))]
224	BadDecryptedDataLength(usize),
225	/// You attempted to decrypt data that we could not decrypt.
226	#[error(
227		"We could not decrypt your data, because it was not padded to the correct length, expected a block size of: {0}"
228	)]
229	#[diagnostic(code(cat_dev::api::mion::firmware::bad_encrypted_data_length))]
230	BadEncryptedDataLength(usize),
231	/// The MION Firmware files end with a final byte that acts as a checksum
232	/// to validate the content before it was correct. Your checksum was not
233	/// correct.
234	#[error(
235		"The MION Firmware file you provided had an invalid checksum, we expected: {1:02x}, but got: {0:02x}"
236	)]
237	#[diagnostic(code(cat_dev::api::mion::firmware::bad_checksum))]
238	BadChecksum(u8, u8),
239	/// All MION Firmware files must be at a minimum 0x26 bytes long.
240	///
241	/// This covers a single AES-256 block (32 bytes), plus the 6 byte footer
242	/// that they contain.
243	#[error(
244		"The MION Firmware file provided was too small, it must be at least 0x26 bytes long, was {0:02x}"
245	)]
246	#[diagnostic(code(cat_dev::api::mion::firmware::too_small))]
247	TooSmall(usize),
248	/// The MION Firmware version string must end with a `0x00`, as this is a
249	/// load-bearing NUL terminator for many parts of the firmware.
250	#[error(
251		"The Version String for MION Firmware Files Typed 'MION', must have their version bytes end with a NUL terminator (0x00) due to an oversight in programming. Your file ended with: ({0:02x})"
252	)]
253	#[diagnostic(code(cat_dev::api::mion::firmware::missing_nul_terminator))]
254	MissingNULTerminator(u8),
255	/// All MION FW files must end with:
256	///
257	/// - `PWI-SS_FW_IMAGE` for IPL/MION firmware types
258	/// - `PWI-SS_FP_IMAGE` for FPGA firmware types.
259	///
260	/// If they do not, they are immediately considered invalid.
261	#[error(
262		"While validating the decrypted contents of your FW we were not able to identify the required ending bytes, this firmware is corrupt."
263	)]
264	#[diagnostic(code(cat_dev::api::mion_fw::missing_signature))]
265	MissingSignature,
266}
267
268/// Calculate a checksum for a given _encrypted_ blob.
269///
270/// The checksum present as the last byte in the file takes in all the content
271/// before the final byte of the encrypted file, and spits out a single `u8`
272/// that represents the final checksum of the firmware.
273///
274/// ## Panics
275///
276/// This function should never panic, however there is an `expect` incase math
277/// ever fundamenetally breaks and performing a `& 0xFF` returns us _more_ than
278/// the last 8 bits.
279fn calculate_checksum(encrypted_blob: &[u8]) -> u8 {
280	let mut chksum = 0_u32;
281	// For every byte except the last byte (CHKSUM), use it's value to calculate
282	// the checksum.
283	for byte in encrypted_blob.iter().take(encrypted_blob.len() - 1) {
284		chksum = chksum.wrapping_add((*byte).into());
285	}
286	while chksum & 0xFFFF_FF00_u32 != 0 {
287		chksum = (chksum & 0xFF) + (chksum >> 8);
288	}
289	u8::try_from(!chksum & 0xFF)
290		.expect("&0xFF did not just give us the last 8 bits??? is math broken?")
291}
292
293/// Encrypt a firmware files contents so it can be uploaded.
294///
295/// ## Errors
296///
297/// If there is a problem encrypting your data. See error codes
298/// from the [`aes`], and [`ecb`] crates.
299#[doc(hidden)]
300pub fn raw_encrypt(file_contents: &[u8]) -> Result<Vec<u8>, MionFirmwareAPIError> {
301	let encryptor = Aes256EcbEnc::new(&STOCK_FW_KEY.into());
302	let mut decrypted = vec![
303		0x0;
304		Aes256EcbEnc::block_size()
305			* (file_contents.len() / Aes256EcbEnc::block_size() + 1)
306	];
307	let actual_len = encryptor
308		.encrypt_padded_b2b_mut::<NoPadding>(file_contents, &mut decrypted)
309		.map_err(|_| MionFirmwareAPIError::BadDecryptedDataLength(Aes256EcbEnc::block_size()))?
310		.len();
311	decrypted.truncate(actual_len);
312	Ok(decrypted)
313}
314
315/// Decrypt a firmware files contents so it can be uploaded.
316///
317/// ## Errors
318///
319/// If your data is not the correct size to be decrypted.
320#[doc(hidden)]
321pub fn raw_decrypt(file_contents: &[u8]) -> Result<Vec<u8>, MionFirmwareAPIError> {
322	let decryptor = Aes256EcbDec::new(&STOCK_FW_KEY.into());
323
324	decryptor
325		.decrypt_padded_vec_mut::<NoPadding>(file_contents)
326		.map_err(|_| MionFirmwareAPIError::BadEncryptedDataLength(Aes256EcbDec::block_size()))
327}
328
329#[cfg(test)]
330mod unit_tests {
331	use super::*;
332	use std::path::PathBuf;
333
334	#[must_use]
335	pub fn get_test_data_path(relative_to_test_data: &str) -> PathBuf {
336		let mut final_path = PathBuf::from(
337			std::env::var("CARGO_MANIFEST_DIR")
338				.expect("Failed to read `CARGO_MANIFEST_DIR` to locate t est files!"),
339		);
340		final_path.push("src");
341		final_path.push("mion");
342		final_path.push("test-data");
343		for file_part in relative_to_test_data.split('/') {
344			if file_part.is_empty() {
345				continue;
346			}
347			final_path.push(file_part);
348		}
349		final_path
350	}
351
352	#[test]
353	pub fn can_decrypt_and_reencrypt_fw() {
354		for (source_file_name, dest_file_name) in vec![
355			("/fpga.13052071.bin", "/fpga.13052071_d.bin"),
356			("/fw.0.00.14.80.bin", "/fw.0.00.14.80_d.bin"),
357			("/ipl.0.5.bin", "/ipl.0.5_d.bin"),
358		] {
359			let encrypted_path = get_test_data_path(source_file_name);
360			let decrypted_path = get_test_data_path(dest_file_name);
361
362			let full_encrypted_contents =
363				std::fs::read(&encrypted_path).expect("Failed to read encrypted file to decrypt!");
364			let decrypted =
365				raw_decrypt(&full_encrypted_contents[..]).expect("Failed to decrypt data!");
366			let expected_decrypted_contents = std::fs::read(&decrypted_path)
367				.expect("Failed to read expected decrypted contents!");
368
369			assert_eq!(
370				decrypted.len(),
371				expected_decrypted_contents.len(),
372				"Decrypted data length did not match expected decrypted data length, file: {}",
373				encrypted_path.display(),
374			);
375			for (idx, byte) in decrypted.iter().enumerate() {
376				if *byte != expected_decrypted_contents[idx] {
377					panic!(
378						"Decrypted Byte at Location: {idx} did not match expected contents! (total: {})",
379						decrypted.len(),
380					);
381				}
382			}
383
384			let re_encrypted = raw_encrypt(&decrypted).expect("Failed to encrypt firmware!");
385			assert_eq!(
386				re_encrypted.len(),
387				full_encrypted_contents.len(),
388				"Encrypted data length did not match expected encrypted data length!",
389			);
390			for (idx, byte) in re_encrypted.iter().enumerate() {
391				if *byte != full_encrypted_contents[idx] {
392					panic!(
393						"Re-Encrypted Byte at Location: {idx} did not match expected contents! (total: {})",
394						re_encrypted.len(),
395					);
396				}
397			}
398		}
399	}
400
401	#[test]
402	pub fn correctly_calculates_checksums() {
403		// These test constants were all taken from real life MION files.
404		//
405		// You can see what the MION would "expect" by connecting to a running
406		// MION device with telnet (the username is `mion`, and password is
407		// `/Multi_I/O_Network/`, the same as the HTTP interface.) Then run the
408		// following commands:
409		//
410		// ```bash
411		// INET> enable_telnet_trace
412		// Telnet port is ENABLED to transmit debug logs...
413		//
414		// INET> enable_debug SATA_H|SATA_D|FPGA|PCIE|EEPROM|HTTP|FWUPD|FPUPD|BIOS|KERNEL
415		// enable_debug SATA_H|SATA_D|FPGA|PCIE|EEPROM|HTTP|FWUPD|FPUPD|BIOS|KERNEL modules are enabled to produce debug logs...
416		// ```
417		//
418		// Then upload a firmware file with a purposefully bad checksum (the last
419		// byte in the file). And in your existing telnet session run:
420		//
421		// ```bash
422		// INET> task_trace
423		// [...snipped...]
424		// 9, TS - 191574, Msg - *** ERR: [HTTP]  >Checck sum : Calc, Image 21, 20
425		//
426		// 9, TS - 193977, Msg - === INF: [HTTP]  >_http_update_fw_html Enter, seq = -2, Mode = Firmware
427		// ```
428		//
429		// The numbers will most likely be different at the beginning but what we
430		// care about is the: `Checck sum : Calc, Image 21, 20` line. In this case
431		// it's letting us know it calculated a checksum of 0x21, but the file had
432		// a checksum of 0x20. So it would have showed the error page.
433		//
434		// You can use this to validate the output of any file.
435
436		for (file_path, footer_data, name, expected_value) in vec![
437			(
438				"/fw.0.00.14.80.bin",
439				vec![0x00_u8, 0x0E, 0x50, 0x00, 0xA1, 0x00],
440				"$encrypted.0.14.80.0.$0xA1",
441				0x21_u8,
442			),
443			(
444				"/fw.0.00.14.80.bin",
445				vec![0x01_u8, 0x0E, 0x50, 0x00, 0xA1, 0x00],
446				"$encrypted.1.14.80.0.$0xA1",
447				0x20_u8,
448			),
449			(
450				"/fw.0.00.14.80.bin",
451				vec![0xFF_u8, 0x0E, 0x50, 0x00, 0xA1, 0x00],
452				"$encrypted.255.14.80.0.$0xA1",
453				0x21_u8,
454			),
455			(
456				"/fw.0.00.14.80.bin",
457				vec![0xCF_u8, 0x0E, 0x50, 0x00, 0xA1, 0x00],
458				"$encrypted.207.14.80.0.$0xA1",
459				0x51_u8,
460			),
461		] {
462			let mut encrypted_contents = std::fs::read(get_test_data_path(file_path))
463				.expect("Failed to read test data file!");
464			encrypted_contents.extend(footer_data);
465			assert_eq!(
466				calculate_checksum(&encrypted_contents),
467				expected_value,
468				"Checksum did not match for named contents: {name}! Please check checksum code!",
469			);
470		}
471	}
472
473	#[test]
474	pub fn can_successfully_parse_real_fw_file() {
475		let mut mion_fw = std::fs::read(get_test_data_path("/fw.0.00.14.80.bin"))
476			.expect("Failed to read encrypted MION FW!");
477		let mut ipl_fw = std::fs::read(get_test_data_path("/ipl.0.5.bin"))
478			.expect("Failed to read encrypted IPL FW!");
479		let mut fpga_fw = std::fs::read(get_test_data_path("/fpga.13052071.bin"))
480			.expect("Failed to read encrypted FPGA FW!");
481		// Append the real life footers that would've been on these files.
482		mion_fw.extend([0x00, 0x0E, 0x50, 0x00, 0xA1, 0x21]);
483		ipl_fw.extend([0x00, 0x00, 0x05, 0x00, 0xA1, 0x3E]);
484		fpga_fw.extend([0x71, 0x20, 0x05, 0x13, 0xA2, 0xBF]);
485
486		let parsed_mion = MionFirmwareFile::parse(&mion_fw, MionFirmwareType::Mion)
487			.expect("Failed to parse MION firmware!");
488		let parsed_ipl = MionFirmwareFile::parse(&ipl_fw, MionFirmwareType::Ipl)
489			.expect("Failed to parse IPL firmware!");
490		let parsed_fpga = MionFirmwareFile::parse(&fpga_fw, MionFirmwareType::Fpga)
491			.expect("Failed to parse FPGA firmware!");
492
493		assert_eq!(parsed_mion.version(), "0.00.14.80");
494		assert_eq!(parsed_ipl.version(), "0.5");
495		assert_eq!(parsed_fpga.version(), "13052071");
496	}
497}