Skip to content

Commit

Permalink
feat: add PNG obfuscation feature
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexTMjugador committed Nov 24, 2024
1 parent ce16d56 commit c092f92
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 16 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ Versioning](https://semver.org/spec/v2.0.0.html).

#### Protection

- Texture files can now be protected to make them harder to view outside of
Minecraft via the new file-specific `png_obfuscation` option. This protection
is independent of the already available ZIP layer protection, so it can be
used alongside it or not, and can be applied to a subset of pack files.
- This protection will not work for resource packs targeting Minecraft 1.12.2
or older. By default, PackSquash will force it to be disabled for such
versions via the new `png_obfuscation_incompatibility` quirk.
- PackSquash now adds an extra layer of protection when
`size_increasing_zip_obfuscation` is enabled on a small subset of pack files,
as far as it safe to do so due to the inner workings of Minecraft. (Thanks to
Expand Down
6 changes: 2 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ mmap = { git = "https://github.com/ComunidadAylas/rust-mmap" }
# https://github.com/shssoichiro/oxipng/pull/645
oxipng = { git = "https://github.com/shssoichiro/oxipng", branch = "refactor/mangen-xtask" }

# Use our fork of spng to add a high-level API for customizing the decoder chunk CRC mismatch action.
# Related PR: https://github.com/aloucks/spng-rs/pull/16
spng = { git = "https://github.com/ComunidadAylas/spng-rs", branch = "feat/decoder-crc-action" }

[profile.release]
opt-level = 3
lto = "fat"
Expand Down
1 change: 1 addition & 0 deletions packages/packsquash/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ core-foundation = "0.10.0"
mach2 = "0.4.2"

[dev-dependencies]
futures = { version = "0.3.31", default-features = false, features = ["std"] }
tokio-test = "0.4.4"
pretty_assertions = "1.4.1"

Expand Down
33 changes: 29 additions & 4 deletions packages/packsquash/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,14 @@ pub enum MinecraftQuirk {
/// err on the safe side and only consider Minecraft versions starting from 1.15 to be
/// compatible. Likewise, the quirk will also be applied to Minecraft versions beginning from
/// 24w13a, two snapshots before the incompatibility was really introduced.
OggObfuscationIncompatibility
OggObfuscationIncompatibility,
/// The PNG decoding code of all known Minecraft versions since resource packs were introduced
/// until 1.13 did not support the PackSquash PNG obfuscation techniques, causing the game to
/// show errors when the affected textures were loaded.
///
/// This workaround ensures that no obfuscation is done to any PNG file generated by PackSquash,
/// so at least the pack will work.
PngObfuscationIncompatibility
}

impl MinecraftQuirk {
Expand All @@ -483,7 +490,8 @@ impl MinecraftQuirk {
"bad_entity_eye_layer_texture_transparency_blending"
}
Self::Java8ZipParsing => "java8_zip_parsing",
Self::OggObfuscationIncompatibility => "ogg_obfuscation_incompatibility"
Self::OggObfuscationIncompatibility => "ogg_obfuscation_incompatibility",
Self::PngObfuscationIncompatibility => "png_obfuscation_incompatibility"
}
}
}
Expand Down Expand Up @@ -570,6 +578,9 @@ impl FileOptions {
file_options.working_around_transparent_pixel_colors_change_quirk = global_options
.work_around_minecraft_quirks
.contains(MinecraftQuirk::BadEntityEyeLayerTextureTransparencyBlending);
file_options.minecraft_version_supports_png_obfuscation = !global_options
.work_around_minecraft_quirks
.contains(MinecraftQuirk::PngObfuscationIncompatibility);
}

if let FileOptions::AudioFileOptions(file_options) = &mut self {
Expand Down Expand Up @@ -946,6 +957,12 @@ pub struct PngFileOptions {
///
/// **Default value**: `true`
pub may_be_directory_listed_atlas_sprite: bool,
/// If `true`, the generated PNG files will be mangled in a way so that they will be harder to
/// view outside of Minecraft. The obfuscation technique used is not robust against some
/// scenarios or expert knowledge, but it does not increase file size.
///
/// **Default value**: `false`
pub png_obfuscation: bool,
/// Crate-private option set by the [MinecraftQuirk::GrayscaleImagesGammaMiscorrection]
/// workaround to not reduce color images to grayscale.
///
Expand All @@ -963,7 +980,13 @@ pub struct PngFileOptions {
///
/// **Default value**: `false`
#[serde(skip)]
pub(crate) working_around_transparent_pixel_colors_change_quirk: bool
pub(crate) working_around_transparent_pixel_colors_change_quirk: bool,
/// Crate-private option set by the [MinecraftQuirk::PngObfuscationIncompatibility]
/// workaround to not obfuscate PNG files.
///
/// **Default value**: `true`
#[serde(skip)]
pub(crate) minecraft_version_supports_png_obfuscation: bool
}

impl Default for PngFileOptions {
Expand All @@ -976,9 +999,11 @@ impl Default for PngFileOptions {
skip_alpha_optimizations: false,
downsize_if_single_color: false,
may_be_directory_listed_atlas_sprite: true,
png_obfuscation: false,
working_around_grayscale_reduction_quirk: false,
working_around_color_type_change_quirk: false,
working_around_transparent_pixel_colors_change_quirk: false
working_around_transparent_pixel_colors_change_quirk: false,
minecraft_version_supports_png_obfuscation: true
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions packages/packsquash/src/pack_file/png_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ impl Decoder for OptimizerDecoder {
// do both downsizing and quantization: there are no colors to quantize when downsizing
// is successful, and quantizing is only useful when the image has many colors. Note that
// both of these operations may change the color type (i.e., turn an indexed image to RGBA,
// or vice-versa)
// or vice versa)
let second_pass_image = match (can_change_color_type
&& self.optimization_settings.downsize_if_single_color)
.then(|| {
Expand Down Expand Up @@ -180,7 +180,7 @@ impl Decoder for OptimizerDecoder {
// dithering. This is relevant when the second pass is used as input for the third
// pass.
// - Downsizing yields smaller files except in some extreme edge cases due to the
// heuristics used by the optimized in the third pass being inappropriate. This may be
// heuristics used by the optimizer in the third pass being inappropriate. This may be
// the case in grayscale images, for example.
//
// We can't do much about the first point, but the second point may be handled by
Expand All @@ -196,7 +196,7 @@ impl Decoder for OptimizerDecoder {
// size when quantization is not forced and executing each pass a single time, explicit
// user configuration of the quantization parameters may be needed to achieve the most
// optimal PNG we are capable of. Luckily, the points above are fairly rare
let (optimized_png, optimization_strategy_message) =
let (mut optimized_png, optimization_strategy_message) =
if !must_use_second_pass_result && first_pass_png.len() < third_pass_png.len() {
(
first_pass_png,
Expand All @@ -222,6 +222,15 @@ impl Decoder for OptimizerDecoder {
)
};

// Final pass: apply obfuscation to the optimized result if possible and desired
if self
.optimization_settings
.minecraft_version_supports_png_obfuscation
&& self.optimization_settings.png_obfuscation
{
image_processor::obfuscate_png(&mut optimized_png);
}

Ok(Some((optimization_strategy_message, optimized_png)))
}
}
Expand Down
52 changes: 51 additions & 1 deletion packages/packsquash/src/pack_file/png_file/image_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::zopfli_iterations_time_model::ZopfliIterationsTimeModel;
use bytes::BytesMut;
use imagequant::{liq_error, Attributes};
use itertools::Itertools;
use obfstr::random;
use oxipng::{indexset, BitDepth, ColorType, Deflaters, Options, RowFilter, StripChunks};
use rgb::{AsPixels, RGBA8};
use spng::{ContextFlags, DecodeFlags, Format};
Expand Down Expand Up @@ -120,6 +121,56 @@ pub fn strip_unnecessary_chunks(
Ok(stripped_png)
}

/// Obfuscates the given known-valid PNG datastream in place to make it less likely to be readable
/// by most decoders.
///
/// # Panics
/// This function assumes that the input PNG datastream is valid and may panic if the PNG data is
/// malformed.
pub fn obfuscate_png(png: &mut [u8]) {
const CRC32_KEY: u32 = {
let k = random!(u32);

if k == 0 {
0xCAFEBABE
} else {
k
}
};
const ADLER32_KEY: u32 = {
let k = random!(u32);

if k == 0 {
0xCAFEBABE
} else {
k
}
};

let mut i = 8;
while i < png.len() {
let data_length = u32::from_be_bytes(png[i..i + 4].try_into().unwrap()) as usize;
let chunk_type = &png[i + 4..i + 8];
let chunk_crc = i + 8 + data_length;

if chunk_type == b"IDAT" {
// The chunk data is a Zlib stream
for (adler32_byte, key_byte) in png[chunk_crc - 4..]
.iter_mut()
.zip(ADLER32_KEY.to_le_bytes())
{
*adler32_byte ^= key_byte;
}
}

for (crc32_byte, key_byte) in png[chunk_crc..].iter_mut().zip(CRC32_KEY.to_le_bytes()) {
*crc32_byte ^= key_byte;
}

i = chunk_crc + 4;
}
}

/// An in-memory rectangular array of pixels in 8-bit RGBA format,
/// stored as a raw byte buffer.
///
Expand Down Expand Up @@ -291,7 +342,6 @@ impl<R: Read> ProcessedImage<R> {
let mut quantization_attributes = Attributes::new();
quantization_attributes.set_max_colors(quantization_target.max_colors())?;
quantization_attributes.set_speed(2)?;
quantization_attributes.set_quality(0, 100)?;

let bitmap = if let Some(pixel_array) = self.as_pixel_array()? {
pixel_array.as_slice()
Expand Down
75 changes: 71 additions & 4 deletions packages/packsquash/src/pack_file/png_file/tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use futures::FutureExt;
use rgb::FromSlice;
use spng::{DecodeFlags, Format};
use spng::{ContextFlags, CrcAction, DecodeFlags, Format};
use std::panic::AssertUnwindSafe;
use std::{env, fs};

use tokio_stream::StreamExt;
use tokio_test::io::Builder;

Expand All @@ -26,6 +27,7 @@ async fn successful_process_test(
expect_smaller_file_size: bool,
expect_same_color_type: bool,
expected_resolution: Option<(u32, u32)>,
lenient_decode: bool,
asset_type: PackFileAssetType,
test_name: &str
) {
Expand Down Expand Up @@ -68,9 +70,17 @@ async fn successful_process_test(
.expect("No error should happen while writing a test result to disk")
}

let mut png_reader = spng::Decoder::new(&*data)
let mut png_decoder = spng::Decoder::new(&*data)
.with_decode_flags(DecodeFlags::TRANSPARENCY)
.with_output_format(Format::Rgba8)
.with_output_format(Format::Rgba8);

if lenient_decode {
png_decoder = png_decoder
.with_context_flags(ContextFlags::IGNORE_ADLER32)
.with_crc_actions(CrcAction::Use, CrcAction::Use);
}

let mut png_reader = png_decoder
.read_info()
.expect("No error should happen while decoding processed PNG");
let image_info = png_reader.info();
Expand Down Expand Up @@ -144,6 +154,7 @@ async fn lossless_optimization_works() {
true, // Smaller size
false, // Not necessarily the same color type
Some((16, 16)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"lossless_optimization_works"
)
Expand All @@ -162,6 +173,7 @@ async fn lossy_optimization_works() {
true, // Smaller size
false, // Not necessarily the same color type
Some((16, 16)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"lossy_optimization_works"
)
Expand All @@ -181,6 +193,7 @@ async fn entity_eye_blending_workaround_works() {
false, // Not necessarily smaller
false, // Not necessarily the same color type
Some((64, 32)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::EyeLayer,
"entity_eye_blending_workaround_works"
)
Expand All @@ -200,6 +213,7 @@ async fn banner_layer_check_workaround_works() {
false, // Not necessarily smaller
true, // Same color type
Some((16, 16)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::BannerLayer,
"banner_layer_check_workaround_works"
)
Expand All @@ -223,6 +237,7 @@ async fn ditherbomb_does_not_get_bigger() {
true, // The first pass strips some non-critical chunks
true, // Should fall back to the first pass result
Some((4395, 6598)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"ditherbomb_does_not_get_bigger"
)
Expand All @@ -246,6 +261,7 @@ async fn ditherbomb_can_be_defused() {
true, // No dithering is enough to make the optimizations work as expected
false, // Not necessarily the same color type
Some((4395, 6598)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"ditherbomb_can_be_defused"
)
Expand All @@ -264,6 +280,7 @@ async fn single_color_image_is_downsized() {
true, // Smaller file size
false, // Maybe different color type
Some((1, 1)), // Not a power of two, so vanilla Minecraft doesn't do mipmaps
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"single_color_image_is_downsized"
)
Expand Down Expand Up @@ -302,8 +319,58 @@ async fn png_data_with_trailing_bytes_is_handled() {
true, // Smaller size
false, // Not necessarily the same color type
Some((16, 16)), // Same resolution
false, // The PNG datastream should be standards-compliant
PackFileAssetType::GenericTexture,
"png_data_with_trailing_bytes_is_handled"
)
.await
}

#[tokio::test]
async fn png_obfuscation_works() {
// AssertUnwindSafe can be used because catch_unwind will not witness any invalid
// state at the expected panic points
let conforming_decoder_test_result = AssertUnwindSafe(successful_process_test(
PNG_DATA,
PngFileOptions {
image_data_compression_iterations: 0,
color_quantization_target: ColorQuantizationTarget::None,
skip_alpha_optimizations: true,
png_obfuscation: true,
..Default::default()
},
true, // Same pixels
false, // Non necessarily smaller
false, // Not necessarily the same color type
Some((16, 16)), // Same resolution
false, // Decode strictly
PackFileAssetType::GenericTexture,
"png_obfuscation_works"
))
.catch_unwind()
.await;

assert!(
conforming_decoder_test_result.is_err(),
"Decoding an obfuscated PNG datastream with a conforming decoder should fail"
);

successful_process_test(
PNG_DATA,
PngFileOptions {
image_data_compression_iterations: 0,
color_quantization_target: ColorQuantizationTarget::None,
skip_alpha_optimizations: true,
png_obfuscation: true,
..Default::default()
},
true, // Same pixels
false, // Non necessarily smaller
false, // Not necessarily the same color type
Some((16, 16)), // Same resolution
true, // Decode leniently
PackFileAssetType::GenericTexture,
"png_obfuscation_works"
)
.await
}
Loading

0 comments on commit c092f92

Please sign in to comment.