Add sig encrypt command

This commit is contained in:
Michael Mikovsky
2026-05-03 17:59:58 -06:00
parent d9729ddb99
commit ace2294748
5 changed files with 255 additions and 5 deletions
+47 -3
View File
@@ -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
View File
@@ -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())
}
+43
View File
@@ -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)
}
+24
View File
@@ -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]
);
}
}