From 6c94102afc70ce28eee3d17aad997a056aaf9195 Mon Sep 17 00:00:00 2001 From: Silas Bartha Date: Fri, 11 Oct 2024 19:01:52 -0400 Subject: gltf, wav, and binary codecs --- Cargo.toml | 44 ++++++++++-- LICENSE-0BSD | 5 ++ src/binary/mod.rs | 2 + src/binary/reverse_appendix.rs | 35 ++++++++++ src/codec.rs | 6 +- src/gltf/extras.rs | 91 ++++++++++++++++++++++++ src/gltf/mod.rs | 2 + src/jpeg/segment.rs | 10 +-- src/lib.rs | 12 ++++ src/lossless/lsb.rs | 16 ++--- src/wav/lsb.rs | 153 +++++++++++++++++++++++++++++++++++++++++ src/wav/mod.rs | 2 + 12 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 LICENSE-0BSD create mode 100644 src/binary/mod.rs create mode 100644 src/binary/reverse_appendix.rs create mode 100644 src/gltf/extras.rs create mode 100644 src/gltf/mod.rs create mode 100644 src/wav/lsb.rs create mode 100644 src/wav/mod.rs diff --git a/Cargo.toml b/Cargo.toml index fda57ee..9299617 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,54 @@ [package] name = "occule" -version = "0.2.1" +version = "0.3.0" edition = "2021" [features] -default = ["jpeg", "lossless"] +default = ["jpeg", "lossless", "gltf", "wav"] jpeg = ["dep:img-parts"] lossless = ["dep:image"] +gltf = ["dep:gltf", "dep:serde", "dep:serde_json", "dep:gltf-json"] +bin = ["dep:serde", "dep:rmp-serde"] +wav = ["dep:hound"] [dependencies] -thiserror = "^1.0" +base64 = "0.22" +thiserror = "1.0" +itertools = "0.13" +num-traits = "0.2" + +[dependencies.rmp-serde] +version = "1.3" +optional = true + +[dependencies.gltf-json] +version = "1.4" +features = ["extras"] +optional = true + +[dependencies.serde] +version = "1.0" +optional = true + +[dependencies.serde_json] +version = "1.0" +features = ["raw_value"] +optional = true [dependencies.img-parts] -version = "0.3.0" +version = "0.3" optional = true [dependencies.image] -version = "0.24" +version = "0.25" +optional = true + +[dependencies.gltf] +version = "1.4" optional = true +features = ["extras"] + +[dependencies.hound] +version = "3.5" +optional = true + diff --git a/LICENSE-0BSD b/LICENSE-0BSD new file mode 100644 index 0000000..7a39b21 --- /dev/null +++ b/LICENSE-0BSD @@ -0,0 +1,5 @@ +Copyright (C) 2024 by Silas Bartha silas@exvacuum.dev + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/binary/mod.rs b/src/binary/mod.rs new file mode 100644 index 0000000..66f6c30 --- /dev/null +++ b/src/binary/mod.rs @@ -0,0 +1,2 @@ +mod reverse_appendix; +pub use reverse_appendix::*; diff --git a/src/binary/reverse_appendix.rs b/src/binary/reverse_appendix.rs new file mode 100644 index 0000000..d131526 --- /dev/null +++ b/src/binary/reverse_appendix.rs @@ -0,0 +1,35 @@ +use crate::{Codec, Error}; + +/// Reverses payload binary data and writes it ass-first past the end of the original data. A +/// length marker is also prepended to the payload *before reversing* so the decoder knows how long +/// the payload is. +pub struct BinaryReverseAppendixCodec; + +impl Codec for BinaryReverseAppendixCodec { + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, crate::Error> { + let mut encoded = Vec::::new(); + encoded.extend(carrier.iter()); + let payload_len = (payload.len() as u64 + 8).to_le_bytes(); + encoded.extend(payload_len.iter().chain(payload.iter()).rev()); + Ok(encoded) + } + + fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), crate::Error> { + if encoded.len() < 8 { + return Err(Error::DataNotEncoded); + } + + let encoded_len = encoded.len(); + let payload_len = u64::from_le_bytes(encoded.iter().rev().take(8).cloned().collect::>().try_into().unwrap()) as usize; + if encoded_len < payload_len + 8 || payload_len < 8 { + return Err(Error::DataNotEncoded); + } + + let carrier_len = encoded_len - payload_len; + + let carrier = encoded[..carrier_len].to_vec(); + let payload = encoded.iter().rev().skip(8).take(payload_len - 8).cloned().collect::>(); + + Ok((carrier, payload)) + } +} diff --git a/src/codec.rs b/src/codec.rs index 11fa76e..c46860f 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -3,16 +3,16 @@ use thiserror::Error; /// Codecs enable the concealment of payload data inside the data of a carrier. pub trait Codec { /// Embeds payload data inside carrier, returning the result. - fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, CodecError>; + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, Error>; /// Extracts payload data from an encoded carrier, returning the carrier with data removed and the /// payload data. - fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), CodecError>; + fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), Error>; } /// Errors produced by a codec #[derive(Debug, Error)] -pub enum CodecError { +pub enum Error { /// Variant used when data is determined not to be encoded. Note that a codec may have no way /// of knowing this, so this may not be returned even if the data was not encoded #[error("Data was not encoded with this codec")] diff --git a/src/gltf/extras.rs b/src/gltf/extras.rs new file mode 100644 index 0000000..eade453 --- /dev/null +++ b/src/gltf/extras.rs @@ -0,0 +1,91 @@ +use std::borrow::Cow; + +use base64::Engine; +use gltf::Gltf; +use serde_json::{json, value::to_raw_value, Value}; + +use crate::{Codec, Error}; + +/// Codec for embedding data in a GLTF file "extras" entry. It uses the extras entry in the first +/// scene in the file and stores the data as base64. +#[derive(Default)] +pub struct ExtrasEntryCodec; + +impl Codec for ExtrasEntryCodec { + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, crate::Error> { + let gltf = match Gltf::from_slice(carrier) { + Ok(gltf) => gltf, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + + let mut json = gltf.document.into_json(); + let mut scene = json.scenes.remove(0); + let mut extras = match serde_json::from_str::(scene.extras.clone().unwrap_or(to_raw_value(&json!({})).unwrap()).get()) { + Ok(extras) => extras, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + match &mut extras { + Value::Object(object) => { + let base64_payload = base64::engine::general_purpose::STANDARD.encode(payload); + object.insert("occule".into(), Value::String(base64_payload)); + }, + _ => return Err(Error::DataInvalid("Carrier has extras in non-object format, not gonna mess with that.".into())) + } + let extras = match to_raw_value(&extras) { + Ok(raw) => Some(raw), + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + scene.extras = extras; + json.scenes.insert(0, scene); + let json_string = match gltf_json::serialize::to_string(&json) { + Ok(json_string) => json_string, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + + let mut glb = match gltf::binary::Glb::from_slice(carrier) { + Ok(glb) => glb, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + glb.header.length = (glb.header.length as usize - glb.json.len() + align_to_multiple_of_four(json_string.len())) as u32; + glb.json = Cow::Owned(json_string.into_bytes()); + + Ok(match glb.to_vec() { + Ok(vec) => vec, + Err(e) => return Err(Error::DependencyError(e.to_string())) + }) + } + + fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), crate::Error> { + let gltf = match Gltf::from_slice(encoded) { + Ok(gltf) => gltf, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }; + let mut json = gltf.document.into_json(); + let mut extras = match &json.scenes[0].extras { + Some(extras) => match serde_json::from_str::(extras.get()) { + Ok(Value::Object(value)) => value, + _ => return Err(Error::DataNotEncoded), + }, + None => return Err(Error::DataNotEncoded), + }; + let payload = match extras.remove("occule".into()) { + Some(Value::String(payload)) => match base64::engine::general_purpose::STANDARD.decode(payload) { + Ok(payload) => payload, + Err(e) => return Err(Error::DependencyError(e.to_string())), + }, + _ => return Err(Error::DataNotEncoded), + }; + + json.scenes[0].extras = match to_raw_value(&Value::Object(extras)) { + Ok(extras) => Some(extras), + Err(e) => return Err(Error::DependencyError(e.to_string())) + }; + + // TODO: remove payload from carrier + Ok((encoded.to_vec(), payload)) + } +} + +fn align_to_multiple_of_four(n: usize) -> usize { + (n + 3) & !3 +} diff --git a/src/gltf/mod.rs b/src/gltf/mod.rs new file mode 100644 index 0000000..42b0bae --- /dev/null +++ b/src/gltf/mod.rs @@ -0,0 +1,2 @@ +mod extras; +pub use extras::*; \ No newline at end of file diff --git a/src/jpeg/segment.rs b/src/jpeg/segment.rs index cd1a651..c0512a1 100644 --- a/src/jpeg/segment.rs +++ b/src/jpeg/segment.rs @@ -2,7 +2,7 @@ use std::{mem::size_of, usize}; use img_parts::jpeg::{markers, Jpeg, JpegSegment}; -use crate::{codec::Codec, CodecError}; +use crate::{codec::Codec, Error}; /// Codec for storing payload data in JPEG comment (COM) segments. Can store an arbitrary amount of /// data, as long as the number of comment segments does not exceed u64::MAX. @@ -13,11 +13,11 @@ pub struct JpegSegmentCodec { } impl Codec for JpegSegmentCodec { - fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, CodecError> + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, Error> { let mut jpeg = match Jpeg::from_bytes(carrier.to_vec().into()) { Ok(v) => v, - Err(e) => return Err(CodecError::DependencyError(e.to_string())) + Err(e) => return Err(Error::DependencyError(e.to_string())) }; let mut payload_bytes = payload.to_vec(); let segment_count = ((payload_bytes.len() + size_of::()) as u64).div_ceil((u16::MAX as usize - size_of::()) as u64); @@ -29,11 +29,11 @@ impl Codec for JpegSegmentCodec { Ok(jpeg.encoder().bytes().to_vec()) } - fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), CodecError> + fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), Error> { let mut jpeg = match Jpeg::from_bytes(encoded.to_vec().into()) { Ok(v) => v, - Err(e) => return Err(CodecError::DependencyError(e.to_string())) + Err(e) => return Err(Error::DependencyError(e.to_string())) }; let segment = jpeg.segments_mut().remove(self.start_index); let segment_bytes = segment.contents(); diff --git a/src/lib.rs b/src/lib.rs index 7cbae7c..6c9237f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,3 +13,15 @@ pub mod jpeg; /// Codecs for carriers in lossless image formats (PNG, WebP, etc.). #[cfg(feature = "lossless")] pub mod lossless; + +/// Codecs for carriers in gltf model format. +#[cfg(feature = "gltf")] +pub mod gltf; + +/// Codecs for binary files. +#[cfg(feature = "bin")] +pub mod binary; + +/// Codecs for wav files +#[cfg(feature = "wav")] +pub mod wav; diff --git a/src/lossless/lsb.rs b/src/lossless/lsb.rs index 2ce1456..1c9a213 100644 --- a/src/lossless/lsb.rs +++ b/src/lossless/lsb.rs @@ -2,7 +2,7 @@ use std::{cmp::Ordering, io::{BufWriter, Cursor}}; use image::{DynamicImage, GenericImageView, Pixel}; -use crate::{codec::Codec, CodecError}; +use crate::{codec::Codec, Error}; /// Least-significant bit (LSB) steganography encodes data in the least-significant bits of colors /// in an image. This implementation reduces the colors in the carrier (irreversibly) in order to @@ -12,14 +12,14 @@ use crate::{codec::Codec, CodecError}; pub struct LsbCodec; impl Codec for LsbCodec { - fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, CodecError> + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, Error> { let image_format = image::guess_format(carrier).unwrap(); let mut image: DynamicImage = image::load_from_memory(carrier).unwrap(); let payload: &[u8] = payload; if image.pixels().count() < payload.len() { - return Err(CodecError::DataInvalid("Payload Too Big for Carrier".into())); + return Err(Error::DataInvalid("Payload Too Big for Carrier".into())); } let mut payload_iter = payload.iter(); @@ -43,17 +43,17 @@ impl Codec for LsbCodec { } } }, - _ => return Err(CodecError::DataInvalid("Unsupported Image Color Format".into())) + _ => return Err(Error::DataInvalid("Unsupported Image Color Format".into())) } let mut buf = BufWriter::new(Cursor::new(Vec::::new())); if let Err(e) = image.write_to(&mut buf, image_format) { - return Err(CodecError::DependencyError(e.to_string())) + return Err(Error::DependencyError(e.to_string())) } Ok(buf.into_inner().unwrap().into_inner()) } - fn decode(&self, carrier: &[u8]) -> Result<(Vec, Vec), CodecError> + fn decode(&self, carrier: &[u8]) -> Result<(Vec, Vec), Error> { let image_format = image::guess_format(carrier).unwrap(); let mut image: DynamicImage = image::load_from_memory(carrier).unwrap(); @@ -78,12 +78,12 @@ impl Codec for LsbCodec { } } }, - _ => return Err(CodecError::DataInvalid("Unsupported Image Color Format".into())) + _ => return Err(Error::DataInvalid("Unsupported Image Color Format".into())) } let mut buf = BufWriter::new(Cursor::new(Vec::::new())); if let Err(e) = image.write_to(&mut buf, image_format) { - return Err(CodecError::DependencyError(e.to_string())) + return Err(Error::DependencyError(e.to_string())) } Ok((buf.into_inner().unwrap().into_inner(), payload)) } diff --git a/src/wav/lsb.rs b/src/wav/lsb.rs new file mode 100644 index 0000000..17e6e28 --- /dev/null +++ b/src/wav/lsb.rs @@ -0,0 +1,153 @@ +use std::io::Cursor; + +use crate::{Codec, Error}; + +use hound::{self, Sample, SampleFormat, WavReader, WavSamples, WavWriter}; +use itertools::{Chunk, Itertools}; +use num_traits::{FromBytes, ToBytes}; + +/// A Least-Significant Bit (LSB) Codec for WAV files. Stores 1 bit of payload data in each sample +/// of a WAV file. Supported sample formats are 8, 16, and 32-bit PCM, and 32-bit float. +pub struct LsbCodec; + +impl Codec for LsbCodec { + fn encode(&self, carrier: &[u8], payload: &[u8]) -> Result, crate::Error> { + if let Ok(mut reader) = hound::WavReader::new(Cursor::new(carrier)) { + let mut encoded = vec![]; + { + let mut writer = WavWriter::new(Cursor::new(&mut encoded), reader.spec()).unwrap(); + match reader.spec().sample_format { + hound::SampleFormat::Float => { + encode::(payload, &mut reader, &mut writer); + } + hound::SampleFormat::Int => { + match reader.spec().bits_per_sample { + 8 => { + encode::(payload, &mut reader, &mut writer); + } + 16 => { + encode::(payload, &mut reader, &mut writer); + } + 32 => { + encode::(payload, &mut reader, &mut writer); + } + _ => return Err(Error::DataInvalid( + "Provided WAV data has an unsupported number of bits per sample." + .into(), + )), + } + } + } + writer.flush().unwrap(); + } + Ok(encoded) + } else { + Err(Error::DataInvalid( + "Could not create WAV reader from provided data".into(), + )) + } + } + + fn decode(&self, encoded: &[u8]) -> Result<(Vec, Vec), crate::Error> { + if let Ok(mut reader) = hound::WavReader::new(Cursor::new(encoded)) { + let decoded = match reader.spec().sample_format { + SampleFormat::Float => decode::(&mut reader)?, + SampleFormat::Int => match reader.spec().bits_per_sample { + 8 => decode::(&mut reader)?, + 16 => decode::(&mut reader)?, + 32 => decode::(&mut reader)?, + _ => return Err(Error::DataNotEncoded), + }, + }; + Ok((encoded.to_vec(), decoded)) + } else { + Err(Error::DataInvalid( + "Could not create WAV reader from provided data".into(), + )) + } + } +} + +fn encode( + payload: &[u8], + reader: &mut WavReader>, + writer: &mut WavWriter>>, +) where + T: Sample + ToBytes + FromBytes, +{ + let payload_len = ((payload.len() + size_of::()) as u32).to_le_bytes(); + let mut payload_iter = payload_len.iter().chain(payload.iter()); + for sample_chunk in &reader.samples::().chunks(8) { + match payload_iter.next() { + Some(payload_byte) => { + encode_byte(writer, *payload_byte, sample_chunk); + } + None => { + for sample in sample_chunk { + writer.write_sample(sample.unwrap()).unwrap(); + } + } + } + } +} + +fn encode_byte( + writer: &mut WavWriter>>, + payload_byte: u8, + sample_chunk: Chunk, T>>, +) where + T: Sample + ToBytes + FromBytes, +{ + for (i, sample) in sample_chunk.enumerate() { + let sample = sample.unwrap(); + let mut sample_bytes = sample.to_le_bytes(); + let payload_bit = (payload_byte >> (7 - i)) & 0b0000_0001; + sample_bytes[1] &= 0b1111_1110; + sample_bytes[1] |= payload_bit; + writer + .write_sample(T::from_le_bytes(&sample_bytes)) + .unwrap(); + } +} + +fn decode(reader: &mut WavReader>) -> Result, Error> +where + T: Sample + ToBytes + FromBytes, +{ + let mut decoded = vec![]; + let mut length_bytes = [0_u8; 4]; + for (i, sample_chunk) in reader + .samples::() + .take(8 * 4) + .chunks(8) + .into_iter() + .enumerate() + { + for (j, sample) in sample_chunk.enumerate() { + let sample = sample.unwrap(); + let sample_bytes = sample.to_le_bytes(); + let payload_bit = (sample_bytes[1] & 0b0000_0001) << (7 - j); + length_bytes[i] |= payload_bit; + } + } + + let payload_length = u32::from_le_bytes(length_bytes) as usize - size_of::(); + if payload_length > reader.samples::().len() { + return Err(Error::DataNotEncoded); + } + + for sample_chunk in &reader.samples::().chunks(8) { + let mut byte = 0_u8; + for (i, sample) in sample_chunk.enumerate() { + let sample = sample.unwrap(); + let sample_bytes = sample.to_le_bytes(); + let payload_bit = (sample_bytes[1] & 0b0000_0001) << (7 - i); + byte |= payload_bit; + } + decoded.push(byte); + if decoded.len() >= payload_length { + break; + } + } + Ok(decoded) +} diff --git a/src/wav/mod.rs b/src/wav/mod.rs new file mode 100644 index 0000000..a6bda54 --- /dev/null +++ b/src/wav/mod.rs @@ -0,0 +1,2 @@ +mod lsb; +pub use lsb::*; -- cgit v1.2.3