# -*- coding: utf-8 -*- """ conda_content_trust.cli This module provides the CLI interface for conda-content-trust. This is intended to provide a command-line signing and metadata update interface. """ # Python2 Compatibility from __future__ import absolute_import, division, print_function, unicode_literals import json from argparse import ArgumentParser import copy import json from conda_content_trust.common import ( canonserialize, load_metadata_from_file, write_metadata_to_file, CCT_Error, PrivateKey, is_gpg_fingerprint, is_hex_key) from conda_content_trust import __version__ import conda_content_trust.root_signing as cct_root_signing import conda_content_trust.signing as cct_signing import conda_content_trust.authentication as cct_authentication import conda_content_trust.metadata_construction as cct_metadata_construction # In Python2, input() performs evaluation and raw_input() does not. In # Python3, input() does not perform evaluation and there is no raw_input(). # So... use raw_input in Python2, and input in Python3. try: # pragma: no cover _input_func = raw_input except NameError: # pragma: no cover _input_func = input def cli(args=None): p = ArgumentParser( description="Signing and verification tools for Conda", conflict_handler='resolve' ) p.add_argument( '-V', '--version', action='version', help='Show the conda-content-trust version number and exit.', version="conda-content-trust %s" % __version__, ) # Create separate parsers for the subcommands. sp = p.add_subparsers(title='subcommands', dest='subcommand_name') # subcommand: sign-artifacts p_signrepo = sp.add_parser( 'sign-artifacts', help=('Given a repodata.json ' 'file, produce signatures over the metadata for each artifact listed, ' 'and update the repodata.json file with their individual signatures.')) p_signrepo.add_argument( 'repodata_fname', help=('the filename of a repodata.json file from ' 'which to retrieve metadata for individual artifacts.')) p_signrepo.add_argument( 'private_key_hex', help=('the ed25519 private key to be used to ' 'sign each artifact\'s metadata')) # subcommand: verify-metadata p_verifymd = sp.add_parser( 'verify-metadata', help=('Uses the first (trusted) metadata file ' 'to verify the second (not yet trusted) metadata file. For ' 'example, ' '"conda-content-trust verify-metadata 4.root.json 5.root.json"' ' to verify version 5 of root based on version 4 of root, or ' '"conda-content-trust verify-metadata 4.root.json key_mgr.json" ' 'to verify key manager metadata based on version 4 of root.')) p_verifymd.add_argument( 'trusted_metadata_filename', help=('the filename of the ' 'already-trusted metadata file that sets the rules for verifying ' 'the untrusted metadata file')) p_verifymd.add_argument( 'untrusted_metadata_filename', help=('the filename of the ' '(untrusted) metadata file to verify')) # subcommand: modify-metadata p_modifymd = sp.add_parser( 'modify-metadata', help=('Interactive metadata modification. Use ' 'this to produce a new version of a metadata file (like root.json ' 'or key_mgr.json), or correct an error in an unpublished metadata ' 'file, or review and sign a metadata file. This increments ' 'version number / timestamp, reports changes on console, etc. For ' 'example, "conda-content-trust modify-metadata 8.root.json" ' 'for assistance in ' 'producing a new version of root (version 9) using version 8.')) p_modifymd.add_argument( 'metadata_filename', help=('the filename of the existing metadata ' 'file to modify')) # If we're missing optional requirements for the next few options, note # that in their help strings. opt_reqs_str = '' if not cct_root_signing.SSLIB_AVAILABLE: opt_reqs_str = ('[Unavailable]: Requires optional ' 'dependencies: securesystemslib and gpg. ') # subcommand: gpg-key-lookup p_gpglookup = sp.add_parser('gpg-key-lookup', help=(opt_reqs_str + 'Given the OpenPGP fingerprint of an ed25519-type OpenPGP key, fetch ' 'the actual ed25519 public key value of the underlying key.')) p_gpglookup.add_argument( 'gpg_key_fingerprint', help=('the 40-hex-character key fingerprint (long keyid) for the ' 'OpenPGP/GPG key that you want to sign something with. Do not ' 'add prefix "0x".')) # subcommand: gpg-sign p_gpgsign = sp.add_parser('gpg-sign', help=(opt_reqs_str + 'Sign a given ' 'piece of metadata using GPG instead of the usual signing ' 'mechanisms. Takes an OpenPGP key fingerprint and a filename.')) p_gpgsign.add_argument( 'gpg_key_fingerprint', help=('the 40-hex-character key fingerprint (long keyid) for the ' 'OpenPGP/GPG key that you want to sign something with. Do not ' 'add prefix "0x".')) p_gpgsign.add_argument( 'filename', help=('the filename of the file that will be signed')) args = p.parse_args(args) if args.subcommand_name == 'gpg-sign': # TODO: Validate arguments. # Strip any whitespace from the key fingerprint and lowercase it. # GPG pops out keys in a variety of whitespace arrangements and cases, # so this is necessary for convenience. gpg_key_fingerprint = ''.join(args.gpg_key_fingerprint.split()).lower() cct_root_signing.sign_root_metadata_via_gpg( args.filename, gpg_key_fingerprint) elif args.subcommand_name == 'sign-artifacts': cct_signing.sign_all_in_repodata( args.repodata_fname, args.private_key_hex) elif args.subcommand_name == 'gpg-key-lookup': gpg_key_fingerprint = ''.join(args.gpg_key_fingerprint.split()).lower() keyval = cct_root_signing.fetch_keyval_from_gpg(gpg_key_fingerprint) print('Underlying ed25519 key value: ' + str(keyval)) elif args.subcommand_name == 'modify-metadata': # `conda-content-trust update-metadata ` # underlying functions: build_delegating_metadata, # load_metadata_from_file # given a metadata file, increment the version number and timestamps, # reporting the changes on the console # strip signatures # indicate what signatures are required # ask if the user wants to sign; query for the key hex or fname; # ideally, offer this functionality for both root and non-root keys. # For root metadata, we can (and should) also report which keys are # expected / still needed in order for the metadata to be verifiable # according to the old metadata and the new metadata old_metadata = load_metadata_from_file(args.metadata_filename) # new_metadata = cct_metadata_construction.interactive_modify_metadata(old_metadata) # if new_metadata is not None and new_metadata: # write_metadata_to_file(new_metadata, args.metadata_filename) interactive_modify_metadata(old_metadata) elif args.subcommand_name == 'verify-metadata': # `conda-content-trust verify-metadata <(optional) role name>` # underlying functions: cct_authentication.verify_delegation, # load_metadata_from_file # takes two metadata files, the first being a trusted file that should # provide the verification criteria (expected keys and expected number # of keys) for the second file. This should support root-root # verification (root chaining as currently implemented in # conda-content-trust) and delegation from one metadata type to another # (e.g. root to key_mgr) # conveys to the user whether or not the file is trusted, and for what # role. e.g., would convey that the first file is (e.g.) a root # metadata file, that it provides a delegation to , and that # the file provides and is signed # appropriately based on what the root metadata file requires of that # delegation. untrusted_metadata = load_metadata_from_file( args.untrusted_metadata_filename) trusted_metadata = load_metadata_from_file( args.trusted_metadata_filename) # TODO✅: Argument validation via the check_format_* calls. metadata_type = untrusted_metadata['signed']['type'] if metadata_type == 'root': # Verifying root has additional steps beyond verify_delegation. try: cct_authentication.verify_root(trusted_metadata, untrusted_metadata) print('Root metadata verification successful.') return 0 # success except CCT_Error as e: errorcode = 10 errorstring = str(e) else: # Verifying anything other than root just uses verify_delegation # directly. try: cct_authentication.verify_delegation( delegation_name=metadata_type, untrusted_delegated_metadata=untrusted_metadata, trusted_delegating_metadata=trusted_metadata) print('Metadata verification successful.') return 0 # success except CCT_Error as e: errorcode = 20 errorstring = str(e) # We should only get here if verification failed. print( 'Verification of untrusted metadata failed. Metadata ' 'type was "' + metadata_type + '". Error reads:\n "' + errorstring + '"') return errorcode # failure; exit code else: print('No command provided. Please use "conda-content-trust -h" for help.') def interactive_modify_metadata(metadata): """ """ # Update version if there is a version. # Update timestamp if there is a timestamp. # # Show metadata contents ('signed') -- pprint? # indicate updated version/timestamp # # Changes phase: # Prompt to # (m) modify a value, (a) add a new entry, (d) delete an entry, # (r) revert to original, (f) finish and sign ((move on to signing # prompts)) # # Signing phase: # Show metadata again, ask if metadata looks right # Show what keys the original was signed by and ask if those should be # the keys used for the new version. # ((Later: if root, vet against contents of new and old root versions)) # Prompt for key (raw key file, raw key data, or gpg key fingerprint) # Sign using the given key (gpg if gpg, else normal signing mechanism). # Write (making sure not to overwrite, and -- if root -- making sure to # prepend "." to root.json file. initial_metadata = metadata metadata = copy.deepcopy(initial_metadata) try: import pygments import pygments.lexers import pygments.formatters except ImportError: print( 'interactive modify-metadata mode employs pygments for syntax ' 'highlighting, if pygments is available. pygments was not ' 'found, so the JSON contents will be... uglier than they ' 'would otherwise be. If you would like syntax highlighting ' 'and prettier printing of JSON, you may install pygments.') pygments = None from pprint import pprint # Build the modification options and prompt. def promptfor(s): return _input_func(F_INSTRUCT + '\n----- Please provide ' + s + ENDC + ': ') def fn_write(): fname = promptfor('a filename to save this metadata as') print('Writing to file....') write_metadata_to_file(metadata, fname) print('Modified metadata written!') return 1 def fn_abort(): # TODO✅: Ask to confirm. print(RED + BOLD + '\nAborting!\n' + ENDC) return 1 def fn_addsig(): if not cct_root_signing.SSLIB_AVAILABLE: print(F_OPTS + 'Signing. ' + RED + 'Please ABORT (control-c) if ' 'the metadata above is not EXACTLY what you want to sign!' + ENDC) key = promptfor( 'a key: either:\n - a 40-character-hex-string GPG PUBLIC ' 'key fingerprint\n' ' for GPG keys (e.g. root YubiKeys), or \n - a ' '64-character-hex-string PRIVATE key value for normal ' 'keys.\n\n Whitespace will be removed and characters will ' 'be lowercased.\n Key') key = ''.join(key.split()).lower() if is_hex_key(key): private_key = PrivateKey.from_hex(key) cct_signing.sign_signable(metadata, private_key) print(F_OPTS + '\n\n--- Successfully signed! Please save.' + ENDC) elif is_gpg_fingerprint(key): try: cct_root_signing.sign_root_metadata_dict_via_gpg(metadata, key) except: print(F_OPTS + '\n\n--- ' + RED + 'Signing FAILED.' + F_OPTS + ' Do you have this key loaded in GPG on ' 'this system?') else: print(F_OPTS + '\n\n--- Successfully signed! Please save.' + ENDC) else: print(F_OPTS + RED + 'Unable to recognize key. Please try again.' + ENDC) return 0 def fn_remsig(): return 0 def fn_update(): return 0 def fn_adddel(): return 0 def fn_remdel(): return 0 def fn_thresh(): delegation = promptfor('a delegation name (one of the entries in the' '\n "delegations" dictionary in the metadata above). ' 'This will\n be the delegation whose threshold number of ' 'required keys we\n will change.') if delegation not in metadata['signed']['delegations']: print(F_OPTS + '\n\n--- ' + RED + 'Unable to find that delegation.' ' Please try again.' + ENDC) return 0 new_thresh = promptfor('a new threshold value. The current value is ' + str(metadata['signed']['delegations'][delegation]['threshold'])) try: new_thresh = int(new_thresh) assert new_thresh >= 1 except: print(F_OPTS + '\n--- ' + RED + 'Invalid value. Expecting integer ' 'greater than or equal to 1. Please try again.' + ENDC) return 0 metadata['signed']['delegations'][delegation]['threshold'] = new_thresh print(F_OPTS + '\n--- Threshold successfully updated.' + ENDC) return 0 def fn_addkey(): return 0 def fn_remkey(): return 0 options = { 0: [fn_write, 'Done: write and save metadata'], 1: [fn_abort, 'Abort: discard changes -- abort without writing'], 2: [fn_addsig, 'Add a signature (sign with a key you have)'], 3: [fn_remsig, 'Remove a signature'], 4: [fn_update, 'Update any top-level dictionary entry'], 5: [fn_adddel, 'Add a delegation'], 6: [fn_remdel, 'Remove a delegation'], 7: [fn_thresh, 'Change the threshold number of keys for a delegation'], 8: [fn_addkey, 'Add an authorized key to a delegation'], 9: [fn_remkey, 'Remove an authorized key from a delegation']} option_text = ( F_INSTRUCT + '\n--- Please choose an operation by entering its ' 'number\n' + ENDC) for index in options: option_text += (' ' + F_LABEL + str(index) + ENDC + ': ' + options[index][1] + ENDC + '\n') done = False while not done: print(F_OPTS + BOLD + '\n\n---------------------\n--- Current metadata:\n---------------------\n' + ENDC) if pygments is not None: formatted_metadata = json.dumps(metadata, sort_keys=True, indent=4) print(pygments.highlight( formatted_metadata.encode('utf-8'), pygments.lexers.JsonLexer(), pygments.formatters.TerminalFormatter())) else: pprint(metadata) print(option_text) selected = _input_func(F_OPTS + 'Choice: ' + ENDC) try: selected = int(selected) except: print(RED + BOLD + '\nInvalid entry. Try again.\n' + ENDC) continue if selected not in options: print(RED + BOLD + '\nInvalid entry. Try again.\n' + ENDC) continue print(F_OPTS + '\nChose "' + options[selected][1] + '"' + ENDC) done = options[selected][0]() # Run the func associated with the option. ### Pull modified from debugging script ### Pull modified from debugging script ### Pull modified from debugging script # Basic text formatting string constants PINK = '\033[95m' BLUE = '\033[94m' CYAN = '\033[96m' GREEN = '\033[92m' YELLOW = '\033[93m' RED = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' # Complete formats F_LABEL = ENDC + UNDERLINE + BOLD + PINK F_INSTRUCT = ENDC + BOLD + PINK F_OPTS = ENDC + GREEN if __name__ == '__main__': import sys exit_status = cli(sys.argv[1:]) sys.exit(exit_status)