diff --git a/ci-requirements.txt b/ci-requirements.txt index 11999994..9f893b4f 100644 --- a/ci-requirements.txt +++ b/ci-requirements.txt @@ -1,5 +1,6 @@ cryptography pynacl +pyspx tox coverage coveralls diff --git a/dev-requirements.txt b/dev-requirements.txt index 2bb26462..d01954ba 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ cryptography==2.3.1 pynacl==1.2.1 +pyspx==0.2.0 six==1.11.0 tox==3.2.1 coveralls==1.3.0 diff --git a/requirements.txt b/requirements.txt index b2accf46..0fccf635 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ cryptography pynacl +pyspx six colorama diff --git a/securesystemslib/formats.py b/securesystemslib/formats.py index 5db2dc0f..5855752c 100755 --- a/securesystemslib/formats.py +++ b/securesystemslib/formats.py @@ -79,6 +79,14 @@ import time import six +# Try to import pyspx to get access to lengths of SPX signatures and keys +try: + import pyspx.shake256_192s as pyspx + +# pyspx's 'cffi' dependency may raise an 'IOError' exception when importing +except (ImportError, IOError): # pragma: no cover + pass + import securesystemslib.schema as SCHEMA import securesystemslib.exceptions @@ -227,7 +235,7 @@ # Supported TUF key types. KEYTYPE_SCHEMA = SCHEMA.OneOf( [SCHEMA.String('rsa'), SCHEMA.String('ed25519'), - SCHEMA.String('ecdsa-sha2-nistp256')]) + SCHEMA.String('ecdsa-sha2-nistp256'), SCHEMA.String('spx')]) # A generic TUF key. All TUF keys should be saved to metadata files in this # format. @@ -249,7 +257,7 @@ expires = SCHEMA.Optional(ISO8601_DATETIME_SCHEMA)) # A TUF key object. This schema simplifies validation of keys that may be one -# of the supported key types. Supported key types: 'rsa', 'ed25519'. +# of the supported key types. Supported key types: 'rsa', 'ed25519', 'spx'. ANYKEY_SCHEMA = SCHEMA.Object( object_name = 'ANYKEY_SCHEMA', keytype = KEYTYPE_SCHEMA, @@ -292,6 +300,15 @@ # An ED25519 raw signature, which must be 64 bytes. ED25519SIGNATURE_SCHEMA = SCHEMA.LengthBytes(64) +# Lengths of SPX raw keys and signatures +try: + SPXPUBLIC_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_PUBLICKEYBYTES) + SPXSEED_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_SECRETKEYBYTES) + SPXSIGNATURE_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_BYTES) + +except NameError: # pragma: no cover + pass # raised when pyspx was not available on import + # An ECDSA signature. ECDSASIGNATURE_SCHEMA = SCHEMA.AnyBytes() @@ -299,7 +316,7 @@ # cryptography modules. REQUIRED_LIBRARIES_SCHEMA = SCHEMA.ListOf(SCHEMA.OneOf( [SCHEMA.String('general'), SCHEMA.String('ed25519'), SCHEMA.String('rsa'), - SCHEMA.String('ecdsa-sha2-nistp256')])) + SCHEMA.String('ecdsa-sha2-nistp256'), SCHEMA.String('spx')])) # Ed25519 signature schemes. The vanilla Ed25519 signature scheme is currently # supported. @@ -314,6 +331,19 @@ keyid_hash_algorithms = SCHEMA.Optional(HASHALGORITHMS_SCHEMA), keyval = KEYVAL_SCHEMA) +# SPX signature schemes. The vanilla SPX signature scheme is currently supported +SPX_SIG_SCHEMA = SCHEMA.OneOf([SCHEMA.String('spx')]) + +# An SPX TUF key. +SPXKEY_SCHEMA = SCHEMA.Object( + object_name = 'SPXKEY_SCHEMA', + keytype = SCHEMA.String('spx'), + scheme = SPX_SIG_SCHEMA, + keyid = KEYID_SCHEMA, + keyid_hash_algorithms = SCHEMA.Optional(HASHALGORITHMS_SCHEMA), + keyval = KEYVAL_SCHEMA) + + # Information about target files, like file length and file hash(es). This # schema allows the storage of multiple hashes for the same file (e.g., sha256 # and sha512 may be computed for the same file and stored). diff --git a/securesystemslib/interface.py b/securesystemslib/interface.py index 32e6157e..a9ec8a31 100755 --- a/securesystemslib/interface.py +++ b/securesystemslib/interface.py @@ -61,7 +61,7 @@ DEFAULT_RSA_KEY_BITS = 3072 # Supported key types. -SUPPORTED_KEY_TYPES = ['rsa', 'ed25519'] +SUPPORTED_KEY_TYPES = ['rsa', 'ed25519', 'ecdsa-sha2-nistp256', 'spx'] @@ -641,7 +641,7 @@ def import_ed25519_privatekey_from_file(filepath, password=None, prompt=False): # key is not encrypted by entering no password in the prompt, as opposed # to a programmer who can call the function with or without a 'password'. # Hence, we treat an empty password here, as if no 'password' was passed. - password = get_password('Enter a password for an encrypted RSA' + password = get_password('Enter a password for an encrypted Ed25519' ' file \'' + Fore.RED + filepath + Fore.RESET + '\': ', confirm=False) @@ -900,6 +900,260 @@ def import_ecdsa_privatekey_from_file(filepath, password=None): return key_object +def generate_and_write_spx_keypair(filepath=None, password=None): + """ + + Generate an SPX keypair, where the encrypted key (using 'password' as + the passphrase) is saved to <'filepath'>. The public key portion of the + generated SPX key is saved to <'filepath'>.pub. If the filepath is not + given, the KEYID is used as the filename and the keypair saved to the + current working directory. + + The private key is encrypted according to 'cryptography's approach: + "Encrypt using the best available encryption for a given key's backend. + This is a curated encryption choice and the algorithm may change over + time." + + + filepath: + The public and private key files are saved to .pub and + , respectively. If the filepath is not given, the public and + private keys are saved to the current working directory as .pub + and . KEYID is the generated key's KEYID. + + password: + The password, or passphrase, to encrypt the private portion of the + generated SPX key. A symmetric encryption key is derived from + 'password', so it is not directly used. + + + securesystemslib.exceptions.FormatError, if the arguments are improperly + formatted. + + securesystemslib.exceptions.CryptoError, if 'filepath' cannot be encrypted. + + + Writes key files to '' and '.pub'. + + + The 'filepath' of the written key. + """ + + # Generate a new SPX key object. + spx_key = securesystemslib.keys.generate_spx_key() + + if not filepath: + filepath = os.path.join(os.getcwd(), spx_key['keyid']) + + else: + logger.debug('The filepath has been specified. Not using the key\'s' + ' KEYID as the default filepath.') + + # Does 'filepath' have the correct format? + # Ensure the arguments have the appropriate number of objects and object + # types, and that all dict keys are properly named. + # Raise 'securesystemslib.exceptions.FormatError' if there is a mismatch. + securesystemslib.formats.PATH_SCHEMA.check_match(filepath) + + # If the caller does not provide a password argument, prompt for one. + if password is None: # pragma: no cover + + # It is safe to specify the full path of 'filepath' in the prompt and not + # worry about leaking sensitive information about the key's location. + # However, care should be taken when including the full path in exceptions + # and log files. + password = get_password('Enter a password for the SPX' + ' key (' + Fore.RED + filepath + Fore.RESET + '): ', + confirm=True) + + else: + logger.debug('The password has been specified. Not prompting for one.') + + # Does 'password' have the correct format? + securesystemslib.formats.PASSWORD_SCHEMA.check_match(password) + + # If the parent directory of filepath does not exist, + # create it (and all its parent directories, if necessary). + securesystemslib.util.ensure_parent_dir(filepath) + + # Create a temporary file, write the contents of the public key, and move + # to final destination. + file_object = securesystemslib.util.TempFile() + + # Generate the spx public key file contents in metadata format (i.e., + # does not include the keyid portion). + keytype = spx_key['keytype'] + keyval = spx_key['keyval'] + scheme = spx_key['scheme'] + spxkey_metadata_format = securesystemslib.keys.format_keyval_to_metadata( + keytype, scheme, keyval, private=False) + + file_object.write(json.dumps(spxkey_metadata_format).encode('utf-8')) + + # Write the public key (i.e., 'public', which is in PEM format) to + # '.pub'. (1) Create a temporary file, (2) write the contents of + # the public key, and (3) move to final destination. + # The temporary file is closed after the final move. + file_object.move(filepath + '.pub') + + # Write the encrypted key string, conformant to + # 'securesystemslib.formats.ENCRYPTEDKEY_SCHEMA', to ''. + file_object = securesystemslib.util.TempFile() + + # Encrypt the private key if 'password' is set. + if len(password): + spx_key = securesystemslib.keys.encrypt_key(spx_key, password) + + else: + logger.debug('An empty password was given. ' + 'Not encrypting the private key.') + spx_key = json.dumps(spx_key) + + # Raise 'securesystemslib.exceptions.CryptoError' if 'spx_key' cannot be + # encrypted. + file_object.write(spx_key.encode('utf-8')) + file_object.move(filepath) + + return filepath + + + + +def import_spx_publickey_from_file(filepath): + """ + + Load the SPX public key object (conformant to + 'securesystemslib.formats.KEY_SCHEMA') stored in 'filepath'. Return + 'filepath' in securesystemslib.formats.SPXKEY_SCHEMA format. + + If the key object in 'filepath' contains a private key, it is discarded. + + + filepath: + .pub file, a public key file. + + + securesystemslib.exceptions.FormatError, if 'filepath' is improperly + formatted or is an unexpected key type. + + + The contents of 'filepath' is read and saved. + + + An SPX key object conformant to + 'securesystemslib.formats.SPXKEY_SCHEMA'. + """ + + # Does 'filepath' have the correct format? + # Ensure the arguments have the appropriate number of objects and object + # types, and that all dict keys are properly named. + # Raise 'securesystemslib.exceptions.FormatError' if there is a mismatch. + securesystemslib.formats.PATH_SCHEMA.check_match(filepath) + + # SPX key objects are saved in json and metadata format. Return the + # loaded key object in securesystemslib.formats.SPXKEY_SCHEMA' format that + # also includes the keyid. + spx_key_metadata = securesystemslib.util.load_json_file(filepath) + spx_key, junk = \ + securesystemslib.keys.format_metadata_to_key(spx_key_metadata) + + # Raise an exception if an unexpected key type is imported. Redundant + # validation of 'keytype'. 'securesystemslib.keys.format_metadata_to_key()' + # should have fully validated 'spx_key_metadata'. + if spx_key['keytype'] != 'spx': # pragma: no cover + message = 'Invalid key type loaded: ' + repr(spx_key['keytype']) + raise securesystemslib.exceptions.FormatError(message) + + return spx_key + + + + + +def import_spx_privatekey_from_file(filepath, password=None, prompt=False): + """ + + Import the encrypted spx key file in 'filepath', decrypt it, and return + the key object in 'securesystemslib.formats.SPXKEY_SCHEMA' format. + + The private key (may also contain the public part) is encrypted with AES + 256 and CTR the mode of operation. The password is strengthened with + PBKDF2-HMAC-SHA256. + + + filepath: + file, an RSA encrypted key file. + + password: + The password, or passphrase, to import the private key (i.e., the + encrypted key file 'filepath' must be decrypted before the spx key + object can be returned. + + prompt: + If True the user is prompted for a passphrase to decrypt 'filepath'. + Default is False. + + + securesystemslib.exceptions.FormatError, if the arguments are improperly + formatted or the imported key object contains an invalid key type (i.e., + not 'spx'). + + securesystemslib.exceptions.CryptoError, if 'filepath' cannot be decrypted. + + + 'password' is used to decrypt the 'filepath' key file. + + + An spx key object of the form: + 'securesystemslib.formats.SPXKEY_SCHEMA'. + """ + + # Does 'filepath' have the correct format? + # Ensure the arguments have the appropriate number of objects and object + # types, and that all dict keys are properly named. + # Raise 'securesystemslib.exceptions.FormatError' if there is a mismatch. + securesystemslib.formats.PATH_SCHEMA.check_match(filepath) + + if password and prompt: + raise ValueError("Passing 'password' and 'prompt' True is not allowed.") + + # If 'password' was passed check format and that it is not empty. + if password is not None: + securesystemslib.formats.PASSWORD_SCHEMA.check_match(password) + + # TODO: PASSWORD_SCHEMA should be securesystemslib.schema.AnyString(min=1) + if not len(password): + raise ValueError('Password must be 1 or more characters') + + elif prompt: + # Password confirmation disabled here, which should ideally happen only + # when creating encrypted key files (i.e., improve usability). + # It is safe to specify the full path of 'filepath' in the prompt and not + # worry about leaking sensitive information about the key's location. + # However, care should be taken when including the full path in exceptions + # and log files. + # NOTE: A user who gets prompted for a password, can only signal that the + # key is not encrypted by entering no password in the prompt, as opposed + # to a programmer who can call the function with or without a 'password'. + # Hence, we treat an empty password here, as if no 'password' was passed. + password = get_password('Enter a password for an encrypted SPX' + ' file \'' + Fore.RED + filepath + Fore.RESET + '\': ', + confirm=False) + + # If user sets an empty string for the password, explicitly set the + # password to None, because some functions may expect this later. + if len(password) == 0: # pragma: no cover + password = None + + # Finally, regardless of password, try decrypting the key, if necessary. + # Otherwise, load it straight from the disk. + with open(filepath, 'rb') as file_object: + json_str = file_object.read() + return securesystemslib.keys.\ + import_spxkey_from_private_json(json_str, password=password) + + + if __name__ == '__main__': # The interactive sessions of the documentation strings can diff --git a/securesystemslib/keys.py b/securesystemslib/keys.py index 239311c6..f292396f 100755 --- a/securesystemslib/keys.py +++ b/securesystemslib/keys.py @@ -98,6 +98,14 @@ # regardless of the availability of PyNaCl. import securesystemslib.ed25519_keys + +try: + import securesystemslib.spx_keys + +except ImportError: #pragma: no cover + pass + + try: import securesystemslib.ecdsa_keys @@ -288,8 +296,8 @@ def generate_ecdsa_key(scheme='ecdsa-sha2-nistp256'): public, private = \ securesystemslib.ecdsa_keys.generate_public_and_private(scheme) - # Generate the keyid of the Ed25519 key. 'key_value' corresponds to the - # 'keyval' entry of the 'Ed25519KEY_SCHEMA' dictionary. The private key + # Generate the keyid of the ECDSA key. 'key_value' corresponds to the + # 'keyval' entry of the 'ECDSAKEY_SCHEMA' dictionary. The private key # information is not included in the generation of the 'keyid' identifier. # Convert any '\r\n' (e.g., Windows) newline characters to '\n' so that a # consistent keyid is generated. @@ -297,8 +305,8 @@ def generate_ecdsa_key(scheme='ecdsa-sha2-nistp256'): 'private': ''} keyid = _get_keyid(keytype, scheme, key_value) - # Build the 'ed25519_key' dictionary. Update 'key_value' with the Ed25519 - # private key prior to adding 'key_value' to 'ed25519_key'. + # Build the 'ecdsa_key' dictionary. Update 'key_value' with the ECDSA + # private key prior to adding 'key_value' to 'ecdsa_key'. key_value['private'] = private @@ -395,6 +403,73 @@ def generate_ed25519_key(scheme='ed25519'): +def generate_spx_key(scheme='spx'): + """ + + Generate public and private SPX keys, In addition, a keyid identifier generated + for the returned SPX object. The object returned conforms to + 'securesystemslib.formats.SPXKEY_SCHEMA' and has the form: + + {'keytype': 'spx', + 'scheme': 'spx', + 'keyid': 'f30a0870d026980100c0573bd557394f8c1bbd6...', + 'keyval': {'public': '9ccf3f02b17f82febf5dd3bab878b767d8408...', + 'private': 'ab310eae0e229a0eceee3947b6e0205dfab3...'}} + + >>> spx_key = generate_spx_key() + >>> securesystemslib.formats.SPXKEY_SCHEMA.matches(spx_key) + True + + + scheme: + The signature scheme used by the generated SPX key. + + + None. + + + The SPX keys are generated by calling the SPX routines provided by 'pyspx'. + + + A dictionary containing the SPX keys and other identifying information. + Conforms to 'securesystemslib.formats.SPXKEY_SCHEMA'. + """ + + # Are the arguments properly formatted? If not, raise an + # 'securesystemslib.exceptions.FormatError' exceptions. + securesystemslib.formats.SPX_SIG_SCHEMA.check_match(scheme) + + # Begin building the SPX key dictionary. + spx_key = {} + keytype = 'spx' + public = None + private = None + + # Generate the public and private SPX key with the 'pyspx' library. + public, private = \ + securesystemslib.spx_keys.generate_public_and_private() + + # Generate the keyid of the SPX key. 'key_value' corresponds to the + # 'keyval' entry of the 'SPXKEY_SCHEMA' dictionary. The private key + # information is not included in the generation of the 'keyid' identifier. + key_value = {'public': binascii.hexlify(public).decode(), + 'private': ''} + keyid = _get_keyid(keytype, scheme, key_value) + + # Build the 'spx_key' dictionary. Update 'key_value' with the SPX + # private key prior to adding 'key_value' to 'spx_key'. + key_value['private'] = binascii.hexlify(private).decode() + + spx_key['keytype'] = keytype + spx_key['scheme'] = scheme + spx_key['keyid'] = keyid + spx_key['keyid_hash_algorithms'] = securesystemslib.settings.HASH_ALGORITHMS + spx_key['keyval'] = key_value + + return spx_key + + + def format_keyval_to_metadata(keytype, scheme, key_value, private=False): @@ -724,6 +799,13 @@ def create_signature(key_dict, data): sig, scheme = securesystemslib.ed25519_keys.create_signature(public, private, data.encode('utf-8'), scheme) + elif keytype == 'spx': + public = binascii.unhexlify(public.encode('utf-8')) + private = binascii.unhexlify(private.encode('utf-8')) + sig, scheme = securesystemslib.spx_keys.create_signature(public, + private, data.encode('utf-8'), scheme) + + elif keytype == 'ecdsa-sha2-nistp256': sig, scheme = securesystemslib.ecdsa_keys.create_signature(public, private, data.encode('utf-8'), scheme) @@ -866,7 +948,14 @@ def verify_signature(key_dict, signature, data): public = binascii.unhexlify(public.encode('utf-8')) valid_signature = securesystemslib.ed25519_keys.verify_signature(public, scheme, sig, data, use_pynacl=USE_PYNACL) + else: + raise securesystemslib.exceptions.UnsupportedAlgorithmError('Unsupported' + ' signature scheme is specified: ' + repr(scheme)) + elif keytype == 'spx': + if scheme == 'spx': + public = binascii.unhexlify(public.encode('utf-8')) + valid_signature = securesystemslib.spx_keys.verify_signature(public, scheme, sig, data) else: raise securesystemslib.exceptions.UnsupportedAlgorithmError('Unsupported' ' signature scheme is specified: ' + repr(scheme)) @@ -1637,6 +1726,46 @@ def import_ed25519key_from_private_json(json_str, password=None): return key_object +def import_spxkey_from_private_json(json_str, password=None): + if password is not None: + # This check will not fail, because a mal-formatted passed password fails + # above and an entered password will always be a string (see get_password) + # However, we include it in case PASSWORD_SCHEMA or get_password changes. + securesystemslib.formats.PASSWORD_SCHEMA.check_match(password) + + # Decrypt the loaded key file, calling the 'cryptography' library to + # generate the derived encryption key from 'password'. Raise + # 'securesystemslib.exceptions.CryptoError' if the decryption fails. + key_object = securesystemslib.keys.\ + decrypt_key(json_str.decode('utf-8'), password) + + else: + logger.debug('No password was given. Attempting to import an' + ' unencrypted file.') + try: + key_object = \ + securesystemslib.util.load_json_string(json_str.decode('utf-8')) + # If the JSON could not be decoded, it is very likely, but not necessarily, + # due to a non-empty password. + except securesystemslib.exceptions.Error: + raise securesystemslib.exceptions\ + .CryptoError('Malformed SPX key JSON, ' + 'possibly due to encryption, ' + 'but no password provided?') + + # Raise an exception if an unexpected key type is imported. + if key_object['keytype'] != 'spx': + message = 'Invalid key type loaded: ' + repr(key_object['keytype']) + raise securesystemslib.exceptions.FormatError(message) + + # Add "keyid_hash_algorithms" so that equal SPX keys with + # different keyids can be associated using supported keyid_hash_algorithms. + key_object['keyid_hash_algorithms'] = \ + securesystemslib.settings.HASH_ALGORITHMS + + return key_object + + diff --git a/securesystemslib/spx_keys.py b/securesystemslib/spx_keys.py new file mode 100755 index 00000000..81ec5c9c --- /dev/null +++ b/securesystemslib/spx_keys.py @@ -0,0 +1,307 @@ +""" + + spx_keys.py + + + Peter Schwabe + + + October 31, 2018. + + + See LICENSE for licensing information. + + + The goal of this module is to support SPINCS+ ("SPX") signatures. SPHINCS+ is an + a framework for creating stateless hash-based signatures. + The concrete instantiation of this framework used here is the "shake256-192s" + parameter set as defined in the SPHINCS+ submission to NIST; see + http://sphincs.org/resources.html + + 'securesystemslib/spx_keys.py' calls 'pyspx.py', which is a wrapper + around the C reference implementation of SPHINCS+ submitted to NIST. See + https://github.com/sphincs/pyspx and + https://github.com/sphincs/sphincsplus. + """ + +# Help with Python 3 compatibility, where the print statement is a function, an +# implicit relative import is invalid, and the '/' operator performs true +# division. Example: print 'hello world' raises a 'SyntaxError' exception. +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals + +# 'binascii' required for hexadecimal conversions. Signatures and +# public/private keys are hexlified. +import binascii + +# 'os' required to generate OS-specific randomness (os.urandom) suitable for +# cryptographic use. +# http://docs.python.org/2/library/os.html#miscellaneous-functions +import os + +# Import the pyspx library, if available. This library is required to use +# spx signatures. +# +# Note: A 'pragma: no cover' comment is intended for test 'coverage'. Lines +# or code blocks with this comment should not be flagged as uncovered. +try: + import pyspx.shake256_192s as pyspx + +# pyspx's 'cffi' dependency may raise an 'IOError' exception when importing +except (ImportError, IOError): # pragma: no cover + pass + +import securesystemslib.formats +import securesystemslib.exceptions + +# Supported spx signing schemes: 'spx'. +_SUPPORTED_SPX_SIGNING_SCHEMES = ['spx'] + + +def generate_public_and_private(): + """ + + Generate a pair of spx public and private keys with pyspx. The public + and private keys returned conform to + 'securesystemslib.formats.SPXPUBLIC_SCHEMA' and + 'securesystemslib.formats.SPXSEED_SCHEMA', respectively. + + An spx seed key is a random 128-byte string. Public keys are 64 bytes. + + >>> public, private = generate_public_and_private() + >>> securesystemslib.formats.SPXPUBLIC_SCHEMA.matches(public) + True + >>> securesystemslib.formats.SPXSEED_SCHEMA.matches(private) + True + + + None. + + + securesystemslib.exceptions.UnsupportedLibraryError, if the pyspx + module is unavailable. + + NotImplementedError, if a randomness source is not found by 'os.urandom'. + + + The spx keys are generated by first creating a random seed + with os.urandom() and then calling pyspx's pyspx.signing.SigningKey(). + + + A (public, private) tuple that conform to + 'securesystemslib.formats.SPXPUBLIC_SCHEMA' and + 'securesystemslib.formats.SPXSEED_SCHEMA', respectively. + """ + + # Generate spx's seed key by calling os.urandom(). The random bytes + # returned should be suitable for cryptographic use and is OS-specific. + # Raise 'NotImplementedError' if a randomness source is not found. + seed = os.urandom(pyspx.crypto_sign_SEEDBYTES) + public = None + + # Generate the public key. pyspx performs the actual key generation. + try: + public, private = pyspx.generate_keypair(seed) + + except NameError: # pragma: no cover + raise securesystemslib.exceptions.UnsupportedLibraryError('The pyspx' + ' library and/or its dependencies unavailable.') + + return public, private + + + + + +def create_signature(public_key, private_key, data, scheme): + """ + + Return a (signature, scheme) tuple, where the signature scheme is 'spx' + and is always generated by pyspx. The signature returned + conforms to 'securesystemslib.formats.SPXSIGNATURE_SCHEMA'. + + >>> public, private = generate_public_and_private() + >>> data = b'The quick brown fox jumps over the lazy dog' + >>> scheme = 'spx' + >>> signature, scheme = \ + create_signature(public, private, data, scheme) + >>> securesystemslib.formats.SPXSIGNATURE_SCHEMA.matches(signature) + True + >>> scheme == 'spx' + True + >>> signature, scheme = \ + create_signature(public, private, data, scheme) + >>> securesystemslib.formats.SPXSIGNATURE_SCHEMA.matches(signature) + True + >>> scheme == 'spx' + True + + + public: + The spx public key, a simple byte string + + private: + The spx private key, a simple byte string + + data: + Data object used by create_signature() to generate the signature. + + scheme: + The signature scheme used to generate the signature. + + + securesystemslib.exceptions.FormatError, if the arguments are improperly + formatted. + + securesystemslib.exceptions.CryptoError, if a signature cannot be created. + + + spx.signing.SigningKey.sign() called to generate the actual signature. + + + A signature dictionary conformat to 'securesystemslib.format.SIGNATURE_SCHEMA'. + """ + + # Does 'public_key' have the correct format? + # This check will ensure 'public_key' conforms to + # 'securesystemslib.formats.SPXPUBLIC_SCHEMA', which must have length 32 + # bytes. Raise 'securesystemslib.exceptions.FormatError' if the check fails. + securesystemslib.formats.SPXPUBLIC_SCHEMA.check_match(public_key) + + # Is 'private_key' properly formatted? + securesystemslib.formats.SPXSEED_SCHEMA.check_match(private_key) + + # Is 'scheme' properly formatted? + securesystemslib.formats.SPX_SIG_SCHEMA.check_match(scheme) + + # Signing the 'data' object requires a seed and public key. + # spx.signing.SigningKey.sign() generates the signature. + public = public_key + private = private_key + + signature = None + + # An if-clause is not strictly needed here, since 'spx' is the only + # currently supported scheme. Nevertheless, include the conditional + # statement to accommodate schemes that might be added in the future. + if scheme == 'spx': + try: + signature = pyspx.sign(data, private) + + # The unit tests expect required libraries to be installed. + except NameError: # pragma: no cover + raise securesystemslib.exceptions.UnsupportedLibraryError('The pyspx' + ' library and/or its dependencies unavailable.') + + except (ValueError, TypeError) as e: + raise securesystemslib.exceptions.CryptoError('An "spx" signature' + ' could not be created with pyspx.' + str(e)) + + # This is a defensive check for a valid 'scheme', which should have already + # been validated in the check_match() above. + else: #pragma: no cover + raise securesystemslib.exceptions.UnsupportedAlgorithmError('Unsupported' + ' signature scheme is specified: ' + repr(scheme)) + + return signature, scheme + + + + + +def verify_signature(public_key, scheme, signature, data): + """ + + Determine whether the private key corresponding to 'public_key' produced + 'signature'. verify_signature() will use the public key, the 'scheme' and + 'sig', and 'data' arguments to complete the verification. + + >>> public, private = generate_public_and_private() + >>> data = b'The quick brown fox jumps over the lazy dog' + >>> scheme = 'spx' + >>> signature, scheme = \ + create_signature(public, private, data, scheme) + >>> verify_signature(public, scheme, signature, data) + True + >>> bad_data = b'The sly brown fox jumps over the lazy dog' + >>> bad_signature, scheme = \ + create_signature(public, private, bad_data, scheme) + >>> verify_signature(public, scheme, bad_signature, data) + False + + + public_key: + The public key is a simple byte string of length spx.crypto_sign_PUBLICKEYBYTES + + scheme: + 'spx' signature scheme + + signature: + The signature is a simple byte string of length spx.crypto_sign_BYTES + + data: + Data object used by securesystemslib.spx_keys.create_signature() to + generate 'signature'. 'data' is needed here to verify the signature. + + + securesystemslib.exceptions.UnsupportedAlgorithmError. Raised if the + signature scheme 'scheme' is not one supported by + securesystemslib.spx_keys.create_signature(). + + securesystemslib.exceptions.FormatError. Raised if the arguments are + improperly formatted. + + + pyspx.signing.VerifyKey.verify() called + + + Boolean. True if the signature is valid, False otherwise. + """ + + # Does 'public_key' have the correct format? + # This check will ensure 'public_key' conforms to + # 'securesystemslib.formats.SPXPUBLIC_SCHEMA', bytes. + # Raise 'securesystemslib.exceptions.FormatError' if the check fails. + securesystemslib.formats.SPXPUBLIC_SCHEMA.check_match(public_key) + + # Is 'scheme' properly formatted? + securesystemslib.formats.SPX_SIG_SCHEMA.check_match(scheme) + + # Is 'signature' properly formatted? + securesystemslib.formats.SPXSIGNATURE_SCHEMA.check_match(signature) + + # Verify 'signature'. Before returning the Boolean result, ensure 'spx' + # was used as the signature scheme. Raise + # 'securesystemslib.exceptions.UnsupportedLibraryError' if 'pyspx' is unavailable. + public = public_key + valid_signature = False + + if scheme in _SUPPORTED_SPX_SIGNING_SCHEMES: + try: + valid_signature = pyspx.verify(data, signature, public) + + # The unit tests expect PyNaCl to be installed. + except NameError: # pragma: no cover + raise securesystemslib.exceptions.UnsupportedLibraryError('The pyspx' + ' library and/or its dependencies unavailable.') + + + # This is a defensive check for a valid 'scheme', which should have already + # been validated in the SPX_SIG_SCHEMA.check_match(scheme) above. + else: #pragma: no cover + message = 'Unsupported spx signature scheme: ' + repr(scheme) + '.\n' + \ + 'Supported schemes: ' + repr(_SUPPORTED_SPX_SIGNING_SCHEMES) + '.' + raise securesystemslib.exceptions.UnsupportedAlgorithmError(message) + + return valid_signature + + + +if __name__ == '__main__': + # The interactive sessions of the documentation strings can + # be tested by running 'spx_keys.py' as a standalone module. + # python -B spx_keys.py + import doctest + doctest.testmod() diff --git a/setup.py b/setup.py index a8b5a4a1..8b57d1b4 100755 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ author = 'https://www.updateframework.com', author_email = 'theupdateframework@googlegroups.com', url = 'https://github.com/secure-systems-lab/securesystemslib', - keywords = 'cryptography, keys, signatures, rsa, ed25519, ecdsa', + keywords = 'cryptography, keys, signatures, rsa, ed25519, ecdsa, spx', classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', diff --git a/tests/test_interface.py b/tests/test_interface.py index 671effa1..83cc7b8c 100755 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -473,6 +473,207 @@ def test_import_ed25519_privatekey_from_file(self): + + def test_generate_and_write_spx_keypair(self): + + # Test normal case. + temporary_directory = tempfile.mkdtemp(dir=self.temporary_directory) + test_keypath = os.path.join(temporary_directory, 'spx_key') + test_keypath_unencrypted = os.path.join(temporary_directory, + 'spx_key_unencrypted') + + returned_path = interface.generate_and_write_spx_keypair( + test_keypath, password='pw') + self.assertTrue(os.path.exists(test_keypath)) + self.assertTrue(os.path.exists(test_keypath + '.pub')) + self.assertEqual(returned_path, test_keypath) + + # If an empty string is given for 'password', the private key file + # is written to disk unencrypted. + interface.generate_and_write_spx_keypair(test_keypath_unencrypted, + password='') + self.assertTrue(os.path.exists(test_keypath_unencrypted)) + self.assertTrue(os.path.exists(test_keypath_unencrypted + '.pub')) + + # Ensure the generated key files are importable. + imported_pubkey = \ + interface.import_spx_publickey_from_file(test_keypath + '.pub') + self.assertTrue(securesystemslib.formats.SPXKEY_SCHEMA\ + .matches(imported_pubkey)) + + imported_privkey = \ + interface.import_spx_privatekey_from_file(test_keypath, 'pw') + self.assertTrue(securesystemslib.formats.SPXKEY_SCHEMA\ + .matches(imported_privkey)) + + # Fail importing encrypted key passing password and prompt + with self.assertRaises(ValueError): + interface.import_spx_privatekey_from_file(test_keypath, + password='pw', + prompt=True) + + # Fail importing encrypted key passing an empty string for passwd + with self.assertRaises(ValueError): + interface.import_spx_privatekey_from_file(test_keypath, + password='') + + # Try to import the unencrypted key file, by not passing a password + imported_privkey = \ + interface.import_spx_privatekey_from_file(test_keypath_unencrypted) + self.assertTrue(securesystemslib.formats.SPXKEY_SCHEMA.\ + matches(imported_privkey)) + + # Try to import the unencrypted key file, by entering an empty password + with mock.patch('securesystemslib.interface.get_password', + return_value=''): + imported_privkey = \ + interface.import_spx_privatekey_from_file(test_keypath_unencrypted, + prompt=True) + self.assertTrue( + securesystemslib.formats.SPXKEY_SCHEMA.matches(imported_privkey)) + + # Fail importing unencrypted key passing a password + with self.assertRaises(securesystemslib.exceptions.CryptoError): + interface.import_spx_privatekey_from_file(test_keypath_unencrypted, + 'pw') + + # Fail importing encrypted key passing no password + with self.assertRaises(securesystemslib.exceptions.CryptoError): + interface.import_spx_privatekey_from_file(test_keypath) + + # Test for a default filepath. If 'filepath' is not given, the key's + # KEYID is used as the filename. The key is saved to the current working + # directory. + default_keypath = interface.generate_and_write_spx_keypair(password='pw') + self.assertTrue(os.path.exists(default_keypath)) + self.assertTrue(os.path.exists(default_keypath + '.pub')) + + written_key = interface.import_spx_publickey_from_file(default_keypath + '.pub') + self.assertEqual(written_key['keyid'], os.path.basename(default_keypath)) + + os.remove(default_keypath) + os.remove(default_keypath + '.pub') + + + # Test improperly formatted arguments. + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.generate_and_write_spx_keypair, 3, password='pw') + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.generate_and_write_rsa_keypair, test_keypath, password=3) + + + + def test_import_spx_publickey_from_file(self): + # Test normal case. + # Generate spx keys that can be imported. + temporary_directory = tempfile.mkdtemp(dir=self.temporary_directory) + spx_keypath = os.path.join(temporary_directory, 'spx_key') + interface.generate_and_write_spx_keypair(spx_keypath, password='pw') + + imported_spx_key = \ + interface.import_spx_publickey_from_file(spx_keypath + '.pub') + self.assertTrue(securesystemslib.formats.SPXKEY_SCHEMA.matches(imported_spx_key)) + + + # Test improperly formatted argument. + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.import_spx_publickey_from_file, 3) + + + # Test invalid argument. + # Non-existent key file. + nonexistent_keypath = os.path.join(temporary_directory, + 'nonexistent_keypath') + self.assertRaises(IOError, interface.import_spx_publickey_from_file, + nonexistent_keypath) + + # Invalid key file argument. + invalid_keyfile = os.path.join(temporary_directory, 'invalid_keyfile') + with open(invalid_keyfile, 'wb') as file_object: + file_object.write(b'bad keyfile') + + self.assertRaises(securesystemslib.exceptions.Error, + interface.import_spx_publickey_from_file, invalid_keyfile) + + # Invalid public key imported (contains unexpected keytype.) + keytype = imported_spx_key['keytype'] + keyval = imported_spx_key['keyval'] + scheme = imported_spx_key['scheme'] + + spxkey_metadata_format = \ + securesystemslib.keys.format_keyval_to_metadata(keytype, scheme, + keyval, private=False) + + spxkey_metadata_format['keytype'] = 'invalid_keytype' + with open(spx_keypath + '.pub', 'wb') as file_object: + file_object.write(json.dumps(spxkey_metadata_format).encode('utf-8')) + + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.import_spx_publickey_from_file, + spx_keypath + '.pub') + + + + def test_import_spx_privatekey_from_file(self): + # Test normal case. + # Generate spx keys that can be imported. + scheme = 'spx' + temporary_directory = tempfile.mkdtemp(dir=self.temporary_directory) + spx_keypath = os.path.join(temporary_directory, 'spx_key') + interface.generate_and_write_spx_keypair(spx_keypath, password='pw') + + imported_spx_key = \ + interface.import_spx_privatekey_from_file(spx_keypath, 'pw') + self.assertTrue(securesystemslib.formats.SPXKEY_SCHEMA.matches(imported_spx_key)) + + + # Test improperly formatted argument. + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.import_spx_privatekey_from_file, 3, 'pw') + + + # Test invalid argument. + # Non-existent key file. + nonexistent_keypath = os.path.join(temporary_directory, + 'nonexistent_keypath') + self.assertRaises(IOError, interface.import_spx_privatekey_from_file, + nonexistent_keypath, 'pw') + + # Invalid key file argument. + invalid_keyfile = os.path.join(temporary_directory, 'invalid_keyfile') + with open(invalid_keyfile, 'wb') as file_object: + file_object.write(b'bad keyfile') + + self.assertRaises(securesystemslib.exceptions.Error, + interface.import_spx_privatekey_from_file, invalid_keyfile, 'pw') + + # Invalid private key imported (contains unexpected keytype.) + imported_spx_key['keytype'] = 'invalid_keytype' + + # Use 'pyca_crypto_keys.py' to bypass the key format validation performed + # by 'keys.py'. + salt, iterations, derived_key = \ + securesystemslib.pyca_crypto_keys._generate_derived_key('pw') + + # Store the derived key info in a dictionary, the object expected + # by the non-public _encrypt() routine. + derived_key_information = {'salt': salt, 'iterations': iterations, + 'derived_key': derived_key} + + # Convert the key object to json string format and encrypt it with the + # derived key. + encrypted_key = \ + securesystemslib.pyca_crypto_keys._encrypt(json.dumps(imported_spx_key), + derived_key_information) + + with open(spx_keypath, 'wb') as file_object: + file_object.write(encrypted_key.encode('utf-8')) + + self.assertRaises(securesystemslib.exceptions.FormatError, + interface.import_spx_privatekey_from_file, spx_keypath, 'pw') + + + def test_generate_and_write_ecdsa_keypair(self): # Test normal case. diff --git a/tests/test_keys.py b/tests/test_keys.py index 5306aa3c..03146dc2 100755 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -50,6 +50,7 @@ def setUpClass(cls): cls.rsakey_dict = KEYS.generate_rsa_key() cls.ed25519key_dict = KEYS.generate_ed25519_key() cls.ecdsakey_dict = KEYS.generate_ecdsa_key() + cls.spxkey_dict = KEYS.generate_spx_key() def test_generate_rsa_key(self): _rsakey_dict = KEYS.generate_rsa_key() @@ -232,6 +233,7 @@ def test_create_signature(self): # Creating a signature for 'DATA'. rsa_signature = KEYS.create_signature(self.rsakey_dict, DATA) ed25519_signature = KEYS.create_signature(self.ed25519key_dict, DATA) + spx_signature = KEYS.create_signature(self.spxkey_dict, DATA) # Check format of output. self.assertEqual(None, @@ -240,6 +242,10 @@ def test_create_signature(self): self.assertEqual(None, securesystemslib.formats.SIGNATURE_SCHEMA.check_match(ed25519_signature), FORMAT_ERROR_MSG) + self.assertEqual(None, + securesystemslib.formats.SIGNATURE_SCHEMA.check_match(spx_signature), + FORMAT_ERROR_MSG) + # Test for invalid signature scheme. args = (self.rsakey_dict, DATA) @@ -292,6 +298,7 @@ def test_verify_signature(self): # Creating a signature of 'DATA' to be verified. rsa_signature = KEYS.create_signature(self.rsakey_dict, DATA) ed25519_signature = KEYS.create_signature(self.ed25519key_dict, DATA) + spx_signature = KEYS.create_signature(self.spxkey_dict, DATA) ecdsa_signature = None ecdsa_signature = KEYS.create_signature(self.ecdsakey_dict, DATA) @@ -305,6 +312,12 @@ def test_verify_signature(self): DATA) self.assertTrue(verified, "Incorrect signature.") + # Verifying the 'spx_signature' of 'DATA'. + verified = KEYS.verify_signature(self.spxkey_dict, spx_signature, + DATA) + self.assertTrue(verified, "Incorrect signature.") + + # Verify that an invalid ed25519 signature scheme is rejected. valid_scheme = self.ed25519key_dict['scheme'] self.ed25519key_dict['scheme'] = 'invalid_scheme' @@ -312,6 +325,14 @@ def test_verify_signature(self): KEYS.verify_signature, self.ed25519key_dict, ed25519_signature, DATA) self.ed25519key_dict['scheme'] = valid_scheme + # Verify that an invalid spx signature scheme is rejected. + valid_scheme = self.spxkey_dict['scheme'] + self.spxkey_dict['scheme'] = 'invalid_scheme' + self.assertRaises(securesystemslib.exceptions.UnsupportedAlgorithmError, + KEYS.verify_signature, self.spxkey_dict, spx_signature, DATA) + self.spxkey_dict['scheme'] = valid_scheme + + verified = KEYS.verify_signature(self.ecdsakey_dict, ecdsa_signature, DATA) self.assertTrue(verified, "Incorrect signature.") @@ -344,6 +365,11 @@ def test_verify_signature(self): self.assertFalse(verified, 'Returned \'True\' on an incorrect signature.') + verified = KEYS.verify_signature(self.spxkey_dict, + spx_signature, _DATA) + self.assertFalse(verified, + 'Returned \'True\' on an incorrect signature.') + verified = KEYS.verify_signature(self.ecdsakey_dict, ecdsa_signature, _DATA) self.assertFalse(verified, 'Returned \'True\' on an incorrect signature.') diff --git a/tests/test_spx_keys.py b/tests/test_spx_keys.py new file mode 100755 index 00000000..4377e35c --- /dev/null +++ b/tests/test_spx_keys.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python + +""" + + test_spx_keys.py + + + Peter Schwabe + + + October 31, 2018. + + + See LICENSE for licensing information. + + + Test cases for test_spx_keys.py. +""" + +# Help with Python 3 compatibility, where the print statement is a function, an +# implicit relative import is invalid, and the '/' operator performs true +# division. Example: print 'hello world' raises a 'SyntaxError' exception. +from __future__ import print_function +from __future__ import absolute_import +from __future__ import division +from __future__ import unicode_literals + +import unittest +import os +import logging + +import securesystemslib.exceptions +import securesystemslib.formats +import securesystemslib.spx_keys + +logger = logging.getLogger('securesystemslib.test_spx_keys') + +public, private = securesystemslib.spx_keys.generate_public_and_private() +FORMAT_ERROR_MSG = 'securesystemslib.exceptions.FormatError raised. Check object\'s format.' + + +class TestSPX_keys(unittest.TestCase): + def setUp(self): + pass + + + def test_generate_public_and_private(self): + pub, priv = securesystemslib.spx_keys.generate_public_and_private() + + # Check format of 'pub' and 'priv'. + self.assertEqual(True, securesystemslib.formats.SPXPUBLIC_SCHEMA.matches(pub)) + self.assertEqual(True, securesystemslib.formats.SPXSEED_SCHEMA.matches(priv)) + + + + def test_create_signature(self): + global public + global private + data = b'The quick brown fox jumps over the lazy dog' + scheme = 'spx' + signature, scheme = securesystemslib.spx_keys.create_signature(public, + private, data, scheme) + + # Verify format of returned values. + self.assertEqual(True, + securesystemslib.formats.SPXSIGNATURE_SCHEMA.matches(signature)) + + self.assertEqual(True, securesystemslib.formats.SPX_SIG_SCHEMA.matches(scheme)) + self.assertEqual('spx', scheme) + + # Check for improperly formatted argument. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.create_signature, 123, private, data, + scheme) + + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.create_signature, public, 123, data, + scheme) + + # Check for invalid 'data'. + self.assertRaises(securesystemslib.exceptions.CryptoError, + securesystemslib.spx_keys.create_signature, public, private, 123, + scheme) + + + def test_verify_signature(self): + global public + global private + data = b'The quick brown fox jumps over the lazy dog' + scheme = 'spx' + signature, scheme = securesystemslib.spx_keys.create_signature(public, + private, data, scheme) + + valid_signature = securesystemslib.spx_keys.verify_signature(public, + scheme, signature, data) + self.assertEqual(True, valid_signature) + + # Check for improperly formatted arguments. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.verify_signature, 123, scheme, + signature, data) + + # Signature method improperly formatted. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.verify_signature, public, 123, + signature, data) + + # Invalid signature method. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.verify_signature, public, + 'unsupported_scheme', signature, data) + + # Signature not a string. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.verify_signature, public, scheme, + 123, data) + + # Invalid signature length, which must be exactly 64 bytes.. + self.assertRaises(securesystemslib.exceptions.FormatError, + securesystemslib.spx_keys.verify_signature, public, scheme, + 'bad_signature', data) + + # Check for invalid signature and data. + # Mismatched data. + self.assertEqual(False, securesystemslib.spx_keys.verify_signature( + public, scheme, signature, b'123')) + + # Mismatched signature. + bad_signature = b'a'*securesystemslib.spx_keys.pyspx.crypto_sign_BYTES + self.assertEqual(False, securesystemslib.spx_keys.verify_signature( + public, scheme, bad_signature, data)) + + # Generated signature created with different data. + new_signature, scheme = securesystemslib.spx_keys.create_signature( + public, private, b'mismatched data', scheme) + + self.assertEqual(False, securesystemslib.spx_keys.verify_signature( + public, scheme, new_signature, data)) + + + +# Run the unit tests. +if __name__ == '__main__': + unittest.main()