Files
chimera-gfx-Public/tools/inspect_siecaf_header.py
T
Chimera GFX release export a6037502d7
phase0-ci / build-and-audit (push) Successful in 2m14s
Publish Chimera GFX source
2026-09-03 03:27:14 +02:00

510 lines
18 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only, host-only parser for bounded PS5 SIECAF structural metadata.
This module never decrypts, extracts content, invokes ps5-bar-tool, opens a
network connection, or writes an output file. The byte layout is based on:
* https://www.psdevwiki.com/ps5/Archive.dat
* c0w-ar/ps5-bar-tool include/bar_file.h at
36d014672bc87577a6e0d750c2cccadc3fae0854
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path
import struct
from typing import Any, BinaryIO, Iterator
import zipfile
MAGIC = b"SIECAF\x00\x00"
HEADER = struct.Struct("<8sQiIiI16s12sIQQQ")
SEGMENT_META = struct.Struct("<iHHQQQQ12sIQ")
SECTION_HASH = struct.Struct("<ii16s24s")
ALIGNMENT = 0x10000
UINT64_MAX = (1 << 64) - 1
SUPPORTED_VERSIONS = frozenset({3, 6})
class SiecafError(ValueError):
"""A fail-closed SIECAF structural error."""
def _sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _checked_add(left: int, right: int, label: str) -> int:
result = left + right
if left < 0 or right < 0 or result > UINT64_MAX:
raise SiecafError(f"{label}: uint64 addition overflow")
return result
def _checked_mul(left: int, right: int, label: str) -> int:
result = left * right
if left < 0 or right < 0 or result > UINT64_MAX:
raise SiecafError(f"{label}: uint64 multiplication overflow")
return result
def _read_exact(stream: BinaryIO, size: int, label: str) -> bytes:
value = stream.read(size)
if len(value) != size:
raise SiecafError(f"{label}: truncated input")
return value
def _canonical_hash(records: list[dict[str, Any]]) -> str:
encoded = json.dumps(
records, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("ascii")
return _sha256(encoded)
def inspect_siecaf(
stream: BinaryIO, source_size: int, *, source_label: str = "<stream>"
) -> dict[str, Any]:
"""Parse structural metadata from an already-open, read-only stream."""
if source_size < HEADER.size:
return {
"source": source_label,
"source_size": source_size,
"classification": "SIECAF_MALFORMED",
"errors": ["header: truncated input"],
"warnings": [],
}
stream.seek(0)
header_raw = _read_exact(stream, HEADER.size, "header")
(
magic,
unknown_u64,
mode,
pad1,
version,
pad2,
key,
iv12,
pad3,
segment_count,
file_offset,
file_size,
) = HEADER.unpack(header_raw)
header = {
"magic_hex": magic.hex(),
"magic_ascii": magic.rstrip(b"\x00").decode("ascii", errors="replace"),
"unknown_u64": unknown_u64,
"mode_i32": mode,
"padding_1_u32": pad1,
"version_i32": version,
"padding_2_u32": pad2,
"key_or_unknown_16_redacted": True,
"key_or_unknown_16_sha256": _sha256(key),
"iv_12_hex": iv12.hex(),
"padding_3_u32": pad3,
"segment_count": segment_count,
"file_offset": file_offset,
"file_size": file_size,
"raw_sha256": _sha256(header_raw),
}
if magic != MAGIC:
return {
"source": source_label,
"source_size": source_size,
"classification": "SIECAF_MALFORMED",
"header": header,
"errors": ["magic: expected SIECAF\\0\\0"],
"warnings": [],
}
if version not in SUPPORTED_VERSIONS:
return {
"source": source_label,
"source_size": source_size,
"classification": "SIECAF_UNSUPPORTED_VERSION",
"header": header,
"errors": [f"unsupported SIECAF header version: {version}"],
"warnings": [],
}
errors: list[str] = []
warnings: list[str] = []
if segment_count == 0:
errors.append("segment_count: zero")
try:
metadata_bytes = _checked_mul(
segment_count, SEGMENT_META.size, "segment metadata table"
)
hash_bytes = _checked_mul(
segment_count, SECTION_HASH.size, "section hash table"
)
metadata_end = _checked_add(HEADER.size, metadata_bytes, "metadata end")
tables_end = _checked_add(metadata_end, hash_bytes, "tables end")
declared_end = _checked_add(file_offset, file_size, "declared file end")
except SiecafError as error:
return {
"source": source_label,
"source_size": source_size,
"classification": "SIECAF_MALFORMED",
"header": header,
"errors": [str(error)],
"warnings": [],
}
if tables_end > source_size:
errors.append("tables: outside source file")
if tables_end > file_offset:
errors.append("tables: overlap declared data region")
if file_offset % ALIGNMENT != 0:
errors.append(f"file_offset: not aligned to {ALIGNMENT}")
if declared_end > source_size:
errors.append("declared file range: outside source file")
elif declared_end < source_size:
warnings.append(
f"trailing data after declared file range: {source_size - declared_end}"
)
if errors:
return {
"source": source_label,
"source_size": source_size,
"classification": "SIECAF_MALFORMED",
"header": header,
"tables_end": tables_end,
"errors": errors,
"warnings": warnings,
}
metadata: list[dict[str, Any]] = []
for index in range(segment_count):
raw = _read_exact(stream, SEGMENT_META.size, f"segment metadata {index}")
(
section_id,
padding_1,
part_number,
data_offset,
aligned_length,
hash_key_id,
encryption_key_id,
segment_iv12,
segment_iv_padding,
unaligned_length,
) = SEGMENT_META.unpack(raw)
iv16 = segment_iv12 + struct.pack("<I", segment_iv_padding)
metadata.append(
{
"table_index": index,
"section_id": section_id,
"padding_1_u16": padding_1,
"part_number": part_number,
"data_offset": data_offset,
"aligned_length": aligned_length,
"unaligned_length": unaligned_length,
"hash_key_id_or_algorithm_type": hash_key_id,
"encryption_key_id_or_algorithm_version": encryption_key_id,
"iv_hex": iv16.hex(),
"raw_sha256": _sha256(raw),
}
)
hashes: list[dict[str, Any]] = []
for index in range(segment_count):
raw = _read_exact(stream, SECTION_HASH.size, f"section hash {index}")
section_id, section_type, section_hash, padding = SECTION_HASH.unpack(raw)
hashes.append(
{
"table_index": index,
"section_id": section_id,
"section_type": section_type,
"section_hash_128_hex": section_hash.hex(),
"padding_hex": padding.hex(),
"raw_sha256": _sha256(raw),
}
)
metadata_keys = [(item["section_id"], item["part_number"]) for item in metadata]
metadata_ids = [item["section_id"] for item in metadata]
hash_ids = [item["section_id"] for item in hashes]
duplicate_metadata_ids = sorted(
{
section_id
for section_id, part_number in metadata_keys
if metadata_keys.count((section_id, part_number)) > 1
}
)
duplicate_metadata_keys = sorted(
{
(section_id, part_number)
for section_id, part_number in metadata_keys
if metadata_keys.count((section_id, part_number)) > 1
}
)
repeated_metadata_ids = sorted(
{item for item in metadata_ids if metadata_ids.count(item) > 1}
)
duplicate_hash_ids = sorted({item for item in hash_ids if hash_ids.count(item) > 1})
if duplicate_metadata_ids:
errors.append(
f"duplicate metadata section ID/part keys: {duplicate_metadata_keys}"
)
if duplicate_hash_ids:
errors.append(f"duplicate hash section IDs: {duplicate_hash_ids}")
if set(hash_ids) != set(range(segment_count)):
errors.append("hash section IDs do not cover metadata table indexes")
hash_by_id = {
item["section_id"]: item
for item in hashes
if item["section_id"] not in duplicate_hash_ids
}
ranges: list[tuple[int, int, int]] = []
for item in metadata:
section_id = item["section_id"]
start = item["data_offset"]
length = item["aligned_length"]
unaligned = item["unaligned_length"]
try:
end = _checked_add(start, length, f"section {section_id} end")
except SiecafError as error:
errors.append(str(error))
continue
if section_id < 0:
errors.append(f"section {section_id}: negative ID")
if start % ALIGNMENT != 0:
errors.append(f"section {section_id}: unaligned data offset")
if length == 0 and unaligned != 0:
errors.append(f"section {section_id}: incoherent unaligned length")
if length > 0 and length % ALIGNMENT != 0:
errors.append(f"section {section_id}: incoherent aligned length")
if length > 0 and (unaligned > length or length - unaligned >= ALIGNMENT):
errors.append(f"section {section_id}: incoherent unaligned length")
if start < file_offset or end > declared_end or end > source_size:
errors.append(f"section {section_id}: range outside declared file data")
if length > 0:
ranges.append((start, end, section_id))
hash_record = hash_by_id.get(item["table_index"])
item["section_type"] = (
hash_record["section_type"] if hash_record is not None else None
)
item["section_hash_128_hex"] = (
hash_record["section_hash_128_hex"] if hash_record is not None else None
)
ranges.sort()
overlaps: list[dict[str, int]] = []
gaps: list[dict[str, int]] = []
previous_end = file_offset
previous_id = -1
for start, end, section_id in ranges:
if start < previous_end:
overlaps.append(
{
"left_section_id": previous_id,
"right_section_id": section_id,
"overlap_bytes": previous_end - start,
}
)
elif start > previous_end:
gaps.append(
{
"after_section_id": previous_id,
"before_section_id": section_id,
"gap_bytes": start - previous_end,
}
)
if end > previous_end:
previous_end = end
previous_id = section_id
if overlaps:
errors.append(f"overlapping section ranges: {len(overlaps)}")
if gaps:
warnings.append(f"gaps between section ranges: {len(gaps)}")
trailing_data = max(0, source_size - previous_end)
if trailing_data:
warnings.append(f"trailing data after final section: {trailing_data}")
if previous_end < declared_end:
warnings.append(
f"uncovered bytes inside declared data range: {declared_end - previous_end}"
)
normalized_segments = [
{
key: item[key]
for key in (
"section_id",
"part_number",
"data_offset",
"aligned_length",
"unaligned_length",
"hash_key_id_or_algorithm_type",
"encryption_key_id_or_algorithm_version",
"iv_hex",
)
}
for item in sorted(
metadata,
key=lambda value: (
value["section_id"],
value["part_number"],
value["data_offset"],
),
)
]
normalized_hashes = [
{
key: item[key]
for key in ("section_id", "section_type", "section_hash_128_hex")
}
for item in sorted(
hashes, key=lambda value: (value["section_id"], value["section_type"])
)
]
normalized_layout = [
{
key: item[key]
for key in (
"section_id",
"part_number",
"data_offset",
"aligned_length",
"unaligned_length",
"hash_key_id_or_algorithm_type",
"encryption_key_id_or_algorithm_version",
)
}
for item in normalized_segments
]
normalized_segment_table_sha256 = _canonical_hash(normalized_segments)
normalized_hash_blocks_sha256 = _canonical_hash(normalized_hashes)
normalized_layout_sha256 = _canonical_hash(
[
{
"unknown_u64": unknown_u64,
"version_i32": version,
"segment_count": segment_count,
"file_offset": file_offset,
"file_size": file_size,
},
*normalized_layout,
]
)
structural_fingerprint_sha256 = _canonical_hash(
[
{
"header_raw_sha256": header["raw_sha256"],
"normalized_segment_table_sha256": (normalized_segment_table_sha256),
"normalized_hash_blocks_sha256": normalized_hash_blocks_sha256,
}
]
)
return {
"source": source_label,
"source_size": source_size,
"classification": ("SIECAF_MALFORMED" if errors else "SIECAF_VALID_STRUCTURE"),
"header": header,
"tables_end": tables_end,
"table_padding_bytes": file_offset - tables_end,
"segments": sorted(metadata, key=lambda item: item["table_index"]),
"section_hashes": sorted(hashes, key=lambda item: item["table_index"]),
"normalized_segment_table_sha256": normalized_segment_table_sha256,
"normalized_hash_blocks_sha256": normalized_hash_blocks_sha256,
"normalized_layout_sha256": normalized_layout_sha256,
"structural_fingerprint_sha256": structural_fingerprint_sha256,
"duplicate_metadata_section_ids": duplicate_metadata_ids,
"duplicate_metadata_section_keys": [
{"section_id": section_id, "part_number": part_number}
for section_id, part_number in duplicate_metadata_keys
],
"repeated_metadata_section_ids": repeated_metadata_ids,
"duplicate_hash_section_ids": duplicate_hash_ids,
"overlaps": overlaps,
"gaps": gaps,
"trailing_data_bytes": trailing_data,
"errors": errors,
"warnings": warnings,
}
def compare_structures(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
"""Classify two already-parsed structural fingerprints."""
if (
left.get("classification") == "SIECAF_UNSUPPORTED_VERSION"
or right.get("classification") == "SIECAF_UNSUPPORTED_VERSION"
):
classification = "SIECAF_UNSUPPORTED_VERSION"
elif (
left.get("classification") != "SIECAF_VALID_STRUCTURE"
or right.get("classification") != "SIECAF_VALID_STRUCTURE"
):
classification = "SIECAF_MALFORMED"
elif left.get("structural_fingerprint_sha256") == right.get(
"structural_fingerprint_sha256"
):
classification = "SIECAF_STRUCTURAL_EXACT"
elif left.get("normalized_layout_sha256") == right.get("normalized_layout_sha256"):
classification = "SIECAF_LAYOUT_MATCH_HASHES_DIFFER"
else:
classification = "SIECAF_LAYOUT_DIFFERENT"
return {
"classification": classification,
"left_structural_fingerprint_sha256": left.get("structural_fingerprint_sha256"),
"right_structural_fingerprint_sha256": right.get(
"structural_fingerprint_sha256"
),
"left_layout_sha256": left.get("normalized_layout_sha256"),
"right_layout_sha256": right.get("normalized_layout_sha256"),
"left_hash_blocks_sha256": left.get("normalized_hash_blocks_sha256"),
"right_hash_blocks_sha256": right.get("normalized_hash_blocks_sha256"),
}
@contextmanager
def open_source(
path: Path, zip_entry: str | None
) -> Iterator[tuple[BinaryIO, int, str]]:
if zip_entry is None:
with path.open("rb") as stream:
yield stream, path.stat().st_size, str(path)
return
with zipfile.ZipFile(path) as archive:
info = archive.getinfo(zip_entry)
with archive.open(info) as stream:
yield stream, info.file_size, f"{path}::{zip_entry}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("path", type=Path)
parser.add_argument("--zip-entry")
parser.add_argument("--label")
parser.add_argument("--pretty", action="store_true")
args = parser.parse_args()
try:
with open_source(args.path.resolve(), args.zip_entry) as (
stream,
size,
default_label,
):
result = inspect_siecaf(
stream, size, source_label=args.label or default_label
)
except (OSError, KeyError, zipfile.BadZipFile, SiecafError) as error:
result = {
"source": args.label or str(args.path),
"classification": "SIECAF_MALFORMED",
"errors": [str(error)],
"warnings": [],
}
print(
json.dumps(
result,
sort_keys=True,
indent=2 if args.pretty else None,
)
)
return 0 if result["classification"] == "SIECAF_VALID_STRUCTURE" else 2
if __name__ == "__main__":
raise SystemExit(main())