Add sig strip command

This commit is contained in:
Michael Mikovsky
2026-05-03 17:43:37 -06:00
parent 743d88d75f
commit d9729ddb99
5 changed files with 353 additions and 2 deletions
+3
View File
@@ -4,3 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
aes = "0.8.4"
cbc = "0.1.2"
clap = { version = "4.5.37", features = ["derive"] }
+57
View File
@@ -0,0 +1,57 @@
//! Clap command and argument definitions.
//!
//! This module is deliberately limited to command-line shape: subcommand names,
//! flags, positional arguments, and user-facing help text. It does not know how
//! to parse or decrypt `.sig` files. Keeping those responsibilities out of the
//! CLI layer makes the format code easier to test and reuse from future commands.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
/// Top-level command-line parser for the `sig` binary.
///
/// `clap` derives `--help`, `--version`, shell-friendly error messages, and the
/// dispatch table for all subcommands from this struct. New subcommands should
/// be added to [`Command`] rather than handled manually in `main`.
#[derive(Parser)]
#[command(version, about = "Interact with Centauri Carbon 2 .sig packages")]
pub struct Cli {
/// The action to run.
#[command(subcommand)]
pub command: Command,
}
/// 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.
#[derive(Subcommand)]
pub enum Command {
/// Strip the .sig wrapper and write the unpacked payload.
Strip(StripArgs),
}
/// Arguments accepted by the `strip` subcommand.
///
/// The command mirrors the browser-based unpacker at
/// <https://docs.opencentauri.cc/extras/cc2_update_decrypt.html>: read a `.sig`
/// file, remove the header, decrypt the payload when needed, trim it to the
/// payload size declared in the header, then write the resulting package bytes.
#[derive(Args)]
pub struct StripArgs {
/// Input `.sig` file.
///
/// The file must begin with the Centauri/Elegoo `ELEG` magic value and must
/// contain the 512-byte header used by Carbon 2 update packages.
pub input: PathBuf,
/// Output path.
///
/// When omitted, the default output is the input filename with a trailing
/// `.sig` extension removed. For example, `update.swu.sig` writes
/// `update.swu`.
#[arg(short, long)]
pub output: Option<PathBuf>,
}
+157
View File
@@ -0,0 +1,157 @@
//! Centauri Carbon 2 `.sig` package parsing and AES-CBC 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
//! type byte whose high bit marks encrypted payloads, stores the unwrapped
//! payload size as a little-endian `u64`, and stores the AES-CBC IV at offset
//! `0xA0`. The wrapped payload starts immediately after the header.
//!
//! This module implements the same unpacking behavior as the public web tool at
//! <https://docs.opencentauri.cc/extras/cc2_update_decrypt.html>. It does not
//! attempt to validate signatures or hashes yet; it only removes the wrapper and
//! decrypts encrypted payloads.
use std::error::Error;
use aes::Aes256;
use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding};
/// Length of the fixed `.sig` header in bytes.
pub const HEADER_LEN: usize = 512;
/// Big-endian `ELEG` magic value found at the beginning of supported packages.
const MAGIC: u32 = 0x454C4547;
/// AES-256-CBC key used by Carbon 2 update packages.
///
/// This key is taken from the referenced browser implementation. The IV is not
/// fixed; each package supplies its IV in the `.sig` header at bytes
/// `0xA0..0xB0`.
const AES_KEY: [u8; 32] = [
0xD1, 0x4E, 0x15, 0x08, 0x43, 0xE9, 0xD1, 0x68, 0x93, 0x89, 0x07, 0x56, 0xD8, 0xF7, 0x7F, 0x67,
0x4E, 0x16, 0x1A, 0x8B, 0xEB, 0xB8, 0xF7, 0x20, 0x73, 0x7E, 0xE6, 0x0E, 0x7F, 0x8C, 0x7E, 0x68,
];
type Aes256CbcDec = cbc::Decryptor<Aes256>;
/// Parsed subset of a Carbon 2 `.sig` header.
///
/// The header contains additional metadata such as package type, version,
/// encrypted byte ranges, and SHA-256 material. `strip` only needs the fields
/// represented here: whether the payload is encrypted, how many bytes should be
/// emitted after unwrap, and which IV to use for AES-CBC.
#[derive(Debug)]
struct Header {
/// Whether the high bit of the package type byte is set.
is_encrypted: bool,
/// Number of plaintext payload bytes to write after removing encryption and
/// any trailing block padding.
filesize: usize,
/// Per-package AES-CBC initialization vector.
iv: [u8; 16],
}
/// Unpack a complete `.sig` file held in memory.
///
/// The input must include both the 512-byte header and the wrapped payload. The
/// returned bytes are suitable to write directly as the stripped output package,
/// such as a `.swu`, `.zip`, or `.json` file.
///
/// For encrypted packages, this function decrypts the bytes after the header
/// with AES-256-CBC and then truncates the plaintext to the `filesize` recorded
/// in the header. For unencrypted packages, it simply copies exactly `filesize`
/// bytes from the post-header payload.
pub fn unpack_sig(raw: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
if raw.len() < HEADER_LEN {
return Err("input is too small to contain a .sig header".into());
}
let header = parse_header(&raw[..HEADER_LEN])?;
let payload = &raw[HEADER_LEN..];
if header.is_encrypted {
decrypt_payload(payload, &header)
} else {
strip_plain_payload(payload, header.filesize)
}
}
/// Parse the fixed-width `.sig` header.
///
/// Offsets are based on the browser implementation:
///
/// - `0x00..0x04`: big-endian `ELEG` magic.
/// - `0x04`: package type; bit `0x80` indicates encryption.
/// - `0x08..0x10`: little-endian plaintext payload size.
/// - `0xA0..0xB0`: AES-CBC IV.
fn parse_header(header: &[u8]) -> Result<Header, Box<dyn Error>> {
if header.len() < HEADER_LEN {
return Err("header must be at least 512 bytes".into());
}
let magic = u32::from_be_bytes(header[0..4].try_into()?);
if magic != MAGIC {
return Err(format!("invalid .sig magic: 0x{magic:08x}").into());
}
let package_type = header[0x04];
let is_encrypted = package_type & 0x80 != 0;
let filesize = u64::from_le_bytes(header[0x08..0x10].try_into()?)
.try_into()
.map_err(|_| "payload filesize does not fit on this platform")?;
let iv = header[0xA0..0xB0].try_into()?;
Ok(Header {
is_encrypted,
filesize,
iv,
})
}
/// Return the plaintext payload for packages that are marked unencrypted.
///
/// Even unencrypted packages still include the `.sig` header. The header's
/// `filesize` field defines how many bytes after the header are part of the
/// actual package payload.
fn strip_plain_payload(payload: &[u8], filesize: usize) -> Result<Vec<u8>, Box<dyn Error>> {
if filesize > payload.len() {
return Err(format!(
"header payload size {} exceeds payload size {}",
filesize,
payload.len()
)
.into());
}
Ok(payload[..filesize].to_vec())
}
/// Decrypt and trim an encrypted `.sig` payload.
///
/// The browser version appends an extra encrypted PKCS#7 padding block to work
/// around Web Crypto's required padding behavior. Rust's `cbc` crate can decrypt
/// without padding, so this implementation decrypts the payload as-is and then
/// trims to the plaintext length declared in the header.
fn decrypt_payload(payload: &[u8], header: &Header) -> Result<Vec<u8>, Box<dyn Error>> {
if payload.len() % 16 != 0 {
return Err("encrypted payload length is not a multiple of the AES block size".into());
}
let mut buffer = payload.to_vec();
let decrypted = Aes256CbcDec::new(&AES_KEY.into(), &header.iv.into())
.decrypt_padded_mut::<NoPadding>(&mut buffer)
.map_err(|_| "AES-CBC decryption failed")?;
if header.filesize > decrypted.len() {
return Err(format!(
"header payload size {} exceeds decrypted payload size {}",
header.filesize,
decrypted.len()
)
.into());
}
Ok(decrypted[..header.filesize].to_vec())
}
+60 -2
View File
@@ -1,3 +1,61 @@
fn main() {
println!("Hello, world!");
//! Command-line entry point for the `sig` utility.
//!
//! The binary intentionally keeps very little logic here. Clap-owned command
//! definitions live in [`cli`], while the Centauri Carbon 2 `.sig` format and
//! cryptographic details live in [`crypto`]. That split keeps argument parsing
//! separate from file-format handling so future commands can reuse the same
//! unpacking code without duplicating CLI concerns.
use std::{error::Error, fs, path::PathBuf};
use clap::Parser;
mod cli;
mod crypto;
#[cfg(test)]
mod tests;
use cli::{Cli, Command};
fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
match cli.command {
Command::Strip(args) => strip(args)?,
}
Ok(())
}
/// Execute the `strip` subcommand.
///
/// `strip` removes the 512-byte `.sig` wrapper and writes the contained package
/// bytes. For encrypted packages, [`crypto::unpack_sig`] performs the AES-CBC
/// decryption before returning the payload.
fn strip(args: cli::StripArgs) -> Result<(), Box<dyn Error>> {
let raw = fs::read(&args.input)?;
let unpacked = crypto::unpack_sig(&raw)?;
let output_path = args
.output
.unwrap_or_else(|| default_output_path(&args.input));
fs::write(&output_path, unpacked)?;
println!("wrote {}", output_path.display());
Ok(())
}
/// Derive the default output path used by the web unpacker.
///
/// A conventional input like `firmware.zip.sig` becomes `firmware.zip`. If the
/// input does not use the `.sig` extension, the tool still writes beside the
/// original file but changes the extension to `.decrypted` to avoid overwriting
/// the input by accident.
fn default_output_path(input: &std::path::Path) -> PathBuf {
if input.extension().is_some_and(|ext| ext == "sig") {
input.with_extension("")
} else {
input.with_extension("decrypted")
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Tests for the Carbon 2 `.sig` unpacking path.
/// Encrypted `ota-package-list.json.sig` fixture bytes.
///
/// The bytes are embedded directly so the test does not depend on external test
/// fixture files being present at runtime.
const ENCRYPTED_OTA_PACKAGE_LIST: &[u8] = &[
0x45, 0x4c, 0x45, 0x47, 0x83, 0x01, 0x02, 0x00, 0xaf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x6f, 0x74, 0x61, 0x2d, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x2d, 0x6c, 0x69, 0x73, 0x74,
0x2e, 0x6a, 0x73, 0x6f, 0x6e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x90, 0x8e, 0x89, 0x12, 0x45, 0xea, 0x37, 0x14, 0xad, 0x5b, 0x8e, 0x5f, 0xa9, 0xcf, 0xbb, 0xc9,
0xb0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xbf, 0x17, 0x4e, 0xde, 0xae, 0x8d, 0x1e, 0x00, 0xe2, 0xdd, 0x44, 0x99, 0x42, 0x32, 0x8e, 0x1a,
0x79, 0xba, 0x2e, 0x50, 0x3d, 0x16, 0x28, 0xaa, 0x50, 0xf8, 0x2d, 0xaf, 0x35, 0x4d, 0x1c, 0xec,
0x5d, 0xe0, 0x8d, 0x4c, 0x80, 0x44, 0xda, 0xc7, 0x63, 0x5c, 0xa7, 0x0f, 0x99, 0xe3, 0x81, 0xb8,
0x27, 0xef, 0xd2, 0x6f, 0xc0, 0x49, 0x91, 0x52, 0xa1, 0x0c, 0x32, 0xea, 0xc1, 0x5c, 0xb8, 0xd1,
0xb8, 0xe4, 0x65, 0x97, 0x4e, 0x4f, 0x2c, 0x7d, 0x8c, 0xb4, 0xf2, 0x65, 0x92, 0xbc, 0x90, 0x97,
0x6d, 0xc1, 0xc1, 0x13, 0xa0, 0xf4, 0xd1, 0xe4, 0xfe, 0x28, 0x77, 0xbc, 0xfe, 0x1f, 0x90, 0x52,
0x72, 0x03, 0x56, 0xc0, 0xce, 0xbb, 0x63, 0x0d, 0xf2, 0x52, 0x81, 0x52, 0xaa, 0xb9, 0xc3, 0x59,
0xf2, 0x58, 0xa8, 0xb6, 0xb1, 0x12, 0xcd, 0x5d, 0x99, 0x11, 0x85, 0x80, 0x2e, 0xa7, 0x1d, 0x84,
0xff, 0x94, 0x4f, 0xc7, 0x94, 0x14, 0xe2, 0x48, 0xb8, 0x50, 0xc8, 0xff, 0xec, 0x23, 0xf3, 0xdb,
0x11, 0x9c, 0xca, 0xc3, 0x6a, 0xd1, 0x42, 0xbc, 0x5b, 0x4b, 0x8e, 0xcf, 0xb3, 0x74, 0x1f, 0x34,
0x80, 0xad, 0xa6, 0x39, 0x98, 0x15, 0x30, 0x31, 0xca, 0x21, 0x03, 0x67, 0x69, 0x50, 0x04, 0xd9,
0xc5, 0x4a, 0xea, 0xd7, 0xed, 0xdc, 0x32, 0xa3, 0x33, 0xc8, 0x87, 0x21, 0xf8, 0x21, 0x96, 0xad,
0xf8, 0xf5, 0xb4, 0xe1, 0xe9, 0xc3, 0x72, 0x2f, 0x88, 0x03, 0xa8, 0x89, 0x65, 0x39, 0xb6, 0x53,
0x98, 0xef, 0xcb, 0x5a, 0x54, 0x13, 0x4c, 0x4a, 0x55, 0xe9, 0x91, 0x90, 0xcb, 0x39, 0xf0, 0x00,
0xee, 0x31, 0xed, 0xa4, 0x43, 0x06, 0x93, 0x75, 0x38, 0x6a, 0xe3, 0xe0, 0x54, 0x8f, 0x0a, 0x3a,
0xf3, 0x79, 0x21, 0x9f, 0x41, 0x97, 0xac, 0x80, 0x8b, 0x87, 0xd0, 0x44, 0x11, 0x04, 0xc6, 0x76,
0x82, 0x5a, 0x35, 0x77, 0x8f, 0x15, 0x05, 0x05, 0x55, 0xfb, 0xeb, 0x46, 0x1a, 0xe8, 0xb8, 0x73,
0x0d, 0xa7, 0xd8, 0xd1, 0x18, 0x3e, 0x46, 0x24, 0x86, 0x67, 0xc3, 0x0c, 0xe5, 0x7d, 0x56, 0x77,
0x3b, 0xd3, 0xd6, 0x95, 0x15, 0x65, 0xf5, 0xb8, 0x68, 0x8d, 0xc9, 0x0b, 0x16, 0x45, 0x91, 0xef,
0xcc, 0xe5, 0x54, 0x16, 0x9b, 0x8c, 0x2c, 0x76, 0x4b, 0xa2, 0x44, 0xbc, 0x49, 0xda, 0x33, 0x04,
0xc5, 0xb6, 0xd7, 0xc6, 0x3b, 0x49, 0x95, 0x08, 0x8c, 0xd3, 0x7e, 0x2b, 0x65, 0xe6, 0x83, 0x62,
0x81, 0xd3, 0x11, 0x24, 0x5a, 0xac, 0x9b, 0xd3, 0xcd, 0x19, 0x06, 0xde, 0xf3, 0x0a, 0xb4, 0x5f,
0x18, 0xc6, 0x06, 0x60, 0x67, 0xca, 0x0c, 0x36, 0x34, 0xb8, 0x92, 0x69, 0xde, 0xf6, 0xe7, 0x64,
0x58, 0x80, 0x4f, 0x5c, 0xf5, 0x44, 0x62, 0x21, 0x2c, 0x9d, 0xe4, 0x18, 0x71, 0xba, 0x18, 0x43,
0x0f, 0x44, 0xa8, 0x1b, 0x35, 0x22, 0x36, 0x77, 0x0c, 0xc6, 0x22, 0x46, 0x4c, 0x11, 0xec, 0xfd,
0xb9, 0xef, 0x51, 0xb5, 0xb2, 0x8e, 0x3f, 0x2f, 0xa5, 0x7c, 0x3c, 0x46, 0x57, 0xfd, 0x5c, 0x8c,
0x6f, 0xc4, 0x13, 0x99, 0xf0, 0x06, 0x01, 0x5f, 0x92, 0x51, 0xc6, 0x94, 0x29, 0xf5, 0xb4, 0xd0,
0x5e, 0x2f, 0x1e, 0x0a, 0x93, 0x8d, 0x08, 0x00, 0xcd, 0x71, 0x4d, 0x6c, 0x11, 0xad, 0xe3, 0x78,
0xba, 0xd3, 0x6d, 0xe8, 0xf5, 0x93, 0xd6, 0x83, 0x94, 0x9d, 0xf1, 0xf0, 0xde, 0x04, 0xcc, 0x41,
];
/// Expected plaintext `ota-package-list.json` fixture bytes.
const EXPECTED_OTA_PACKAGE_LIST: &[u8] = &[
0x7b, 0x22, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x22, 0x3a, 0x20, 0x5b, 0x7b, 0x22,
0x66, 0x69, 0x6c, 0x65, 0x22, 0x3a, 0x20, 0x22, 0x63, 0x63, 0x32, 0x5f, 0x65, 0x65, 0x62, 0x30,
0x30, 0x31, 0x5f, 0x30, 0x31, 0x2e, 0x30, 0x33, 0x2e, 0x30, 0x32, 0x2e, 0x33, 0x36, 0x5f, 0x32,
0x30, 0x32, 0x36, 0x30, 0x33, 0x32, 0x36, 0x31, 0x37, 0x31, 0x37, 0x34, 0x35, 0x2e, 0x73, 0x77,
0x75, 0x2e, 0x73, 0x69, 0x67, 0x22, 0x2c, 0x20, 0x22, 0x68, 0x61, 0x73, 0x68, 0x22, 0x3a, 0x20,
0x22, 0x34, 0x36, 0x61, 0x61, 0x34, 0x35, 0x39, 0x62, 0x36, 0x31, 0x33, 0x34, 0x30, 0x61, 0x38,
0x34, 0x66, 0x66, 0x35, 0x32, 0x65, 0x32, 0x38, 0x36, 0x35, 0x33, 0x34, 0x37, 0x64, 0x34, 0x32,
0x61, 0x35, 0x36, 0x38, 0x33, 0x35, 0x31, 0x39, 0x31, 0x39, 0x34, 0x34, 0x31, 0x66, 0x62, 0x39,
0x64, 0x62, 0x36, 0x65, 0x31, 0x38, 0x35, 0x34, 0x32, 0x34, 0x32, 0x32, 0x30, 0x62, 0x31, 0x39,
0x32, 0x22, 0x7d, 0x5d, 0x2c, 0x20, 0x22, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x3a,
0x20, 0x22, 0x30, 0x31, 0x2e, 0x30, 0x33, 0x2e, 0x30, 0x32, 0x2e, 0x33, 0x36, 0x22, 0x7d,
];
/// Decrypting the signed OTA package list should reproduce the known plaintext
/// JSON fixture byte-for-byte.
#[test]
fn decrypts_ota_package_list_fixture() {
let actual = super::crypto::unpack_sig(ENCRYPTED_OTA_PACKAGE_LIST)
.expect("failed to unpack embedded .sig fixture");
assert_eq!(actual, EXPECTED_OTA_PACKAGE_LIST);
}