diff --git a/README.md b/README.md index 550626a..3d01576 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/sig/Cargo.toml b/sig/Cargo.toml index 477b522..9156c67 100644 --- a/sig/Cargo.toml +++ b/sig/Cargo.toml @@ -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"] } diff --git a/sig/OTA_REPACKING.md b/sig/OTA_REPACKING.md new file mode 100644 index 0000000..591f4a8 --- /dev/null +++ b/sig/OTA_REPACKING.md @@ -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. diff --git a/sig/README.md b/sig/README.md new file mode 100644 index 0000000..cdf4762 --- /dev/null +++ b/sig/README.md @@ -0,0 +1 @@ +This is a pile of badly made tooling to repack CC2 OTAs diff --git a/sig/scripts/build-release-zip.sh b/sig/scripts/build-release-zip.sh new file mode 100755 index 0000000..dcdb5fc --- /dev/null +++ b/sig/scripts/build-release-zip.sh @@ -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 diff --git a/sig/scripts/redo-release-package.sh b/sig/scripts/redo-release-package.sh new file mode 100755 index 0000000..0e051f2 --- /dev/null +++ b/sig/scripts/redo-release-package.sh @@ -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 diff --git a/sig/scripts/undo-release-package.sh b/sig/scripts/undo-release-package.sh new file mode 100755 index 0000000..8eb0606 --- /dev/null +++ b/sig/scripts/undo-release-package.sh @@ -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" <: 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, } -/// 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, - /// 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, } diff --git a/sig/src/crypto.rs b/sig/src/crypto.rs index f9e3a35..3c3a57b 100644 --- a/sig/src/crypto.rs +++ b/sig/src/crypto.rs @@ -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; type Aes256CbcEnc = cbc::Encryptor; @@ -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, Box> { } } -/// 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> { + 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> { + 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, Box> { + 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, Box> { 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, Box> { - 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, ) -> 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])) + 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, 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()); + 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> { 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; - header[0x05] = 0x01; - header[0x06] = 0x02; + 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, Box> { + let key = RsaPrivateKey::from_pkcs8_pem(RSA_PRIVATE_KEY_PEM)?; + Ok(key.sign(Pkcs1v15Sign::new::(), 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::(), payload_sha256, signature) + .is_ok() +} + /// Parse the fixed-width `.sig` header. /// /// Offsets are based on the browser implementation: diff --git a/sig/src/main.rs b/sig/src/main.rs index 4b5ff79..dbe841a 100644 --- a/sig/src/main.rs +++ b/sig/src/main.rs @@ -22,31 +22,86 @@ fn main() -> Result<(), Box> { 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> { +/// Execute the `info` subcommand. +fn info(args: cli::InfoArgs) -> Result<(), Box> { 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("") + ); + 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> { + 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> { 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> { +fn unpack(args: cli::UnpackArgs) -> Result<(), Box> { 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(()) diff --git a/sig/src/tests.rs b/sig/src/tests.rs index e814584..39c7b71 100644 --- a/sig/src/tests.rs +++ b/sig/src/tests.rs @@ -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"); diff --git a/sig/test/cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip b/sig/test/cc2-01.03.02.36-modified.zip.sig similarity index 85% rename from sig/test/cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip rename to sig/test/cc2-01.03.02.36-modified.zip.sig index 62deb0a..6264da5 100644 Binary files a/sig/test/cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip and b/sig/test/cc2-01.03.02.36-modified.zip.sig differ diff --git a/sig/test/cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip.sig b/sig/test/cc2-01.03.02.36-unmodified.zip.sig similarity index 100% rename from sig/test/cc2-01.03.02.36-ee76546c665bb272b43798813f60f8dd-release-abroad.zip.sig rename to sig/test/cc2-01.03.02.36-unmodified.zip.sig diff --git a/sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu.sig b/sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu.sig deleted file mode 100644 index e744ebb..0000000 Binary files a/sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu.sig and /dev/null differ diff --git a/sig/test/ec-eeb001-gui b/sig/test/ec-eeb001-gui new file mode 100755 index 0000000..64274d0 Binary files /dev/null and b/sig/test/ec-eeb001-gui differ diff --git a/sig/test/ota-package-list.json b/sig/test/ota-package-list.json deleted file mode 100644 index c1a03e5..0000000 --- a/sig/test/ota-package-list.json +++ /dev/null @@ -1 +0,0 @@ -{"packages": [{"file": "cc2_eeb001_01.03.02.36_20260326171745.swu.sig", "hash": "46aa459b61340a84ff52e2865347d42a568351919441fb9db6e185424220b192"}], "version": "01.03.02.36"} \ No newline at end of file diff --git a/sig/test/ota-package-list.json.sig b/sig/test/ota-package-list.json.sig deleted file mode 100644 index fbcfb01..0000000 Binary files a/sig/test/ota-package-list.json.sig and /dev/null differ diff --git a/sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu b/sig/test/rootfs_modified similarity index 93% rename from sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu rename to sig/test/rootfs_modified index 7176fe4..7a07275 100644 Binary files a/sig/test/cc2_eeb001_01.03.02.36_20260326171745.swu and b/sig/test/rootfs_modified differ