master
/ miniconda3 / lib / python3.11 / site-packages / conda_content_trust / signing.py

signing.py @74036c5 raw · history · blame

# -*- coding: utf-8 -*-

""" conda_content_trust.signing
This module contains functions that sign data using ed25519 keys, via the
pyca/cryptography library.  Functions that perform OpenPGP-compliant (e.g. GPG)
signing are provided instead in root_signing.

Function Manifest for this Module:
    serialize_and_sign
    wrap_as_signable
    sign_signable

"""

# Python2 Compatibility
from __future__ import absolute_import, division, print_function, unicode_literals

# std libs
import binascii
import copy # for deepcopy
import json # for json.dump

# Dependency-provided libraries
#import cryptography
#import cryptography.exceptions
#import cryptography.hazmat.primitives.asymmetric.ed25519 as ed25519
#import cryptography.hazmat.primitives.serialization as serialization
#import cryptography.hazmat.primitives.hashes
#import cryptography.hazmat.backends


# conda-content-trust modules
from .common import (
        SUPPORTED_SERIALIZABLE_TYPES, canonserialize,
        load_metadata_from_file, write_metadata_to_file,
        PublicKey, PrivateKey,
        checkformat_string, checkformat_key, checkformat_hex_key,
        checkformat_signable, checkformat_signature,
        #is_hex_string, is_hex_signature, is_hex_key,
        #checkformat_natural_int, checkformat_expiration_distance,
        #checkformat_hex_key, checkformat_list_of_hex_keys,
        #checkformat_utc_isoformat,
        )


def serialize_and_sign(obj, private_key):
    """
    Given a JSON-compatible object, does the following:
     - serializes the dictionary as utf-8-encoded JSON, lazy-canonicalized
       such that any dictionary keys in any dictionaries inside <dictionary>
       are sorted and indentation is used and set to 2 spaces (using json lib)
     - creates a signature over that serialized result using private_key
     - returns that signature as a hex string

    See comments in common.canonserialize()

    Arguments:
      obj: a JSON-compatible object -- see common.canonserialize()
      private_key: a conda_content_trust.common.PrivateKey object

    # TODO ✅: Consider taking the private key data as a hex string instead?
    #          On the other hand, it's useful to support an object that could
    #          obscure the key (or provide an interface to a hardware key).
    """

    # Try converting to a JSON string.
    serialized = canonserialize(obj)

    signature_as_bytes = private_key.sign(serialized)

    signature_as_hexstr = binascii.hexlify(signature_as_bytes).decode('utf-8')

    return signature_as_hexstr



def wrap_as_signable(obj):
    """
    Given a JSON-serializable object (dictionary, list, string, numeric, etc.),
    returns a wrapped copy of that object:

        {'signatures': {},
         'signed': <deep copy of the given object>}

    Expects strict typing matches (not duck typing), for no good reason.
    (Trying JSON serialization repeatedly could be too time consuming.)

    TODO: ✅ Consider whether or not the copy can be shallow instead, for speed.

    Raises ❌TypeError if the given object is not a JSON-serializable type per
    SUPPORTED_SERIALIZABLE_TYPES
    """
    if not type(obj) in SUPPORTED_SERIALIZABLE_TYPES:
        raise TypeError(
                'wrap_dict_as_signable requires a JSON-serializable object, '
                'but the given argument is of type ' + str(type(obj)) + ', '
                'which is not supported by the json library functions.')

    # TODO: ✅ Later on, consider switching back to TUF-style
    #          signatures-as-a-list.  (Is there some reason it's saner?)
    #          Going with my sense of what's best now, which is dicts instead.
    #          It's simpler and it naturally avoids duplicates.  We don't do it
    #          this way in TUF, but we also don't depend on it being an ordered
    #          list anyway, so a dictionary is probably better.

    return {'signatures': {}, 'signed': copy.deepcopy(obj)}



def sign_signable(signable, private_key):
    """
    Given a JSON-compatible signable dictionary (as produced by calling
    wrap_dict_as_signable with a JSON-compatible dictionary), calls
    serialize_and_sign on the enclosed dictionary at signable['signed'],
    producing a signature, and places the signature in
    signable['signatures'], in an entry indexed by the public key
    corresponding to the given private_key.

    Updates the given signable in place, returning nothing.
    Overwrites if there is already an existing signature by the given key.

    # TODO ✅: Take hex string keys for sign_signable and serialize_and_sign
    #          instead of constructed PrivateKey objects?  Add the comment
    #          below if so:
    # # Unlike with lower-level functions, both signatures and public keys are
    # # always written as hex strings.

    Raises ❌TypeError if the given object is not a JSON-serializable type per
    SUPPORTED_SERIALIZABLE_TYPES
    """
    # Argument checking
    checkformat_key(private_key)
    checkformat_signable(signable)
    # if not is_a_signable(signable):
    #     raise TypeError(
    #             'Expected a signable dictionary; the given argument of type ' +
    #             str(type(signable)) + ' failed the check.')

    # private_key = PrivateKey.from_hex(private_key_hex)

    signature_as_hexstr = serialize_and_sign(signable['signed'], private_key)

    public_key_as_hexstr = private_key.public_key().to_hex()

    # To fit a general format, we wrap it this way, instead of just using the
    # hexstring.  This is because OpenPGP signatures that we use for root
    # signatures look similar and have a few extra fields beyond the signature
    # value itself.
    signature_dict = {'signature': signature_as_hexstr}

    checkformat_signature(signature_dict)

    # TODO: ✅⚠️ Log a warning in whatever conda's style is (or conda-build):
    #
    # if public_key_as_hexstr in signable['signatures']:
    #   warn(    # replace: log, 'warnings' module, print statement, whatever
    #           'Overwriting existing signature by the same key on given '
    #           'signable.  Public key: ' + public_key + '.')

    # Add signature in-place, in the usual signature format.
    signable['signatures'][public_key_as_hexstr] = signature_dict



def sign_all_in_repodata(fname, private_key_hex):
    """
    Given a repodata.json filename, reads the "packages" entries in that file,
    and produces a signature over each artifact, with the given key.  The
    signatures are then placed in a "signatures" entry parallel to the
    "packages" entry in the json file.  The file is overwritten.

    Arguments:
        fname: filename of a repodata.json file
        private_key_hex:
            a private ed25519 key value represented as a 64-char hex string
    """
    checkformat_hex_key(private_key_hex)
    checkformat_string(fname)
    # TODO ✅⚠️: Consider filename validation.  What does conda use for that?

    private = PrivateKey.from_hex(private_key_hex)
    public_hex = private.public_key().to_hex()

    # Loading the whole file at once instead of reading it as we go, because
    # it's less complex and this only needs to run repository-side.
    repodata = load_metadata_from_file(fname)
    # with open(fname, 'rb') as fobj:
    #     repodata = json.load(fname)

    # TODO ✅: Consider more validation for the gross structure expected of
    #            repodata.json
    if not 'packages' in repodata:
        raise ValueError('Expected a "packages" entry in given repodata file.')

    # Add an empty 'signatures' dict to repodata.
    # If it's already there for whatever reason, we replace it entirely.  This
    # avoids leaving existing signatures that might not get replaced -- e.g. if
    # the artifact is not in the "packages" dict, but is in the "signatures"
    # dict for some reason.  What comes out of this process will be limited to
    # what we sign in this function.
    repodata['signatures'] = {}

    for artifact_name, metadata in repodata['packages'].items():
        # TODO ✅: Further consider the significance of the artifact name
        #          itself not being part of the signed metadata.  The info used
        #          to generate the name (package name + version + build) is
        #          part of the signed metadata, but the full name is not.
        #          Keep in mind attacks that swap metadata among artifacts;
        #          signatures would still read as correct in that circumstance.
        signature_hex = serialize_and_sign(metadata, private)

        # To fit a general format, we wrap it this way, instead of just using
        # the hexstring.  This is because OpenPGP signatures that we use for
        # root signatures look similar and have a few extra fields beyond the
        # signature value itself.
        signature_dict = {'signature': signature_hex}

        checkformat_signature(signature_dict)

        repodata['signatures'][artifact_name] = {public_hex: signature_dict}

    # Repeat for the .conda packages in 'packages.conda'.
    for artifact_name, metadata in repodata.get('packages.conda', {}).items():
        signature_hex = serialize_and_sign(metadata, private)
        repodata['signatures'][artifact_name] = {
                public_hex: {'signature': signature_hex}}



    # Note: takes >0.5s on a macbook for large files
    write_metadata_to_file(repodata, fname)