为pngme拓展加密功能与jpg格式支持

为pngme拓展加密功能与jpg格式支持

引言

发现了一个好玩的小项目:Introduction - PNGme: An Intermediate Rust Project。这里面提到了根据PNG标准格式特点,将信息写入PNG文件而不影响文件自身被正常解析的功能。这个项目已经被非常多的人复刻了,我自己也想玩一下,不过,我要做一些拓展,包括但不限于:

  1. 支持utf8。这个,我看rust的支持很不错,相比于大部分人的实现,不要对信息数据逐字节解析为char,而是要通过String::from_utf8_lossy(self.data())或者其他方法将数据整体转为字符串就可以了,这里不做赘述。
  2. 支持密文。毕竟都已经实现文件隐写了,加个密岂不是更好玩😋。
  3. 支持jpg格式。jpg也很常见了,万一一时半会掏不出png岂不是很扫兴。

对于PNGme本身的中文讲解,可以参考PNGme:基于Rust的命令行程序 在PNG图片中隐藏信息 | 乱up廿四

我的项目名为PngKey(最开始也只是想支持png,后来懒得改名了😂)Smart-Space/pngkey: 将信息文本或密文写入PNG或JPG文件。除了上面所述的三个拓展,还进行了一些其他改进。


信息加密

明文与密文

在pngme的原版实现里,encode命令只包含文件路径、块名称与信息,现在,修改clap的命令行参数,可选支持密码以及输出文件(输出到其他文件很简单,这里不讲):

rust 复制代码
#[derive(Debug, Args)]
pub struct EncodeArgs {
    /// The file path to the PNG file to be encoded.
    pub file_path: PathBuf,
    /// The chunk type to be used for the message.
    pub chunk_type: String,
    /// The message to be encoded.
    pub message: String,
    /// The output file path. If not specified, the original file will be overwritten.
    #[clap(short, long)]
    pub output: Option<PathBuf>,
    /// The password to be used for encryption. If not specified, the message will be stored in plain text.
    #[clap(short, long)]
    pub password: Option<String>,
}

那么,对密码的处理也很简单,有就是有,没有就是没有:

rust 复制代码
let password = args.password.unwrap_or_else(|| "".to_string());

这样的话,方便后面判断信息应当是明文还是密文:

rust 复制代码
if !password.is_empty() {
    encrypted_message = key::encrypt(&message, &password)?;
} else {
    encrypted_message = message;
}

加密

PngKey选择使用ChaCha20-Poly1305方式加密,输入的密码通过Argon2id派生密钥,一通操作下来,将salt、nonce、密文用base64转码,再用::拼接输出。

rust 复制代码
pub fn encrypt(plaintext: &str, password: &str) -> Result<String> {
    // 生成随机salt
    let salt = SaltString::generate(&mut OsRng);
    
    // 使用Argon2id派生密钥
    let argon2 = Argon2::default();
    let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
    let key = password_hash.hash.unwrap();
    
    // 生成随机nonce(ChaCha20-Poly1305使用12字节nonce)
    let mut nonce_bytes = [0u8; 12];
    OsRng.fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);
    
    // 创建加密器
    let cipher = ChaCha20Poly1305::new_from_slice(key.as_bytes())?;
    
    // 加密
    let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())?;
    
    // 组合:salt + nonce + ciphertext
    let salt_b64 = general_purpose::STANDARD.encode(salt.as_str().as_bytes());
    let nonce_b64 = general_purpose::STANDARD.encode(&nonce_bytes);
    let ciphertext_b64 = general_purpose::STANDARD.encode(&ciphertext);
    let combined = format!("{}::{}::{}", salt_b64, nonce_b64, ciphertext_b64);
    
    Ok(combined)
}

解密

基本就是加密的逆过程,通过看能不能被::分割判断是否需要密码,那么密码对不对呢?很简单,直接用?自行抛出AEAD的错误就行了,我不管了😇。

rust 复制代码
pub fn decrypt(encrypted: &str, password: &str) -> Result<String> {
    let parts: Vec<&str> = encrypted.split("::").collect();
    if parts.len() != 3 {
        return Ok(String::from(encrypted));
    }
    if password.is_empty() {
        return Err("Need password to decrypt".into());
    }
    
    let salt_str = String::from_utf8(general_purpose::STANDARD.decode(parts[0])?)?;
    let nonce_bytes = general_purpose::STANDARD.decode(parts[1])?;
    let ciphertext = general_purpose::STANDARD.decode(parts[2])?;
    
    let nonce = Nonce::from_slice(&nonce_bytes);
    
    // 使用Argon2重新派生密钥
    let argon2 = Argon2::default();
    let salt = SaltString::from_b64(&salt_str)?;
    let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
    let key = password_hash.hash.unwrap();
    
    // 解密
    let cipher = ChaCha20Poly1305::new_from_slice(key.as_bytes())?;
    let plaintext_bytes = cipher.decrypt(nonce, ciphertext.as_slice())?;
    
    Ok(String::from_utf8(plaintext_bytes)?)
}

JPG格式支持

JPG格式简读

中文解读可以参考完整教程:【图像处理】jpeg 格式详解 - yangykaifa - 博客园,这里只说明要点。

SOI和EOI均只有两个字节。

SOS的长度信息是6 + 2×组件数量(如3个组件时为00 0C=12字节),不是数据长度信息,SOS的数据会一直持续到下一个块。因此SOS的数据内容需要通过寻找下一个块的位置来间接获得。

块间间隔是0xFF,且后面不应当是0x00,对于数据中出现的0xFF,需要后补0x00。而我们要填入utf8字符串或者密文的块在EOI块后,解析器根本不会理会,因此不需要特殊操作。

代码结构

因为要支持两种格式,肯定需要两套操作,而为了保证编程的方便,两套操作应当封装为具有相同名称的子库,供commands.rs统一调用。

复制代码
│  args.rs
│  commands.rs
│  jpg.rs
│  key.rs
│  main.rs
│  png.rs
│
├─jpg
│      chunk.rs
│      chunk_type.rs
│      command.rs
│
└─png
        chunk.rs
        chunk_type.rs
        command.rs

其中,pngjpg模块分别提供is_pngis_jpg供主流程判断该选择进行哪一类操作。

JPG块结构

rust 复制代码
pub struct Chunk {
    head: u8,
    chunk_type: ChunkType,
    length: u16,
    data: Vec<u8>,
}

显然对于一些块类型,我们没必要去理会它们的长度:

rust 复制代码
static AVOID_LENGTH_TYPE: [u8; 3] = [0xd8, 0xd9, 0xda];

impl Chunk {
    pub fn new(chunk_type: ChunkType, data: Vec<u8>) -> Chunk {
        let length: u16;
        if AVOID_LENGTH_TYPE.contains(&chunk_type.bytes()) {
            if &chunk_type.bytes() == &0xda {
                // DA的长度放在数据里,这里只是显示头长度
                length = u16::from_be_bytes([data[0], data[1]]);
            } else {
                length = 0;
            }
        } else {
            length = u16::try_from(data.len()).unwrap() + 2;
        }
        Chunk {
            head: 0xff,
            chunk_type,
            length,
            data,
        }
    }
    //...
}

从数据读取为JPG结构

JPG的整体结构跟PNG一样简单:

rust 复制代码
pub struct Jpg {
    header: [u8; 2],
    chunks: Vec<Chunk>,
}

从数据读取时需要注意SOI和EOI,以及对SOS的读取方式:

rust 复制代码
fn find_next_marker(bytes: &[u8], start: usize) -> Option<usize> {
    let mut i = start;
    while i + 1 < bytes.len() {
        if bytes[i] == 0xFF && bytes[i+1] != 0x00 {
            return Some(i);
        }
        i += 1;
    }
    None
}

impl TryFrom<&[u8]> for Jpg {
    type Error = Error;

    fn try_from(bytes: &[u8]) -> Result<Jpg> {
        let header: [u8; 2] = [bytes[0], bytes[1]];
        let mut chunks = Vec::new();
        let mut index = 2;
        while index < bytes.len() {
            let marker_type = bytes[index + 1];
            index += 2;

            match marker_type {
                0xD8 => { // SOI
                    chunks.push(Chunk::new(ChunkType::try_from(0xD8).unwrap(), (&[]).to_vec()));
                }
                0xD9 => { // EOI
                    chunks.push(Chunk::new(ChunkType::try_from(0xD9).unwrap(), (&[]).to_vec()));
                    // JPG标准结束
                }
                0xDA => { // SOS
                    // 找到下一个 marker
                    if let Some(next_marker_pos) = find_next_marker(bytes, index) {
                        let chunk_bytes = &bytes[index..next_marker_pos];
                        chunks.push(Chunk::new(ChunkType::try_from(0xDA).unwrap(), chunk_bytes.to_vec()));
                        index = next_marker_pos;
                    } else {
                        return Err("No marker found after SOS chunk".into());
                    }
                }
                _ => {
                    let length_bytes: [u8; 2] = bytes[index..index+2].try_into().unwrap();
                    let length = u16::from_be_bytes(length_bytes) as usize;
                    let chunk_end = index + length;
                    let chunk_bytes = &bytes[index+2..chunk_end];
                    chunks.push(Chunk::new(ChunkType::try_from(marker_type).unwrap(), chunk_bytes.to_vec()));
                    index = chunk_end;
                }
            }

        }
        Ok(Jpg { header, chunks })
    }
}

部分展示

相关推荐
古城小栈17 小时前
Rust Vec与HashMap全功能解析:定义、使用与进阶技巧
算法·rust
techdashen2 天前
Rust OnceCell 深度解析:延迟初始化的优雅解决方案
开发语言·oracle·rust
superman超哥2 天前
Serde 的零成本抽象设计:深入理解 Rust 序列化框架的哲学
开发语言·rust·开发工具·编程语言·rust序列化
星辰徐哥2 天前
Rust函数与流程控制——构建逻辑清晰的系统级程序
开发语言·后端·rust
superman超哥2 天前
序列化格式的灵活切换:Serde 生态的统一抽象力量
开发语言·rust·编程语言·rust serde·序列化格式·rust序列化格式
superman超哥2 天前
派生宏(Derive Macro)的工作原理:编译时元编程的艺术
开发语言·rust·开发工具·编程语言·rust派生宏·derive macro·rust元编程
superman超哥2 天前
处理复杂数据结构:Serde 在实战中的深度应用
开发语言·rust·开发工具·编程语言·rust serde·rust数据结构
superman超哥2 天前
错误处理与验证:Serde 中的类型安全与数据完整性
开发语言·rust·编程语言·rust编程·rust错误处理与验证·rust serde
禁默2 天前
【鸿蒙PC命令行适配】rust应用交叉编译环境搭建和bat命令的移植实战指南
华为·rust·harmonyos