From bb4d43028a839e505137d1672c1d59df1f09b14c Mon Sep 17 00:00:00 2001 From: yarrrly <36712561+yarrrly@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:28:44 +0200 Subject: [PATCH] Verify GBLv4 content hashes, memory sections and the manifest signature The manifest already cross-references the rest of a v4 file: CONTENT_HASH covers the payload after it, each UPDATE_MEMORY_SECTION carries the digest of a MEMORY_SECTION_INFO tag and the offset of the section it belongs to, and that info tag carries the digest of the image the section expands to. None of it was checkable, unlike GBL3Image.verify_signature. Adds verify_content_hash, verify_memory_sections, verify_signature, plain_image and the offsets and enums they need, mirroring the v3 API. --- README.md | 25 ++ pygbl/__init__.py | 8 + pygbl/crypto.py | 12 + pygbl/gbl4.py | 222 ++++++++++++- tests/test_gbl4.py | 55 +++- tests/test_gbl4_verification.py | 545 ++++++++++++++++++++++++++++++++ tests/test_optional_deps.py | 19 ++ 7 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 tests/test_gbl4_verification.py diff --git a/README.md b/README.md index 0830ae2..c5cae7d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,31 @@ for info in image.get_tags(GBL4MemorySectionInfo): print(info.compression_scheme, info.encryption_scheme, info.nonce.hex()) ``` +The manifest binds the rest of the file together, and those bindings can be checked +before flashing anything: + +```python +assert image.verify_content_hash() # the payload after the manifest +assert image.verify_memory_sections() # each section against the update that names it +assert image.verify_signature() # the manifest, against its own certificate +``` + +`verify_signature` with no argument uses the key in the image's `CERTIFICATE` tag, which +shows the manifest has not been altered since signing, not that a vendor you trust +signed it. Pass the key you trust to check that; images that carry no certificate, as +Philips Hue's do not, require one. + +Sections expand to the image the device flashes: + +```python +for section in image.get_tags(GBL4MemorySection): + print(len(image.plain_image(section))) +``` + +An encrypted section raises `ValidationError`: the key lives in the device, so the blob +cannot be expanded off-device. `verify_memory_sections` still checks such a section +against the digest the manifest holds for it. + Reading and writing are supported; building a v4 image from an ELF is not, the ELF helpers are GBLv3 only. diff --git a/pygbl/__init__.py b/pygbl/__init__.py index 5176535..60a3edf 100644 --- a/pygbl/__init__.py +++ b/pygbl/__init__.py @@ -67,8 +67,11 @@ GBL4_MAGIC, GBL4BundleVersion, GBL4Certificate, + GBL4CompressionScheme, GBL4ContentHash, + GBL4EncryptionScheme, GBL4Feature, + GBL4HashType, GBL4Image, GBL4Manifest, GBL4ManifestFinish, @@ -80,6 +83,7 @@ GBL4Root, GBL4SeBlob, GBL4Signature, + GBL4SignatureType, GBL4Tag, GBL4TagBase, GBL4TagId, @@ -145,8 +149,11 @@ def parse_firmware_image(data: bytes, *, validate: bool = True) -> FirmwareImage "GBL4_MAGIC", "GBL4BundleVersion", "GBL4Certificate", + "GBL4CompressionScheme", "GBL4ContentHash", + "GBL4EncryptionScheme", "GBL4Feature", + "GBL4HashType", "GBL4Image", "GBL4Manifest", "GBL4ManifestFinish", @@ -158,6 +165,7 @@ def parse_firmware_image(data: bytes, *, validate: bool = True) -> FirmwareImage "GBL4Root", "GBL4SeBlob", "GBL4Signature", + "GBL4SignatureType", "GBL4Tag", "GBL4TagBase", "GBL4TagId", diff --git a/pygbl/crypto.py b/pygbl/crypto.py index 4fa9281..66e9096 100644 --- a/pygbl/crypto.py +++ b/pygbl/crypto.py @@ -59,6 +59,18 @@ def aes_ctr_crypt( return encryptor.update(data) + encryptor.finalize() +def load_public_key(key: bytes) -> ec.EllipticCurvePublicKey: + """Load a public key stored as the raw `x || y` pair the certificates carry.""" + require_cryptography() + + if len(key) != 2 * SIGNATURE_COMPONENT_SIZE: + raise ValueError( + f"Key must be {2 * SIGNATURE_COMPONENT_SIZE} bytes, got {len(key)}" + ) + + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), b"\x04" + key) + + def sign_digest( private_key: ec.EllipticCurvePrivateKey, digest: bytes ) -> tuple[bytes, bytes]: diff --git a/pygbl/gbl4.py b/pygbl/gbl4.py index 060c9ad..b2b36a2 100644 --- a/pygbl/gbl4.py +++ b/pygbl/gbl4.py @@ -9,9 +9,16 @@ import dataclasses import enum -from typing import Any, Self, Union +import hashlib +from typing import TYPE_CHECKING, Any, Self, Union -from pygbl.types import ParseError, T +from pygbl.compression import lz4_decompress, lzma_decompress +from pygbl.crypto import load_public_key, verify_digest +from pygbl.types import ParseError, T, ValidationError + +if TYPE_CHECKING: + # Only needed for annotations: signature verification is an optional feature + from cryptography.hazmat.primitives.asymmetric import ec # Root tag `0x84A617EB`, from the same `A617EB` family as v3's header but with version # byte 0x84 instead of 0x03 @@ -52,6 +59,29 @@ class GBL4TargetMemory(enum.IntEnum): OSPI2 = 2 +class GBL4HashType(enum.IntEnum): + """The `hash_type` word of a `HashValue` holding a digest.""" + + SHA256 = 1 + + +class GBL4SignatureType(enum.IntEnum): + """The same word, when the `HashValue` holds a signature instead of a digest.""" + + ECDSA_P256 = 2 + + +class GBL4CompressionScheme(enum.IntEnum): + NONE = 0 + LZ4 = 1 + LZMA = 2 + + +class GBL4EncryptionScheme(enum.IntEnum): + NONE = 0 + AES_CCM = 1 + + @dataclasses.dataclass(frozen=True, kw_only=True) class HashValue: """A hash or signature: a type word followed by the value. @@ -74,6 +104,17 @@ def from_bytes(cls, data: bytes) -> HashValue: def serialize(self) -> bytes: return self.hash_type.to_bytes(4, "little") + self.value + def digest(self, data: bytes) -> bytes: + """Hash `data` with the algorithm this value declares.""" + if self.hash_type != GBL4HashType.SHA256: + raise ValidationError(f"Unsupported hash type {self.hash_type:#x}") + + return hashlib.sha256(data).digest() + + def matches(self, data: bytes) -> bool: + """Whether `data` hashes to this value.""" + return self.digest(data) == self.value + @dataclasses.dataclass(frozen=True, kw_only=True) class GBL4TagBase: @@ -128,8 +169,26 @@ class GBL4UpdateProcess(_GBL4Container): @dataclasses.dataclass(frozen=True, kw_only=True) class GBL4MemorySection(_GBL4Container): + """One update payload: a fixed info header followed by the image blob.""" + tag_id: int = GBL4TagId.MEMORY_SECTION + @property + def info(self) -> GBL4MemorySectionInfo: + for child in self.children: + if isinstance(child, GBL4MemorySectionInfo): + return child + + raise ParseError("Memory section has no info tag") + + @property + def blob(self) -> GBL4MemorySectionBlob: + for child in self.children: + if isinstance(child, GBL4MemorySectionBlob): + return child + + raise ParseError("Memory section has no blob tag") + @dataclasses.dataclass(frozen=True, kw_only=True) class GBL4Certificate(GBL4TagBase): @@ -165,6 +224,10 @@ def serialize_payload(self) -> bytes: + self.signature ) + def public_key(self) -> ec.EllipticCurvePublicKey: + """The certified key, as a `cryptography` object.""" + return load_public_key(self.key) + @dataclasses.dataclass(frozen=True, kw_only=True) class GBL4Signature(GBL4TagBase): @@ -555,3 +618,158 @@ def walk(tags: list[GBL4TagBase]) -> None: def get_first_tag(self, tag_type: type[T]) -> T: return self.get_tags(tag_type)[0] + + def root_child_offsets(self) -> list[tuple[GBL4TagBase, int]]: + """Each top-level tag with its offset from the start of the image. + + The manifest addresses payloads by absolute file offset, so the offsets have to + include the root tag's own 8 byte header. + """ + offsets: list[tuple[GBL4TagBase, int]] = [] + offset = 8 + + for child in self.root.children: + offsets.append((child, offset)) + offset += 8 + len(child.serialize_payload()) + + return offsets + + def memory_section_at(self, position: int) -> GBL4MemorySection: + """The memory section a `memory_section_position` points at.""" + for child, offset in self.root_child_offsets(): + if offset == position and isinstance(child, GBL4MemorySection): + return child + + raise KeyError(f"No memory section at offset {position:#x}") + + def content(self) -> bytes: + """The bytes `CONTENT_HASH` covers: everything after the manifest tag.""" + children = self.root.children + + for index, child in enumerate(children): + if isinstance(child, GBL4Manifest): + return b"".join(serialize_tag(t) for t in children[index + 1 :]) + + raise KeyError("Image has no manifest") + + def verify_content_hash(self) -> bool: + """Whether the payload following the manifest hashes to `CONTENT_HASH`.""" + return self.get_first_tag(GBL4ContentHash).hash.matches(self.content()) + + def verify_memory_sections(self) -> bool: + """Whether every update in the manifest matches the section it points at. + + Each `UPDATE_MEMORY_SECTION` carries the digest of a whole `MEMORY_SECTION_INFO` + tag, header included, which in turn carries the digest of the image the section + expands to. The second half can only be checked when the section is one this + library can expand, so an encrypted section is verified as far as its info tag. + """ + for update in self.get_tags(GBL4UpdateMemorySection): + try: + section = self.memory_section_at(update.memory_section_position) + except KeyError: + return False + + if not update.hash.matches(serialize_tag(section.info)): + return False + + if section.info.encryption_scheme != GBL4EncryptionScheme.NONE: + continue + + plain = self.plain_image(section) + + if len(plain) != update.plain_image_size: + return False + + # A fixed 64 byte field holding a 32 byte digest, zero padded + if hashlib.sha256(plain).digest() != section.info.final_image_hash[:32]: + return False + + return True + + def plain_image(self, section: GBL4MemorySection) -> bytes: + """Expand a memory section into the image the device will flash.""" + info = section.info + + if info.encryption_scheme != GBL4EncryptionScheme.NONE: + raise ValidationError( + f"Memory section is encrypted with scheme {info.encryption_scheme:#x}," + f" which cannot be decrypted without the device's key" + ) + + data = section.blob.data + + if info.compression_scheme == GBL4CompressionScheme.NONE: + return data + + if info.compression_scheme == GBL4CompressionScheme.LZMA: + return lzma_decompress(data) + + if info.compression_scheme == GBL4CompressionScheme.LZ4: + return lz4_decompress(data) + + raise ValidationError( + f"Unsupported compression scheme {info.compression_scheme:#x}" + ) + + def signing_content(self) -> bytes: + """The manifest tags an `ECDSA_P256` signature covers. + + Everything in the manifest after the signature tag itself, which is the manifest + info, the bundle version, the content hash and the update process. The + certificate sits before the signature and is not part of the signed range. + """ + manifest = self.get_first_tag(GBL4Manifest) + children = manifest.children + + for index, child in enumerate(children): + if isinstance(child, GBL4Signature): + return b"".join(serialize_tag(t) for t in children[index + 1 :]) + + raise KeyError("Image is not signed") + + def signing_digest(self) -> bytes: + return hashlib.sha256(self.signing_content()).digest() + + def verify_signature( + self, public_key: ec.EllipticCurvePublicKey | None = None + ) -> bool: + """Whether the manifest signature is valid. + + With no key, the one in the image's own certificate is used. That checks the + manifest has not been altered since it was signed, not that a vendor you trust + signed it: pass the key you trust to check that. + """ + signatures = self.get_tags(GBL4Signature, allow_missing=True) + + if not signatures: + raise KeyError("Image is not signed") + + signature = signatures[0].signature + + if signature.hash_type != GBL4SignatureType.ECDSA_P256: + raise ValidationError( + f"Unsupported signature type {signature.hash_type:#x}" + ) + + if len(signature.value) != 64: + raise ValidationError( + f"ECDSA P-256 signature must be 64 bytes, got {len(signature.value)}" + ) + + if public_key is None: + certificates = self.get_tags(GBL4Certificate, allow_missing=True) + + if not certificates: + raise KeyError( + "Image has no certificate, so a public key must be passed" + ) + + public_key = certificates[0].public_key() + + return verify_digest( + public_key, + self.signing_digest(), + signature.value[:32], + signature.value[32:], + ) diff --git a/tests/test_gbl4.py b/tests/test_gbl4.py index ed37a0a..4e8a239 100644 --- a/tests/test_gbl4.py +++ b/tests/test_gbl4.py @@ -13,6 +13,8 @@ from pygbl import ( GBL4_MAGIC, GBL4BundleVersion, + GBL4Certificate, + GBL4EncryptionScheme, GBL4Feature, GBL4Image, GBL4Manifest, @@ -30,6 +32,7 @@ GBL4UpdateProcess, HashValue, ParseError, + ValidationError, parse_firmware_image, ) @@ -92,7 +95,13 @@ def test_manifest_fields(path: pathlib.Path) -> None: info = image.get_first_tag(GBL4ManifestInfo) assert GBL4Feature.COMPRESSION in info.features - assert GBL4Feature.ENCRYPTION_AESCCM in info.features + + # Encryption is per vendor: Hue encrypts its sections, IKEA ships them in the clear + encrypted = GBL4Feature.ENCRYPTION_AESCCM in info.features + schemes = {i.encryption_scheme for i in image.get_tags(GBL4MemorySectionInfo)} + assert schemes == ( + {GBL4EncryptionScheme.AES_CCM} if encrypted else {GBL4EncryptionScheme.NONE} + ) bundle = image.get_first_tag(GBL4BundleVersion) assert len(bundle.product_id) == 16 @@ -134,6 +143,50 @@ def test_padding_is_preserved(path: pathlib.Path) -> None: assert pad.data == pad.serialize_payload() +@pytest.mark.parametrize("path", IMAGES, ids=lambda p: p.name) +def test_content_hash_verifies(path: pathlib.Path) -> None: + assert load(path).verify_content_hash() + + +@pytest.mark.parametrize("path", IMAGES, ids=lambda p: p.name) +def test_memory_sections_verify(path: pathlib.Path) -> None: + assert load(path).verify_memory_sections() + + +@pytest.mark.parametrize("path", IMAGES, ids=lambda p: p.name) +def test_updates_resolve_to_sections(path: pathlib.Path) -> None: + image = load(path) + + for update in image.get_tags(GBL4UpdateMemorySection): + section = image.memory_section_at(update.memory_section_position) + assert section in image.get_tags(GBL4MemorySection) + + +@pytest.mark.parametrize("path", IMAGES, ids=lambda p: p.name) +def test_signature_verifies_when_a_certificate_is_present(path: pathlib.Path) -> None: + """Hue images are signed but ship no certificate, so the key has to come from + elsewhere. Images that do carry one verify against it.""" + image = load(path) + + if image.get_tags(GBL4Certificate, allow_missing=True): + assert image.verify_signature() + else: + with pytest.raises(KeyError, match="no certificate"): + image.verify_signature() + + +@pytest.mark.parametrize("path", IMAGES, ids=lambda p: p.name) +def test_encrypted_sections_are_not_expanded(path: pathlib.Path) -> None: + image = load(path) + + for section in image.get_tags(GBL4MemorySection): + if section.info.encryption_scheme == GBL4EncryptionScheme.NONE: + assert len(image.plain_image(section)) > 0 + else: + with pytest.raises(ValidationError, match="encrypted"): + image.plain_image(section) + + def test_hash_value_roundtrip() -> None: value = HashValue(hash_type=1, value=bytes(range(32))) diff --git a/tests/test_gbl4_verification.py b/tests/test_gbl4_verification.py new file mode 100644 index 0000000..d883a14 --- /dev/null +++ b/tests/test_gbl4_verification.py @@ -0,0 +1,545 @@ +"""The manifest checks a Series 3 bootloader makes before it flashes an image. + +The images here are synthetic, built to the layout the real ones use: a manifest whose +signature covers the tags after it, a content hash over the payload that follows the +manifest, and one memory section per update, bound to the manifest by the digest of its +info tag. `tests/test_gbl4.py` runs the same checks against the local corpus. +""" + +from __future__ import annotations + +import dataclasses +import hashlib + +from cryptography.hazmat.primitives.asymmetric import ec +import pytest + +from pygbl import ( + GBL4BundleVersion, + GBL4Certificate, + GBL4CompressionScheme, + GBL4ContentHash, + GBL4EncryptionScheme, + GBL4Feature, + GBL4HashType, + GBL4Image, + GBL4Manifest, + GBL4ManifestFinish, + GBL4ManifestInfo, + GBL4MemorySection, + GBL4MemorySectionBlob, + GBL4MemorySectionInfo, + GBL4Pad, + GBL4Root, + GBL4SeBlob, + GBL4Signature, + GBL4SignatureType, + GBL4TagBase, + GBL4UpdateMemorySection, + GBL4UpdateProcess, + GBL4UpdateSe, + HashValue, + ParseError, + ValidationError, +) +from pygbl.compression import lz4_compress, lzma_compress +from pygbl.crypto import load_public_key, sign_digest +from pygbl.gbl4 import serialize_tag + +PRIVATE_KEY = ec.generate_private_key(ec.SECP256R1()) +OTHER_KEY = ec.generate_private_key(ec.SECP256R1()) +PRODUCT_ID = bytes(range(16)) +APPLICATION = bytes((i * 7) % 251 for i in range(8192)) +BOOTLOADER = bytes((i * 11) % 241 for i in range(2048)) + + +def raw_public_key(private_key: ec.EllipticCurvePrivateKey) -> bytes: + numbers = private_key.public_key().public_numbers() + + return numbers.x.to_bytes(32, "big") + numbers.y.to_bytes(32, "big") + + +def build_section( + plain: bytes, + *, + compression: GBL4CompressionScheme = GBL4CompressionScheme.LZMA, + encryption: GBL4EncryptionScheme = GBL4EncryptionScheme.NONE, +) -> GBL4MemorySection: + blob = lzma_compress(plain) if compression == GBL4CompressionScheme.LZMA else plain + + return GBL4MemorySection( + children=[ + GBL4MemorySectionInfo( + compression_scheme=compression, + encryption_scheme=encryption, + secure_boot_scheme=0, + reserved=0, + sign_block_size=0, + num_blocks=0, + nonce=bytes(12), + # A fixed 64 byte field holding a 32 byte digest, zero padded + final_image_hash=hashlib.sha256(plain).digest() + bytes(32), + secure_boot_signature=bytes(128), + ), + GBL4MemorySectionBlob(data=blob), + ] + ) + + +def build_image( + payloads: list[bytes], + *, + certificate: bool = True, + se_blob: bytes | None = None, + pad: bool = False, + encryption: GBL4EncryptionScheme = GBL4EncryptionScheme.NONE, +) -> GBL4Image: + """Assemble a signed image whose offsets, digests and signature all agree. + + Every field the manifest cross-references has a fixed width, so the layout can be + settled first and the values filled in afterwards without moving anything. + """ + sections = [build_section(p, encryption=encryption) for p in payloads] + updates = [ + GBL4UpdateMemorySection( + target_memory=0, + plain_image_size=len(plain), + target_address=0x01008000 + index * 0x1000, + type=1, + version=1, + capabilities=0, + memory_section_position=0, + hash=HashValue( + hash_type=GBL4HashType.SHA256, + value=hashlib.sha256(serialize_tag(section.info)).digest(), + ), + ) + for index, (plain, section) in enumerate(zip(payloads, sections)) + ] + + manifest_children: list[GBL4TagBase] = [] + + if certificate: + manifest_children.append( + GBL4Certificate( + struct_version=1, + flags=bytes(3), + key=raw_public_key(PRIVATE_KEY), + version=2, + signature=bytes(64), + ) + ) + + manifest_children += [ + GBL4Signature( + signature=HashValue(hash_type=GBL4SignatureType.ECDSA_P256, value=bytes(64)) + ), + GBL4ManifestInfo(version=0x04000000, features=GBL4Feature.COMPRESSION), + GBL4BundleVersion(product_id=PRODUCT_ID, bundle_version=0, min_version=0), + GBL4ContentHash(hash=HashValue(hash_type=GBL4HashType.SHA256, value=bytes(32))), + GBL4UpdateProcess( + children=( + ([GBL4UpdateSe(version=0x30307, tlv_position=0)] if se_blob else []) + + [*updates, GBL4ManifestFinish()] + ) + ), + ] + + root_children: list[GBL4TagBase] = [GBL4Manifest(children=manifest_children)] + + if se_blob is not None: + root_children.append(GBL4SeBlob(data=se_blob)) + + root_children += sections + + if pad: + root_children.append(GBL4Pad(data=b"\xff\xff")) + + image = GBL4Image(root=GBL4Root(children=root_children)) + + return sign_image(fill_positions(image)) + + +def fill_positions(image: GBL4Image) -> GBL4Image: + """Point each update at its section, now that the layout is known.""" + offsets = image.root_child_offsets() + sections = [(o, c) for c, o in offsets if isinstance(c, GBL4MemorySection)] + se_offsets = [o for c, o in offsets if isinstance(c, GBL4SeBlob)] + process = image.get_first_tag(GBL4UpdateProcess) + children: list[GBL4TagBase] = [] + index = 0 + + for child in process.children: + if isinstance(child, GBL4UpdateMemorySection): + child = dataclasses.replace( + child, memory_section_position=sections[index][0] + ) + index += 1 + elif isinstance(child, GBL4UpdateSe): + # Unlike a memory section, an SE update points at the blob's payload + child = dataclasses.replace(child, tlv_position=se_offsets[0] + 8) + + children.append(child) + + return replace_tag(image, process, GBL4UpdateProcess(children=children)) + + +def sign_image(image: GBL4Image) -> GBL4Image: + """Fill in the content hash, then sign everything the signature covers.""" + content_hash = image.get_first_tag(GBL4ContentHash) + image = replace_tag( + image, + content_hash, + GBL4ContentHash( + hash=HashValue( + hash_type=GBL4HashType.SHA256, + value=hashlib.sha256(image.content()).digest(), + ) + ), + ) + + r, s = sign_digest(PRIVATE_KEY, image.signing_digest()) + signature = image.get_first_tag(GBL4Signature) + + return replace_tag( + image, + signature, + GBL4Signature( + signature=HashValue(hash_type=GBL4SignatureType.ECDSA_P256, value=r + s) + ), + ) + + +def replace_tag(image: GBL4Image, old: GBL4TagBase, new: GBL4TagBase) -> GBL4Image: + """Swap one tag for another of the same size, anywhere in the tree.""" + + def walk(tags: list[GBL4TagBase]) -> list[GBL4TagBase]: + out: list[GBL4TagBase] = [] + + for tag in tags: + if tag is old: + out.append(new) + elif isinstance(tag, (GBL4Manifest, GBL4UpdateProcess, GBL4MemorySection)): + out.append(dataclasses.replace(tag, children=walk(tag.children))) + else: + out.append(tag) + + return out + + return GBL4Image(root=GBL4Root(children=walk(image.root.children))) + + +@pytest.fixture +def image() -> GBL4Image: + return build_image([APPLICATION]) + + +@pytest.fixture +def bundle() -> GBL4Image: + """The shape a Hue SiMG301 file has: an SE blob and two memory sections.""" + return build_image( + [APPLICATION, BOOTLOADER], se_blob=bytes(range(256)) * 4, pad=True + ) + + +def test_offsets_include_the_root_header(image: GBL4Image) -> None: + """Positions in the manifest are file offsets, not offsets into the root payload.""" + data = image.serialize() + + for tag, offset in image.root_child_offsets(): + assert data[offset : offset + len(serialize_tag(tag))] == serialize_tag(tag) + + assert image.root_child_offsets()[0][1] == 8 + + +def test_update_points_at_its_section(bundle: GBL4Image) -> None: + sections = bundle.get_tags(GBL4MemorySection) + updates = bundle.get_tags(GBL4UpdateMemorySection) + + assert len(sections) == len(updates) == 2 + + for update, section in zip(updates, sections): + assert bundle.memory_section_at(update.memory_section_position) is section + + +def test_memory_section_at_rejects_a_bad_position(image: GBL4Image) -> None: + with pytest.raises(KeyError, match="No memory section at offset"): + image.memory_section_at(0x1234) + + +def test_verification_passes(image: GBL4Image, bundle: GBL4Image) -> None: + for subject in (image, bundle): + assert subject.verify_content_hash() + assert subject.verify_memory_sections() + assert subject.verify_signature() + + +def test_content_is_everything_after_the_manifest(image: GBL4Image) -> None: + data = image.serialize() + manifest = serialize_tag(image.get_first_tag(GBL4Manifest)) + + assert image.content() == data[8 + len(manifest) :] + + +def test_content_hash_notices_a_changed_payload(image: GBL4Image) -> None: + blob = image.get_first_tag(GBL4MemorySectionBlob) + flipped = bytes([blob.data[0] ^ 0xFF]) + blob.data[1:] + tampered = replace_tag(image, blob, GBL4MemorySectionBlob(data=flipped)) + + assert not tampered.verify_content_hash() + + +def test_memory_sections_notice_a_changed_info_tag(image: GBL4Image) -> None: + info = image.get_first_tag(GBL4MemorySectionInfo) + tampered = replace_tag( + image, info, dataclasses.replace(info, final_image_hash=bytes(64)) + ) + + assert not tampered.verify_memory_sections() + + +def test_memory_sections_notice_a_moved_section(image: GBL4Image) -> None: + update = image.get_first_tag(GBL4UpdateMemorySection) + tampered = replace_tag( + image, update, dataclasses.replace(update, memory_section_position=0x1234) + ) + + assert not tampered.verify_memory_sections() + + +def test_memory_sections_notice_a_wrong_plain_size(image: GBL4Image) -> None: + update = image.get_first_tag(GBL4UpdateMemorySection) + tampered = replace_tag( + image, update, dataclasses.replace(update, plain_image_size=1) + ) + + assert not tampered.verify_memory_sections() + + +def test_signature_covers_the_manifest_after_itself(image: GBL4Image) -> None: + manifest = image.get_first_tag(GBL4Manifest) + signature = image.get_first_tag(GBL4Signature) + after = manifest.children[manifest.children.index(signature) + 1 :] + + assert image.signing_content() == b"".join(serialize_tag(t) for t in after) + # The certificate sits before the signature, so it is not covered + assert serialize_tag(image.get_first_tag(GBL4Certificate)) not in image.content() + + +def test_signature_notices_a_changed_manifest(image: GBL4Image) -> None: + info = image.get_first_tag(GBL4ManifestInfo) + tampered = replace_tag(image, info, dataclasses.replace(info, version=0x05000000)) + + assert not tampered.verify_signature() + + +def test_signature_against_an_explicit_key(image: GBL4Image) -> None: + assert image.verify_signature(PRIVATE_KEY.public_key()) + assert not image.verify_signature(OTHER_KEY.public_key()) + + +def test_signature_without_a_certificate() -> None: + """A Hue image is signed but carries no certificate, so a key has to be supplied.""" + unsigned_by_us = build_image([APPLICATION], certificate=False) + + with pytest.raises(KeyError, match="no certificate"): + unsigned_by_us.verify_signature() + + assert unsigned_by_us.verify_signature(PRIVATE_KEY.public_key()) + + +def test_unsigned_image_raises(image: GBL4Image) -> None: + manifest = image.get_first_tag(GBL4Manifest) + signature = image.get_first_tag(GBL4Signature) + stripped = GBL4Image( + root=GBL4Root( + children=[ + dataclasses.replace( + manifest, + children=[c for c in manifest.children if c is not signature], + ), + *image.root.children[1:], + ] + ) + ) + + with pytest.raises(KeyError, match="not signed"): + stripped.verify_signature() + + with pytest.raises(KeyError, match="not signed"): + stripped.signing_content() + + +def test_unknown_signature_type_is_refused(image: GBL4Image) -> None: + signature = image.get_first_tag(GBL4Signature) + tampered = replace_tag( + image, + signature, + GBL4Signature(signature=dataclasses.replace(signature.signature, hash_type=9)), + ) + + with pytest.raises(ValidationError, match="Unsupported signature type"): + tampered.verify_signature() + + +def test_unknown_hash_type_is_refused(image: GBL4Image) -> None: + content_hash = image.get_first_tag(GBL4ContentHash) + tampered = replace_tag( + image, + content_hash, + GBL4ContentHash(hash=dataclasses.replace(content_hash.hash, hash_type=9)), + ) + + with pytest.raises(ValidationError, match="Unsupported hash type"): + tampered.verify_content_hash() + + +def test_plain_image_expands_lzma(image: GBL4Image) -> None: + section = image.get_first_tag(GBL4MemorySection) + + assert image.plain_image(section) == APPLICATION + assert len(section.blob.data) < len(APPLICATION) + + +def test_plain_image_passes_through_uncompressed_sections() -> None: + uncompressed = build_image([APPLICATION]) + section = uncompressed.get_first_tag(GBL4MemorySection) + swapped = replace_tag( + uncompressed, + section, + GBL4MemorySection( + children=[ + dataclasses.replace( + section.info, compression_scheme=GBL4CompressionScheme.NONE + ), + GBL4MemorySectionBlob(data=APPLICATION), + ] + ), + ) + + assert swapped.plain_image(swapped.get_first_tag(GBL4MemorySection)) == APPLICATION + + +def test_plain_image_refuses_an_encrypted_section() -> None: + encrypted = build_image([APPLICATION], encryption=GBL4EncryptionScheme.AES_CCM) + section = encrypted.get_first_tag(GBL4MemorySection) + + with pytest.raises(ValidationError, match="encrypted"): + encrypted.plain_image(section) + + +def test_encrypted_sections_are_still_bound_to_the_manifest() -> None: + """What can be checked without the key: the section is the one the manifest names.""" + encrypted = build_image([APPLICATION], encryption=GBL4EncryptionScheme.AES_CCM) + + assert encrypted.verify_memory_sections() + assert encrypted.verify_content_hash() + assert encrypted.verify_signature() + + +def test_plain_image_refuses_an_unknown_compression_scheme(image: GBL4Image) -> None: + section = image.get_first_tag(GBL4MemorySection) + tampered = replace_tag( + image, + section, + dataclasses.replace( + section, + children=[ + dataclasses.replace(section.info, compression_scheme=9), + section.blob, + ], + ), + ) + + with pytest.raises(ValidationError, match="Unsupported compression scheme"): + tampered.plain_image(tampered.get_first_tag(GBL4MemorySection)) + + +def test_section_without_an_info_tag_is_refused(image: GBL4Image) -> None: + section = image.get_first_tag(GBL4MemorySection) + stripped = GBL4MemorySection(children=[section.blob]) + + with pytest.raises(ParseError, match="no info tag"): + _ = stripped.info + + with pytest.raises(ParseError, match="no blob tag"): + _ = GBL4MemorySection(children=[section.info]).blob + + +def rebind(image: GBL4Image, section: GBL4MemorySection) -> GBL4Image: + """Recompute the update digest for a section that was changed on purpose.""" + update = image.get_first_tag(GBL4UpdateMemorySection) + + return replace_tag( + image, + update, + dataclasses.replace( + update, + hash=HashValue( + hash_type=GBL4HashType.SHA256, + value=hashlib.sha256(serialize_tag(section.info)).digest(), + ), + ), + ) + + +def test_memory_sections_notice_a_wrong_final_image_hash(image: GBL4Image) -> None: + """The section still matches its update, but not the image it expands to.""" + section = image.get_first_tag(GBL4MemorySection) + changed = dataclasses.replace( + section, + children=[ + dataclasses.replace(section.info, final_image_hash=bytes(64)), + section.blob, + ], + ) + + assert not rebind( + replace_tag(image, section, changed), changed + ).verify_memory_sections() + + +def test_plain_image_expands_lz4() -> None: + section = build_section(APPLICATION, compression=GBL4CompressionScheme.NONE) + packed = dataclasses.replace( + section, + children=[ + dataclasses.replace( + section.info, compression_scheme=GBL4CompressionScheme.LZ4 + ), + GBL4MemorySectionBlob(data=lz4_compress(APPLICATION)), + ], + ) + image = build_image([APPLICATION]) + + assert image.plain_image(packed) == APPLICATION + + +def test_content_without_a_manifest(image: GBL4Image) -> None: + stripped = GBL4Image( + root=GBL4Root( + children=[c for c in image.root.children if not isinstance(c, GBL4Manifest)] + ) + ) + + with pytest.raises(KeyError, match="no manifest"): + stripped.content() + + +def test_signature_of_the_wrong_length_is_refused(image: GBL4Image) -> None: + signature = image.get_first_tag(GBL4Signature) + tampered = replace_tag( + image, + signature, + GBL4Signature( + signature=dataclasses.replace(signature.signature, value=bytes(32)) + ), + ) + + with pytest.raises(ValidationError, match="must be 64 bytes"): + tampered.verify_signature() + + +def test_public_key_must_be_a_raw_point() -> None: + with pytest.raises(ValueError, match="Key must be 64 bytes"): + load_public_key(bytes(32)) diff --git a/tests/test_optional_deps.py b/tests/test_optional_deps.py index efa63a8..9d8bc3d 100644 --- a/tests/test_optional_deps.py +++ b/tests/test_optional_deps.py @@ -16,6 +16,7 @@ GBL3Type, MissingDependencyError, ) +from tests.test_gbl4_verification import APPLICATION, build_image as build_gbl4_image PAYLOAD = bytes((i * 3) % 211 for i in range(4096)) CRYPTO_EXTRA = r"pygbl\[crypto\]" @@ -114,3 +115,21 @@ def test_lzma_needs_nothing() -> None: assert image.compress(GBL3Compression.LZMA).decompress().serialize() == ( image.serialize() ) + + +def test_gbl4_verify_signature_needs_cryptography() -> None: + image = build_gbl4_image([APPLICATION]) + + with ( + patch("pygbl.crypto.HAVE_CRYPTOGRAPHY", False), + pytest.raises(MissingDependencyError, match=CRYPTO_EXTRA), + ): + image.verify_signature() + + +def test_gbl4_hashes_do_not_need_cryptography() -> None: + image = build_gbl4_image([APPLICATION]) + + with patch("pygbl.crypto.HAVE_CRYPTOGRAPHY", False): + assert image.verify_content_hash() + assert image.verify_memory_sections()