From ace22947486cf7f5b0d404a94e0de6437cdec5fc Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Sun, 3 May 2026 17:59:58 -0600 Subject: [PATCH] Add sig encrypt command --- sig/Cargo.toml | 2 + sig/src/cli.rs | 50 +++++++++++++++- sig/src/crypto.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++- sig/src/main.rs | 43 ++++++++++++++ sig/src/tests.rs | 24 ++++++++ 5 files changed, 255 insertions(+), 5 deletions(-) diff --git a/sig/Cargo.toml b/sig/Cargo.toml index 2c04633..477b522 100644 --- a/sig/Cargo.toml +++ b/sig/Cargo.toml @@ -7,3 +7,5 @@ edition = "2024" aes = "0.8.4" cbc = "0.1.2" clap = { version = "4.5.37", features = ["derive"] } +rand = "0.8.5" +sha2 = "0.10.9" diff --git a/sig/src/cli.rs b/sig/src/cli.rs index 2fdcf59..801c9f2 100644 --- a/sig/src/cli.rs +++ b/sig/src/cli.rs @@ -24,13 +24,16 @@ pub struct Cli { /// Supported subcommands. /// -/// The tool currently has one operation: `strip`. The enum layout leaves room -/// for future package inspection, signing, or repacking commands without -/// changing the top-level parser API. +/// The tool currently supports unpacking and re-encrypting package payloads. +/// The enum layout leaves room for future package inspection, signing, or +/// repacking commands without changing the top-level parser API. #[derive(Subcommand)] pub enum Command { /// Strip the .sig wrapper and write the unpacked payload. Strip(StripArgs), + + /// Encrypt a file and wrap it in a .sig container. + Encrypt(EncryptArgs), } /// Arguments accepted by the `strip` subcommand. @@ -55,3 +58,44 @@ pub struct StripArgs { #[arg(short, long)] pub output: Option, } + +/// Arguments accepted by the `encrypt` subcommand. +/// +/// This command performs the inverse of `strip` for the subset of the format the +/// tool understands: it encrypts a plaintext payload with the Carbon 2 AES key, +/// writes a 512-byte `.sig` header, and appends the ciphertext. It does not yet +/// create or verify any vendor signature material beyond the metadata needed for +/// decryption. +#[derive(Args)] +pub struct EncryptArgs { + /// Input plaintext package file. + /// + /// The bytes are copied into the encrypted payload with PKCS#7 padding. The + /// original unpadded length is stored in the header so `strip` can recover + /// the exact input bytes. + pub input: PathBuf, + + /// Output `.sig` path. + /// + /// When omitted, the command appends `.sig` to the input filename. For + /// example, `update.swu` writes `update.swu.sig`. + #[arg(short, long)] + pub output: Option, + + /// Filename to store inside the `.sig` header. + /// + /// Defaults to the input file name. The `.sig` format reserves 48 bytes for + /// this field, so values longer than 47 bytes are rejected to preserve the + /// trailing NUL terminator used by existing packages. + #[arg(long)] + pub filename: Option, + + /// Existing `.sig` file whose header metadata should be reused. + /// + /// Use this when you need reproducible output that can match an existing + /// encrypted package byte-for-byte. The command reuses the template IV and + /// opaque header metadata, then rewrites size, filename, and payload hash + /// fields for the new encrypted payload. + #[arg(long)] + pub template: Option, +} diff --git a/sig/src/crypto.rs b/sig/src/crypto.rs index 3f0af5d..f9e3a35 100644 --- a/sig/src/crypto.rs +++ b/sig/src/crypto.rs @@ -1,4 +1,4 @@ -//! Centauri Carbon 2 `.sig` package parsing and AES-CBC decryption. +//! Centauri Carbon 2 `.sig` package parsing, encryption, and decryption. //! //! The Carbon 2 update `.sig` container starts with a fixed 512-byte header. //! The header begins with the big-endian magic value `ELEG`, carries a package @@ -14,7 +14,12 @@ use std::error::Error; use aes::Aes256; -use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; +use cbc::cipher::{ + BlockDecryptMut, BlockEncryptMut, KeyIvInit, + block_padding::{NoPadding, Pkcs7}, +}; +use rand::{RngCore, rngs::OsRng}; +use sha2::{Digest, Sha256}; /// Length of the fixed `.sig` header in bytes. pub const HEADER_LEN: usize = 512; @@ -33,6 +38,7 @@ const AES_KEY: [u8; 32] = [ ]; type Aes256CbcDec = cbc::Decryptor; +type Aes256CbcEnc = cbc::Encryptor; /// Parsed subset of a Carbon 2 `.sig` header. /// @@ -78,6 +84,120 @@ pub fn unpack_sig(raw: &[u8]) -> Result, Box> { } } +/// Pack plaintext bytes into an encrypted `.sig` container. +/// +/// The generated file is intentionally conservative: it writes the fields needed +/// by the known Carbon 2 unpacker and by [`unpack_sig`], but it does not claim to +/// recreate every vendor metadata field. The payload is encrypted with the fixed +/// Carbon 2 AES-256 key and a fresh random IV stored in the header. +pub fn pack_sig(payload: &[u8], filename: &str) -> Result, Box> { + let mut iv = [0u8; 16]; + OsRng.fill_bytes(&mut iv); + + pack_sig_with_iv(payload, filename, iv) +} + +/// Pack plaintext bytes into an encrypted `.sig` container with a caller-supplied IV. +/// +/// Normal callers should use [`pack_sig`], which generates a fresh random IV. +/// This function exists for reproducible output, especially regression tests that +/// need to prove the encryption path can recreate a known package byte-for-byte. +pub fn pack_sig_with_iv( + payload: &[u8], + filename: &str, + iv: [u8; 16], +) -> Result, Box> { + pack_sig_inner(payload, filename, iv, None) +} + +/// Pack plaintext bytes using the IV and opaque metadata from an existing `.sig` header. +/// +/// The `.sig` header contains a 256-byte block at `0x100..0x200` that appears to +/// be signature or vendor metadata. The current tool cannot derive that block +/// from plaintext, so exact byte-for-byte reproduction of an existing `.sig` +/// requires copying it from a template. Known length, filename, IV, and encrypted +/// hash fields are still rewritten so the header matches the newly encrypted +/// payload. +pub fn pack_sig_with_template( + payload: &[u8], + filename: &str, + template: &[u8], +) -> Result, Box> { + if template.len() < HEADER_LEN { + return Err("template is too small to contain a .sig header".into()); + } + + let iv = template[0xA0..0xB0].try_into()?; + pack_sig_inner(payload, filename, iv, Some(&template[..HEADER_LEN])) +} + +fn pack_sig_inner( + payload: &[u8], + filename: &str, + iv: [u8; 16], + header_template: Option<&[u8]>, +) -> Result, Box> { + let filename_bytes = filename.as_bytes(); + if filename_bytes.len() >= 48 { + return Err("header filename must be 47 bytes or shorter".into()); + } + + let ciphertext = encrypt_payload(payload, &iv)?; + let encrypted_sha256 = Sha256::digest(&ciphertext).into(); + let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + output.extend_from_slice(&build_header( + filename_bytes, + payload.len(), + ciphertext.len(), + &iv, + encrypted_sha256, + header_template, + )?); + output.extend_from_slice(&ciphertext); + + Ok(output) +} + +/// Build a 512-byte encrypted `.sig` header. +/// +/// Field choices mirror the observed OTA package-list fixture where possible: +/// package type `0x83` marks an encrypted type-3 package, version bytes are +/// `1.2`, `0x08..0x10` stores the plaintext size, `0x98..0xA0` and +/// `0xB0..0xB8` store the encrypted payload length, and `0xA0..0xB0` stores the +/// IV. Bytes `0xE0..0x100` store the SHA-256 digest of the encrypted payload. If +/// a template is supplied, unknown metadata bytes are preserved from it. +fn build_header( + filename: &[u8], + payload_len: usize, + encrypted_len: usize, + iv: &[u8; 16], + encrypted_sha256: [u8; 32], + template: Option<&[u8]>, +) -> Result<[u8; HEADER_LEN], Box> { + let payload_len = u64::try_from(payload_len).map_err(|_| "payload is too large")?; + let encrypted_len = + u64::try_from(encrypted_len).map_err(|_| "encrypted payload is too large")?; + + let mut header = [0u8; HEADER_LEN]; + if let Some(template) = template { + header.copy_from_slice(&template[..HEADER_LEN]); + } + + header[0..4].copy_from_slice(&MAGIC.to_be_bytes()); + header[0x04] = 0x83; + header[0x05] = 0x01; + header[0x06] = 0x02; + header[0x08..0x10].copy_from_slice(&payload_len.to_le_bytes()); + header[0x10..0x40].fill(0); + header[0x10..0x10 + filename.len()].copy_from_slice(filename); + header[0x98..0xA0].copy_from_slice(&encrypted_len.to_le_bytes()); + header[0xA0..0xB0].copy_from_slice(iv); + header[0xB0..0xB8].copy_from_slice(&encrypted_len.to_le_bytes()); + header[0xE0..0x100].copy_from_slice(&encrypted_sha256); + + Ok(header) +} + /// Parse the fixed-width `.sig` header. /// /// Offsets are based on the browser implementation: @@ -155,3 +275,20 @@ fn decrypt_payload(payload: &[u8], header: &Header) -> Result, Box Result, Box> { + let padded_len = payload.len().next_multiple_of(16) + 16; + let mut buffer = vec![0u8; padded_len]; + buffer[..payload.len()].copy_from_slice(payload); + + let encrypted = Aes256CbcEnc::new(&AES_KEY.into(), iv.into()) + .encrypt_padded_mut::(&mut buffer, payload.len()) + .map_err(|_| "AES-CBC encryption failed")?; + + Ok(encrypted.to_vec()) +} diff --git a/sig/src/main.rs b/sig/src/main.rs index 8b5eeca..4b5ff79 100644 --- a/sig/src/main.rs +++ b/sig/src/main.rs @@ -23,11 +23,41 @@ fn main() -> Result<(), Box> { match cli.command { Command::Strip(args) => strip(args)?, + Command::Encrypt(args) => encrypt(args)?, } Ok(()) } +/// Execute the `encrypt` subcommand. +/// +/// The command reads a plaintext payload, wraps it with a fresh-IV encrypted +/// `.sig` header via [`crypto::pack_sig`], and writes the resulting container. +fn encrypt(args: cli::EncryptArgs) -> Result<(), Box> { + let raw = fs::read(&args.input)?; + let filename = match args.filename { + Some(filename) => filename, + None => args + .input + .file_name() + .ok_or("input path does not have a file name")? + .to_string_lossy() + .into_owned(), + }; + let packed = if let Some(template) = args.template { + let template = fs::read(template)?; + crypto::pack_sig_with_template(&raw, &filename, &template)? + } else { + crypto::pack_sig(&raw, &filename)? + }; + let output_path = args.output.unwrap_or_else(|| sig_output_path(&args.input)); + + fs::write(&output_path, packed)?; + println!("wrote {}", output_path.display()); + + Ok(()) +} + /// Execute the `strip` subcommand. /// /// `strip` removes the 512-byte `.sig` wrapper and writes the contained package @@ -59,3 +89,16 @@ fn default_output_path(input: &std::path::Path) -> PathBuf { input.with_extension("decrypted") } } + +/// Derive the default output path for `encrypt`. +/// +/// Unlike [`default_output_path`], encryption appends an extension instead of +/// replacing one: `firmware.zip` becomes `firmware.zip.sig`. +fn sig_output_path(input: &std::path::Path) -> PathBuf { + let mut filename = input + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_else(|| "output".into()); + filename.push(".sig"); + input.with_file_name(filename) +} diff --git a/sig/src/tests.rs b/sig/src/tests.rs index c7e76d7..e814584 100644 --- a/sig/src/tests.rs +++ b/sig/src/tests.rs @@ -74,3 +74,27 @@ fn decrypts_ota_package_list_fixture() { assert_eq!(actual, EXPECTED_OTA_PACKAGE_LIST); } + +/// Encrypting plaintext with this tool should produce a `.sig` package that the +/// same unpacking path can decrypt back to the original bytes. +#[test] +fn encrypts_ota_package_list_fixture() { + let encrypted = super::crypto::pack_sig_with_template( + EXPECTED_OTA_PACKAGE_LIST, + "ota-package-list.json", + ENCRYPTED_OTA_PACKAGE_LIST, + ) + .expect("failed to pack embedded plaintext fixture"); + + assert_eq!(encrypted.len(), ENCRYPTED_OTA_PACKAGE_LIST.len()); + if let Some(index) = encrypted + .iter() + .zip(ENCRYPTED_OTA_PACKAGE_LIST) + .position(|(actual, expected)| actual != expected) + { + panic!( + "encrypted fixture differs at byte {index}: actual=0x{:02x}, expected=0x{:02x}", + encrypted[index], ENCRYPTED_OTA_PACKAGE_LIST[index] + ); + } +}