From 9c2d7a7f7b2183203cfd8c03e96b432ca4499b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Tue, 4 Feb 2025 12:38:26 +0100 Subject: [PATCH] Add a `dangerzone-image prepare-archive` command --- dangerzone/container_utils.py | 1 - dangerzone/updater/attestations.py | 6 +- dangerzone/updater/cli.py | 31 +++++----- dangerzone/updater/cosign.py | 32 +++++++++++ dangerzone/updater/errors.py | 4 ++ dangerzone/updater/signatures.py | 57 ++++++++++--------- dangerzone/updater/utils.py | 10 ---- .../independent-container-updates.md | 16 ++++++ 8 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 dangerzone/updater/cosign.py delete mode 100644 dangerzone/updater/utils.py diff --git a/dangerzone/container_utils.py b/dangerzone/container_utils.py index e9205e2..ea95e1a 100644 --- a/dangerzone/container_utils.py +++ b/dangerzone/container_utils.py @@ -180,7 +180,6 @@ def get_image_id_by_digest(digest: str) -> str: process = subprocess.run( cmd, startupinfo=get_subprocess_startupinfo(), check=True, capture_output=True ) - breakpoint() # In case we have multiple lines, we only want the first one. return process.stdout.decode().strip().split("\n")[0] diff --git a/dangerzone/updater/attestations.py b/dangerzone/updater/attestations.py index 3028ec3..5581e7d 100644 --- a/dangerzone/updater/attestations.py +++ b/dangerzone/updater/attestations.py @@ -1,17 +1,17 @@ import subprocess from tempfile import NamedTemporaryFile -from . import utils +from . import cosign -def verify_attestation( +def verify( manifest: bytes, attestation_bundle: bytes, image_tag: str, expected_repo: str ) -> bool: """ Look up the image attestation to see if the image has been built on Github runners, and from a given repository. """ - utils.ensure_cosign() + cosign.ensure_installed() # Put the value in files and verify with cosign with ( diff --git a/dangerzone/updater/cli.py b/dangerzone/updater/cli.py index 7dc9c35..7667825 100644 --- a/dangerzone/updater/cli.py +++ b/dangerzone/updater/cli.py @@ -5,13 +5,7 @@ import logging import click from ..util import get_resource_path -from . import errors, log, registry -from .attestations import verify_attestation -from .signatures import ( - upgrade_container_image, - upgrade_container_image_airgapped, - verify_offline_image_signature, -) +from . import attestations, errors, log, registry, signatures DEFAULT_REPOSITORY = "freedomofpress/dangerzone" DEFAULT_IMAGE_NAME = "ghcr.io/freedomofpress/dangerzone" @@ -36,7 +30,7 @@ def upgrade(image: str, pubkey: str) -> None: """Upgrade the image to the latest signed version.""" manifest_hash = registry.get_manifest_hash(image) try: - is_upgraded = upgrade_container_image(image, manifest_hash, pubkey) + is_upgraded = signatures.upgrade_container_image(image, manifest_hash, pubkey) click.echo(f"✅ The local image {image} has been upgraded") except errors.ImageAlreadyUpToDate as e: click.echo(f"✅ {e}") @@ -47,25 +41,34 @@ def upgrade(image: str, pubkey: str) -> None: @click.argument("image_filename") @click.option("--pubkey", default=PUBKEY_DEFAULT_LOCATION) @click.option("--image-name", default=DEFAULT_IMAGE_NAME) -def upgrade_airgapped(image_filename: str, pubkey: str, image_name: str) -> None: - """Upgrade the image to the latest signed version.""" +def load_archive(image_filename: str, pubkey: str, image_name: str) -> None: + """Upgrade the local image to the one in the archive.""" try: - upgrade_container_image_airgapped(image_filename, pubkey, image_name) + signatures.upgrade_container_image_airgapped(image_filename, pubkey, image_name) click.echo(f"✅ Installed image {image_filename} on the system") except errors.ImageAlreadyUpToDate as e: click.echo(f"✅ {e}") raise click.Abort() +@main.command() +@click.argument("image") +@click.option("--destination", default="dangerzone-airgapped.tar") +def prepare_archive(image: str, destination: str) -> None: + """Prepare an archive to upgrade the dangerzone image on an airgapped environment.""" + signatures.prepare_airgapped_archive(image, destination) + click.echo(f"✅ Archive {destination} created") + + @main.command() @click.argument("image") @click.option("--pubkey", default=PUBKEY_DEFAULT_LOCATION) -def verify_offline(image: str, pubkey: str) -> None: +def verify_local(image: str, pubkey: str) -> None: """ Verify the local image signature against a public key and the stored signatures. """ # XXX remove a potentiel :tag - if verify_offline_image_signature(image, pubkey): + if signatures.verify_local_image(image, pubkey): click.echo( ( f"Verifying the local image:\n\n" @@ -109,7 +112,7 @@ def attest_provenance(image: str, repository: str) -> None: parsed = registry.parse_image_location(image) manifest, bundle = registry.get_attestation(image) - verified = verify_attestation(manifest, bundle, parsed.tag, repository) + verified = attestations.verify(manifest, bundle, parsed.tag, repository) if verified: click.echo( f"🎉 The image available at `{parsed.full_name}` has been built by Github Runners from the `{repository}` repository" diff --git a/dangerzone/updater/cosign.py b/dangerzone/updater/cosign.py new file mode 100644 index 0000000..4ae65fd --- /dev/null +++ b/dangerzone/updater/cosign.py @@ -0,0 +1,32 @@ +import subprocess + +from . import errors, log + + +def ensure_installed() -> None: + try: + subprocess.run(["cosign", "version"], capture_output=True, check=True) + except subprocess.CalledProcessError: + raise errors.CosignNotInstalledError() + + +def verify_local_image(oci_image_folder: str, pubkey: str) -> bool: + """Verify the given path against the given public key""" + + ensure_installed() + cmd = [ + "cosign", + "verify", + "--key", + pubkey, + "--offline", + "--local-image", + oci_image_folder, + ] + log.debug(" ".join(cmd)) + result = subprocess.run(cmd, capture_output=True) + if result.returncode == 0: + log.debug("Signature verified") + return True + log.debug("Failed to verify signature", result.stderr) + return False diff --git a/dangerzone/updater/errors.py b/dangerzone/updater/errors.py index cd9c2b8..935ce3c 100644 --- a/dangerzone/updater/errors.py +++ b/dangerzone/updater/errors.py @@ -14,6 +14,10 @@ class RegistryError(UpdaterError): pass +class AirgappedImageDownloadError(UpdaterError): + pass + + class NoRemoteSignatures(SignatureError): pass diff --git a/dangerzone/updater/signatures.py b/dangerzone/updater/signatures.py index f38ea14..0a0b124 100644 --- a/dangerzone/updater/signatures.py +++ b/dangerzone/updater/signatures.py @@ -11,7 +11,7 @@ from tempfile import NamedTemporaryFile, TemporaryDirectory from typing import Dict, List, Optional, Tuple from .. import container_utils as runtime -from . import errors, log, registry, utils +from . import cosign, errors, log, registry try: import platformdirs @@ -55,34 +55,12 @@ def signature_to_bundle(sig: Dict) -> Dict: } -def cosign_verify_local_image(oci_image_folder: str, pubkey: str) -> bool: - """Verify the given path against the given public key""" - - utils.ensure_cosign() - cmd = [ - "cosign", - "verify", - "--key", - pubkey, - "--offline", - "--local-image", - oci_image_folder, - ] - log.debug(" ".join(cmd)) - result = subprocess.run(cmd, capture_output=True) - if result.returncode == 0: - log.debug("Signature verified") - return True - log.debug("Failed to verify signature", result.stderr) - return False - - def verify_signature(signature: dict, image_hash: str, pubkey: str) -> bool: """Verify a signature against a given public key""" # XXX - Also verfy the identity/docker-reference field against the expected value # e.g. ghcr.io/freedomofpress/dangerzone/dangerzone - utils.ensure_cosign() + cosign.ensure_installed() signature_bundle = signature_to_bundle(signature) payload_bytes = b64decode(signature_bundle["Payload"]) @@ -188,7 +166,7 @@ def upgrade_container_image_airgapped( # XXX Check if the contained signatures match the given ones? # Or maybe store both signatures? - if not cosign_verify_local_image(tmpdir, pubkey): + if not cosign.verify_local_image(tmpdir, pubkey): raise errors.SignatureVerificationError() # Remove the signatures from the archive. @@ -321,7 +299,7 @@ def store_signatures(signatures: list[Dict], image_hash: str, pubkey: str) -> No json.dump(signatures, f) -def verify_offline_image_signature(image: str, pubkey: str) -> bool: +def verify_local_image(image: str, pubkey: str) -> bool: """ Verifies that a local image has a valid signature """ @@ -341,7 +319,7 @@ def verify_offline_image_signature(image: str, pubkey: str) -> bool: def get_remote_signatures(image: str, hash: str) -> List[Dict]: """Retrieve the signatures from the registry, via `cosign download`.""" - utils.ensure_cosign() + cosign.ensure_installed() process = subprocess.run( ["cosign", "download", "signature", f"{image}@sha256:{hash}"], @@ -356,3 +334,28 @@ def get_remote_signatures(image: str, hash: str) -> List[Dict]: if len(signatures) < 1: raise errors.NoRemoteSignatures("No signatures found for the image") return signatures + + +def prepare_airgapped_archive(image_name, destination): + if "@sha256:" not in image_name: + raise errors.AirgappedImageDownloadError( + "The image name must include a digest, e.g. ghcr.io/freedomofpress/dangerzone/dangerzone@sha256:123456" + ) + + cosign.ensure_installed() + # Get the image from the registry + + with TemporaryDirectory() as tmpdir: + msg = f"Downloading image {image_name}. \nIt might take a while." + log.info(msg) + + process = subprocess.run( + ["cosign", "save", image_name, "--dir", tmpdir], + capture_output=True, + check=True, + ) + if process.returncode != 0: + raise errors.AirgappedImageDownloadError() + + with tarfile.open(destination, "w") as archive: + archive.add(tmpdir, arcname=".") diff --git a/dangerzone/updater/utils.py b/dangerzone/updater/utils.py deleted file mode 100644 index a97a49e..0000000 --- a/dangerzone/updater/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -import subprocess - -from . import errors - - -def ensure_cosign() -> None: - try: - subprocess.run(["cosign", "version"], capture_output=True, check=True) - except subprocess.CalledProcessError: - raise errors.CosignNotInstalledError() diff --git a/docs/developer/independent-container-updates.md b/docs/developer/independent-container-updates.md index 25a7d43..7f5029e 100644 --- a/docs/developer/independent-container-updates.md +++ b/docs/developer/independent-container-updates.md @@ -21,3 +21,19 @@ In case of sucess, it will report back: ``` 🎉 The image available at `ghcr.io/freedomofpress/dangerzone/dangerzone:latest` has been built by Github runners from the `freedomofpress/dangerzone` repository. ``` + +## Container updates on air-gapped environments + +In order to make updates on an air-gapped environment, you will need to prepare an archive for the air-gapped environment. This archive will contain all the needed material to validate that the new container image has been signed and is valid. + +On the machine on which you prepare the packages: + +```bash +dangerzone-image prepare-archive ghcr.io/almet/dangerzone/dangerzone@sha256:fa948726aac29a6ac49f01ec8fbbac18522b35b2491fdf716236a0b3502a2ca7 +``` + +On the airgapped machine, copy the file and run the following command: + +```bash +dangerzone-image load-archive +``` \ No newline at end of file