Repository: crashvb/docker-sign-verify (language: python)

Issue:
Refactor to use aiofiles
We need to refactor on aiofiles so that long running blocking operations are handled concurrently. Additionally, and implementation needs to be provided for asynchronous temporary files.


Discussion / hints:

Fix patch:
diff --git a/.travis.yml b/.travis.yml
index c127cdf..57894b5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,10 +1,11 @@
-language: python
-python:
-- "3.7"
-- "pypy3"
+env:
+- TRAVIS=true
 install:
 - python -m pip install --upgrade pip
 - python -m pip install --editable .[dev]
+language: python
+python:
+- "3.8.3"
 script:
 # TODO: Figure out anonymous bind credentials for dockerhub API and change to "args=--allow-online make test"
 - make test
diff --git a/README.md b/README.md
index 40220b1..fde9136 100644
--- a/README.md
+++ b/README.md
@@ -18,8 +18,7 @@ The goal of this utility is to operate directly on:
 
 ## Compatibility
 
-* Tested with python 3.7
-* Not tested in python 2.X
+* Tested with python 3.8
 
 ## Installation
 ### From [pypi.org](https://pypi.org/project/docker_sign_verify/)
diff --git a/docker_sign_verify/__init__.py b/docker_sign_verify/__init__.py
index 48c47d2..2dfd164 100644
--- a/docker_sign_verify/__init__.py
+++ b/docker_sign_verify/__init__.py
@@ -2,24 +2,19 @@
 
 """Utilities for verifying docker image integrity and signatures."""
 
-# TODO: Revamp interfaces to conform with https://github.com/opencontainers/image-spec/blob/master/media-types.md
-
-from .manifests import (
-    ArchiveManifest,
-    DeviceMapperRepositoryManifest,
-    Manifest,
-    RegistryV2Manifest,
-)
-from .signers import GPGSigner, PKISigner, Signer
-from .imageconfig import ImageConfig, SignatureTypes
-from .imagename import ImageName
-from .imagesources import (
-    ArchiveImageSource,
-    DeviceMapperRepositoryImageSource,
-    ImageSource,
-    RegistryV2ImageSource,
-)
-from .utils import FormattedSHA256
-
+from .archiveimagesource import ArchiveImageSource
+from .archivemanifest import ArchiveManifest
+from .devicemapperimagesource import DeviceMapperRepositoryImageSource
+from .devicemappermanifest import DeviceMapperRepositoryManifest
+from .exceptions import *
+from .gpgsigner import GPGSigner
+from .imageconfig import ImageConfig
+from .imagesource import ImageSource
+from .manifest import Manifest
+from .pkisigner import PKISigner
+from .registryv2imagesource import RegistryV2ImageSource
+from .registryv2manifest import RegistryV2Manifest
+from .signer import Signer
+from .specs import SignatureTypes
 
 __version__ = "1.0.1.dev0"
diff --git a/docker_sign_verify/aiotempfile.py b/docker_sign_verify/aiotempfile.py
new file mode 100644
index 0000000..8ae5b99
--- /dev/null
+++ b/docker_sign_verify/aiotempfile.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+
+# pylint: disable=protected-access,redefined-builtin
+
+"""Classes that provide asynchronous temporary file functionality."""
+
+# ... while we wait for:
+# * https://github.com/Tinche/aiofiles/issues/20
+# * https://github.com/Tinche/aiofiles/pull/56/files
+
+import asyncio
+import logging
+import os
+import tempfile
+
+from functools import partial
+from pathlib import Path
+from types import coroutine, MethodType
+
+from aiofiles.base import AiofilesContextManager
+from aiofiles.threadpool import sync_open, wrap
+
+LOGGER = logging.getLogger(__name__)
+
+
+def open(
+    mode="w+b", suffix="", prefix=tempfile.template, dir="/tmp"
+) -> AiofilesContextManager:
+    """
+    Create and return a temporary file.
+    Args:
+        mode: The mode argument to io.open (default "w+b").
+        suffix: If 'suffix' is not None, the file name will end with that suffix, otherwise there will be no suffix.
+        prefix:If 'prefix' is not None, the file name will begin with that prefix, otherwise a default prefix is used.
+        dir: If 'dir' is not None, the file will be created in that directory, otherwise a default directory is used.
+
+    Returns:
+        The corresponding temporary file.
+    """
+    # pylint: disable=protected-access
+    name = next(tempfile._RandomNameSequence())
+    path = Path(dir).joinpath(f"{prefix}{name}{suffix}")
+
+    # aiofiles_context_manager = aiofiles.open(path, mode=mode)
+    aiofiles_context_manager = AiofilesContextManager(_open(path, mode=mode))
+    aiofiles_context_manager.__setattr__("name", str(path))
+    return aiofiles_context_manager
+
+
+@coroutine
+def _open(file, *args, mode="r", loop=None, executor=None, **kwargs):
+    """Wrapping wrapped types that are instantiated in a concurrent generator makes my head hurt; punch the duck!"""
+    if loop is None:
+        loop = asyncio.get_event_loop()
+    partial_function = partial(sync_open, file, *args, mode=mode, **kwargs)
+    file_descriptior = yield from loop.run_in_executor(executor, partial_function)
+    baseio = wrap(file_descriptior, loop=loop, executor=executor)
+
+    # DUCK PUNCH: name
+    baseio.__setattr__("name", str(file))
+
+    # DUCK PUNCH: __del__()
+    baseio.__del__ = MethodType(duck_punch___del__, baseio)
+
+    # DUCK PUNCH: close()
+    baseio._original_close = baseio.close
+    baseio.close = MethodType(duck_punch_close, baseio)
+
+    return baseio
+
+
+def duck_punch___del__(self):
+    """Ensure the file is deleted on __del__()."""
+    try:
+        os.unlink(self._file.name)
+    except FileNotFoundError:
+        ...
+
+
+def duck_punch_close(self):
+    """Ensure the file is delete on close()."""
+    result = self._original_close()
+    try:
+        os.unlink(self._file.name)
+    except FileNotFoundError:
+        ...
+    return result
diff --git a/docker_sign_verify/archiveimagesource.py b/docker_sign_verify/archiveimagesource.py
new file mode 100644
index 0000000..611243a
--- /dev/null
+++ b/docker_sign_verify/archiveimagesource.py
@@ -0,0 +1,268 @@
+#!/usr/bin/env python
+
+"""Classes that provide a source of docker images."""
+
+import datetime
+import io
+import logging
+import os
+import random
+import tempfile
+from typing import Dict, List
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+from docker_registry_client_async.utils import must_be_equal
+
+from .archivemanifest import ArchiveManifest
+from .imageconfig import ImageConfig, SignatureTypes
+from .imagesource import ImageSource
+from .manifest import Manifest
+from .signer import Signer
+from .utils import (
+    file_exists_in_tar,
+    tar,
+    tar_add_file,
+    tar_delete_file,
+    tar_mkdir,
+    untar,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+
+class ArchiveImageSource(ImageSource):
+    """
+    Docker archive image source.
+    """
+
+    FILE_ARCHIVE_MANIFEST = "manifest.json"
+
+    def __init__(self, *, archive, **kwargs):
+        """
+        Args:
+            archive: Path to the docker image archive.
+        """
+        super().__init__(**kwargs)
+        self.archive = archive
+
+    def _file_exists(self, name) -> bool:
+        """
+        Checks if a give file exists within this image source.
+
+        Args:
+            name: Name of the file to be checked.
+
+        Returns:
+            bool: True if the file exists, False otherwise.
+        """
+        with open(self.archive, "rb") as file_in:
+            return file_exists_in_tar(file_in, name)
+
+    def get_file_from_archive(self, path):
+        """
+        Retrieves a file from within this image source.
+
+        Args:
+            path: Relative path of the file to be retrieved.
+
+        Returns:
+            The file content.
+        """
+        bytesio = io.BytesIO()
+        with open(self.archive, "rb") as file:
+            untar(file, path, bytesio)
+        return bytesio.read()
+
+    # ImageSource Members
+
+    async def get_image_config(self, image_name: ImageName, **kwargs) -> ImageConfig:
+        manifest = await self.get_manifest()
+        config = manifest.get_config(image_name)
+        return ImageConfig(self.get_file_from_archive(config["Config"]))
+
+    async def get_image_layer_to_disk(
+        self, image_name: ImageName, layer: FormattedSHA256, file, **kwargs
+    ):
+        # TODO: Convert to async
+        with open(self.archive, "rb") as file_in:
+            return untar(file_in, ArchiveManifest.digest_to_layer(layer), file)
+
+    # TODO: def put_image_layer(self, image, content):
+
+    async def get_manifest(self, image_name: ImageName = None, **kwargs) -> Manifest:
+        # TODO: Convert to async
+        raw_archive_manifest = self.get_file_from_archive(
+            ArchiveImageSource.FILE_ARCHIVE_MANIFEST
+        )
+        return ArchiveManifest(raw_archive_manifest)
+
+    async def layer_exists(
+        self, image_name: ImageName, layer: FormattedSHA256, **kwargs
+    ) -> bool:
+        # TODO: Convert to async
+        return self._file_exists(ArchiveManifest.digest_to_layer(layer))
+
+    async def put_image(
+        self,
+        image_source,
+        image_name: ImageName,
+        manifest: Manifest,
+        image_config: ImageConfig,
+        layer_files: List,
+        **kwargs,
+    ):
+        # TODO: Implement this method, refactor sign_image to use it ...
+        raise NotImplementedError
+
+    async def put_image_config(
+        self, image_name: ImageName, image_config: ImageConfig, **kwargs
+    ):
+        # TODO: Convert to async
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_image_config")
+            return
+        digest = image_config.get_digest()
+        name = "{0}.json".format(digest.sha256)
+        if not self._file_exists(name):
+            with open(self.archive, "rb+") as file_out:
+                tar_add_file(file_out, name, image_config.get_bytes())
+
+    async def put_image_layer(self, image_name: ImageName, content, **kwargs):
+        raise NotImplementedError
+
+    async def put_image_layer_from_disk(
+        self, image_name: ImageName, file, **kwargs
+    ) -> FormattedSHA256:
+        # TODO: Convert to async
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_image_layer_from_disk")
+            return FormattedSHA256("0" * 64)
+        # TODO: Do we really want to use random garbage here???
+        #       Look into moby/.../save.go to find what to use instead.
+        digest = FormattedSHA256.calculate(
+            "{0}{1}{2}".format(
+                str(image_name), datetime.datetime.now(), random.randint(1, 101)
+            ).encode("utf-8")
+        )
+        layer = ArchiveManifest.digest_to_layer(digest)
+        with open(self.archive, "rb+") as file_out:
+            tar_mkdir(file_out, os.path.dirname(layer))
+            file_out.seek(0)
+            tar(file_out, layer, file)
+        return digest
+
+    async def put_manifest(
+        self, manifest: Manifest, image_name: ImageName = None, **kwargs
+    ):
+        # TODO: Convert to async
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_manifest")
+            return
+        with open(self.archive, "rb+") as file_out:
+            tar_delete_file(file_out, ArchiveImageSource.FILE_ARCHIVE_MANIFEST)
+            file_out.seek(0)
+            tar_add_file(
+                file_out,
+                ArchiveImageSource.FILE_ARCHIVE_MANIFEST,
+                str(manifest).encode("utf-8"),
+            )
+
+    async def sign_image(
+        self,
+        signer: Signer,
+        src_image_name: ImageName,
+        dest_image_source: ImageSource,
+        dest_image_name: ImageName,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        **kwargs,
+    ):
+        LOGGER.debug(
+            "%s: %s ...",
+            "Endorsing"
+            if signature_type == SignatureTypes.ENDORSE
+            else "Signing"
+            if signature_type == SignatureTypes.SIGN
+            else "Resigning",
+            src_image_name.resolve_name(),
+        )
+
+        # Generate a signed image configuration ...
+        data = await self._sign_image_config(signer, src_image_name, signature_type)
+        manifest = data["verify_image_data"]["manifest"]
+        LOGGER.debug("    Signature:\n%s", data["signature_value"])
+        image_config = data["image_config"]
+
+        # Replicate all of the image layers ...
+        LOGGER.debug("    Replicating image layers ...")
+        archive_layers = manifest.get_layers(src_image_name)
+        archive_layers_changed = archive_layers.copy()
+        for i, archive_layer in enumerate(archive_layers):
+            if not dest_image_source.layer_exists(dest_image_name, archive_layer):
+                # Update the layer
+                digest = dest_image_source.put_image_layer_from_disk(
+                    dest_image_name,
+                    data["verify_image_data"]["uncompressed_layer_files"][i],
+                )
+                archive_layers_changed[i] = digest
+        archive_layers = archive_layers_changed
+
+        # Push the new image configuration ...
+        config_digest_signed = image_config.get_digest()
+        LOGGER.debug("    config digest (signed): %s", config_digest_signed)
+        await dest_image_source.put_image_config(dest_image_name, image_config)
+
+        # Generate a new archive manifest, and push ...
+        if isinstance(dest_image_source, ArchiveImageSource):
+            manifest_signed = (
+                await dest_image_source.get_manifest()
+            )  # type: ArchiveManifest
+
+            repotags = None
+            if dest_image_name.tag:
+                repotags = [str(dest_image_name)]
+                manifest_signed.append_config(
+                    config_digest_signed, archive_layers, repotags
+                )
+            data["manifest_signed"] = manifest_signed
+            # TODO: make sure to remove conflicting tags in "other" config entries
+            await dest_image_source.put_manifest(manifest_signed)
+            # TODO: Update foo.tar:/repositories as well
+        elif type(dest_image_source).__name__ == "RegistryV2ImageSource":
+            raise NotImplementedError
+        elif type(dest_image_source).__name__ == "DeviceMapperRepositoryImageSource":
+            raise NotImplementedError
+        else:
+            raise RuntimeError(
+                "Unknown derived class: {0}".format(type(dest_image_source))
+            )
+
+        if not self.dry_run:
+            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
+
+        return data
+
+    async def verify_image_integrity(self, image_name: ImageName, **kwargs) -> Dict:
+        data = await self._verify_image_config(image_name)
+
+        # Reconcile manifest layers and image layers (in order)...
+        uncompressed_layer_files = []
+        for i, layer in enumerate(data["manifest_layers"]):
+            # Retrieve the archive image layer and verify the digest ...
+            uncompressed_layer_files.append(tempfile.NamedTemporaryFile())
+            data_uncompressed = await self.get_image_layer_to_disk(
+                image_name, layer, uncompressed_layer_files[i]
+            )
+            must_be_equal(
+                data["image_layers"][i],
+                data_uncompressed["digest"],
+                "Archive layer[{0}] digest mismatch".format(i),
+            )
+
+        LOGGER.debug("Integrity check passed.")
+
+        return {
+            "compressed_layer_files": "TODO",
+            "image_config": data["image_config"],
+            "manifest": data["manifest"],
+            "uncompressed_layer_files": uncompressed_layer_files,
+        }
diff --git a/docker_sign_verify/archivemanifest.py b/docker_sign_verify/archivemanifest.py
new file mode 100644
index 0000000..172c555
--- /dev/null
+++ b/docker_sign_verify/archivemanifest.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python
+
+"""Classes that provide abstractions of different image source manifests."""
+
+from typing import List
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+
+from .manifest import Manifest
+
+
+class ArchiveManifest(Manifest):
+    """
+    Image source manifest for docker archives.
+    """
+
+    @staticmethod
+    def layer_to_digest(layer: str) -> FormattedSHA256:
+        """
+        Coverts a archive layer identifier to a digest value.
+
+        Args:
+            layer: The archive layer identifier (relative tar path).
+
+        Returns:
+            The corresponding digest value in the form: <hash type>:<digest value>.
+        """
+        return FormattedSHA256(layer[:-10])
+
+    @staticmethod
+    def digest_to_layer(digest: FormattedSHA256) -> str:
+        """
+        Converts a digest value to a archive layer identifier.
+
+        Args:
+            digest: The digest value in the form: <hash type>:<digest value>.
+
+        Returns:
+            The corresponding archive layer identifier (relative tar path).
+        """
+        return "{0}/layer.tar".format(digest.sha256)
+
+    def get_config(self, image_name: ImageName):
+        """
+        Retrieves the image configuration dictionary for a given image name from the image source manifest.
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            A dictionary as defined by the Docker specification.
+            dict:
+                Config: Image configuration (relative tar path).
+                RepoTags: Docker repository tags.
+                Layers: List of image layer (relative tar paths).
+        """
+        if image_name.tag:
+            repotag = f"{image_name.image}:{image_name.tag}"
+            for config in self.get_json():
+                if config["RepoTags"] and repotag in config["RepoTags"]:
+                    return config
+        else:
+            manifest_name = f"{image_name.resolve_digest().sha256}.json"
+            return [
+                config
+                for config in self.get_json()
+                if config["Config"] == manifest_name
+            ][0]
+        raise RuntimeError("Unable to locate configuration in archive manifest!")
+
+    def append_config(
+        self,
+        config_digest: FormattedSHA256,
+        layers: List[FormattedSHA256],
+        repotags: List = None,
+    ):
+        """
+        Appends an image configuration dictionary to the image source manifest
+
+        Args:
+            config_digest: Image configuration digest in the form <hash type>:<digest value>.
+            layers: List of image layer identifiers in the form: <hash type>:<digest_value>.
+            repotags: List of docker repository tags.
+        """
+        config = {
+            "Config": "{0}.json".format(config_digest.sha256),
+            "Layers": [ArchiveManifest.digest_to_layer(l) for l in layers],
+        }
+        if repotags:
+            config["RepoTags"] = repotags
+
+        # TODO: Check to make sure it doesn't already exist?
+        json = self.get_json()
+        json.append(config)
+        self._set_json(json)
+
+    # Manifest Members
+
+    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
+        config = self.get_config(image_name)
+        return FormattedSHA256(config["Config"][:-5])
+
+    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
+        layers = self.get_config(image_name)["Layers"]
+        return [ArchiveManifest.layer_to_digest(l) for l in layers]
diff --git a/docker_sign_verify/devicemapperimagesource.py b/docker_sign_verify/devicemapperimagesource.py
new file mode 100644
index 0000000..f80f933
--- /dev/null
+++ b/docker_sign_verify/devicemapperimagesource.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python
+
+"""Classes that provide a source of docker images."""
+
+import json
+import logging
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Dict, List
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+from docker_registry_client_async.utils import must_be_equal
+
+from .devicemappermanifest import DeviceMapperRepositoryManifest
+from .imageconfig import ImageConfig, SignatureTypes
+from .imagesource import ImageSource
+from .manifest import Manifest
+from .signer import Signer
+from .utils import (
+    chunk_file,
+    read_file,
+    write_file,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+
+class DeviceMapperRepositoryImageSource(ImageSource):
+    """
+    Docker repository image source.
+    """
+
+    DOCKER_ROOT = Path("/var/lib/docker")
+    DM_CONTENT_ROOT = DOCKER_ROOT.joinpath("image/devicemapper/imagedb/content/sha256")
+    DM_LAYER_ROOT = DOCKER_ROOT.joinpath("image/devicemapper/layerdb/sha256")
+    DM_METADATA_ROOT = DOCKER_ROOT.joinpath("devicemapper/metadata")
+    DM_REPOSITORIES = DOCKER_ROOT.joinpath("image/devicemapper/repositories.json")
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+    # ImageSource Members
+
+    async def get_image_config(self, image_name: ImageName, **kwargs) -> ImageConfig:
+        manifest = await self.get_manifest()
+        config_digest = manifest.get_config_digest(image_name)
+        path = DeviceMapperRepositoryImageSource.DM_CONTENT_ROOT.joinpath(
+            config_digest.sha256
+        )
+        return ImageConfig(await read_file(path))
+
+    async def get_image_layer_to_disk(
+        self, image_name: ImageName, layer: FormattedSHA256, file, **kwargs
+    ):
+        # TODO: Convert to async
+        # Retrieve the devicemapper metadata ...
+        path = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
+            layer[7:], "cache-id"
+        )
+        _bytes = await read_file(path)
+        cache_id = _bytes.decode("utf-8")
+        if not cache_id:
+            raise RuntimeError("Unable to find cache id for layer: {0}".format(layer))
+        path = DeviceMapperRepositoryImageSource.DM_METADATA_ROOT.joinpath(cache_id)
+        raw_metadata = read_file(path)
+        metadata = json.loads(raw_metadata)
+        if not (metadata["device_id"] or metadata["size"]):
+            raise RuntimeError(
+                "Unable to find device id and / or size for layer: {0}".format(layer)
+            )
+
+        # Create the devicemapper table ...
+        device_name = "dsv-{0}".format(layer)
+        # TODO: How to we find the vgname?
+        volume_group = "/dev/mapper/rhel-docker--pool"
+        table = "0 {0} thin {1} {2}".format(
+            metadata["size"], volume_group, metadata["device_id"]
+        )
+        subprocess.run(
+            ["/sbin/dmsetup", "create", device_name, "--table", table], check=True
+        )
+
+        # Mount the layer ...
+        mount = Path(tempfile.mkdtemp())
+        subprocess.run(
+            [
+                "mount",
+                "-o",
+                "ro",
+                Path("/dev/mapper").joinpath(device_name),
+                mount.absolute(),
+            ],
+            check=True,
+        )
+
+        # Reconstruct the layer tar ...
+        path = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
+            layer[7:], "tar-split.json.gz"
+        )
+        rootfs = mount.joinpath("rootfs")
+        if not rootfs.exists():
+            raise RuntimeError(
+                "Root filesystem does not exist for layer: {0}".format(layer)
+            )
+        # TODO: Replace this with a pure-python implementation
+        process = subprocess.Popen(
+            ["tar-split", "asm", "--input", str(path), "--path", rootfs],
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+        )
+        # TODO: This still buffers the entire file in memory ...
+        result = await chunk_file(process.stdout, file, file_in_is_async=False)
+        return_code = process.wait(120)
+        if return_code != 0:
+            raise RuntimeError("Tar-split failed!")
+
+        # Unmount the layer, remove the devicemapper table, delete the mountpoint ...
+        subprocess.run(["umount", mount.absolute()], check=True)
+        subprocess.run(["/sbin/dmsetup", "remove", device_name], check=True)
+        mount.rmdir()
+
+        return result
+
+    async def get_manifest(self, image_name: ImageName = None, **kwargs) -> Manifest:
+        _bytes = await read_file(DeviceMapperRepositoryImageSource.DM_REPOSITORIES)
+        return DeviceMapperRepositoryManifest(_bytes)
+
+    async def layer_exists(
+        self, image_name: ImageName, layer: FormattedSHA256, **kwargs
+    ) -> bool:
+        # TODO: Convert to async
+        result = False
+        path_layer = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
+            layer.sha256
+        ).joinpath("cache-id")
+        if path_layer.exists():
+            _bytes = await read_file(path_layer)
+            cache_id = _bytes.decode("utf-8")
+            path_cache = DeviceMapperRepositoryImageSource.DM_METADATA_ROOT.joinpath(
+                cache_id
+            )
+            result = path_cache.exists()
+        return result
+
+    async def put_image(
+        self,
+        image_source,
+        image_name: ImageName,
+        manifest: Manifest,
+        image_config: ImageConfig,
+        layer_files: List,
+        **kwargs,
+    ):
+        # TODO: Implement this method, refactor sign_image to use it ...
+        raise NotImplementedError
+
+    async def put_image_config(
+        self, image_name: ImageName, image_config: ImageConfig, **kwargs
+    ):
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_image_config")
+            return
+        # TODO: Remove debug code
+        # TODO: Convert to async
+        # path = DeviceMapperRepositoryImageSource.DM_CONTENT_ROOT.joinpath(image_config.get_digest().sha256)
+        path = Path(
+            "/tmp/docker-ridavis/image/devicemapper/imagedb/conent/sha256/"
+        ).joinpath(image_config.get_digest().sha256)
+        path.parent.mkdir(exist_ok=True, parents=True)
+        await write_file(path, image_config.get_bytes())
+
+    async def put_image_layer(self, image_name: ImageName, content, **kwargs):
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_image_layer")
+            return
+        # TODO: Implement this method ...
+        raise NotImplementedError
+
+    async def put_image_layer_from_disk(self, image_name: ImageName, file, **kwargs):
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_image_layer_from_disk")
+            return
+        # TODO: Implement this method ...
+        raise NotImplementedError
+
+    async def put_manifest(
+        self, manifest: Manifest, image_name: ImageName = None, **kwargs
+    ):
+        if self.dry_run:
+            LOGGER.debug("Dry Run: skipping put_manifest")
+            return
+        # TODO: Remove debug code
+        # TODO: Convert to async
+        # path = DeviceMapperRepositoryImageSource.DM_REPOSITORIES
+        path = Path("/tmp/docker-ridavis/repositories.json")
+        path.parent.mkdir(exist_ok=True, parents=True)
+        await write_file(path, str(manifest).encode("utf-8"))
+
+    async def sign_image(
+        self,
+        signer: Signer,
+        src_image_name: ImageName,
+        dest_image_source: ImageSource,
+        dest_image_name: ImageName,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        **kwargs,
+    ):
+        LOGGER.debug(
+            "%s: %s ...",
+            "Endorsing"
+            if signature_type == SignatureTypes.ENDORSE
+            else "Signing"
+            if signature_type == SignatureTypes.SIGN
+            else "Resigning",
+            src_image_name.resolve_name(),
+        )
+
+        # Generate a signed image configuration ...
+        data = await self._sign_image_config(signer, src_image_name, signature_type)
+        manifest = data["verify_image_data"]["manifest"]
+        LOGGER.debug("    Signature:\n%s", data["signature_value"])
+        image_config = data["image_config"]
+
+        # Replicate all of the image layers ...
+        LOGGER.debug("    Replicating image layers ...")
+        repository_layers = manifest.get_layers(src_image_name)
+        for i, repository_layer in enumerate(repository_layers):
+            if not dest_image_source.layer_exists(dest_image_name, repository_layer):
+                await dest_image_source.put_image_layer_from_disk(
+                    dest_image_name,
+                    data["verify_image_data"]["compressed_layer_files"][i],
+                )
+        # TODO: We we need to track the layer translations here? Is this possible for DM repos?
+
+        # Push the new image configuration ...
+        config_digest_signed = image_config.get_digest()
+        LOGGER.debug("    config digest (signed): %s", config_digest_signed)
+        await dest_image_source.put_image_config(dest_image_name, image_config)
+
+        # Generate a new repository manifest, and push ...
+        if type(dest_image_source).__name__ == "ArchiveImageSource":
+            raise NotImplementedError
+        elif type(dest_image_source).__name__ == "RegistryV2ImageSource":
+            raise NotImplementedError
+        elif isinstance(dest_image_source, DeviceMapperRepositoryImageSource):
+            manifest_signed = (
+                await dest_image_source.get_manifest()
+            )  # type: DeviceMapperRepositoryManifest
+
+            if dest_image_name.tag:
+                manifest_signed.override_config(config_digest_signed, dest_image_name)
+            data["manifest_signed"] = manifest_signed
+            await dest_image_source.put_manifest(manifest_signed, dest_image_name)
+        else:
+            raise RuntimeError(
+                "Unknown derived class: {0}".format(type(dest_image_source))
+            )
+
+        if not self.dry_run:
+            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
+
+        return data
+
+    async def verify_image_integrity(self, image_name: ImageName, **kwargs) -> Dict:
+        data = await self._verify_image_config(image_name)
+
+        # Note: We do not need to reconcile manifest layer ids here, as "we" derived them in
+        # :func:docker_sign_verify.manifests.DeviceMapperRepositoryManifest.get_layers.
+
+        # Reconcile manifest layers and image layers (in order)...
+        uncompressed_layer_files = []
+        for i, layer in enumerate(data["manifest_layers"]):
+            # Retrieve the repository image layer and verify the digest ...
+            uncompressed_layer_files.append(tempfile.NamedTemporaryFile())
+            data_compressed = await self.get_image_layer_to_disk(
+                image_name, layer, uncompressed_layer_files[i]
+            )
+            must_be_equal(
+                data["image_layers"][i],
+                data_compressed["digest"],
+                "Repository layer[{0}] digest mismatch".format(i),
+            )
+
+        LOGGER.debug("Integrity check passed.")
+
+        return {
+            "compressed_layer_files": "TODO",
+            "image_config": data["image_config"],
+            "manifest": data["manifest"],
+            "uncompressed_layer_files": uncompressed_layer_files,
+        }
diff --git a/docker_sign_verify/devicemappermanifest.py b/docker_sign_verify/devicemappermanifest.py
new file mode 100644
index 0000000..2208fe1
--- /dev/null
+++ b/docker_sign_verify/devicemappermanifest.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+
+"""Classes that provide abstractions of different image source manifests."""
+
+from copy import deepcopy
+from pathlib import Path
+from typing import List
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+
+from .imageconfig import ImageConfig
+from .manifest import Manifest
+from .utils import read_file
+
+
+class DeviceMapperRepositoryManifest(Manifest):
+    """
+    Image source manifest for docker repositories.
+    """
+
+    @staticmethod
+    def _get_repository_key(image_name: ImageName) -> str:
+        """
+        Retrieves the repository key for a given image name.
+
+        Args:
+            image_name: Image name for which to retrieve the key.
+
+        Returns:
+            The corresponding repository key.
+        """
+        key_name = deepcopy(image_name)
+        key_name.tag = None
+        return str(key_name)
+
+    @staticmethod
+    def get_combined_layerid(
+        parent: FormattedSHA256, layer: FormattedSHA256
+    ) -> FormattedSHA256:
+        """
+        Retrieves the layer identifier for a given parent-layer combination.
+
+        Args:
+            parent: The parent layer identifier.
+            layer:  The layer identifier.
+
+        Returns:
+            The corresponding layer identifier.
+        """
+        result = layer
+        if parent:
+            # Note: The image layer is the digest value of the formatted string.
+            result = FormattedSHA256.calculate(
+                "{0} {1}".format(parent, layer).encode("utf-8")
+            )
+        return result
+
+    def override_config(self, config_digest: FormattedSHA256, image_name: ImageName):
+        """
+        Assigns the image configuration digest for a given image name in the image source manifest.
+
+        Args:
+            config_digest: Image configuration digest in the form <hash type>:<digest value>.
+            image_name: The image name.
+        """
+
+        # Note: registry.json can only contain two formats:
+        #       [<endpoint>/]<image>:<tag>
+        #       [<endpoint>/]<image>@<hash type>:<digest value>
+        json = self.get_json()
+        key = DeviceMapperRepositoryManifest._get_repository_key(image_name)
+        image = json["Repositories"].get(key, {})
+        image[str(image_name)] = config_digest
+        json["Repositories"][key] = image
+        self._set_json(json)
+
+    # Manifest Members
+
+    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
+        key = DeviceMapperRepositoryManifest._get_repository_key(image_name)
+        image = self.get_json()["Repositories"].get(key, {})
+        return FormattedSHA256.parse(image.get(str(image_name), None))
+
+    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
+        # TODO: How can this be reimplemented to avoid duplicate code?
+        DM_CONTENT_ROOT = Path(
+            "/var/lib/docker/image/devicemapper/imagedb/content/sha256"
+        )
+        path = DM_CONTENT_ROOT.joinpath(self.get_config_digest(image_name).sha256)
+        # TODO: Convert to async
+        with path.open("r+b") as file:
+            image_config = ImageConfig(file.read())
+
+        result = []
+        parent = None
+        for layer in image_config.get_image_layers():
+            result.append(
+                DeviceMapperRepositoryManifest.get_combined_layerid(parent, layer)
+            )
+            parent = result[-1]
+
+        return result
diff --git a/docker_sign_verify/exceptions.py b/docker_sign_verify/exceptions.py
new file mode 100644
index 0000000..c961077
--- /dev/null
+++ b/docker_sign_verify/exceptions.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python
+
+# pylint: disable=too-few-public-methods
+
+"""Reusable string literals."""
+
+from typing import Any
+
+
+class DigestMismatchError(ValueError):
+    """Error raised when two compared digest values are not equal."""
+
+
+class MalformedConfigurationError(ValueError):
+    """Error raised when assertions about a configuration fail."""
+
+    def __init__(
+        self, message: str = "Configuration is malformed!", *, config: Any = None
+    ):
+        super().__init__(message)
+        self.config = config
+
+
+class NoSignatureError(RuntimeError):
+    """Error raised when a signed image does not contain any signatures."""
+
+    def __init__(
+        self,
+        message: str = "Image does not contain any signatures!",
+        image_name: Any = None,
+    ):
+        super().__init__(message)
+        self.image_name = image_name
+
+
+class SignatureMismatchError(ValueError):
+    """Error raised when the signature value for an images is invalid."""
+
+
+class UnsupportedSignatureTypeError(RuntimeError):
+    """Error raised when the type of an image signature cannot be derived."""
+
+    def __init__(
+        self, message: str = "Unsupported signature type!", *, signature: str = None
+    ):
+        super().__init__(message)
+        self.signature = signature
diff --git a/docker_sign_verify/gpgsigner.py b/docker_sign_verify/gpgsigner.py
new file mode 100644
index 0000000..60f285a
--- /dev/null
+++ b/docker_sign_verify/gpgsigner.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python
+
+"""Classes that provide signature functionality."""
+
+import logging
+import io
+import os
+import tempfile
+
+from pathlib import Path
+from typing import Any
+
+import gnupg
+
+from .signer import Signer
+
+LOGGER = logging.getLogger(__name__)
+
+
+class GPGSigner(Signer):
+    """
+    Creates and verifies docker image signatures using GnuPG.
+    """
+
+    HOMEDIR = Path(os.environ.get("DSV_GPG_DATASTORE", Path.home().joinpath(".gnupg")))
+    OPTIONS = os.environ.get("DSV_GPG_OPTIONS", "--pinentry-mode loopback")
+
+    def __init__(
+        self,
+        *,
+        keyid: str = None,
+        passphrase: str = None,
+        homedir: Path = None,
+        **kwargs
+    ):
+        """
+        Args:
+            keyid: The GPG key identifier, only required for signing.
+            passphrase: The passphrase used to unlock the GPG key.
+            homedir: The GPG home directory (default: ~/.gnupg).
+        """
+        self.keyid = keyid
+        self.passphrase = passphrase
+        self.homedir = homedir if homedir else GPGSigner.HOMEDIR
+
+        LOGGER.debug("Using trust store: %s", self.homedir)
+        self.gpg = gnupg.GPG(
+            homedir=self.homedir,
+            ignore_homedir_permissions=True,
+            options=[GPGSigner.OPTIONS],
+            **kwargs
+        )
+
+    async def _debug_init_store(
+        self, name: str = "DSV Test Key", email: str = "test@key.com"
+    ):
+        """
+        Initializes a new GPG keystore for testing purposes.
+
+        Args:
+            name: GPG identity name used to create the new key.
+            email: GPG identity email used to create the new key.
+
+        Returns:
+            The GPG key identifier of the newly created key.
+        """
+        input_data = self.gpg.gen_key_input(
+            name_email=name, name_real=email, passphrase=self.passphrase
+        )
+
+        result = self.gpg.gen_key(input_data)
+        if not result:
+            LOGGER.warning("GPG keystore generation failed!")
+        self.keyid = str(result)
+
+        return result
+
+    # Signer Members
+
+    # TODO: Convert to async
+    async def sign(self, data: bytes) -> str:
+        if not self.keyid:
+            LOGGER.warning("Signing using implicit / default keyid!")
+            # raise RuntimeError("Cannot sign without keyid!")
+
+        kwargs = {}
+        if self.keyid:
+            kwargs = {"default_key": self.keyid}
+
+        result = self.gpg.sign(
+            data, clearsign=False, detach=True, passphrase=self.passphrase, **kwargs
+        )
+
+        return str(result).rstrip()
+
+    # TODO: Convert to async
+    async def verify(self, data: bytes, signature: str) -> Any:
+        # Note: gnupg.py:verify_file() forces sig_file to be on disk, as the
+        #       underlying gpg utility does the same =(
+        with tempfile.NamedTemporaryFile() as tmpfile:
+            tmpfile.write(signature.encode("utf-8"))
+            tmpfile.flush()
+            os.fsync(tmpfile.fileno())
+            return self.gpg.verify_file(io.BytesIO(data), tmpfile.name)
diff --git a/docker_sign_verify/imageconfig.py b/docker_sign_verify/imageconfig.py
index 7261b43..3dfb94b 100644
--- a/docker_sign_verify/imageconfig.py
+++ b/docker_sign_verify/imageconfig.py
@@ -7,32 +7,41 @@
 (eventually, https://github.com/opencontainers/image-spec/blob/master/config.md)
 """
 
-import copy
 import json
 import logging
 
-from enum import Enum
-from typing import Dict, List
+from copy import deepcopy
+from typing import Any, Dict, List, TypedDict
 
 import canonicaljson
 
-from .signers import Signer
-from .utils import formatted_digest, must_be_equal, FormattedSHA256
+from docker_registry_client_async import FormattedSHA256, JsonBytes
+from docker_registry_client_async.utils import must_be_equal
+
+from .exceptions import (
+    DigestMismatchError,
+    MalformedConfigurationError,
+    NoSignatureError,
+)
+from .signer import Signer
+from .specs import SignatureTypes
 
 LOGGER = logging.getLogger(__name__)
 
 
-class SignatureTypes(Enum):
-    """
-    Docker signature types.
-    """
+class ImageConfigSignatureEntry(TypedDict):
+    # pylint: disable=missing-class-docstring
+    digest: FormattedSHA256
+    signature: str
+
 
-    SIGN = 0  # Append (Co-)signature
-    ENDORSE = 1  # Append endorsement
-    RESIGN = 2  # Replace signature(s)
+class ImageConfigVerifySignatures(TypedDict):
+    # pylint: disable=missing-class-docstring
+    signatures: List[ImageConfigSignatureEntry]
+    results: List[Any]
 
 
-class ImageConfig:
+class ImageConfig(JsonBytes):
     """
     Docker image configuration.
     """
@@ -44,18 +53,15 @@ class ImageConfig:
     DEFAULT_SIGNATURES_VALUE = "[]"
 
     def __init__(self, config: bytes):
+        # pylint: disable=useless-super-delegation
         """
         Args:
             config: The raw image configuration.
         """
-        self.config = self.config_json = None
-        self._set_config(config)
-
-    def __str__(self):
-        return self.get_config().decode("utf-8")
+        super().__init__(config)
 
     @staticmethod
-    def _get_labels(config_json) -> Dict:
+    def _get_labels(config_json) -> Dict[str, str]:
         """
         Retrieves the "Labels" dictionary from the given image configuration.
 
@@ -63,7 +69,7 @@ def _get_labels(config_json) -> Dict:
             config_json: The image configuration from which to retrieve the dictionary.
 
         Returns:
-            Dict: The corresponding dictionary, or an empty dictionary if NoneType.
+            dict: The corresponding dictionary, or an empty dictionary if NoneType.
         """
 
         # Note: We need to handle both key cases, as Red Hat does not conform to the standard.
@@ -73,8 +79,9 @@ def _get_labels(config_json) -> Dict:
             config = config_json["config"]
 
         if config is None:
-            raise RuntimeError(
-                "Unable to locate [Cc]onfig key within image configuration!"
+            raise MalformedConfigurationError(
+                "Unable to locate [Cc]onfig key within image configuration!",
+                config=config_json,
             )
 
         try:
@@ -107,66 +114,26 @@ def _normalize(config_json):
 
         return config_json
 
-    def _set_config(self, config: bytes):
-        """
-        Assigns the raw image configuration and updates the internal JSON object.
-
-        Args:
-            config: The raw image configuration.
-        """
-        self.config = config
-        # Note: Do not normalize here, or integrity verification of unsigned images will fail.
-        self.config_json = json.loads(self.config)
-
-    def _set_config_json(self, config_json):
-        """
-        Assigns the internal JSON object and updates the raw image configuration.
-
-        Args:
-            config_json: The internal JSON object.
-        """
-        self.config_json = config_json
-        self.config = json.dumps(self.config_json).encode("utf-8")
-
-    def get_config(self) -> bytes:
-        """
-        Retrieves the raw image configuration.
-
-        Returns:
-            The raw image configuration.
-        """
-        return self.config
-
-    def get_config_canonical(self):
+    def get_bytes_canonical(self) -> bytes:
         """
         Retrieves the image configuration in canonical JSON form.
 
         Returns:
             The image configuration in canonical JSON form.
         """
-        config_json = copy.deepcopy(self.config_json)
-        config_json = ImageConfig._normalize(config_json)
+        config_json = ImageConfig._normalize(self.get_json())
         return canonicaljson.encode_canonical_json(config_json)
 
-    def get_config_digest(self) -> FormattedSHA256:
-        """
-        Retrieves the SHA256 digest value of the raw image configuration.
-
-        Returns:
-            The SHA256 digest value of the raw image configuration.
-        """
-        return formatted_digest(self.get_config())
-
-    def get_config_digest_canonical(self) -> FormattedSHA256:
+    def get_digest_canonical(self) -> FormattedSHA256:
         """
         Retrieves the SHA256 digest value of the image configuration in canonical JSON form.
 
         Returns:
             The SHA256 digest value of the image configuration in canonical JSON form.
         """
-        return formatted_digest(self.get_config_canonical())
+        return FormattedSHA256.calculate(self.get_bytes_canonical())
 
-    def get_image_layers(self) -> List:
+    def get_image_layers(self) -> List[FormattedSHA256]:
         """
         Retrieves the listing of image layer identifiers.
 
@@ -175,13 +142,14 @@ def get_image_layers(self) -> List:
         """
         # Note: We need to handle both key cases, as Microsoft does not conform to the standard.
         try:
-            rootfs = self.config_json["rootfs"]
+            rootfs = self.get_json()["rootfs"]
         except KeyError:
-            rootfs = self.config_json["rootfS"]
+            rootfs = self.get_json()["rootfS"]
 
         if rootfs is None:
-            raise RuntimeError(
-                "Unable to locate rootf[Ss] key within image configuration!"
+            raise MalformedConfigurationError(
+                "Unable to locate rootf[Ss] key within image configuration!",
+                config=self,
             )
 
         diff_ids = rootfs["diff_ids"]
@@ -191,7 +159,7 @@ def clear_signature_list(self):
         """Helper method to remove all signatures from the image configuration."""
         self.set_signature_list([])
 
-    def get_signature_list(self) -> List:
+    def get_signature_list(self) -> List[ImageConfigSignatureEntry]:
         """
         Retrieves the signature list from the image configuration.
 
@@ -209,14 +177,14 @@ def get_signature_list(self) -> List:
         Returns:
             The deserialized / unescaped signature list in JSON form.
         """
-        labels = ImageConfig._get_labels(self.config_json)
+        labels = ImageConfig._get_labels(self.get_json())
         return json.loads(
             labels.get(
                 ImageConfig.SIGNATURES_LABEL, ImageConfig.DEFAULT_SIGNATURES_VALUE
             )
         )
 
-    def set_signature_list(self, signatures: List):
+    def set_signature_list(self, signatures: List[ImageConfigSignatureEntry]):
         """
         Serializes / escapes and assigns signature list to the image configuration. The method modifies the raw image
         configuration.
@@ -224,11 +192,12 @@ def set_signature_list(self, signatures: List):
         Args:
             signatures: The deserialized / unescaped signature list in JSON form.
         """
-        labels = ImageConfig._get_labels(self.config_json)
+        _json = self.get_json()
+        labels = ImageConfig._get_labels(_json)
         labels[ImageConfig.SIGNATURES_LABEL] = json.dumps(signatures)
-        self._set_config_json(self.config_json)
+        self._set_json(_json)
 
-    def sign(
+    async def sign(
         self, signer: Signer, signature_type: SignatureTypes = SignatureTypes.SIGN
     ) -> str:
         """
@@ -253,8 +222,8 @@ def sign(
         signatures = self.get_signature_list()
         if signature_type != SignatureTypes.ENDORSE:
             self.clear_signature_list()
-        digest = self.get_config_digest_canonical()
-        signature = signer.sign(digest.encode("utf-8"))
+        digest = self.get_digest_canonical()
+        signature = await signer.sign(digest.encode("utf-8"))
         if not signature:
             raise RuntimeError("Failed to create signature!")
 
@@ -267,7 +236,7 @@ def sign(
 
         return signature
 
-    def verify_signatures(self) -> Dict:
+    async def verify_signatures(self) -> ImageConfigVerifySignatures:
         """
         Verifies the PEM encoded signature values in the image configuration.
 
@@ -278,7 +247,7 @@ def verify_signatures(self) -> Dict:
         """
         signatures = self.get_signature_list()
         if not signatures:
-            raise RuntimeError("Image does not contain any signatures!")
+            raise NoSignatureError()
 
         # Assumptions:
         # * The signature list is ordered.
@@ -287,7 +256,7 @@ def verify_signatures(self) -> Dict:
 
         results = []
         for i, signature in enumerate(signatures):
-            _temp = copy.deepcopy(self)
+            _temp = deepcopy(self)
             # (Co-)signature
             if signature["digest"] == signatures[0]["digest"]:
                 _temp.clear_signature_list()
@@ -295,13 +264,16 @@ def verify_signatures(self) -> Dict:
             else:
                 _temp.set_signature_list(signatures[:i])
 
-            digest = _temp.get_config_digest_canonical()
+            digest = _temp.get_digest_canonical()
             must_be_equal(
-                signature["digest"], digest, "Image config canonical digest mismatch"
+                signature["digest"],
+                digest,
+                "Image config canonical digest mismatch",
+                error_type=DigestMismatchError,
             )
 
             signer = Signer.for_signature(signature["signature"])
-            result = signer.verify(digest.encode("utf-8"), signature["signature"])
+            result = await signer.verify(digest.encode("utf-8"), signature["signature"])
             results.append(result)
 
         return {"signatures": signatures, "results": results}
diff --git a/docker_sign_verify/imagename.py b/docker_sign_verify/imagename.py
deleted file mode 100644
index 8326923..0000000
--- a/docker_sign_verify/imagename.py
+++ /dev/null
@@ -1,152 +0,0 @@
-#!/usr/bin/env python
-
-"""Class that provides parsing and formatting of docker image names."""
-
-import os
-
-from typing import Dict, Union
-
-
-class ImageName:
-    """
-    Docker image name abstraction.
-    """
-
-    DEFAULT_REGISTRY_ENDPOINT = os.environ.get(
-        "DSV_DEFAULT_REGISTRY", "index.docker.io"
-    )
-
-    DEFAULT_REGISTRY_NAMESPACE = os.environ.get("DSV_DEFAULT_NAMESPACE", "library")
-
-    DEFAULT_REGISTRY_TAG = os.environ.get("DSV_DEFAULT_TAG", "latest")
-
-    def __init__(self, endpoint: Union[str, None], image: str, tag: str = None):
-        """
-        Args:
-            endpoint: Endpoint address for fully qualified image names.
-            image: Name of the image, optionally including a namespace.
-            tag: Optional tag name.
-        """
-        self.endpoint = endpoint
-        self.image = image
-        self.tag = tag
-
-    def __str__(self):
-        result = self.image
-        if self.tag:
-            result = "{0}:{1}".format(result, self.tag)
-        if self.endpoint:
-            result = "{0}/{1}".format(self.endpoint, result)
-
-        return result
-
-    @staticmethod
-    def _parse_string(string: str) -> Dict:
-        """
-        Parses the endpoint, image, and tag from a given string.
-
-        Args:
-            string: The string to be parsed.
-
-        Returns:
-            dict:
-                endpoint: The registry endpoint; address with optional port.
-                image: The name of the image; the image name and optional namespace.
-                tag: The tag name.
-        """
-        result = {"endpoint": None, "image": None, "tag": None}
-
-        segments = string.split("/")
-
-        parts = segments[-1].split(":")
-        result["image"] = parts[0]
-        if len(parts) == 2:
-            result["tag"] = parts[1]
-
-        if len(segments) == 1:
-            # image[:tag]
-            pass
-        elif len(segments) == 2:
-            # host[:port]/image[:tag] OR namespace/image[:tag]
-
-            # Note: https://docs.docker.com/engine/reference/commandline/tag/
-            #
-            #       An image name is made up of slash-separated name components, optionally prefixed by a registry
-            #       hostname. The hostname must comply with standard DNS rules, but may not contain underscores. If a
-            #       hostname is present, it may optionally be followed by a port number in the format :8080. ... Name
-            #       components may contain lowercase letter, digits and separators. A separator is defined as a period,
-            #       one or two underscores, or one or more dashes. A name component may not start or end with a
-            #       separator. A tag name ... may contain lowercase and uppercase letters, digits, underscores, periods
-            #       and dashes. A tag name may not start with a period or a dash ... .
-
-            # Assumption: That endpoint addresses will contain at least one '.' (period) character, and by convention
-            #             images namespaces will not.
-            if ":" not in segments[0] and "." not in segments[0]:
-                result["image"] = "{0}/{1}".format(segments[0], result["image"])
-            else:
-                result["endpoint"] = segments[0]
-        elif len(segments) == 3:
-            # host[:port]/namespace/image[:tag]
-            result["endpoint"] = segments[0]
-            result["image"] = "{0}/{1}".format(segments[1], result["image"])
-        else:
-            raise RuntimeError("Unable to parse string: {0}".format(string))
-
-        return result
-
-    @staticmethod
-    def parse(image_name: str):
-        """
-        Initializes an ImageName from a given image name string.
-
-        Args:
-            image_name: String containing the image name to be parsed.
-
-        Returns:
-            The newly initialized object.
-        """
-        parsed = ImageName._parse_string(image_name)
-        return ImageName(parsed["endpoint"], parsed["image"], parsed["tag"])
-
-    def resolve_endpoint(self) -> str:
-        """
-        Resolves the registry endpoint.
-
-        Returns:
-            The explicit registry endpoint.
-        """
-        if self.endpoint:
-            return self.endpoint
-
-        return ImageName.DEFAULT_REGISTRY_ENDPOINT
-
-    def resolve_image(self) -> str:
-        """
-        Resolves the name of the image.
-
-        Returns:
-            The explicit name of the image, with namespace.
-        """
-
-        segments = self.image.split("/")
-        if len(segments) < 2:
-            return "{0}/{1}".format(ImageName.DEFAULT_REGISTRY_NAMESPACE, self.image)
-
-        return self.image
-
-    def resolve_name(self):
-        return "{0}/{1}:{2}".format(
-            self.resolve_endpoint(), self.resolve_image(), self.resolve_tag()
-        )
-
-    def resolve_tag(self) -> str:
-        """
-        Resolves the tag name.
-
-        Returns:
-            The explicit tag name.
-        """
-        if self.tag:
-            return self.tag
-
-        return ImageName.DEFAULT_REGISTRY_TAG
diff --git a/docker_sign_verify/imagesource.py b/docker_sign_verify/imagesource.py
new file mode 100644
index 0000000..b0033bf
--- /dev/null
+++ b/docker_sign_verify/imagesource.py
@@ -0,0 +1,409 @@
+#!/usr/bin/env python
+
+"""Classes that provide a source of docker images."""
+
+import abc
+import logging
+import time
+
+from functools import wraps
+from typing import Any, cast, List, Optional, TypedDict
+
+import gnupg  # Needed for type checking
+
+from aiofiles.base import AiofilesContextManager
+from docker_registry_client_async import FormattedSHA256, ImageName
+from docker_registry_client_async.typing import UtilsChunkToFile
+from docker_registry_client_async.utils import must_be_equal
+
+from .exceptions import SignatureMismatchError
+from .imageconfig import ImageConfig, SignatureTypes
+from .manifest import Manifest
+from .signer import Signer
+from .utils import xellipsis
+
+LOGGER = logging.getLogger(__name__)
+
+
+class ImageSourceVerifyImageIntegrity(TypedDict):
+    # pylint: disable=missing-class-docstring
+    compressed_layer_files: Optional[List[AiofilesContextManager]]
+    image_config: ImageConfig
+    manifest: Manifest
+    uncompressed_layer_files: List[AiofilesContextManager]
+
+
+class ImageSourceSignImageConfig(TypedDict):
+    # pylint: disable=missing-class-docstring
+    image_config: ImageConfig
+    signature_value: str
+    verify_image_data: ImageSourceVerifyImageIntegrity
+
+
+class ImageSourceVerifyImageConfig(TypedDict):
+    # pylint: disable=missing-class-docstring
+    image_config: ImageConfig
+    image_layers: List[FormattedSHA256]
+    manifest: Manifest
+    manifest_layers: List[FormattedSHA256]
+
+
+class ImageSourceGetImageLayerToDisk(UtilsChunkToFile):
+    # pylint: disable=missing-class-docstring
+    pass
+
+
+class ImageSourceSignImage(ImageSourceSignImageConfig):
+    # pylint: disable=missing-class-docstring
+    manifest_signed: Manifest
+
+
+class ImageSourceVerifyImageSignatures(ImageSourceVerifyImageIntegrity):
+    # pylint: disable=missing-class-docstring
+    signatures: Any
+
+
+class ImageSource(abc.ABC):
+    """
+    Abstract source of docker images.
+    """
+
+    def __init__(self, *, dry_run: bool = False):
+        """
+        Args:
+            dry_run: If true, destination image sources will not be changed.
+        """
+        self.dry_run = dry_run
+
+    @staticmethod
+    def check_dry_run(func):
+        """Validates the state of ImageSource.dry_run before invoking the wrapped method."""
+
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            if args[0].dry_run:
+                LOGGER.debug("Dry Run: skipping %s", func)
+            else:
+                return await func(*args, **kwargs)
+
+        return wrapper
+
+    async def _sign_image_config(
+        self,
+        signer: Signer,
+        image_name: ImageName,
+        signature_type: SignatureTypes,
+        **kwargs,
+    ) -> ImageSourceSignImageConfig:
+        """
+        Verifies an image, then signs it without storing it in the image source.
+
+        Args:
+            signer: The signer used to create the signature value.
+            image_name: The image name.
+            signature_type: Specifies what type of signature action to perform.
+
+        Returns:
+            dict:
+                image_config: The ImageConfig object corresponding to the signed image.
+                signature_value: as defined by :func:~docker_sign_verify.ImageConfig.sign.
+                verify_image_data: as defined by :func:~docker_sign_verify.ImageSource.verify_image_integrity.
+        """
+        # Verify image integrity (we use the verified values from this point on)
+        data = await self.verify_image_integrity(image_name, **kwargs)
+        image_config = data["image_config"]
+
+        # Perform the desired signing operation
+        signature_value = await image_config.sign(signer, signature_type)
+
+        return {
+            "image_config": image_config,
+            "signature_value": signature_value,
+            "verify_image_data": data,
+        }
+
+    async def _verify_image_config(
+        self, image_name: ImageName, **kwargs
+    ) -> ImageSourceVerifyImageConfig:
+        """
+        Verifies the integration of an image configuration against metadata contained within a manifest.
+
+        Args:
+            image_name: The image name for which to retrieve the configuration.
+
+        Returns:
+            dict:
+                image_config: The image configuration.
+                image_layers: The listing of image layer identifiers.
+                manifest: The image-source specific manifest.
+                manifest_layers: The listing of manifest layer identifiers.
+        """
+
+        # Retrieve the image configuration digest and layers identifiers from the manifest ...
+        LOGGER.debug("Verifying Integrity: %s ...", image_name.resolve_name())
+        manifest = await self.get_manifest(image_name, **kwargs)
+        LOGGER.debug("    manifest digest: %s", xellipsis(manifest.get_digest()))
+        config_digest = manifest.get_config_digest(image_name)
+        LOGGER.debug("    config digest: %s", xellipsis(config_digest))
+        manifest_layers = manifest.get_layers(image_name)
+        LOGGER.debug("    manifest layers:")
+        for layer in manifest_layers:
+            LOGGER.debug("        %s", xellipsis(layer))
+
+        # Retrieve the image configuration ...
+        image_config = await self.get_image_config(image_name, **kwargs)
+        config_digest_canonical = image_config.get_digest_canonical()
+        LOGGER.debug(
+            "    config digest (canonical): %s", xellipsis(config_digest_canonical)
+        )
+        must_be_equal(
+            config_digest, image_config.get_digest(), "Image config digest mismatch",
+        )
+
+        # Retrieve the image layers from the image configuration ...
+        image_layers = image_config.get_image_layers()
+        LOGGER.debug("    image layers:")
+        for layer in image_layers:
+            LOGGER.debug("        %s", xellipsis(layer))
+
+        # Quick check: Ensure that the layer counts are consistent
+        must_be_equal(len(manifest_layers), len(image_layers), "Layer count mismatch")
+
+        return {
+            "image_config": image_config,
+            "image_layers": image_layers,
+            "manifest": manifest,
+            "manifest_layers": manifest_layers,
+        }
+
+    @abc.abstractmethod
+    async def get_image_config(self, image_name: ImageName, **kwargs) -> ImageConfig:
+        """
+        Retrieves an image configuration (config.json).
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            The image configuration.
+        """
+
+    @abc.abstractmethod
+    async def get_image_layer_to_disk(
+        self, image_name: ImageName, layer: FormattedSHA256, file, **kwargs
+    ) -> ImageSourceGetImageLayerToDisk:
+        """
+        Retrieves a single image layer stored to disk.
+
+        Args:
+            image_name: The image name.
+            layer: The layer identifier in the form: <hash type>:<digest value>.
+            file: File in which to store the image layer.
+        """
+
+    @abc.abstractmethod
+    async def get_manifest(self, image_name: ImageName = None, **kwargs) -> Manifest:
+        """
+        Retrieves the manifest for a given image.
+
+        Args:
+            image_name: The name image for which to retrieve the manifest.
+
+        Returns:
+            The image source-specific manifest.
+        """
+
+    @abc.abstractmethod
+    async def layer_exists(
+        self, image_name: ImageName, layer: FormattedSHA256, **kwargs
+    ) -> bool:
+        """
+        Checks if a given image layer exists.
+
+        Args:
+            image_name: The image name.
+            layer: The layer identifier in the form: <hash type>:<digest value>.
+
+        Returns:
+            bool: True if the layer exists, False otherwise.
+        """
+
+    @abc.abstractmethod
+    async def put_image(
+        self,
+        image_source,
+        image_name: ImageName,
+        manifest: Manifest,
+        image_config: ImageConfig,
+        layer_files: List,
+        **kwargs,
+    ):
+        """
+        Stores a given image (manifest, image_config, and layers) from another image source.
+
+        Args:
+            image_source: The source image source.
+            image_name: The name of the image being stored.
+            manifest: The image source-specific manifest to be stored, in source image source format.
+            image_config: The image configuration to be stored.
+            layer_files: List of files from which to read the layer content, in source image source format.
+        """
+
+    @abc.abstractmethod
+    async def put_image_config(
+        self, image_name: ImageName, image_config: ImageConfig, **kwargs
+    ):
+        """
+        Assigns an image configuration (config.json).
+
+        Args:
+            image_name: The image name.
+            image_config: The image configuration to be assigned.
+        """
+
+    @abc.abstractmethod
+    async def put_image_layer(self, image_name: ImageName, content, **kwargs):
+        """
+        Assigns a single image layer.
+
+        Args:
+            image_name: The image name.
+            content: The layer content.
+        """
+
+    @abc.abstractmethod
+    async def put_image_layer_from_disk(self, image_name: ImageName, file, **kwargs):
+        """
+        Assigns a single image layer read from disk.
+
+        Args:
+            image_name: The image name.
+            file: File from which to read the layer content.
+        """
+
+    @abc.abstractmethod
+    async def put_manifest(
+        self, manifest: Manifest, image_name: ImageName = None, **kwargs
+    ):
+        """
+        Assigns the manifest for a given image.
+
+        Args:
+            manifest: The image source-specific manifest to be assigned.
+            image_name: The name of the image for which to assign the manifest.
+        """
+
+    @abc.abstractmethod
+    async def sign_image(
+        self,
+        signer: Signer,
+        src_image_name: ImageName,
+        dest_image_source,
+        dest_image_name: ImageName,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        **kwargs,
+    ) -> ImageSourceSignImage:
+        """
+        Retrieves, verifies and signs the image, storing it in the destination image source.
+
+        Args:
+            signer: The signer used to create the signature value.
+            src_image_name: The source image name.
+            dest_image_source: The destination image source into which to store the signed image.
+            dest_image_name: The description image name.
+            signature_type: Specifies what type of signature action to perform.
+
+        Returns:
+            dict:
+                image_config: The ImageConfig object corresponding to the signed image.
+                signature_value: as defined by :func:~docker_sign_verify.ImageConfig.sign.
+                verify_image_data: as defined by :func:~docker_sign_verify.ImageSource.verify_image_integrity.
+                manifest_signed: The signed image source-specific manifest.
+        """
+
+    @abc.abstractmethod
+    async def verify_image_integrity(
+        self, image_name: ImageName, **kwargs
+    ) -> ImageSourceVerifyImageIntegrity:
+        """
+        Verifies that the image source data format is consistent with respect to the image configuration and image
+        layers, and that the image configuration and image layers are internally consistent (the digest values match).
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            dict:
+                compressed_layer_files: The list of compressed layer files on disk (optional).
+                image config: The image configuration.
+                manifest: The image source-specific manifest file (archive, registry, repository).
+                uncompressed_layer_files: The list of uncompressed layer files on disk.
+        """
+
+    async def verify_image_signatures(
+        self, image_name: ImageName, **kwargs
+    ) -> ImageSourceVerifyImageSignatures:
+        """
+        Verifies that signatures contained within the image source data format are valid (that the image has not been
+        modified since they were created)
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            dict:
+                compressed_layer_files: The list of compressed layer files on disk (optional).
+                image config: The image configuration.
+                manifest: The image source-specific manifest file (archive, registry, repository).
+                signatures: as defined by :func:~docker_sign_verify.ImageConfig.verify_signatures.
+                uncompressed_layer_files: The list of uncompressed layer files on disk.
+        """
+
+        # Verify image integrity (we use the verified values from this point on)
+        integrity_data = await self.verify_image_integrity(image_name, **kwargs)
+
+        # Verify image signatures ...
+        LOGGER.debug("Verifying Signature(s): %s ...", image_name.resolve_name())
+        LOGGER.debug(
+            "    config digest (signed): %s",
+            xellipsis(integrity_data["image_config"].get_digest()),
+        )
+        integrity_data = cast(ImageSourceVerifyImageSignatures, integrity_data)
+        integrity_data["signatures"] = await integrity_data[
+            "image_config"
+        ].verify_signatures()
+
+        # List the image signatures ...
+        LOGGER.debug("    signatures:")
+        for result in integrity_data["signatures"]["results"]:
+            # pylint: disable=protected-access
+            if isinstance(result, gnupg._parsers.Verify):
+                if not result.valid:
+                    raise SignatureMismatchError(
+                        "Verification failed for signature with key_id '{0}': {1}".format(
+                            result.key_id, result.status
+                        )
+                    )
+                LOGGER.debug(
+                    "        Signature made %s using key ID %s",
+                    time.strftime(
+                        "%Y-%m-%d %H:%M:%S", time.gmtime(float(result.sig_timestamp))
+                    ),
+                    result.key_id,
+                )
+                LOGGER.debug("            %s", result.username)
+            elif result.get("type", None) == "pki":
+                if not result["valid"]:
+                    raise SignatureMismatchError(
+                        "Verification failed for signature using cert: {0}".format(
+                            result["keypair_path"]
+                        )
+                    )
+                # TODO: Add better debug logging
+                LOGGER.debug("        Signature made using undetailed PKI keypair.")
+            else:
+                LOGGER.error("Unknown Signature Type: %s", type(result))
+
+        LOGGER.debug("Signature check passed.")
+
+        return integrity_data
diff --git a/docker_sign_verify/imagesources.py b/docker_sign_verify/imagesources.py
deleted file mode 100644
index 30bba29..0000000
--- a/docker_sign_verify/imagesources.py
+++ /dev/null
@@ -1,1391 +0,0 @@
-#!/usr/bin/env python
-
-"""Classes that provide a source of docker images."""
-
-import abc
-import copy
-import datetime
-import hashlib
-import io
-import json
-import logging
-import os
-import random
-import subprocess
-import tempfile
-import time
-
-from pathlib import Path
-from typing import Dict, List
-from urllib.parse import urlparse
-
-import gnupg  # Needed for type checking
-import requests
-from requests.models import Response
-import www_authenticate
-
-from .manifests import (
-    ArchiveManifest,
-    DeviceMapperRepositoryManifest,
-    Manifest,
-    RegistryV2Manifest,
-)
-from .imageconfig import ImageConfig, SignatureTypes
-from .imagename import ImageName
-from .signers import Signer
-from .utils import (
-    CHUNK_SIZE,
-    copy_file,
-    file_exists_in_tar,
-    formatted_digest,
-    gunzip,
-    must_be_equal,
-    read_file,
-    tar,
-    tar_add_file,
-    tar_delete_file,
-    tar_mkdir,
-    untar,
-    write_file,
-    xellipsis,
-    FormattedSHA256,
-)
-
-LOGGER = logging.getLogger(__name__)
-
-
-class ImageSource(abc.ABC):
-    """
-    Abstract source of docker images.
-    """
-
-    def __init__(self, *, dry_run: bool = False):
-        """
-        Args:
-            dry_run: If true, destination image sources will not be changed.
-        """
-        self.dry_run = dry_run
-
-    def _sign_image_config(
-        self, signer: Signer, image_name: ImageName, signature_type: SignatureTypes
-    ) -> Dict:
-        """
-        Verifies an image, then signs it without storing it in the image source.
-
-        Args:
-            signer: The signer used to create the signature value.
-            image_name: The image name.
-            signature_type: Specifies what type of signature action to perform.
-
-        Returns:
-            dict:
-                image_config: The ImageConfig object corresponding to the signed image.
-                signature_value: as defined by :func:~docker_sign_verify.ImageConfig.sign.
-                verify_image_data: as defined by :func:~docker_sign_verify.ImageSource.verify_image_integrity.
-        """
-        # Verify image integrity (we use the verified values from this point on)
-        data = self.verify_image_integrity(image_name)
-        image_config = data["image_config"]  # type: ImageConfig
-
-        # Append our signature to any existing signatures ....
-        signature_value = image_config.sign(signer, signature_type)
-
-        return {
-            "image_config": image_config,
-            "signature_value": signature_value,
-            "verify_image_data": data,
-        }
-
-    def _verify_image_config(self, image_name: ImageName) -> Dict:
-        """
-        Verifies the integration of an image configuration against metadata contained within a manifest.
-
-        Args:
-            image_name: The image name for which to retrieve the configuration.
-
-        Returns:
-            dict:
-                image_config: The image configuration.
-                image_layers: The listing of image layer identifiers.
-                manifest: The image-source specific manifest.
-                manifest_layers: The listing of manifest layer identifiers.
-        """
-
-        # Retrieve the image configuration digest and layers identifiers from the manifest ...
-        LOGGER.debug("Verifying Integrity: %s ...", image_name.resolve_name())
-        manifest = self.get_manifest(image_name)
-        config_digest = manifest.get_config_digest(image_name)
-        LOGGER.debug("    config digest: %s", xellipsis(config_digest))
-        manifest_layers = manifest.get_layers(image_name)
-        LOGGER.debug("    manifest layers:")
-        [LOGGER.debug("        %s", xellipsis(l)) for l in manifest_layers]
-
-        # Retrieve the image layers from the image configuration ...
-        image_config = self.get_image_config(image_name)
-        config_digest_canonical = image_config.get_config_digest_canonical()
-        LOGGER.debug(
-            "    config digest (canonical): %s", xellipsis(config_digest_canonical)
-        )
-        must_be_equal(
-            config_digest,
-            image_config.get_config_digest(),
-            "Image config digest mismatch",
-        )
-        image_layers = image_config.get_image_layers()
-        LOGGER.debug("    image layers:")
-        [LOGGER.debug("        %s", xellipsis(l)) for l in image_layers]
-
-        # Quick check: Ensure that the layer counts are consistent
-        must_be_equal(len(manifest_layers), len(image_layers), "Layer count mismatch")
-
-        return {
-            "image_config": image_config,
-            "image_layers": image_layers,
-            "manifest": manifest,
-            "manifest_layers": manifest_layers,
-        }
-
-    @abc.abstractmethod
-    def get_image_config(self, image_name: ImageName) -> ImageConfig:
-        """
-        Retrieves an image configuration (config.json).
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            The image configuration.
-        """
-
-    @abc.abstractmethod
-    # TODO: Can we make layer type 'FormattedSHA256' ???
-    def get_image_layer_to_disk(self, image_name: ImageName, layer: str, file):
-        """
-        Retrieves a single image layer stored to disk.
-
-        Args:
-            image_name: The image name.
-            layer: The layer identifier in the form: <hash type>:<digest value>.
-            file: File in which to store the image layer.
-        """
-
-    @abc.abstractmethod
-    def get_manifest(self, image_name: ImageName = None) -> Manifest:
-        """
-        Retrieves the manifest for a given image.
-
-        Args:
-            image_name: The name image for which to retrieve the manifest.
-
-        Returns:
-            The image source-specific manifest.
-        """
-
-    @abc.abstractmethod
-    def layer_exists(self, image_name: ImageName, layer: FormattedSHA256) -> bool:
-        """
-        Checks if a given image layer exists.
-
-        Args:
-            image_name: The image name.
-            layer: The layer identifier in the form: <hash type>:<digest value>.
-
-        Returns:
-            bool: True if the layer exists, False otherwise.
-        """
-
-    @abc.abstractmethod
-    def put_image(
-        self,
-        image_source,
-        image_name: ImageName,
-        manifest: Manifest,
-        image_config: ImageConfig,
-        layer_files: List,
-    ):
-        """
-        Stores a given image (manifest, image_config, and layers) from another image source.
-
-        Args:
-            image_source: The source image source.
-            image_name: The name of the image being stored.
-            manifest: The image source-specific manifest to be stored, in source image source format.
-            image_config: The image configuration to be stored.
-            layer_files: List of files from which to read the layer content, in source image source format.
-        """
-
-    @abc.abstractmethod
-    def put_image_config(self, image_name: ImageName, image_config: ImageConfig):
-        """
-        Assigns an image configuration (config.json).
-
-        Args:
-            image_name: The image name.
-            image_config: The image configuration to be assigned.
-        """
-
-    @abc.abstractmethod
-    def put_image_layer(self, image_name: ImageName, content):
-        """
-        Assigns a single image layer.
-
-        Args:
-            image_name: The image name.
-            content: The layer content.
-        """
-
-    @abc.abstractmethod
-    def put_image_layer_from_disk(self, image_name: ImageName, file):
-        """
-        Assigns a single image layer read from disk.
-
-        Args:
-            image_name: The image name.
-            file: File from which to read the layer content.
-        """
-
-    @abc.abstractmethod
-    def put_manifest(self, manifest: Manifest, image_name: ImageName = None):
-        """
-        Assigns the manifest for a given image.
-
-        Args:
-            manifest: The image source-specific manifest to be assigned.
-            image_name: The name of the image for which to assign the manifest.
-        """
-
-    @abc.abstractmethod
-    def sign_image(
-        self,
-        signer: Signer,
-        src_image_name: ImageName,
-        dest_image_source,
-        dest_image_name: ImageName,
-        signature_type: SignatureTypes = SignatureTypes.SIGN,
-    ):
-        """
-        Retrieves, verifies and signs the image, storing it in the destination image source.
-
-        Args:
-            signer: The signer used to create the signature value.
-            src_image_name: The source image name.
-            dest_image_source: The destination image source into which to store the signed image.
-            dest_image_name: The description image name.
-            signature_type: Specifies what type of signature action to perform.
-
-        Returns:
-            dict: as defined by :func:~docker_sign_verify.ImageSource._sign_image_config.
-        """
-
-    @abc.abstractmethod
-    def verify_image_integrity(self, image_name: ImageName) -> Dict:
-        """
-        Verifies that the image source data format is consistent with respect to the image configuration and image
-        layers, and that the image configuration and image layers are internally consistent (the digest values match).
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            dict:
-                compressed_layer_files: The list of compressed layer files on disk (optional).
-                image config: The image configuration.
-                manifest: The image source-specific manifest file (archive, registry, repository).
-                uncompressed_layer_files: The list of uncompressed layer files on disk.
-        """
-
-    def verify_image_signatures(self, image_name: ImageName) -> Dict:
-        """
-        Verifies that signatures contained within the image source data format are valid (that the image has not been
-        modified since they were created)
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            dict:
-                compressed_layer_files: The list of compressed layer files on disk (optional).
-                image config: The image configuration.
-                manifest: The image source-specific manifest file (archive, registry, repository).
-                signatures: as defined by :func:~docker_sign_verify.ImageConfig.verify_signatures.
-                uncompressed_layer_files: The list of uncompressed layer files on disk.
-        """
-
-        # Verify image integrity (we use the verified values from this point on)
-        integrity_data = self.verify_image_integrity(image_name)
-
-        # Verify image signatures ...
-        LOGGER.debug("Verifying Signature(s): %s ...", image_name.resolve_name())
-        LOGGER.debug(
-            "    config digest (signed): %s",
-            xellipsis(integrity_data["image_config"].get_config_digest()),
-        )
-        signature_data = integrity_data["image_config"].verify_signatures()
-
-        # List the image signatures ...
-        LOGGER.debug("    signatures:")
-        for result in signature_data["results"]:
-            # pylint: disable=protected-access
-            if isinstance(result, gnupg._parsers.Verify):
-                if not result.valid:
-                    raise RuntimeError(
-                        "Verification failed for signature with key_id '{0}': {1}".format(
-                            result.key_id, result.status
-                        )
-                    )
-                LOGGER.debug(
-                    "        Signature made %s using key ID %s",
-                    time.strftime(
-                        "%Y-%m-%d %H:%M:%S", time.gmtime(float(result.sig_timestamp))
-                    ),
-                    result.key_id,
-                )
-                LOGGER.debug("            %s", result.username)
-            elif result.get("type", None) == "pki":
-                if not result["valid"]:
-                    raise RuntimeError(
-                        "Verification failed for signature using cert: {0}".format(
-                            result["keypair_path"]
-                        )
-                    )
-                # TODO: Add better debug logging
-                LOGGER.debug("        Signature made using undetailed PKI keypair.")
-            else:
-                LOGGER.error("Unknown Signature Type: %s", type(result))
-
-        LOGGER.debug("Signature check passed.")
-
-        return {
-            "compressed_layer_files": integrity_data["compressed_layer_files"],
-            "image_config": integrity_data["image_config"],
-            "manifest": integrity_data["manifest"],
-            "signatures": signature_data["results"],
-            "uncompressed_layer_files": integrity_data["uncompressed_layer_files"],
-        }
-
-
-class ArchiveImageSource(ImageSource):
-    """
-    Docker archive image source.
-    """
-
-    FILE_ARCHIVE_MANIFEST = "manifest.json"
-
-    def __init__(self, *, archive, **kwargs):
-        """
-        Args:
-            archive: Path to the docker image archive.
-        """
-        super(ArchiveImageSource, self).__init__(**kwargs)
-        self.archive = archive
-
-    def _file_exists(self, name) -> bool:
-        """
-        Checks if a give file exists within this image source.
-
-        Args:
-            name: Name of the file to be checked.
-
-        Returns:
-            bool: True if the file exists, False otherwise.
-        """
-        with open(self.archive, "rb") as file_in:
-            return file_exists_in_tar(file_in, name)
-
-    def get_file_from_archive(self, path):
-        """
-        Retrieves a file from within this image source.
-
-        Args:
-            path: Relative path of the file to be retrieved.
-
-        Returns:
-            The file content.
-        """
-        bytesio = io.BytesIO()
-        with open(self.archive, "rb") as file:
-            untar(file, path, bytesio)
-        return bytesio.read()
-
-    # ImageSource Members
-
-    def get_image_config(self, image_name: ImageName) -> ImageConfig:
-        config = self.get_manifest().get_config(image_name)
-        return ImageConfig(self.get_file_from_archive(config["Config"]))
-
-    def get_image_layer_to_disk(self, image_name: ImageName, layer: str, file):
-        with open(self.archive, "rb") as file_in:
-            return untar(file_in, ArchiveManifest.digest_to_layer(layer), file)
-
-            #    def put_image_layer(self, image, content):
-
-    def get_manifest(self, image_name: ImageName = None) -> Manifest:
-        raw_archive_manifest = self.get_file_from_archive(
-            ArchiveImageSource.FILE_ARCHIVE_MANIFEST
-        )
-        return ArchiveManifest(raw_archive_manifest)
-
-    def layer_exists(self, image_name: ImageName, layer: FormattedSHA256) -> bool:
-        return self._file_exists(ArchiveManifest.digest_to_layer(layer))
-
-    def put_image(
-        self,
-        image_source,
-        image_name: ImageName,
-        manifest: Manifest,
-        image_config: ImageConfig,
-        layer_files: List,
-    ):
-        # TODO: Implement this method, refactor sign_image to use it ...
-        raise NotImplementedError
-
-    def put_image_config(self, image_name: ImageName, image_config: ImageConfig):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_config")
-            return
-        digest = image_config.get_config_digest()
-        name = "{0}.json".format(digest.sha256)
-        if not self._file_exists(name):
-            with open(self.archive, "rb+") as file_out:
-                tar_add_file(file_out, name, image_config.get_config())
-
-    def put_image_layer(self, image_name: ImageName, content):
-        raise NotImplementedError
-
-    def put_image_layer_from_disk(self, image_name: ImageName, file) -> FormattedSHA256:
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_layer_from_disk")
-            return FormattedSHA256("0" * 64)
-        # TODO: Do we really want to use random garbage here???
-        #       Look into moby/.../save.go to find what to use instead.
-        digest = formatted_digest(
-            "{0}{1}{2}".format(
-                str(image_name), datetime.datetime.now(), random.randint(1, 101)
-            ).encode("utf-8")
-        )
-        layer = ArchiveManifest.digest_to_layer(digest)
-        with open(self.archive, "rb+") as file_out:
-            tar_mkdir(file_out, os.path.dirname(layer))
-            file_out.seek(0)
-            tar(file_out, layer, file)
-        return digest
-
-    def put_manifest(self, manifest: Manifest, image_name: ImageName = None):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_manifest")
-            return
-        with open(self.archive, "rb+") as file_out:
-            tar_delete_file(file_out, ArchiveImageSource.FILE_ARCHIVE_MANIFEST)
-            file_out.seek(0)
-            tar_add_file(
-                file_out,
-                ArchiveImageSource.FILE_ARCHIVE_MANIFEST,
-                str(manifest).encode("utf-8"),
-            )
-
-    def sign_image(
-        self,
-        signer: Signer,
-        src_image_name: ImageName,
-        dest_image_source: ImageSource,
-        dest_image_name: ImageName,
-        signature_type: SignatureTypes = SignatureTypes.SIGN,
-    ):
-        LOGGER.debug(
-            "%s: %s ...",
-            "Endorsing"
-            if signature_type == SignatureTypes.ENDORSE
-            else "Signing"
-            if signature_type == SignatureTypes.SIGN
-            else "Resigning",
-            src_image_name.resolve_name(),
-        )
-
-        # Generate a signed image configuration ...
-        data = self._sign_image_config(signer, src_image_name, signature_type)
-        manifest = data["verify_image_data"]["manifest"]
-        LOGGER.debug("    Signature:\n%s", data["signature_value"])
-        image_config = data["image_config"]
-
-        # Replicate all of the image layers ...
-        LOGGER.debug("    Replicating image layers ...")
-        archive_layers = manifest.get_layers(src_image_name)
-        archive_layers_changed = archive_layers.copy()
-        for i, archive_layer in enumerate(archive_layers):
-            if not dest_image_source.layer_exists(dest_image_name, archive_layer):
-                # Update the layer
-                digest = dest_image_source.put_image_layer_from_disk(
-                    dest_image_name,
-                    data["verify_image_data"]["uncompressed_layer_files"][i],
-                )
-                archive_layers_changed[i] = digest
-        archive_layers = archive_layers_changed
-
-        # Push the new image configuration ...
-        config_digest_signed = image_config.get_config_digest()
-        LOGGER.debug("    config digest (signed): %s", config_digest_signed)
-        dest_image_source.put_image_config(dest_image_name, image_config)
-
-        # Generate a new archive manifest, and push ...
-        if isinstance(dest_image_source, ArchiveImageSource):
-            manifest_signed = dest_image_source.get_manifest()  # type: ArchiveManifest
-
-            repotags = None
-            if dest_image_name.tag:
-                repotags = [str(dest_image_name)]
-                manifest_signed.append_config(
-                    config_digest_signed, archive_layers, repotags
-                )
-            data["manifest_signed"] = manifest_signed
-            # TODO: make sure to remove conflicting tags in "other" config entries
-            dest_image_source.put_manifest(manifest_signed)
-            # TODO: Update foo.tar:/repositories as well
-        elif isinstance(dest_image_source, RegistryV2ImageSource):
-            raise NotImplementedError
-        elif isinstance(dest_image_source, DeviceMapperRepositoryImageSource):
-            raise NotImplementedError
-        else:
-            raise RuntimeError(
-                "Unknown derived class: {0}".format(type(dest_image_source))
-            )
-
-        if not self.dry_run:
-            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
-
-        return data
-
-    def verify_image_integrity(self, image_name: ImageName) -> Dict:
-        data = self._verify_image_config(image_name)
-
-        # Reconcile manifest layers and image layers (in order)...
-        uncompressed_layer_files = []
-        for i, layer in enumerate(data["manifest_layers"]):
-            # Retrieve the archive image layer and verify the digest ...
-            uncompressed_layer_files.append(tempfile.NamedTemporaryFile())
-            data_uncompressed = self.get_image_layer_to_disk(
-                image_name, layer, uncompressed_layer_files[i]
-            )
-            must_be_equal(
-                data["image_layers"][i],
-                data_uncompressed["digest"],
-                "Archive layer[{0}] digest mismatch".format(i),
-            )
-
-        LOGGER.debug("Integrity check passed.")
-
-        return {
-            "compressed_layer_files": "TODO",
-            "image_config": data["image_config"],
-            "manifest": data["manifest"],
-            "uncompressed_layer_files": uncompressed_layer_files,
-        }
-
-
-class RegistryV2ImageSource(ImageSource):
-    """
-    Docker registry image source.
-    """
-
-    BLOB_MIME_TYPE = "application/vnd.docker.container.image.v1+json"
-    BLOB_URL_PATTERN = "https://{0}/v2/{1}/blobs/{2}"
-    DOCKERHUB_AUTH_URL_PATTERN = (
-        "{0}?service={1}&scope={2}&client_id=docker-sign-verify"
-    )
-    MANIFEST_MIME_TYPE = "application/vnd.docker.distribution.manifest.v2+json"
-    MANIFEST_LIST_MIME_TYPE = (
-        "application/vnd.docker.distribution.manifest.list.v2+json"
-    )
-    MANIFEST_URL_PATTERN = "https://{0}/v2/{1}/manifests/{2}"
-
-    PLATFORM_ARCHITECTURE = os.environ.get("DSV_ARCHITECTURE", "amd64")
-    PLATFORM_OS = os.environ.get("DSV_OPERATING_SYSTEM", "linux")
-
-    DEFAULT_CREDENTIALS_STORE = Path.home().joinpath(".docker/config.json")
-
-    def __init__(self, *, credentials_store: Path = None, **kwargs):
-        """
-        Args:
-            credentials_store: Path to the docker registry credentials store.
-        """
-        super(RegistryV2ImageSource, self).__init__(**kwargs)
-        if not credentials_store:
-            credentials_store = Path(
-                os.environ.get(
-                    "DSV_CREDENTIALS_STORE",
-                    RegistryV2ImageSource.DEFAULT_CREDENTIALS_STORE,
-                )
-            )
-        self.credentials_store = credentials_store
-        self.credentials = None
-        self.token = None
-
-    def _get_auth_token(self, credentials: str, endpoint: str, scope: str) -> str:
-        """
-        Retrieves the registry auth token for a given scope.
-
-        Args:
-            credentials: The credentials to use to retrieve the auth token.
-            endpoint: Registry endpoint for which to retrieve the token.
-            scope: The scope of the auth token.
-
-        Returns:
-            The corresponding auth token, or None.
-        """
-
-        # https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md
-        if not self.token:
-            # Test using HTTP basic authentication to retrieve the www-authenticate response header ...
-            headers = {"Authorization": "Basic {0}".format(credentials)}
-            url = "https://{0}/v2/".format(endpoint)
-            response = requests.get(url, headers=headers)
-
-            auth_params = www_authenticate.parse(response.headers["Www-Authenticate"])
-            bearer = auth_params["bearer"]
-
-            url = RegistryV2ImageSource.DOCKERHUB_AUTH_URL_PATTERN.format(
-                bearer["realm"], bearer["service"], scope
-            )
-            response = requests.get(url, headers=headers)
-            # LOGGER.debug("Token Response: %s", response.content)
-            must_be_equal(200, response.status_code, "Failed to retrieve bearer token")
-
-            self.token = response.json()["token"]
-
-        return self.token
-
-    def _get_credentials(self, endpoint: str) -> str:
-        """
-        Retrieves the registry credentials from the docker registry credentials store for a given endpoint
-
-        Args:
-            endpoint: Registry endpoint for which to retrieve the credentials.
-
-        Returns:
-            The corresponding base64 encoded registry credentials, or None.
-        """
-        result = None
-
-        if self.credentials is None and self.credentials_store:
-            LOGGER.debug("Using credentials store: %s", self.credentials_store)
-
-            # TODO: Add support for secure providers:
-            #       https://docs.docker.com/engine/reference/commandline/login/#credentials-store
-
-            self.credentials = {}
-            if self.credentials_store.is_file():
-                with self.credentials_store.open(mode="rb") as file:
-                    self.credentials = json.loads(file.read()).get("auths", {})
-
-        for endpoint_auth in [
-            u for u in self.credentials if endpoint in (u, urlparse(u).netloc)
-        ]:
-            result = self.credentials[endpoint_auth].get("auth", None)
-            if result:
-                break
-
-        return result
-
-    def _get_request_headers(self, image_name: ImageName, headers=None):
-        """
-        Generates request headers that contain registry credentials for a given registry endpoint.
-
-        Args:
-            image_name: Image name for which to retrieve the request headers.
-            headers: Optional supplemental request headers to be returned.
-
-        Returns:
-            The generated request headers.
-        """
-        if not headers:
-            headers = {}
-
-        if "User-Agent" not in headers:
-            # Note: This cannot be imported above, as it causes a circular import!
-            from . import __version__
-
-            headers["User-Agent"] = "docker-sign-verify/{0}".format(__version__)
-
-        endpoint = image_name.resolve_endpoint()
-        credentials = self._get_credentials(endpoint)
-        if credentials:
-            if "docker.io" in endpoint:
-                token = self._get_auth_token(
-                    credentials,
-                    endpoint,
-                    "repository:{0}:pull".format(image_name.resolve_image()),
-                )
-                headers["Authorization"] = "Bearer {0}".format(token)
-            else:
-                headers["Authorization"] = "Basic {0}".format(credentials)
-
-        return headers
-
-    def monolithic_blob_upload(
-        self, image_name: ImageName, blob, digest: FormattedSHA256 = None
-    ) -> Response:
-        """
-        Performs a monolithic upload of an image blob.
-
-        Args:
-            image_name: The name of the target repository.
-            blob: The data to be uploaded.
-            digest: Digest of uploaded blob.
-
-        Returns:
-            The underlying response.
-        """
-        url = RegistryV2ImageSource.BLOB_URL_PATTERN.format(
-            image_name.resolve_endpoint(), image_name.resolve_image(), "uploads/"
-        )
-        headers = self._get_request_headers(
-            image_name, {"Content-Type": "application/octet-stream"}
-        )
-        if not digest:
-            digest = formatted_digest(blob)
-        response = requests.post(
-            url, params={"digest": digest}, headers=headers, data=blob
-        )
-        must_be_equal(202, response.status_code, "Failed to upload blob")
-
-        # TODO: Do we need to emulate the Docker-Content-Digest return type?
-        return response
-
-    def initiate_resumable_blob_upload(self, image_name: ImageName) -> Response:
-        """
-        Starts a resumable upload of an image blob.
-
-        Args:
-            image_name: The name of the target repository.
-
-        Returns:
-            The underlying response.
-        """
-        url = RegistryV2ImageSource.BLOB_URL_PATTERN.format(
-            image_name.resolve_endpoint(), image_name.resolve_image(), "uploads/"
-        )
-        headers = self._get_request_headers(image_name)
-        response = requests.post(url, headers=headers)
-        # logging.debug("UUID: {0}".format(response.headers["Docker-Upload-UUID"]))
-        must_be_equal(
-            202, response.status_code, "Failed to initiate resumable blob upload"
-        )
-        return response
-
-    def resume_blob_upload(self, location, offset, chunk) -> Response:
-        """
-        Continues a resumable upload of an image blob.
-
-        Args:
-            location:
-                The Resumable location provided from
-                :func:~docker_sign_verify.RegistryV2ImageSource.initiate_resumable_blob_upload.
-            offset: Range of bytes identifying the desired block of content represented by the body.
-            chunk: The chunk of data to be uploaded.
-
-        Returns:
-            The underlying response.
-        """
-        headers = self._get_request_headers(
-            ImageName.parse(urlparse(location).netloc + "/"),
-            {
-                "Content-Range": "{0}-{1}".format(offset, offset + len(chunk) - 1),
-                "Content-Type": "application/octet-stream",
-            },
-        )
-        response = requests.patch(location, headers=headers, data=chunk)
-        must_be_equal(202, response.status_code, "Failed to upload blob chunk")
-
-        return response
-
-    def complete_resumable_blob_upload(
-        self, location, digest: FormattedSHA256, chunk=None
-    ) -> FormattedSHA256:
-        """
-        Finishes a resumable upload of an image blob.
-
-        Args:
-            location:
-                The Resumable location provided from
-                :func:~docker_sign_verify.RegistryV2ImageSource.initiate_resumable_blob_upload.
-            digest: Digest of uploaded blob.
-            chunk: The chunk of data to be uploaded.
-
-        Returns:
-            FormattedSHA256: Digest of the targeted content for the request.
-        """
-        headers = self._get_request_headers(
-            ImageName.parse(urlparse(location).netloc + "/"),
-            {"Content-Type": "application/octet-stream"},
-        )
-        response = requests.put(
-            location, params={"digest": digest}, headers=headers, data=chunk
-        )
-        must_be_equal(
-            201, response.status_code, "Failed to complete resumable blob upload"
-        )
-
-        return FormattedSHA256.parse(response.headers["Docker-Content-Digest"])
-
-    # TODO: Re-implement or remove this and use get_image_layer_to_disk with BytesIO instead.
-    def get_image_layer(self, image_name: ImageName, layer: FormattedSHA256):
-        """
-        Retrieves a single image layer.
-
-        Args:
-            image_name: Name of the image under which the layer exists.
-            layer: The layer identifier in the form: <hash type>:<digest value>.
-
-        Returns:
-            TODO
-        """
-        headers = self._get_request_headers(
-            image_name, {"Accept": RegistryV2ImageSource.BLOB_MIME_TYPE}
-        )
-        url = RegistryV2ImageSource.BLOB_URL_PATTERN.format(
-            image_name.resolve_endpoint(), image_name.resolve_image(), layer
-        )
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-        return response.content
-
-    def get_manifest_list(self, image_name: ImageName = None) -> Manifest:
-        """
-        Retrieves the manifest list for a given image.
-
-        Args:
-            image_name: The name image for which to retrieve the manifest list .
-
-        Returns:
-            The json representation of the manifest list.
-        """
-        headers = self._get_request_headers(
-            image_name, {"Accept": RegistryV2ImageSource.MANIFEST_LIST_MIME_TYPE}
-        )
-        url = RegistryV2ImageSource.MANIFEST_URL_PATTERN.format(
-            image_name.resolve_endpoint(),
-            image_name.resolve_image(),
-            image_name.resolve_tag(),
-        )
-
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-
-        return json.loads(response.content)
-
-    # ImageSource Members
-
-    def get_image_config(self, image_name: ImageName) -> ImageConfig:
-        config_digest = self.get_manifest(image_name).get_config_digest()
-        return ImageConfig(self.get_image_layer(image_name, config_digest))
-
-    def get_image_layer_to_disk(self, image_name: ImageName, layer: str, file):
-        headers = self._get_request_headers(
-            image_name, {"Accept": RegistryV2ImageSource.BLOB_MIME_TYPE}
-        )
-        url = RegistryV2ImageSource.BLOB_URL_PATTERN.format(
-            image_name.resolve_endpoint(), image_name.resolve_image(), layer
-        )
-        response = requests.get(url, headers=headers, stream=True)
-        response.raise_for_status()
-
-        hasher = hashlib.sha256()
-        for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
-            if not chunk:
-                break
-            file.write(chunk)
-            hasher.update(chunk)
-        file.flush()
-        os.fsync(file.fileno())
-
-        size = file.tell()
-
-        # Be kind, rewind ...
-        file.seek(0)
-
-        return {"digest": FormattedSHA256(hasher.hexdigest()), "size": size}
-
-    def get_manifest(self, image_name: ImageName = None) -> Manifest:
-        headers = self._get_request_headers(
-            image_name, {"Accept": RegistryV2ImageSource.MANIFEST_MIME_TYPE}
-        )
-        url = RegistryV2ImageSource.MANIFEST_URL_PATTERN.format(
-            image_name.resolve_endpoint(),
-            image_name.resolve_image(),
-            image_name.resolve_tag(),
-        )
-
-        raw_registry_manifest = None
-        response = requests.get(url, headers=headers)
-        if response.status_code == 404:
-            LOGGER.debug(
-                "Manifest not found; attempting manifest list (arch=%s, os=%s)...",
-                RegistryV2ImageSource.PLATFORM_ARCHITECTURE,
-                RegistryV2ImageSource.PLATFORM_OS,
-            )
-            manifest_list = self.get_manifest_list(image_name)
-            for manifest in manifest_list["manifests"]:
-                platform = manifest["platform"]
-                if (
-                    platform.get("architecture", "")
-                    == RegistryV2ImageSource.PLATFORM_ARCHITECTURE
-                    and platform.get("os", "") == RegistryV2ImageSource.PLATFORM_OS
-                ):
-                    raw_registry_manifest = self.get_image_layer(
-                        image_name, manifest["digest"]
-                    )
-                    break
-        else:
-            raw_registry_manifest = response.content
-
-        return RegistryV2Manifest(raw_registry_manifest)
-
-    def layer_exists(self, image_name: ImageName, layer: FormattedSHA256) -> bool:
-        url = RegistryV2ImageSource.BLOB_URL_PATTERN.format(
-            image_name.resolve_endpoint(), image_name.resolve_image(), layer
-        )
-        headers = self._get_request_headers(image_name)
-        response = requests.head(url, headers=headers)
-
-        return response.status_code in [200, 307]
-
-    def put_image(
-        self,
-        image_source,
-        image_name: ImageName,
-        manifest: Manifest,
-        image_config: ImageConfig,
-        layer_files: List,
-    ):
-        # Replicate all of the image layers ...
-        LOGGER.debug("    Replicating image layers ...")
-        manifest_layers = manifest.get_layers()
-        for i, manifest_layer in enumerate(manifest_layers):
-            if not self.layer_exists(image_name, manifest_layer):
-                if isinstance(image_source, RegistryV2ImageSource):
-                    image_source.put_image_layer_from_disk(image_name, layer_files[i])
-                else:
-                    raise RuntimeError(
-                        "Translation from '{0}' to '{1}' is not supported!".format(
-                            type(image_source), type(self)
-                        )
-                    )
-
-        # Replicate the image configuration ...
-        LOGGER.debug("    Replicating image configuration ...")
-        self.put_image_config(image_name, image_config)
-
-        # Replicate the manifest ...
-        LOGGER.debug("    Replicating image manifest ...")
-        if isinstance(image_source, RegistryV2ImageSource):
-            self.put_manifest(manifest, image_name)
-        else:
-            raise RuntimeError(
-                "Translation from '{0}' to '{1}' is not supported!".format(
-                    type(image_source), type(self)
-                )
-            )
-
-    def put_image_config(self, image_name: ImageName, image_config: ImageConfig):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_config")
-            return
-        if not self.layer_exists(image_name, image_config.get_config_digest()):
-            self.put_image_layer(image_name, image_config.get_config())
-
-    def put_image_layer(self, image_name: ImageName, content):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_layer")
-            return
-
-        # TODO: Why doesn't this work?
-        # self.monolithic_blob_upload(image_name, content)
-
-        digest = formatted_digest(content)
-        location = self.initiate_resumable_blob_upload(image_name).headers["location"]
-        return self.complete_resumable_blob_upload(location, digest, content)
-
-        # TODO: Should we check self.layer_exists(image_name, digest) here, like docker CLI does?
-
-    def put_image_layer_from_disk(self, image_name: ImageName, file):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_layer_from_disk")
-            return
-        location = self.initiate_resumable_blob_upload(image_name).headers["location"]
-
-        file.seek(0)
-        hasher = hashlib.sha256()
-        while True:
-            offset = file.tell()
-            chunk = file.read(CHUNK_SIZE)
-            if not chunk:
-                break
-            location = self.resume_blob_upload(location, offset, chunk).headers[
-                "location"
-            ]
-            hasher.update(chunk)
-        digest = FormattedSHA256(hasher.hexdigest())
-
-        return self.complete_resumable_blob_upload(location, digest)
-
-        # TODO: Should we check self.layer_exists(image_name, digest) here, like docker CLI does?
-
-    def put_manifest(self, manifest: Manifest, image_name: ImageName = None):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_manifest")
-            return
-        url = RegistryV2ImageSource.MANIFEST_URL_PATTERN.format(
-            image_name.resolve_endpoint(),
-            image_name.resolve_image(),
-            image_name.resolve_tag(),
-        )
-        headers = self._get_request_headers(
-            image_name, {"Content-Type": RegistryV2ImageSource.MANIFEST_MIME_TYPE}
-        )
-
-        response = requests.put(
-            url, headers=headers, data=str(manifest).encode("utf-8")
-        )
-        must_be_equal(201, response.status_code, "Failed to upload manifest")
-
-        return response
-
-    def sign_image(
-        self,
-        signer: Signer,
-        src_image_name: ImageName,
-        dest_image_source: ImageSource,
-        dest_image_name: ImageName,
-        signature_type: SignatureTypes = SignatureTypes.SIGN,
-    ):
-        LOGGER.debug(
-            "%s: %s ...",
-            "Endorsing"
-            if signature_type == SignatureTypes.ENDORSE
-            else "Signing"
-            if signature_type == SignatureTypes.SIGN
-            else "Resigning",
-            src_image_name.resolve_name(),
-        )
-
-        # Generate a signed image configuration ...
-        data = self._sign_image_config(signer, src_image_name, signature_type)
-        LOGGER.debug("    Signature:\n%s", data["signature_value"])
-        image_config = data["image_config"]
-        config_digest = image_config.get_config_digest()
-        LOGGER.debug("    config digest (signed): %s", config_digest)
-
-        # Generate a new registry manifest ...
-        manifest = copy.deepcopy(
-            data["verify_image_data"]["manifest"]
-        )  # type: RegistryV2Manifest
-        manifest.set_config_digest(config_digest, len(image_config.get_config()))
-        data["manifest_signed"] = manifest
-
-        dest_image_source.put_image(
-            self,
-            dest_image_name,
-            manifest,
-            image_config,
-            data["verify_image_data"]["compressed_layer_files"],
-        )
-
-        if not self.dry_run:
-            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
-
-        return data
-
-    def verify_image_integrity(self, image_name: ImageName) -> Dict:
-        data = self._verify_image_config(image_name)
-
-        # Reconcile manifest layers and image layers (in order)...
-        compressed_layer_files = []
-        uncompressed_layer_files = []
-        for i, layer in enumerate(data["manifest_layers"]):
-            # Retrieve the registry image layer and verify the digest ...
-            compressed_layer_files.append(tempfile.NamedTemporaryFile())
-            data_compressed = self.get_image_layer_to_disk(
-                image_name, layer, compressed_layer_files[i]
-            )
-            must_be_equal(
-                layer,
-                data_compressed["digest"],
-                "Registry layer[{0}] digest mismatch".format(i),
-            )
-            must_be_equal(
-                os.path.getsize(compressed_layer_files[i].name),
-                data_compressed["size"],
-                "Registry layer[{0}] size mismatch".format(i),
-            )
-
-            # Decompress (convert) the registry image layer into the image layer
-            # and verify the digest ...
-            uncompressed_layer_files.append(tempfile.NamedTemporaryFile())
-            data_uncompressed = gunzip(
-                compressed_layer_files[i], uncompressed_layer_files[i]
-            )
-            must_be_equal(
-                data["image_layers"][i],
-                data_uncompressed["digest"],
-                "Image layer[{0}] digest mismatch".format(i),
-            )
-
-        LOGGER.debug("Integrity check passed.")
-
-        return {
-            "compressed_layer_files": compressed_layer_files,
-            "image_config": data["image_config"],
-            "manifest": data["manifest"],
-            "uncompressed_layer_files": uncompressed_layer_files,
-        }
-
-
-class DeviceMapperRepositoryImageSource(ImageSource):
-    """
-    Docker repository image source.
-    """
-
-    DOCKER_ROOT = Path("/var/lib/docker")
-    DM_CONTENT_ROOT = DOCKER_ROOT.joinpath("image/devicemapper/imagedb/content/sha256")
-    DM_LAYER_ROOT = DOCKER_ROOT.joinpath("image/devicemapper/layerdb/sha256")
-    DM_METADATA_ROOT = DOCKER_ROOT.joinpath("devicemapper/metadata")
-    DM_REPOSITORIES = DOCKER_ROOT.joinpath("image/devicemapper/repositories.json")
-
-    def __init__(self, **kwargs):
-        super(DeviceMapperRepositoryImageSource, self).__init__(**kwargs)
-
-    # ImageSource Members
-
-    def get_image_config(self, image_name: ImageName) -> ImageConfig:
-        config_digest = self.get_manifest().get_config_digest(image_name)
-        path = DeviceMapperRepositoryImageSource.DM_CONTENT_ROOT.joinpath(
-            config_digest.sha256
-        )
-        raw_image_config = read_file(path)
-        return ImageConfig(raw_image_config)
-
-    def get_image_layer_to_disk(self, image_name: ImageName, layer: str, file):
-        # Retrieve the devicemapper metadata ...
-        path = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
-            layer[7:], "cache-id"
-        )
-        cache_id = read_file(path).decode("utf-8")
-        if not cache_id:
-            raise RuntimeError("Unable to find cache id for layer: {0}".format(layer))
-        path = DeviceMapperRepositoryImageSource.DM_METADATA_ROOT.joinpath(cache_id)
-        raw_metadata = read_file(path)
-        metadata = json.loads(raw_metadata)
-        if not (metadata["device_id"] or metadata["size"]):
-            raise RuntimeError(
-                "Unable to find device id and / or size for layer: {0}".format(layer)
-            )
-
-        # Create the devicemapper table ...
-        device_name = "dsv-{0}".format(layer)
-        # TODO: How to we find the vgname?
-        volume_group = "/dev/mapper/rhel-docker--pool"
-        table = "0 {0} thin {1} {2}".format(
-            metadata["size"], volume_group, metadata["device_id"]
-        )
-        subprocess.run(
-            ["/sbin/dmsetup", "create", device_name, "--table", table], check=True
-        )
-
-        # Mount the layer ...
-        mount = Path(tempfile.mkdtemp())
-        subprocess.run(
-            [
-                "mount",
-                "-o",
-                "ro",
-                Path("/dev/mapper").joinpath(device_name),
-                mount.absolute(),
-            ],
-            check=True,
-        )
-
-        # Reconstruct the layer tar ...
-        path = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
-            layer[7:], "tar-split.json.gz"
-        )
-        rootfs = mount.joinpath("rootfs")
-        if not rootfs.exists():
-            raise RuntimeError(
-                "Root filesystem does not exist for layer: {0}".format(layer)
-            )
-        # TODO: Replace this with a pure-python implementation
-        process = subprocess.Popen(
-            ["tar-split", "asm", "--input", str(path), "--path", rootfs],
-            stdout=subprocess.PIPE,
-            stderr=subprocess.DEVNULL,
-        )
-        # TODO: This still buffers the entire file in memory ...
-        result = copy_file(process.stdout, file)
-        return_code = process.wait(120)
-        if return_code != 0:
-            raise RuntimeError("Tar-split failed!")
-
-        # Unmount the layer, remove the devicemapper table, delete the mountpoint ...
-        subprocess.run(["umount", mount.absolute()], check=True)
-        subprocess.run(["/sbin/dmsetup", "remove", device_name], check=True)
-        mount.rmdir()
-
-        return result
-
-    def get_manifest(self, image_name: ImageName = None) -> Manifest:
-        raw_repository_manifest = read_file(
-            DeviceMapperRepositoryImageSource.DM_REPOSITORIES
-        )
-        return DeviceMapperRepositoryManifest(raw_repository_manifest)
-
-    def layer_exists(self, image_name: ImageName, layer: FormattedSHA256) -> bool:
-        result = False
-        path_layer = DeviceMapperRepositoryImageSource.DM_LAYER_ROOT.joinpath(
-            layer.sha256
-        ).joinpath("cache-id")
-        if path_layer.exists():
-            cache_id = read_file(path_layer).decode("utf-8")
-            path_cache = DeviceMapperRepositoryImageSource.DM_METADATA_ROOT.joinpath(
-                cache_id
-            )
-            result = path_cache.exists()
-        return result
-
-    def put_image(
-        self,
-        image_source,
-        image_name: ImageName,
-        manifest: Manifest,
-        image_config: ImageConfig,
-        layer_files: List,
-    ):
-        # TODO: Implement this method, refactor sign_image to use it ...
-        raise NotImplementedError
-
-    def put_image_config(self, image_name: ImageName, image_config: ImageConfig):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_config")
-            return
-        # TODO: Remove debug code
-        # path = DeviceMapperRepositoryImageSource.DM_CONTENT_ROOT.joinpath(image_config.get_config_digest().sha256)
-        path = Path(
-            "/tmp/docker-ridavis/image/devicemapper/imagedb/conent/sha256/"
-        ).joinpath(image_config.get_config_digest().sha256)
-        path.parent.mkdir(exist_ok=True, parents=True)
-        write_file(path, image_config.get_config())
-
-    def put_image_layer(self, image_name: ImageName, content):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_layer")
-            return
-        # TODO: Implement this method ...
-        raise NotImplementedError
-
-    def put_image_layer_from_disk(self, image_name: ImageName, file):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_image_layer_from_disk")
-            return
-        # TODO: Implement this method ...
-        raise NotImplementedError
-
-    def put_manifest(self, manifest: Manifest, image_name: ImageName = None):
-        if self.dry_run:
-            LOGGER.debug("Dry Run: skipping put_manifest")
-            return
-        # TODO: Remove debug code
-        # path = DeviceMapperRepositoryImageSource.DM_REPOSITORIES
-        path = Path("/tmp/docker-ridavis/repositories.json")
-        path.parent.mkdir(exist_ok=True, parents=True)
-        write_file(path, str(manifest).encode("utf-8"))
-
-    def sign_image(
-        self,
-        signer: Signer,
-        src_image_name: ImageName,
-        dest_image_source: ImageSource,
-        dest_image_name: ImageName,
-        signature_type: SignatureTypes = SignatureTypes.SIGN,
-    ):
-        LOGGER.debug(
-            "%s: %s ...",
-            "Endorsing"
-            if signature_type == SignatureTypes.ENDORSE
-            else "Signing"
-            if signature_type == SignatureTypes.SIGN
-            else "Resigning",
-            src_image_name.resolve_name(),
-        )
-
-        # Generate a signed image configuration ...
-        data = self._sign_image_config(signer, src_image_name, signature_type)
-        manifest = data["verify_image_data"]["manifest"]
-        LOGGER.debug("    Signature:\n%s", data["signature_value"])
-        image_config = data["image_config"]
-
-        # Replicate all of the image layers ...
-        LOGGER.debug("    Replicating image layers ...")
-        repository_layers = manifest.get_layers(src_image_name)
-        for i, repository_layer in enumerate(repository_layers):
-            if not dest_image_source.layer_exists(dest_image_name, repository_layer):
-                dest_image_source.put_image_layer_from_disk(
-                    dest_image_name,
-                    data["verify_image_data"]["compressed_layer_files"][i],
-                )
-        # TODO: We we need to track the layer translations here? Is this possible for DM repos?
-
-        # Push the new image configuration ...
-        config_digest_signed = image_config.get_config_digest()
-        LOGGER.debug("    config digest (signed): %s", config_digest_signed)
-        dest_image_source.put_image_config(dest_image_name, image_config)
-
-        # Generate a new repository manifest, and push ...
-        if isinstance(dest_image_source, ArchiveImageSource):
-            raise NotImplementedError
-        elif isinstance(dest_image_source, RegistryV2ImageSource):
-            raise NotImplementedError
-        elif isinstance(dest_image_source, DeviceMapperRepositoryImageSource):
-            manifest_signed = (
-                dest_image_source.get_manifest()
-            )  # type: DeviceMapperRepositoryManifest
-
-            if dest_image_name.tag:
-                manifest_signed.override_config(config_digest_signed, dest_image_name)
-            data["manifest_signed"] = manifest_signed
-            dest_image_source.put_manifest(manifest_signed, dest_image_name)
-        else:
-            raise RuntimeError(
-                "Unknown derived class: {0}".format(type(dest_image_source))
-            )
-
-        if not self.dry_run:
-            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
-
-        return data
-
-    def verify_image_integrity(self, image_name: ImageName) -> Dict:
-        data = self._verify_image_config(image_name)
-
-        # Note: We do not need to reconcile manifest layer ids here, as "we" derived them in
-        # :func:docker_sign_verify.manifests.DeviceMapperRepositoryManifest.get_layers.
-
-        # Reconcile manifest layers and image layers (in order)...
-        uncompressed_layer_files = []
-        for i, layer in enumerate(data["manifest_layers"]):
-            # Retrieve the repository image layer and verify the digest ...
-            uncompressed_layer_files.append(tempfile.NamedTemporaryFile())
-            data_compressed = self.get_image_layer_to_disk(
-                image_name, layer, uncompressed_layer_files[i]
-            )
-            must_be_equal(
-                data["image_layers"][i],
-                data_compressed["digest"],
-                "Repository layer[{0}] digest mismatch".format(i),
-            )
-
-        LOGGER.debug("Integrity check passed.")
-
-        return {
-            "compressed_layer_files": "TODO",
-            "image_config": data["image_config"],
-            "manifest": data["manifest"],
-            "uncompressed_layer_files": uncompressed_layer_files,
-        }
diff --git a/docker_sign_verify/manifest.py b/docker_sign_verify/manifest.py
new file mode 100644
index 0000000..222e4cf
--- /dev/null
+++ b/docker_sign_verify/manifest.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+
+"""Classes that provide abstractions of different image source manifests."""
+
+import abc
+
+from typing import List
+
+from docker_registry_client_async import (
+    FormattedSHA256,
+    ImageName,
+    Manifest as DRCAManifest,
+)
+
+
+class Manifest(abc.ABC, DRCAManifest):
+    """
+    Abstract class to retrieve and manipulate image manifests.
+    """
+
+    def __init__(self, manifest: bytes, *, media_type: str = None, **kwargs):
+        # pylint: disable=useless-super-delegation
+        """
+        Args:
+            manifest: The raw image manifest value.
+            media_type: The media type of the image manifest.
+        """
+        super().__init__(manifest, media_type=media_type, **kwargs)
+
+    @abc.abstractmethod
+    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
+        """
+        Retrieves the image configuration digest value from the image source manifest.
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            The image configuration digest value in the form: <hash type>:<digest value>.
+        """
+
+    @abc.abstractmethod
+    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
+        """
+        Retrieves the listing of manifest layer identifiers.
+
+        Args:
+            image_name: The image name.
+
+        Returns:
+            list: Layer identifiers in the form: <hash type>:<digest value>.
+        """
+
+    # TODO: Move weird, type-specific, manifest logic to these methods
+    # @abc.abstractmethod
+    # def copyfrom(self, src_image_source: ImageSource, dest_image_name, config_digest_signed) -> str:
diff --git a/docker_sign_verify/manifests.py b/docker_sign_verify/manifests.py
deleted file mode 100644
index dd062f6..0000000
--- a/docker_sign_verify/manifests.py
+++ /dev/null
@@ -1,299 +0,0 @@
-#!/usr/bin/env python
-
-"""Classes that provide abstractions of different image source manifests."""
-
-import abc
-import copy
-import json
-
-from pathlib import Path
-from typing import List
-
-from .imageconfig import ImageConfig
-from .imagename import ImageName
-from .utils import formatted_digest, read_file, FormattedSHA256
-
-
-class Manifest(abc.ABC):
-    """
-    Abstract class to retrieve and manipulate image source manifests.
-    """
-
-    def __init__(self, manifest: bytes):
-        """
-        Args:
-            manifest: The raw image source manifest value.
-        """
-        self.json = json.loads(manifest)
-
-    def __str__(self):
-        return json.dumps(self.json)
-
-    @abc.abstractmethod
-    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
-        """
-        Retrieves the image configuration digest value from the image source manifest.
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            The image configuration digest value in the form: <hash type>:<digest value>.
-        """
-
-    @abc.abstractmethod
-    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
-        """
-        Retrieves the listing of manifest layer identifiers.
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            list: Layer identifiers in the form: <hash type>:<digest value>.
-        """
-
-    # TODO: Move weird, type-specific, manifest logic to these methods
-    # @abc.abstractmethod
-    # def copyfrom(self, src_image_source: ImageSource, dest_image_name, config_digest_signed) -> str:
-
-
-class ArchiveManifest(Manifest):
-    """
-    Image source manifest for docker archives.
-    """
-
-    @staticmethod
-    def layer_to_digest(layer: str) -> FormattedSHA256:
-        """
-        Coverts a archive layer identifier to a digest value.
-
-        Args:
-            layer: The archive layer identifier (relative tar path).
-
-        Returns:
-            The corresponding digest value in the form: <hash type>:<digest value>.
-        """
-        return FormattedSHA256(layer[:-10])
-
-    @staticmethod
-    def digest_to_layer(digest: FormattedSHA256) -> str:
-        """
-        Converts a digest value to a archive layer identifier.
-
-        Args:
-            digest: The digest value in the form: <hash type>:<digest value>.
-
-        Returns:
-            The corresponding archive layer identifier (relative tar path).
-        """
-        return "{0}/layer.tar".format(digest.sha256)
-
-    def get_config(self, image_name: ImageName):
-        """
-        Retrieves the image configuration dictionary for a given image name from the image source manifest.
-
-        Args:
-            image_name: The image name.
-
-        Returns:
-            A dictionary as defined by the Docker specification.
-            dict:
-                Config: Image configuration (relative tar path).
-                RepoTags: Docker repository tags.
-                Layers: List of image layer (relative tar paths).
-        """
-        if image_name.tag:
-            repotag = str(image_name)
-            for config in self.json:
-                if config["RepoTags"] and repotag in config["RepoTags"]:
-                    return config
-        else:
-            return [
-                config
-                for config in self.json
-                if config["Config"] == "{0}.json".format(image_name.image)
-            ][0]
-        raise RuntimeError("Unable to locate configuration in archive manifest!")
-
-    def append_config(
-        self,
-        config_digest: FormattedSHA256,
-        layers: List[FormattedSHA256],
-        repotags: List = None,
-    ):
-        """
-        Appends an image configuration dictionary to the image source manifest
-
-        Args:
-            config_digest: Image configuration digest in the form <hash type>:<digest value>.
-            layers: List of image layer identifiers in the form: <hash type>:<digest_value>.
-            repotags: List of docker repository tags.
-        """
-        config = {
-            "Config": "{0}.json".format(config_digest.sha256),
-            "Layers": [ArchiveManifest.digest_to_layer(l) for l in layers],
-        }
-        if repotags:
-            config["RepoTags"] = repotags
-
-        # TODO: Check to make sure it doesn't already exist?
-        self.json.append(config)
-
-    # Manifest Members
-
-    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
-        config = self.get_config(image_name)
-        return FormattedSHA256(config["Config"][:-5])
-
-    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
-        layers = self.get_config(image_name)["Layers"]
-        return [ArchiveManifest.layer_to_digest(l) for l in layers]
-
-
-class RegistryV2Manifest(Manifest):
-    """
-    Manifest list, aka "fat manifest" as defined in:
-
-    https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-2.md
-    """
-
-    def set_config_digest(self, config_digest: FormattedSHA256, size: int):
-        """
-        Assigns the image configuration digest and size in the image source manifest.
-
-        Args:
-            config_digest: Image configuration digest in the form <hash type>:<digest value>.
-            size: Image configuration size in bytes.
-        """
-        if self.json["schemaVersion"] == 2:
-            self.json["config"]["digest"] = config_digest
-            self.json["config"]["size"] = size
-        else:
-            raise RuntimeError(
-                "Unsupported schema version: {0}".format(self.json["schemaVersion"])
-            )
-
-    def set_layers(self, layers: List[FormattedSHA256]):
-        """
-        Assigns the list of manifest layer identifiers.
-
-        Note: It is not the intention of this utility to modify layer content! As such we only support updating the
-              manifest layer identifiers as part of layer replication between two disjoint registries. (i.e.
-              Modification of the overall layer count, order, or individual layer sizes is explicitly not implemented.)
-
-        Args:
-            layers: List of manifest layer identifiers in the form: <hash type>:<digest_value>.
-        """
-        if self.json["schemaVersion"] == 2:
-            for i, layer in enumerate(self.json["layers"]):
-                layer["digest"] = layers[i]
-        else:
-            raise RuntimeError(
-                "Unsupported schema version: {0}".format(self.json["schemaVersion"])
-            )
-
-    # Manifest Members
-
-    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
-        if self.json["schemaVersion"] == 2:
-            result = FormattedSHA256.parse(self.json["config"]["digest"])
-        else:
-            raise RuntimeError(
-                "Unsupported schema version: {0}".format(self.json["schemaVersion"])
-            )
-
-        return result
-
-    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
-        if self.json["schemaVersion"] == 2:
-            result = [FormattedSHA256.parse(l["digest"]) for l in self.json["layers"]]
-        else:
-            raise RuntimeError(
-                "Unsupported schema version: {0}".format(self.json["schemaVersion"])
-            )
-
-        return result
-
-
-class DeviceMapperRepositoryManifest(Manifest):
-    """
-    Image source manifest for docker repositories.
-    """
-
-    @staticmethod
-    def _get_repository_key(image_name: ImageName) -> str:
-        """
-        Retrieves the repository key for a given image name.
-
-        Args:
-            image_name: Image name for which to retrieve the key.
-
-        Returns:
-            The corresponding repository key.
-        """
-        key_name = copy.deepcopy(image_name)
-        key_name.tag = None
-        return str(key_name)
-
-    @staticmethod
-    def get_combined_layerid(
-        parent: FormattedSHA256, layer: FormattedSHA256
-    ) -> FormattedSHA256:
-        """
-        Retrieves the layer identifier for a given parent-layer combination.
-
-        Args:
-            parent: The parent layer identifier.
-            layer:  The layer identifier.
-
-        Returns:
-            The corresponding layer identifier.
-        """
-        result = layer
-        if parent:
-            # Note: The image layer is the digest value of the formatted string.
-            result = formatted_digest("{0} {1}".format(parent, layer).encode("utf-8"))
-        return result
-
-    def override_config(self, config_digest: FormattedSHA256, image_name: ImageName):
-        """
-        Assigns the image configuration digest for a given image name in the image source manifest.
-
-        Args:
-            config_digest: Image configuration digest in the form <hash type>:<digest value>.
-            image_name: The image name.
-        """
-
-        # Note: registry.json can only contain two formats:
-        #       [<endpoint>/]<image>:<tag>
-        #       [<endpoint>/]<image>@<hash type>:<digest value>
-        key = DeviceMapperRepositoryManifest._get_repository_key(image_name)
-        image = self.json["Repositories"].get(key, {})
-        image[str(image_name)] = config_digest
-        self.json["Repositories"][key] = image
-
-    # Manifest Members
-
-    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
-        key = DeviceMapperRepositoryManifest._get_repository_key(image_name)
-        image = self.json["Repositories"].get(key, {})
-        return FormattedSHA256.parse(image.get(str(image_name), None))
-
-    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
-        # TODO: How can this be reimplemented to avoid duplicate code?
-        DM_CONTENT_ROOT = Path(
-            "/var/lib/docker/image/devicemapper/imagedb/content/sha256"
-        )
-        path = DM_CONTENT_ROOT.joinpath(self.get_config_digest(image_name).sha256)
-        image_config = ImageConfig(read_file(path))
-
-        result = []
-        parent = None
-        for layer in image_config.get_image_layers():
-            result.append(
-                DeviceMapperRepositoryManifest.get_combined_layerid(parent, layer)
-            )
-            parent = result[-1]
-
-        return result
diff --git a/docker_sign_verify/pkisigner.py b/docker_sign_verify/pkisigner.py
new file mode 100644
index 0000000..af104ec
--- /dev/null
+++ b/docker_sign_verify/pkisigner.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python
+
+"""Classes that provide signature functionality."""
+
+import base64
+import logging
+import os
+import re
+
+from pathlib import Path
+from typing import Any, List
+
+from Crypto.PublicKey import RSA
+from Crypto.Signature import pkcs1_15
+from Crypto.Signature.pkcs1_15 import PKCS115_SigScheme
+from Crypto.Hash import SHA256
+
+
+from .signer import Signer
+
+LOGGER = logging.getLogger(__name__)
+
+
+# https://pysheeet.readthedocs.io/en/latest/notes/python-security.html
+class PKISigner(Signer):
+    """
+    Creates and verifies docker image signatures using OpenSSL.
+    """
+
+    KEYPAIR = os.environ.get("DSV_PKI_DATASTORE", Path.home().joinpath("dsv.pem"))
+
+    TAG_START = "-----BEGIN PKI SIGNATURE-----"
+    TAG_END = "-----END PKI SIGNATURE-----"
+
+    def __init__(self, *, keypair_path=KEYPAIR, passphrase: str = None):
+        """
+        Reference: https://www.digicert.com/ssl-support/pem-ssl-creation.htm
+        Args:
+            keypair_path: Path to a file containing the entire trust chain and private key, PEM encoded.
+            passphrase: The passphrase used to unlock the private key.
+        """
+        self.keypair_entries = None
+        self.keypair_path = keypair_path
+        self.passphrase = passphrase
+        self.private_signer = None
+        self.public_signer = None
+
+        LOGGER.debug("Using keypair: %s", self.keypair_path)
+
+    async def _debug_init_keypair(self, bits: int = 4096):
+        """
+        Initializes a new PKI certificate pair.
+
+        Args:
+            bits: Entropy to use when generating the private key.
+        """
+        key = RSA.generate(bits)
+        private_key = key.export_key(passphrase=self.passphrase)
+        public_key = key.publickey().export_key()
+
+        # TODO: Convert to async
+        with open(self.keypair_path, "wb") as file:
+            file.write(public_key)
+            file.write(os.linesep.encode("utf-8"))
+            file.write(private_key)
+            file.flush()
+
+    async def get_keypair(self) -> List:
+        """
+        Retrieves the keypair entries from disk.
+
+        Returns:
+            The list of PEM encoded entries.
+        """
+        # TODO: Convert to generator (yeild) and refactore get_private_signer and get_public_signer to use "async for" instead ...
+        if self.keypair_entries is None:
+            # TODO: Convert to async
+            with open(self.keypair_path, "r") as file:
+                content = file.read()
+            self.keypair_entries = re.findall(
+                r"(-{5}BEGIN([^-]+)-{5}.+-{5}END\2-{5})", content, re.DOTALL
+            )
+            self.keypair_entries = [x[0] for x in self.keypair_entries]
+        return self.keypair_entries
+
+    async def get_private_signer(self) -> PKCS115_SigScheme:
+        """
+        Retrieves a private signer, initialized from the first PEM encoded private key entry.
+
+        Returns:
+            A private signer initialized from the first PEM encoded private key entry.
+        """
+        if not self.private_signer:
+            for entry in await self.get_keypair():
+                if "RSA PRIVATE KEY" in entry:
+                    private_rsa_key = RSA.import_key(entry, self.passphrase)
+                    self.private_signer = pkcs1_15.new(private_rsa_key)
+        return self.private_signer
+
+    async def get_public_signer(self) -> PKCS115_SigScheme:
+        """
+        Retrieves a public signer, initialized from the first PEM encoded public key entry.
+
+        Returns:
+            A public signer, initialized from the first PEM encoded public key entry.
+        """
+        if not self.public_signer:
+            for entry in await self.get_keypair():
+                if "CERTIFICATE" in entry or "PUBLIC KEY" in entry:
+                    # TODO: Collect x509 certificate details
+                    # TODO: Is it possible to use OS-level cacerts instead of providing the public key?
+                    public_rsa_key = RSA.import_key(entry)
+                    self.public_signer = pkcs1_15.new(public_rsa_key)
+        return self.public_signer
+
+    # Signer Members
+
+    async def sign(self, data: bytes) -> str:
+        digest = SHA256.new(data)
+        private_signer = await self.get_private_signer()
+        raw_signature = private_signer.sign(digest)
+
+        return "{0}\n\n{1}\n{2}".format(
+            PKISigner.TAG_START,
+            base64.b64encode(raw_signature).decode(),
+            PKISigner.TAG_END,
+        )
+
+    async def verify(self, data: bytes, signature: str) -> Any:
+        # if not self.public_key_path:
+        #    raise RuntimeError("Cannot verify without public key!")
+        buffer = base64.b64decode(signature.split()[3])
+
+        digest = SHA256.new(data)
+        # pylint: disable=not-callable
+        valid = False
+        try:
+            public_signer = await self.get_public_signer()
+            public_signer.verify(digest, buffer)
+            valid = True
+        except ValueError:
+            ...
+
+        # TODO: Refactor this to be a class that is similar to what GPG returns, and update
+        #       imagesource.verify_image_signatures to refelect the changes.
+        return {"keypair_path": self.keypair_path, "type": "pki", "valid": valid}
diff --git a/docker_sign_verify/registryv2imagesource.py b/docker_sign_verify/registryv2imagesource.py
new file mode 100644
index 0000000..36a8f3f
--- /dev/null
+++ b/docker_sign_verify/registryv2imagesource.py
@@ -0,0 +1,289 @@
+#!/usr/bin/env python
+
+"""Classes that provide a source of docker images."""
+
+import logging
+import os
+
+from copy import deepcopy
+from typing import cast, List
+
+from docker_registry_client_async import (
+    DockerRegistryClientAsync,
+    FormattedSHA256,
+    ImageName,
+)
+from docker_registry_client_async.typing import (
+    DockerRegistryClientAsyncPutBlobUpload,
+    DockerRegistryClientAsyncPutManifest,
+)
+from docker_registry_client_async.utils import must_be_equal
+
+from .aiotempfile import open as aiotempfile
+from .imageconfig import ImageConfig, SignatureTypes
+from .imagesource import (
+    ImageSource,
+    ImageSourceGetImageLayerToDisk,
+    ImageSourceSignImage,
+    ImageSourceVerifyImageIntegrity,
+)
+from .manifest import Manifest
+from .registryv2manifest import RegistryV2Manifest
+from .signer import Signer
+from .utils import gunzip
+
+LOGGER = logging.getLogger(__name__)
+
+
+class RegistryV2ImageSource(ImageSource):
+    """
+    Docker registry image source.
+    """
+
+    PLATFORM_ARCHITECTURE = os.environ.get("DSV_ARCHITECTURE", "amd64")
+    PLATFORM_OS = os.environ.get("DSV_OPERATING_SYSTEM", "linux")
+
+    def __init__(self, **kwargs):
+        """
+        Args:
+            credentials_store: Path to the docker registry credentials store.
+        """
+        super().__init__(**kwargs)
+        kwargs.pop("dry_run", None)
+        self.docker_registry_client_async = DockerRegistryClientAsync(**kwargs)
+
+    async def __aenter__(self) -> "RegistryV2ImageSource":
+        return self
+
+    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
+        await self.close()
+
+    async def close(self):
+        """Gracefully closes this instance."""
+        if self.docker_registry_client_async:
+            await self.docker_registry_client_async.close()
+
+    # ImageSource Members
+
+    async def get_image_config(self, image_name: ImageName, **kwargs) -> ImageConfig:
+        manifest = await self.get_manifest(image_name, **kwargs)
+        config_digest = manifest.get_config_digest()
+        response = await self.docker_registry_client_async.get_blob(
+            image_name, config_digest, **kwargs
+        )
+        return ImageConfig(response["blob"])
+
+    async def get_image_layer_to_disk(
+        self, image_name: ImageName, layer: FormattedSHA256, file, **kwargs
+    ) -> ImageSourceGetImageLayerToDisk:
+        return await self.docker_registry_client_async.get_blob_to_disk(
+            image_name, layer, file, **kwargs
+        )
+
+    async def get_manifest(
+        self, image_name: ImageName = None, **kwargs
+    ) -> RegistryV2Manifest:
+        # FIXME: How do we handle manifest lists?
+        response = await self.docker_registry_client_async.get_manifest(
+            image_name, **kwargs
+        )
+        manifest = RegistryV2Manifest.new_from(response["manifest"])
+        return manifest
+
+    async def layer_exists(
+        self, image_name: ImageName, layer: FormattedSHA256, **kwargs
+    ) -> bool:
+        response = await self.docker_registry_client_async.head_blob(
+            image_name, layer, **kwargs
+        )
+        return response["result"]
+
+    async def put_image(
+        self,
+        image_source,
+        image_name: ImageName,
+        manifest: Manifest,
+        image_config: ImageConfig,
+        layer_files: List,
+        **kwargs,
+    ):
+        # Replicate all of the image layers ...
+        LOGGER.debug("    Replicating image layers ...")
+        manifest_layers = manifest.get_layers()
+        for i, manifest_layer in enumerate(manifest_layers):
+            if not await self.layer_exists(image_name, manifest_layer, **kwargs):
+                if isinstance(image_source, RegistryV2ImageSource):
+                    await image_source.put_image_layer_from_disk(
+                        image_name, layer_files[i], **kwargs
+                    )
+                else:
+                    raise NotImplementedError(
+                        "Translation from '{0}' to '{1}' is not supported!".format(
+                            type(image_source), type(self)
+                        )
+                    )
+
+        # Replicate the image configuration ...
+        LOGGER.debug("    Replicating image configuration ...")
+        await self.put_image_config(image_name, image_config, **kwargs)
+
+        # Replicate the manifest ...
+        LOGGER.debug("    Replicating image manifest ...")
+        if isinstance(image_source, RegistryV2ImageSource):
+            await self.put_manifest(manifest, image_name, **kwargs)
+        else:
+            raise NotImplementedError(
+                "Translation from '{0}' to '{1}' is not supported!".format(
+                    type(image_source), type(self)
+                )
+            )
+
+    @ImageSource.check_dry_run
+    async def put_image_config(
+        self, image_name: ImageName, image_config: ImageConfig, **kwargs
+    ):
+        if not await self.layer_exists(image_name, image_config.get_digest(), **kwargs):
+            return await self.put_image_layer(
+                image_name, image_config.get_bytes(), **kwargs
+            )
+
+    @ImageSource.check_dry_run
+    async def put_image_layer(
+        self, image_name: ImageName, content, **kwargs
+    ) -> DockerRegistryClientAsyncPutBlobUpload:
+        response = await self.docker_registry_client_async.post_blob(
+            image_name, **kwargs
+        )
+        digest = FormattedSHA256.calculate(content)
+        return await self.docker_registry_client_async.put_blob_upload(
+            response["location"], digest, data=content, **kwargs
+        )
+        # TODO: Should we check self.layer_exists(image_name, digest) here, like docker CLI does?
+
+    @ImageSource.check_dry_run
+    async def put_image_layer_from_disk(
+        self, image_name: ImageName, file, **kwargs
+    ) -> DockerRegistryClientAsyncPutBlobUpload:
+        response = await self.docker_registry_client_async.post_blob(
+            image_name, **kwargs
+        )
+        # Note: PATCH is needed to retrieve the digest of the local content, needed by POST
+        response = await self.docker_registry_client_async.patch_blob_upload_from_disk(
+            response["location"], file, **kwargs
+        )
+        return await self.docker_registry_client_async.put_blob_upload(
+            response["location"], response["digest"], **kwargs
+        )
+        # TODO: Should we check self.layer_exists(image_name, digest) here, like docker CLI does?
+
+    @ImageSource.check_dry_run
+    async def put_manifest(
+        self, manifest: Manifest, image_name: ImageName = None, **kwargs
+    ) -> DockerRegistryClientAsyncPutManifest:
+        return await self.docker_registry_client_async.put_manifest(
+            image_name, manifest, **kwargs
+        )
+
+    async def sign_image(
+        self,
+        signer: Signer,
+        src_image_name: ImageName,
+        dest_image_source: ImageSource,
+        dest_image_name: ImageName,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        **kwargs,
+    ) -> ImageSourceSignImage:
+        LOGGER.debug(
+            "%s: %s ...",
+            "Endorsing"
+            if signature_type == SignatureTypes.ENDORSE
+            else "Signing"
+            if signature_type == SignatureTypes.SIGN
+            else "Resigning",
+            src_image_name.resolve_name(),
+        )
+
+        if dest_image_name.resolve_digest():
+            dest_image_name.digest = None
+            LOGGER.warning(
+                "It is not possible to store a signed image to a predetermined digest! Adjusted destination: %s",
+                dest_image_name.resolve_name(),
+            )
+
+        # Generate a signed image configuration ...
+        data = await self._sign_image_config(
+            signer, src_image_name, signature_type, **kwargs
+        )
+        LOGGER.debug("    Signature:\n%s", data["signature_value"])
+        image_config = data["image_config"]
+        config_digest = image_config.get_digest()
+        LOGGER.debug("    config digest (signed): %s", config_digest)
+
+        # Generate a new registry manifest ...
+        manifest = deepcopy(data["verify_image_data"]["manifest"])
+        manifest = cast(RegistryV2Manifest, manifest)
+        manifest.set_config_digest(config_digest, len(image_config.get_bytes()))
+        data = cast(ImageSourceSignImage, data)
+        data["manifest_signed"] = manifest
+
+        await dest_image_source.put_image(
+            self,
+            dest_image_name,
+            manifest,
+            image_config,
+            data["verify_image_data"]["compressed_layer_files"],
+            **kwargs,
+        )
+
+        dest_image_name.digest = manifest.get_digest()
+
+        if not self.dry_run:
+            LOGGER.debug("Created new image: %s", dest_image_name.resolve_name())
+
+        return data
+
+    async def verify_image_integrity(
+        self, image_name: ImageName, **kwargs
+    ) -> ImageSourceVerifyImageIntegrity:
+        data = await self._verify_image_config(image_name, **kwargs)
+
+        # Reconcile manifest layers and image layers (in order)...
+        compressed_layer_files = []
+        uncompressed_layer_files = []
+        for i, layer in enumerate(data["manifest_layers"]):
+            # Retrieve the registry image layer and verify the digest ...
+            compressed_layer_files.append(await aiotempfile())
+            data_compressed = await self.get_image_layer_to_disk(
+                image_name, layer, compressed_layer_files[i], **kwargs
+            )
+            must_be_equal(
+                layer,
+                data_compressed["digest"],
+                "Registry layer[{0}] digest mismatch".format(i),
+            )
+            must_be_equal(
+                os.path.getsize(compressed_layer_files[i].name),
+                data_compressed["size"],
+                "Registry layer[{0}] size mismatch".format(i),
+            )
+
+            # Decompress (convert) the registry image layer into the image layer
+            # and verify the digest ...
+            uncompressed_layer_files.append(await aiotempfile())
+            data_uncompressed = await gunzip(
+                compressed_layer_files[i].name, uncompressed_layer_files[i]
+            )
+            must_be_equal(
+                data["image_layers"][i],
+                data_uncompressed["digest"],
+                "Image layer[{0}] digest mismatch".format(i),
+            )
+
+        LOGGER.debug("Integrity check passed.")
+
+        return {
+            "compressed_layer_files": compressed_layer_files,
+            "image_config": data["image_config"],
+            "manifest": data["manifest"],
+            "uncompressed_layer_files": uncompressed_layer_files,
+        }
diff --git a/docker_sign_verify/registryv2manifest.py b/docker_sign_verify/registryv2manifest.py
new file mode 100644
index 0000000..768708c
--- /dev/null
+++ b/docker_sign_verify/registryv2manifest.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python
+
+"""Classes that provide abstractions of different image source manifests."""
+
+from typing import List
+
+from docker_registry_client_async import (
+    DockerMediaTypes,
+    FormattedSHA256,
+    ImageName,
+    OCIMediaTypes,
+)
+
+from .manifest import Manifest
+
+
+class RegistryV2Manifest(Manifest):
+    """
+    Manifest list, aka "fat manifest" as defined in:
+
+    https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-2.md
+    """
+
+    @staticmethod
+    def new_from(manifest: Manifest) -> "RegistryV2Manifest":
+        """
+        Copy constructor.
+
+        Args:
+            manifest: The manifest from which to copy.
+
+        Returns:
+
+        """
+        # TODO: Follow up, and hopefully remove this method in favor of inlining ...
+        # https://stackoverflow.com/questions/3464061/cast-base-class-to-derived-class-python-or-more-pythonic-way-of-extending-class
+        # https://bugs.python.org/issue35048
+        # manifest.__class__ = RegistryV2Manifest
+        # return manifest
+
+        # https://stackoverflow.com/questions/43057218/python-multiple-inheritance-copy-constructor-class-initialization-and-over
+        return RegistryV2Manifest(
+            manifest=manifest.bytes, media_type=manifest.media_type
+        )
+
+    def set_config_digest(self, config_digest: FormattedSHA256, size: int):
+        """
+        Assigns the image configuration digest and size in the image source manifest.
+
+        Args:
+            config_digest: Image configuration digest in the form <hash type>:<digest value>.
+            size: Image configuration size in bytes.
+        """
+        if self.get_media_type() not in [
+            DockerMediaTypes.DISTRIBUTION_MANIFEST_V2,
+            OCIMediaTypes.IMAGE_MANIFEST_V1,
+        ]:
+            raise NotImplementedError(
+                "Unsupported media type: {0}".format(self.get_media_type())
+            )
+
+        json = self.get_json()
+        json["config"]["digest"] = config_digest
+        json["config"]["size"] = size
+        self._set_json(json)
+
+    def set_layers(self, layers: List[FormattedSHA256]):
+        """
+        Assigns the list of manifest layer identifiers.
+
+        Note: It is not the intention of this utility to modify layer content! As such we only support updating the
+              manifest layer identifiers as part of layer replication between two disjoint registries. (i.e.
+              Modification of the overall layer count, order, or individual layer sizes is explicitly not implemented.)
+
+        Args:
+            layers: List of manifest layer identifiers in the form: <hash type>:<digest_value>.
+        """
+        if self.get_media_type() not in [
+            DockerMediaTypes.DISTRIBUTION_MANIFEST_V2,
+            OCIMediaTypes.IMAGE_MANIFEST_V1,
+        ]:
+            raise NotImplementedError(
+                "Unsupported media type: {0}".format(self.get_media_type())
+            )
+
+        json = self.get_json()
+        for i, layer in enumerate(json["layers"]):
+            layer["digest"] = layers[i]
+        self._set_json(json)
+
+    # Manifest Members
+
+    def get_config_digest(self, image_name: ImageName = None) -> FormattedSHA256:
+        if self.get_media_type() not in [
+            DockerMediaTypes.DISTRIBUTION_MANIFEST_V2,
+            OCIMediaTypes.IMAGE_MANIFEST_V1,
+        ]:
+            raise NotImplementedError(
+                "Unsupported media type: {0}".format(self.get_media_type())
+            )
+
+        return FormattedSHA256.parse(self.get_json()["config"]["digest"])
+
+    def get_layers(self, image_name: ImageName = None) -> List[FormattedSHA256]:
+        if self.get_media_type() not in [
+            DockerMediaTypes.DISTRIBUTION_MANIFEST_V2,
+            OCIMediaTypes.IMAGE_MANIFEST_V1,
+        ]:
+            raise NotImplementedError(
+                "Unsupported media type: {0}".format(self.get_media_type())
+            )
+
+        return [
+            FormattedSHA256.parse(layer["digest"])
+            for layer in self.get_json()["layers"]
+        ]
diff --git a/docker_sign_verify/scripts/common.py b/docker_sign_verify/scripts/common.py
index f1700f0..1c62b9c 100755
--- a/docker_sign_verify/scripts/common.py
+++ b/docker_sign_verify/scripts/common.py
@@ -5,22 +5,47 @@
 import logging
 import sys
 
+from functools import wraps
+
+import asyncio
 import click
 
 from docker_sign_verify import __version__
 
+LOGGING_DEFAULT = 2
+
+
+def async_command(func):
+    """Asynchronous command wrapper that allows click commands to be async."""
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        # pylint: disable=no-member,protected-access
+        coroutine = func(*args, **kwargs)
+        event_loop = asyncio._get_running_loop()
+        # Allow func to be called, or awaited ...
+        if event_loop is None:
+            return asyncio.run(coroutine)
+        return coroutine
+
+    return wrapper
+
 
 def logging_options(function):
     """Common logging options."""
 
     function = click.option(
-        "-s", "--silent", "verbosity", flag_value=0, help="Suppress all output."
+        "-s",
+        "--silent",
+        "verbosity",
+        flag_value=LOGGING_DEFAULT - 2,
+        help="Suppress all output.",
     )(function)
     function = click.option(
         "-q",
         "--quiet",
         "verbosity",
-        flag_value=1,
+        flag_value=LOGGING_DEFAULT - 1,
         help="Restrict output to warnings and errors.",
     )(function)
     function = click.option(
@@ -29,17 +54,21 @@ def logging_options(function):
         "-v",
         "--verbose",
         "verbosity",
-        flag_value=3,
+        flag_value=LOGGING_DEFAULT + 1,
         help="Show debug logging.",
     )(function)
     function = click.option(
-        "-vv", "--very-verbose", "verbosity", flag_value=4, help="Enable all logging."
+        "-vv",
+        "--very-verbose",
+        "verbosity",
+        flag_value=LOGGING_DEFAULT + 2,
+        help="Enable all logging.",
     )(function)
 
     return function
 
 
-def set_log_levels(verbosity):
+def set_log_levels(verbosity: int = LOGGING_DEFAULT):
     """
     Assigns the logging levels in a consistent way.
 
@@ -47,33 +76,32 @@ def set_log_levels(verbosity):
         verbosity: The logging verbosity level from  0 (least verbose) to 4 (most verbose).
     """
     levels = {
-        0: logging.FATAL,
+        0: logging.FATAL + 10,
         1: logging.WARNING,
         2: logging.INFO,
         3: logging.DEBUG,
         4: logging.NOTSET,
     }
 
-    if verbosity is None:
-        verbosity = 2
-
-    format = None
-    if verbosity < 3:
-        format = "%(message)s"
+    _format = None
+    # normal, quiet, silent ...
+    if verbosity <= LOGGING_DEFAULT:
+        _format = "%(message)s"
         logging.getLogger("gnupg").setLevel(logging.FATAL)
-    elif verbosity == 3:
-        format = "%(asctime)s %(levelname)-8s %(message)s"
+    # debug / verbose ...
+    elif verbosity == LOGGING_DEFAULT + 1:
+        _format = "%(asctime)s %(levelname)-8s %(message)s"
         logging.getLogger("gnupg").setLevel(logging.WARNING)
-        logging.getLogger("requests").setLevel(logging.WARNING)
         logging.getLogger("urllib3").setLevel(logging.WARNING)
+    # very verbose ...
     else:
-        # format = "%(asctime)s.%(msecs)d %(levelname)-8s [%(name)s.%(funcName)s:%(lineno)d]  %(message)s"
-        format = "%(asctime)s.%(msecs)d %(levelname)-8s %(name)s %(message)s"
+        # _format = "%(asctime)s.%(msecs)d %(levelname)-8s %(name)s %(message)s"
+        _format = "%(asctime)s.%(msecs)d %(levelname)-8s [%(name)s.%(funcName)s:%(lineno)d] %(message)s"
 
     logging.basicConfig(
         stream=sys.stdout,
         level=levels[verbosity],
-        format=format,
+        format=_format,
         datefmt="%Y-%m-%d %H:%M:%S",
     )
 
@@ -81,5 +109,4 @@ def set_log_levels(verbosity):
 @click.command()
 def version():
     """Displays the utility version."""
-
     print(__version__)
diff --git a/docker_sign_verify/scripts/docker_copy.py b/docker_sign_verify/scripts/docker_copy.py
index a99721a..bc6316b 100755
--- a/docker_sign_verify/scripts/docker_copy.py
+++ b/docker_sign_verify/scripts/docker_copy.py
@@ -3,25 +3,51 @@
 """Docker copy command line interface."""
 
 import logging
+import sys
+
+from traceback import print_exception
+from typing import cast, TypedDict
 
 import click
-import urllib3
 
+from click.core import Context
+from docker_registry_client_async import ImageName
 from docker_sign_verify import (
     ArchiveImageSource,
     DeviceMapperRepositoryImageSource,
-    ImageName,
+    ImageSource,
     RegistryV2ImageSource,
 )
 
-from .common import logging_options, set_log_levels, version
-from .docker_verify import verify
+from .common import (
+    async_command,
+    LOGGING_DEFAULT,
+    logging_options,
+    set_log_levels,
+    version,
+)
+from .docker_verify import (
+    _verify,
+    TypingContextObject as DockerVerifyTypingContextObject,
+)
 from .utils import to_image_name
 
 LOGGER = logging.getLogger(__name__)
 
-# Bug Fix: There isn't anything we can do about mis-configured remote certificates ...
-urllib3.disable_warnings(urllib3.exceptions.SecurityWarning)
+
+class TypingContextObject(TypedDict):
+    # pylint: disable=missing-class-docstring
+    check_signatures: bool
+    dry_run: bool
+    verbosity: int
+    src_image_name: ImageName
+    dest_image_name: ImageName
+    imagesource: ImageSource
+
+
+def get_context_object(context: Context) -> TypingContextObject:
+    """Wrapper method to enforce type checking."""
+    return context.obj
 
 
 def copy_options(function):
@@ -37,31 +63,46 @@ def copy_options(function):
     return function
 
 
-def copy(context):
+@async_command
+async def copy(context: Context):
     """Copies and image."""
-    context.obj["images"] = [context.obj["src_image_name"]]
-    result = verify(context)[0]
-
-    context.obj["imagesource"].put_image(
-        context.obj["imagesource"],
-        context.obj["dest_image_name"],
-        result["manifest"],
-        result["image_config"],
-        # TODO: Select compressed_layer_files vs uncompressed_layer_files based on type(imagesource).
-        result["compressed_layer_files"],
-    )
-    if context.obj["dry_run"]:
-        LOGGER.info(
-            "Dry run completed for image: %s (%s)",
-            context.obj["dest_image_name"].resolve_name(),
-            result["image_config"].get_config_digest(),
-        )
-    else:
-        LOGGER.info(
-            "Created new image: %s (%s)",
-            context.obj["dest_image_name"].resolve_name(),
-            result["image_config"].get_config_digest(),
+
+    ctx = get_context_object(context)
+    try:
+        ctx_verify = cast(DockerVerifyTypingContextObject, ctx)
+        ctx_verify["images"] = [ctx["src_image_name"]]
+        result = await _verify(ctx)
+        result = result[0]
+
+        await ctx["imagesource"].put_image(
+            ctx["imagesource"],
+            ctx["dest_image_name"],
+            result["manifest"],
+            result["image_config"],
+            # TODO: Select compressed_layer_files vs uncompressed_layer_files based on type(imagesource).
+            result["compressed_layer_files"],
         )
+        if ctx["dry_run"]:
+            LOGGER.info(
+                "Dry run completed for image: %s (%s)",
+                ctx["dest_image_name"].resolve_name(),
+                result["image_config"].get_digest(),
+            )
+        else:
+            LOGGER.info(
+                "Replicated new image: %s (%s)",
+                ctx["dest_image_name"].resolve_name(),
+                result["image_config"].get_digest(),
+            )
+    except Exception as exception:  # pylint: disable=broad-except
+        if ctx["verbosity"] > 0:
+            logging.fatal(exception)
+        if ctx["verbosity"] > LOGGING_DEFAULT:
+            exc_info = sys.exc_info()
+            print_exception(*exc_info)
+        sys.exit(1)
+    finally:
+        await ctx["imagesource"].close()
 
 
 @click.group()
@@ -76,14 +117,24 @@ def copy(context):
 )
 @logging_options
 @click.pass_context
-def cli(context, check_signatures: bool, dry_run: False, verbosity: int = 2):
-    """
-    Replicates docker images while verifying embedded signatures, and the integrity of docker image layers and metadata.
-    """
+def cli(
+    context: Context,
+    check_signatures: bool,
+    dry_run: False,
+    verbosity: int = LOGGING_DEFAULT,
+):
+    """Replicates docker images while verifying embedded signatures, and the integrity of docker image layers and metadata."""
+
+    if verbosity is None:
+        verbosity = LOGGING_DEFAULT
 
     set_log_levels(verbosity)
 
-    context.obj = {"check_signatures": check_signatures, "dry_run": dry_run}
+    context.obj = {
+        "check_signatures": check_signatures,
+        "dry_run": dry_run,
+        "verbosity": verbosity,
+    }
 
 
 @cli.command()
@@ -98,41 +149,43 @@ def cli(context, check_signatures: bool, dry_run: False, verbosity: int = 2):
 @click.pass_context
 # pylint: disable=redefined-outer-name
 def archive(
-    context, src_image_name: ImageName, dest_image_name: ImageName, archive: str
+    context: Context,
+    src_image_name: ImageName,
+    dest_image_name: ImageName,
+    archive: str,
 ):
     """Operates on docker-save produced archives."""
 
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = ArchiveImageSource(
-        archive=archive, dry_run=context.obj["dry_run"]
-    )
+    ctx = get_context_object(context)
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = ArchiveImageSource(archive=archive, dry_run=ctx["dry_run"])
     copy(context)
 
 
 @cli.command()
 @copy_options
 @click.pass_context
-def registry(context, src_image_name: ImageName, dest_image_name: ImageName):
+def registry(context: Context, src_image_name: ImageName, dest_image_name: ImageName):
     """Operates on docker registries (v2)."""
 
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = RegistryV2ImageSource(dry_run=context.obj["dry_run"])
+    ctx = get_context_object(context)
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = RegistryV2ImageSource(dry_run=ctx["dry_run"])
     copy(context)
 
 
 @cli.command()
 @copy_options
 @click.pass_context
-def repository(context, src_image_name: ImageName, dest_image_name: ImageName):
+def repository(context: Context, src_image_name: ImageName, dest_image_name: ImageName):
     """Operates on docker repositories."""
 
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = DeviceMapperRepositoryImageSource(
-        dry_run=context.obj["dry_run"]
-    )
+    ctx = get_context_object(context)
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = DeviceMapperRepositoryImageSource(dry_run=ctx["dry_run"])
     copy(context)
 
 
diff --git a/docker_sign_verify/scripts/docker_sign.py b/docker_sign_verify/scripts/docker_sign.py
index 60aa216..b7e65a2 100755
--- a/docker_sign_verify/scripts/docker_sign.py
+++ b/docker_sign_verify/scripts/docker_sign.py
@@ -4,25 +4,52 @@
 
 import logging
 import os
+import sys
+
+from traceback import print_exception
+from typing import TypedDict
 
 import click
-import urllib3
 
+from click.core import Context
+from docker_registry_client_async import ImageName
 from docker_sign_verify import (
     ArchiveImageSource,
     DeviceMapperRepositoryImageSource,
-    ImageName,
+    ImageSource,
     RegistryV2ImageSource,
     SignatureTypes,
 )
-
-from .common import logging_options, set_log_levels, version
+from docker_sign_verify.imagesource import ImageSourceSignImage
+
+from .common import (
+    async_command,
+    LOGGING_DEFAULT,
+    logging_options,
+    set_log_levels,
+    version,
+)
 from .utils import to_image_name, HiddenPassword
 
 LOGGER = logging.getLogger(__name__)
 
-# Bug Fix: There isn't anything we can do about mis-configured remote certificates ...
-urllib3.disable_warnings(urllib3.exceptions.SecurityWarning)
+
+class TypingContextObject(TypedDict):
+    # pylint: disable=missing-class-docstring
+    dest_image_name: ImageName
+    dry_run: bool
+    imagesource: ImageSource
+    keyid: str
+    keypass: str
+    signature_type: str
+    sigtype: str
+    src_image_name: ImageName
+    verbosity: int
+
+
+def get_context_object(context: Context) -> TypingContextObject:
+    """Wrapper method to enforce type checking."""
+    return context.obj
 
 
 def sign_options(function):
@@ -64,39 +91,57 @@ def sign_options(function):
     return function
 
 
-def sign(context):
+@async_command
+async def sign(context: Context) -> ImageSourceSignImage:
     """Signs an image."""
 
-    if context.obj["sigtype"] == "gpg" and (
-        "." in context.obj["keyid"] or "/" in context.obj["keyid"]
-    ):
-        LOGGER.warning("Key identifier looks like path, but signature type is GPG!")
-
-    signer_module = __import__("docker_sign_verify.signers")
-    signer_class = getattr(
-        signer_module, "{0}Signer".format(context.obj["sigtype"].upper())
-    )
-    signer = signer_class(context.obj["keyid"], context.obj["keypass"])
-
-    result = context.obj["imagesource"].sign_image(
-        signer,
-        context.obj["src_image_name"],
-        context.obj["imagesource"],
-        context.obj["dest_image_name"],
-        SignatureTypes[context.obj["signature_type"].upper()],
-    )
-    if context.obj["dry_run"]:
-        LOGGER.info(
-            "Dry run completed for image: %s (%s)",
-            context.obj["dest_image_name"].resolve_name(),
-            result["image_config"].get_config_digest(),
-        )
-    else:
-        LOGGER.info(
-            "Created new image: %s (%s)",
-            context.obj["dest_image_name"].resolve_name(),
-            result["image_config"].get_config_digest(),
+    result = None
+
+    ctx = get_context_object(context)
+    try:
+        if ctx["sigtype"] == "gpg" and ("." in ctx["keyid"] or "/" in ctx["keyid"]):
+            LOGGER.warning("Key identifier looks like path, but signature type is GPG!")
+
+        # TODO: Do we still need to use reflection here to avoid circular dependencies?
+        module = __import__(__package__)
+        sigtype = f"{ctx['sigtype'].upper()}Signer"
+        if sigtype == "GPGSigner":
+            signer_class = getattr(module, sigtype)
+            signer = signer_class(keyid=ctx["keyid"], passphrase=ctx["keypass"])
+        elif sigtype == "PKISigner":
+            signer_class = getattr(module, sigtype)
+            signer = signer_class(keypair_path=ctx["keyid"], passphrase=ctx["keypass"])
+        else:
+            raise RuntimeError(f"Unknown signature type: {ctx['sigtype']}!")
+
+        result = await ctx["imagesource"].sign_image(
+            signer,
+            ctx["src_image_name"],
+            ctx["imagesource"],
+            ctx["dest_image_name"],
+            SignatureTypes[ctx["signature_type"].upper()],
         )
+        if ctx["dry_run"]:
+            LOGGER.info(
+                "Dry run completed for image: %s (%s)",
+                ctx["dest_image_name"].resolve_name(),
+                result["image_config"].get_digest(),
+            )
+        else:
+            LOGGER.info(
+                "Created new image: %s (%s)",
+                ctx["dest_image_name"].resolve_name(),
+                result["image_config"].get_digest(),
+            )
+    except Exception as exception:  # pylint: disable=broad-except
+        if ctx["verbosity"] > 0:
+            logging.fatal(exception)
+        if ctx["verbosity"] > LOGGING_DEFAULT:
+            exc_info = sys.exc_info()
+            print_exception(*exc_info)
+        sys.exit(1)
+    finally:
+        await ctx["imagesource"].close()
 
     return result
 
@@ -114,11 +159,23 @@ def sign(context):
 )
 @logging_options
 @click.pass_context
-def cli(context, dry_run: False, signature_type: "sign", verbosity: int = 2):
+def cli(
+    context: Context,
+    dry_run: False,
+    signature_type: "sign",
+    verbosity: int = LOGGING_DEFAULT,
+):
     """Creates and embeds signatures into docker images."""
 
+    if verbosity is None:
+        verbosity = LOGGING_DEFAULT
+
     set_log_levels(verbosity)
-    context.obj = {"dry_run": dry_run, "signature_type": signature_type}
+    context.obj = {
+        "dry_run": dry_run,
+        "signature_type": signature_type,
+        "verbosity": verbosity,
+    }
 
 
 @cli.command()
@@ -133,7 +190,7 @@ def cli(context, dry_run: False, signature_type: "sign", verbosity: int = 2):
 @click.pass_context
 # pylint: disable=redefined-outer-name,too-many-arguments
 def archive(
-    context,
+    context: Context,
     keyid: str,
     keypass: str,
     sigtype: str,
@@ -143,14 +200,13 @@ def archive(
 ):
     """Operates on docker-save produced archives."""
 
-    context.obj["keyid"] = keyid
-    context.obj["keypass"] = keypass
-    context.obj["sigtype"] = sigtype
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = ArchiveImageSource(
-        archive=archive, dry_run=context.obj["dry_run"]
-    )
+    ctx = get_context_object(context)
+    ctx["keyid"] = keyid
+    ctx["keypass"] = keypass
+    ctx["sigtype"] = sigtype
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = ArchiveImageSource(archive=archive, dry_run=ctx["dry_run"])
     sign(context)
 
 
@@ -159,7 +215,7 @@ def archive(
 @click.pass_context
 # pylint: disable=too-many-arguments
 def registry(
-    context,
+    context: Context,
     keyid: str,
     keypass: str,
     sigtype: str,
@@ -168,12 +224,13 @@ def registry(
 ):
     """Operates on docker registries (v2)."""
 
-    context.obj["keyid"] = keyid
-    context.obj["keypass"] = keypass
-    context.obj["sigtype"] = sigtype
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = RegistryV2ImageSource(dry_run=context.obj["dry_run"])
+    ctx = get_context_object(context)
+    ctx["keyid"] = keyid
+    ctx["keypass"] = keypass
+    ctx["sigtype"] = sigtype
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = RegistryV2ImageSource(dry_run=ctx["dry_run"])
     sign(context)
 
 
@@ -182,7 +239,7 @@ def registry(
 @click.pass_context
 # pylint: disable=too-many-arguments
 def repository(
-    context,
+    context: Context,
     keyid: str,
     keypass: str,
     sigtype: str,
@@ -191,14 +248,13 @@ def repository(
 ):
     """Operates on docker repositories."""
 
-    context.obj["keyid"] = keyid
-    context.obj["keypass"] = keypass
-    context.obj["sigtype"] = sigtype
-    context.obj["src_image_name"] = src_image_name
-    context.obj["dest_image_name"] = dest_image_name
-    context.obj["imagesource"] = DeviceMapperRepositoryImageSource(
-        dry_run=context.obj["dry_run"]
-    )
+    ctx = get_context_object(context)
+    ctx["keyid"] = keyid
+    ctx["keypass"] = keypass
+    ctx["sigtype"] = sigtype
+    ctx["src_image_name"] = src_image_name
+    ctx["dest_image_name"] = dest_image_name
+    ctx["imagesource"] = DeviceMapperRepositoryImageSource(dry_run=ctx["dry_run"])
     sign(context)
 
 
diff --git a/docker_sign_verify/scripts/docker_verify.py b/docker_sign_verify/scripts/docker_verify.py
index d709101..be4e47f 100755
--- a/docker_sign_verify/scripts/docker_verify.py
+++ b/docker_sign_verify/scripts/docker_verify.py
@@ -3,25 +3,46 @@
 """Docker verify command line interface."""
 
 import logging
+import sys
 
-from typing import List
+from traceback import print_exception
+from typing import List, TypedDict
 
 import click
-import urllib3
 
+from click.core import Context
+from docker_registry_client_async import ImageName
 from docker_sign_verify import (
     ArchiveImageSource,
     DeviceMapperRepositoryImageSource,
+    ImageSource,
     RegistryV2ImageSource,
 )
-
-from .common import logging_options, set_log_levels, version
+from docker_sign_verify.imagesource import ImageSourceVerifyImageSignatures
+
+from .common import (
+    async_command,
+    LOGGING_DEFAULT,
+    logging_options,
+    set_log_levels,
+    version,
+)
 from .utils import to_image_name
 
 LOGGER = logging.getLogger(__name__)
 
-# Bug Fix: There isn't anything we can do about mis-configured remote certificates ...
-urllib3.disable_warnings(urllib3.exceptions.SecurityWarning)
+
+class TypingContextObject(TypedDict):
+    # pylint: disable=missing-class-docstring
+    check_signatures: bool
+    verbosity: int
+    images: List[ImageName]
+    imagesource: ImageSource
+
+
+def get_context_object(context: Context) -> TypingContextObject:
+    """Wrapper method to enforce type checking."""
+    return context.obj
 
 
 def verify_options(function):
@@ -33,32 +54,55 @@ def verify_options(function):
     return function
 
 
-def verify(context):
+async def _verify(ctx: TypingContextObject) -> List[ImageSourceVerifyImageSignatures]:
     """Verifies an image(s)."""
 
     results = []
-    for image_name in context.obj["images"]:
-        if context.obj["check_signatures"]:
-            result = context.obj["imagesource"].verify_image_signatures(image_name)
+
+    for image_name in ctx["images"]:
+        if ctx["check_signatures"]:
+            result = await ctx["imagesource"].verify_image_signatures(image_name)
             LOGGER.info(
-                "Image %s (%s) is consistent.",
+                "Image %s (%s) is consistent; %d signature(s) verified.",
                 image_name.resolve_name(),
-                result["image_config"].get_config_digest(),
+                result["image_config"].get_digest(),
+                len(result["signatures"]["signatures"]),
             )
-            LOGGER.info("Image %s signature(s) verified.", len(result["signatures"]))
         else:
-            result = context.obj["imagesource"].verify_image_integrity(image_name)
+            result = await ctx["imagesource"].verify_image_integrity(image_name)
             LOGGER.info(
                 "Image %s (%s) is consistent.",
                 image_name.resolve_name(),
-                result["image_config"].get_config_digest(),
+                result["image_config"].get_digest(),
             )
-            LOGGER.info("Image signature(s) not verified.")
+            LOGGER.info("Image signature(s) NOT verified.")
         results.append(result)
 
     return results
 
 
+@async_command
+async def verify(context: Context) -> List[ImageSourceVerifyImageSignatures]:
+    """Verifies an image(s)."""
+
+    results = []
+
+    ctx = get_context_object(context)
+    try:
+        results = await _verify(ctx)
+    except Exception as exception:  # pylint: disable=broad-except
+        if ctx["verbosity"] > 0:
+            logging.fatal(exception)
+        if ctx["verbosity"] > LOGGING_DEFAULT:
+            exc_info = sys.exc_info()
+            print_exception(*exc_info)
+        sys.exit(1)
+    finally:
+        await ctx["imagesource"].close()
+
+    return results
+
+
 @click.group()
 @click.option(
     "--check-signatures/--no-check-signatures",
@@ -68,11 +112,14 @@ def verify(context):
 )
 @logging_options
 @click.pass_context
-def cli(context, check_signatures: bool, verbosity: int = 2):
+def cli(context: Context, check_signatures: bool, verbosity: int = LOGGING_DEFAULT):
     """Verifies embedded signatures, and the integrity of docker image layers and metadata."""
 
+    if verbosity is None:
+        verbosity = LOGGING_DEFAULT
+
     set_log_levels(verbosity)
-    context.obj = {"check_signatures": check_signatures}
+    context.obj = {"check_signatures": check_signatures, "verbosity": verbosity}
 
 
 @cli.command()
@@ -86,33 +133,36 @@ def cli(context, check_signatures: bool, verbosity: int = 2):
 @verify_options
 @click.pass_context
 # pylint: disable=redefined-outer-name
-def archive(context, images: List, archive: str):
+def archive(context: Context, images: List[ImageName], archive: str):
     """Operates on docker-save produced archives."""
 
-    context.obj["images"] = images
-    context.obj["imagesource"] = ArchiveImageSource(archive=archive)
+    ctx = get_context_object(context)
+    ctx["images"] = images
+    ctx["imagesource"] = ArchiveImageSource(archive=archive)
     verify(context)
 
 
 @cli.command()
 @verify_options
 @click.pass_context
-def registry(context, images: List):
+def registry(context: Context, images: List[ImageName]):
     """Operates on docker registries (v2)."""
 
-    context.obj["images"] = images
-    context.obj["imagesource"] = RegistryV2ImageSource()
+    ctx = get_context_object(context)
+    ctx["images"] = images
+    ctx["imagesource"] = RegistryV2ImageSource()
     verify(context)
 
 
 @cli.command()
 @verify_options
 @click.pass_context
-def repository(context, images: List):
+def repository(context: Context, images: List[ImageName]):
     """Operates on docker repositories."""
 
-    context.obj["images"] = images
-    context.obj["imagesource"] = DeviceMapperRepositoryImageSource()
+    ctx = get_context_object(context)
+    ctx["images"] = images
+    ctx["imagesource"] = DeviceMapperRepositoryImageSource()
     verify(context)
 
 
diff --git a/docker_sign_verify/scripts/utils.py b/docker_sign_verify/scripts/utils.py
index 1794d0b..f41e5b2 100755
--- a/docker_sign_verify/scripts/utils.py
+++ b/docker_sign_verify/scripts/utils.py
@@ -3,7 +3,7 @@
 """Utility classes."""
 
 
-from docker_sign_verify import ImageName
+from docker_registry_client_async import ImageName
 
 
 # pylint: disable=unused-argument
diff --git a/docker_sign_verify/signer.py b/docker_sign_verify/signer.py
new file mode 100644
index 0000000..8867565
--- /dev/null
+++ b/docker_sign_verify/signer.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python
+
+"""Classes that provide signature functionality."""
+
+import abc
+import logging
+
+from typing import Any
+
+from .exceptions import UnsupportedSignatureTypeError
+
+LOGGER = logging.getLogger(__name__)
+
+
+class Signer(abc.ABC):
+    """
+    Abstract class to create and verify docker image signatures.
+    """
+
+    @staticmethod
+    def for_signature(signature: str) -> "Signer":
+        """
+        Retrieves a signer that can be used to verify a given signature value.
+
+        Args:
+            signature: The PEM encoded signature value for which to retrieve the signer.
+
+        Returns:
+            The corresponding signer.
+        """
+        result = None
+
+        module = __import__(__package__)
+        if "PGP SIGNATURE" in signature:
+            result = getattr(module, "GPGSigner")()
+        elif "PKI SIGNATURE" in signature:
+            result = getattr(module, "PKISigner")()
+
+        if not result:
+            raise UnsupportedSignatureTypeError(signature=signature)
+
+        return result
+
+    @abc.abstractmethod
+    async def sign(self, data: bytes) -> str:
+        """
+        Signs given data.
+
+        Args:
+            data: The data to be signed.
+
+        Returns:
+            A PEM encoded signature value.
+        """
+
+    @abc.abstractmethod
+    async def verify(self, data: bytes, signature: str) -> Any:
+        """
+        Verifies data against a given signature.
+
+        Args:
+            data: The data to be verified.
+            signature: The signature against which to verify the data.
+
+        Returns:
+            Signer-specific result value.
+        """
diff --git a/docker_sign_verify/signers.py b/docker_sign_verify/signers.py
deleted file mode 100644
index c2184ea..0000000
--- a/docker_sign_verify/signers.py
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/usr/bin/env python
-
-"""Classes that provide signature functionality."""
-
-# TODO: Update pycharm version and test
-# from __future__ import annotations
-
-import abc
-import base64
-import logging
-import io
-import os
-import re
-import tempfile
-
-from pathlib import Path
-from typing import List
-
-import gnupg
-
-from Crypto.PublicKey import RSA
-from Crypto.Signature import PKCS1_v1_5
-from Crypto.Signature.pkcs1_15 import PKCS115_SigScheme
-from Crypto.Hash import SHA256
-
-LOGGER = logging.getLogger(__name__)
-
-
-class Signer(abc.ABC):
-    """
-    Abstract class to create and verify docker image signatures.
-    """
-
-    @staticmethod
-    def for_signature(signature: str):
-        """
-        Retrieves a signer that can be used to verify a given signature value.
-
-        Args:
-            signature: The PEM encoded signature value for which to retrieve the signer.
-
-        Returns:
-            The corresponding signer.
-        """
-        result = None
-
-        if "PGP SIGNATURE" in signature:
-            result = GPGSigner()
-        elif "PKI SIGNATURE" in signature:
-            result = PKISigner()
-
-        if not result:
-            raise RuntimeError("Unsupported signature type!")
-
-        return result
-
-    @abc.abstractmethod
-    def sign(self, data: bytes) -> str:
-        """
-        Signs given data.
-
-        Args:
-            data: The data to be signed.
-
-        Returns:
-            A PEM encoded signature value.
-        """
-
-    @abc.abstractmethod
-    def verify(self, data: bytes, signature: bytes):
-        """
-        Verifies data against a given signature.
-
-        Args:
-            data: The data to be verified.
-            signature: The signature against which to verify the data.
-
-        Returns:
-            Signer-specific result value.
-        """
-
-
-class GPGSigner(Signer):
-    """
-    Creates and verifies docker image signatures using GnuPG.
-    """
-
-    HOMEDIR = os.environ.get("DSV_GPG_DATASTORE", Path.home().joinpath(".gnupg"))
-
-    def __init__(self, keyid: str = None, passphrase: str = None, homedir: str = ""):
-        """
-        Args:
-            keyid: The GPG key identifier, only required for signing.
-            passphrase: The passphrase used to unlock the GPG key.
-            homedir: The GPG home directory (default: ~/.gnupg).
-        """
-        self.keyid = keyid
-        self.passphrase = passphrase
-        self.homedir = homedir if homedir != "" else GPGSigner.HOMEDIR
-
-        LOGGER.debug("Using trust store: %s", self.homedir)
-        self.gpg = gnupg.GPG(homedir=self.homedir, ignore_homedir_permissions=True)
-
-    def _debug_init_store(
-        self, name: str = "DSV Test Key", email: str = "test@key.com"
-    ):
-        """
-        Initializes a new GPG keystore for testing purposes.
-
-        Args:
-            name: GPG identity name used to create the new key.
-            email: GPG identity email used to create the new key.
-
-        Returns:
-            The GPG key identifier of the newly created key.
-        """
-        input_data = self.gpg.gen_key_input(
-            name_email=name, name_real=email, passphrase=self.passphrase
-        )
-
-        result = self.gpg.gen_key(input_data)
-        self.keyid = str(result)
-
-        return result
-
-    # Signer Members
-
-    def sign(self, data: bytes) -> str:
-        if not self.keyid:
-            LOGGER.warning("Signing using implicit / default keyid!")
-            # raise RuntimeError("Cannot sign without keyid!")
-
-        kwargs = {}
-        if self.keyid:
-            kwargs = {"default_key": self.keyid}
-
-        result = self.gpg.sign(
-            data, clearsign=False, detach=True, passphrase=self.passphrase, **kwargs
-        )
-
-        return str(result).rstrip()
-
-    def verify(self, data: bytes, signature: str):
-        # Note: gnupg.py:verify_file() forces sig_file to be on disk, as the
-        #       underlying gpg utility does the same =(
-        with tempfile.NamedTemporaryFile() as tmpfile:
-            tmpfile.write(signature.encode("utf-8"))
-            tmpfile.flush()
-            os.fsync(tmpfile.fileno())
-            return self.gpg.verify_file(io.BytesIO(data), tmpfile.name)
-
-
-# https://pysheeet.readthedocs.io/en/latest/notes/python-security.html
-class PKISigner(Signer):
-    """
-    Creates and verifies docker image signatures using OpenSSL.
-    """
-
-    KEYPAIR = os.environ.get("DSV_PKI_DATASTORE", Path.home().joinpath("dsv.pem"))
-
-    TAG_START = "-----BEGIN PKI SIGNATURE-----"
-    TAG_END = "-----END PKI SIGNATURE-----"
-
-    def __init__(self, keypair_path=KEYPAIR, passphrase: str = None):
-        """
-        Reference: https://www.digicert.com/ssl-support/pem-ssl-creation.htm
-        Args:
-            keypair_path: Path to a file containing the entire trust chain and private key, PEM encoded.
-            passphrase: The passphrase used to unlock the private key.
-        """
-        self.keypair_entries = None
-        self.keypair_path = keypair_path
-        self.passphrase = passphrase
-        self.private_signer = None
-        self.public_signer = None
-
-        LOGGER.debug("Using keypair: %s", self.keypair_path)
-
-    def _debug_init_keypair(self, bits: int = 2048):
-        """
-        Initializes a new PKI certificate pair.
-
-        Args:
-            bits: Entropy to use when generating the private key.
-        """
-        key = RSA.generate(bits)
-        private_key = key.export_key(passphrase=self.passphrase)
-        public_key = key.publickey().export_key()
-
-        with open(self.keypair_path, "wb") as file:
-            file.write(public_key)
-            file.write(os.linesep.encode("utf-8"))
-            file.write(private_key)
-            file.flush()
-
-    def get_keypair(self) -> List:
-        """
-        Retrieves the keypair entries from disk.
-
-        Returns:
-            The list of PEM encoded entries.
-        """
-        if self.keypair_entries is None:
-            with open(self.keypair_path, "r") as file:
-                content = file.read()
-            self.keypair_entries = re.findall(
-                r"(-{5}BEGIN([^-]+)-{5}.+-{5}END\2-{5})", content, re.DOTALL
-            )
-            self.keypair_entries = [x[0] for x in self.keypair_entries]
-        return self.keypair_entries
-
-    def get_private_signer(self) -> PKCS115_SigScheme:
-        """
-        Retrieves a private signer, initialized from the first PEM encoded private key entry.
-
-        Returns:
-            A private signer initialized from the first PEM encoded private key entry.
-        """
-        if not self.private_signer:
-            for entry in self.get_keypair():
-                if "RSA PRIVATE KEY" in entry:
-                    private_rsa_key = RSA.import_key(entry, self.passphrase)
-                    self.private_signer = PKCS1_v1_5.new(private_rsa_key)
-        return self.private_signer
-
-    def get_public_signer(self) -> PKCS115_SigScheme:
-        """
-        Retrieves a public signer, initialized from the first PEM encoded public key entry.
-
-        Returns:
-            A public signer, initialized from the first PEM encoded public key entry.
-        """
-        if not self.public_signer:
-            for entry in self.get_keypair():
-                if "CERTIFICATE" in entry or "PUBLIC KEY" in entry:
-                    # TODO: Collect x509 certificate details
-                    # TODO: Is it possible to use OS-level cacerts instead of providing the public key?
-                    public_rsa_key = RSA.import_key(entry)
-                    self.public_signer = PKCS1_v1_5.new(public_rsa_key)
-        return self.public_signer
-
-    # Signer Members
-
-    def sign(self, data: bytes) -> str:
-        digest = SHA256.new(data)
-        raw_signature = self.get_private_signer().sign(digest)
-
-        return "{0}\n\n{1}\n{2}".format(
-            PKISigner.TAG_START,
-            base64.b64encode(raw_signature).decode(),
-            PKISigner.TAG_END,
-        )
-
-    def verify(self, data: bytes, signature: str):
-        # if not self.public_key_path:
-        #    raise RuntimeError("Cannot verify without public key!")
-        buffer = base64.b64decode(signature.split()[3])
-
-        digest = SHA256.new(data)
-        # pylint: disable=not-callable
-        valid = False
-        try:
-            self.get_public_signer().verify(digest, buffer)
-            valid = True
-        except ValueError:
-            ...
-
-        return {"keypair_path": self.keypair_path, "type": "pki", "valid": valid}
diff --git a/docker_sign_verify/specs.py b/docker_sign_verify/specs.py
new file mode 100644
index 0000000..a0dbbfe
--- /dev/null
+++ b/docker_sign_verify/specs.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+
+# pylint: disable=too-few-public-methods
+
+"""Reusable string literals."""
+
+from enum import Enum
+
+
+class SignatureTypes(Enum):
+    """
+    Docker signature types.
+    """
+
+    SIGN = 0  # Append (Co-)signature
+    ENDORSE = 1  # Append endorsement
+    RESIGN = 2  # Replace signature(s)
diff --git a/docker_sign_verify/utils.py b/docker_sign_verify/utils.py
index e13616d..7ae4917 100644
--- a/docker_sign_verify/utils.py
+++ b/docker_sign_verify/utils.py
@@ -5,93 +5,71 @@
 import gzip
 import hashlib
 import io
+import logging
 import os
 import shutil
 import tarfile
 import tempfile
 import time
 
-from typing import Dict
+from pathlib import Path
+from typing import TypedDict
 
-CHUNK_SIZE = int(os.environ.get("DSV_CHUNK_SIZE", 1048576))
+import aiofiles
 
+from docker_registry_client_async import FormattedSHA256
+from docker_registry_client_async.hashinggenerator import HashingGenerator
+from docker_registry_client_async.utils import (
+    async_wrap,
+    be_kind_rewind,
+    CHUNK_SIZE as DRCA_CHUNK_SIZE,
+)
 
-class FormattedSHA256(str):
-    """A algorithm prefixed SHA256 hash value."""
+LOGGER = logging.getLogger(__name__)
 
-    def __new__(cls, sha256: str):
-        if not sha256 or len(sha256) != 64:
-            raise ValueError(sha256)
-        obj = super().__new__(cls, "sha256:{0}".format(sha256))
-        obj.sha256 = sha256
-        return obj
+CHUNK_SIZE = int(os.environ.get("DSV_CHUNK_SIZE", DRCA_CHUNK_SIZE))
 
-    @staticmethod
-    def parse(digest: str):
-        """
-        Initializes a FormattedSHA256 from a given SHA256 digest value.
 
-        Args:
-            digest: A SHA256 digest value in form SHA256:<digest value>.
+class UtilChunkFile(TypedDict):
+    # pylint: disable=missing-class-docstring
+    digest: FormattedSHA256
+    size: int
 
-        Returns:
-            The newly initialized object.
-        """
-        if not digest or not digest.startswith("sha256:") or len(digest) != 71:
-            raise ValueError(digest)
-        return FormattedSHA256(digest[7:])
 
-
-def _chunk_file(file_in, file_out) -> Dict:
+async def chunk_file(
+    file_in, file_out, *, file_in_is_async: bool = True, file_out_is_async: bool = True
+) -> UtilChunkFile:
     """
-    Copies from one file to another in chunks.
+    Copies chunkcs from one file to another.
 
     Args:
-        file_in: The input file.
-        file_out: The output file.
+        file_in: The file from which to retrieve the file chunks.
+        file_out: The file to which to store the file chunks.
+        file_in_is_async: If True, all file_in IO operations will be awaited.
+        file_out_is_async: If True, all file_out IO operations will be awaited.
 
     Returns:
         dict:
-            digest: The SHA256 digest value of the output file.
-            size: Size of the output file in bytes.
+            digest: The digest value of the chunked data.
+            size: The byte size of the chunked data in bytes.
     """
-    size = 0
+    coroutine_read = file_in.read if file_in_is_async else async_wrap(file_in.read)
+    coroutine_write = (
+        file_out.write if file_out_is_async else async_wrap(file_out.write)
+    )
     hasher = hashlib.sha256()
+    size = 0
     while True:
-        chunk = file_in.read(CHUNK_SIZE)
+        chunk = await coroutine_read(CHUNK_SIZE)
         if not chunk:
             break
-        file_out.write(chunk)
-        size += len(chunk)
+        await coroutine_write(chunk)
         hasher.update(chunk)
-    file_out.flush()
-
-    # Not all file object are native ...
-    try:
-        os.fsync(file_out.fileno())
-    # pylint: disable=bare-except
-    except:
-        ...
-
-    # Be kind, rewind ...
-    file_out.seek(0)
-
-    return {"digest": "sha256:{0}".format(hasher.hexdigest()), "size": size}
-
+        size += len(chunk)
 
-def must_be_equal(
-    expected, actual, msg: str = "Actual value does not match expected value"
-):
-    """
-    Compares two values and raises an exception if they are not equal.
+    await be_kind_rewind(file_out, file_is_async=file_out_is_async)
 
-    Args:
-        expected: The expected value.
-        actual: The actual value.
-        msg: Message describing the context of the comparison.
-    """
-    if actual != expected:
-        raise RuntimeError("{0}: {1} != {2}".format(msg, actual, expected))
+    return {"digest": FormattedSHA256(hasher.hexdigest()), "size": size}
 
 
 def xellipsis(string: str) -> str:
@@ -126,36 +104,9 @@ def str_ellipsis(string: str, max_length: int = 40) -> str:
     return "{0}...{1}".format(string[: int(n_1)], string[-int(n_2) :])
 
 
-def formatted_digest(data: bytes) -> FormattedSHA256:
-    """
-    Returns the algorithm prefixed digest value of given data.
-
-    Args:
-        data: The data for which to calculate the digest value.
-
-    Returns:
-        The digest value of the data in the format <hast type>:<digest value>.
-    """
-    return FormattedSHA256(hashlib.sha256(data).hexdigest())
-
-
-def copy_file(file_in, file_out) -> Dict:
-    """
-    Copies from one file to another.
-
-    Args:
-        file_in: The input file.
-        file_out: The output file.
-
-    Returns:
-        dict: as defined by :func:~docker_sign_verify.Utils._chunk_file.
-    """
-    return _chunk_file(file_in, file_out)
-
-
-def read_file(path) -> bytes:
+async def read_file(path: Path) -> bytes:
     """
-    Retrieves the entire contents of a file.
+    Generator that asynchronously reads from a given file.
 
     Args:
         path: Absolute path of the file to be read.
@@ -163,28 +114,25 @@ def read_file(path) -> bytes:
     Returns:
         The file content.
     """
-    bytesio = io.BytesIO()
-    with path.open(mode="rb") as file:
-        copy_file(file, bytesio)
-    return bytesio.read()
+    async with aiofiles.open(path, mode="r+b") as file:
+        return bytes(HashingGenerator(file))
 
 
-def write_file(path, content: bytes) -> Dict:
+async def write_file(path: Path, content: bytes):
     """
     Assigns the entire contents of a file.
 
     Args:
         path: Absolute path of the file to be assigned.
         content: The content to be assigned.
-
-    Returns:
-        dict: as defined by :func:~docker_sign_verify.Utils.copy_file.
     """
-    bytesio = io.BytesIO(content)
-    with path.open(mode="wb") as file:
-        return copy_file(bytesio, file)
+    async with aiofiles.open(path, mode="w+b") as file:
+        # TODO: Split content into chunks ...
+        # TODO: Should we digest here?
+        await file.write(content)
 
 
+# TODO: Convert to aysnc
 def tar_mkdir(file_out, name: str):
     """
     Creates an empty directory in a given tar archive on disk.
@@ -206,6 +154,7 @@ def tar_mkdir(file_out, name: str):
 
 
 # TODO: What is the type of "content"?
+# TODO: Convert to aysnc
 def tar_add_file(file_out, name: str, content):
     """
     Creates a file from memory in a given tar archive on disk.
@@ -229,6 +178,7 @@ def tar_add_file(file_out, name: str, content):
         tfile_out.addfile(tarinfo, bytesio)
 
 
+# TODO: Convert to aysnc
 def tar_delete_file(file_out, name: str):
     """
     Removes a file from a given tar archive on disk.
@@ -254,6 +204,7 @@ def tar_delete_file(file_out, name: str):
         shutil.copyfileobj(tmp, file_out)
 
 
+# TODO: Convert to aysnc
 def tar(file_out, name: str, file_in):
     """
     Adds a file to a given tar archive on disk to disk.
@@ -271,7 +222,9 @@ def tar(file_out, name: str, file_in):
         tfile_out.addfile(tarinfo, file_in)
 
 
-def untar(file_in, name: str, file_out):
+async def untar(
+    file_in, name: str, file_out, *, file_out_is_async: bool = True
+) -> UtilChunkFile:
     """
     Extracts a file from a given tar archive.
 
@@ -279,16 +232,22 @@ def untar(file_in, name: str, file_out):
         file_in: The input file (tar).
         name: The name of the file to be extracted.
         file_out: The output file.
+        file_out_is_async: If True, all file_out IO operations will be awaited.
     """
     result = None
     with tarfile.open(fileobj=file_in) as tfile_in:
         for tarinfo in tfile_in:
             if tarinfo.name == name:
-                result = _chunk_file(tfile_in.extractfile(tarinfo), file_out)
+                result = await chunk_file(
+                    tfile_in.extractfile(tarinfo),
+                    file_out,
+                    file_in_is_async=file_out_is_async,
+                )
                 break
     return result
 
 
+# TODO: Convert to aysnc
 def file_exists_in_tar(file_in, name: str):
     """
     Checks if a file exists in a given tar archive.
@@ -307,16 +266,17 @@ def file_exists_in_tar(file_in, name: str):
     return False
 
 
-def gunzip(file_in, file_out) -> Dict:
+async def gunzip(path: Path, file_out) -> UtilChunkFile:
     """
     Uncompresses a given gzip archive.
 
     Args:
-        file_in: The input file (gz)
+        path: Path to the gzipped file.
         file_out: The output file.
 
     Returns:
         dict: as defined by :func:~docker_sign_verify.Utils._chunk_file.
     """
-    with gzip.GzipFile(fileobj=file_in, mode="rb") as gfile_in:
-        return _chunk_file(gfile_in, file_out)
+    # TODO: Implement an async GzipFile ...
+    with gzip.GzipFile(filename=path, mode="rb") as file_in:
+        return await chunk_file(file_in, file_out, file_in_is_async=False)
diff --git a/makefile b/makefile
index 67662c6..f262ea1 100644
--- a/makefile
+++ b/makefile
@@ -23,6 +23,12 @@ verify:
 test:
 	python -m pytest --log-cli-level info $(args)
 
+test_all:
+	python -m pytest --log-cli-level info --allow-online-modification $(args)
+
+test_all_verbose:
+	python -m pytest -r sx --log-cli-level debug --allow-online-modification $(args)
+
 test_code:
 	# Note: https://github.com/PyCQA/pylint/issues/289
 	python -m pylint --disable C0330,R0801 --max-line-length=120 docker_sign_verify tests
diff --git a/setup.py b/setup.py
index 8dfbdfb..9e4c226 100755
--- a/setup.py
+++ b/setup.py
@@ -34,15 +34,28 @@ def find_version(*segments):
         docker-sign=docker_sign_verify.scripts.docker_sign:cli
         docker-verify=docker_sign_verify.scripts.docker_verify:cli
     """,
-    extras_require={"dev": ["black", "pylint", "pytest", "twine", "wheel"]},
+    extras_require={
+        "dev": [
+            "black",
+            "docker",
+            "docker-compose",
+            "lovely-pytest-docker",
+            "pylint",
+            "pytest",
+            "pytest-asyncio",
+            "twine",
+            "wheel",
+        ]
+    },
     include_package_data=True,
     install_requires=[
+        "aiofiles",
+        "aiohttp",
         "canonicaljson",
+        "docker-registry-client-async>=0.1.0",
         "click",
         "gnupg",
         "pycryptodome",
-        "requests",
-        "www_authenticate",
     ],
     keywords="docker docker-sign docker-verify integrity sign signatures verify",
     license="Apache License 2.0",
@@ -51,7 +64,13 @@ def find_version(*segments):
     name="docker_sign_verify",
     packages=find_packages(),
     setup_requires=["pytest-runner"],
-    tests_require=["pytest"],
+    tests_require=[
+        "docker",
+        "docker-compose",
+        "lovely-pytest-docker",
+        "pytest",
+        "pytest-asyncio",
+    ],
     test_suite="tests",
     url="https://pypi.org/project/docker-sign-verify/",
     version=find_version("docker_sign_verify", "__init__.py"),


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 30b61e3..c9b25ce 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,17 @@
 #!/usr/bin/env python
 
+# pylint: disable=redefined-outer-name
+
 """Configures execution of pytest."""
 
+from typing import Generator
+
 import pytest
 
+from click.testing import CliRunner
+
+from .stubs import DSVCliRunner
+
 # https://stackoverflow.com/questions/51883573/using-a-command-line-option-in-a-pytest-skip-if-condition
 
 
@@ -15,21 +23,55 @@ def pytest_addoption(parser):
         default=False,
         help="Allow execution of online tests.",
     )
+    parser.addoption(
+        "--allow-online-modification",
+        action="store_true",
+        default=False,
+        help="Allow modification of online content (implies --allow-online).",
+    )
 
 
 def pytest_collection_modifyitems(config, items):
     """pytest collection modifier."""
-    if config.getoption("--allow-online"):
-        return
 
     skip_online = pytest.mark.skip(
         reason="Execution of online tests requires --allow-online option."
     )
+    skip_online_modification = pytest.mark.skip(
+        reason="Modification of online content requires --allow-online-modification option."
+    )
     for item in items:
-        if "online" in item.keywords:
+        if "online_modification" in item.keywords and not config.getoption(
+            "--allow-online-modification"
+        ):
+            item.add_marker(skip_online_modification)
+        elif (
+            "online" in item.keywords
+            and not config.getoption("--allow-online")
+            and not config.getoption("--allow-online-modification")
+        ):
             item.add_marker(skip_online)
 
 
 def pytest_configure(config):
     """pytest configuration hook."""
     config.addinivalue_line("markers", "online: allow execution of online tests.")
+    config.addinivalue_line(
+        "markers", "online_modification: allow modification of online content."
+    )
+
+
+@pytest.fixture
+def clirunner() -> Generator[CliRunner, None, None]:
+    """Provides a runner for testing click command line interfaces."""
+    runner = CliRunner()
+    with runner.isolated_filesystem():
+        yield runner
+
+
+@pytest.fixture
+def runner() -> Generator[DSVCliRunner, None, None]:
+    """Provides a runner for testing click command line interfaces."""
+    runner = DSVCliRunner()
+    with runner.isolated_filesystem():
+        yield runner
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
new file mode 100644
index 0000000..34871d0
--- /dev/null
+++ b/tests/docker-compose.yml
@@ -0,0 +1,13 @@
+#!/usr/bin/env docker-compose
+
+version: "3"
+services:
+  pytest-registry:
+    container_name: pytest-registry
+    environment:
+      #REGISTRY_STORAGE_DELETE_ENABLED: "true"
+      REGISTRY_STORAGE_DELETE: '{"enabled":true}'
+      REGISTRY_LOG_LEVEL_DEBUG: "true"
+    image: library/registry:2.7.1
+    ports:
+    - "5001:5000/tcp"
diff --git a/tests/localregistry.py b/tests/localregistry.py
new file mode 100644
index 0000000..4f1b29c
--- /dev/null
+++ b/tests/localregistry.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""Utility classes."""
+
+from typing import Dict, Generator, Optional, TypedDict
+
+import docker
+import pytest
+
+from docker import DockerClient
+from docker_registry_client_async import (
+    DockerMediaTypes,
+    FormattedSHA256,
+    ImageName,
+    Indices,
+)
+
+
+class TypingGetTestDataLocal(TypedDict):
+    # pylint: disable=missing-class-docstring
+    image: str
+    tag: str
+    digests: Dict[str, FormattedSHA256]
+    original_endpoint: Optional[str]
+    protocol: Optional[str]
+
+
+class TypingKnownGoodImage(TypingGetTestDataLocal):
+    # pylint: disable=missing-class-docstring
+    image_name: ImageName
+
+
+def get_test_data_local() -> Generator[TypingGetTestDataLocal, None, None]:
+    """Dynamically initializes test data for a local mutable registry."""
+    images = [
+        {
+            "image": "{0}/library/python",
+            "tag": "3.7.2-slim-stretch",
+            "digests": {
+                DockerMediaTypes.DISTRIBUTION_MANIFEST_V2: FormattedSHA256(
+                    "0005ba40bf87e486d7061ca0112123270e4a6088b5071223c8d467db3dbba908"
+                ),
+            },
+            "original_endpoint": Indices.DOCKERHUB,
+            "protocol": "http",
+        },
+        {
+            "image": "{0}/library/busybox",
+            "tag": "1.30.1",
+            "digests": {
+                DockerMediaTypes.DISTRIBUTION_MANIFEST_V2: FormattedSHA256(
+                    "4fe8827f51a5e11bb83afa8227cbccb402df840d32c6b633b7ad079bc8144100"
+                ),
+            },
+            "original_endpoint": Indices.DOCKERHUB,
+            "protocol": "http",
+        },
+    ]
+    for image in images:
+        yield image
+
+
+@pytest.fixture(scope="session")
+def docker_client() -> DockerClient:
+    """Provides a Docker API client."""
+    return docker.from_env()
+
+
+@pytest.fixture(params=get_test_data_local())
+def known_good_image_local(request, pytest_registry: str) -> TypingKnownGoodImage:
+    """Provides 'known good' metadata for a local image that can be modified."""
+    request.param["image"] = request.param["image"].format(pytest_registry)
+    manifest_digest = request.param["digests"][
+        DockerMediaTypes.DISTRIBUTION_MANIFEST_V2
+    ]
+    request.param["image_name"] = ImageName.parse(
+        f"{request.param['image']}:{request.param['tag']}@{manifest_digest}"
+    )
+    return request.param
+
+
+@pytest.fixture(params=get_test_data_local())
+def known_good_image_remote(request) -> TypingKnownGoodImage:
+    """Provides 'known good' metadata for a remote image that is readonly."""
+    request.param["image"] = request.param["image"].format(
+        request.param["original_endpoint"]
+    )
+    manifest_digest = request.param["digests"][
+        DockerMediaTypes.DISTRIBUTION_MANIFEST_V2
+    ]
+    request.param["image_name"] = ImageName.parse(
+        f"{request.param['image']}:{request.param['tag']}@{manifest_digest}"
+    )
+    return request.param
+
+
+@pytest.fixture(scope="session")
+def pytest_registry(docker_client: DockerClient, docker_services) -> str:
+    """Provides the endpoint of a local, mutable, docker registry."""
+    # Start a local registry using docker-compose ...
+    service = "pytest-registry"
+    docker_services.start(service)
+    public_port = docker_services.wait_for_service(service, 5000)
+    endpoint = f"{docker_services.docker_ip}:{public_port}"
+
+    # Replicate select images locally ...
+    for item in get_test_data_local():
+        repository_src = item["image"].format(item["original_endpoint"])
+        repository_dest = item["image"].format(endpoint)
+        image = docker_client.images.pull(repository_src, item["tag"])
+        image.tag(repository_dest, item["tag"])
+        docker_client.images.push(repository_dest, item["tag"])
+        docker_client.images.remove(image=f"{repository_src}:{item['tag']}")
+        docker_client.images.remove(image=f"{repository_dest}:{item['tag']}")
+
+    return endpoint
diff --git a/tests/stubs.py b/tests/stubs.py
index ca163d4..0a38823 100644
--- a/tests/stubs.py
+++ b/tests/stubs.py
@@ -2,22 +2,77 @@
 
 """Stub classes for offline testing."""
 
-from typing import List
+import shlex
+import sys
 
+from typing import Dict, List
+
+from click.testing import CliRunner, Result
+from docker_registry_client_async import FormattedSHA256, ImageName
 from docker_sign_verify import (
-    FormattedSHA256,
     ImageConfig,
-    ImageName,
     ImageSource,
     Manifest,
     RegistryV2Manifest,
+    SignatureTypes,
     Signer,
 )
 
 from .testutils import get_test_data
 
 
-def _signer_for_signature(signature: str):
+class DSVCliRunner(CliRunner):
+    """
+    click.testing.CliRunner.invoke w/o isolation, as it breaks logging:
+    https://github.com/pallets/click/issues/824
+    """
+
+    def invoke(self, cli, args=None, catch_exceptions=True, **extra) -> Result:
+        # pylint: disable=arguments-differ
+        exc_info = None
+        exception = None
+        exit_code = 0
+
+        if isinstance(args, str):
+            args = shlex.split(args)
+
+        if "prog_name" not in extra:
+            extra["prog_name"] = self.get_default_prog_name(cli)
+
+        try:
+            cli.main(args=args or (), **extra)
+        except SystemExit as exc:
+            exc_info = sys.exc_info()
+            exit_code = exc.code
+            if exit_code is None:
+                exit_code = 0
+
+            if exit_code != 0:
+                exception = exc
+
+            if not isinstance(exit_code, int):
+                sys.stdout.write(str(exit_code))
+                sys.stdout.write("\n")
+                exit_code = 1
+
+        except Exception as exc:  # pylint: disable=broad-except
+            if not catch_exceptions:
+                raise
+            exc_info = sys.exc_info()
+            exit_code = 1
+            exception = exc
+
+        return Result(
+            runner=self,
+            stdout_bytes=b"",
+            stderr_bytes=b"",
+            exit_code=exit_code,
+            exception=exception,
+            exc_info=exc_info,
+        )
+
+
+def _signer_for_signature(signature: str) -> Signer:
     """Override of docker_sign_verify.Signer::_for_signature()."""
     if "FAKE SIGNATURE" in signature:
         return FakeSigner()
@@ -35,10 +90,10 @@ def __init__(
 
     # Signer Members
 
-    def sign(self, data: bytes) -> str:
-        return self.signature_value
+    async def sign(self, data: bytes) -> str:
+        return self.signature_value.format(data)
 
-    def verify(self, data: bytes, signature: str):
+    async def verify(self, data: bytes, signature: str):
         return {"type": "fake", "valid": True}
 
 
@@ -46,13 +101,13 @@ class FakeRegistryV2ImageSourceNoLabels(ImageSource):
     """Fake image source used to expose methods in the abstract base class."""
 
     def __init__(self, request, layer_exists: bool = True, **kwargs):
-        super(FakeRegistryV2ImageSourceNoLabels, self).__init__(**kwargs)
+        super().__init__(**kwargs)
         self.config = None
         self.does_layer_exists = layer_exists
         self.manifest = None
         self.request = request
 
-    def quick_sign(self, image_name: ImageName) -> dict:
+    async def quick_sign(self, image_name: ImageName) -> Dict:
         """
         Signs a given image in an image source using a fake signer and returns the results.
         This method is a testing shortcut.
@@ -64,69 +119,81 @@ def quick_sign(self, image_name: ImageName) -> dict:
             The results of the docker_sign_verify.ImageSource::_sign_image_config() method.
         """
         # pylint: disable=protected-access
-        result = self._sign_image_config(FakeSigner(), image_name, False)
+        result = await self._sign_image_config(
+            FakeSigner(), image_name, SignatureTypes.SIGN
+        )
 
         self.config = result["image_config"]
         self.manifest.set_config_digest(
-            result["image_config"].get_config_digest(),
-            len(result["image_config"].get_config()),
+            result["image_config"].get_digest(),
+            len(result["image_config"].get_bytes()),
         )
         return result
 
     # ImageSource Members
 
-    def get_image_config(self, image_name: ImageName) -> ImageConfig:
+    async def get_image_config(self, image_name: ImageName, **kwargs) -> ImageConfig:
         if not self.config:
             config = get_test_data(self.request, __name__, "stub_config.json")
             self.config = ImageConfig(config)
         return self.config
 
-    def get_image_layer_to_disk(self, image_name: ImageName, layer: str, file):
+    async def get_image_layer_to_disk(
+        self, image_name: ImageName, layer: str, file, **kwargs
+    ):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def get_manifest(self, image_name: ImageName = None) -> Manifest:
+    async def get_manifest(self, image_name: ImageName = None, **kwargs) -> Manifest:
         if not self.manifest:
             manifest = get_test_data(self.request, __name__, "stub_manifest.json")
             self.manifest = RegistryV2Manifest(manifest)
         return self.manifest
 
-    def layer_exists(self, image_name: ImageName, layer: FormattedSHA256) -> bool:
+    async def layer_exists(
+        self, image_name: ImageName, layer: FormattedSHA256, **kwargs
+    ) -> bool:
         return self.does_layer_exists
 
-    def put_image(
+    async def put_image(
         self,
         image_source,
         image_name: ImageName,
         manifest: Manifest,
         image_config: ImageConfig,
         layer_files: List,
+        **kwargs,
     ):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def put_image_config(self, image_name: ImageName, image_config: ImageConfig):
+    async def put_image_config(
+        self, image_name: ImageName, image_config: ImageConfig, **kwargs
+    ):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def put_image_layer(self, image_name: ImageName, content):
+    async def put_image_layer(self, image_name: ImageName, content, **kwargs):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def put_image_layer_from_disk(self, image_name: ImageName, file):
+    async def put_image_layer_from_disk(self, image_name: ImageName, file, **kwargs):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def put_manifest(self, manifest: Manifest, image_name: ImageName = None):
+    async def put_manifest(
+        self, manifest: Manifest, image_name: ImageName = None, **kwargs
+    ):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def sign_image(
+    async def sign_image(
         self,
         signer: Signer,
         src_image_name: ImageName,
         dest_image_source,
         dest_image_name: ImageName,
-        endorse: bool,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        **kwargs,
     ):
         raise RuntimeError("Logic error; method should not be invoked!")
 
-    def verify_image_integrity(self, image_name: ImageName):
-        data = self._verify_image_config(image_name)
+    async def verify_image_integrity(self, image_name: ImageName, **kwargs):
+        data = await self._verify_image_config(image_name)
 
         # LGTM ...
 
diff --git a/tests/test_aiotempfile.py b/tests/test_aiotempfile.py
new file mode 100644
index 0000000..d8e5d43
--- /dev/null
+++ b/tests/test_aiotempfile.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python
+
+# pylint: disable=protected-access,redefined-outer-name
+
+"""AIOTempFile tests."""
+
+import logging
+
+from asyncio import coroutine
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from aiofiles.base import AiofilesContextManager
+from aiofiles.threadpool.binary import AsyncBufferedReader
+from docker_sign_verify.aiotempfile import open as aiotempfile
+
+pytestmark = [pytest.mark.asyncio]
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def close_file(file, path: Path):
+    """Closes a given file, ensures it's deleted."""
+    await file.close()
+    assert file.closed
+    assert not path.exists()
+
+
+async def read_file(file) -> bytes:
+    """Reads a given file."""
+    await file.seek(0)
+    return await file.read()
+
+
+async def write_to_file(file: Any, content: bytes) -> Path:
+    """Consolidated file write tests."""
+    assert isinstance(file, AsyncBufferedReader)
+    path = Path(file.name)
+    assert path
+    await file.write(content)
+    assert await file.tell() > 0
+    return path
+
+
+async def test_open_async_with():
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    async with aiotempfile() as file:
+        path = await write_to_file(file, content_expected)
+        assert await read_file(file) == content_expected
+        await close_file(file, path)
+
+
+async def test_open_context_manager__aenter__():
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    aiofiles_context_manager = aiotempfile()
+    assert isinstance(aiofiles_context_manager, AiofilesContextManager)
+    file = await aiofiles_context_manager.__aenter__()
+
+    path = await write_to_file(file, content_expected)
+    assert await read_file(file) == content_expected
+    await close_file(file, path)
+
+    await aiofiles_context_manager.__aexit__(None, None, None)
+    aiofiles_context_manager.close()
+
+
+async def test_open_context_manager__anext__():
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    aiofiles_context_manager = aiotempfile()
+    assert isinstance(aiofiles_context_manager, AiofilesContextManager)
+
+    file = await aiofiles_context_manager.__anext__()
+    path = await write_to_file(file, content_expected)
+    assert await read_file(file) == content_expected
+    await close_file(file, path)
+
+    aiofiles_context_manager.close()
+
+
+def test_open_context_manager__await__():
+    # pylint: disable=not-an-iterable
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    aiofiles_context_manager = aiotempfile()
+    assert isinstance(aiofiles_context_manager, AiofilesContextManager)
+
+    # https://stackoverflow.com/a/56114311/1201075
+    @coroutine
+    def do_test() -> Path:
+        file = yield from aiofiles_context_manager.__await__()
+        path = yield from write_to_file(file, content_expected)
+        content_acutal = yield from read_file(file)
+        assert content_acutal == content_expected
+        yield from close_file(file, path)
+
+    yield from do_test()
+
+    aiofiles_context_manager.close()
+
+
+async def test_open_context_manager__iter__():
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    aiofiles_context_manager = aiotempfile()
+    assert isinstance(aiofiles_context_manager, AiofilesContextManager)
+
+    file = await aiofiles_context_manager.__iter__()
+    path = await write_to_file(file, content_expected)
+    assert await read_file(file) == content_expected
+    await close_file(file, path)
+
+    aiofiles_context_manager.close()
+
+
+async def test_open_context_manager_await():
+    """Test that temporary files can be opened."""
+    content_expected = b"This is test content."
+
+    aiofiles_context_manager = aiotempfile()
+    assert isinstance(aiofiles_context_manager, AiofilesContextManager)
+
+    file = await aiofiles_context_manager
+    path = await write_to_file(file, content_expected)
+    assert await read_file(file) == content_expected
+    await close_file(file, path)
+
+    aiofiles_context_manager.close()
diff --git a/tests/test_archivemanifest.py b/tests/test_archivemanifest.py
new file mode 100644
index 0000000..a2b8302
--- /dev/null
+++ b/tests/test_archivemanifest.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""Manifest tests."""
+
+import pytest
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+from docker_sign_verify import ArchiveManifest
+
+from .testutils import get_test_data
+
+
+@pytest.fixture
+def archive_manifest(manifest_archive: bytes) -> ArchiveManifest:
+    """Provides an ArchiveManifest instance for the sample archive manifest."""
+    # Do not use caching; get a new instance for each test
+    return ArchiveManifest(manifest_archive)
+
+
+@pytest.fixture()
+def formattedsha256() -> FormattedSHA256:
+    """Provides a FormattedSHA256 instance with a distinct digest value."""
+    return FormattedSHA256(
+        "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
+    )
+
+
+@pytest.fixture(
+    params=[
+        "ignored@sha256:a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782",
+        "base:7.2@sha256:adecf4209bb9dd67d96393774cbd7f8bd2bad3596da42cde33daa0c41b14ac62",
+        "ignored@sha256:adecf4209bb9dd67d96393774cbd7f8bd2bad3596da42cde33daa0c41b14ac62",
+    ]
+)
+def image_name(request) -> ImageName:
+    """Provides a 'known good' image name."""
+    yield ImageName.parse(request.param)
+
+
+@pytest.fixture
+def manifest_archive(request) -> bytes:
+    """Provides a sample archive manifest."""
+    return get_test_data(request, __name__, "manifest_archive.json")
+
+
+@pytest.fixture()
+def sha256_archive_layer(formattedsha256: FormattedSHA256) -> str:
+    """Provides the archive layer identifier for the distinct digest value."""
+    return "{0}/layer.tar".format(formattedsha256.sha256)
+
+
+def test___init__(archive_manifest: ArchiveManifest):
+    """Test that an ArchiveManifest can be instantiated."""
+    assert archive_manifest
+
+
+def test___bytes__(archive_manifest: ArchiveManifest, manifest_archive: bytes):
+    """Test __str__ pass-through for different variants."""
+    assert bytes(archive_manifest) == manifest_archive
+
+
+def test___str__(archive_manifest: ArchiveManifest, manifest_archive: bytes):
+    """Test __str__ pass-through for different variants."""
+    assert str(archive_manifest) == manifest_archive.decode("utf-8")
+
+
+def test___layer_to_digest(
+    archive_manifest: ArchiveManifest,
+    sha256_archive_layer: str,
+    formattedsha256: FormattedSHA256,
+):
+    """Test layer to hash conversion."""
+    assert archive_manifest.layer_to_digest(sha256_archive_layer) == formattedsha256
+
+
+def test__digest_to_layer(
+    archive_manifest: ArchiveManifest,
+    sha256_archive_layer: str,
+    formattedsha256: FormattedSHA256,
+):
+    """Test hash to layer conversion."""
+    assert archive_manifest.digest_to_layer(formattedsha256) == sha256_archive_layer
+
+
+def test_get_config(archive_manifest: ArchiveManifest, image_name: ImageName):
+    """Test configuration retrieval."""
+    assert (
+        archive_manifest.get_config(image_name)["Config"]
+        == f"{image_name.resolve_digest().sha256}.json"
+    )
+
+
+@pytest.mark.parametrize("repotag", ["REPOTAG", None])
+def test_append_config(
+    archive_manifest: ArchiveManifest,
+    sha256_archive_layer: str,
+    formattedsha256: FormattedSHA256,
+    repotag,
+):
+    """Test appending configurations."""
+    layers = [formattedsha256]
+    archive_manifest.append_config(formattedsha256, layers, repotag)
+    image_name = ImageName("ignored", digest=formattedsha256)
+    config = archive_manifest.get_config(image_name)
+    assert config["Config"] == "{0}.json".format(formattedsha256.sha256)
+    assert config["Layers"] == [sha256_archive_layer]
+    assert config.get("RepoTags", None) == repotag
+
+
+def test_get_config_digest(archive_manifest: ArchiveManifest, image_name: ImageName):
+    """Test configuration digest retrieval."""
+    assert archive_manifest.get_config_digest(image_name) == image_name.resolve_digest()
+
+
+def test_get_layers(archive_manifest: ArchiveManifest, image_name: ImageName):
+    """Test manifest layer retrieval."""
+    if (
+        image_name.resolve_digest()
+        == "sha256:a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782"
+    ):
+        assert archive_manifest.get_layers(image_name) == [
+            "sha256:137120c8596a15ab42c39c0c5cf83ef864b6b65b5516887c895915e87292bd07",
+            "sha256:755520f73bc74ae73b12f53229e401e8d4c584b74f5704d2d36ba7c45e2657cf",
+            "sha256:13fb089903a5e0e9b00d78ba48496da528ce8d81e08a1042ebeced8c35d714cb",
+            "sha256:f86d68f70ca006025a7f7013f69898f78d1d9272c4d3909e3ec4c7f9958da20e",
+            "sha256:7b4a4edd704242cec1710679a088be8aabff25c3a79f4eecbe8d11d57c53a20b",
+            "sha256:ef4724d42630f3022ef67c3f6749e85a13e81b8efcf98fbd517476499f10e5ab",
+        ]
+    else:
+        assert archive_manifest.get_layers(image_name) == [
+            "sha256:2c2e149ae9a88ae6bee1583459b2d3e5e317877b795c08781fab36eab4b4329f",
+            "sha256:83419ef1d0ad0520c9fc44da4345637e5c05e34fa564ddf2fb6d6f94a6b2d205",
+            "sha256:c2d494c64683fb7edac60aaffc570c514c1c80c797aafcf25b8a9438690da4df",
+            "sha256:d3b1a8ce509767258045f6cc050dfc8cff27f66f9fa8c61c9dc46733e492e0af",
+            "sha256:0a0084e273d71f3b39100a5c209a3208fafdb90e3dd038cded5e2a54265d23fa",
+        ]
diff --git a/tests/test_cli.py b/tests/test_cli.py
deleted file mode 100644
index 7bbff8c..0000000
--- a/tests/test_cli.py
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/usr/bin/env python
-
-"""CLI tests."""
-
-from docker_sign_verify.scripts.docker_copy import cli as docker_copy_cli
-from docker_sign_verify.scripts.docker_sign import cli as docker_sign_cli
-from docker_sign_verify.scripts.docker_verify import cli as docker_verify_cli
-
-from .testutils import runner
-
-
-def test_docker_copy_empty(runner):
-    """Test docker-copy CLI can be invoked."""
-    result = runner.invoke(docker_copy_cli, ["registry"])
-    assert "Usage:" in result.stdout
-    assert result.exit_code != 0
-
-
-def test_docker_sign_empty(runner):
-    """Test docker-copy CLI can be invoked."""
-    result = runner.invoke(docker_sign_cli, ["registry"])
-    assert "Usage:" in result.stdout
-    assert result.exit_code != 0
-
-
-def test_docker_verify_empty(runner):
-    """Test docker-copy CLI can be invoked."""
-    result = runner.invoke(docker_verify_cli, ["registry"])
-    assert "Usage:" in result.stdout
-    assert result.exit_code != 0
diff --git a/tests/test_devicemappermanifest.py b/tests/test_devicemappermanifest.py
new file mode 100644
index 0000000..270fb84
--- /dev/null
+++ b/tests/test_devicemappermanifest.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""Manifest tests."""
+
+import pytest
+from docker_registry_client_async import FormattedSHA256, ImageName
+
+from docker_sign_verify import DeviceMapperRepositoryManifest
+from .testutils import get_test_data
+
+
+@pytest.fixture
+def devicemapper_repository_manifest(
+    manifest_repository: bytes,
+) -> DeviceMapperRepositoryManifest:
+    """Provides a DeviceMapperRepositoryManifest instance for the sample repository manifest."""
+    # Do not use caching; get a new instance for each test
+    return DeviceMapperRepositoryManifest(manifest_repository)
+
+
+@pytest.fixture()
+def formattedsha256() -> FormattedSHA256:
+    """Provides a FormattedSHA256 instance with a distinct digest value."""
+    return FormattedSHA256(
+        "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
+    )
+
+
+@pytest.fixture
+def manifest_repository(request) -> bytes:
+    """Provides a sample repository manifest."""
+    return get_test_data(request, __name__, "manifest_repository.json")
+
+
+def test___init__(devicemapper_repository_manifest: DeviceMapperRepositoryManifest):
+    """Test that an DeviceMapperRepositoryManifest can be instantiated."""
+    assert devicemapper_repository_manifest
+
+
+def test___bytes__(
+    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
+    manifest_repository: bytes,
+):
+    """Test __str__ pass-through for different variants."""
+    assert bytes(devicemapper_repository_manifest) == manifest_repository
+
+
+def test___str__(
+    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
+    manifest_repository: bytes,
+):
+    """Test __str__ pass-through for different variants."""
+    assert str(devicemapper_repository_manifest) == manifest_repository.decode("utf-8")
+
+
+# TODO: def test__get_repository_key
+# TODO: get_combined_layerid
+
+
+@pytest.mark.parametrize(
+    "img_name", ["endpoint:port/namespace/image:tag", "rancher/swarmkit:v1.13.0-beta.1"]
+)
+def test_override_config(
+    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
+    formattedsha256: FormattedSHA256,
+    img_name: str,
+):
+    """Test overriding manifest values."""
+    image_name = ImageName.parse(img_name)
+    devicemapper_repository_manifest.override_config(formattedsha256, image_name)
+    assert (
+        devicemapper_repository_manifest.get_config_digest(image_name)
+        == formattedsha256
+    )
+
+
+@pytest.mark.parametrize(
+    "img_name,config_digest",
+    [
+        (
+            "busybox:latest",
+            "sha256:6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30",
+        )
+    ],
+)
+def test_get_config_digest(
+    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
+    img_name: str,
+    config_digest: str,
+):
+    """Test image configuration digest retrieval."""
+    image_name = ImageName.parse(img_name)
+    assert (
+        devicemapper_repository_manifest.get_config_digest(image_name) == config_digest
+    )
+
+
+# def test_repository_get_layers(devicemapper_repository_manifest: DeviceMapperRepositoryManifest):
+#    """Test manifest layer retrieval."""
+#    with pytest.raises(NotImplementedError):
+#        devicemapper_repository_manifest.get_layers()
diff --git a/tests/test_gpgsigner.py b/tests/test_gpgsigner.py
new file mode 100644
index 0000000..4d303b0
--- /dev/null
+++ b/tests/test_gpgsigner.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""GPGSigner tests."""
+
+import logging
+import os
+import subprocess
+
+from pathlib import Path
+
+import aiofiles
+import pytest
+
+from _pytest.logging import LogCaptureFixture
+from docker_sign_verify import GPGSigner, Signer
+
+pytestmark = [pytest.mark.asyncio]
+
+LOGGER = logging.getLogger(__name__)
+
+if os.environ.get("TRAVIS", "") == "true":
+    pytest.skip(
+        "TODO: Figure out why GnuPG isn't working under travis", allow_module_level=True
+    )
+
+
+@pytest.fixture()
+async def gpgsigner(request, tmp_path: Path, caplog: LogCaptureFixture) -> GPGSigner:
+    """Provides a GPGSinger instance."""
+
+    caplog.set_level(logging.FATAL, logger="gnupg")
+
+    # https://github.com/isislovecruft/python-gnupg/issues/137#issuecomment-459043779
+    LOGGER.debug("Initializing GPG home: %s ...", tmp_path)
+    tmp_path.chmod(0o700)
+    path = tmp_path.joinpath("gpg-agent.conf")
+    async with aiofiles.open(path, mode="w") as file:
+        await file.write("allow-loopback-pinentry\n")
+        await file.write("max-cache-ttl 60\n")
+    path.chmod(0o600)
+
+    # TODO: Can this be converted to async?
+    def _stop_gpg_agent():
+        subprocess.run(
+            [
+                "/usr/bin/gpg-connect-agent",
+                "--homedir",
+                str(tmp_path),
+                "killagent",
+                "/bye",
+            ],
+            check=True,
+        )
+
+    request.addfinalizer(_stop_gpg_agent)
+    signer = GPGSigner(keyid=None, passphrase="testing", homedir=tmp_path,)
+    # pylint: disable=protected-access
+    await signer._debug_init_store()
+
+    yield signer
+
+
+def test_for_signature(caplog: LogCaptureFixture):
+    """Tests subclass instantiation."""
+    caplog.set_level(logging.FATAL, logger="gnupg")
+    result = Signer.for_signature("PGP SIGNATURE")
+    assert result
+    assert isinstance(result, GPGSigner)
+
+
+async def test_simple(gpgsigner: GPGSigner):
+    """Test configuration signing and verification using GPG."""
+
+    data = b"TEST DATA"
+
+    # Generate a signature for the test data ...
+    signature = await gpgsigner.sign(data)
+    assert "PGP SIGNATURE" in signature
+
+    # Verify the generated signature against the test data ...
+    result = await gpgsigner.verify(data, signature)
+    assert result.valid
+    assert gpgsigner.keyid.endswith(result.key_id)
+    assert result.status == "signature valid"
+
+
+async def test_bad_data(gpgsigner: GPGSigner):
+    """Test configuration signing and verification using GPG with bad data."""
+
+    data = b"TEST DATA"
+
+    # Generate a signature for the test data ...
+    signature = await gpgsigner.sign(data)
+    assert "PGP SIGNATURE" in signature
+
+    data += b"tampertampertamper"
+
+    # Verify the generated signature against the test data ...
+    result = await gpgsigner.verify(data, signature)
+    assert not result.valid
+    assert gpgsigner.keyid.endswith(result.key_id)
+    assert result.status == "signature bad"
diff --git a/tests/test_imageconfig.py b/tests/test_imageconfig.py
index 3ee6f3a..32e347e 100644
--- a/tests/test_imageconfig.py
+++ b/tests/test_imageconfig.py
@@ -1,20 +1,34 @@
 #!/usr/bin/env python
 
-# pylint: disable=redefined-outer-name
+# pylint: disable=protected-access,redefined-outer-name
 
 """ImageConfig tests."""
 
-import copy
+import json
+import logging
 
+from copy import deepcopy
 from typing import List
 
 import pytest
 
-from docker_sign_verify import FormattedSHA256, ImageConfig, Signer, SignatureTypes
+from docker_registry_client_async import FormattedSHA256
+from docker_sign_verify import (
+    DigestMismatchError,
+    ImageConfig,
+    NoSignatureError,
+    Signer,
+    SignatureTypes,
+    UnsupportedSignatureTypeError,
+)
 
 from .stubs import _signer_for_signature, FakeSigner
 from .testutils import get_test_data
 
+pytestmark = [pytest.mark.asyncio]
+
+LOGGER = logging.getLogger(__name__)
+
 
 @pytest.fixture
 def json_bytes(request) -> bytes:
@@ -101,13 +115,24 @@ def signature(request) -> str:
     return get_test_data(request, __name__, "signature", "r")
 
 
-def test_init(image_config: ImageConfig, image_config_signed: ImageConfig):
+def test___init__(image_config: ImageConfig, image_config_signed: ImageConfig):
     """Test that signed and unsigned configurations can be instantiated."""
     assert image_config
     assert image_config_signed
 
 
-def test_str(
+def test___bytes__(
+    image_config: ImageConfig,
+    image_config_signed: ImageConfig,
+    json_bytes: bytes,
+    json_bytes_signed: bytes,
+):
+    """Test __bytes__ pass-through for signed and unsigned configurations (with encoding)."""
+    assert bytes(image_config) == json_bytes
+    assert bytes(image_config_signed) == json_bytes_signed
+
+
+def test___str__(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     json_bytes: bytes,
@@ -118,51 +143,87 @@ def test_str(
     assert str(image_config_signed) == json_bytes_signed.decode("utf-8")
 
 
-def test_get_config(
+def test__get_labels():
+    """Test that labels are able to be retrieved."""
+    # Uppercase 'C'
+    assert ImageConfig._get_labels(json.loads('{"Config":{"Labels":{"x":"5"}}}')) == {
+        "x": "5"
+    }
+
+    # Lowercase 'C'
+    assert ImageConfig._get_labels(json.loads('{"config":{"Labels":{"x":"5"}}}')) == {
+        "x": "5"
+    }
+
+    # Missing 'Labels'
+    assert ImageConfig._get_labels(json.loads('{"Config":{}}')) == {}
+
+
+def test__normalize():
+    """Test that signed and unsigned configuration can be normalized."""
+    # Missing 'Labels'
+    assert ImageConfig._normalize(json.loads('{"Config":{}}')) == {
+        "Config": {"Labels": {"signatures": "[]"}}
+    }
+
+    # Missing 'signatures'
+    assert ImageConfig._normalize(json.loads('{"Config":{"Labels":{"x":"5"}}}')) == {
+        "Config": {"Labels": {"signatures": "[]", "x": "5"}}
+    }
+
+    # Empty 'signatures'
+    assert ImageConfig._normalize(
+        json.loads('{"Config":{"Labels":{"signatures":"[]","x":"5"}}}')
+    ) == {"Config": {"Labels": {"signatures": "[]", "x": "5"}}}
+
+    # Existing 'signatures'
+    assert ImageConfig._normalize(
+        json.loads('{"Config":{"Labels":{"signatures":"[{\\"y\\":\\"4\\"}]","x":"5"}}}')
+    ) == {"Config": {"Labels": {"signatures": '[{"y":"4"}]', "x": "5"}}}
+
+
+def test_get_bytes(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     json_bytes: bytes,
     json_bytes_signed: bytes,
 ):
-    """Test get_config() pass-through for signed and unsigned configurations."""
-    assert image_config.get_config() == json_bytes
-    assert image_config_signed.get_config() == json_bytes_signed
+    """Test get_bytes() pass-through for signed and unsigned configurations."""
+    assert image_config.get_bytes() == json_bytes
+    assert image_config_signed.get_bytes() == json_bytes_signed
 
 
-def test_get_config_canonical(
+def test_get_bytes_canonical(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     json_bytes_canonical: bytes,
     json_bytes_signed_canonical: bytes,
 ):
     """Test the canonical form for signed and unsigned configurations."""
-    assert image_config.get_config_canonical() == json_bytes_canonical
-    assert image_config_signed.get_config_canonical() == json_bytes_signed_canonical
+    assert image_config.get_bytes_canonical() == json_bytes_canonical
+    assert image_config_signed.get_bytes_canonical() == json_bytes_signed_canonical
 
 
-def test_get_config_digest(
+def test_get_digest(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     config_digest: str,
     config_digest_signed: str,
 ):
     """Test digest calculation for signed and unsigned configurations."""
-    assert image_config.get_config_digest() == config_digest
-    assert image_config_signed.get_config_digest() == config_digest_signed
+    assert image_config.get_digest() == config_digest
+    assert image_config_signed.get_digest() == config_digest_signed
 
 
-def test_get_config_digest_canonical(
+def test_get_digest_canonical(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     config_digest_canonical: str,
     config_digest_signed_canonical: str,
 ):
     """Test canonical digest calculation for signed and unsigned configurations."""
-    assert image_config.get_config_digest_canonical() == config_digest_canonical
-    assert (
-        image_config_signed.get_config_digest_canonical()
-        == config_digest_signed_canonical
-    )
+    assert image_config.get_digest_canonical() == config_digest_canonical
+    assert image_config_signed.get_digest_canonical() == config_digest_signed_canonical
 
 
 def test_get_image_layers(
@@ -173,6 +234,18 @@ def test_get_image_layers(
     assert image_config_signed.get_image_layers() == image_layers
 
 
+def test_clear_signature_list(
+    image_config: ImageConfig, image_config_signed: ImageConfig
+):
+    """Test signature data parsing for signed and unsigned configurations."""
+    image_config_signed.clear_signature_list()
+    assert not image_config_signed.get_signature_list()
+
+    image_config.clear_signature_list()
+    signatures_unsigned = image_config.get_signature_list()
+    assert not signatures_unsigned
+
+
 def test_get_signature_list(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
@@ -189,7 +262,8 @@ def test_get_signature_list(
     assert not signatures_unsigned
 
 
-def test_sign(
+# TODO: Scale out these tests to use all types of signers ...
+async def test_sign(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     config_digest_canonical: str,
@@ -198,19 +272,19 @@ def test_sign(
     """Test configuration signing for signed and unsigned configurations."""
 
     signer = FakeSigner()
-    assert image_config.sign(signer) == signer.signature_value
-    assert image_config_signed.sign(signer) == signer.signature_value
+    assert await image_config.sign(signer) == signer.signature_value
+    assert await image_config_signed.sign(signer) == signer.signature_value
 
     # Previously unsigned configurations should now contain the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config.get_bytes()
     signatures = image_config.get_signature_list()
     assert len(signatures) == 1
     assert signatures[0]["digest"] == config_digest_canonical
     assert signatures[0]["signature"] == signer.signature_value
 
     # Previously signed configurations should now contain the original signature(s) and the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_config()
-    assert b"BEGIN PGP SIGNATURE" in image_config_signed.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_bytes()
+    assert b"BEGIN PGP SIGNATURE" in image_config_signed.get_bytes()
     signatures_signed = image_config_signed.get_signature_list()
     assert len(signatures_signed) == 2
     assert signatures_signed[0]["digest"] == config_digest_canonical
@@ -219,7 +293,7 @@ def test_sign(
     assert signatures_signed[1]["signature"] == signer.signature_value
 
 
-def test_sign_endorse(
+async def test_sign_endorse(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     config_digest_canonical: str,
@@ -229,22 +303,25 @@ def test_sign_endorse(
     """Test configuration endorsement for signed and unsigned configurations."""
 
     signer = FakeSigner()
-    assert image_config.sign(signer, SignatureTypes.ENDORSE) == signer.signature_value
     assert (
-        image_config_signed.sign(signer, SignatureTypes.ENDORSE)
+        await image_config.sign(signer, SignatureTypes.ENDORSE)
+        == signer.signature_value
+    )
+    assert (
+        await image_config_signed.sign(signer, SignatureTypes.ENDORSE)
         == signer.signature_value
     )
 
     # Previously unsigned configurations should now contain the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config.get_bytes()
     signatures = image_config.get_signature_list()
     assert len(signatures) == 1
     assert signatures[0]["digest"] == config_digest_canonical
     assert signatures[0]["signature"] == signer.signature_value
 
     # Previously signed configurations should now contain the original signature(s) and the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_config()
-    assert b"BEGIN PGP SIGNATURE" in image_config_signed.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_bytes()
+    assert b"BEGIN PGP SIGNATURE" in image_config_signed.get_bytes()
     signatures_signed = image_config_signed.get_signature_list()
     assert len(signatures_signed) == 2
     assert signatures_signed[0]["digest"] == config_digest_canonical
@@ -253,103 +330,122 @@ def test_sign_endorse(
     assert signatures_signed[1]["signature"] == signer.signature_value
 
 
-def test_sign_resign(
+async def test_sign_resign(
     image_config: ImageConfig,
     image_config_signed: ImageConfig,
     config_digest_canonical: str,
-    config_digest_signed_canonical: str,
-    signature: str,
 ):
     """Test configuration resigning for signed and unsigned configurations."""
 
     signer = FakeSigner()
-    assert image_config.sign(signer, SignatureTypes.RESIGN) == signer.signature_value
     assert (
-        image_config_signed.sign(signer, SignatureTypes.RESIGN)
+        await image_config.sign(signer, SignatureTypes.RESIGN) == signer.signature_value
+    )
+    assert (
+        await image_config_signed.sign(signer, SignatureTypes.RESIGN)
         == signer.signature_value
     )
 
     # Previously unsigned configurations should now contain the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config.get_bytes()
     signatures = image_config.get_signature_list()
     assert len(signatures) == 1
     assert signatures[0]["digest"] == config_digest_canonical
     assert signatures[0]["signature"] == signer.signature_value
 
     # Previously signed configurations should now contain (only) the new signature.
-    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_config()
-    assert b"BEGIN PGP SIGNATURE" not in image_config_signed.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config_signed.get_bytes()
+    assert b"BEGIN PGP SIGNATURE" not in image_config_signed.get_bytes()
     signatures_signed = image_config_signed.get_signature_list()
     assert len(signatures_signed) == 1
     assert signatures[0]["digest"] == config_digest_canonical
     assert signatures[0]["signature"] == signer.signature_value
 
 
-def test_sign_endorse_recursive(image_config: ImageConfig):
+async def test_sign_endorse_recursive(image_config: ImageConfig):
     """Test interlaced signatures and endorsements."""
 
     # Stack representation of a binary tree
-    stack = [copy.deepcopy(image_config)]
+    stack = [{"name": "?-Unsigned", "image_config": deepcopy(image_config)}]
+    LOGGER.debug("Unsigned Canonical Digest: %s", image_config.get_digest_canonical())
+
+    async def append_new_image_config(
+        *,
+        config: ImageConfig,
+        signature_type: SignatureTypes = SignatureTypes.SIGN,
+        iteration,
+    ):
+        action = f"X{signature_type.name}"
+        signer = FakeSigner(f"[{iteration}-{action: <8}: {{0}}]")
+        await config.sign(signer, signature_type)
+        stack.append({"name": f"{iteration}-{action}", "image_config": config})
+
     iterations = 6
     # Breadth first traversal ...
     for i in range(iterations):
+        LOGGER.debug("Iteration %d", i)
         for _ in range(len(stack)):
+            frame = stack[0]
+            LOGGER.debug("  Checking %s", frame["name"])
             # Validate the signature / endorsement permutations of the first entry on the stack ...
-            signatures = stack[0].get_signature_list()
-            assert len(signatures) == i
+            signatures = frame["image_config"].get_signature_list()
+
+            flat_list = "".join([signature["signature"] for signature in signatures])
+            if f"X{SignatureTypes.RESIGN.name}" in flat_list:
+                # Too lazy to calculate how many signatures were removed ...
+                assert len(signatures) <= i
+            else:
+                assert len(signatures) == i
+
             for sig, signature in enumerate(signatures):
-                if "Signing" in signature["signature"] or sig == 0:
-                    # Signature digests should be independent of the number of signatures
-                    assert (
-                        signature["digest"]
-                        == image_config.get_config_digest_canonical()
-                    )
-                else:
-                    # Endorsement digests should include all entities of a lower order
-                    temp = copy.deepcopy(stack[0])
+                LOGGER.debug("    %s", signature["signature"])
+                if f"X{SignatureTypes.ENDORSE.name}" in signature["signature"]:
+                    # Endorsement digests should include all entities of a lower order.
+                    temp = deepcopy(frame["image_config"])
                     temp.set_signature_list(temp.get_signature_list()[:sig])
-                    assert signature["digest"] == temp.get_config_digest_canonical()
-
-            def append_new_image_config(
-                config: ImageConfig,
-                signature_type: SignatureTypes = SignatureTypes.SIGN,
+                    assert signature["digest"] == temp.get_digest_canonical()
+                    assert temp.get_digest_canonical() in signature["signature"]
+                else:
+                    # Signature digests should be independent of the number of signatures.
+                    # Re-signed images should always contain 1 signature.
+                    assert signature["digest"] == image_config.get_digest_canonical()
+                    assert image_config.get_digest_canonical() in signature["signature"]
+
+            # Unshift the first image configuration, append three more image configurations on to the stack: ...
+            # ... one signed ...
+            await append_new_image_config(
+                config=deepcopy(frame["image_config"]), iteration=i
+            )
+            # ... one endorsed ...
+            await append_new_image_config(
+                config=deepcopy(frame["image_config"]),
+                signature_type=SignatureTypes.ENDORSE,
+                iteration=i,
+            )
+            # ... one resigned ...
+            await append_new_image_config(
+                config=stack.pop(0).get("image_config"),
+                signature_type=SignatureTypes.RESIGN,
                 iteration=i,
-            ):
-                signer = FakeSigner(
-                    "<<< {0} {1}: {2} >>>".format(
-                        iteration,
-                        "Endorsing"
-                        if signature_type == SignatureTypes.ENDORSE
-                        else "Signing",
-                        config.get_config_digest_canonical(),
-                    )
-                )
-                config.sign(signer, signature_type)
-                stack.append(config)
-
-            # TODO: Add optimization to stop appending to the stack if they will never be validated
-
-            # Push two more image configurations on to the stack: one signed, one endorsed ...
-            append_new_image_config(copy.deepcopy(stack[0]))
-            append_new_image_config(stack.pop(0), SignatureTypes.ENDORSE)
-
-
-def test_verify_signatures(image_config: ImageConfig):
+            )
+
+
+async def test_verify_signatures(image_config: ImageConfig):
     """Test signature verification for signed and unsigned configurations."""
 
     # Unsigned configurations should explicitly raise an exception.
-    with pytest.raises(Exception) as exception:
-        image_config.verify_signatures()
+    with pytest.raises(NoSignatureError) as exception:
+        await image_config.verify_signatures()
     assert str(exception.value) == "Image does not contain any signatures!"
 
     # Sign a previously unsigned configuration, so that only the new signature type is present.
     # Note: It is not trivial to embed "known" GPG / PKI signature types, as assumptions about the
     #       test environment are difficult to make.
-    image_config.sign(FakeSigner())
+    await image_config.sign(FakeSigner())
 
     # An exception should be raised if the provider for a signature type is not known
-    with pytest.raises(Exception) as exception:
-        image_config.verify_signatures()
+    with pytest.raises(UnsupportedSignatureTypeError) as exception:
+        await image_config.verify_signatures()
     assert str(exception.value) == "Unsupported signature type!"
 
     # Replace the class method for resolving signature providers ...
@@ -357,74 +453,78 @@ def test_verify_signatures(image_config: ImageConfig):
     Signer.for_signature = _signer_for_signature
 
     # The Signer's verify() method should be invoked.
-    assert image_config.verify_signatures()["results"] == [
-        {"type": "fake", "valid": True}
-    ]
+    response = await image_config.verify_signatures()
+    assert response["results"] == [{"type": "fake", "valid": True}]
 
     # Restore the original class method
     Signer.for_signature = original_method
 
 
-def test_verify_signatures_manipulated_signatures(image_config: ImageConfig):
+async def test_verify_signatures_manipulated_signatures(image_config: ImageConfig):
     """Test that signature verification detects manipulated signatures."""
 
     # Add a single signature ...
     signer = FakeSigner()
-    assert image_config.sign(signer) == signer.signature_value
+    assert await image_config.sign(signer) == signer.signature_value
 
     # Replace the class method for resolving signature providers ...
     original_method = Signer.for_signature
     Signer.for_signature = _signer_for_signature
 
     # Sanity check
-    assert image_config.verify_signatures()["results"][0]["valid"] is True
+    response = await image_config.verify_signatures()
+    assert response["results"][0]["valid"] is True
 
     # Modify the digest value of the (first) signature ...
     signatures = image_config.get_signature_list()
-    temp = copy.deepcopy(signatures)
+    temp = deepcopy(signatures)
     temp[0]["digest"] = "tampertampertamper"
     image_config.set_signature_list(temp)
 
     # An exception should be raised if digest value from the signature does not match the canonical digest of the image
     # configuration (without any signatures).
-    with pytest.raises(Exception) as exception:
-        image_config.verify_signatures()
+    with pytest.raises(DigestMismatchError) as exception:
+        await image_config.verify_signatures()
     assert str(exception.value).startswith("Image config canonical digest mismatch:")
 
     # Restore the unmodified signature and endorse ...
     image_config.set_signature_list(signatures)
-    assert image_config.sign(signer, SignatureTypes.ENDORSE) == signer.signature_value
+    assert (
+        await image_config.sign(signer, SignatureTypes.ENDORSE)
+        == signer.signature_value
+    )
 
     # Sanity check
-    assert image_config.verify_signatures()["results"][0]["valid"] is True
+    response = await image_config.verify_signatures()
+    assert response["results"][0]["valid"] is True
 
     # Modify the digest value of the second signature ...
     signatures = image_config.get_signature_list()
-    temp = copy.deepcopy(signatures)
+    temp = deepcopy(signatures)
     temp[1]["digest"] = "tampertampertamper"
     image_config.set_signature_list(temp)
 
     # An exception should be raised if digest value from the signature does not match the canonical digest of the image
     # configuration (including the first signature).
-    with pytest.raises(Exception) as exception:
-        image_config.verify_signatures()
+    with pytest.raises(DigestMismatchError) as exception:
+        await image_config.verify_signatures()
     assert str(exception.value).startswith("Image config canonical digest mismatch:")
 
     # Restore the original class method
     Signer.for_signature = original_method
 
 
-def test_minimal():
+async def test_minimal():
     """Test minimal image configuration (for non-conformant labels)k."""
 
     # Note: At a minimum, [Cc]onfig key must exist with non-null value
     image_config = ImageConfig(b'{"Config":{}}')
-    config_digest_canonical = image_config.get_config_digest_canonical()
+    config_digest_canonical = image_config.get_digest_canonical()
     signer = FakeSigner()
-    assert image_config.sign(signer) == signer.signature_value
+    assert await image_config.sign(signer) == signer.signature_value
 
     # A signature should always be able to be added ...
-    assert b"BEGIN FAKE SIGNATURE" in image_config.get_config()
+    assert b"BEGIN FAKE SIGNATURE" in image_config.get_bytes()
     signatures = image_config.get_signature_list()
     assert len(signatures) == 1
     assert signatures[0]["digest"] == config_digest_canonical
diff --git a/tests/test_imagename.py b/tests/test_imagename.py
deleted file mode 100644
index 19cc53e..0000000
--- a/tests/test_imagename.py
+++ /dev/null
@@ -1,121 +0,0 @@
-#!/usr/bin/env python
-
-# pylint: disable=redefined-outer-name
-
-"""ImageName tests."""
-
-import pytest
-
-from docker_sign_verify import ImageName
-
-
-@pytest.fixture()
-def url():
-    """Provides a fake URL with endpoint, port, namespace, image, and tag segments."""
-    return "endpoint:port/namespace/image:tag"
-
-
-@pytest.fixture()
-def image_name(url: str) -> ImageName:
-    """Provides ImageName instance for the fake URL."""
-    return ImageName.parse(url)
-
-
-def test_init():
-    """Test that image name can be instantiated."""
-    assert ImageName("endpoint:port", "namespace/image", "tag")
-
-
-def test_str():
-    """Test __str__ pass-through for different variants."""
-    for endpoint in ["endpoint.io", "endpoint:port", None]:
-        for image in ["image", "namespace/image"]:
-            for tag in ["tag", None]:
-                image_name = ImageName(endpoint, image, tag)
-                string = str(image_name)
-                assert image in string
-                if endpoint:
-                    assert endpoint in string
-                if tag:
-                    assert tag in string
-                assert "None" not in string
-
-
-def test_parse_string():
-    """Test string parsing for complex image names."""
-    for endpoint in ["endpoint.io", "endpoint:port", None]:
-        for image in ["image", "namespace/image"]:
-            for tag in ["tag", None]:
-
-                # Construct a complex string ...
-                string = image
-                if tag:
-                    string = "{0}:{1}".format(string, tag)
-                if endpoint:
-                    string = "{0}/{1}".format(endpoint, string)
-
-                # Verify the complex string was parsed correctly ...
-                # pylint: disable=protected-access
-                result = ImageName._parse_string(string)
-                assert result["endpoint"] == endpoint
-                assert result["image"] == image
-                assert result["tag"] == tag
-
-
-def test_parse():
-    """Test initialization via parsed strings."""
-    (endpoint, image, tag) = ["address:port", "namespace/image", "tag"]
-    image_name = ImageName.parse("{0}/{1}:{2}".format(endpoint, image, tag))
-    assert image_name.endpoint == endpoint
-    assert image_name.image == image
-    assert image_name.tag == tag
-
-
-def test_endpoint(image_name: ImageName):
-    """Tests endpoint retrieval."""
-    assert image_name.endpoint == "endpoint:port"
-
-
-def test_image(image_name: ImageName):
-    """Tests image retrieval."""
-    assert image_name.image == "namespace/image"
-
-
-def test_tag(image_name: ImageName):
-    """Tests tag retrieval."""
-    assert image_name.tag == "tag"
-
-
-def test_resolv_endpoint(image_name: ImageName):
-    """Test endpoint resolution."""
-    assert image_name.resolve_endpoint() == "endpoint:port"
-
-    image_name = ImageName(None, image_name.image, image_name.tag)
-    assert image_name.resolve_endpoint() == ImageName.DEFAULT_REGISTRY_ENDPOINT
-
-
-def test_resolv_image(image_name: ImageName):
-    """Test image resolution."""
-    assert image_name.resolve_image() == "namespace/image"
-
-    image_name = ImageName(image_name.endpoint, "image", image_name.tag)
-    assert image_name.resolve_image() == "{0}/{1}".format(
-        ImageName.DEFAULT_REGISTRY_NAMESPACE, image_name.image
-    )
-
-
-def test_resolv_name(image_name: ImageName):
-    """Test name resolution."""
-    assert image_name.resolve_name() == "{0}/{1}:{2}".format(
-        image_name.resolve_endpoint(),
-        image_name.resolve_image(),
-        image_name.resolve_tag(),
-    )
-
-
-def test_resolv_tag(image_name: ImageName):
-    """Test tag resolution."""
-    assert image_name.resolve_tag() == "tag"
-
-    image_name = ImageName(image_name.endpoint, image_name.image, None)
-    assert image_name.resolve_tag() == ImageName.DEFAULT_REGISTRY_TAG
diff --git a/tests/test_imagesources.py b/tests/test_imagesource.py
similarity index 68%
rename from tests/test_imagesources.py
rename to tests/test_imagesource.py
index f099846..c8093e9 100644
--- a/tests/test_imagesources.py
+++ b/tests/test_imagesource.py
@@ -7,10 +7,12 @@
 import json
 import pytest
 
-from docker_sign_verify import ImageName, ImageSource, Signer
+from docker_registry_client_async import ImageName
+from docker_sign_verify import ImageSource, NoSignatureError, Signer
 
 from .stubs import _signer_for_signature, FakeRegistryV2ImageSourceNoLabels
-from .test_imagename import image_name, url
+
+pytestmark = [pytest.mark.asyncio]
 
 # TODO: Additionally, test all methods with config.json that *does* contain 'Labels' ...
 
@@ -22,23 +24,29 @@ def fake_registry_v2_image_source(request) -> FakeRegistryV2ImageSourceNoLabels:
     return FakeRegistryV2ImageSourceNoLabels(request, layer_exists=True, dry_run=True)
 
 
-def test_init(fake_registry_v2_image_source: ImageSource):
+@pytest.fixture()
+def image_name() -> ImageName:
+    """Provides ImageName instance for the fake URL."""
+    return ImageName.parse("endpoint:port/namespace/image:tag")
+
+
+def test___init__(fake_registry_v2_image_source: ImageSource):
     """Test that the image source can be instantiated."""
     assert fake_registry_v2_image_source
 
 
-def test__sign_image_config(
+async def test__sign_image_config(
     fake_registry_v2_image_source: FakeRegistryV2ImageSourceNoLabels,
     image_name: ImageName,
 ):
     """Test adding signature(s) to the image configuration."""
-    result = fake_registry_v2_image_source.quick_sign(image_name)
+    result = await fake_registry_v2_image_source.quick_sign(image_name)
     assert result
 
     image_config = result["image_config"]
     assert image_config
     assert "FAKE SIGNATURE" in str(image_config)
-    assert json.loads(image_config.get_config())
+    assert json.loads(image_config.get_bytes())
 
     signature_value = result["signature_value"]
     assert signature_value
@@ -50,11 +58,11 @@ def test__sign_image_config(
 
     manifest = verify_image_data["manifest"]
     assert manifest
-    assert manifest.get_config_digest() == image_config.get_config_digest()
+    assert manifest.get_config_digest() == image_config.get_digest()
     assert len(manifest.get_layers()) == len(image_config.get_image_layers())
 
 
-def test__verify_image_config(
+async def test__verify_image_config(
     fake_registry_v2_image_source: FakeRegistryV2ImageSourceNoLabels,
     image_name: ImageName,
 ):
@@ -65,15 +73,15 @@ def assertions(result: dict):
 
         image_config = result["image_config"]
         assert image_config
-        assert json.loads(image_config.get_config())
+        assert json.loads(image_config.get_bytes())
 
         image_layers = result["image_layers"]
         assert image_layers
 
         manifest = result["manifest"]
         assert manifest
-        assert manifest.get_config_digest() == image_config.get_config_digest()
-        assert json.loads(image_config.get_config())
+        assert manifest.get_config_digest() == image_config.get_digest()
+        assert json.loads(image_config.get_bytes())
 
         manifest_layers = result["manifest_layers"]
         assert manifest_layers
@@ -81,34 +89,34 @@ def assertions(result: dict):
 
     # 1. Pre signature
     # pylint: disable=protected-access
-    assertions(fake_registry_v2_image_source._verify_image_config(image_name))
+    assertions(await fake_registry_v2_image_source._verify_image_config(image_name))
 
     # Sign
-    fake_registry_v2_image_source.quick_sign(image_name)
+    await fake_registry_v2_image_source.quick_sign(image_name)
 
     # 2. Post signature
     # pylint: disable=protected-access
-    assertions(fake_registry_v2_image_source._verify_image_config(image_name))
+    assertions(await fake_registry_v2_image_source._verify_image_config(image_name))
 
 
-def test_verify_image_signatures(
+async def test_verify_image_signatures(
     fake_registry_v2_image_source: FakeRegistryV2ImageSourceNoLabels,
     image_name: ImageName,
 ):
     """Test verifying the signatures within the image configuration."""
     # An exception should be raised if the image configuration is not signed
-    with pytest.raises(Exception) as exception:
-        fake_registry_v2_image_source.verify_image_signatures(image_name)
+    with pytest.raises(NoSignatureError) as exception:
+        await fake_registry_v2_image_source.verify_image_signatures(image_name)
     assert str(exception.value) == "Image does not contain any signatures!"
 
     # Sign
-    fake_registry_v2_image_source.quick_sign(image_name)
+    await fake_registry_v2_image_source.quick_sign(image_name)
 
     # Replace the class method for resolving signature providers ...
     original_method = Signer.for_signature
     Signer.for_signature = _signer_for_signature
 
-    result = fake_registry_v2_image_source.verify_image_signatures(image_name)
+    result = await fake_registry_v2_image_source.verify_image_signatures(image_name)
     assert result["image_config"]
     assert result["signatures"]
 
diff --git a/tests/test_imagesources_registry.py b/tests/test_imagesources_registry.py
deleted file mode 100644
index afb0f53..0000000
--- a/tests/test_imagesources_registry.py
+++ /dev/null
@@ -1,224 +0,0 @@
-#!/usr/bin/env python
-
-# pylint: disable=redefined-outer-name
-
-"""RegistryV2ImageSource tests."""
-
-import base64
-import copy
-import tempfile
-
-import pytest
-
-from docker_sign_verify import (
-    ImageConfig,
-    ImageName,
-    RegistryV2ImageSource,
-    RegistryV2Manifest,
-)
-from docker_sign_verify.utils import FormattedSHA256
-
-from .stubs import FakeSigner
-from .testutils import get_test_data_path
-
-KNOWN_GOOD_IMAGE_LIST = ["busybox:1.30.1", "library/python:3.7.2-slim-stretch"]
-
-
-@pytest.fixture
-def registry_v2_image_source() -> RegistryV2ImageSource:
-    """Provides a RegistryV2ImageSource instance."""
-    # Do not use caching; get a new instance for each test
-    return RegistryV2ImageSource(dry_run=True)
-
-
-def test_init(registry_v2_image_source: RegistryV2ImageSource):
-    """Test that the image source can be instantiated."""
-    assert registry_v2_image_source
-
-
-@pytest.mark.parametrize(
-    "endpoint,expected_username,expected_password",
-    [
-        ("endpoint:port", "username", "password"),
-        ("endpoint2:port2", "username2", "password2"),
-    ],
-)
-def test__get_credentials(
-    request,
-    registry_v2_image_source: RegistryV2ImageSource,
-    endpoint: str,
-    expected_username: str,
-    expected_password: str,
-):
-    """Test credentials retrieval."""
-    registry_v2_image_source.credentials_store = get_test_data_path(
-        request, "credentials_store.json"
-    )
-    # pylint: disable=protected-access
-    credentials = registry_v2_image_source._get_credentials(endpoint)
-    assert credentials
-
-    decoded = base64.decodebytes(credentials.encode("utf-8")).decode("utf-8")
-    assert decoded
-
-    actual_username, actual_password = decoded.split(":")
-    assert actual_username == expected_username
-    assert actual_password == expected_password
-
-
-@pytest.mark.parametrize(
-    "image,expected_credentials",
-    [
-        ("endpoint:port/image", "dXNlcm5hbWU6cGFzc3dvcmQ="),
-        ("endpoint2:port2/image", "dXNlcm5hbWUyOnBhc3N3b3JkMg=="),
-    ],
-)
-def test__get_request_headers(
-    request,
-    registry_v2_image_source: RegistryV2ImageSource,
-    image: str,
-    expected_credentials: str,
-):
-    """Test request headers retrieval."""
-    registry_v2_image_source.credentials_store = get_test_data_path(
-        request, "credentials_store.json"
-    )
-    image_name = ImageName.parse(image)
-    # pylint: disable=protected-access
-    headers = registry_v2_image_source._get_request_headers(image_name)
-    assert headers
-    assert expected_credentials in headers["Authorization"]
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_get_iamge_config(registry_v2_image_source: RegistryV2ImageSource, image: str):
-    """Test image configuration retrieval."""
-    image_name = ImageName.parse(image)
-    config = registry_v2_image_source.get_image_config(image_name)
-
-    assert config
-    assert isinstance(config, ImageConfig)
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_get_image_layer_to_disk(
-    registry_v2_image_source: RegistryV2ImageSource, image: str
-):
-    """Test layer retrieval to disk."""
-    image_name = ImageName.parse(image)
-    config_digest = registry_v2_image_source.get_manifest(
-        image_name
-    ).get_config_digest()
-    temp = tempfile.NamedTemporaryFile()
-    result = registry_v2_image_source.get_image_layer_to_disk(
-        image_name, config_digest, temp
-    )
-    assert result["digest"] == config_digest
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_get_manifest(registry_v2_image_source: RegistryV2ImageSource, image: str):
-    """Test manifest retrieval."""
-    image_name = ImageName.parse(image)
-    manifest = registry_v2_image_source.get_manifest(image_name)
-
-    assert manifest
-    assert isinstance(manifest, RegistryV2Manifest)
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_layer_exists(registry_v2_image_source: RegistryV2ImageSource, image: str):
-    """Test layer existence."""
-    image_name = ImageName.parse(image)
-    layer = registry_v2_image_source.get_manifest(image_name).get_layers()[-1]
-    assert registry_v2_image_source.layer_exists(image_name, layer)
-    assert not registry_v2_image_source.layer_exists(
-        image_name, FormattedSHA256("0" * 64)
-    )
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_sign_image_same_image_source(
-    registry_v2_image_source: RegistryV2ImageSource, image: str
-):
-    """Test image signing."""
-    src_image_name = ImageName.parse(image)
-    dest_image_name = copy.deepcopy(src_image_name)
-    dest_image_name.tag = "{0}_signed".format(dest_image_name.tag)
-
-    def assertions(result: dict):
-        assert result
-
-        image_config = result["image_config"]
-        assert image_config
-        assert "FAKE SIGNATURE" in str(image_config)
-
-        signature_value = result["signature_value"]
-        assert signature_value
-        assert "FAKE SIGNATURE" in signature_value
-
-        verify_image_data = result["verify_image_data"]
-        assert verify_image_data
-        assert image_config == verify_image_data["image_config"]
-
-        manifest = verify_image_data["manifest"]
-        assert manifest
-
-        manifest_signed = result["manifest_signed"]
-        assert manifest_signed
-        assert manifest_signed.get_config_digest() == image_config.get_config_digest()
-        assert len(manifest_signed.get_layers()) == len(image_config.get_image_layers())
-
-    # 1. Single signature
-    assertions(
-        registry_v2_image_source.sign_image(
-            FakeSigner(),
-            src_image_name,
-            registry_v2_image_source,
-            dest_image_name,
-            False,
-        )
-    )
-
-    # TODO: Test signing image twice (with same key, with different keys ...)
-    #       Can we do this here (using dockerhub), or do we need to do this in test_imageconfig.py???
-
-
-# TODO: test_sign_image_different_image_source
-
-
-@pytest.mark.online
-@pytest.mark.parametrize("image", KNOWN_GOOD_IMAGE_LIST)
-def test_verify_image_integrity(
-    registry_v2_image_source: RegistryV2ImageSource, image: str
-):
-    """Test image integrity."""
-    image_name = ImageName.parse(image)
-
-    def assertions(result: dict):
-        assert result
-
-        image_config = result["image_config"]
-        assert image_config
-
-        manifest = result["manifest"]
-        assert manifest
-
-        assert len(result["compressed_layer_files"]) == len(
-            result["uncompressed_layer_files"]
-        )
-
-        assert len(result["uncompressed_layer_files"]) == len(
-            result["uncompressed_layer_files"]
-        )
-
-    # 1. Unsigned
-    assertions(registry_v2_image_source.verify_image_integrity(image_name))
-
-    # TODO: Test integrity on a signed image ...
-    #       Can we do this here (using dockerhub), or do we need to do this in test_imageconfig.py???
diff --git a/tests/test_manifests.py b/tests/test_manifests.py
deleted file mode 100644
index cff60d5..0000000
--- a/tests/test_manifests.py
+++ /dev/null
@@ -1,261 +0,0 @@
-#!/usr/bin/env python
-
-# pylint: disable=redefined-outer-name
-
-"""Manifest tests."""
-
-import json
-import pytest
-
-from docker_sign_verify import (
-    ArchiveManifest,
-    DeviceMapperRepositoryManifest,
-    FormattedSHA256,
-    ImageName,
-    RegistryV2Manifest,
-)
-
-from .testutils import get_test_data
-
-
-@pytest.fixture()
-def formattedsha256() -> FormattedSHA256:
-    """Provides a FormattedSHA256 instance with a distinct digest value."""
-    return FormattedSHA256(
-        "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
-    )
-
-
-@pytest.fixture()
-def sha256_archive_layer(formattedsha256: FormattedSHA256) -> str:
-    """Provides the archive layer identifier for the distinct digest value."""
-    return "{0}/layer.tar".format(formattedsha256.sha256)
-
-
-@pytest.fixture
-def manifest_archive(request) -> bytes:
-    """Provides a sample archive manifest."""
-    return get_test_data(request, __name__, "manifest_archive.json")
-
-
-@pytest.fixture
-def manifest_registry(request) -> bytes:
-    """Provides a sample registry manifest."""
-    return get_test_data(request, __name__, "manifest_registry.json")
-
-
-@pytest.fixture
-def manifest_repository(request) -> bytes:
-    """Provides a sample repository manifest."""
-    return get_test_data(request, __name__, "manifest_repository.json")
-
-
-@pytest.fixture
-def archive_manifest(manifest_archive: bytes) -> ArchiveManifest:
-    """Provides an ArchiveManifest instance for the sample archive manifest."""
-    # Do not use caching; get a new instance for each test
-    return ArchiveManifest(manifest_archive)
-
-
-@pytest.fixture
-def devicemapper_repository_manifest(
-    manifest_repository: bytes
-) -> DeviceMapperRepositoryManifest:
-    """Provides a DeviceMapperRepositoryManifest instance for the sample repository manifest."""
-    # Do not use caching; get a new instance for each test
-    return DeviceMapperRepositoryManifest(manifest_repository)
-
-
-@pytest.fixture
-def registry_v2_manifest(manifest_registry: bytes) -> RegistryV2Manifest:
-    """Provides a RegistryV2Manifest instance for the sample registry manifest."""
-    # Do not use caching; get a new instance for each test
-    return RegistryV2Manifest(manifest_registry)
-
-
-def test_init(
-    archive_manifest: ArchiveManifest,
-    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
-    registry_v2_manifest: RegistryV2Manifest,
-):
-    """Test that all manifest types can be instantiated."""
-    assert archive_manifest
-    assert devicemapper_repository_manifest
-    assert registry_v2_manifest
-
-
-def test_str(
-    archive_manifest: ArchiveManifest,
-    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
-    registry_v2_manifest: RegistryV2Manifest,
-    manifest_archive: bytes,
-    manifest_registry: bytes,
-    manifest_repository: bytes,
-):
-    """Test __str__ pass-through for all manifest types (with encoding)."""
-
-    # Note: Due to whitespace differences, compare the parsed string form.
-    assert json.loads(str(archive_manifest)) == json.loads(manifest_archive)
-    assert json.loads(str(devicemapper_repository_manifest)) == json.loads(
-        manifest_repository
-    )
-    assert json.loads(str(registry_v2_manifest)) == json.loads(manifest_registry)
-
-
-def test_archive__layer_to_hash(
-    archive_manifest: ArchiveManifest,
-    sha256_archive_layer: str,
-    formattedsha256: FormattedSHA256,
-):
-    """Test layer to hash conversion."""
-    assert archive_manifest.layer_to_digest(sha256_archive_layer) == formattedsha256
-
-
-def test_archive__hash_to_layer(
-    archive_manifest: ArchiveManifest,
-    sha256_archive_layer: str,
-    formattedsha256: FormattedSHA256,
-):
-    """Test hash to layer conversion."""
-    assert archive_manifest.digest_to_layer(formattedsha256) == sha256_archive_layer
-
-
-@pytest.mark.parametrize(
-    "img_name,config_value",
-    [
-        (
-            "a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782",
-            "a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782.json",
-        ),
-        (
-            "base:7.2",
-            "adecf4209bb9dd67d96393774cbd7f8bd2bad3596da42cde33daa0c41b14ac62.json",
-        ),
-        (
-            "adecf4209bb9dd67d96393774cbd7f8bd2bad3596da42cde33daa0c41b14ac62",
-            "adecf4209bb9dd67d96393774cbd7f8bd2bad3596da42cde33daa0c41b14ac62.json",
-        ),
-    ],
-)
-def test_archive__get_config(
-    archive_manifest: ArchiveManifest, img_name: str, config_value: str
-):
-    """Test configuration retrieval."""
-    image_name = ImageName.parse(img_name)
-    assert archive_manifest.get_config(image_name)["Config"] == config_value
-
-
-@pytest.mark.parametrize("repotag", ["REPOTAG", None])
-def test_archive_append_config(
-    archive_manifest: ArchiveManifest,
-    sha256_archive_layer: str,
-    formattedsha256: FormattedSHA256,
-    repotag,
-):
-    """Test appending configurations."""
-    layers = [formattedsha256]
-    archive_manifest.append_config(formattedsha256, layers, repotag)
-    image_name = ImageName(None, formattedsha256.sha256)
-    config = archive_manifest.get_config(image_name)
-    assert config["Config"] == "{0}.json".format(formattedsha256.sha256)
-    assert config["Layers"] == [sha256_archive_layer]
-    assert config.get("RepoTags", None) == repotag
-
-
-def test_archive_get_config_digest(archive_manifest: ArchiveManifest):
-    """Test configuration digest retrieval."""
-    formattedsha256 = FormattedSHA256(
-        "a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782"
-    )
-    image_name = ImageName.parse(formattedsha256.sha256)
-    assert archive_manifest.get_config_digest(image_name) == formattedsha256
-
-
-def test_archive_get_layers(archive_manifest: ArchiveManifest):
-    """Test manifest layer retrieval."""
-    sha256 = "a51f3f9281a1a3d89dce25fec8acffbe9f59ddb67d98e04245c4c886e32d3782"
-    image_name = ImageName.parse(sha256)
-    assert archive_manifest.get_layers(image_name) == [
-        "sha256:137120c8596a15ab42c39c0c5cf83ef864b6b65b5516887c895915e87292bd07",
-        "sha256:755520f73bc74ae73b12f53229e401e8d4c584b74f5704d2d36ba7c45e2657cf",
-        "sha256:13fb089903a5e0e9b00d78ba48496da528ce8d81e08a1042ebeced8c35d714cb",
-        "sha256:f86d68f70ca006025a7f7013f69898f78d1d9272c4d3909e3ec4c7f9958da20e",
-        "sha256:7b4a4edd704242cec1710679a088be8aabff25c3a79f4eecbe8d11d57c53a20b",
-        "sha256:ef4724d42630f3022ef67c3f6749e85a13e81b8efcf98fbd517476499f10e5ab",
-    ]
-
-
-def test_registry_override_config(
-    registry_v2_manifest: RegistryV2Manifest, formattedsha256: FormattedSHA256
-):
-    """Test overriding manifest values."""
-    size = 1234
-    registry_v2_manifest.set_config_digest(formattedsha256, size)
-    assert registry_v2_manifest.get_config_digest() == formattedsha256
-    assert registry_v2_manifest.json["config"]["size"] == size
-
-
-def test_registry_get_config_digest(registry_v2_manifest: RegistryV2Manifest):
-    """Test image configuration digest retrieval."""
-    formattedsha256 = FormattedSHA256(
-        "8f1196ff19e7b5c5861de192ae77e8d7a692fcbca2dd3174d324980f72ab49bf"
-    )
-    assert registry_v2_manifest.get_config_digest() == formattedsha256
-    assert (
-        registry_v2_manifest.get_config_digest(ImageName.parse("ignored"))
-        == formattedsha256
-    )
-
-
-def test_registry_get_layers(registry_v2_manifest: RegistryV2Manifest):
-    """Test manifest layer retrieval."""
-    layers = [
-        "sha256:6c8c72249e560701aa1da4cd40192274a8c0419ddb8e4a553aa02b5a1acdb863",
-        "sha256:1403b179e2c9df4f57e9ea94e32882739c6b3d75ed756d4e67fcc424288c29cc",
-    ]
-    assert registry_v2_manifest.get_layers() == layers
-    assert registry_v2_manifest.get_layers(ImageName.parse("ignored")) == layers
-
-
-@pytest.mark.parametrize(
-    "img_name", ["endpoint:port/namespace/image:tag", "rancher/swarmkit:v1.13.0-beta.1"]
-)
-def test_repository_override_config(
-    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
-    formattedsha256: FormattedSHA256,
-    img_name: str,
-):
-    """Test overriding manifest values."""
-    image_name = ImageName.parse(img_name)
-    devicemapper_repository_manifest.override_config(formattedsha256, image_name)
-    assert (
-        devicemapper_repository_manifest.get_config_digest(image_name)
-        == formattedsha256
-    )
-
-
-@pytest.mark.parametrize(
-    "img_name,config_digest",
-    [
-        (
-            "busybox:latest",
-            "sha256:6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30",
-        )
-    ],
-)
-def test_repository_get_config_digest(
-    devicemapper_repository_manifest: DeviceMapperRepositoryManifest,
-    img_name: str,
-    config_digest: str,
-):
-    """Test image configuration digest retrieval."""
-    image_name = ImageName.parse(img_name)
-    assert (
-        devicemapper_repository_manifest.get_config_digest(image_name) == config_digest
-    )
-
-
-# def test_repository_get_layers(devicemapper_repository_manifest: DeviceMapperRepositoryManifest):
-#    """Test manifest layer retrieval."""
-#    with pytest.raises(NotImplementedError):
-#        devicemapper_repository_manifest.get_layers()
diff --git a/tests/test_pkisigner.py b/tests/test_pkisigner.py
new file mode 100644
index 0000000..33e8764
--- /dev/null
+++ b/tests/test_pkisigner.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""PKISigner tests."""
+
+import logging
+import os
+import tempfile
+
+import pytest
+
+from docker_sign_verify import PKISigner, Signer
+
+pytestmark = [pytest.mark.asyncio]
+
+LOGGER = logging.getLogger(__name__)
+
+
+@pytest.fixture()
+async def pkisigner(request) -> PKISigner:
+    """Provides a PKISigner instance."""
+    keypair = tempfile.mkstemp()[1]
+
+    def _remove_keypair():
+        os.remove(keypair)
+
+    request.addfinalizer(_remove_keypair)
+    signer = PKISigner(keypair_path=keypair, passphrase="testing")
+    # pylint: disable=protected-access
+    await signer._debug_init_keypair(2048)
+    return signer
+
+
+def test_for_signature():
+    """Tests subclass instantiation."""
+    result = Signer.for_signature("PKI SIGNATURE")
+    assert result
+    assert isinstance(result, PKISigner)
+
+
+async def test_simple(pkisigner: PKISigner):
+    """Test configuration signing and verification using PKI."""
+
+    data = b"TEST DATA"
+
+    # Generate a signature for the test data ...
+    signature = await pkisigner.sign(data)
+    assert "PKI SIGNATURE" in signature
+
+    # Verify the generated signature against the test data ...
+    result = await pkisigner.verify(data, signature)
+    assert result["valid"]
+    assert result["keypair_path"] == pkisigner.keypair_path
+    assert result["type"] == "pki"
+
+
+async def test_bad_data(pkisigner: PKISigner):
+    """Test configuration signing and verification using PKI with bad data."""
+
+    data = b"TEST DATA"
+
+    # Generate a signature for the test data ...
+    signature = await pkisigner.sign(data)
+    assert "PKI SIGNATURE" in signature
+
+    data += b"tampertampertamper"
+
+    # Verify the generated signature against the test data ...
+    result = await pkisigner.verify(data, signature)
+    assert not result["valid"]
+    assert result["keypair_path"] == pkisigner.keypair_path
+    assert result["type"] == "pki"
diff --git a/tests/test_registryv2imagesource.py b/tests/test_registryv2imagesource.py
new file mode 100644
index 0000000..1a5e918
--- /dev/null
+++ b/tests/test_registryv2imagesource.py
@@ -0,0 +1,394 @@
+#!/usr/bin/env python
+
+# pylint: disable=protected-access,redefined-outer-name
+
+"""RegistryV2ImageSource tests."""
+
+import logging
+
+from copy import deepcopy
+from datetime import datetime
+from pathlib import Path
+from typing import Dict
+
+import aiofiles
+import pytest
+
+from docker_registry_client_async import FormattedSHA256
+from docker_sign_verify import (
+    ImageConfig,
+    RegistryV2ImageSource,
+    RegistryV2Manifest,
+    SignatureTypes,
+)
+from docker_sign_verify.aiotempfile import open as aiotempfile
+from docker_sign_verify.imagesource import (
+    ImageSourceSignImage,
+    ImageSourceVerifyImageIntegrity,
+)
+
+from .localregistry import (
+    docker_client,
+    known_good_image_local,
+    known_good_image_remote,
+    pytest_registry,
+)  # Needed for pytest.fixtures
+from .stubs import FakeSigner
+from .testutils import hash_file
+
+pytestmark = [pytest.mark.asyncio]
+
+LOGGER = logging.getLogger(__name__)
+
+
+@pytest.fixture
+async def registry_v2_image_source() -> RegistryV2ImageSource:
+    """Provides a RegistryV2ImageSource instance."""
+    # Do not use caching; get a new instance for each test
+    # Implicitly tests __aenter__(), __aexit__(), and close()
+    async with RegistryV2ImageSource() as registry_v2_image_source:
+        yield registry_v2_image_source
+
+
+def test___init__(registry_v2_image_source: RegistryV2ImageSource):
+    """Test that the image source can be instantiated."""
+    assert registry_v2_image_source
+
+
+@pytest.mark.online
+async def test_get_image_config(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image configuration retrieval."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    config = await registry_v2_image_source.get_image_config(
+        known_good_image_local["image_name"], **kwargs
+    )
+    assert isinstance(config, ImageConfig)
+
+
+@pytest.mark.online
+async def test_get_image_layer_to_disk(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test layer retrieval to disk."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    manifest = await registry_v2_image_source.get_manifest(
+        known_good_image_local["image_name"], **kwargs
+    )
+    config_digest = manifest.get_config_digest()
+
+    LOGGER.debug("Retrieving blob: %s/%s ...", config_digest, config_digest)
+    async with aiotempfile(mode="w+b") as file:
+        result = await registry_v2_image_source.get_image_layer_to_disk(
+            known_good_image_local["image_name"], config_digest, file, **kwargs
+        )
+        LOGGER.debug("Verifying digest of written file ...")
+        assert await hash_file(file.name) == config_digest
+    assert result["digest"] == config_digest
+
+
+@pytest.mark.online
+async def test_get_manifest(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test manifest retrieval."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving manifest for: %s ...", known_good_image_local["image_name"]
+    )
+    manifest = await registry_v2_image_source.get_manifest(
+        known_good_image_local["image_name"], **kwargs
+    )
+    assert isinstance(manifest, RegistryV2Manifest)
+    assert (
+        manifest.get_digest() == known_good_image_local["image_name"].resolve_digest()
+    )
+
+
+@pytest.mark.online
+async def test_layer_exists(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test layer existence."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving manifest for: %s ...", known_good_image_local["image_name"]
+    )
+    manifest = await registry_v2_image_source.get_manifest(
+        known_good_image_local["image_name"], **kwargs
+    )
+    layer = manifest.get_layers()[-1]
+    assert await registry_v2_image_source.layer_exists(
+        known_good_image_local["image_name"], layer, **kwargs
+    )
+    assert not await registry_v2_image_source.layer_exists(
+        known_good_image_local["image_name"], FormattedSHA256("0" * 64), **kwargs
+    )
+
+
+# TODO async def test_put_image
+
+
+@pytest.mark.online_modification
+async def test_put_image_config(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image configuration assignment."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving image configuration for: %s ...",
+        known_good_image_local["image_name"],
+    )
+    image_config = await registry_v2_image_source.get_image_config(
+        known_good_image_local["image_name"], **kwargs
+    )
+
+    # Modify the configuration
+    json = image_config.get_json()
+    labels = image_config._get_labels(json)
+    labels["foo"] = datetime.now().strftime("%d%m%Y%H%M%S")
+    image_config._set_json(json)
+
+    LOGGER.debug(
+        "Storing modified image configuration: %s ...", image_config.get_digest()
+    )
+    response = await registry_v2_image_source.put_image_config(
+        known_good_image_local["image_name"], image_config, **kwargs
+    )
+    # Note: If NoneType, digest may already exist
+    assert response["digest"] == image_config.get_digest()
+
+
+@pytest.mark.online_modification
+async def test_put_image(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image layer assignment."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    image_name = known_good_image_local["image_name"]
+    LOGGER.debug("Retrieving image: %s ...", image_name)
+    response = await registry_v2_image_source.verify_image_integrity(
+        image_name, **kwargs
+    )
+
+    image_name.tag += "_copy"
+
+    LOGGER.debug("Storing image: %s ...", image_name)
+    response = await registry_v2_image_source.put_image(
+        registry_v2_image_source,
+        image_name,
+        response["manifest"],
+        response["image_config"],
+        response["compressed_layer_files"],
+        **kwargs,
+    )
+
+
+@pytest.mark.online_modification
+async def test_put_image_layer(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image layer assignment."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving image configuration for: %s ...",
+        known_good_image_local["image_name"],
+    )
+    image_config = await registry_v2_image_source.get_image_config(
+        known_good_image_local["image_name"], **kwargs
+    )
+
+    # Modify the configuration
+    json = image_config.get_json()
+    labels = image_config._get_labels(json)
+    labels["foo"] = datetime.now().strftime("%d%m%Y%H%M%S")
+    image_config._set_json(json)
+
+    LOGGER.debug(
+        "Storing modified image configuration: %s ...", image_config.get_digest()
+    )
+    response = await registry_v2_image_source.put_image_layer(
+        known_good_image_local["image_name"], image_config.get_bytes(), **kwargs
+    )
+    assert response["digest"] == image_config.get_digest()
+
+
+@pytest.mark.online_modification
+async def test_put_image_layer_from_disk(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    tmp_path: Path,
+    **kwargs,
+):
+    """Test image layer assignment from disk."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving image configuration for: %s ...",
+        known_good_image_local["image_name"],
+    )
+    image_config = await registry_v2_image_source.get_image_config(
+        known_good_image_local["image_name"], **kwargs
+    )
+
+    # Modify the configuration
+    json = image_config.get_json()
+    labels = image_config._get_labels(json)
+    labels["foo"] = datetime.now().strftime("%d%m%Y%H%M%S")
+    image_config._set_json(json)
+
+    LOGGER.debug("Writing modified configuration to disk ...")
+    path = tmp_path.joinpath("image_config.json")
+    async with aiofiles.open(path, mode="w+b") as file:
+        await file.write(image_config.get_bytes())
+
+    LOGGER.debug(
+        "Storing modified image configuration: %s ...", image_config.get_digest()
+    )
+    async with aiofiles.open(path, mode="r+b") as file:
+        response = await registry_v2_image_source.put_image_layer_from_disk(
+            known_good_image_local["image_name"], file, **kwargs
+        )
+    assert response["digest"] == image_config.get_digest()
+
+
+@pytest.mark.online_modification
+async def test_put_manifest(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test manifest assignment."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    LOGGER.debug(
+        "Retrieving manifest for: %s ...", known_good_image_local["image_name"]
+    )
+    manifest = await registry_v2_image_source.get_manifest(
+        known_good_image_local["image_name"], **kwargs
+    )
+    assert isinstance(manifest, RegistryV2Manifest)
+    assert (
+        manifest.get_digest() == known_good_image_local["image_name"].resolve_digest()
+    )
+
+    LOGGER.debug("Storing manifest for: %s ...", known_good_image_local["image_name"])
+    response = await registry_v2_image_source.put_manifest(
+        manifest, known_good_image_local["image_name"], **kwargs
+    )
+    assert response["digest"] == known_good_image_local["image_name"].resolve_digest()
+
+
+@pytest.mark.online_modification
+async def test_sign_image_same_image_source(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image signing."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+    dest_image_name = deepcopy(known_good_image_local["image_name"])
+    dest_image_name.digest = None
+    dest_image_name.tag = "{0}_signed".format(dest_image_name.tag)
+
+    def assertions(result: ImageSourceSignImage):
+        assert result
+
+        image_config = result["image_config"]
+        assert image_config
+        assert "FAKE SIGNATURE" in str(image_config)
+
+        signature_value = result["signature_value"]
+        assert signature_value
+        assert "FAKE SIGNATURE" in signature_value
+
+        verify_image_data = result["verify_image_data"]
+        assert verify_image_data
+        assert image_config == verify_image_data["image_config"]
+
+        manifest = verify_image_data["manifest"]
+        assert manifest
+
+        manifest_signed = result["manifest_signed"]
+        assert manifest_signed
+        assert manifest_signed.get_config_digest() == image_config.get_digest()
+        assert len(manifest_signed.get_layers()) == len(image_config.get_image_layers())
+
+    # 1. Single signature
+    assertions(
+        await registry_v2_image_source.sign_image(
+            FakeSigner(),
+            known_good_image_local["image_name"],
+            registry_v2_image_source,
+            dest_image_name,
+            SignatureTypes.SIGN,
+            **kwargs,
+        )
+    )
+
+    # TODO: Test signing image twice (with same key, with different keys ...)
+    #       Can we do this here (using dockerhub), or do we need to do this in test_imageconfig.py???
+
+
+# TODO: test_sign_image_different_image_source
+
+
+@pytest.mark.online
+async def test_verify_image_integrity(
+    registry_v2_image_source: RegistryV2ImageSource,
+    known_good_image_local: Dict,
+    **kwargs,
+):
+    """Test image integrity verification."""
+    if "protocol" in known_good_image_local:
+        kwargs["protocol"] = known_good_image_local["protocol"]
+
+    def assertions(result: ImageSourceVerifyImageIntegrity):
+        assert result
+
+        image_config = result["image_config"]
+        assert image_config
+
+        manifest = result["manifest"]
+        assert manifest
+
+        assert len(result["compressed_layer_files"]) == len(
+            result["uncompressed_layer_files"]
+        )
+
+        assert len(result["uncompressed_layer_files"]) == len(
+            result["uncompressed_layer_files"]
+        )
+
+    # 1. Unsigned
+    assertions(
+        await registry_v2_image_source.verify_image_integrity(
+            known_good_image_local["image_name"], **kwargs
+        )
+    )
+
+    # TODO: Test integrity on a signed image ...
+    #       Can we do this here (using dockerhub), or do we need to do this in test_imageconfig.py???
diff --git a/tests/test_registryv2manifest.py b/tests/test_registryv2manifest.py
new file mode 100644
index 0000000..18e32ce
--- /dev/null
+++ b/tests/test_registryv2manifest.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""Manifest tests."""
+
+import pytest
+
+from docker_registry_client_async import FormattedSHA256, ImageName
+
+from docker_sign_verify import RegistryV2Manifest
+
+from .testutils import get_test_data
+
+
+@pytest.fixture()
+def formattedsha256() -> FormattedSHA256:
+    """Provides a FormattedSHA256 instance with a distinct digest value."""
+    return FormattedSHA256(
+        "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
+    )
+
+
+@pytest.fixture
+def manifest_registry(request) -> bytes:
+    """Provides a sample registry manifest."""
+    return get_test_data(request, __name__, "manifest_registry.json")
+
+
+@pytest.fixture
+def registry_v2_manifest(manifest_registry: bytes) -> RegistryV2Manifest:
+    """Provides a RegistryV2Manifest instance for the sample registry manifest."""
+    # Do not use caching; get a new instance for each test
+    return RegistryV2Manifest(manifest_registry)
+
+
+def test___init__(registry_v2_manifest: RegistryV2Manifest):
+    """Test that an RegistryV2Manifest can be instantiated."""
+    assert registry_v2_manifest
+
+
+def test___bytes__(registry_v2_manifest: RegistryV2Manifest, manifest_registry: bytes):
+    """Test __str__ pass-through for different variants."""
+    assert bytes(registry_v2_manifest) == manifest_registry
+
+
+def test___str__(registry_v2_manifest: RegistryV2Manifest, manifest_registry: bytes):
+    """Test __str__ pass-through for different variants."""
+    assert str(registry_v2_manifest) == manifest_registry.decode("utf-8")
+
+
+def test_set_config_digest(
+    registry_v2_manifest: RegistryV2Manifest, formattedsha256: FormattedSHA256
+):
+    """Test overriding manifest values."""
+    size = 1234
+    registry_v2_manifest.set_config_digest(formattedsha256, size)
+    assert registry_v2_manifest.get_config_digest() == formattedsha256
+    assert registry_v2_manifest.get_json()["config"]["size"] == size
+
+
+# TODO: def test_set_layers(registry_v2_manifest: RegistryV2Manifest)
+
+
+def test_get_config_digest(registry_v2_manifest: RegistryV2Manifest):
+    """Test image configuration digest retrieval."""
+    formattedsha256 = FormattedSHA256(
+        "8f1196ff19e7b5c5861de192ae77e8d7a692fcbca2dd3174d324980f72ab49bf"
+    )
+    assert registry_v2_manifest.get_config_digest() == formattedsha256
+    assert (
+        registry_v2_manifest.get_config_digest(ImageName.parse("ignored"))
+        == formattedsha256
+    )
+
+
+def test_get_layers(registry_v2_manifest: RegistryV2Manifest):
+    """Test manifest layer retrieval."""
+    layers = [
+        "sha256:6c8c72249e560701aa1da4cd40192274a8c0419ddb8e4a553aa02b5a1acdb863",
+        "sha256:1403b179e2c9df4f57e9ea94e32882739c6b3d75ed756d4e67fcc424288c29cc",
+    ]
+    assert registry_v2_manifest.get_layers() == layers
+    assert registry_v2_manifest.get_layers(ImageName.parse("ignored")) == layers
diff --git a/tests/test_script_docker_copy_registry.py b/tests/test_script_docker_copy_registry.py
new file mode 100644
index 0000000..5a58e85
--- /dev/null
+++ b/tests/test_script_docker_copy_registry.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""CLI tests."""
+
+import logging
+
+from copy import deepcopy
+
+import pytest
+
+from _pytest.logging import LogCaptureFixture
+from docker_registry_client_async import Indices
+from docker_sign_verify import GPGSigner
+from docker_sign_verify.scripts.docker_copy import cli
+from docker_sign_verify.scripts.docker_sign import cli as cli_signer
+
+from .localregistry import (
+    docker_client,
+    known_good_image_local,
+    known_good_image_remote,
+    pytest_registry,
+    TypingKnownGoodImage,
+)  # Needed for pytest.fixtures
+from .test_script_docker_sign_registry import insecure_registry, temporary_gpg_homedir
+from .test_gpgsigner import gpgsigner
+
+LOGGER = logging.getLogger(__name__)
+
+
+def test_empty_args(clirunner):
+    """Test docker-verify CLI can be invoked."""
+    result = clirunner.invoke(cli, ["registry"], catch_exceptions=False)
+    assert "Usage:" in result.stdout
+    assert result.exit_code != 0
+
+
+@pytest.mark.online
+def test_invalid_keyid(
+    runner,
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-verify can handle signed images with unknown keyids."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry():
+        with temporary_gpg_homedir(gpgsigner.homedir):
+            result = clirunner.invoke(
+                cli_signer,
+                args=[
+                    "registry",
+                    "--keyid",
+                    gpgsigner.keyid,
+                    str(source),
+                    str(destination),
+                ],
+                input=f"{gpgsigner.passphrase}\n",
+            )
+            assert not result.exception
+            assert "Integrity check passed." in caplog.text
+            assert "Created new image" in caplog.text
+            assert str(destination) in caplog.text
+
+        caplog.clear()
+
+        copy = deepcopy(destination)
+        copy.tag += "_copy"
+
+        result = runner.invoke(cli, args=["registry", str(destination), str(copy)])
+        assert result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Verification failed for signature with key_id" in caplog.text
+        assert "no public key" in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_check_signatures(
+    runner, known_good_image_local: TypingKnownGoodImage, caplog: LogCaptureFixture
+):
+    """Test docker-verify can operate on images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_copy"
+
+    with insecure_registry():
+        result = runner.invoke(
+            cli,
+            args=["--check-signatures", "registry", str(source), str(destination),],
+        )
+    assert isinstance(result.exception, SystemExit)
+    assert "does not contain any signatures" in caplog.text
+    assert "is consistent." not in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_no_check_signatures(
+    runner, known_good_image_local: TypingKnownGoodImage, caplog: LogCaptureFixture
+):
+    """Test docker-verify can operate on images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_copy"
+
+    with insecure_registry():
+        result = runner.invoke(
+            cli,
+            args=[
+                "--very-verbose",
+                "--no-check-signatures",
+                "registry",
+                str(source),
+                str(destination),
+            ],
+        )
+    assert not result.exception
+    assert "is consistent." in caplog.text
+    assert "does not contain any signatures" not in caplog.text
+    assert "Replicated new image" in caplog.text
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_not_found(runner, caplog: LogCaptureFixture):
+    """Test docker-verify can handle incorrect image names."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = f"{Indices.DOCKERHUB}/library/python:dummy"
+    destination = f"{Indices.DOCKERHUB}/library/python:dummy_copy"
+    result = runner.invoke(cli, args=["registry", source, destination])
+    assert isinstance(result.exception, SystemExit)
+    assert "404" in caplog.text
+    assert "Not Found" in caplog.text
+    assert source in caplog.text
+
+
+@pytest.mark.online
+def test_signed(
+    runner,
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-verify can handle signed images."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli_signer,
+            args=[
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+        assert not result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Created new image" in caplog.text
+        assert str(destination) in caplog.text
+
+        caplog.clear()
+
+        copy = deepcopy(destination)
+        copy.tag += "_copy"
+
+        result = runner.invoke(cli, args=["registry", str(destination), str(copy)])
+        assert not result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Signature check passed." in caplog.text
+        assert " is consistent; 1 signature(s) verified." in caplog.text
+        assert "Replicated new image" in caplog.text
+        assert str(copy) in caplog.text
+
+
+@pytest.mark.online
+def test_unauthorized(runner, caplog: LogCaptureFixture):
+    """Test docker-verify can handle incorrect credentials."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = f"{Indices.DOCKERHUB}/dummy:dummy"
+    destination = f"{Indices.DOCKERHUB}/dummy:dummy_copy"
+    result = runner.invoke(cli, args=["registry", source, destination])
+    assert isinstance(result.exception, SystemExit)
+    assert "401" in caplog.text
+    assert "Unauthorized" in caplog.text
+    assert source in caplog.text
diff --git a/tests/test_script_docker_sign_registry.py b/tests/test_script_docker_sign_registry.py
new file mode 100644
index 0000000..1a8cc4c
--- /dev/null
+++ b/tests/test_script_docker_sign_registry.py
@@ -0,0 +1,332 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""CLI tests."""
+
+import logging
+
+from contextlib import contextmanager
+from copy import deepcopy
+from pathlib import Path
+
+import pytest
+
+from _pytest.logging import LogCaptureFixture
+from docker_registry_client_async import DockerRegistryClientAsync, Indices
+from docker_sign_verify.gpgsigner import GPGSigner
+from docker_sign_verify.scripts.docker_sign import cli
+
+from .localregistry import (
+    docker_client,
+    known_good_image_local,
+    known_good_image_remote,
+    pytest_registry,
+    TypingKnownGoodImage,
+)  # Needed for pytest.fixtures
+from .test_gpgsigner import gpgsigner
+
+LOGGER = logging.getLogger(__name__)
+
+
+@contextmanager
+def insecure_registry():
+    """Context manager to globally disable TLS for registry access."""
+    old = DockerRegistryClientAsync.DEFAULT_PROTOCOL
+    DockerRegistryClientAsync.DEFAULT_PROTOCOL = "http"
+    yield None
+    DockerRegistryClientAsync.DEFAULT_PROTOCOL = old
+
+
+@contextmanager
+def temporary_gpg_homedir(homedir: Path):
+    """Conext manager to globally set the GNUPGHOME location."""
+    old = GPGSigner.HOMEDIR
+    GPGSigner.HOMEDIR = homedir
+    yield None
+    GPGSigner.HOMEDIR = old
+
+
+@pytest.mark.online
+def test_bad_keyid(
+    clirunner, known_good_image_local: TypingKnownGoodImage, caplog: LogCaptureFixture
+):
+    """Test docker-sign can handle invalid keyids."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_foobar"
+
+    with insecure_registry():
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "registry",
+                "--keyid",
+                "invalidkeyid",
+                str(source),
+                str(destination),
+            ],
+            input="\n",
+        )
+
+    assert result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "Failed to create signature!" in caplog.text
+
+
+def test_empty_args(clirunner):
+    """Test docker-sign CLI can be invoked."""
+    result = clirunner.invoke(cli, ["registry"], catch_exceptions=False)
+    assert "Usage:" in result.stdout
+    assert result.exit_code != 0
+
+
+@pytest.mark.online
+def test_forced_digest_value(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can handle a forced digest value."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.tag += "_signed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert not result.exception
+    assert (
+        "It is not possible to store a signed image to a predetermined digest"
+        in caplog.text
+    )
+    assert "Integrity check passed." in caplog.text
+    assert "Created new image" in caplog.text
+    assert str(destination) not in caplog.text
+    destination.digest = None
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_endorse(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can endorse images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_endorsed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "--signature-type",
+                "endorse",
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert not result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "Created new image" in caplog.text
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_sign(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can sign images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "--signature-type",
+                "sign",
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert not result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "Created new image" in caplog.text
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_sign_implicit(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can sign (implicit) images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert not result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "Created new image" in caplog.text
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_resign(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can resign images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_resigned"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "--signature-type",
+                "resign",
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert not result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "Created new image" in caplog.text
+    assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_unauthorized_destination(
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-sign can handle incorrect credentials."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_foobar"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(known_good_image_local["image_name"]),
+                f"{Indices.DOCKERHUB}/dummy:dummy",
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+
+    assert result.exception
+    assert "Integrity check passed." in caplog.text
+    assert "401" in caplog.text
+    assert "Unauthorized" in caplog.text
+
+
+@pytest.mark.online
+def test_unauthorized_source(
+    clirunner, known_good_image_local: TypingKnownGoodImage, caplog: LogCaptureFixture,
+):
+    """Test docker-sign can handle incorrect credentials."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    with insecure_registry():
+        result = clirunner.invoke(
+            cli,
+            args=[
+                "registry",
+                "--keyid",
+                "invalidkeyid",
+                f"{Indices.DOCKERHUB}/dummy:dummy",
+                str(known_good_image_local["image_name"]),
+            ],
+            input="\n",
+        )
+
+    assert result.exception
+    assert "401" in caplog.text
+    assert "Unauthorized" in caplog.text
diff --git a/tests/test_script_docker_verify_registry.py b/tests/test_script_docker_verify_registry.py
new file mode 100644
index 0000000..7eae8e7
--- /dev/null
+++ b/tests/test_script_docker_verify_registry.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python
+
+# pylint: disable=redefined-outer-name
+
+"""CLI tests."""
+
+import logging
+
+from copy import deepcopy
+
+import pytest
+
+from _pytest.logging import LogCaptureFixture
+from docker_registry_client_async import Indices
+from docker_sign_verify import GPGSigner
+from docker_sign_verify.scripts.docker_sign import cli as cli_signer
+from docker_sign_verify.scripts.docker_verify import cli
+
+from .localregistry import (
+    docker_client,
+    known_good_image_local,
+    known_good_image_remote,
+    pytest_registry,
+    TypingKnownGoodImage,
+)  # Needed for pytest.fixtures
+from .test_script_docker_sign_registry import insecure_registry, temporary_gpg_homedir
+from .test_gpgsigner import gpgsigner
+
+LOGGER = logging.getLogger(__name__)
+
+
+def test_empty_args(clirunner):
+    """Test docker-verify CLI can be invoked."""
+    result = clirunner.invoke(cli, ["registry"], catch_exceptions=False)
+    assert "Usage:" in result.stdout
+    assert result.exit_code != 0
+
+
+@pytest.mark.online
+def test_invalid_keyid(
+    runner,
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-verify can handle signed images with unknown keyids."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry():
+        with temporary_gpg_homedir(gpgsigner.homedir):
+            result = clirunner.invoke(
+                cli_signer,
+                args=[
+                    "registry",
+                    "--keyid",
+                    gpgsigner.keyid,
+                    str(source),
+                    str(destination),
+                ],
+                input=f"{gpgsigner.passphrase}\n",
+            )
+            assert not result.exception
+            assert "Integrity check passed." in caplog.text
+            assert "Created new image" in caplog.text
+            assert str(destination) in caplog.text
+
+        caplog.clear()
+
+        result = runner.invoke(cli, args=["registry", str(destination)])
+        assert result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Verification failed for signature with key_id" in caplog.text
+        assert "no public key" in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_check_signatures(
+    runner, known_good_image_remote: TypingKnownGoodImage, caplog: LogCaptureFixture
+):
+    """Test docker-verify can operate on images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    image = str(known_good_image_remote["image_name"])
+    result = runner.invoke(cli, args=["--check-signatures", "registry", image,],)
+    assert isinstance(result.exception, SystemExit)
+    assert "does not contain any signatures" in caplog.text
+    assert "is consistent." not in caplog.text
+    assert image in caplog.text
+
+
+@pytest.mark.online
+def test_no_signatures_no_check_signatures(
+    runner, known_good_image_remote: TypingKnownGoodImage, caplog: LogCaptureFixture
+):
+    """Test docker-verify can operate on images without existing signatures."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    image = str(known_good_image_remote["image_name"])
+    result = runner.invoke(cli, args=["--no-check-signatures", "registry", image,],)
+    assert not result.exception
+    assert "is consistent." in caplog.text
+    assert "does not contain any signatures" not in caplog.text
+    assert image in caplog.text
+
+
+@pytest.mark.online
+def test_not_found(runner, caplog: LogCaptureFixture):
+    """Test docker-verify can handle incorrect image names."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    image = f"{Indices.DOCKERHUB}/library/python:dummy"
+    result = runner.invoke(cli, args=["registry", image])
+    assert isinstance(result.exception, SystemExit)
+    assert "404" in caplog.text
+    assert "Not Found" in caplog.text
+    assert image in caplog.text
+
+
+@pytest.mark.online
+def test_signed(
+    runner,
+    clirunner,
+    known_good_image_local: TypingKnownGoodImage,
+    gpgsigner: GPGSigner,
+    caplog: LogCaptureFixture,
+):
+    """Test docker-verify can handle signed images."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    source = known_good_image_local["image_name"]
+    destination = deepcopy(source)
+    destination.digest = None
+    destination.tag += "_signed"
+
+    with insecure_registry(), temporary_gpg_homedir(gpgsigner.homedir):
+        result = clirunner.invoke(
+            cli_signer,
+            args=[
+                "registry",
+                "--keyid",
+                gpgsigner.keyid,
+                str(source),
+                str(destination),
+            ],
+            input=f"{gpgsigner.passphrase}\n",
+        )
+        assert not result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Created new image" in caplog.text
+        assert str(destination) in caplog.text
+
+        caplog.clear()
+
+        result = runner.invoke(cli, args=["registry", str(destination)])
+        assert not result.exception
+        assert "Integrity check passed." in caplog.text
+        assert "Signature check passed." in caplog.text
+        assert " is consistent; 1 signature(s) verified." in caplog.text
+        assert str(destination) in caplog.text
+
+
+@pytest.mark.online
+def test_unauthorized(runner, caplog: LogCaptureFixture):
+    """Test docker-verify can handle incorrect credentials."""
+    caplog.clear()
+    caplog.set_level(logging.DEBUG)
+
+    image = f"{Indices.DOCKERHUB}/dummy:dummy"
+    result = runner.invoke(cli, args=["registry", image])
+    assert isinstance(result.exception, SystemExit)
+    assert "401" in caplog.text
+    assert "Unauthorized" in caplog.text
+    assert image in caplog.text
diff --git a/tests/test_signers.py b/tests/test_signers.py
deleted file mode 100644
index 223c818..0000000
--- a/tests/test_signers.py
+++ /dev/null
@@ -1,85 +0,0 @@
-#!/usr/bin/env python
-
-# pylint: disable=redefined-outer-name
-
-"""Signer tests."""
-
-import os
-import tempfile
-import shutil
-
-from pathlib import Path
-
-import pytest
-
-from docker_sign_verify import GPGSigner, PKISigner
-
-
-@pytest.fixture()
-def gpgsigner(request):
-    """Provides a GPGSinger instance."""
-    homedir = Path(tempfile.mkdtemp())
-
-    def _remove_homedir():
-        shutil.rmtree(homedir)
-
-    request.addfinalizer(_remove_homedir)
-    signer = GPGSigner(None, "testing", homedir)
-    # pylint: disable=protected-access
-    signer._debug_init_store()
-
-    return signer
-
-
-@pytest.fixture()
-def pkisigner(request):
-    """Provides a PKISigner instance."""
-    keypair = tempfile.mkstemp()[1]
-
-    def _remove_keypair():
-        os.remove(keypair)
-
-    request.addfinalizer(_remove_keypair)
-    signer = PKISigner(keypair, "testing")
-    # pylint: disable=protected-access
-    signer._debug_init_keypair()
-    return signer
-
-
-# def test_gpgsigner(caplog, gpgsigner: GPGSigner):
-@pytest.mark.skip(
-    reason="Fails on systems with GPG2 without loopback agent configured: https://github.com/isislovecruft/python-gnupg/issues/257"
-)
-def test_gpgsigner(gpgsigner: GPGSigner):
-    """Test configuration signing and verification using GPG."""
-
-    # TODO: Figure out why this isn't working ...
-    # caplog.set_level(logging.FATAL, logger="gnupg")
-
-    data = b"TEST DATA"
-
-    # Generate a signature for the test data ...
-    signature = gpgsigner.sign(data)
-    assert "PGP SIGNATURE" in signature
-
-    # Verify the generated signature against the test data ...
-    result = gpgsigner.verify(data, signature)
-    assert result.valid
-    assert gpgsigner.keyid == result.key_id
-    assert result.status == "signature valid"
-
-
-def test_pkisigner(pkisigner: PKISigner):
-    """Test configuration signing and verification using PKI."""
-
-    data = b"TEST DATA"
-
-    # Generate a signature for the test data ...
-    signature = pkisigner.sign(data)
-    assert "PKI SIGNATURE" in signature
-
-    # Verify the generated signature against the test data ...
-    result = pkisigner.verify(data, signature)
-    assert result["valid"]
-    assert result["keypair_path"] == pkisigner.keypair_path
-    assert result["type"] == "pki"
diff --git a/tests/testutils.py b/tests/testutils.py
index e7e06f0..45b3944 100644
--- a/tests/testutils.py
+++ b/tests/testutils.py
@@ -2,18 +2,23 @@
 
 """Utility classes."""
 
-import pytest
+import hashlib
 
-from click.testing import CliRunner
 from pathlib import Path
+from typing import Union
 
+import aiofiles
 
-def get_test_data_path(request, name):
+from docker_registry_client_async.formattedsha256 import FormattedSHA256
+from docker_registry_client_async.utils import CHUNK_SIZE
+
+
+def get_test_data_path(request, name) -> Path:
     """Helper method to retrieve the path of test data."""
     return Path(request.fspath).parent.joinpath("data").joinpath(name)
 
 
-def get_test_data(request, klass, name, mode="rb"):
+def get_test_data(request, klass, name, mode="rb") -> Union[bytes, str]:
     """Helper method to retrieve test data."""
     key = "{0}/{1}".format(klass, name)
     result = request.config.cache.get(key, None)
@@ -26,8 +31,13 @@ def get_test_data(request, klass, name, mode="rb"):
     return result
 
 
-@pytest.fixture
-def runner():
-    runner = CliRunner()
-    with runner.isolated_filesystem():
-        yield runner
+async def hash_file(path: Path) -> FormattedSHA256:
+    """Returns the sha256 digest value for the content of a given file."""
+    hasher = hashlib.sha256()
+    async with aiofiles.open(path, mode="r+b") as file:
+        while True:
+            chunk = await file.read(CHUNK_SIZE)
+            if not chunk:
+                break
+            hasher.update(chunk)
+    return FormattedSHA256(hasher.hexdigest())