diff --git a/README.rst b/README.rst index 60b518b2..43e8f815 100644 --- a/README.rst +++ b/README.rst @@ -22,11 +22,15 @@ securesystemslib supports public-key and general-purpose cryptography, such as `ECDSA `_, `Ed25519 `_, `RSA -`_, SHA256, SHA512, etc. -Most of the cryptographic operations are performed by the `cryptography +`_, +`SPHINCS+-shake256-192s `_, SHA256, SHA512, etc. Most of +the cryptographic operations are performed by the `cryptography `_ and `PyNaCl `_ libraries, but verification of Ed25519 -signatures can be done in pure Python. +signatures can be done in pure Python. The `PySPX +`_ Python bindings are used for SPHINCS+ +signatures. + The `cryptography` library is used to generate keys and signatures with the ECDSA and RSA algorithms, and perform general-purpose cryptography such as @@ -46,12 +50,14 @@ Installation The default installation only supports Ed25519 keys and signatures (in pure Python). Support for RSA, ECDSA, and E25519 via the `cryptography` and -`PyNaCl` libraries is available by installing the `crypto` and `pynacl` extras: +`PyNaCl` libraries is available by installing the `crypto` and `pynacl` extras. +Support for SPHINCS+ signatures is available via the `pyspx` extra. :: $ pip install securesystemslib[crypto] $ pip install securesystemslib[pynacl] + $ pip install securesystemslib[pyspx] Create RSA Keys 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..883d6495 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ cryptography==2.3.1 pynacl==1.2.1 +pyspx==0.4.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/ed25519_keys.py b/securesystemslib/ed25519_keys.py index caf3233d..cc686a24 100755 --- a/securesystemslib/ed25519_keys.py +++ b/securesystemslib/ed25519_keys.py @@ -201,12 +201,6 @@ def create_signature(public_key, private_key, data, scheme): True >>> scheme == 'ed25519' True - >>> signature, scheme = \ - create_signature(public, private, data, scheme) - >>> securesystemslib.formats.ED25519SIGNATURE_SCHEMA.matches(signature) - True - >>> scheme == 'ed25519' - True public: diff --git a/securesystemslib/formats.py b/securesystemslib/formats.py index 5db2dc0f..c613e542 100755 --- a/securesystemslib/formats.py +++ b/securesystemslib/formats.py @@ -227,7 +227,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 +249,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, @@ -299,7 +299,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 +314,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..5930bd08 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,259 @@ 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_data = securesystemslib.util.load_json_file(filepath) + spx_key, junk = securesystemslib.keys.format_metadata_to_key(spx_key_data) + + # 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_data'. + 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 51ebcb94..226865b5 100755 --- a/securesystemslib/keys.py +++ b/securesystemslib/keys.py @@ -98,6 +98,15 @@ # regardless of the availability of PyNaCl. import securesystemslib.ed25519_keys + +try: + import securesystemslib.spx_keys + +# pyspx's 'cffi' dependency may raise an 'IOError' exception when importing +except (ImportError, IOError): #pragma: no cover + pass + + try: import securesystemslib.ecdsa_keys @@ -288,8 +297,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 +306,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 +404,70 @@ 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' + + # 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): @@ -629,6 +702,12 @@ def create_signature(key_dict, data): ed25519 - high-speed high security signatures http://ed25519.cr.yp.to/ + 'spx' + Sphinx+-Shake256 + https://sphincs.org/ + + 'ecdsa-sha2-nistp256' + Which signature to generate is determined by the key type of 'key_dict' and the available cryptography library specified in 'settings'. @@ -721,6 +800,11 @@ def create_signature(key_dict, data): sig, scheme = securesystemslib.ed25519_keys.create_signature( public, private, data, scheme) + elif keytype == 'spx': + private = binascii.unhexlify(private.encode('utf-8')) + sig, scheme = securesystemslib.spx_keys.create_signature(private, data, + scheme) + elif keytype == 'ecdsa-sha2-nistp256': sig, scheme = securesystemslib.ecdsa_keys.create_signature( public, private, data, scheme) @@ -860,6 +944,15 @@ 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' @@ -1631,6 +1724,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..e6248b4e --- /dev/null +++ b/securesystemslib/spx_keys.py @@ -0,0 +1,222 @@ +""" + + 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 + 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. +import pyspx.shake256_192s as pyspx + +import securesystemslib.formats +import securesystemslib.exceptions +import securesystemslib.schema as SCHEMA + +# Define lengths of SPX keys and signature bytes +# NOTE: Define module scope schemas here to avoid conditional imports of +# optional 'pyspx' package in 'formats' module. ImportError and IOError should +# be handled by whoever imports this 'spx_keys' module. +SPX_PUBLIC_BYTES_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_PUBLICKEYBYTES) +SPX_PRIVATE_BYTES_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_SECRETKEYBYTES) +SPX_SIG_BYTES_SCHEMA = SCHEMA.LengthBytes(pyspx.crypto_sign_BYTES) + +def generate_public_and_private(): + """ + + Generate a pair of spx public and private keys with pyspx. The public + and private keys returned conform to 'SPX_PUBLIC_BYTES_SCHEMA' and + 'SPX_PRIVATE_BYTES_SCHEMA', respectively. + + An spx seed key is a random 128-byte string. Public keys are 64 bytes. + + >>> public, private = generate_public_and_private() + >>> SPX_PUBLIC_BYTES_SCHEMA.matches(public) + True + >>> SPX_PRIVATE_BYTES_SCHEMA.matches(private) + True + + + None. + + + 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 + 'SPX_PUBLIC_BYTES_SCHEMA' and + 'SPX_PRIVATE_BYTES_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) + + # Generate the public key. pyspx performs the actual key generation. + public, private = pyspx.generate_keypair(seed) + + return public, private + + + + + +def create_signature(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 'SPX_SIG_BYTES_SCHEMA'. + + >>> public, private = generate_public_and_private() + >>> data = b'The quick brown fox jumps over the lazy dog' + >>> scheme = 'spx' + >>> signature, scheme = \ + create_signature(private, data, scheme) + >>> SPX_SIG_BYTES_SCHEMA.matches(signature) + True + >>> scheme == 'spx' + True + + + 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 conformant to + 'securesystemslib.format.SIGNATURE_SCHEMA'. + + """ + # Validate arguments + SPX_PRIVATE_BYTES_SCHEMA.check_match(private_key) + securesystemslib.formats.SPX_SIG_SCHEMA.check_match(scheme) + + try: + signature = pyspx.sign(data, private_key) + + except (ValueError, TypeError) as e: + raise securesystemslib.exceptions.CryptoError('An "spx" signature' + ' could not be created with pyspx: ' + str(e)) + + 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(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(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_PUBLIC_BYTES_SCHEMA. + + scheme: + 'spx' signature scheme + + signature: + The signature is a simple byte string of length SPX_SIG_BYTES_SCHEMA. + + data: + Data object used by securesystemslib.spx_keys.create_signature() to + generate 'signature'. 'data' is needed here to verify the 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. + """ + # Validate arguments + SPX_PUBLIC_BYTES_SCHEMA.check_match(public_key) + SPX_SIG_BYTES_SCHEMA.check_match(signature) + securesystemslib.formats.SPX_SIG_SCHEMA.check_match(scheme) + + # Return boolean signature verification result + return pyspx.verify(data, signature, public_key) + + + +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..6faa3509 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', @@ -98,7 +98,8 @@ ], install_requires = ['six>=1.11.0'], extras_require = {'crypto': ['cryptography>=2.2.2', 'colorama>=0.3.9'], - 'pynacl': ['pynacl>1.2.0']}, + 'pynacl': ['pynacl>1.2.0'], + 'pyspx': ['pyspx>=0.4.0']}, packages = find_packages(exclude=['tests', 'debian']), scripts = [] ) diff --git a/tests/test_interface.py b/tests/test_interface.py index 671effa1..023aed04 100755 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -311,7 +311,7 @@ def test_generate_and_write_ed25519_keypair(self): password='pw', prompt=True) - # Fail importing encrypted key passing an empty string for passwd + # Fail importing encrypted key passing an empty string for passwd with self.assertRaises(ValueError): interface.import_ed25519_privatekey_from_file(test_keypath, password='') @@ -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 165c96b6..c42db377 100755 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -51,6 +51,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() @@ -233,6 +234,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, @@ -241,6 +243,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) @@ -293,8 +299,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) - ecdsa_signature = None - + spx_signature = KEYS.create_signature(self.spxkey_dict, DATA) ecdsa_signature = KEYS.create_signature(self.ecdsakey_dict, DATA) # Verifying the 'signature' of 'DATA'. @@ -306,6 +311,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' @@ -313,6 +324,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.") @@ -346,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..f0893738 --- /dev/null +++ b/tests/test_spx_keys.py @@ -0,0 +1,139 @@ +#!/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.spx_keys.SPX_PUBLIC_BYTES_SCHEMA.matches(pub)) + self.assertEqual(True, securesystemslib.spx_keys.SPX_PRIVATE_BYTES_SCHEMA.matches(priv)) + + + + def test_create_signature(self): + global private + data = b'The quick brown fox jumps over the lazy dog' + scheme = 'spx' + signature, scheme = securesystemslib.spx_keys.create_signature( + private, data, scheme) + + # Verify format of returned values. + self.assertEqual(True, + securesystemslib.spx_keys.SPX_SIG_BYTES_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, data, + scheme) + + # Check for invalid 'data'. + self.assertRaises(securesystemslib.exceptions.CryptoError, + securesystemslib.spx_keys.create_signature, 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(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( + 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()