""" schema1 implements pure data transformations according to the Docker Manifest v2.1 Specification. https://github.com/docker/distribution/blob/master/docs/spec/manifest-v2-1.md """ import hashlib import json import logging from collections import namedtuple, OrderedDict from datetime import datetime import dateutil.parser from jsonschema import validate as validate_schema, ValidationError from jwkest.jws import SIGNER_ALGS, keyrep, BadSignature from jwt.utils import base64url_encode, base64url_decode from digest import digest_tools from image.docker import ManifestException from image.docker.types import ManifestImageLayer from image.docker.interfaces import ManifestInterface from image.docker.v1 import DockerV1Metadata from image.docker.schemautil import to_canonical_json from util.bytes import Bytes logger = logging.getLogger(__name__) # Content Types DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest.v1+json' DOCKER_SCHEMA1_SIGNED_MANIFEST_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest.v1+prettyjws' DOCKER_SCHEMA1_CONTENT_TYPES = {DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE, DOCKER_SCHEMA1_SIGNED_MANIFEST_CONTENT_TYPE} # Keys for signature-related data DOCKER_SCHEMA1_SIGNATURES_KEY = 'signatures' DOCKER_SCHEMA1_HEADER_KEY = 'header' DOCKER_SCHEMA1_SIGNATURE_KEY = 'signature' DOCKER_SCHEMA1_PROTECTED_KEY = 'protected' DOCKER_SCHEMA1_FORMAT_LENGTH_KEY = 'formatLength' DOCKER_SCHEMA1_FORMAT_TAIL_KEY = 'formatTail' # Keys for manifest-related data DOCKER_SCHEMA1_REPO_NAME_KEY = 'name' DOCKER_SCHEMA1_REPO_TAG_KEY = 'tag' DOCKER_SCHEMA1_ARCH_KEY = 'architecture' DOCKER_SCHEMA1_FS_LAYERS_KEY = 'fsLayers' DOCKER_SCHEMA1_BLOB_SUM_KEY = 'blobSum' DOCKER_SCHEMA1_HISTORY_KEY = 'history' DOCKER_SCHEMA1_V1_COMPAT_KEY = 'v1Compatibility' DOCKER_SCHEMA1_SCHEMA_VER_KEY = 'schemaVersion' # Format for time used in the protected payload. _ISO_DATETIME_FORMAT_ZULU = '%Y-%m-%dT%H:%M:%SZ' # The algorithm we use to sign the JWS. _JWS_SIGNING_ALGORITHM = 'RS256' class MalformedSchema1Manifest(ManifestException): """ Raised when a manifest fails an assertion that should be true according to the Docker Manifest v2.1 Specification. """ pass class InvalidSchema1Signature(ManifestException): """ Raised when there is a failure verifying the signature of a signed Docker 2.1 Manifest. """ pass class Schema1Layer(namedtuple('Schema1Layer', ['digest', 'v1_metadata', 'raw_v1_metadata', 'compressed_size', 'is_remote', 'urls'])): """ Represents all of the data about an individual layer in a given Manifest. This is the union of the fsLayers (digest) and the history entries (v1_compatibility). """ class Schema1V1Metadata(namedtuple('Schema1V1Metadata', ['image_id', 'parent_image_id', 'created', 'comment', 'command', 'author', 'labels'])): """ Represents the necessary data extracted from the v1 compatibility string in a given layer of a Manifest. """ class DockerSchema1Manifest(ManifestInterface): METASCHEMA = { 'type': 'object', 'properties': { DOCKER_SCHEMA1_SIGNATURES_KEY: { 'type': 'array', 'items': { 'type': 'object', 'properties': { DOCKER_SCHEMA1_PROTECTED_KEY: { 'type': 'string', }, DOCKER_SCHEMA1_HEADER_KEY: { 'type': 'object', 'properties': { 'alg': { 'type': 'string', }, 'jwk': { 'type': 'object', }, }, 'required': ['alg', 'jwk'], }, DOCKER_SCHEMA1_SIGNATURE_KEY: { 'type': 'string', }, }, 'required': [DOCKER_SCHEMA1_PROTECTED_KEY, DOCKER_SCHEMA1_HEADER_KEY, DOCKER_SCHEMA1_SIGNATURE_KEY], }, }, DOCKER_SCHEMA1_REPO_TAG_KEY: { 'type': 'string', }, DOCKER_SCHEMA1_REPO_NAME_KEY: { 'type': 'string', }, DOCKER_SCHEMA1_HISTORY_KEY: { 'type': 'array', 'items': { 'type': 'object', 'properties': { DOCKER_SCHEMA1_V1_COMPAT_KEY: { 'type': 'string', }, }, 'required': [DOCKER_SCHEMA1_V1_COMPAT_KEY], }, }, DOCKER_SCHEMA1_FS_LAYERS_KEY: { 'type': 'array', 'items': { 'type': 'object', 'properties': { DOCKER_SCHEMA1_BLOB_SUM_KEY: { 'type': 'string', }, }, 'required': [DOCKER_SCHEMA1_BLOB_SUM_KEY], }, }, }, 'required': [DOCKER_SCHEMA1_REPO_TAG_KEY, DOCKER_SCHEMA1_REPO_NAME_KEY, DOCKER_SCHEMA1_FS_LAYERS_KEY, DOCKER_SCHEMA1_HISTORY_KEY], } def __init__(self, manifest_bytes, validate=True): assert isinstance(manifest_bytes, Bytes) self._layers = None self._bytes = manifest_bytes try: self._parsed = json.loads(manifest_bytes.as_encoded_str()) except ValueError as ve: raise MalformedSchema1Manifest('malformed manifest data: %s' % ve) try: validate_schema(self._parsed, DockerSchema1Manifest.METASCHEMA) except ValidationError as ve: raise MalformedSchema1Manifest('manifest data does not match schema: %s' % ve) self._signatures = self._parsed.get(DOCKER_SCHEMA1_SIGNATURES_KEY) self._architecture = self._parsed.get(DOCKER_SCHEMA1_ARCH_KEY) self._tag = self._parsed[DOCKER_SCHEMA1_REPO_TAG_KEY] repo_name = self._parsed[DOCKER_SCHEMA1_REPO_NAME_KEY] repo_name_tuple = repo_name.split('/') if len(repo_name_tuple) > 1: self._namespace, self._repo_name = repo_name_tuple elif len(repo_name_tuple) == 1: self._namespace = '' self._repo_name = repo_name_tuple[0] else: raise MalformedSchema1Manifest('malformed repository name: %s' % repo_name) if validate: self._validate() def _validate(self): if not self._signatures: return payload_str = self._payload for signature in self._signatures: bytes_to_verify = '{0}.{1}'.format(signature['protected'], base64url_encode(payload_str)) signer = SIGNER_ALGS[signature['header']['alg']] key = keyrep(signature['header']['jwk']) gk = key.get_key() sig = base64url_decode(signature['signature'].encode('utf-8')) try: verified = signer.verify(bytes_to_verify, sig, gk) except BadSignature: raise InvalidSchema1Signature() if not verified: raise InvalidSchema1Signature() def validate(self, content_retriever): """ Performs validation of required assertions about the manifest. Raises a ManifestException on failure. """ # Already validated. @property def is_signed(self): """ Returns whether the schema is signed. """ return bool(self._signatures) @property def architecture(self): return self._architecture @property def is_manifest_list(self): return False @property def schema_version(self): return 1 @property def content_type(self): return (DOCKER_SCHEMA1_SIGNED_MANIFEST_CONTENT_TYPE if self._signatures else DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE) @property def media_type(self): return self.content_type @property def signatures(self): return self._signatures @property def namespace(self): return self._namespace @property def repo_name(self): return self._repo_name @property def tag(self): return self._tag @property def bytes(self): return self._bytes @property def manifest_json(self): return self._parsed @property def manifest_dict(self): return self._parsed @property def layers_compressed_size(self): return None @property def digest(self): return digest_tools.sha256_digest(self._payload) @property def image_ids(self): return {mdata.v1_metadata.image_id for mdata in self.layers} @property def parent_image_ids(self): return {mdata.v1_metadata.parent_image_id for mdata in self.layers if mdata.v1_metadata.parent_image_id} @property def checksums(self): return list({str(mdata.digest) for mdata in self.layers}) @property def leaf_layer(self): return self.layers[-1] @property def created_datetime(self): created_datetime_str = self.leaf_layer.v1_metadata.created if created_datetime_str is None: return None try: return dateutil.parser.parse(created_datetime_str).replace(tzinfo=None) except: # parse raises different exceptions, so we cannot use a specific kind of handler here. return None @property def layers(self): if self._layers is None: self._layers = list(self._generate_layers()) return self._layers def get_layers(self, content_retriever): """ Returns the layers of this manifest, from base to leaf or None if this kind of manifest does not support layers. """ for layer in self.layers: created_datetime = None try: created_datetime = dateutil.parser.parse(layer.v1_metadata.created).replace(tzinfo=None) except: pass yield ManifestImageLayer(layer_id=layer.v1_metadata.image_id, compressed_size=layer.compressed_size, is_remote=False, urls=None, command=layer.v1_metadata.command, comment=layer.v1_metadata.comment, author=layer.v1_metadata.author, blob_digest=layer.digest, created_datetime=created_datetime, internal_layer=layer) @property def blob_digests(self): return [str(layer.digest) for layer in self.layers] @property def local_blob_digests(self): return self.blob_digests def get_blob_digests_for_translation(self): """ Returns the blob digests for translation of this manifest into another manifest. This method will ignore missing IDs in layers, unlike `blob_digests`. """ layers = self._generate_layers(allow_missing_ids=True) return [str(layer.digest) for layer in layers] def child_manifests(self, content_retriever): return None def get_manifest_labels(self, content_retriever): return self.layers[-1].v1_metadata.labels def get_requires_empty_layer_blob(self, content_retriever): return False def _unsigned_builder(self): builder = DockerSchema1ManifestBuilder(self._namespace, self._repo_name, self._tag, self._architecture) for layer in reversed(self.layers): builder.add_layer(str(layer.digest), layer.raw_v1_metadata) return builder def unsigned(self): if self.media_type == DOCKER_SCHEMA1_MANIFEST_CONTENT_TYPE: return self # Create an unsigned version of the manifest. return self._unsigned_builder().build() def with_tag_name(self, tag_name, json_web_key=None): """ Returns a copy of this manifest, with the tag changed to the given tag name. """ builder = DockerSchema1ManifestBuilder(self._namespace, self._repo_name, tag_name, self._architecture) for layer in reversed(self.layers): builder.add_layer(str(layer.digest), layer.raw_v1_metadata) return builder.build(json_web_key) def _generate_layers(self, allow_missing_ids=False): """ Returns a generator of objects that have the blobSum and v1Compatibility keys in them, starting from the base image and working toward the leaf node. """ for blob_sum_obj, history_obj in reversed(zip(self._parsed[DOCKER_SCHEMA1_FS_LAYERS_KEY], self._parsed[DOCKER_SCHEMA1_HISTORY_KEY])): try: image_digest = digest_tools.Digest.parse_digest(blob_sum_obj[DOCKER_SCHEMA1_BLOB_SUM_KEY]) except digest_tools.InvalidDigestException: raise MalformedSchema1Manifest('could not parse manifest digest: %s' % blob_sum_obj[DOCKER_SCHEMA1_BLOB_SUM_KEY]) metadata_string = history_obj[DOCKER_SCHEMA1_V1_COMPAT_KEY] try: v1_metadata = json.loads(metadata_string) except (ValueError, TypeError): raise MalformedSchema1Manifest('Could not parse metadata string: %s' % metadata_string) container_config = v1_metadata.get('container_config') or {} command_list = container_config.get('Cmd', None) command = to_canonical_json(command_list) if command_list else None if not allow_missing_ids and not 'id' in v1_metadata: raise MalformedSchema1Manifest('id field missing from v1Compatibility JSON') labels = v1_metadata.get('config', {}).get('Labels', {}) or {} extracted = Schema1V1Metadata(image_id=v1_metadata.get('id'), parent_image_id=v1_metadata.get('parent'), created=v1_metadata.get('created'), comment=v1_metadata.get('comment'), author=v1_metadata.get('author'), command=command, labels=labels) compressed_size = v1_metadata.get('Size') yield Schema1Layer(image_digest, extracted, metadata_string, compressed_size, False, None) @property def _payload(self): if self._signatures is None: return self._bytes.as_encoded_str() byte_data = self._bytes.as_encoded_str() protected = str(self._signatures[0][DOCKER_SCHEMA1_PROTECTED_KEY]) parsed_protected = json.loads(base64url_decode(protected)) signed_content_head = byte_data[:parsed_protected[DOCKER_SCHEMA1_FORMAT_LENGTH_KEY]] signed_content_tail = base64url_decode(str(parsed_protected[DOCKER_SCHEMA1_FORMAT_TAIL_KEY])) return signed_content_head + signed_content_tail def generate_legacy_layers(self, images_map, content_retriever): return self.rewrite_invalid_image_ids(images_map) def get_legacy_image_ids(self, content_retriever): return self.legacy_image_ids @property def legacy_image_ids(self): return {mdata.v1_metadata.image_id for mdata in self.layers} @property def has_legacy_image(self): return True @property def leaf_layer_v1_image_id(self): return self.layers[-1].v1_metadata.image_id def get_leaf_layer_v1_image_id(self, content_retriever): return self.layers[-1].v1_metadata.image_id def get_schema1_manifest(self, namespace_name, repo_name, tag_name, content_retriever): """ Returns the manifest that is compatible with V1, by virtue of being `amd64` and `linux`. If none, returns None. """ # Note: schema1 *technically* supports non-amd64 architectures, but in practice these were never # used, so to ensure full backwards compatibility, we just always return the schema. return self def convert_manifest(self, allowed_mediatypes, namespace_name, repo_name, tag_name, content_retriever): if self.media_type in allowed_mediatypes: return self unsigned = self.unsigned() if unsigned.media_type in allowed_mediatypes: return unsigned return None def rewrite_invalid_image_ids(self, images_map): """ Rewrites Docker v1 image IDs and returns a generator of DockerV1Metadata. If Docker gives us a layer with a v1 image ID that already points to existing content, but the checksums don't match, then we need to rewrite the image ID to something new in order to ensure consistency. """ # Used to synthesize a new "content addressable" image id digest_history = hashlib.sha256() has_rewritten_ids = False updated_id_map = {} for layer in self.layers: digest_str = str(layer.digest) extracted_v1_metadata = layer.v1_metadata working_image_id = extracted_v1_metadata.image_id # Update our digest_history hash for the new layer data. digest_history.update(digest_str) digest_history.update("@") digest_history.update(layer.raw_v1_metadata.encode('utf-8')) digest_history.update("|") # Ensure that the v1 image's storage matches the V2 blob. If not, we've # found a data inconsistency and need to create a new layer ID for the V1 # image, and all images that follow it in the ancestry chain. digest_mismatch = (extracted_v1_metadata.image_id in images_map and images_map[extracted_v1_metadata.image_id].content_checksum != digest_str) if digest_mismatch or has_rewritten_ids: working_image_id = digest_history.hexdigest() has_rewritten_ids = True # Store the new docker id in the map updated_id_map[extracted_v1_metadata.image_id] = working_image_id # Lookup the parent image for the layer, if any. parent_image_id = extracted_v1_metadata.parent_image_id if parent_image_id is not None: parent_image_id = updated_id_map.get(parent_image_id, parent_image_id) # Synthesize and store the v1 metadata in the db. v1_metadata_json = layer.raw_v1_metadata if has_rewritten_ids: v1_metadata_json = _updated_v1_metadata(v1_metadata_json, updated_id_map) updated_image = DockerV1Metadata( namespace_name=self.namespace, repo_name=self.repo_name, image_id=working_image_id, created=extracted_v1_metadata.created, comment=extracted_v1_metadata.comment, author=extracted_v1_metadata.author, command=extracted_v1_metadata.command, compat_json=v1_metadata_json, parent_image_id=parent_image_id, checksum=None, # TODO: Check if we need this. content_checksum=digest_str, ) yield updated_image class DockerSchema1ManifestBuilder(object): """ A convenient abstraction around creating new DockerSchema1Manifests. """ def __init__(self, namespace_name, repo_name, tag, architecture='amd64'): repo_name_key = '{0}/{1}'.format(namespace_name, repo_name) if namespace_name == '': repo_name_key = repo_name self._base_payload = { DOCKER_SCHEMA1_REPO_TAG_KEY: tag, DOCKER_SCHEMA1_REPO_NAME_KEY: repo_name_key, DOCKER_SCHEMA1_ARCH_KEY: architecture, DOCKER_SCHEMA1_SCHEMA_VER_KEY: 1, } self._fs_layer_digests = [] self._history = [] self._namespace_name = namespace_name self._repo_name = repo_name self._tag = tag self._architecture = architecture def add_layer(self, layer_digest, v1_json_metadata): self._fs_layer_digests.append({ DOCKER_SCHEMA1_BLOB_SUM_KEY: layer_digest, }) self._history.append({ DOCKER_SCHEMA1_V1_COMPAT_KEY: v1_json_metadata or '{}', }) return self def with_metadata_removed(self): """ Returns a copy of the builder where every layer but the leaf layer has its metadata stripped down to the bare essentials. """ builder = DockerSchema1ManifestBuilder(self._namespace_name, self._repo_name, self._tag, self._architecture) for index, fs_layer in enumerate(self._fs_layer_digests): try: metadata = json.loads(self._history[index][DOCKER_SCHEMA1_V1_COMPAT_KEY]) except (ValueError, TypeError): logger.exception('Could not parse existing builder') raise MalformedSchema1Manifest fixed_metadata = {} if index == 0: # Leaf layer is at index 0 in schema 1. fixed_metadata = metadata else: # Remove all container config from the metadata. fixed_metadata['id'] = metadata['id'] if 'parent' in metadata: fixed_metadata['parent'] = metadata['parent'] if 'created' in metadata: fixed_metadata['created'] = metadata['created'] if 'author' in metadata: fixed_metadata['author'] = metadata['author'] if 'comment' in metadata: fixed_metadata['comment'] = metadata['comment'] if 'Size' in metadata: fixed_metadata['Size'] = metadata['Size'] if 'Cmd' in metadata.get('container_config', {}): fixed_metadata['container_config'] = { 'Cmd': metadata['container_config']['Cmd'], } builder.add_layer(fs_layer[DOCKER_SCHEMA1_BLOB_SUM_KEY], json.dumps(fixed_metadata)) return builder def build(self, json_web_key=None, ensure_ascii=True): """ Builds a DockerSchema1Manifest object, with optional signature. """ payload = OrderedDict(self._base_payload) payload.update({ DOCKER_SCHEMA1_HISTORY_KEY: self._history, DOCKER_SCHEMA1_FS_LAYERS_KEY: self._fs_layer_digests, }) payload_str = json.dumps(payload, indent=3, ensure_ascii=ensure_ascii) if json_web_key is None: return DockerSchema1Manifest(Bytes.for_string_or_unicode(payload_str)) payload_str = Bytes.for_string_or_unicode(payload_str).as_encoded_str() split_point = payload_str.rfind('\n}') protected_payload = { 'formatTail': base64url_encode(payload_str[split_point:]), 'formatLength': split_point, 'time': datetime.utcnow().strftime(_ISO_DATETIME_FORMAT_ZULU), } protected = base64url_encode(json.dumps(protected_payload, ensure_ascii=ensure_ascii)) logger.debug('Generated protected block: %s', protected) bytes_to_sign = '{0}.{1}'.format(protected, base64url_encode(payload_str)) signer = SIGNER_ALGS[_JWS_SIGNING_ALGORITHM] signature = base64url_encode(signer.sign(bytes_to_sign, json_web_key.get_key())) logger.debug('Generated signature: %s', signature) public_members = set(json_web_key.public_members) public_key = {comp: value for comp, value in json_web_key.to_dict().items() if comp in public_members} signature_block = { DOCKER_SCHEMA1_HEADER_KEY: {'jwk': public_key, 'alg': _JWS_SIGNING_ALGORITHM}, DOCKER_SCHEMA1_SIGNATURE_KEY: signature, DOCKER_SCHEMA1_PROTECTED_KEY: protected, } logger.debug('Encoded signature block: %s', json.dumps(signature_block)) payload.update({DOCKER_SCHEMA1_SIGNATURES_KEY: [signature_block]}) json_str = json.dumps(payload, indent=3, ensure_ascii=ensure_ascii) return DockerSchema1Manifest(Bytes.for_string_or_unicode(json_str)) def _updated_v1_metadata(v1_metadata_json, updated_id_map): """ Updates v1_metadata with new image IDs. """ parsed = json.loads(v1_metadata_json) parsed['id'] = updated_id_map[parsed['id']] if parsed.get('parent') and parsed['parent'] in updated_id_map: parsed['parent'] = updated_id_map[parsed['parent']] if parsed.get('container_config', {}).get('Image'): existing_image = parsed['container_config']['Image'] if existing_image in updated_id_map: parsed['container_config']['image'] = updated_id_map[existing_image] return to_canonical_json(parsed)