mirror of
https://github.com/Astatin3/CC2.git
synced 2026-06-09 00:18:00 -06:00
Add sig encrypt command
This commit is contained in:
@@ -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"
|
||||
|
||||
+47
-3
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
+139
-2
@@ -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<Aes256>;
|
||||
type Aes256CbcEnc = cbc::Encryptor<Aes256>;
|
||||
|
||||
/// Parsed subset of a Carbon 2 `.sig` header.
|
||||
///
|
||||
@@ -78,6 +84,120 @@ pub fn unpack_sig(raw: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, Box<dyn Error>> {
|
||||
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<Vec<u8>, Box<dyn Error>> {
|
||||
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<Vec<u8>, Box<dyn Error>> {
|
||||
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<Vec<u8>, Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<Vec<u8>, Box<dyn E
|
||||
|
||||
Ok(decrypted[..header.filesize].to_vec())
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext payload with AES-256-CBC and no padding mode.
|
||||
///
|
||||
/// The observed Carbon 2 packages use PKCS#7 padding for encryption. The
|
||||
/// original byte length is stored separately in the header, so [`unpack_sig`]
|
||||
/// can decrypt without removing padding and then trim to the exact payload size.
|
||||
fn encrypt_payload(payload: &[u8], iv: &[u8; 16]) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
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::<Pkcs7>(&mut buffer, payload.len())
|
||||
.map_err(|_| "AES-CBC encryption failed")?;
|
||||
|
||||
Ok(encrypted.to_vec())
|
||||
}
|
||||
|
||||
@@ -23,11 +23,41 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
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<dyn Error>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user