Sucessfully repack OTA!

This commit is contained in:
Michael Mikovsky
2026-05-03 21:26:14 -06:00
parent 309d01bc30
commit ef1eced02d
18 changed files with 828 additions and 65 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
# CC2
My research for the Centauri Carbon 2
My research for the Centauri Carbon 2.
> A lot of this is AI-generated so please be courteous!
+2 -1
View File
@@ -8,4 +8,5 @@ aes = "0.8.4"
cbc = "0.1.2"
clap = { version = "4.5.37", features = ["derive"] }
rand = "0.8.5"
sha2 = "0.10.9"
rsa = "0.9.8"
sha2 = { version = "0.10.9", features = ["oid"] }
+203
View File
@@ -0,0 +1,203 @@
# OTA Unpack And Repack Guide
This guide describes how to unpack and repack a Centauri Carbon 2 OTA update using
the tools in this directory.
## Build The Tool
```bash
cargo build
```
The compiled tool is:
```bash
target/debug/sig
```
## Inspect A `.sig` File
Print parsed header information:
```bash
target/debug/sig info path/to/file.sig
```
Print raw unknown header bytes too:
```bash
target/debug/sig info --raw path/to/file.sig
```
Important fields:
- `encrypted`: whether the payload is AES-CBC encrypted.
- `filesize`: plaintext payload size.
- `stored_sha256`: SHA-256 of the payload stored in the header.
- `payload_sha256_matches`: whether the stored hash matches the actual payload.
- `signature_valid`: whether the RSA signature at `0x100..0x200` verifies.
## Unpack A Single `.sig`
```bash
target/debug/sig unpack input.sig -o output-file
```
Examples:
```bash
target/debug/sig unpack ota-package-list.json.sig -o ota-package-list.json
target/debug/sig unpack cc2_eeb001_01.03.02.36_20260326171745.swu.sig -o update.swu
target/debug/sig unpack release.zip.sig -o release.zip
```
`unpack` removes the 512-byte `.sig` header. If the payload is encrypted, it also
decrypts it with the Carbon 2 AES key and trims the result to the plaintext size
stored in the header.
## Repack A Single `.sig`
Use `repack` to wrap a file in a valid signed `.sig` header:
```bash
target/debug/sig repack input-file -o output.sig
```
For encrypted inner files, use a template from the original package:
```bash
target/debug/sig repack modified.swu \
--template original.swu.sig \
-o modified.swu.sig
```
```bash
target/debug/sig repack ota-package-list.json \
--template original-ota-package-list.json.sig \
-o ota-package-list.json.sig
```
Template mode preserves metadata such as package type, version, IV, filename, and
unknown header fields, then rewrites the size, payload hash, and RSA signature.
To force encryption without a template:
```bash
target/debug/sig repack input-file --encrypt -o input-file.sig
```
Without `--encrypt` and without a template, `repack` writes a plain signed `.sig`.
## Unpack A Full OTA Update
The full OTA package has this structure:
```text
release.zip.sig
└── release.zip
├── cc2_...swu.sig
│ └── cc2_...swu
│ └── SWU CPIO archive contents
└── ota-package-list.json.sig
└── ota-package-list.json
```
Use the helper script:
```bash
scripts/undo-release-package.sh \
cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip.sig \
release-unpacked
```
This creates:
```text
release-unpacked/
├── templates/
│ ├── release.zip.sig
│ ├── cc2_...swu.sig
│ └── ota-package-list.json.sig
├── release-signed/
│ ├── cc2_...swu.sig
│ └── ota-package-list.json.sig
├── release/
│ ├── cc2_...swu
│ └── ota-package-list.json
├── swu/
│ ├── .swu-manifest.json
│ ├── sw-description
│ ├── resource
│ ├── uboot
│ ├── boot0
│ ├── kernel
│ ├── rootfs
│ └── cpio_item_md5
└── repack.env
```
Edit files under `release-unpacked/swu/` to change SWU contents. For example, to
replace the rootfs image:
```bash
cp -p test/rootfs_modified release-unpacked/swu/rootfs
```
## Repack A Full OTA Update
After editing the unpacked contents, run:
```bash
scripts/redo-release-package.sh \
release-unpacked \
repacked.zip.sig
```
The script:
- Rebuilds the SWU CPIO archive from `release-unpacked/swu/`.
- Recomputes `cpio_item_md5`.
- Re-signs the SWU `.sig` using the original SWU `.sig` as a template.
- Updates the hash in `ota-package-list.json`.
- Re-signs `ota-package-list.json.sig` using its original template.
- Rebuilds the release ZIP while preserving original ZIP timestamps and modes.
- Re-signs the outer `.zip.sig` using the original outer `.sig` as a template.
If no files were changed, the rebuilt package should be bit-perfect:
```bash
cmp repacked.zip.sig original.zip.sig
```
If files were changed, the rebuilt package should differ, but all `.sig` headers
should still validate:
```bash
target/debug/sig info repacked.zip.sig
```
To validate inner signatures, unpack the release ZIP and run `info` on the inner
`.sig` files:
```bash
target/debug/sig unpack repacked.zip.sig -o /tmp/release.zip
unzip -q /tmp/release.zip -d /tmp/release
target/debug/sig info /tmp/release/cc2_eeb001_01.03.02.36_20260326171745.swu.sig
target/debug/sig info /tmp/release/ota-package-list.json.sig
```
Each should show:
```text
payload_sha256_matches: true
signature_valid: true
```
## Notes
- Inner `.sig` files are encrypted and signed.
- The outer `.zip.sig` is plain but signed.
- The RSA signature is stored in header bytes `0x100..0x200`.
- The signature signs the SHA-256 digest stored at `0xE0..0x100`.
- Reusing old signatures after modifying payloads will not work. Always use
`repack` or `redo-release-package.sh` so signatures are regenerated.
+1
View File
@@ -0,0 +1 @@
This is a pile of badly made tooling to repack CC2 OTAs
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
printf 'Usage: %s input-dir [output.zip]\n' "$0" >&2
exit 1
fi
indir=$1
output=${2:-release.zip}
python3 - "$indir" "$output" <<'PY'
import pathlib
import stat
import sys
import zipfile
def fail(message):
print(message, file=sys.stderr)
sys.exit(1)
if len(sys.argv) != 3:
fail("Usage: build-release-zip.sh input-dir [output.zip]")
root = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
if not root.is_dir():
fail(f"input directory does not exist: {root}")
entries = sorted(path for path in root.iterdir() if path.is_file())
if not entries:
fail(f"input directory contains no regular files: {root}")
def zip_date_time(path):
modified = path.stat().st_mtime
return tuple(__import__("time").localtime(modified)[:6])
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w") as archive:
for src in entries:
mode = stat.S_IMODE(src.stat().st_mode)
info = zipfile.ZipInfo(src.name, date_time=zip_date_time(src))
info.create_system = 3
info.external_attr = (stat.S_IFREG | mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(
info,
src.read_bytes(),
compress_type=zipfile.ZIP_DEFLATED,
compresslevel=6,
)
print(f"wrote {output}")
PY
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
printf 'Usage: %s unpacked-dir [output.zip.sig]\n' "$0" >&2
exit 1
fi
workdir=$1
output=${2:-$workdir/repacked.zip.sig}
env_file="$workdir/repack.env"
if [[ ! -f "$env_file" ]]; then
printf 'missing repack metadata: %s\n' "$env_file" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$env_file"
required=(
"$workdir/templates/release.zip.sig"
"$workdir/templates/$SWU_SIG_NAME"
"$workdir/templates/$OTA_SIG_NAME"
"$workdir/release-signed/$SWU_SIG_NAME"
"$workdir/release-signed/$OTA_SIG_NAME"
"$workdir/release/$OTA_NAME"
"$workdir/swu/.swu-manifest.json"
)
for path in "${required[@]}"; do
if [[ ! -e "$path" ]]; then
printf 'missing required repack input: %s\n' "$path" >&2
exit 1
fi
done
cargo build --quiet
sig_bin=target/debug/sig
build_dir="$workdir/build"
release_dir="$build_dir/release"
rebuilt_swu="$build_dir/$SWU_NAME"
rebuilt_ota="$build_dir/$OTA_NAME"
rebuilt_zip="$build_dir/$ZIP_NAME"
rm -rf "$build_dir"
mkdir -p "$release_dir"
scripts/build-swu.sh "$workdir/swu" "$rebuilt_swu"
"$sig_bin" repack "$rebuilt_swu" --template "$workdir/templates/$SWU_SIG_NAME" -o "$release_dir/$SWU_SIG_NAME"
cp "$workdir/release/$OTA_NAME" "$rebuilt_ota"
python3 - "$rebuilt_ota" "$workdir/release/$OTA_NAME" "$release_dir/$SWU_SIG_NAME" "$SWU_SIG_NAME" <<'PY'
import hashlib
import json
import pathlib
import sys
ota_path = pathlib.Path(sys.argv[1])
source_ota_path = pathlib.Path(sys.argv[2])
swu_sig_path = pathlib.Path(sys.argv[3])
swu_sig_name = sys.argv[4]
data = json.loads(ota_path.read_text())
digest = hashlib.sha256(swu_sig_path.read_bytes()).hexdigest()
matched = False
for package in data.get("packages", []):
if package.get("file") == swu_sig_name:
package["hash"] = digest
matched = True
if not matched:
raise SystemExit(f"ota package list does not reference {swu_sig_name}")
updated = json.dumps(data, separators=(", ", ": "))
ota_path.write_text(updated)
source_ota_path.write_text(updated)
PY
"$sig_bin" repack "$rebuilt_ota" --template "$workdir/templates/$OTA_SIG_NAME" -o "$release_dir/$OTA_SIG_NAME"
touch -r "$workdir/release-signed/$SWU_SIG_NAME" "$release_dir/$SWU_SIG_NAME"
touch -r "$workdir/release-signed/$OTA_SIG_NAME" "$release_dir/$OTA_SIG_NAME"
chmod --reference="$workdir/release-signed/$SWU_SIG_NAME" "$release_dir/$SWU_SIG_NAME"
chmod --reference="$workdir/release-signed/$OTA_SIG_NAME" "$release_dir/$OTA_SIG_NAME"
scripts/build-release-zip.sh "$release_dir" "$rebuilt_zip"
"$sig_bin" repack "$rebuilt_zip" --template "$workdir/templates/release.zip.sig" -o "$output"
printf 'rebuilt %s\n' "$output"
if [[ -n "${ORIGINAL_SIG:-}" && -f "$ORIGINAL_SIG" ]]; then
if cmp -s "$output" "$ORIGINAL_SIG"; then
printf 'bit-perfect match: %s\n' "$ORIGINAL_SIG"
else
printf 'warning: rebuilt output differs from %s\n' "$ORIGINAL_SIG" >&2
exit 2
fi
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
printf 'Usage: %s input.zip.sig [output-dir]\n' "$0" >&2
exit 1
fi
input=$1
outdir=${2:-release-unpacked}
if [[ ! -f "$input" ]]; then
printf 'input does not exist: %s\n' "$input" >&2
exit 1
fi
if [[ -e "$outdir" && -n "$(ls -A "$outdir")" ]]; then
printf 'output directory is not empty: %s\n' "$outdir" >&2
exit 1
fi
mkdir -p "$outdir" "$outdir/templates" "$outdir/release-signed" "$outdir/release" "$outdir/swu"
cargo build --quiet
sig_bin=target/debug/sig
base=$(basename "$input")
zip_name=${base%.sig}
zip_path="$outdir/$zip_name"
cp -p "$input" "$outdir/templates/release.zip.sig"
"$sig_bin" unpack "$input" -o "$zip_path"
unzip -q "$zip_path" -d "$outdir/release-signed"
mapfile -t signed_files < <(python3 - "$zip_path" <<'PY'
import sys
import zipfile
with zipfile.ZipFile(sys.argv[1]) as archive:
for item in archive.infolist():
if not item.is_dir():
print(item.filename)
PY
)
swu_sig_name=''
ota_sig_name=''
for name in "${signed_files[@]}"; do
case "$name" in
*.swu.sig) swu_sig_name=$name ;;
ota-package-list.json.sig) ota_sig_name=$name ;;
esac
done
if [[ -z "$swu_sig_name" || -z "$ota_sig_name" ]]; then
printf 'release zip must contain one .swu.sig and ota-package-list.json.sig\n' >&2
exit 1
fi
swu_name=${swu_sig_name%.sig}
ota_name=${ota_sig_name%.sig}
cp -p "$outdir/release-signed/$swu_sig_name" "$outdir/templates/$swu_sig_name"
cp -p "$outdir/release-signed/$ota_sig_name" "$outdir/templates/$ota_sig_name"
"$sig_bin" unpack "$outdir/release-signed/$swu_sig_name" -o "$outdir/release/$swu_name"
"$sig_bin" unpack "$outdir/release-signed/$ota_sig_name" -o "$outdir/release/$ota_name"
scripts/extract-swu.sh "$outdir/release/$swu_name" "$outdir/swu"
cat > "$outdir/repack.env" <<EOF
ORIGINAL_SIG=$input
ZIP_NAME=$zip_name
SWU_SIG_NAME=$swu_sig_name
SWU_NAME=$swu_name
OTA_SIG_NAME=$ota_sig_name
OTA_NAME=$ota_name
EOF
printf 'unpacked %s to %s\n' "$input" "$outdir"
+43 -20
View File
@@ -29,21 +29,37 @@ pub struct Cli {
/// 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),
/// Unpack the .sig wrapper and write the contained payload.
#[command(alias = "strip")]
Unpack(UnpackArgs),
/// Encrypt a file and wrap it in a .sig container.
Encrypt(EncryptArgs),
/// Print parsed .sig header information.
Info(InfoArgs),
/// Repack a file into a signed .sig container.
#[command(alias = "encrypt")]
Repack(RepackArgs),
}
/// Arguments accepted by the `strip` subcommand.
/// Arguments accepted by the `info` subcommand.
#[derive(Args)]
pub struct InfoArgs {
/// Input `.sig` file to inspect.
pub input: PathBuf,
/// Print exact hex bytes for unknown/reserved header regions.
#[arg(long)]
pub raw: bool,
}
/// Arguments accepted by the `unpack` 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 {
pub struct UnpackArgs {
/// Input `.sig` file.
///
/// The file must begin with the Centauri/Elegoo `ELEG` magic value and must
@@ -59,20 +75,19 @@ pub struct StripArgs {
pub output: Option<PathBuf>,
}
/// Arguments accepted by the `encrypt` subcommand.
/// Arguments accepted by the `repack` 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.
/// This command performs the inverse of `unpack`: it writes a 512-byte `.sig`
/// header, signs the payload hash, and appends either plaintext or encrypted
/// payload bytes. When a template is supplied, unknown header metadata and the
/// template encryption mode are preserved by default.
#[derive(Args)]
pub struct EncryptArgs {
pub struct RepackArgs {
/// 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.
/// With `--encrypt`, the bytes are copied into the encrypted payload with
/// PKCS#7 padding. The original unpadded length is stored in the header so
/// `unpack` can recover the exact input bytes.
pub input: PathBuf,
/// Output `.sig` path.
@@ -82,9 +97,18 @@ pub struct EncryptArgs {
#[arg(short, long)]
pub output: Option<PathBuf>,
/// Filename to store inside the `.sig` header.
/// Encrypt the payload before writing it.
///
/// Defaults to the input file name. The `.sig` format reserves 48 bytes for
/// Without a template, repack writes a plain signed `.sig` unless this flag
/// is set. With a template, the command preserves the template encryption
/// mode unless this flag is set, in which case output is encrypted.
#[arg(long)]
pub encrypt: bool,
/// Override the filename stored inside the `.sig` header.
///
/// Defaults to the template header filename when `--template` is provided,
/// otherwise 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)]
@@ -94,8 +118,7 @@ pub struct EncryptArgs {
///
/// 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.
/// metadata, then rewrites size, filename, payload hash, and RSA signature.
#[arg(long)]
pub template: Option<PathBuf>,
}
+264 -24
View File
@@ -19,6 +19,11 @@ use cbc::cipher::{
block_padding::{NoPadding, Pkcs7},
};
use rand::{RngCore, rngs::OsRng};
use rsa::{
RsaPrivateKey, RsaPublicKey,
pkcs1v15::Pkcs1v15Sign,
pkcs8::{DecodePrivateKey, DecodePublicKey},
};
use sha2::{Digest, Sha256};
/// Length of the fixed `.sig` header in bytes.
@@ -37,6 +42,47 @@ const AES_KEY: [u8; 32] = [
0x4E, 0x16, 0x1A, 0x8B, 0xEB, 0xB8, 0xF7, 0x20, 0x73, 0x7E, 0xE6, 0x0E, 0x7F, 0x8C, 0x7E, 0x68,
];
/// RSA private key used to sign the SHA-256 digest stored in `.sig` headers.
const RSA_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCgET+oNgUA1acA
vyESnNsTy+Ae+k6Q/LLM5bh4sLfFeu0TT9Ka4SQa6LffDLJvaqjt++jsB2PwPvZc
clHcG5yC2bNikz+x2WaHCQOI26vzy8oh8x+wLI77GfVG82mWpo6gHfyu51LBvCDo
aDfghSUL7B9lIaCDb0dbBfpjTTrQr0UcUET9RvsrjMkyLI5jfBnd1VNTvFD9gngc
wAmZ7s65UIEkKjmQZH+xK+gDa+oZBJpjz7vgXEezIVSDuZQKQFB/4E5YErftRwNp
h7rngDTpyFYjaAvD9ZoM90exFWtRWMvjt2HNtDUOHjRI4C5XToqm/FfPxYAproJU
PyE6ckFrAgMBAAECggEAIzAHhWvyp59QKiraE2RmCLEN4OF3ugnDKKXraqS2kXQX
f+JRUvjhXgUAvsjkxPd2kXKKXrC1OJAuyl3bPv7W5jEDbU0feHJpRpAltcVMxLht
BA+VTL5O5EZtlB5YfOS6f9p3vN9fYvV/anfWqMW8QiWzNSEyTxJ8ZjcnNwM4Rb2Y
hMxDpQ8EMIK4Hwchmv8V2vNIY1CnIFz+i1U722PnnR6wDO4obFX2wh+6KCBuTrwW
babpNhGpW2P/f33HoeVhoJkt4k7kogM45cqPwC9i3YKpEgeHdo6aRs+h/hhFk9BV
NKks3lK6kY20KvGLyhauh9usKZRiONxn/w0YmB4CeQKBgQC2tuIqXg1KARaZ7FkY
Bw/2sr2uCbiN3vgP30RMWNtrtCluZ49HSphV8Td69qgI6hXyFBzm1uaLA8gAZ6DR
r9VYu6uQTLZDhMkd36pbcCQsvmF1Q9yifBd3nBHZWt6eek61mbK0MrBq2ofEDSAd
dPV1VEAMthB20yAT7qI33XLj7QKBgQDgRPhATfkdVcRE0D4+XkIUmNUF7MLzRfxq
ZvXhhxci0d2TtkIKhMzNFQrmIaIp8ex8mpbmyebmsLX6lINjvQAzmHGuk5niOR8w
T5Lkj8WY9E7lw+clApo5TXLJunHxEdtamASdjRdI1lAGNhG4SzH/ea6OtR/HeziA
kQ6NMOI/twKBgBlQjVVBYqX2MKNy04U4tUWAzjbmseM2GThZvqS1SvFJLNRXFMrT
0vdVTFKFChLyG8hGcRqqe5aXF4a21Nk4e16n4cVEW5xPMW4qJvg0OU7ZsbcFh/Qb
LUUtImvy4xUh7PXMLa45t6eWT2kiSGjMY5W17onUT8OmzLL2RRNoYxqhAoGABLZO
RQOeZVhk/FEnzaWrW8VuTGaSHgxtZkrthaSR/uBL+IuOzavGpdR4Wyd/wcPchS22
V/kMCfLSkAZI0HKrK2pbkSB2zkMG/bveSUEgFLulYLyCAcwRM30GGWj6dec7Jacm
Ca1qPNSL7+V479dcoJKM8WCq30Uehc0Gcj8BsfcCgYBTicTc3S/o2jsJLRtXzFap
eooocDD8Q0xIFRMZplDOvAnWXq6QJ0Xyeqlx9dk41dwWvnHHJGazZPZA4wijyI0T
orTbpLkOKpMz0hlqy9j2/k+vgf004+xfDSAywtOPop9csXxFWPB5qmdjTONv5Zpn
bzzXqOTAU1QZvdTdOBvbUw==
-----END PRIVATE KEY-----"#;
/// RSA public key used to verify the header signature.
const RSA_PUBLIC_KEY_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoBE/qDYFANWnAL8hEpzb
E8vgHvpOkPyyzOW4eLC3xXrtE0/SmuEkGui33wyyb2qo7fvo7Adj8D72XHJR3Buc
gtmzYpM/sdlmhwkDiNur88vKIfMfsCyO+xn1RvNplqaOoB38rudSwbwg6Gg34IUl
C+wfZSGgg29HWwX6Y0060K9FHFBE/Ub7K4zJMiyOY3wZ3dVTU7xQ/YJ4HMAJme7O
uVCBJCo5kGR/sSvoA2vqGQSaY8+74FxHsyFUg7mUCkBQf+BOWBK37UcDaYe654A0
6chWI2gLw/WaDPdHsRVrUVjL47dhzbQ1Dh40SOAuV06KpvxXz8WAKa6CVD8hOnJB
awIDAQAB
-----END PUBLIC KEY-----"#;
type Aes256CbcDec = cbc::Decryptor<Aes256>;
type Aes256CbcEnc = cbc::Encryptor<Aes256>;
@@ -59,6 +105,72 @@ struct Header {
iv: [u8; 16],
}
/// Human-readable view of every known `.sig` header field.
///
/// The format still has unknown regions. Those are exposed as non-zero counts
/// and SHA-256 digests so different files can be compared without dumping large
/// binary blobs to the terminal.
#[derive(Debug)]
pub struct HeaderInfo {
/// Total input file length.
pub total_len: usize,
/// Number of bytes after the fixed 512-byte header.
pub payload_len: usize,
/// Big-endian magic as raw bytes.
pub magic: [u8; 4],
/// Raw package type byte at `0x04`.
pub package_type_raw: u8,
/// Low seven bits of the package type byte.
pub package_type: u8,
/// Whether the high bit of the package type byte is set.
pub is_encrypted: bool,
/// Header version major byte at `0x05`.
pub version_major: u8,
/// Header version minor byte at `0x06`.
pub version_minor: u8,
/// Currently unknown byte at `0x07`.
pub byte_07: u8,
/// Plaintext payload size from `0x08..0x10`.
pub filesize: u64,
/// NUL-terminated header filename from `0x10..0x40`.
pub filename: String,
/// Unknown/reserved bytes from `0x40..0x90`.
pub reserved_40_90: RegionInfo,
/// Encryption offset from `0x90..0x98`.
pub encrypt_offset: u64,
/// Encrypted payload length from `0x98..0xA0`.
pub encrypt_length: u64,
/// AES-CBC IV from `0xA0..0xB0`.
pub iv_hex: String,
/// Encrypted payload length/size mirror from `0xB0..0xB8`.
pub encrypt_filesize: u64,
/// Unknown/reserved bytes from `0xB8..0xE0`.
pub reserved_b8_e0: RegionInfo,
/// Stored SHA-256 digest from `0xE0..0x100`.
pub stored_sha256_hex: String,
/// SHA-256 of the actual bytes after the header.
pub payload_sha256_hex: String,
/// Whether `stored_sha256_hex` matches `payload_sha256_hex`.
pub payload_sha256_matches: bool,
/// Whether the RSA signature at `0x100..0x200` verifies the stored SHA-256.
pub signature_valid: bool,
/// Opaque 256-byte region from `0x100..0x200`.
pub opaque_100_200: RegionInfo,
}
/// Summary for an unknown or reserved header byte range.
#[derive(Debug)]
pub struct RegionInfo {
/// Number of bytes in the region.
pub len: usize,
/// Number of non-zero bytes in the region.
pub nonzero_count: usize,
/// SHA-256 digest of the region.
pub sha256_hex: String,
/// Exact region bytes as lowercase hexadecimal.
pub bytes_hex: String,
}
/// Unpack a complete `.sig` file held in memory.
///
/// The input must include both the 512-byte header and the wrapped payload. The
@@ -84,13 +196,101 @@ pub fn unpack_sig(raw: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
}
}
/// Pack plaintext bytes into an encrypted `.sig` container.
/// Return the NUL-terminated filename stored in a `.sig` header.
pub fn header_filename(raw: &[u8]) -> Result<String, Box<dyn Error>> {
if raw.len() < HEADER_LEN {
return Err("input is too small to contain a .sig header".into());
}
let header = &raw[..HEADER_LEN];
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 filename = &header[0x10..0x40];
let end = filename
.iter()
.position(|byte| *byte == 0)
.unwrap_or(filename.len());
Ok(std::str::from_utf8(&filename[..end])?.to_owned())
}
/// Parse and summarize all known `.sig` header fields.
pub fn header_info(raw: &[u8]) -> Result<HeaderInfo, Box<dyn Error>> {
if raw.len() < HEADER_LEN {
return Err("input is too small to contain a .sig header".into());
}
let header = &raw[..HEADER_LEN];
let magic = header[0..4].try_into()?;
if u32::from_be_bytes(magic) != MAGIC {
return Err(format!("invalid .sig magic: 0x{:08x}", u32::from_be_bytes(magic)).into());
}
let package_type_raw = header[0x04];
let payload = &raw[HEADER_LEN..];
let stored_sha256_hex = bytes_to_hex(&header[0xE0..0x100]);
let payload_sha256_hex = bytes_to_hex(&Sha256::digest(payload));
let payload_sha256_matches = stored_sha256_hex == payload_sha256_hex;
Ok(HeaderInfo {
total_len: raw.len(),
payload_len: payload.len(),
magic,
package_type_raw,
package_type: package_type_raw & 0x7F,
is_encrypted: package_type_raw & 0x80 != 0,
version_major: header[0x05],
version_minor: header[0x06],
byte_07: header[0x07],
filesize: u64::from_le_bytes(header[0x08..0x10].try_into()?),
filename: header_filename(raw)?,
reserved_40_90: region_info(&header[0x40..0x90]),
encrypt_offset: u64::from_le_bytes(header[0x90..0x98].try_into()?),
encrypt_length: u64::from_le_bytes(header[0x98..0xA0].try_into()?),
iv_hex: bytes_to_hex(&header[0xA0..0xB0]),
encrypt_filesize: u64::from_le_bytes(header[0xB0..0xB8].try_into()?),
reserved_b8_e0: region_info(&header[0xB8..0xE0]),
stored_sha256_hex,
payload_sha256_hex,
payload_sha256_matches,
signature_valid: verify_payload_signature(&header[0xE0..0x100], &header[0x100..0x200]),
opaque_100_200: region_info(&header[0x100..0x200]),
})
}
fn region_info(bytes: &[u8]) -> RegionInfo {
RegionInfo {
len: bytes.len(),
nonzero_count: bytes.iter().filter(|byte| **byte != 0).count(),
sha256_hex: bytes_to_hex(&Sha256::digest(bytes)),
bytes_hex: bytes_to_hex(bytes),
}
}
fn bytes_to_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0F) as usize] as char);
}
out
}
/// Pack plaintext bytes into a plain signed `.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.
/// recreate every vendor metadata field.
pub fn pack_sig(payload: &[u8], filename: &str) -> Result<Vec<u8>, Box<dyn Error>> {
pack_sig_inner(payload, filename, None, None, false)
}
/// Pack plaintext bytes into an encrypted signed `.sig` container.
pub fn pack_sig_encrypted(payload: &[u8], filename: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let mut iv = [0u8; 16];
OsRng.fill_bytes(&mut iv);
@@ -107,7 +307,7 @@ pub fn pack_sig_with_iv(
filename: &str,
iv: [u8; 16],
) -> Result<Vec<u8>, Box<dyn Error>> {
pack_sig_inner(payload, filename, iv, None)
pack_sig_inner(payload, filename, Some(iv), None, true)
}
/// Pack plaintext bytes using the IV and opaque metadata from an existing `.sig` header.
@@ -122,38 +322,53 @@ pub fn pack_sig_with_template(
payload: &[u8],
filename: &str,
template: &[u8],
encrypt: Option<bool>,
) -> 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]))
let template_header = &template[..HEADER_LEN];
let template_is_encrypted = template_header[0x04] & 0x80 != 0;
let encrypt = encrypt.unwrap_or(template_is_encrypted);
let iv = if encrypt {
Some(template_header[0xA0..0xB0].try_into()?)
} else {
None
};
pack_sig_inner(payload, filename, iv, Some(template_header), encrypt)
}
fn pack_sig_inner(
payload: &[u8],
filename: &str,
iv: [u8; 16],
iv: Option<[u8; 16]>,
header_template: Option<&[u8]>,
encrypt: bool,
) -> 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());
let payload_out = if encrypt {
let iv = iv.ok_or("encrypted repack requires an IV")?;
encrypt_payload(payload, &iv)?
} else {
payload.to_vec()
};
let payload_sha256 = Sha256::digest(&payload_out).into();
let mut output = Vec::with_capacity(HEADER_LEN + payload_out.len());
output.extend_from_slice(&build_header(
filename_bytes,
payload.len(),
ciphertext.len(),
&iv,
encrypted_sha256,
payload_out.len(),
iv,
payload_sha256,
header_template,
encrypt,
)?);
output.extend_from_slice(&ciphertext);
output.extend_from_slice(&payload_out);
Ok(output)
}
@@ -169,14 +384,15 @@ fn pack_sig_inner(
fn build_header(
filename: &[u8],
payload_len: usize,
encrypted_len: usize,
iv: &[u8; 16],
encrypted_sha256: [u8; 32],
output_payload_len: usize,
iv: Option<[u8; 16]>,
payload_sha256: [u8; 32],
template: Option<&[u8]>,
encrypt: bool,
) -> 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 output_payload_len =
u64::try_from(output_payload_len).map_err(|_| "output payload is too large")?;
let mut header = [0u8; HEADER_LEN];
if let Some(template) = template {
@@ -184,20 +400,44 @@ fn build_header(
}
header[0..4].copy_from_slice(&MAGIC.to_be_bytes());
header[0x04] = 0x83;
if template.is_none() || (header[0x04] & 0x80 != 0) != encrypt {
header[0x04] = if encrypt { 0x83 } else { 0x04 };
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);
if encrypt {
let iv = iv.ok_or("encrypted header requires an IV")?;
header[0x98..0xA0].copy_from_slice(&output_payload_len.to_le_bytes());
header[0xA0..0xB0].copy_from_slice(&iv);
header[0xB0..0xB8].copy_from_slice(&output_payload_len.to_le_bytes());
} else {
header[0x98..0xA0].fill(0);
header[0xA0..0xB0].fill(0);
header[0xB0..0xB8].copy_from_slice(&payload_len.to_le_bytes());
}
header[0xE0..0x100].copy_from_slice(&payload_sha256);
let signature = sign_payload_hash(&payload_sha256)?;
header[0x100..0x200].copy_from_slice(&signature);
Ok(header)
}
fn sign_payload_hash(payload_sha256: &[u8; 32]) -> Result<Vec<u8>, Box<dyn Error>> {
let key = RsaPrivateKey::from_pkcs8_pem(RSA_PRIVATE_KEY_PEM)?;
Ok(key.sign(Pkcs1v15Sign::new::<Sha256>(), payload_sha256)?)
}
fn verify_payload_signature(payload_sha256: &[u8], signature: &[u8]) -> bool {
let Ok(key) = RsaPublicKey::from_public_key_pem(RSA_PUBLIC_KEY_PEM) else {
return false;
};
key.verify(Pkcs1v15Sign::new::<Sha256>(), payload_sha256, signature)
.is_ok()
}
/// Parse the fixed-width `.sig` header.
///
/// Offsets are based on the browser implementation:
+73 -16
View File
@@ -22,31 +22,86 @@ fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
match cli.command {
Command::Strip(args) => strip(args)?,
Command::Encrypt(args) => encrypt(args)?,
Command::Unpack(args) => unpack(args)?,
Command::Info(args) => info(args)?,
Command::Repack(args) => repack(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>> {
/// Execute the `info` subcommand.
fn info(args: cli::InfoArgs) -> Result<(), Box<dyn Error>> {
let raw = fs::read(&args.input)?;
let filename = match args.filename {
Some(filename) => filename,
None => args
let info = crypto::header_info(&raw)?;
println!("file: {}", args.input.display());
println!("total_len: {}", info.total_len);
println!("header_len: {}", crypto::HEADER_LEN);
println!("payload_len: {}", info.payload_len);
println!(
"magic: {}",
std::str::from_utf8(&info.magic).unwrap_or("<non-ascii>")
);
println!("package_type_raw: 0x{:02x}", info.package_type_raw);
println!("package_type: {}", info.package_type);
println!("encrypted: {}", info.is_encrypted);
println!("version: {}.{}", info.version_major, info.version_minor);
println!("byte_07: 0x{:02x}", info.byte_07);
println!("filesize: {}", info.filesize);
println!("filename: {}", info.filename);
print_region("reserved_40_90", &info.reserved_40_90, args.raw);
println!("encrypt_offset: {}", info.encrypt_offset);
println!("encrypt_length: {}", info.encrypt_length);
println!("iv: {}", info.iv_hex);
println!("encrypt_filesize: {}", info.encrypt_filesize);
print_region("reserved_b8_e0", &info.reserved_b8_e0, args.raw);
println!("stored_sha256: {}", info.stored_sha256_hex);
println!("payload_sha256: {}", info.payload_sha256_hex);
println!("payload_sha256_matches: {}", info.payload_sha256_matches);
println!("signature_valid: {}", info.signature_valid);
print_region("opaque_100_200", &info.opaque_100_200, args.raw);
Ok(())
}
fn print_region(name: &str, region: &crypto::RegionInfo, raw: bool) {
println!("{name}_len: {}", region.len);
println!("{name}_nonzero_count: {}", region.nonzero_count);
println!("{name}_sha256: {}", region.sha256_hex);
if raw {
println!("{name}_bytes_hex:");
for chunk in region.bytes_hex.as_bytes().chunks(64) {
println!(" {}", std::str::from_utf8(chunk).unwrap_or(""));
}
}
}
/// Execute the `repack` subcommand.
///
/// The command reads a plaintext payload, wraps it in a signed `.sig` header,
/// and optionally encrypts the payload.
fn repack(args: cli::RepackArgs) -> Result<(), Box<dyn Error>> {
let raw = fs::read(&args.input)?;
let template = match args.template {
Some(template) => Some(fs::read(template)?),
None => None,
};
let filename = match (args.filename, template.as_deref()) {
(Some(filename), _) => filename,
(None, Some(template)) => crypto::header_filename(template)?,
(None, 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)?
let packed = if let Some(template) = template {
let encrypt = if args.encrypt { Some(true) } else { None };
crypto::pack_sig_with_template(&raw, &filename, &template, encrypt)?
} else if args.encrypt {
crypto::pack_sig_encrypted(&raw, &filename)?
} else {
crypto::pack_sig(&raw, &filename)?
};
@@ -58,19 +113,21 @@ fn encrypt(args: cli::EncryptArgs) -> Result<(), Box<dyn Error>> {
Ok(())
}
/// Execute the `strip` subcommand.
/// Execute the `unpack` subcommand.
///
/// `strip` removes the 512-byte `.sig` wrapper and writes the contained package
/// `unpack` 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>> {
fn unpack(args: cli::UnpackArgs) -> Result<(), Box<dyn Error>> {
let raw = fs::read(&args.input)?;
let filename = crypto::header_filename(&raw)?;
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!("header filename: {filename}");
println!("wrote {}", output_path.display());
Ok(())
+1
View File
@@ -83,6 +83,7 @@ fn encrypts_ota_package_list_fixture() {
EXPECTED_OTA_PACKAGE_LIST,
"ota-package-list.json",
ENCRYPTED_OTA_PACKAGE_LIST,
None,
)
.expect("failed to pack embedded plaintext fixture");
BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"packages": [{"file": "cc2_eeb001_01.03.02.36_20260326171745.swu.sig", "hash": "46aa459b61340a84ff52e2865347d42a568351919441fb9db6e185424220b192"}], "version": "01.03.02.36"}
Binary file not shown.