If GPG is missing, signature verification must fail closed

·3 mins

An updater effectively contained the equivalent of if gpg: verify_signature(), then extracted the downloaded archive unconditionally. On a minimal installation without the optional binding, unsigned content took the trusted path. If authenticity is required, treat an unavailable verifier as a hard failure, not permission to continue.

This pattern is CWE-347, improper verification of a cryptographic signature . It becomes more dangerous when the next stage expands an archive, loads a plugin, flashes firmware, or executes an installer, because the bypass then feeds directly into execution.

Optional crypto turns verification into best effort

This shape is unsafe:

if verifier is not None:
    if not verifier.verify(payload, signature):
        raise SignatureError("bad signature")

extract(payload)  # Runs when no verifier exists.

The absence branch is the bypass. During review, search for trust decisions guarded by import success, feature flags, platform checks, or broad exception handlers, then follow each branch to the first sensitive consumer. A green path should require a positive verification result.

An attacker still needs influence over the downloaded payload or its source. That may come from a compromised update endpoint, repository, mirror, DNS or TLS failure, or another write primitive. Missing verification does not by itself prove remote code execution; it removes the authenticity barrier that should contain those failures.

Archive extraction can turn bypass into a file write

A second bug often makes the impact concrete: archive members such as ../../outside or symlinks that escape the destination. Joining a destination with an attacker-controlled member name does not remove traversal so the archive bug can turn the bypass into a file write.

Python added extraction filters in 3.12, and Python 3.14 changed the default to the data filter. The current tarfile documentation still recommends explicit filtering and additional limits because no built-in filter stops every denial-of-service or filesystem hazard.

This local fixture asserts both failure paths. Save it as verify_extract.py and run it only in a disposable directory:

import io
import tarfile
import tempfile

class VerificationError(Exception):
    pass

def verify_and_extract(payload, signature, verifier, destination):
    if verifier is None:
        raise VerificationError("signature verifier unavailable")
    if not verifier(payload, signature):
        raise VerificationError("signature rejected")
    if not hasattr(tarfile, "data_filter"):
        raise RuntimeError("safe tar filter unavailable")
    with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
        archive.extractall(destination, filter="data")

def traversal_archive():
    output = io.BytesIO()
    with tarfile.open(fileobj=output, mode="w") as archive:
        body = b"test"
        member = tarfile.TarInfo("../outside")
        member.size = len(body)
        archive.addfile(member, io.BytesIO(body))
    return output.getvalue()

payload = traversal_archive()
with tempfile.TemporaryDirectory() as destination:
    try:
        verify_and_extract(payload, b"sig", None, destination)
        raise AssertionError("missing verifier was accepted")
    except VerificationError:
        pass

    try:
        verify_and_extract(payload, b"sig", lambda data, sig: True, destination)
        raise AssertionError("traversal member was accepted")
    except tarfile.FilterError:
        pass

print("missing verifier and traversal archive rejected")

The fake verifier isolates extraction behavior; it is not production signature logic. The fixture passes on Python 3.12 or newer when the filter is available.

Make verification a prerequisite state

Package the verifier as a required dependency for update support. If it cannot initialize, turn off updating and report a clear error. Pin the expected public key or trust root, validate the signature over the exact bytes later consumed, and reject missing, malformed, expired, or wrong-signer results. Do not catch verification errors and continue.

After verification, extract into a new temporary directory with filter="data". Reject links if they are unnecessary, limit member count and total expanded size, allow only expected file types, and publish the completed tree atomically. Run the updater with the least filesystem privilege it needs.

Regression tests should cover a valid signature, an invalid signature, a missing signature, an unavailable verifier, a corrupt archive, a traversal member, an escaping link, and an oversized archive. Assert that no file outside the staging directory changes on every failure.

Audit every if crypto_library: branch as if the dependency were absent, because eventually it will be.