diff --git a/cc2camera/__init__.py b/cc2camera/__init__.py index c4386ec..092d094 100644 --- a/cc2camera/__init__.py +++ b/cc2camera/__init__.py @@ -5,4 +5,4 @@ __all__ = ["__version__", "commands"] -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/cc2camera/image.py b/cc2camera/image.py index 9ba0ed3..2fc1dd3 100644 --- a/cc2camera/image.py +++ b/cc2camera/image.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Strict, self-contained recovery builder for the Elegoo Centauri Carbon 2 -stock camera firmware family observed in two independent 8 MiB dumps. +stock camera firmware family observed in four independent 8 MiB dumps. The tool: * validates every invariant firmware byte against fingerprints derived @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +from datetime import date import hashlib import json import lzma @@ -66,18 +67,22 @@ (5, 0x020000, "config"), ) -# The HWCONFIG type-12 record contains a unit-specific two-byte check value and -# a 94-byte encrypted/encoded UOID in its known 256-byte prefix. Some cameras -# append opaque bytes to that payload. The record length bounds those bytes; -# recovery preserves them exactly but normalizes them for invariant hashing. +# The HWCONFIG type-12 record contains a unit-specific three-byte check value, +# a 94-byte encrypted/encoded UOID, and a four-byte little-endian calendar date +# in its known 256-byte prefix. Some cameras append opaque bytes to that +# payload. The record length bounds those bytes; recovery preserves all of +# these unit-specific values exactly but normalizes them for invariant hashing. HW_RECORD_START = 0x7D2000 HW_RECORD_PAYLOAD_START = HW_RECORD_START + 4 HW_KNOWN_PAYLOAD_END = HW_RECORD_PAYLOAD_START + 0x100 HW_SUPPORTED_PAYLOAD_LENGTHS = (0x100, 0x105) -HW_CHECK_START = 0x7D200B +HW_CHECK_START = 0x7D200A HW_CHECK_END = 0x7D200D HW_UOID_START = 0x7D2011 HW_UOID_END = 0x7D206F +HW_DATE_START = HW_UOID_END +HW_DATE_END = 0x7D2073 +HW_SUPPORTED_DATES = frozenset((date(2026, 3, 2), date(2026, 4, 1))) JFFS2_MAGIC = 0x1985 JFFS2_NODE_ACCURATE = 0x2000 @@ -94,8 +99,8 @@ b"dev_config.cfg", } -SERIAL_PATTERN = re.compile(rb"^serial=(12PSSSS4[A-Z0-9]{28})\n$") -UOID_PATTERN = re.compile(rb"^12PSSSS4[A-Za-z0-9+/=]{86}$") +SERIAL_PATTERN = re.compile(rb"^serial=(12PSSSS[34][A-Z0-9]{28})\n$") +UOID_PATTERN = re.compile(rb"^12PSSSS[34][A-Za-z0-9+/=]{86}$") # Exact byte ranges shared by every supported HWCONFIG variant. REFERENCE_SEGMENTS = { @@ -107,17 +112,18 @@ "hwconfig_between_identity_fields": (0x7D200D, 0x7D2011, "3c3351dc1dedcd627419e02de4fc8202e2d507d786c26f142b767fd9859d0cb4"), } -# These hashes use the type-12 record's canonical 256-byte payload length and -# zero bytes in place of any declared extension. This retains exact checking of -# all known bytes without treating an unknown opaque extension as firmware. +# These hashes use the type-12 record's canonical 256-byte payload length, +# exclude the structurally validated unit fields, and use zero bytes in place +# of any declared extension. This retains exact checking of every other byte +# without treating unit data or an unknown opaque extension as firmware. HWCONFIG_BEFORE_IDENTITY_SHA256 = ( - "0e1514680c4e25e5c431adae5d4cb98bac14746b6e8fc30eb346ec75249f7cc5" + "e9ba6b36ab55dd0284e7cfcaf8c6ece3b4903bf953dcae34026244a5febd701d" ) -HWCONFIG_AFTER_UOID_SHA256 = ( - "98d0beba4c7328a7237bc1a18fdd5e3da64c9f009e3b1c253ea39167a6dabd97" +HWCONFIG_AFTER_UNIT_FIELDS_SHA256 = ( + "f2e10823638187acb4572437debe618bb1c27d0a8796d40445c3132cdd805601" ) NORMALIZED_INVARIANT_SHA256 = ( - "7346221d7814c8ef4412ced5f3795891f077f89ba64cf61d087e01fc5344f62f" + "4dee29ee9f996779a8af0f4e4a66ebad3c6c1ca353cf8b4287b4f1f9a8b12823" ) ORIGINAL_PATCH_SHA256 = "5591f5350feabb73fd29e21ae72ee9c3c9dab0c6bb78e02178267e5cb2ab4780" @@ -1178,12 +1184,12 @@ def normalized_hwconfig_before_identity(image: bytes) -> bytes: ) -def normalized_hwconfig_after_uoid( +def normalized_hwconfig_after_unit_fields( image: bytes, record: dict[str, Any] ) -> bytes: extension_length = record["extension_length"] return ( - image[HW_UOID_END:HW_KNOWN_PAYLOAD_END] + image[HW_DATE_END:HW_KNOWN_PAYLOAD_END] + b"\0" * extension_length + image[record["record_end"]:CONFIG_START] ) @@ -1200,10 +1206,32 @@ def invariant_bytes( + (0x100).to_bytes(2, "little") + image[HW_RECORD_PAYLOAD_START:HW_CHECK_START] + image[HW_CHECK_END:HW_UOID_START] - + normalized_hwconfig_after_uoid(image, record) + + normalized_hwconfig_after_unit_fields(image, record) ) +def decode_hwconfig_date(image: bytes) -> str: + year = int.from_bytes(image[HW_DATE_START:HW_DATE_START + 2], "little") + month = image[HW_DATE_START + 2] + day = image[HW_DATE_START + 3] + try: + value = date(year, month, day) + except ValueError as exc: + raise ValidationError( + "The unit-specific HWCONFIG date field is not a valid " + f"little-endian year/month/day value ({year:04d}-{month:02d}-{day:02d})" + ) from exc + if value not in HW_SUPPORTED_DATES: + supported = ", ".join( + item.isoformat() for item in sorted(HW_SUPPORTED_DATES) + ) + raise ValidationError( + "The unit-specific HWCONFIG date field is not one of the physically " + f"observed values {supported} (value={value.isoformat()})" + ) + return value.isoformat() + + def identify_hwconfig_variant(image: bytes) -> tuple[str, dict[str, Any]]: record_type = int.from_bytes( image[HW_RECORD_START:HW_RECORD_START + 2], "little" @@ -1337,11 +1365,11 @@ def analyze_image( HWCONFIG_BEFORE_IDENTITY_SHA256, normalized_hwconfig_before_identity(image), ), - "hwconfig_after_uoid": ( - HW_UOID_END, + "hwconfig_after_unit_fields": ( + HW_DATE_END, CONFIG_START, - HWCONFIG_AFTER_UOID_SHA256, - normalized_hwconfig_after_uoid(image, hwconfig_record), + HWCONFIG_AFTER_UNIT_FIELDS_SHA256, + normalized_hwconfig_after_unit_fields(image, hwconfig_record), ), } for name, (start, end, expected_hash, normalized_bytes) in ( @@ -1361,8 +1389,8 @@ def analyze_image( if not ok: errors.append( f"{name} 0x{start:06X}-0x{end - 1:06X} does not match " - "the supported reference firmware after normalizing the " - "HWCONFIG extension" + "the supported reference firmware after normalizing " + "HWCONFIG unit fields and extension" ) actual_invariant_hash = sha256(invariant_bytes(image, hwconfig_record)) @@ -1393,11 +1421,17 @@ def analyze_image( ) check_value = image[HW_CHECK_START:HW_CHECK_END] - if check_value in (b"\x00\x00", b"\xFF\xFF"): + if check_value in (b"\x00" * 3, b"\xFF" * 3): warnings.append( - "The unit-specific two-byte HWCONFIG check value is all-zero/all-FF" + "The unit-specific three-byte HWCONFIG check value is all-zero/all-FF" ) + try: + hwconfig_date = decode_hwconfig_date(image) + except ValidationError as exc: + errors.append(str(exc)) + hwconfig_date = "invalid" + config = image[CONFIG_START:CONFIG_END] try: config_info = extract_serial_and_config_info( @@ -1438,6 +1472,7 @@ def analyze_image( "system_state": system_state, "system_patch_sha256": patch_hash, "hwconfig_check_hex": check_value.hex(), + "hwconfig_date": hwconfig_date, "uoid": uoid, "uoid_sha256": sha256(uoid), "serial_payload": config_info["serial_payload"], @@ -1543,6 +1578,7 @@ def format_analysis(analysis: dict[str, Any], show_serial: bool = False) -> str: Unit-specific data ------------------ HWCONFIG check bytes: {analysis['hwconfig_check_hex']} +HWCONFIG unit date: {analysis['hwconfig_date']} HWCONFIG UOID: {uoid_display} UOID SHA-256: {analysis['uoid_sha256']} serial.cfg source: {analysis['serial_source']} @@ -1779,6 +1815,7 @@ def build_recovery( "hwconfig_extension_sha256": analysis[ "hwconfig_extension_sha256" ], + "hwconfig_date": analysis["hwconfig_date"], "system_state_before": analysis["system_state"], "system_state_after": output_analysis["system_state"], "serial_source": analysis["serial_source"], diff --git a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md index 2786645..a9f4bae 100644 --- a/hardware-recovery/CC2_RECOVERY_VERIFICATION.md +++ b/hardware-recovery/CC2_RECOVERY_VERIFICATION.md @@ -1,5 +1,12 @@ # CC2 camera recovery tool — independent verification +> **Historical verification record:** This document describes the v1.1.0 tool +> and the two physical dumps available on 2026-08-22. It is retained as an +> audit record, not as the current compatibility contract. See +> [TECHNICAL_DETAILS.md](TECHNICAL_DETAILS.md) for the current supported +> HWCONFIG/identity structures and [TEST_RESULTS.md](TEST_RESULTS.md) for the +> four-camera validation record. + Review date: 2026-08-22 Reviewed tool: `cc2_sig_tool.py` v1.1.0 @@ -51,9 +58,10 @@ At the image level, yes. Both supplied bricked configs retain one unambiguous CR The result for both real units is deterministic and matches the previously documented output hashes. This is sufficient to verify image construction, identity preservation, and filesystem consistency. Only an actual flash/readback/boot test can establish electrical and runtime recovery for a particular board. -## Cross-serial behavior and residual risk +## Historical cross-serial behavior and residual risk -Cross-serial support was directly verified on two real units and structurally tested on a third synthetic identity. +At the time of this review, cross-serial support was directly verified on two +real units and structurally tested on a third synthetic identity. The validator normalizes the observed five-byte opaque HWCONFIG extension and excludes the known unit-specific HWCONFIG fields and mutable config log from diff --git a/hardware-recovery/REFERENCE_FINGERPRINTS.json b/hardware-recovery/REFERENCE_FINGERPRINTS.json index 8d9851d..f75858a 100644 --- a/hardware-recovery/REFERENCE_FINGERPRINTS.json +++ b/hardware-recovery/REFERENCE_FINGERPRINTS.json @@ -11,7 +11,7 @@ "start": "0x7E0000", "validation": "JFFS2 structure/CRCs, known names, unambiguous serial" }, - "format_version": 4, + "format_version": 5, "full_size": 8388608, "invariant_definition": { "excluded_or_normalized_ranges": [ @@ -27,14 +27,19 @@ }, { "end_inclusive": "0x7D200C", - "reason": "unit-specific HWCONFIG check value", - "start": "0x7D200B" + "reason": "unit-specific three-byte HWCONFIG check value", + "start": "0x7D200A" }, { "end_inclusive": "0x7D206E", "reason": "unit-specific 94-byte HWCONFIG UOID", "start": "0x7D2011" }, + { + "end_inclusive": "0x7D2072", + "reason": "unit-specific little-endian year/month/day field", + "start": "0x7D206F" + }, { "end_inclusive": "0x7D2108", "reason": "observed five-byte opaque type-12 extension, normalized to zero bytes when present", @@ -43,14 +48,14 @@ ], "included": "All bytes from 0x000000 through 0x7DFFFF except or normalized as listed below" }, - "normalized_invariant_sha256": "7346221d7814c8ef4412ced5f3795891f077f89ba64cf61d087e01fc5344f62f", + "normalized_invariant_sha256": "4dee29ee9f996779a8af0f4e4a66ebad3c6c1ca353cf8b4287b4f1f9a8b12823", "hwconfig_record": { "record_type": 12, "known_payload_length": 256, "supported_payload_lengths": [256, 261], "extension_handling": "For payload length 261, preserve all five extension bytes exactly, report their SHA-256, and normalize them to zero for invariant comparison; reject other payload lengths", - "normalized_before_identity_sha256": "0e1514680c4e25e5c431adae5d4cb98bac14746b6e8fc30eb346ec75249f7cc5", - "normalized_after_uoid_sha256": "98d0beba4c7328a7237bc1a18fdd5e3da64c9f009e3b1c253ea39167a6dabd97", + "normalized_before_identity_sha256": "e9ba6b36ab55dd0284e7cfcaf8c6ece3b4903bf953dcae34026244a5febd701d", + "normalized_after_unit_fields_sha256": "f2e10823638187acb4572437debe618bb1c27d0a8796d40445c3132cdd805601", "observed_payloads": [ { "payload_length": 256, @@ -59,9 +64,21 @@ { "payload_length": 261, "extension_hex": "0000029840" + }, + { + "payload_length": 261, + "extension_hex": "0000000840" } ] }, + "identity_fields": { + "check_range": "0x7D200A-0x7D200C", + "date_encoding": "little-endian uint16 year, uint8 month, uint8 day; only physically observed values are accepted", + "date_range": "0x7D206F-0x7D2072", + "observed_date_values": ["2026-03-02", "2026-04-01"], + "serial_prefixes": ["12PSSSS3", "12PSSSS4"], + "uoid_prefixes": ["12PSSSS3", "12PSSSS4"] + }, "reference_images": [ { "label": "Discord bricked image", @@ -87,9 +104,14 @@ "label": "Third camera bricked image", "sha256": "75d676ba4e478572777761d8caea43a6774a51d812050e4ba6ffa371a8ccc1e1", "state": "stock system, exhausted config, 261-byte HWCONFIG record" + }, + { + "label": "Fourth camera bricked image", + "sha256": "574c71e6572093ea1b65c3ca9c3e5e1dd1e55d71443ca7b282db8ce700c45c85", + "state": "stock system, exhausted config, 12PSSSS3 identity, 261-byte HWCONFIG record" } ], - "scope": "Exact CC2 stock-camera firmware family and the 256- and 261-byte type-12 payload lengths observed in three independent cameras; the five-byte extension contents are normalized", + "scope": "Exact CC2 stock-camera firmware family, 12PSSSS3 and 12PSSSS4 identities, and the 256- and 261-byte type-12 payload lengths observed in four independent cameras; unit check/date fields and five-byte extension contents are normalized", "segments": { "boot": { "end_exclusive": "0x040000", diff --git a/hardware-recovery/TECHNICAL_DETAILS.md b/hardware-recovery/TECHNICAL_DETAILS.md index 67a9e9b..7ed39f3 100644 --- a/hardware-recovery/TECHNICAL_DETAILS.md +++ b/hardware-recovery/TECHNICAL_DETAILS.md @@ -120,7 +120,7 @@ The 16 MiB family is not accepted by the builder or installers. A manufacturing code, PCB revision or stream signature never overrides firmware, capacity, partition-map or erase-geometry validation. -This release intentionally accepts only the exact firmware family already verified in three independent camera dumps with different unit identities: +This release intentionally accepts only the exact firmware family already verified in four independent camera dumps with different unit identities: - 8 MiB `ZB25VQ64` SPI NOR - Ingenic T23N @@ -142,7 +142,13 @@ There is no override or `--force` option for a mismatching firmware build. A ref ## How validation handles different camera identities -A complete raw dump cannot be compared by ignoring only the visible serial string. JFFS2 appends new nodes on each boot, so two otherwise identical cameras naturally have different raw `config` histories. The HWCONFIG partition also contains a per-unit UOID and an associated two-byte value. +A complete raw dump cannot be compared by ignoring only the visible serial +string. JFFS2 appends new nodes on each boot, so two otherwise identical +cameras naturally have different raw `config` histories. The HWCONFIG +partition also contains a per-unit UOID, an associated three-byte value, and a +four-byte little-endian year/month/day field. The date interpretation is based +on the observed `2026-03-02` and `2026-04-01` byte sequences; its exact vendor +semantics remain unknown. The known 256-byte prefix of its type-12 length-prefixed record is validated. Two payload lengths have been directly observed: @@ -150,15 +156,22 @@ Two payload lengths have been directly observed: | Observed shape | Encoded payload length | Bytes after the known 256-byte prefix | |---|---:|---| | `type12-length256` | 256 (`0x0100`) | none | -| `type12-length261` | 261 (`0x0105`) | `00 00 02 98 40` | +| `type12-length261` | 261 (`0x0105`) | `00 00 02 98 40` or `00 00 00 08 40` | -The second shape was observed in a third stable three-read dump. Its boot, -kernel, root, system, stock startup-script window, partition layout, identity -structure, and exhausted JFFS2 failure state match the supported family. The -record length exactly includes the five added bytes. The camera's own +The second shape was observed in stable multi-read dumps. Their boot, kernel, +root, system, stock startup-script window, partition layout, identity +structures, and exhausted JFFS2 failure states match the supported family. +The record length exactly includes the five added bytes. The camera's own `ucamera` executable advances over this structure as a little-endian 16-bit type, a little-endian 16-bit payload length, and that many payload bytes. +The observed serial and UOID families begin with either `12PSSSS3` or +`12PSSSS4`. Both retain the same total lengths and remaining character +structure. The fourth camera established that the adjacent check field spans +three bytes and that the calendar-shaped field varies by unit. Both fields are +preserved exactly and validated structurally rather than treated as firmware +bytes. + The meaning of the five-byte extension is unknown, so its value is not treated as a firmware invariant. The validator requires record type 12 and one of the two physically observed payload lengths: 256 bytes with no extension, or 261 @@ -170,13 +183,16 @@ lengths remain unsupported until physically observed. The validator therefore performs the strongest safe equivalent of “100% match apart from unit data”: -1. Every firmware byte that should be invariant must match the exact fingerprints after normalizing the five-byte HWCONFIG extension, when present. +1. Every firmware byte that should be invariant must match the exact + fingerprints after normalizing the structurally validated unit fields and + the five-byte HWCONFIG extension, when present. 2. The 32 KiB SquashFS window containing `bashrc.sh` must equal either the exact stock hash or the exact audited patched hash. 3. These fields are excluded from or normalized for the invariant hash: ```text -0x7D200B–0x7D200C two-byte HWCONFIG unit check value +0x7D200A–0x7D200C three-byte HWCONFIG unit check value 0x7D2011–0x7D206E 94-byte HWCONFIG UOID +0x7D206F–0x7D2072 little-endian year/month/day field 0x7D2002–0x7D2003 normalized type-12 payload length 0x7D2104–0x7D2108 opaque five-byte type-12 extension, when present 0x7E0000–0x7FFFFF writable JFFS2 config log @@ -185,7 +201,10 @@ The validator therefore performs the strongest safe equivalent of “100% match 4. The excluded fields are still validated: - the type-12 length must be one of the two physically observed lengths; - the extension length and SHA-256 are recorded; - - the UOID must have the expected 94-byte structure; + - an all-zero or all-`FF` check value is reported as a warning; + - the UOID must have an observed prefix and the expected 94-byte structure; + - the four-byte date must decode as one of the physically observed calendar + values, `2026-03-02` or `2026-04-01`; - `config` must contain CRC-valid JFFS2 nodes; - serial-only accepts only the six known filenames unless `--wipe-unknown-config` is explicit; @@ -225,10 +244,11 @@ only for the observed five-byte extension. A future Elegoo/Jovision build, another payload length, or a different record type or known-prefix layout must be analyzed and fingerprinted separately. -Three real unit identities were directly inspected, including one with the -261-byte HWCONFIG record. Invariant firmware and the known HWCONFIG prefix must -match exactly after extension normalization, while the UOID and serial must -each match their observed structure. +Four real unit identities were directly inspected, including two with the +261-byte HWCONFIG record and one `12PSSSS3` identity. Invariant firmware and +the known HWCONFIG prefix must match exactly after unit-field and extension +normalization, while the UOID and serial must each match their observed +structure. All unit-specific HWCONFIG bytes and the recovered serial payload are preserved exactly. Because the proprietary full serial↔UOID derivation is unknown, the tool does not infer or enforce a relationship between those independently diff --git a/hardware-recovery/TEST_RESULTS.md b/hardware-recovery/TEST_RESULTS.md index aa95aa3..dcaa392 100644 --- a/hardware-recovery/TEST_RESULTS.md +++ b/hardware-recovery/TEST_RESULTS.md @@ -51,11 +51,44 @@ was not present in this interoperability run. | Shared bricked camera | distinct unit A | `4325aebe84d70dd937de1790aa48f4b36ee2731def0ee5504ed080e4df13e819` | 98.567% non-`FF`; 628 obsolete nodes; only `serial.cfg` live | `5939d62a32fd97ab15a5d9b8ab71571831f0f2a30d66019c23deef892905ec09` | passed | | Second bricked camera | distinct unit B | `ff9c8962abd06a14661db1857f6318b06f6378e93c4d812d96224094063bbcf9` | 98.486% non-`FF`; 653 obsolete nodes; only `serial.cfg` live | `269f1b3b205e2ac30ada7cb98a7aeb9ada2e76786dc14abe95ea9c56dce73d1f` | passed | -Both inputs have the same exact invariant firmware fingerprint and stock SquashFS window, but different serials, UOIDs, two-byte HWCONFIG check values, and raw JFFS2 histories. Each output preserves its own complete HWCONFIG partition and exact recovered `serial.cfg` payload. +Both inputs have the same exact invariant firmware fingerprint and stock SquashFS window, but different serials, UOIDs, three-byte HWCONFIG check values, and raw JFFS2 histories. Each output preserves its own complete HWCONFIG partition and exact recovered `serial.cfg` payload. The attached unit-B hardware readback was independently hashed and compared byte-for-byte with the newly generated clean-data image in this review. +## `12PSSSS3` identity variant validation + +Validation date: 2026-09-20 + +A fourth physical camera supplied a stable multi-read dump with SHA-256 +`574c71e6572093ea1b65c3ca9c3e5e1dd1e55d71443ca7b282db8ce700c45c85`. +The contributor compared multiple independent reads by MD5; one representative +raw dump was available for the local software test and was not added to the +repository. + +- Boot, kernel, root, and every invariant system byte match the supported + firmware exactly. +- The stock startup-script window is present. +- The type-12 record has the observed 261-byte shape and a second observed + opaque extension value. +- The serial and UOID use the structurally matching `12PSSSS3` family. +- The dump established a three-byte unit check at `0x7D200A–0x7D200C` and a + calendar-shaped field at `0x7D206F–0x7D2072`, decoding to `2026-03-02`. +- After normalizing those validated unit fields, every remaining HWCONFIG byte + matches the existing cameras exactly. +- Config is 98.761% non-`FF`, with 634 CRC-valid nodes, 632 obsolete nodes, and + only `serial.cfg` live. +- Strict inspection passed. A local serial-only recovery build passed complete + post-build validation and produced SHA-256 + `c28fdd1865219a82dd815bea3333acba91091ac809721b130e805adb53062b66`. + The build preserved HWCONFIG byte-for-byte and changed only the audited + startup-script window and config partition. + +The local recovery build used the explicit reduced-read option because only +one representative file, rather than all independently acquired copies, was +available in the test environment. This is software validation only; the +generated image was not flashed or boot-tested. + ## Independent filesystem and patch checks - A separate read-only SquashFS v4/XZ parser extracted `/home/bashrc.sh` from stock and generated images. @@ -77,8 +110,10 @@ byte-for-byte with the newly generated clean-data image in this review. bytes passed strict analysis and recovery generation; the generated image retained HWCONFIG byte-for-byte. This is software validation, not a physical flash or boot test of that camera. -- Two distinct real serial/UOID pairs: accepted and preserved. -- Structurally valid synthetic third unit with two confirmation reads (three total): accepted and preserved. +- Four distinct real serial/UOID identities were inspected; the available + full-ROM regression inputs were accepted and preserved. +- An additional structurally valid synthetic identity with two confirmation + reads (three total): accepted and preserved. - A raw image with only one read: rejected unless `--allow-fewer-reads` is explicit. - One-bit kernel mutation: rejected. - One-bit system-patch-window mutation: rejected. @@ -91,6 +126,6 @@ byte-for-byte with the newly generated clean-data image in this review. ## Scope of the recovery claim -Software-level recovery is verified: both bricked dumps are transformed into strict, canonical images with the documented hashes, readable patched startup logic, preserved unit identity, and valid filesystem structures. One output also equals the earlier recorded permanent readback hash. +Software-level recovery is verified: the available bricked dumps are transformed into strict, canonical images with the documented hashes, readable patched startup logic, preserved unit identity, and valid filesystem structures. One output also equals the earlier recorded permanent readback hash. Hardware-level recovery is not newly proven by this review. That requires flashing a physical bricked camera, making a full byte-identical readback, and observing a successful boot/USB enumeration. The tool's generated `FLASHING.txt` makes that the required final validation step. diff --git a/tests/test_hwconfig_variants.py b/tests/test_hwconfig_variants.py index 9585d71..e1edd5a 100644 --- a/tests/test_hwconfig_variants.py +++ b/tests/test_hwconfig_variants.py @@ -22,6 +22,22 @@ def hwconfig_record(length: int, extension: bytes = b"") -> bytes: class HwconfigVariantTests(unittest.TestCase): + def test_observed_identity_prefixes_are_supported(self): + for family in (b"3", b"4"): + with self.subTest(family=family.decode("ascii")): + serial = b"serial=12PSSSS" + family + b"A" * 28 + b"\n" + uoid = b"12PSSSS" + family + b"A" * 86 + self.assertTrue(tool.serial_payload_is_valid(serial)) + self.assertIsNotNone(tool.UOID_PATTERN.fullmatch(uoid)) + + def test_unobserved_identity_prefixes_are_rejected(self): + for family in (b"2", b"5"): + with self.subTest(family=family.decode("ascii")): + serial = b"serial=12PSSSS" + family + b"A" * 28 + b"\n" + uoid = b"12PSSSS" + family + b"A" * 86 + self.assertFalse(tool.serial_payload_is_valid(serial)) + self.assertIsNone(tool.UOID_PATTERN.fullmatch(uoid)) + def test_original_256_byte_record_is_supported(self): name, record = tool.identify_hwconfig_variant(hwconfig_record(0x100)) self.assertEqual(name, "type12-length256") @@ -67,6 +83,67 @@ def test_extension_is_normalized_without_hiding_later_mutations(self): tool.invariant_bytes(bytes(mutated), extended_record), ) + def test_unit_fields_are_normalized_without_hiding_neighbors(self): + original = bytearray(hwconfig_record(0x100)) + original[tool.HW_CHECK_START:tool.HW_CHECK_END] = b"\x01\x02\x03" + original[tool.HW_UOID_START:tool.HW_UOID_END] = ( + b"12PSSSS4" + b"A" * 86 + ) + original[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((4, 1)) + ) + + observed = bytearray(hwconfig_record(0x105, bytes.fromhex("0000000840"))) + observed[tool.HW_CHECK_START:tool.HW_CHECK_END] = b"\x04\x05\x06" + observed[tool.HW_UOID_START:tool.HW_UOID_END] = ( + b"12PSSSS3" + b"B" * 86 + ) + observed[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((3, 2)) + ) + + original = bytes(original) + observed = bytes(observed) + _, original_record = tool.identify_hwconfig_variant(original) + _, observed_record = tool.identify_hwconfig_variant(observed) + self.assertEqual( + tool.invariant_bytes(original, original_record), + tool.invariant_bytes(observed, observed_record), + ) + + for offset in (tool.HW_CHECK_START - 1, tool.HW_DATE_END): + mutated = bytearray(observed) + mutated[offset] ^= 1 + with self.subTest(offset=f"0x{offset:06X}"): + self.assertNotEqual( + tool.invariant_bytes(original, original_record), + tool.invariant_bytes(bytes(mutated), observed_record), + ) + + def test_hwconfig_date_is_validated(self): + image = bytearray(hwconfig_record(0x100)) + image[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((3, 2)) + ) + self.assertEqual(tool.decode_hwconfig_date(bytes(image)), "2026-03-02") + + image[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((4, 1)) + ) + self.assertEqual(tool.decode_hwconfig_date(bytes(image)), "2026-04-01") + + image[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((2, 30)) + ) + with self.assertRaisesRegex(tool.ValidationError, "not a valid"): + tool.decode_hwconfig_date(bytes(image)) + + image[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((3, 3)) + ) + with self.assertRaisesRegex(tool.ValidationError, "physically observed"): + tool.decode_hwconfig_date(bytes(image)) + def test_short_known_payload_is_rejected(self): with self.assertRaisesRegex(tool.ValidationError, "shorter"): tool.identify_hwconfig_variant( diff --git a/tests/test_implementation.py b/tests/test_implementation.py index 596e3f9..3ac9b93 100644 --- a/tests/test_implementation.py +++ b/tests/test_implementation.py @@ -81,12 +81,15 @@ def test_serial_and_uoid_are_validated_independently(self): tool.HW_KNOWN_PAYLOAD_END: tool.HW_KNOWN_PAYLOAD_END + len(extension) ] = extension - uoid = b"12PSSSS4DIFF" + b"A" * 82 + uoid = b"12PSSSS3DIFF" + b"A" * 82 image[tool.HW_UOID_START:tool.HW_UOID_END] = uoid - image[tool.HW_CHECK_START:tool.HW_CHECK_END] = b"\x01\x02" + image[tool.HW_CHECK_START:tool.HW_CHECK_END] = b"\x01\x02\x03" + image[tool.HW_DATE_START:tool.HW_DATE_END] = ( + (2026).to_bytes(2, "little") + bytes((3, 2)) + ) serial_payload = ( - b"serial=12PSSSS4TEST000000000000000000000000\n" + b"serial=12PSSSS3TEST000000000000000000000000\n" ) serial_value = serial_payload.removeprefix(b"serial=").removesuffix(b"\n") self.assertNotEqual(serial_value[:12], uoid[:12]) @@ -99,8 +102,8 @@ def test_serial_and_uoid_are_validated_independently(self): before_identity_sha256 = tool.sha256( tool.normalized_hwconfig_before_identity(image) ) - after_uoid_sha256 = tool.sha256( - tool.normalized_hwconfig_after_uoid(image, hwconfig_record) + after_unit_fields_sha256 = tool.sha256( + tool.normalized_hwconfig_after_unit_fields(image, hwconfig_record) ) invariant_sha256 = tool.sha256( tool.invariant_bytes(image, hwconfig_record) @@ -129,8 +132,8 @@ def test_serial_and_uoid_are_validated_independently(self): ), mock.patch.object( tool, - "HWCONFIG_AFTER_UOID_SHA256", - after_uoid_sha256, + "HWCONFIG_AFTER_UNIT_FIELDS_SHA256", + after_unit_fields_sha256, ), mock.patch.object( tool, @@ -176,6 +179,7 @@ def test_serial_and_uoid_are_validated_independently(self): extension, ) self.assertEqual(result["analysis"]["hwconfig_extension_length"], 5) + self.assertEqual(result["analysis"]["hwconfig_date"], "2026-03-02") self.assertEqual( result["analysis"]["hwconfig_extension_sha256"], tool.sha256(extension), @@ -202,6 +206,10 @@ def test_serial_and_uoid_are_validated_independently(self): generated_manifest["validation"]["hwconfig_extension_sha256"], tool.sha256(extension), ) + self.assertEqual( + generated_manifest["validation"]["hwconfig_date"], + "2026-03-02", + ) self.assertNotIn( "serial_uoid_prefix_match", generated_manifest["validation"] )