From 03f3933d32930f05747609489ffc630b7be129b9 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 25 Apr 2017 22:10:17 +0200 Subject: [PATCH 01/46] Fist python script alpha release --- gpgit.py | 816 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 816 insertions(+) create mode 100755 gpgit.py diff --git a/gpgit.py b/gpgit.py new file mode 100755 index 0000000..737a8a9 --- /dev/null +++ b/gpgit.py @@ -0,0 +1,816 @@ +#!/usr/bin/env python3 + +from __future__ import print_function +import os +import sys +import argparse +import tempfile +import filecmp +import hashlib +import gzip +import lzma +import bz2 +from github import Github +import git +from git import Repo +import gnupg + + +# TODO: check == True to is True +# TODO proper document functions with """ to generate __docnames___ +# TODO pylint analysis +# TODO add zip and lz support, make xz default +# TODO swap step 4.2 and 4.3 +# TODO remove returns after self.error as it already exits +# TODO document compression level default: gzip/bz2 max and lzma/xz 6. see note about level 6 https://docs.python.org/3/library/lzma.html +# TODO replace armorfrom true/false to .sig/.asc? + +class colors(object): + RED = "\033[1;31m" + BLUE = "\033[1;34m" + CYAN = "\033[1;36m" + MAGENTA = "\033[1;35m" + YELLOW = "\033[1;33m" + GREEN = "\033[1;32m" + UNDERLINE = '\033[4m' + BOLD = "\033[;1m" + REVERSE = "\033[;7m" + RESET = "\033[0;0m" + +class Substep(object): + color = { + 'OK': colors.GREEN, + 'FAIL': colors.RED, + 'INFO': colors.YELLOW, + 'WARN': colors.YELLOW, + 'TODO': colors.MAGENTA, + 'NOTE': colors.BLUE, + } + + def __init__(self, number, name, funct): + # Params + self.number = number + self.name = name + self.funct = funct + + # Default values + self.status = 'FAIL' + self.msg = 'Aborting due to previous errors' + self.infos = [] + + def setstatus(self, status, msg, infos): + self.status = status + self.msg = msg + self.infos = infos + + def printstatus(self): + # Check if status is known + if self.status not in self.color: + raise SystemError('Internal error. Please report this issue.') + + # Sample: "1.2 [ OK ] Key already generated" + print(colors.BOLD + ' ' + self.number, self.color[self.status] + '[' + self.status.center(4) + ']' + colors.RESET, self.msg) + + # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" + for info in self.infos: + print(colors.BOLD + ' -> ' + self.color['INFO'] + '[INFO]' + colors.RESET, info) + + # Check for error + if self.status == 'FAIL': + return True + + def run(self): + # Run selected step function if activated + if self.status == 'TODO': + # Sample: " -> Will associate your GPG key with Github" + print(colors.BLUE + " ->", colors.BOLD + self.msg + colors.RESET) + return self.funct() + +class Step(object): + def __init__(self, number, name, substeps): + # Params + self.number = number + self.name = name + self.substeps = substeps + + def printstatus(self): + # Sample: "1. Generate a new GPG key" + print(colors.BOLD + self.number, self.name + colors.RESET) + err = False + for substep in self.substeps: + if substep.printstatus(): + err = True + return err + + def run(self): + # Run all substeps if enabled + # Sample: "==> 2. Publish your key" + print(colors.GREEN + "==>", colors.BOLD + self.number, self.name + colors.RESET) + for substep in self.substeps: + if substep.run(): + return True + +# Helper class to compare a stream without writing +class strmcmp(object): + def __init__(self, strmcmp): + self.strmcmp = strmcmp + self.__equal = True + def write(self, data): + if data != self.strmcmp.read(len(data)): + self.__equal = False + def equal(self): + # Check end of file too + if self.strmcmp.read(1) == b'' and self.__equal: + return True + +class GPGit(object): + # RFC4880 9.1. Public-Key Algorithms + gpgAlgorithmIDs = { + '1': 'RSA', + '2': 'RSA Encrypt-Only', + '3': 'RSA Sign-Only', + '17': 'DSA', + '18': 'Elliptic Curve', + '19': 'ECDSA', + '21': 'DH', + } + + # TODO add elliptic curve support + gpgSecureAlgorithmIDs = [ '1', '3' ] + gpgSecureKeyLength = [ '2048', '4096' ] + + compressionAlgorithms = { + 'gz': gzip, + 'gzip': gzip, + 'xz': lzma, + 'bz2': bz2, + 'bzip2': bz2, + } + + def __init__(self, config): + self.Steps = [ + Step('1.', 'Generate a new GPG key', [ + Substep('1.1', 'Strong, unique, secret passphrase', self.step_1_1), + Substep('1.2', 'Key generation', self.step_1_2), + ]), + Step('2.', 'Publish your key', [ + Substep('2.1', 'Submit your key to a key server', self.step_2_1), + Substep('2.2', 'Associate GPG key with Github', self.step_2_2), + Substep('2.3', 'Publish your full fingerprint', self.step_2_3), + ]), + Step('3.', 'Usage of GPG by git', [ + Substep('3.1', 'Configure git GPG key', self.step_3_1), + Substep('3.2', 'Commit signing', self.step_3_2), + Substep('3.3', 'Create signed git tag', self.step_3_3), + ]), + Step('4.', 'Creation of a signed compressed release archive', [ + Substep('4.1', 'Create compressed archive', self.step_4_1), + Substep('4.2', 'Sign the sources', self.step_4_2), + Substep('4.3', 'Create the message digest', self.step_4_3), + ]), + Step('5.', 'Upload the release', [ + Substep('5.1', 'Github', self.step_5_1), + Substep('5.2', 'Configure HTTPS for your download server', self.step_5_2), + ]) + ] + + self.config = config + #self.config['signingkey'] = None + # self.config['gpgsign'] = None + + + self.gpg = gnupg.GPG() + self.gpgkey = None + self.repo = None + + # Expand hash info list + self.hash = {} + for sha in self.config['sha']: + self.hash[sha] = {} + + def load_defaults(self): + # Create git repository instance + try: + self.repo = Repo(self.config['git_dir'], search_parent_directories=True) + except git.exc.InvalidGitRepositoryError: + self.error('Not inside a git directory: ' + self.config['git_dir']) + reader = self.repo.config_reader() + + gitconfig = [ + ['username', 'user', 'name'], + ['email', 'user', 'email'], + ['signingkey', 'user', 'signingkey'], + ['gpgsign', 'commit', 'gpgsign'], + ['output', 'user', 'gpgitoutput'], + ['token', 'user', 'githubtoken'] + ] + + # Read in git config values + for config in gitconfig: + # Create not existing keys + if config[0] not in self.config: + self.config[config[0]] = None + + # Check if gitconfig provides a setting + if self.config[config[0]] is None and reader.has_option(config[1], config[2]): + val = reader.get_value(config[1], config[2]) + if type(val) == int: + val = str(val) + self.config[config[0]] = val + + # Get default git signing key + if self.config['fingerprint'] is None and self.config['signingkey']: + self.config['fingerprint'] = self.config['signingkey'] + + # Check if Github URL is used + if self.config['github'] is True: + if 'github' not in self.repo.remotes.origin.url.lower(): + self.config['github'] = False + + # Default message + if self.config['message'] is None: + self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit\nhttps://github.com/NicoHood/gpgit' + + # Default output path + if self.config['output'] is None: + self.config['output'] = os.path.join(self.repo.working_tree_dir, 'archive') + + # Check if path exists + if not os.path.isdir(self.config['output']): + self.error('Not a valid path: ' + self.config['output']) + + # Set default project name + if self.config['project'] is None: + self.config['project'] = os.path.basename(self.repo.remotes.origin.url).replace('.git','') + + # Default config level (repository == local) + self.config['config_level'] = 'repository' + + # Ask for Github token + #TODO + #if self.config['token'] is None: + # self.config['token'] = input('Enter Github token to access release API: ') + + # Create Github API instance + #TODO + # self.github = Github(self.config['token']) + # + # print(dir(self.github)) + # self.githubuser = self.github.get_user() + # self.githubrepo = self.githubuser.get_repo(self.config['project']) + # rel = self.githubrepo.get_release(self.config['tag']) + # print(rel, dir(rel)) + + def set_substep_status(self, number, status, msg, infos=[]): + # Search for substep by number and add new data + for step in self.Steps: + for substep in step.substeps: + if substep.number == number: + # Only overwrite if entry is relevant + if substep.status != 'TODO' or status == 'FAIL': + substep.setstatus(status, msg, infos) + return + raise SystemError('Internal error. Please report this issue.') + + def analyze(self): + # Checks to execute + checks = [ + ['Analyzing gpg key', self.analyze_step_1], + ['Receiving key from keyserver', self.analyze_step_2], + ['Analyzing git settings', self.analyze_step_3], + ['Analyzing existing archives/signatures/message digests', self.analyze_step_4], + ['Analyzing server settings', self.analyze_step_5], + ] + + # Execute checks and print status + for check in checks: + print(check[0], end='...', flush=True) + err = check[1]() + print('\r\033[K', end='') + if err: + return True + + def analyze_step_1(self): + # Get private keys + private_keys = self.gpg.list_keys(True) + for key in private_keys: + # Check key algorithm gpgit support + if key['algo'] not in self.gpgAlgorithmIDs: + raise SystemError('Unknown key algorithm. Please report this issue. ID: ' + key['algo']) + else: + key['algoname'] = self.gpgAlgorithmIDs[key['algo']] + + # Check if a fingerprint was selected/found + if self.config['fingerprint'] is None: + # Check if gpg keys are available, but not yet configured + if private_keys is not None: + # TODO fancier print + print('\r\033[K', end='') + print("GPG seems to be already configured on your system but git is not.") + print('Please select one of the existing keys below or generate a new one:') + print() + + # Print option menu + print('0: Generate a new RSA 4096 key') + for i, key in enumerate(private_keys, start=1): + print(str(i) + ':', key['fingerprint'], key['uids'][0], key['algoname'], key['length']) + + # User input + # TODO cannot do ctrl + c + userinput = -1 + while userinput < 0 or userinput > len(private_keys): + try: + userinput = int(input("Please select a key number from above: ")) + except: + userinput = -1 + print() + + # Safe new fingerprint + if userinput != 0: + self.config['fingerprint'] = private_keys[userinput - 1]['fingerprint'] + + # Validate selected gpg key + if self.config['fingerprint'] is not None: + # Check if the full fingerprint is used + if len(self.config['fingerprint']) != 40: + self.set_substep_status('1.2', 'FAIL', + 'Please specify the full fingerprint', + ['GPG ID: ' + self.config['fingerprint']]) + return True + + # Find selected key + for key in private_keys: + if key['fingerprint'] == self.config['fingerprint']: + self.gpgkey = key + break; + + # Check if key is available in keyring + if self.gpgkey is None: + self.set_substep_status('1.2', 'FAIL', + 'Selected key is not available in keyring', + ['GPG ID: ' + self.config['fingerprint']]) + return True + + # Check key algorithm security + if self.gpgkey['algo'] not in self.gpgSecureAlgorithmIDs \ + or self.gpgkey['length'] not in self.gpgSecureKeyLength: + self.set_substep_status('1.2', 'FAIL', + 'Insecure key algorithm used: ' + + self.gpgkey['algoname'] + ' ' + + self.gpgkey['length'], + ['GPG ID: ' + self.config['fingerprint']]) + return True + + # Check key algorithm security + if self.gpgkey['trust'] == 'r': + self.set_substep_status('1.2', 'FAIL', + 'Selected key is revoked', + ['GPG ID: ' + self.config['fingerprint']]) + return True + + # Use selected key + self.set_substep_status('1.2', 'OK', + 'Key already generated', [ + 'GPG key: ' + self.gpgkey['uids'][0], + 'GPG ID: [' + self.gpgkey['algoname'] + ' ' + + self.gpgkey['length'] + '] ' + self.gpgkey['fingerprint'] + + ' ' + ]) + + # Warn about strong passphrase + self.set_substep_status('1.1', 'NOTE', + 'Please use a strong, unique, secret passphrase') + + else: + # Generate a new key + self.set_substep_status('1.2', 'TODO', + 'Generating an RSA 4096 GPG key for ' + + self.config['username'] + + ' ' + self.config['email'] + + ' valid for 3 years.') + + # Warn about strong passphrase + self.set_substep_status('1.1', 'TODO', + 'Please use a strong, unique, secret passphrase') + + def analyze_step_2(self): + # Add publish note + self.set_substep_status('2.3', 'NOTE', + 'Please publish the full GPG fingerprint on your project page') + + # Check Github GPG key + if self.config['github'] == True: + # TODO Will associate your GPG key with Github + self.set_substep_status('2.2', 'NOTE', + 'Please associate your GPG key with Github') + else: + self.set_substep_status('2.2', 'OK', + 'No Github repository used') + + if self.config['fingerprint'] is None: + self.set_substep_status('2.3', 'TODO', + 'Please publish the full GPG fingerprint on your project page') + else: + self.set_substep_status('2.3', 'NOTE', + 'Please publish the full GPG fingerprint on your project page') + + # Only check if a fingerprint was specified + if self.config['fingerprint'] is not None: + # Check key on keyserver + # TODO catch receive exception + # TODO add timeout + # https://stackoverflow.com/questions/366682/how-to-limit-execution-time-of-a-function-call-in-python + key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) + + # Found key on keyserver + if self.config['fingerprint'] in key.fingerprints: + self.set_substep_status('2.1', 'OK', + 'Key already published on ' + self.config['keyserver']) + return + + # Upload key to keyserver + self.set_substep_status('2.1', 'TODO', + 'Publishing key on ' + self.config['keyserver']) + + def analyze_step_3(self): + # Check if git was already configured with the gpg key + if self.config['signingkey'] != self.config['fingerprint'] \ + or self.config['fingerprint'] is None: + # Check if git was already configured with a different key + if self.config['signingkey'] is None: + self.config['config_level'] = 'global' + + self.set_substep_status('3.1', 'TODO', + 'Configuring ' + self.config['config_level'] + + ' git settings with your GPG key') + else: + self.set_substep_status('3.1', 'OK', + 'Git already configured with your GPG key') + + # Check commit signing + if self.config['gpgsign'] == True: + self.set_substep_status('3.2', 'OK', + 'Commit signing already enabled') + else: + self.set_substep_status('3.2', 'TODO', + 'Enabling ' + self.config['config_level'] + + ' git settings with commit signing') + + # Check if tag was already created + # TODO refresh tags? + if self.repo.tag('refs/tags/' + self.config['tag']) in self.repo.tags: + # Verify signature + try: + self.repo.create_tag(self.config['tag'], + verify=True, + ref=None) + # # Check if every added file has been commited TODO + # if ! git diff --cached --exit-code &>/dev/null; then + # print_step "INFO" 'You have added new changes but did not commit them yet. See "git status" or "git diff"' + # fi + except: + self.set_substep_status('3.3', 'FAIL', + 'Invalid signature for tag ' + self.config['tag'] + '. Was the tag even signed?') + return True + else: + self.set_substep_status('3.3', 'OK', + 'Good signature for existing tag ' + self.config['tag']) + else: + self.set_substep_status('3.3', 'TODO', + 'Creating signed tag ' + self.config['tag'] + ' and pushing it to the remote git') + + def analyze_step_4(self): + # Check all compression option tar files + filename = self.config['project'] + '-' + self.config['tag'] + for tar in self.config['tar']: + # Get tar filename + tarfile = filename + '.tar.' + tar + tarfilepath = os.path.join(self.config['output'], tarfile) + + # Check if compressed tar files exist + if os.path.isfile(tarfilepath): + # TODO multiple compression algorithms + # Verify existing archive + try: + with self.compressionAlgorithms[tar].open(tarfilepath, "rb") as tarstream: + cmptar = strmcmp(tarstream) + self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') + if not cmptar.equal(): + self.set_substep_status('4.1', 'FAIL', + 'Existing archive differs from local source', [tarfilepath]) + return True + except lzma.LZMAError: + self.set_substep_status('4.1', 'FAIL', + 'Archive not in ' + tar + ' format', [tarfilepath]) + return True + + # Successfully verified + self.set_substep_status('4.1', 'OK', + 'Existing archive(s) verified successfully', ['Path: ' + self.config['output'], 'Basename: ' + filename]) + else: + self.set_substep_status('4.1', 'TODO', + 'Creating new release archive(s)', ['Path: ' + self.config['output'], 'Basename: ' + filename]) + + # Get signature filename from setting + if self.config['no_armor']: + sigfilepath = tarfilepath + '.sig' + else: + sigfilepath = tarfilepath + '.asc' + + # Check if signature is existant + if os.path.isfile(sigfilepath): + # Check if signature for tar exists + if not os.path.isfile(tarfilepath): + self.set_substep_status('4.2', 'FAIL', + 'Signature found without corresponding archive', + [sigfilepath]) + return True + + # Verify signature + with open(sigfilepath, "rb") as sig: + verified = self.gpg.verify_file(sig, tarfilepath) + # Check trust level and fingerprint match + if verified.trust_level is None \ + or verified.trust_level < verified.TRUST_FULLY \ + or verified.fingerprint != self.config['fingerprint']: + if verified.trust_text is None: + verified.trust_text = 'Invalid signature' + self.set_substep_status('4.2', 'FAIL', + 'Signature could not be verified successfully with gpg', + [sigfilepath, 'Trust level: ' + verified.trust_text]) + return True + + # Successfully verified + self.set_substep_status('4.2', 'OK', + 'Existing signature(s) verified successfully') + else: + self.set_substep_status('4.2', 'TODO', + 'Creating GPG signature(s) for archive(s)') + + # Verify all selected shasums if existant + for sha in self.config['sha']: + shafilepath = tarfilepath + '.' + sha + + # Calculate hash of tarfile + if os.path.isfile(tarfilepath): + hash_sha = hashlib.new(sha) + with open(tarfilepath, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha.update(chunk) + self.hash[sha][tarfile] = hash_sha.hexdigest() + + # Check if hash already exists + if os.path.isfile(shafilepath): + # Check if tar for hash exists + if not os.path.isfile(tarfilepath): + self.set_substep_status('4.3', 'FAIL', + 'Message digest found without corresponding archive', + [shafilepath]) + return True + + # Read hash and filename + with open(shafilepath, "r") as f: + hashinfo = f.readline().split() + + # Verify hash + if len(hashinfo) != 2 \ + or self.hash[sha][tarfile] != hashinfo[0] \ + or os.path.basename(hashinfo[1]) != tarfile: + self.set_substep_status('4.3', 'FAIL', + 'Message digest could not be successfully verified', + [shafilepath]) + return True + + # Successfully verified + self.set_substep_status('4.3', 'OK', + 'Existing message digest(s) verified successfully') + else: + self.set_substep_status('4.3', 'TODO', + 'Creating message digest(s) for archive(s)') + + def analyze_step_5(self): + # Check Github GPG key + if self.config['github'] == True: + # TODO Check github API + self.set_substep_status('5.1', 'TODO', + 'Uploading release files to Github') + self.set_substep_status('5.2', 'OK', + 'Github uses well configured https') + else: + self.set_substep_status('5.1', 'NOTE', + 'Please upload the compressed archive, signature and message digest manually') + self.set_substep_status('5.2', 'NOTE', + 'Please configure HTTPS for your download server') + + # Strong, unique, secret passphrase + def step_1_1(self): + print('More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + + # Key generation + def step_1_2(self): + #TODO + pass + + # Submit your key to a key server + def step_2_1(self): + self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) + + # Associate GPG key with Github + def step_2_2(self): + pass + + # Publish your full fingerprint + def step_2_3(self): + print('Your fingerprint is:', self.config['fingerprint']) + + # Configure git GPG key + def step_3_1(self): + # Configure git signingkey settings + with self.repo.config_writer(config_level=self.config['config_level']) as cw: + cw.set("user", "signingkey", self.config['fingerprint']) + + # Commit signing + def step_3_2(self): + # Configure git signingkey settings + with self.repo.config_writer(config_level=self.config['config_level']) as cw: + cw.set("commit", "gpgsign", True) + + # Create signed git tag + def step_3_3(self): + try: + self.repo.create_tag(self.config['tag'], + message=self.config['message'], + sign=True, + local_user=self.config['fingerprint']) + except: + self.error("Signing tag failed") + return True + + # Create compressed archive + def step_4_1(self): + # Check all compression option tar files + filename = self.config['project'] + '-' + self.config['tag'] + for tar in self.config['tar']: + # Get tar filename + tarfile = filename + '.tar.' + tar + tarfilepath = os.path.join(self.config['output'], tarfile) + + # Create compressed tar files if it does not exist + if not os.path.isfile(tarfilepath): + print(':: Creating', tarfilepath) + with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: + self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') + + # Sign the sources + def step_4_2(self): + # Check all compression option tar files + filename = self.config['project'] + '-' + self.config['tag'] + for tar in self.config['tar']: + # Get tar filename + tarfile = filename + '.tar.' + tar + tarfilepath = os.path.join(self.config['output'], tarfile) + + # Get signature filename from setting + if self.config['no_armor']: + sigfilepath = tarfilepath + '.sig' + else: + sigfilepath = tarfilepath + '.asc' + + # Check if signature is existant + if not os.path.isfile(sigfilepath): + # Sign tar file + with open(tarfilepath, 'rb') as tarstream: + print(':: Creating', sigfilepath) + signed_data = self.gpg.sign_file( + tarstream, + keyid=self.config['fingerprint'], + binary=bool(self.config['no_armor']), + detach=True, + output=sigfilepath, + #digest_algo='SHA512' #TODO v 2.x gpg module + ) + if signed_data.fingerprint != self.config['fingerprint']: + self.error('Signing data failed') + # TODO https://tools.ietf.org/html/rfc4880#section-9.4 + #print(signed_data.hash_algo) -> 8 -> SHA256 + + # Create the message digest + def step_4_3(self): + # Check all compression option tar files + filename = self.config['project'] + '-' + self.config['tag'] + for tar in self.config['tar']: + # Get tar filename + tarfile = filename + '.tar.' + tar + tarfilepath = os.path.join(self.config['output'], tarfile) + + # Verify all selected shasums if existant + for sha in self.config['sha']: + # Check if hash already exists + shafilepath = tarfilepath + '.' + sha + if not os.path.isfile(shafilepath): + + # Calculate hash of tarfile + if tarfile not in self.hash[sha]: + hash_sha = hashlib.new(sha) + with open(tarfilepath, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha.update(chunk) + self.hash[sha][tarfile] = hash_sha.hexdigest() + + # Write cached hash and filename + print(':: Creating', shafilepath) + with open(shafilepath, "w") as f: + f.write(self.hash[sha][tarfile] + ' ' + tarfile) + + # Github + def step_5_1(self): + pass + + # Configure HTTPS for your download server + def step_5_2(self): + pass + + def printstatus(self): + # Print the status tree + err = False + for step in self.Steps: + if step.printstatus(): + err = True + if err: + self.error('Exiting due to previous errors') + return err + + def run(self): + # Execute all steps + for step in self.Steps: + if step.run(): + self.error('Executing step failed') + return True + + def error(self, msg): + print(colors.RED + '==> Error:' + colors.RESET, msg) + sys.exit(1) + + +def main(arguments): + parser = argparse.ArgumentParser(description= + 'A Python script that automates the process of signing git sources via GPG') + parser.add_argument('tag', action='store', help='Tagname') + parser.add_argument('-v', '--version', action='version', version='GPGit 2.0.0') + parser.add_argument('-m', '--message', action='store', help='tag message') + parser.add_argument('-o', '--output', action='store', help='output path of the compressed archive, signature and message digest') + parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), help='path of the git project') + parser.add_argument('-f', '--fingerprint', action='store', help='(full) GPG fingerprint to use for signing/verifying') + parser.add_argument('-p', '--project', action='store', help='name of the project, used for archive generation') + parser.add_argument('-e', '--email', action='store', help='email used for gpg key generation') + parser.add_argument('-u', '--username', action='store', help='username used for gpg key generation') + parser.add_argument('-c', '--comment', action='store', help='comment used for gpg key generation') + parser.add_argument('-k', '--keyserver', action='store', default='hkps://hkps.pool.sks-keyservers.net', help='keyserver to use for up/downloading gpg keys') + parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') + parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], nargs='+', help='compression option') + parser.add_argument('-s', '--sha', choices=['sha256', 'sha384', 'sha512'], default=['sha512'], nargs='+', help='message digest option') + parser.add_argument('-b', '--no-armor', action='store_true', help='do not create ascii armored signature output') + + args = parser.parse_args() + + # TODO debug + #print(vars(args)) + + gpgit = GPGit(vars(args)) + gpgit.load_defaults() + gpgit.analyze() + gpgit.printstatus() + print() + # TODO user selection + # TODO check if even something needs to be done + input('Continue with the selected operations? [Y/n]') + print() + gpgit.run() + + + + # rorepo is a Repo instance pointing to the git-python repository. + # For all you know, the first argument to Repo is a path to the repository + # you want to work with + # path = '.' + # repo = Repo(path, search_parent_directories=True) + # assert not repo.bare + # reader = repo.config_reader() # get a config reader for read-only access + # with repo.config_writer(): # get a config writer to change configuration + # pass # call release() to be sure changes are written and locks are released + # with repo.config_writer() as cw: + # # TODO catch new section error + # cw.set("gpgit", "test", "test") + # + # print(reader.sections()) + # print(reader.get_value('user', 'email')) + # for entry in reader: + # print(entry) + # #print(repo.untracked_files) + # print(repo.config_level) + # + # print("done") + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) From bccbd7b5f5016b26d66409974bddaf8ffa690be0 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 25 Apr 2017 22:16:05 +0200 Subject: [PATCH 02/46] Added user input check --- gpgit.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gpgit.py b/gpgit.py index 737a8a9..79c05c7 100755 --- a/gpgit.py +++ b/gpgit.py @@ -494,6 +494,7 @@ def analyze_step_4(self): try: with self.compressionAlgorithms[tar].open(tarfilepath, "rb") as tarstream: cmptar = strmcmp(tarstream) + # TODO catch error when archive exists but no tag --> compare wont work self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') if not cmptar.equal(): self.set_substep_status('4.1', 'FAIL', @@ -783,9 +784,12 @@ def main(arguments): print() # TODO user selection # TODO check if even something needs to be done - input('Continue with the selected operations? [Y/n]') - print() - gpgit.run() + ret = input('Continue with the selected operations? [Y/n]') + if ret == 'y' or ret == '': + print() + #gpgit.run() + else: + gpgit.error('Aborted by user') From b86a7db767134fffa9663ae56edcbf146c354ceb Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 18:45:45 +0200 Subject: [PATCH 03/46] Check for Github assets and stop if nothing todo --- gpgit.py | 99 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/gpgit.py b/gpgit.py index 79c05c7..0ae5098 100755 --- a/gpgit.py +++ b/gpgit.py @@ -179,6 +179,11 @@ def __init__(self, config): # self.config['gpgsign'] = None + self.assets = [] + self.newassets = [] + self.todo = False + + self.gpg = gnupg.GPG() self.gpgkey = None self.repo = None @@ -246,22 +251,11 @@ def load_defaults(self): # Default config level (repository == local) self.config['config_level'] = 'repository' - # Ask for Github token - #TODO - #if self.config['token'] is None: - # self.config['token'] = input('Enter Github token to access release API: ') - - # Create Github API instance - #TODO - # self.github = Github(self.config['token']) - # - # print(dir(self.github)) - # self.githubuser = self.github.get_user() - # self.githubrepo = self.githubuser.get_repo(self.config['project']) - # rel = self.githubrepo.get_release(self.config['tag']) - # print(rel, dir(rel)) - def set_substep_status(self, number, status, msg, infos=[]): + # Flag execution of minimum one step + if status == 'TODO': + self.todo = True + # Search for substep by number and add new data for step in self.Steps: for substep in step.substeps: @@ -485,6 +479,7 @@ def analyze_step_4(self): for tar in self.config['tar']: # Get tar filename tarfile = filename + '.tar.' + tar + self.assets += [tarfile] tarfilepath = os.path.join(self.config['output'], tarfile) # Check if compressed tar files exist @@ -514,9 +509,11 @@ def analyze_step_4(self): # Get signature filename from setting if self.config['no_armor']: - sigfilepath = tarfilepath + '.sig' + sigfile = tarfile + '.sig' else: - sigfilepath = tarfilepath + '.asc' + sigfile = tarfile + '.asc' + self.assets += [sigfile] + sigfilepath = os.path.join(self.config['output'], sigfile) # Check if signature is existant if os.path.isfile(sigfilepath): @@ -550,7 +547,9 @@ def analyze_step_4(self): # Verify all selected shasums if existant for sha in self.config['sha']: - shafilepath = tarfilepath + '.' + sha + shafile = tarfile + '.' + sha + self.assets += [shafile] + shafilepath = os.path.join(self.config['output'], shafile) # Calculate hash of tarfile if os.path.isfile(tarfilepath): @@ -592,11 +591,44 @@ def analyze_step_4(self): def analyze_step_5(self): # Check Github GPG key if self.config['github'] == True: - # TODO Check github API - self.set_substep_status('5.1', 'TODO', - 'Uploading release files to Github') self.set_substep_status('5.2', 'OK', 'Github uses well configured https') + + # Ask for Github token + if self.config['token'] is None: + self.config['token'] = input('Enter Github token to access release API: ') + + # Create Github API instance + self.github = Github(self.config['token']) + + # Acces Github API + try: + self.githubuser = self.github.get_user() + self.githubrepo = self.githubuser.get_repo(self.config['project']) + except: + self.error('Error accessing Github API for project ' + self.config['project']) + + # Check Release and its assets + rel = None + try: + rel = self.githubrepo.get_release(self.config['tag']) + except: + self.newassets = self.assets + else: + # Determine which assets need to be uploaded + asset_list = [x.name for x in rel.get_assets()] + for asset in self.assets: + if asset not in asset_list: + self.newassets += [asset] + + # Check if assets already uploaded + if len(self.newassets) == 0: + self.set_substep_status('5.1', 'OK', + 'Release already published on Github') + else: + self.set_substep_status('5.1', 'TODO', + 'Uploading release files to Github') + else: self.set_substep_status('5.1', 'NOTE', 'Please upload the compressed archive, signature and message digest manually') @@ -774,22 +806,27 @@ def main(arguments): args = parser.parse_args() - # TODO debug - #print(vars(args)) - gpgit = GPGit(vars(args)) gpgit.load_defaults() gpgit.analyze() gpgit.printstatus() print() - # TODO user selection - # TODO check if even something needs to be done - ret = input('Continue with the selected operations? [Y/n]') - if ret == 'y' or ret == '': - print() - #gpgit.run() + + # Check if even something needs to be done + if gpgit.todo: + # User selection + ret = input('Continue with the selected operations? [Y/n]') + if ret == 'y' or ret == '': + print() + if not gpgit.run(): + print('Finished without errors') + else: + gpgit.error('Aborted by user') else: - gpgit.error('Aborted by user') + print(colors.GREEN + "==>", colors.RESET, 'Everything looks okay. Nothing to do.') + + + From abaded2c45db74ac796db6b0165f19893e6978bd Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 18:49:37 +0200 Subject: [PATCH 04/46] Remove debug stuff --- gpgit.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/gpgit.py b/gpgit.py index 0ae5098..a488df1 100755 --- a/gpgit.py +++ b/gpgit.py @@ -825,33 +825,5 @@ def main(arguments): else: print(colors.GREEN + "==>", colors.RESET, 'Everything looks okay. Nothing to do.') - - - - - - # rorepo is a Repo instance pointing to the git-python repository. - # For all you know, the first argument to Repo is a path to the repository - # you want to work with - # path = '.' - # repo = Repo(path, search_parent_directories=True) - # assert not repo.bare - # reader = repo.config_reader() # get a config reader for read-only access - # with repo.config_writer(): # get a config writer to change configuration - # pass # call release() to be sure changes are written and locks are released - # with repo.config_writer() as cw: - # # TODO catch new section error - # cw.set("gpgit", "test", "test") - # - # print(reader.sections()) - # print(reader.get_value('user', 'email')) - # for entry in reader: - # print(entry) - # #print(repo.untracked_files) - # print(repo.config_level) - # - # print("done") - - if __name__ == '__main__': sys.exit(main(sys.argv[1:])) From 1da6bca5a2ff88d7d95834c5ca80d3a26b1dc0ce Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 19:58:17 +0200 Subject: [PATCH 05/46] Pull and push tags --- gpgit.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index a488df1..116a20f 100755 --- a/gpgit.py +++ b/gpgit.py @@ -450,8 +450,10 @@ def analyze_step_3(self): 'Enabling ' + self.config['config_level'] + ' git settings with commit signing') + # Refresh tags + self.repo.remotes.origin.fetch('--tags') + # Check if tag was already created - # TODO refresh tags? if self.repo.tag('refs/tags/' + self.config['tag']) in self.repo.tags: # Verify signature try: @@ -670,8 +672,13 @@ def step_3_2(self): # Create signed git tag def step_3_3(self): + print(':: Creating, signing and pushing tag', self.config['tag']) + + # Create a signed tag + newtag = None try: - self.repo.create_tag(self.config['tag'], + newtag = self.repo.create_tag( + self.config['tag'], message=self.config['message'], sign=True, local_user=self.config['fingerprint']) @@ -679,6 +686,13 @@ def step_3_3(self): self.error("Signing tag failed") return True + # Push tag + try: + self.repo.remotes.origin.push(newtag) + except: + self.error("Pushing tag failed") + return True + # Create compressed archive def step_4_1(self): # Check all compression option tar files From 51d437e46d96eb80004f0697ff8f4bd12a5d8316 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 20:30:00 +0200 Subject: [PATCH 06/46] Add Release uploading --- gpgit.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/gpgit.py b/gpgit.py index 116a20f..d619538 100755 --- a/gpgit.py +++ b/gpgit.py @@ -178,14 +178,20 @@ def __init__(self, config): #self.config['signingkey'] = None # self.config['gpgsign'] = None - + # Github API + self.github = None + self.githubuser = None + self.githubrepo = None + self.release = None self.assets = [] self.newassets = [] self.todo = False - + # GPG self.gpg = gnupg.GPG() self.gpgkey = None + + # Git self.repo = None # Expand hash info list @@ -611,14 +617,16 @@ def analyze_step_5(self): self.error('Error accessing Github API for project ' + self.config['project']) # Check Release and its assets - rel = None try: - rel = self.githubrepo.get_release(self.config['tag']) + self.release = self.githubrepo.get_release(self.config['tag']) except: self.newassets = self.assets + self.set_substep_status('5.1', 'TODO', + 'Creating release and uploading release files to Github') + return else: # Determine which assets need to be uploaded - asset_list = [x.name for x in rel.get_assets()] + asset_list = [x.name for x in self.release.get_assets()] for asset in self.assets: if asset not in asset_list: self.newassets += [asset] @@ -771,7 +779,21 @@ def step_4_3(self): # Github def step_5_1(self): - pass + # Create release if not existant + if self.release is None: + self.release = self.githubrepo.create_git_release( + self.config['tag'], + self.config['project'] + ' ' + self.config['tag'], + self.config['message'], + draft=False, prerelease=self.config['prerelease']) + + # Upload assets + for asset in self.newassets: + assetpath = os.path.join(self.config['output'], asset) + print(':: Uploading', assetpath) + # TODO not functional see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 + # TODO change label and mime type + self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") # Configure HTTPS for your download server def step_5_2(self): @@ -814,6 +836,7 @@ def main(arguments): parser.add_argument('-c', '--comment', action='store', help='comment used for gpg key generation') parser.add_argument('-k', '--keyserver', action='store', default='hkps://hkps.pool.sks-keyservers.net', help='keyserver to use for up/downloading gpg keys') parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') + parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], nargs='+', help='compression option') parser.add_argument('-s', '--sha', choices=['sha256', 'sha384', 'sha512'], default=['sha512'], nargs='+', help='message digest option') parser.add_argument('-b', '--no-armor', action='store_true', help='do not create ascii armored signature output') From 38a1850c2ac8c101ceb8ad14f5b5a78ae3e7a9a5 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 20:31:45 +0200 Subject: [PATCH 07/46] Check if tag exists befor verifying archive --- gpgit.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index d619538..6986c2b 100755 --- a/gpgit.py +++ b/gpgit.py @@ -492,12 +492,16 @@ def analyze_step_4(self): # Check if compressed tar files exist if os.path.isfile(tarfilepath): - # TODO multiple compression algorithms + # Check if tag exists + if self.repo.tag('refs/tags/' + self.config['tag']) not in self.repo.tags: + self.set_substep_status('4.1', 'FAIL', + 'Archive exists but no corresponding tag!?', [tarfilepath]) + return True + # Verify existing archive try: with self.compressionAlgorithms[tar].open(tarfilepath, "rb") as tarstream: cmptar = strmcmp(tarstream) - # TODO catch error when archive exists but no tag --> compare wont work self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') if not cmptar.equal(): self.set_substep_status('4.1', 'FAIL', From 32642424526e5c273f3d471537cfa125cf661ebc Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 20:33:51 +0200 Subject: [PATCH 08/46] Remove debug note --- gpgit.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/gpgit.py b/gpgit.py index 6986c2b..fe9395c 100755 --- a/gpgit.py +++ b/gpgit.py @@ -466,10 +466,6 @@ def analyze_step_3(self): self.repo.create_tag(self.config['tag'], verify=True, ref=None) - # # Check if every added file has been commited TODO - # if ! git diff --cached --exit-code &>/dev/null; then - # print_step "INFO" 'You have added new changes but did not commit them yet. See "git status" or "git diff"' - # fi except: self.set_substep_status('3.3', 'FAIL', 'Invalid signature for tag ' + self.config['tag'] + '. Was the tag even signed?') From 435cd80f6a040126506ba83f9bcbea922dcded47 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 21:58:05 +0200 Subject: [PATCH 09/46] Fix key selection menu --- gpgit.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/gpgit.py b/gpgit.py index fe9395c..8f15b6d 100755 --- a/gpgit.py +++ b/gpgit.py @@ -303,8 +303,7 @@ def analyze_step_1(self): # Check if a fingerprint was selected/found if self.config['fingerprint'] is None: # Check if gpg keys are available, but not yet configured - if private_keys is not None: - # TODO fancier print + if len(private_keys): print('\r\033[K', end='') print("GPG seems to be already configured on your system but git is not.") print('Please select one of the existing keys below or generate a new one:') @@ -316,13 +315,16 @@ def analyze_step_1(self): print(str(i) + ':', key['fingerprint'], key['uids'][0], key['algoname'], key['length']) # User input - # TODO cannot do ctrl + c - userinput = -1 - while userinput < 0 or userinput > len(private_keys): - try: - userinput = int(input("Please select a key number from above: ")) - except: - userinput = -1 + try: + userinput = -1 + while userinput < 0 or userinput > len(private_keys): + try: + userinput = int(input("Please select a key number from above: ")) + except ValueError: + userinput = -1 + except KeyboardInterrupt: + print() + self.error('Aborted by user') print() # Safe new fingerprint From 571369f334ca175a5723e9c3d8fdb4dba1798642 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 21:59:02 +0200 Subject: [PATCH 10/46] Add key generation --- gpgit.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/gpgit.py b/gpgit.py index 8f15b6d..187fc79 100755 --- a/gpgit.py +++ b/gpgit.py @@ -389,7 +389,7 @@ def analyze_step_1(self): 'Generating an RSA 4096 GPG key for ' + self.config['username'] + ' ' + self.config['email'] - + ' valid for 3 years.') + + ' valid for 1 year.') # Warn about strong passphrase self.set_substep_status('1.1', 'TODO', @@ -653,8 +653,32 @@ def step_1_1(self): # Key generation def step_1_2(self): - #TODO - pass + # Generate RSA key command + # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html + # Preferences: TODO https://security.stackexchange.com/questions/82216/how-to-change-default-cipher-in-gnupg-on-both-linux-and-windows + input_data = """ + Key-Type: RSA + Key-Length: 4096 + Key-Usage: cert sign auth + Subkey-Type: RSA + Subkey-Length: 4096 + Subkey-Usage: encrypt + Name-Real: {0} + Name-Email: {1} + Expire-Date: 1y + Preferences: SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed + Keyserver: {2} + %ask-passphrase + %commit + """.format(self.config['username'], self.config['email'], self.config['keyserver']) + + # Execute gpg key generation command + print(colors.BLUE + ':: ' + colors.RESET + 'We need to generate a lot of random bytes. It is a good idea to perform') + print(colors.BLUE + ':: ' + colors.RESET + 'some other action (type on the keyboard, move the mouse, utilize the') + print(colors.BLUE + ':: ' + colors.RESET + 'disks) during the prime generation; this gives the random number') + print(colors.BLUE + ':: ' + colors.RESET + 'generator a better chance to gain enough entropy.') + self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) + print(colors.BLUE + ':: ' + colors.RESET + 'Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) # Submit your key to a key server def step_2_1(self): From 808f35aa93ce1867603cf3b4274adbda3f961c93 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 22:09:29 +0200 Subject: [PATCH 11/46] Catch gpg keyserver errors --- gpgit.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gpgit.py b/gpgit.py index 187fc79..09841fa 100755 --- a/gpgit.py +++ b/gpgit.py @@ -422,7 +422,10 @@ def analyze_step_2(self): # TODO catch receive exception # TODO add timeout # https://stackoverflow.com/questions/366682/how-to-limit-execution-time-of-a-function-call-in-python - key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) + try: + key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) + except: + self.error('Unkown keyserver download error. Please try again alter.') # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: From 8722c483d9e2208408b2b59013227199efddf01a Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 22:10:00 +0200 Subject: [PATCH 12/46] Colorize output --- gpgit.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gpgit.py b/gpgit.py index 09841fa..1d320f7 100755 --- a/gpgit.py +++ b/gpgit.py @@ -652,7 +652,7 @@ def analyze_step_5(self): # Strong, unique, secret passphrase def step_1_1(self): - print('More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + print(colors.BLUE + ':: ' + colors.RESET + 'More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') # Key generation def step_1_2(self): @@ -685,6 +685,7 @@ def step_1_2(self): # Submit your key to a key server def step_2_1(self): + print(colors.BLUE + ':: ' + colors.RESET + 'Publishing key ' + self.config['fingerprint']) self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) # Associate GPG key with Github @@ -709,7 +710,7 @@ def step_3_2(self): # Create signed git tag def step_3_3(self): - print(':: Creating, signing and pushing tag', self.config['tag']) + print(colors.BLUE + ':: ' + colors.RESET + 'Creating, signing and pushing tag', self.config['tag']) # Create a signed tag newtag = None @@ -741,7 +742,7 @@ def step_4_1(self): # Create compressed tar files if it does not exist if not os.path.isfile(tarfilepath): - print(':: Creating', tarfilepath) + print(colors.BLUE + ':: ' + colors.RESET + 'Creating', tarfilepath) with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') @@ -764,7 +765,7 @@ def step_4_2(self): if not os.path.isfile(sigfilepath): # Sign tar file with open(tarfilepath, 'rb') as tarstream: - print(':: Creating', sigfilepath) + print(colors.BLUE + ':: ' + colors.RESET + 'Creating', sigfilepath) signed_data = self.gpg.sign_file( tarstream, keyid=self.config['fingerprint'], @@ -802,7 +803,7 @@ def step_4_3(self): self.hash[sha][tarfile] = hash_sha.hexdigest() # Write cached hash and filename - print(':: Creating', shafilepath) + print(colors.BLUE + ':: ' + colors.RESET + 'Creating', shafilepath) with open(shafilepath, "w") as f: f.write(self.hash[sha][tarfile] + ' ' + tarfile) @@ -819,7 +820,7 @@ def step_5_1(self): # Upload assets for asset in self.newassets: assetpath = os.path.join(self.config['output'], asset) - print(':: Uploading', assetpath) + print(colors.BLUE + ':: ' + colors.RESET + 'Uploading', assetpath) # TODO not functional see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 # TODO change label and mime type self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") From ae46b63fab36a5fe6a582c55914cb501a8b8ad06 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 22:10:28 +0200 Subject: [PATCH 13/46] Minor syle changes --- gpgit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index 1d320f7..1836e96 100755 --- a/gpgit.py +++ b/gpgit.py @@ -24,6 +24,7 @@ # TODO remove returns after self.error as it already exits # TODO document compression level default: gzip/bz2 max and lzma/xz 6. see note about level 6 https://docs.python.org/3/library/lzma.html # TODO replace armorfrom true/false to .sig/.asc? +# TODO don't use plain except:, always specify which errors you'll get class colors(object): RED = "\033[1;31m" @@ -174,9 +175,8 @@ def __init__(self, config): ]) ] + # Config via parameters self.config = config - #self.config['signingkey'] = None - # self.config['gpgsign'] = None # Github API self.github = None From 2cd8c1bb688e1c4e772fcecf2a36b8ac5fd9abcf Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 22:33:42 +0200 Subject: [PATCH 14/46] Flush user input --- gpgit.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index 1836e96..6255b5a 100755 --- a/gpgit.py +++ b/gpgit.py @@ -26,6 +26,15 @@ # TODO replace armorfrom true/false to .sig/.asc? # TODO don't use plain except:, always specify which errors you'll get +def flush_input(): + try: + import sys, termios + termios.tcflush(sys.stdin, termios.TCIOFLUSH) + except ImportError: + import msvcrt + while msvcrt.kbhit(): + msvcrt.getch() + class colors(object): RED = "\033[1;31m" BLUE = "\033[1;34m" @@ -319,6 +328,7 @@ def analyze_step_1(self): userinput = -1 while userinput < 0 or userinput > len(private_keys): try: + flush_input() userinput = int(input("Please select a key number from above: ")) except ValueError: userinput = -1 @@ -609,7 +619,8 @@ def analyze_step_5(self): # Ask for Github token if self.config['token'] is None: - self.config['token'] = input('Enter Github token to access release API: ') + flush_input() + self.config['token'] = input('Enter Github token to access release API: ') # Create Github API instance self.github = Github(self.config['token']) @@ -882,7 +893,12 @@ def main(arguments): # Check if even something needs to be done if gpgit.todo: # User selection - ret = input('Continue with the selected operations? [Y/n]') + flush_input() + try: + ret = input('Continue with the selected operations? [Y/n]') + except KeyboardInterrupt: + print() + gpgit.error('Aborted by user') if ret == 'y' or ret == '': print() if not gpgit.run(): From d163d11305871b2a0fa4efa708b5fb722ec8b775 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 23:17:06 +0200 Subject: [PATCH 15/46] Catch interrupt signal for token input --- gpgit.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/gpgit.py b/gpgit.py index 6255b5a..f384a9d 100755 --- a/gpgit.py +++ b/gpgit.py @@ -620,7 +620,11 @@ def analyze_step_5(self): # Ask for Github token if self.config['token'] is None: flush_input() - self.config['token'] = input('Enter Github token to access release API: ') + try: + self.config['token'] = input('Enter Github token to access release API: ') + except KeyboardInterrupt: + print() + gpgit.error('Aborted by user') # Create Github API instance self.github = Github(self.config['token']) From d5b1615db4216300e43cf1a27da0b45f8e4df05c Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 23:36:15 +0200 Subject: [PATCH 16/46] Add some offline error detections --- gpgit.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/gpgit.py b/gpgit.py index f384a9d..d8296d5 100755 --- a/gpgit.py +++ b/gpgit.py @@ -14,7 +14,21 @@ import git from git import Repo import gnupg +import signal +from contextlib import contextmanager +class TimeoutException(Exception): pass + +@contextmanager +def time_limit(seconds): + def signal_handler(signum, frame): + raise TimeoutException + signal.signal(signal.SIGALRM, signal_handler) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) # TODO: check == True to is True # TODO proper document functions with """ to generate __docnames___ @@ -429,13 +443,11 @@ def analyze_step_2(self): # Only check if a fingerprint was specified if self.config['fingerprint'] is not None: # Check key on keyserver - # TODO catch receive exception - # TODO add timeout - # https://stackoverflow.com/questions/366682/how-to-limit-execution-time-of-a-function-call-in-python try: - key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) - except: - self.error('Unkown keyserver download error. Please try again alter.') + with time_limit(10): + key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) + except TimeoutException: + self.error('Keyserver timed out. Please try again alter.') # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: @@ -472,7 +484,10 @@ def analyze_step_3(self): + ' git settings with commit signing') # Refresh tags - self.repo.remotes.origin.fetch('--tags') + try: + self.repo.remotes.origin.fetch('--tags') + except git.exc.GitCommandError: + self.error('Error fetching remote tags.') # Check if tag was already created if self.repo.tag('refs/tags/' + self.config['tag']) in self.repo.tags: From aafbe44f7e22d3c5e26fb76f7779835c2d25ed15 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 23:36:39 +0200 Subject: [PATCH 17/46] Show version in message --- gpgit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index d8296d5..62292f5 100755 --- a/gpgit.py +++ b/gpgit.py @@ -148,6 +148,8 @@ def equal(self): return True class GPGit(object): + version = '2.0.0' + # RFC4880 9.1. Public-Key Algorithms gpgAlgorithmIDs = { '1': 'RSA', @@ -263,7 +265,7 @@ def load_defaults(self): # Default message if self.config['message'] is None: - self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit\nhttps://github.com/NicoHood/gpgit' + self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' + self.version + '\nhttps://github.com/NicoHood/gpgit' # Default output path if self.config['output'] is None: @@ -885,7 +887,7 @@ def main(arguments): parser = argparse.ArgumentParser(description= 'A Python script that automates the process of signing git sources via GPG') parser.add_argument('tag', action='store', help='Tagname') - parser.add_argument('-v', '--version', action='version', version='GPGit 2.0.0') + parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') parser.add_argument('-o', '--output', action='store', help='output path of the compressed archive, signature and message digest') parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), help='path of the git project') From 8ef8d96424a8c4d2aaec4f8778fc6c36c3a7dc4a Mon Sep 17 00:00:00 2001 From: NicoHood Date: Fri, 12 May 2017 23:36:59 +0200 Subject: [PATCH 18/46] Show tar and sha filetypes in preview --- gpgit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index 62292f5..ec4d3f4 100755 --- a/gpgit.py +++ b/gpgit.py @@ -545,7 +545,7 @@ def analyze_step_4(self): 'Existing archive(s) verified successfully', ['Path: ' + self.config['output'], 'Basename: ' + filename]) else: self.set_substep_status('4.1', 'TODO', - 'Creating new release archive(s)', ['Path: ' + self.config['output'], 'Basename: ' + filename]) + 'Creating new release archive(s): ' + ', '.join(str(x) for x in self.config['tar']), ['Path: ' + self.config['output'], 'Basename: ' + filename]) # Get signature filename from setting if self.config['no_armor']: @@ -626,7 +626,7 @@ def analyze_step_4(self): 'Existing message digest(s) verified successfully') else: self.set_substep_status('4.3', 'TODO', - 'Creating message digest(s) for archive(s)') + 'Creating message digest(s) for archive(s): ' + ', '.join(str(x) for x in self.config['sha'])) def analyze_step_5(self): # Check Github GPG key From dc62c9c85e0498822ac0ecfe8867242448751be4 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 00:04:57 +0200 Subject: [PATCH 19/46] Remove key server from key properties to avoid later issues --- gpgit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index ec4d3f4..f515354 100755 --- a/gpgit.py +++ b/gpgit.py @@ -702,10 +702,9 @@ def step_1_2(self): Name-Email: {1} Expire-Date: 1y Preferences: SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed - Keyserver: {2} %ask-passphrase %commit - """.format(self.config['username'], self.config['email'], self.config['keyserver']) + """.format(self.config['username'], self.config['email']) # Execute gpg key generation command print(colors.BLUE + ':: ' + colors.RESET + 'We need to generate a lot of random bytes. It is a good idea to perform') From 441a683a833dad47e56de17e4feac18bd095d0ef Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 00:47:39 +0200 Subject: [PATCH 20/46] minor --- gpgit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gpgit.py b/gpgit.py index f515354..de1d164 100755 --- a/gpgit.py +++ b/gpgit.py @@ -690,7 +690,6 @@ def step_1_1(self): def step_1_2(self): # Generate RSA key command # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html - # Preferences: TODO https://security.stackexchange.com/questions/82216/how-to-change-default-cipher-in-gnupg-on-both-linux-and-windows input_data = """ Key-Type: RSA Key-Length: 4096 @@ -884,7 +883,7 @@ def error(self, msg): def main(arguments): parser = argparse.ArgumentParser(description= - 'A Python script that automates the process of signing git sources via GPG') + 'A Python script that automates the process of signing git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') From 98dfc71bff052aa891caf9ee1100044cbbf8f90d Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 01:30:01 +0200 Subject: [PATCH 21/46] Readme update --- Readme.md | 318 ++++++++++++++++++++++----------------------- img/screenshot.png | Bin 0 -> 96194 bytes requirements.txt | 3 + 3 files changed, 156 insertions(+), 165 deletions(-) create mode 100644 img/screenshot.png create mode 100644 requirements.txt diff --git a/Readme.md b/Readme.md index 4b813bf..b8e86b2 100644 --- a/Readme.md +++ b/Readme.md @@ -2,9 +2,43 @@ ![gpgit.png](img/gpgit.png) -GPGit is meant to bring GPG to the masses. It is not only a shell script that -automates the process of creating new signed git releases with GPG but also -comes with this step-by-step readme guide for learning how to use GPG. +## Introduction +As we all know, today more than ever before, it is crucial to be able to trust +our computing environments. One of the main difficulties that package +maintainers of Linux distributions face, is the difficulty to verify the +authenticity and the integrity of the source code. With GPG signatures it is +possible for packagers to verify easily and quickly source code releases. + +##### Overview of the required tasks: +* Create and/or use a **[4096-bit RSA keypair][1]** for the file signing +* Use a **[strong, unique, secret passphrase][2]** for the key +* Upload the public key to a **[key server][3]** and **[publish the full fingerprint][4]** +* **Sign** every new git **[commit][5]** and **[tag][6]** +* Create **[signed][7], [compressed][8]** (xz --best) release **archives** +* Upload a **[strong message digest][9]** (sha512) of the archive +* Configure **[HTTPS][10]** for your download server + +#### GPGit +[GPGit][11] is meant to bring GPG to the masses. It is not only a shell script +that automates the process of [creating new signed git releases with GPG][12] +but also comes with a [step-by-step readme guide][13] for learning how to use +GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. + +[1]: https://github.com/NicoHood/gpgit#12-key-generation +[2]: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase +[3]: https://github.com/NicoHood/gpgit#21-submit-your-key-to-a-key-server +[4]: https://github.com/NicoHood/gpgit#23-publish-your-full-fingerprint +[5]: https://github.com/NicoHood/gpgit#32-commit-signing +[6]: https://github.com/NicoHood/gpgit#33-create-signed-git-tag +[7]: https://github.com/NicoHood/gpgit#43-sign-the-sources +[8]: https://github.com/NicoHood/gpgit#41-create-compressed-archive +[9]: https://github.com/NicoHood/gpgit#42-create-the-message-digest +[10]: https://github.com/NicoHood/gpgit#52-configure-https-for-your-download-server +[11]: https://github.com/NicoHood/gpgit +[12]: https://github.com/NicoHood/gpgit#script-usage +[13]: https://github.com/NicoHood/gpgit#gpg-quick-start-guide +[14]: https://developer.github.com/v3/repos/releases/ + ## Index * [Introduction](#introduction) @@ -14,57 +48,19 @@ comes with this step-by-step readme guide for learning how to use GPG. * [Appendix](#appendix) * [Version History](#version-history) -## Introduction -As we all know, today more than ever before, it is crucial to be able to trust -our computing environments. One of the main difficulties that package -maintainers of Linux distributions face, is the difficulty to verify the -authenticity and the integrity of the source code. With GPG signatures it is -possible to verify easily and quickly source code releases. - -##### Overview of the required tasks: -* Create and/or use a 4096-bit RSA keypair for the file signing. -* Keep your key secret, use a strong unique passphrase for the key. -* Upload the public key to a key server and publish the [full fingerprint](https://lkml.org/lkml/2016/8/15/445). -* Sign every new git commit and tag. -* Create signed compressed (xz --best) release archives -* Upload a strong message digest (sha512) of the archive -* Configure https for your download server - -### Explanation -Only a large key can remain secure for a long period of time. It is crucial to -secure this key with a strong unique passphrase so nobody is able to fake -releases of your software. Do not store the key in untrusted devices. - -Every git commit/tag/release needs to be signed in order to verify the history -of the whole software as well as the latest source files. As an alternative -strong message digest can help to add another layer of securing the source -integrity. - -Https ensure that your sources are downloaded over an encrypted, secure channel. -It also gives your public fingerprint and the message digest more trust. - ## Installation ### ArchLinux You can install gpgit from [AUR](https://aur.archlinux.org/packages/gpgit/). Make sure to [build in a clean chroot](https://wiki.archlinux.org/index.php/DeveloperWiki:Building_in_a_Clean_Chroot). +Please give the package a vote so I can move it to the official ArchLinux +[community] repository for even simpler installation. -### Manual Installation -##### Dependencies: -* bash -* gnupg -* git -* coreutils -* grep - -##### Optional Dependencies: -* wget (online source verification) -* curl (Github uploading) -* gzip (compression algorithm) -* xz (compression algorithm) -* lzip (compression algorithm) +### Ubuntu/Debian/Other +GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pypi/pip). ```bash -PREFIX=/usr/local sudo make install +sudo apt-get install python pip gnupg git +pip install --user -r requirements.txt ``` ## Script Usage @@ -72,116 +68,108 @@ The script guides you through all 5 steps of the [GPG quick start guide](#gpg-quick-start-guide). **By default no extra arguments beside the tag are required.** Follow the instructions and you are good to go. -```bash -$ gpgit 1.1.3 -==> 1. Generate new GPG key - Key already generated. Using key: 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161 -==> 2. Publish your key - Assuming key was already published after its creation. If not please do so. -==> 3. Usage of GPG by git - -> 3.1 Configure git GPG key - Git already configured with your GPG key - -> 3.2 Commit signing - Commit signing already enabled. - -> 3.3 Create signed git tag - Refreshing tags from upstream. - Continue? [Y/n]y -Already up-to-date. - Creating signed tag 1.1.3 and pushing it to the remote git. - Continue? [Y/n]y -Counting objects: 1, done. -Writing objects: 100% (1/1), 794 bytes | 0 bytes/s, done. -Total 1 (delta 0), reused 0 (delta 0) -To github.com:NicoHood/gpgit.git - * [new tag] 1.1.3 -> 1.1.3 -==> 4. Creation of a signed compressed release archive - -> 4.0 Download archive from online source - Downloading source from URL https://github.com/NicoHood/gpgit/archive/1.1.3.tar.gz - Continue? [Y/n]y ---2017-01-29 13:05:43-- https://github.com/NicoHood/gpgit/archive/1.1.3.tar.gz -Resolving github.com (github.com)... 192.30.253.112, 192.30.253.113 -Connecting to github.com (github.com)|192.30.253.112|:443... connected. -HTTP request sent, awaiting response... 302 Found -Location: https://codeload.github.com/NicoHood/gpgit/tar.gz/1.1.3 [following] ---2017-01-29 13:05:43-- https://codeload.github.com/NicoHood/gpgit/tar.gz/1.1.3 -Resolving codeload.github.com (codeload.github.com)... 192.30.253.121, 192.30.253.120 -Connecting to codeload.github.com (codeload.github.com)|192.30.253.121|:443... connected. -HTTP request sent, awaiting response... 200 OK -Length: unspecified [application/x-gzip] -Saving to: '/hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz' - -/hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz [ <=> ] 10.90K --.-KB/s in 0.001s - -2017-01-29 13:05:44 (8.16 MB/s) - '/hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz' saved [11162] - - -> 4.1 Create compressed archive - Archive /hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz already exists. - Verifying git against local source. - Continue? [Y/n]y - Existing archive successfully verified against local source. - -> 4.2 Create message digest - Creating message digest /hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz.sha512 - Continue? [Y/n]y - -> 4.3 Sign the sources - Creating signature /hackallthethings/gpgit/archive/gpgit-1.1.3.tar.gz.sig - Continue? [Y/n]y -==> 5. Upload the release - -> 5.1 Github - Uploading to Github. Please setup a Github token first: - (Github->Settings->Personal access tokens; public repo access) - Continue? [Y/n]y - Enter your Github token: - Github release created. - Signature uploaded. - Message digest uploaded. -==> Finished without errors +![screenshot](img/screenshot.png) + +### Parameters + +For more information checkout the help page: +``` +$ gpgit --help +usage: gpgit.py [-h] [-v] [-m MESSAGE] [-o OUTPUT] [-g GIT_DIR] + [-f FINGERPRINT] [-p PROJECT] [-e EMAIL] [-u USERNAME] + [-c COMMENT] [-k KEYSERVER] [-n] [-a] + [-t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...]] + [-s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...]] [-b] + tag + +A Python script that automates the process of signing git sources via GPG. + +positional arguments: + tag Tagname + +optional arguments: + -h, --help show this help message and exit + -v, --version show program's version number and exit + -m MESSAGE, --message MESSAGE + tag message + -o OUTPUT, --output OUTPUT + output path of the compressed archive, signature and + message digest + -g GIT_DIR, --git-dir GIT_DIR + path of the git project + -f FINGERPRINT, --fingerprint FINGERPRINT + (full) GPG fingerprint to use for signing/verifying + -p PROJECT, --project PROJECT + name of the project, used for archive generation + -e EMAIL, --email EMAIL + email used for gpg key generation + -u USERNAME, --username USERNAME + username used for gpg key generation + -c COMMENT, --comment COMMENT + comment used for gpg key generation + -k KEYSERVER, --keyserver KEYSERVER + keyserver to use for up/downloading gpg keys + -n, --no-github disable Github API functionallity + -a, --prerelease Flag as Github prerelease + -t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...], --tar {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...] + compression option + -s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...], --sha {sha256,sha384,sha512} [{sha256,sha384,sha512} ...] + message digest option + -b, --no-armor do not create ascii armored signature output ``` -For additional tweaks you may use some optional parameters: +### Configuration +Additional configuration can be made via [git config](https://git-scm.com/docs/git-config). + ```bash -$ gpgit --help -Usage: gpgit [options] - -Mandatory parameters: - Tagname - -Actions: --h --help Show this help message - -Options: --o, --output The output path of the compressed archive, signature and message digest. - Default: "git rev-parse --show-toplevel)/archive" --u, --username Username of the user. Used for GPG key generation. - Default: git config user.name --e, --email Email of the user. Used for GPG key generation. - Default: "git config user.email" --p, --project The name of the project. Used for archive geneation. - Default: "git config --local remote.origin.url \ - | sed -n \'s#.*/\([^.]*\)\.git#\1#p\'" --g, --gpg Specify (full) GPG fingerprint to use for signing. - Default: "git config user.signingkey" --w, --wget Download source from a user-specified URL. - Default: Auto detection for Github URL --t, --tar Valid compression options: gz|xz|lz - Default: gz --s, --sha Valid message digest options: sha256|sha384|sha512 - Default: sha512 --m, --message Specify the tag message. - Default: "Release " --y, --yes Assume "yes" on all questions. +# GPGit settings +git config user.githubtoken +git config user.gpgitoutput ~/gpgit + +# GPG settings +git config user.signingkey +git config commit.gpgsign true + +# General settings +git config user.name +git config user.email ``` -## GPG quick start guide +## GPG Quick Start Guide GPGit guides you through 5 simple steps to get your software project ready with GPG signatures. Further details can be found below. 1. [Generate a new GPG key](#1-generate-a-new-gpg-key) + 1. Strong, unique, secret passphrase + 2. Key generation 2. [Publish your key](#2-publish-your-key) + 1. Submit your key to a key server + 2. Associate GPG key with Github + 3. Publish your full fingerprint 3. [Usage of GPG by git](#3-usage-of-gpg-by-git) + 1. Configure git GPG key + 2. Commit signing + 3. Create signed git tag 4. [Creation of a signed compressed release archive](#4-creation-of-a-signed-compressed-release-archive) + 1. Create compressed archive + 2. Create the message digest + 3. Sign the sources 5. [Upload the release](#5-upload-the-release) + 1. Github + 2. Configure HTTPS for your download server ### 1. Generate a new GPG key +#### 1.1 Strong, unique, secret passphrase +Make sure that your new passphrase for the GPG key meets high security +standards. If the passphrase/key is compromised all of your signatures are +compromised too. + +Here are a few examples how to keep a passphrase strong but easy to remember: +* [How to Create a Secure Password](https://open.buffer.com/creating-a-secure-password/) +* [Mooltipass](https://www.themooltipass.com/) +* [Keepass](http://keepass.info/) + +#### 1.2 Key generation If you don't have a GPG key yet, create a new one first. You can use RSA (4096 bits) or ECC (Curve 25519) for a strong key. The latter one does currently not work with Github. You want to stay with RSA for now. @@ -193,7 +181,7 @@ Crucial key generation settings: * (1) RSA and RSA * 4096 bit key size * 4096 bit subkey size -* Valid for 3 years (3y) +* Valid for 1 year (1y) * Username and email ##### Example key generation: @@ -207,11 +195,11 @@ gpg: revocation certificate stored as '/tmp/openpgp-revocs.d/3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6.rev' public and secret key created and signed. -pub rsa4096 2017-01-04 [SC] [expires: 2020-01-04] +pub rsa4096 2017-01-04 [SC] [expires: 2018-01-04] 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 uid John Doe (gpgit example) -sub rsa4096 2017-01-04 [E] [expires: 2020-01-04] +sub rsa4096 2017-01-04 [E] [expires: 2018-01-04] ``` The generated key has the fingerprint `3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6` @@ -231,13 +219,13 @@ Now the user can get your key by requesting the fingerprint from the keyserver: ```bash # Publish key -gpg --keyserver hkps://hkps.pool.sks-keyservers.net --send-keys 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 +gpg --keyserver hkps://hkps.pool.sks-keyservers.net --send-keys 6 # Import key -gpg --keyserver hkps://hkps.pool.sks-keyservers.net --recv-keys 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 +gpg --keyserver hkps://hkps.pool.sks-keyservers.net --recv-keys ``` -#### 2.2 Associate GPG key with github +#### 2.2 Associate GPG key with Github To make Github display your commits as "verified" you also need to add your public [GPG key to your Github profile](https://github.com/settings/keys). [[Read more]](https://help.github.com/articles/generating-a-gpg-key/) @@ -250,9 +238,9 @@ gpg --list-secret-keys --keyid-format LONG gpg --armor --export ``` -#### 2.3 Publish your fingerprint +#### 2.3 Publish your full fingerprint To make it easy for everyone else to find your key it is crucial that you -publish the fingerprint on a trusted platform, such as your website or Github. +publish the [**full fingerprint**](https://lkml.org/lkml/2016/8/15/445) on a trusted platform, such as your website or Github. To give the key more trust other users can sign your key too. [[Read more]](https://wiki.debian.org/Keysigning) @@ -304,7 +292,7 @@ for those countries with slow and unstable internet connections. git archive --format=tar.gz -o gpgit-1.0.0.tar.gz --prefix gpgit-1.0.0 1.0.0 # .tar.xz -git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | xz -9 > gpgit-1.0.0.tar.xz +git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | xz > gpgit-1.0.0.tar.xz # .tar.lz git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | lzip --best > gpgit-1.0.0.tar.xz @@ -353,20 +341,26 @@ The script also supports uploading to Github directly. Create a new Github token first and then follow the instructions of the script. How to generate a Github token: -* Go to preferences -* Developer settings section on the left -* Personal access tokens -* Generate a new token -* Check "public_repo" -* Generate the token and store it safely +* Go to ["Settings - Personal access tokens"](https://github.com/settings/tokens) +* Generate a new token with permissions "public_repo" and "admin:gpg_key" +* Store it safely + +#### 5.2 Configure HTTPS for your download server +* [Why HTTPS Matters](https://developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https) +* [Let's Encrypt](https://letsencrypt.org/) +* [SSL Server Test](https://www.ssllabs.com/ssltest/) ## Appendix -### Email encryption -You can also use this key for email encryption +### Email Encryption +You can also use your GPG key for email encryption with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird#EnigMail_-_Encryption). [[Read more]](https://www.enigmail.net/index.php/en/) +## Contact +You can get securely in touch with me [here](http://contact.nicohood.de). +Don't hesitate to [file a bug at Github](https://github.com/NicoHood/gpgit/issues). + ## Version History ``` 1.2.0 (24.04.2017) @@ -399,9 +393,3 @@ with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird Untagged Release (16.12.2016) * Initial release of the software ``` - -## Legal Notes -The GPGit logo is a mix of the gnupg and the git logo which are both distributed as Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0). See [LICENSE](img/LICENSE) for more details. - -* https://www.gnupg.org/copying.html -* https://git-scm.com/downloads/logos diff --git a/img/screenshot.png b/img/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..2d5c1e9e77bd8c5556e4e02b099e1244bf760db2 GIT binary patch literal 96194 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 z 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 pc|Gp(w~W7l Date: Sat, 13 May 2017 01:30:16 +0200 Subject: [PATCH 22/46] Create non-existing output folder --- gpgit.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/gpgit.py b/gpgit.py index de1d164..bbff658 100755 --- a/gpgit.py +++ b/gpgit.py @@ -41,6 +41,7 @@ def signal_handler(signum, frame): # TODO don't use plain except:, always specify which errors you'll get def flush_input(): + # TODO removes in progress prints. try: import sys, termios termios.tcflush(sys.stdin, termios.TCIOFLUSH) @@ -273,7 +274,17 @@ def load_defaults(self): # Check if path exists if not os.path.isdir(self.config['output']): - self.error('Not a valid path: ' + self.config['output']) + # Create not existing path + print('Not a valid path: ' + self.config['output']) + try: + ret = input('Create non-existing output path? [Y/n]') + except KeyboardInterrupt: + print() + self.error('Aborted by user') + if ret == 'y' or ret == '': + os.makedirs(self.config['output']) + else: + self.error('Aborted by user') # Set default project name if self.config['project'] is None: From 4898f1a99a269de28f89cf1ce0b269e3eaeab7c2 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 01:39:19 +0200 Subject: [PATCH 23/46] Readme fixes --- Readme.md | 50 +++++++++++++++++++++++++------------------------- gpgit.py | 2 +- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Readme.md b/Readme.md index b8e86b2..8db7baa 100644 --- a/Readme.md +++ b/Readme.md @@ -19,7 +19,7 @@ possible for packagers to verify easily and quickly source code releases. * Configure **[HTTPS][10]** for your download server #### GPGit -[GPGit][11] is meant to bring GPG to the masses. It is not only a shell script +[GPGit][11] is meant to bring GPG to the masses. It is not only a python script that automates the process of [creating new signed git releases with GPG][12] but also comes with a [step-by-step readme guide][13] for learning how to use GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. @@ -140,23 +140,23 @@ GPGit guides you through 5 simple steps to get your software project ready with GPG signatures. Further details can be found below. 1. [Generate a new GPG key](#1-generate-a-new-gpg-key) - 1. Strong, unique, secret passphrase - 2. Key generation + 1. [Strong, unique, secret passphrase](#11-strong-unique-secret-passphrase) + 2. [Key generation](#12-key-generation) 2. [Publish your key](#2-publish-your-key) - 1. Submit your key to a key server - 2. Associate GPG key with Github - 3. Publish your full fingerprint + 1. [Submit your key to a key server](#21-submit-your-key-to-a-key-server) + 2. [Associate GPG key with Github](#22-associate-gpg-key-with-github) + 3. [Publish your full fingerprint](#23-publish-your-full-fingerprint) 3. [Usage of GPG by git](#3-usage-of-gpg-by-git) - 1. Configure git GPG key - 2. Commit signing - 3. Create signed git tag + 1. [Configure git GPG key](#31-configure-git-gpg-key) + 2. [Commit signing](#32-commit-signing) + 3. [Create signed git tag](#33-create-signed-git-tag) 4. [Creation of a signed compressed release archive](#4-creation-of-a-signed-compressed-release-archive) - 1. Create compressed archive - 2. Create the message digest - 3. Sign the sources + 1. [Create compressed archive](#41-create-compressed-archive) + 2. [Sign the sources](#42-create-the-message-digest) + 3. [Create the message digest](#43-sign-the-sources) 5. [Upload the release](#5-upload-the-release) - 1. Github - 2. Configure HTTPS for your download server + 1. [Github](#51-github) + 2. [Configure HTTPS for your download server](#52-configure-https-for-your-download-server) ### 1. Generate a new GPG key #### 1.1 Strong, unique, secret passphrase @@ -301,17 +301,7 @@ git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | lzip --best > gpgit-1.0.0. git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | cmp <(xz -dc gpgit-1.0.0.tar.xz) ``` -#### 4.2 Create the message digest -Message digests are used to ensure the integrity of a file. It can also serve as -checksum to verify the download. Message digests **do not** replace GPG -signatures. They rather provide and alternative simple way to verify the source. -Make sure to provide message digest over a secure channel like https. - -```bash -sha512 gpgit-1.0.0.tar.xz > gpgit-1.0.0.tar.xz.sha512 -``` - -#### 4.3 Sign the sources +#### 4.2 Sign the sources Type the filename of the tarball that you want to sign and then run: ```bash gpg --armor --detach-sign gpgit-1.0.0.tar.xz @@ -332,6 +322,16 @@ to first verify the signature after downloading. gpg --verify gpgit-1.0.0.tar.xz.asc ``` +#### 4.3 Create the message digest +Message digests are used to ensure the integrity of a file. It can also serve as +checksum to verify the download. Message digests **do not** replace GPG +signatures. They rather provide and alternative simple way to verify the source. +Make sure to provide message digest over a secure channel like https. + +```bash +sha512 gpgit-1.0.0.tar.xz > gpgit-1.0.0.tar.xz.sha512 +``` + ### 5. Upload the release #### 5.1 Github Create a new "Github Release" to add additional data to the tag. Then drag the diff --git a/gpgit.py b/gpgit.py index bbff658..c806279 100755 --- a/gpgit.py +++ b/gpgit.py @@ -894,7 +894,7 @@ def error(self, msg): def main(arguments): parser = argparse.ArgumentParser(description= - 'A Python script that automates the process of signing git sources via GPG.') + 'A python script that automates the process of signing git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') From 4cf57a40c37f96c40305be6dea63a59e67bc3765 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 01:41:30 +0200 Subject: [PATCH 24/46] Extended Contact info --- Readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Readme.md b/Readme.md index 8db7baa..8993fed 100644 --- a/Readme.md +++ b/Readme.md @@ -46,6 +46,7 @@ GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. * [Script Usage](#script-usage) * [GPG quick start guide](#gpg-quick-start-guide) * [Appendix](#appendix) +* [Contact][#contact] * [Version History](#version-history) ## Installation @@ -360,6 +361,7 @@ with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird ## Contact You can get securely in touch with me [here](http://contact.nicohood.de). Don't hesitate to [file a bug at Github](https://github.com/NicoHood/gpgit/issues). +More cool projects from me can be found [here](http://www.nicohood.de). ## Version History ``` From 12e4e60cdfd838c1c931914c0bee432532782d3d Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 12:25:02 +0200 Subject: [PATCH 25/46] Readme plan --- Readme.md | 44 ++++++++++++++++++++++++++++++-------------- gpgit.py | 2 +- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/Readme.md b/Readme.md index 8993fed..1179a79 100644 --- a/Readme.md +++ b/Readme.md @@ -9,7 +9,7 @@ maintainers of Linux distributions face, is the difficulty to verify the authenticity and the integrity of the source code. With GPG signatures it is possible for packagers to verify easily and quickly source code releases. -##### Overview of the required tasks: +#### Overview of the required tasks: * Create and/or use a **[4096-bit RSA keypair][1]** for the file signing * Use a **[strong, unique, secret passphrase][2]** for the key * Upload the public key to a **[key server][3]** and **[publish the full fingerprint][4]** @@ -18,7 +18,7 @@ possible for packagers to verify easily and quickly source code releases. * Upload a **[strong message digest][9]** (sha512) of the archive * Configure **[HTTPS][10]** for your download server -#### GPGit +### GPGit [GPGit][11] is meant to bring GPG to the masses. It is not only a python script that automates the process of [creating new signed git releases with GPG][12] but also comes with a [step-by-step readme guide][13] for learning how to use @@ -46,7 +46,7 @@ GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. * [Script Usage](#script-usage) * [GPG quick start guide](#gpg-quick-start-guide) * [Appendix](#appendix) -* [Contact][#contact] +* [Contact](#contact) * [Version History](#version-history) ## Installation @@ -60,8 +60,24 @@ Please give the package a vote so I can move it to the official ArchLinux GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pypi/pip). ```bash -sudo apt-get install python pip gnupg git -pip install --user -r requirements.txt +# Install dependencies +sudo apt-get install python3 python3-pip gnupg2 git +VERSION=2.0.0 + +# Download and verify source +wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz +wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz.asc +gpg2 --keyserver hkps://pgp.mit.edu --recv-keys 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161 +gpg2 --verify gpgit-${VERSION}.tar.xz.asc gpgit-${VERSION}.tar.xz + +# Extract and install dependencies +tar -xf gpgit-${VERSION}.tar.xz +cd gpgit-${VERSION} +pip3 install --user -r requirements.txt + +# Install and run GPGit +sudo cp gpgit.py /usr/local/bin/gpgit +gpgit --help ``` ## Script Usage @@ -124,16 +140,16 @@ Additional configuration can be made via [git config](https://git-scm.com/docs/g ```bash # GPGit settings -git config user.githubtoken -git config user.gpgitoutput ~/gpgit +git config --global user.githubtoken +git config --global user.gpgitoutput ~/gpgit # GPG settings -git config user.signingkey -git config commit.gpgsign true +git config --global user.signingkey +git config --global commit.gpgsign true # General settings -git config user.name -git config user.email +git config --global user.name +git config --global user.email ``` ## GPG Quick Start Guide @@ -208,7 +224,7 @@ in this example. Share it with others so they can verify your source. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Create_key_pair) If you ever move your installation make sure to backup `~/.gnupg/` as it -contains the private key and the revocation certificate. Handle it with care. +contains the **private key** and the **revocation certificate**. Handle it with care. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Revoking_a_key) ### 2. Publish your key @@ -220,10 +236,10 @@ Now the user can get your key by requesting the fingerprint from the keyserver: ```bash # Publish key -gpg --keyserver hkps://hkps.pool.sks-keyservers.net --send-keys 6 +gpg --keyserver hkps://pgp.mit.edu --send-keys 6 # Import key -gpg --keyserver hkps://hkps.pool.sks-keyservers.net --recv-keys +gpg --keyserver hkps://pgp.mit.edu --recv-keys ``` #### 2.2 Associate GPG key with Github diff --git a/gpgit.py b/gpgit.py index c806279..3fa9e48 100755 --- a/gpgit.py +++ b/gpgit.py @@ -905,7 +905,7 @@ def main(arguments): parser.add_argument('-e', '--email', action='store', help='email used for gpg key generation') parser.add_argument('-u', '--username', action='store', help='username used for gpg key generation') parser.add_argument('-c', '--comment', action='store', help='comment used for gpg key generation') - parser.add_argument('-k', '--keyserver', action='store', default='hkps://hkps.pool.sks-keyservers.net', help='keyserver to use for up/downloading gpg keys') + parser.add_argument('-k', '--keyserver', action='store', default='hkps://pgp.mit.edu', help='keyserver to use for up/downloading gpg keys') parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], nargs='+', help='compression option') From 39ce8074d84e55039a0b467ec77f7de4c7cce116 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 15:31:42 +0200 Subject: [PATCH 26/46] Add option to resign an older tag --- gpgit.py | 52 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/gpgit.py b/gpgit.py index 3fa9e48..f087755 100755 --- a/gpgit.py +++ b/gpgit.py @@ -503,19 +503,23 @@ def analyze_step_3(self): self.error('Error fetching remote tags.') # Check if tag was already created - if self.repo.tag('refs/tags/' + self.config['tag']) in self.repo.tags: + tag = self.repo.tag('refs/tags/' + self.config['tag']) + if tag in self.repo.tags: # Verify signature try: self.repo.create_tag(self.config['tag'], verify=True, ref=None) - except: - self.set_substep_status('3.3', 'FAIL', - 'Invalid signature for tag ' + self.config['tag'] + '. Was the tag even signed?') - return True + except git.exc.GitCommandError: + if hasattr(tag.tag, 'message') and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: + self.set_substep_status('3.3', 'FAIL', + 'Invalid signature for tag ' + self.config['tag']) + return True + self.set_substep_status('3.3', 'TODO', + 'Adding signature for unsigned tag: ' + self.config['tag']) else: self.set_substep_status('3.3', 'OK', - 'Good signature for existing tag ' + self.config['tag']) + 'Good signature for existing tag: ' + self.config['tag']) else: self.set_substep_status('3.3', 'TODO', 'Creating signed tag ' + self.config['tag'] + ' and pushing it to the remote git') @@ -753,21 +757,37 @@ def step_3_2(self): def step_3_3(self): print(colors.BLUE + ':: ' + colors.RESET + 'Creating, signing and pushing tag', self.config['tag']) + # Check if tag needs to be recreated + force = False + ref = 'HEAD' + date = '' + tag = self.repo.tag('refs/tags/' + self.config['tag']) + if tag in self.repo.tags: + force = True + ref = self.config['tag'] + if hasattr(tag.tag, 'message'): + self.config['message'] = tag.tag.message + if hasattr(tag.tag, 'tagged_date'): + date = str(tag.tag.tagged_date) + # Create a signed tag newtag = None - try: - newtag = self.repo.create_tag( - self.config['tag'], - message=self.config['message'], - sign=True, - local_user=self.config['fingerprint']) - except: - self.error("Signing tag failed") - return True + with self.repo.git.custom_environment(GIT_COMMITTER_DATE=date): + try: + newtag = self.repo.create_tag( + self.config['tag'], + ref = ref, + message = self.config['message'], + sign = True, + local_user = self.config['fingerprint'], + force = force) + except git.exc.GitCommandError: + self.error("Signing tag failed.") + return True # Push tag try: - self.repo.remotes.origin.push(newtag) + self.repo.remotes.origin.push(newtag, force = force) except: self.error("Pushing tag failed") return True From 6bf3ba89105c9d13e0176da3f8bb72162f88ece2 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 15:33:39 +0200 Subject: [PATCH 27/46] Remove input flushing as it also clears STOUT for some readon which makes the output disappear sometimes --- gpgit.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/gpgit.py b/gpgit.py index f087755..cabefe5 100755 --- a/gpgit.py +++ b/gpgit.py @@ -40,16 +40,6 @@ def signal_handler(signum, frame): # TODO replace armorfrom true/false to .sig/.asc? # TODO don't use plain except:, always specify which errors you'll get -def flush_input(): - # TODO removes in progress prints. - try: - import sys, termios - termios.tcflush(sys.stdin, termios.TCIOFLUSH) - except ImportError: - import msvcrt - while msvcrt.kbhit(): - msvcrt.getch() - class colors(object): RED = "\033[1;31m" BLUE = "\033[1;34m" @@ -355,7 +345,6 @@ def analyze_step_1(self): userinput = -1 while userinput < 0 or userinput > len(private_keys): try: - flush_input() userinput = int(input("Please select a key number from above: ")) except ValueError: userinput = -1 @@ -651,7 +640,6 @@ def analyze_step_5(self): # Ask for Github token if self.config['token'] is None: - flush_input() try: self.config['token'] = input('Enter Github token to access release API: ') except KeyboardInterrupt: @@ -943,7 +931,6 @@ def main(arguments): # Check if even something needs to be done if gpgit.todo: # User selection - flush_input() try: ret = input('Continue with the selected operations? [Y/n]') except KeyboardInterrupt: From 95bc5d02edcb4529bb5e2bba7e489812c8238435 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 16:03:44 +0200 Subject: [PATCH 28/46] Improve exceptions --- gpgit.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/gpgit.py b/gpgit.py index cabefe5..a57e79f 100755 --- a/gpgit.py +++ b/gpgit.py @@ -10,7 +10,7 @@ import gzip import lzma import bz2 -from github import Github +from github import Github, GithubException import git from git import Repo import gnupg @@ -34,11 +34,9 @@ def signal_handler(signum, frame): # TODO proper document functions with """ to generate __docnames___ # TODO pylint analysis # TODO add zip and lz support, make xz default -# TODO swap step 4.2 and 4.3 # TODO remove returns after self.error as it already exits # TODO document compression level default: gzip/bz2 max and lzma/xz 6. see note about level 6 https://docs.python.org/3/library/lzma.html # TODO replace armorfrom true/false to .sig/.asc? -# TODO don't use plain except:, always specify which errors you'll get class colors(object): RED = "\033[1;31m" @@ -650,16 +648,19 @@ def analyze_step_5(self): self.github = Github(self.config['token']) # Acces Github API + print(dir(GithubException)) try: self.githubuser = self.github.get_user() self.githubrepo = self.githubuser.get_repo(self.config['project']) - except: - self.error('Error accessing Github API for project ' + self.config['project']) + except GithubException: + # TODO improve: https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 + self.error('Error accessing Github API for project ' + self.config['project'] + '. Wrong token?') # Check Release and its assets try: self.release = self.githubrepo.get_release(self.config['tag']) - except: + except GithubException: + # TODO improve: https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 self.newassets = self.assets self.set_substep_status('5.1', 'TODO', 'Creating release and uploading release files to Github') @@ -774,11 +775,12 @@ def step_3_3(self): return True # Push tag - try: - self.repo.remotes.origin.push(newtag, force = force) - except: - self.error("Pushing tag failed") - return True + #try: + self.repo.remotes.origin.push(newtag, force = force) + # TODO check exception https://github.com/gitpython-developers/GitPython/issues/621 + # except ???: + # self.error("Pushing tag failed") + # return True # Create compressed archive def step_4_1(self): From fa3c2a08c20d58b1302fad35a2feea3e5f52166f Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 16:11:06 +0200 Subject: [PATCH 29/46] Minor fixes --- gpgit.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/gpgit.py b/gpgit.py index a57e79f..511c876 100755 --- a/gpgit.py +++ b/gpgit.py @@ -30,14 +30,6 @@ def signal_handler(signum, frame): finally: signal.alarm(0) -# TODO: check == True to is True -# TODO proper document functions with """ to generate __docnames___ -# TODO pylint analysis -# TODO add zip and lz support, make xz default -# TODO remove returns after self.error as it already exits -# TODO document compression level default: gzip/bz2 max and lzma/xz 6. see note about level 6 https://docs.python.org/3/library/lzma.html -# TODO replace armorfrom true/false to .sig/.asc? - class colors(object): RED = "\033[1;31m" BLUE = "\033[1;34m" @@ -425,7 +417,7 @@ def analyze_step_2(self): 'Please publish the full GPG fingerprint on your project page') # Check Github GPG key - if self.config['github'] == True: + if self.config['github'] is True: # TODO Will associate your GPG key with Github self.set_substep_status('2.2', 'NOTE', 'Please associate your GPG key with Github') @@ -475,7 +467,7 @@ def analyze_step_3(self): 'Git already configured with your GPG key') # Check commit signing - if self.config['gpgsign'] == True: + if self.config['gpgsign'] is True: self.set_substep_status('3.2', 'OK', 'Commit signing already enabled') else: @@ -632,7 +624,7 @@ def analyze_step_4(self): def analyze_step_5(self): # Check Github GPG key - if self.config['github'] == True: + if self.config['github'] is True: self.set_substep_status('5.2', 'OK', 'Github uses well configured https') @@ -772,7 +764,6 @@ def step_3_3(self): force = force) except git.exc.GitCommandError: self.error("Signing tag failed.") - return True # Push tag #try: @@ -780,7 +771,6 @@ def step_3_3(self): # TODO check exception https://github.com/gitpython-developers/GitPython/issues/621 # except ???: # self.error("Pushing tag failed") - # return True # Create compressed archive def step_4_1(self): @@ -895,7 +885,6 @@ def run(self): for step in self.Steps: if step.run(): self.error('Executing step failed') - return True def error(self, msg): print(colors.RED + '==> Error:' + colors.RESET, msg) From 2aaf867aa790f6c414c3fabfded32152d8fcc069 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 13 May 2017 16:47:10 +0200 Subject: [PATCH 30/46] Style fixes --- gpgit.py | 298 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 152 insertions(+), 146 deletions(-) diff --git a/gpgit.py b/gpgit.py index 511c876..15710e8 100755 --- a/gpgit.py +++ b/gpgit.py @@ -4,18 +4,17 @@ import os import sys import argparse -import tempfile -import filecmp import hashlib import gzip import lzma import bz2 +import signal +from contextlib import contextmanager from github import Github, GithubException import git from git import Repo import gnupg -import signal -from contextlib import contextmanager + class TimeoutException(Exception): pass @@ -31,14 +30,14 @@ def signal_handler(signum, frame): signal.alarm(0) class colors(object): - RED = "\033[1;31m" - BLUE = "\033[1;34m" - CYAN = "\033[1;36m" + RED = "\033[1;31m" + BLUE = "\033[1;34m" + CYAN = "\033[1;36m" MAGENTA = "\033[1;35m" YELLOW = "\033[1;33m" GREEN = "\033[1;32m" UNDERLINE = '\033[4m' - BOLD = "\033[;1m" + BOLD = "\033[;1m" REVERSE = "\033[;7m" RESET = "\033[0;0m" @@ -74,7 +73,8 @@ def printstatus(self): raise SystemError('Internal error. Please report this issue.') # Sample: "1.2 [ OK ] Key already generated" - print(colors.BOLD + ' ' + self.number, self.color[self.status] + '[' + self.status.center(4) + ']' + colors.RESET, self.msg) + print(colors.BOLD + ' ' + self.number, self.color[self.status] + '[' + + self.status.center(4) + ']' + colors.RESET, self.msg) # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" for info in self.infos: @@ -143,8 +143,8 @@ class GPGit(object): } # TODO add elliptic curve support - gpgSecureAlgorithmIDs = [ '1', '3' ] - gpgSecureKeyLength = [ '2048', '4096' ] + gpgSecureAlgorithmIDs = ['1', '3'] + gpgSecureKeyLength = ['2048', '4096'] compressionAlgorithms = { 'gz': gzip, @@ -246,7 +246,8 @@ def load_defaults(self): # Default message if self.config['message'] is None: - self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' + self.version + '\nhttps://github.com/NicoHood/gpgit' + self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ + + self.version + '\nhttps://github.com/NicoHood/gpgit' # Default output path if self.config['output'] is None: @@ -268,7 +269,8 @@ def load_defaults(self): # Set default project name if self.config['project'] is None: - self.config['project'] = os.path.basename(self.repo.remotes.origin.url).replace('.git','') + url = self.repo.remotes.origin.url + self.config['project'] = os.path.basename(url).replace('.git', '') # Default config level (repository == local) self.config['config_level'] = 'repository' @@ -312,7 +314,7 @@ def analyze_step_1(self): for key in private_keys: # Check key algorithm gpgit support if key['algo'] not in self.gpgAlgorithmIDs: - raise SystemError('Unknown key algorithm. Please report this issue. ID: ' + key['algo']) + raise SystemError('Unknown key algorithm ID: ' + key['algo']) else: key['algoname'] = self.gpgAlgorithmIDs[key['algo']] @@ -328,7 +330,8 @@ def analyze_step_1(self): # Print option menu print('0: Generate a new RSA 4096 key') for i, key in enumerate(private_keys, start=1): - print(str(i) + ':', key['fingerprint'], key['uids'][0], key['algoname'], key['length']) + print(str(i) + ':', key['fingerprint'], key['uids'][0], key['algoname'], + key['length']) # User input try: @@ -351,9 +354,8 @@ def analyze_step_1(self): if self.config['fingerprint'] is not None: # Check if the full fingerprint is used if len(self.config['fingerprint']) != 40: - self.set_substep_status('1.2', 'FAIL', - 'Please specify the full fingerprint', - ['GPG ID: ' + self.config['fingerprint']]) + self.set_substep_status('1.2', 'FAIL', 'Please specify the full fingerprint', + ['GPG ID: ' + self.config['fingerprint']]) return True # Find selected key @@ -364,73 +366,63 @@ def analyze_step_1(self): # Check if key is available in keyring if self.gpgkey is None: - self.set_substep_status('1.2', 'FAIL', - 'Selected key is not available in keyring', - ['GPG ID: ' + self.config['fingerprint']]) + self.set_substep_status('1.2', 'FAIL', 'Selected key is not available in keyring', + ['GPG ID: ' + self.config['fingerprint']]) return True # Check key algorithm security if self.gpgkey['algo'] not in self.gpgSecureAlgorithmIDs \ or self.gpgkey['length'] not in self.gpgSecureKeyLength: - self.set_substep_status('1.2', 'FAIL', - 'Insecure key algorithm used: ' - + self.gpgkey['algoname'] + ' ' - + self.gpgkey['length'], - ['GPG ID: ' + self.config['fingerprint']]) + self.set_substep_status('1.2', 'FAIL', 'Insecure key algorithm used: ' + + self.gpgkey['algoname'] + ' ' + self.gpgkey['length'], + ['GPG ID: ' + self.config['fingerprint']]) return True # Check key algorithm security if self.gpgkey['trust'] == 'r': - self.set_substep_status('1.2', 'FAIL', - 'Selected key is revoked', - ['GPG ID: ' + self.config['fingerprint']]) + self.set_substep_status('1.2', 'FAIL', 'Selected key is revoked', + ['GPG ID: ' + self.config['fingerprint']]) return True # Use selected key - self.set_substep_status('1.2', 'OK', - 'Key already generated', [ - 'GPG key: ' + self.gpgkey['uids'][0], - 'GPG ID: [' + self.gpgkey['algoname'] + ' ' - + self.gpgkey['length'] + '] ' + self.gpgkey['fingerprint'] - + ' ' - ]) + self.set_substep_status('1.2', 'OK', 'Key already generated', + [ + 'GPG key: ' + self.gpgkey['uids'][0], + 'GPG ID: [' + self.gpgkey['algoname'] + ' ' + + self.gpgkey['length'] + '] ' + self.gpgkey['fingerprint'] + + ' ' ]) # Warn about strong passphrase - self.set_substep_status('1.1', 'NOTE', - 'Please use a strong, unique, secret passphrase') + self.set_substep_status('1.1', 'NOTE', 'Please use a strong, unique, secret passphrase') else: # Generate a new key - self.set_substep_status('1.2', 'TODO', - 'Generating an RSA 4096 GPG key for ' - + self.config['username'] - + ' ' + self.config['email'] - + ' valid for 1 year.') + self.set_substep_status('1.2', 'TODO', 'Generating an RSA 4096 GPG key for ' + + self.config['username'] + + ' ' + self.config['email'] + + ' valid for 1 year.') # Warn about strong passphrase - self.set_substep_status('1.1', 'TODO', - 'Please use a strong, unique, secret passphrase') + self.set_substep_status('1.1', 'TODO', 'Please use a strong, unique, secret passphrase') def analyze_step_2(self): # Add publish note self.set_substep_status('2.3', 'NOTE', - 'Please publish the full GPG fingerprint on your project page') + 'Please publish the full GPG fingerprint on your project page') # Check Github GPG key if self.config['github'] is True: # TODO Will associate your GPG key with Github - self.set_substep_status('2.2', 'NOTE', - 'Please associate your GPG key with Github') + self.set_substep_status('2.2', 'NOTE', 'Please associate your GPG key with Github') else: - self.set_substep_status('2.2', 'OK', - 'No Github repository used') + self.set_substep_status('2.2', 'OK', 'No Github repository used') if self.config['fingerprint'] is None: self.set_substep_status('2.3', 'TODO', - 'Please publish the full GPG fingerprint on your project page') + 'Please publish the full GPG fingerprint on your project page') else: self.set_substep_status('2.3', 'NOTE', - 'Please publish the full GPG fingerprint on your project page') + 'Please publish the full GPG fingerprint on your project page') # Only check if a fingerprint was specified if self.config['fingerprint'] is not None: @@ -444,12 +436,11 @@ def analyze_step_2(self): # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: self.set_substep_status('2.1', 'OK', - 'Key already published on ' + self.config['keyserver']) + 'Key already published on ' + self.config['keyserver']) return # Upload key to keyserver - self.set_substep_status('2.1', 'TODO', - 'Publishing key on ' + self.config['keyserver']) + self.set_substep_status('2.1', 'TODO', 'Publishing key on ' + self.config['keyserver']) def analyze_step_3(self): # Check if git was already configured with the gpg key @@ -459,21 +450,17 @@ def analyze_step_3(self): if self.config['signingkey'] is None: self.config['config_level'] = 'global' - self.set_substep_status('3.1', 'TODO', - 'Configuring ' + self.config['config_level'] - + ' git settings with your GPG key') + self.set_substep_status('3.1', 'TODO', 'Configuring ' + self.config['config_level'] + + ' git settings with your GPG key') else: - self.set_substep_status('3.1', 'OK', - 'Git already configured with your GPG key') + self.set_substep_status('3.1', 'OK', 'Git already configured with your GPG key') # Check commit signing if self.config['gpgsign'] is True: - self.set_substep_status('3.2', 'OK', - 'Commit signing already enabled') + self.set_substep_status('3.2', 'OK', 'Commit signing already enabled') else: - self.set_substep_status('3.2', 'TODO', - 'Enabling ' + self.config['config_level'] - + ' git settings with commit signing') + self.set_substep_status('3.2', 'TODO', 'Enabling ' + self.config['config_level'] + + ' git settings with commit signing') # Refresh tags try: @@ -486,22 +473,21 @@ def analyze_step_3(self): if tag in self.repo.tags: # Verify signature try: - self.repo.create_tag(self.config['tag'], - verify=True, - ref=None) + self.repo.create_tag(self.config['tag'], verify=True, ref=None) except git.exc.GitCommandError: - if hasattr(tag.tag, 'message') and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: + if hasattr(tag.tag, 'message') \ + and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: self.set_substep_status('3.3', 'FAIL', - 'Invalid signature for tag ' + self.config['tag']) + 'Invalid signature for tag ' + self.config['tag']) return True - self.set_substep_status('3.3', 'TODO', - 'Adding signature for unsigned tag: ' + self.config['tag']) + self.set_substep_status('3.3', 'TODO', 'Adding signature for unsigned tag: ' + + self.config['tag']) else: - self.set_substep_status('3.3', 'OK', - 'Good signature for existing tag: ' + self.config['tag']) + self.set_substep_status('3.3', 'OK', 'Good signature for existing tag: ' + + self.config['tag']) else: - self.set_substep_status('3.3', 'TODO', - 'Creating signed tag ' + self.config['tag'] + ' and pushing it to the remote git') + self.set_substep_status('3.3', 'TODO', 'Creating signed tag ' + self.config['tag'] + + ' and pushing it to the remote git') def analyze_step_4(self): # Check all compression option tar files @@ -517,29 +503,33 @@ def analyze_step_4(self): # Check if tag exists if self.repo.tag('refs/tags/' + self.config['tag']) not in self.repo.tags: self.set_substep_status('4.1', 'FAIL', - 'Archive exists but no corresponding tag!?', [tarfilepath]) + 'Archive exists but no corresponding tag!?', + [tarfilepath]) return True # Verify existing archive try: with self.compressionAlgorithms[tar].open(tarfilepath, "rb") as tarstream: cmptar = strmcmp(tarstream) - self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') + self.repo.archive(cmptar, treeish=self.config['tag'], + prefix=filename + '/', format='tar') if not cmptar.equal(): self.set_substep_status('4.1', 'FAIL', - 'Existing archive differs from local source', [tarfilepath]) + 'Existing archive differs from local source', + [tarfilepath]) return True except lzma.LZMAError: - self.set_substep_status('4.1', 'FAIL', - 'Archive not in ' + tar + ' format', [tarfilepath]) + self.set_substep_status('4.1', 'FAIL', 'Archive not in ' + tar + ' format', + [tarfilepath]) return True # Successfully verified - self.set_substep_status('4.1', 'OK', - 'Existing archive(s) verified successfully', ['Path: ' + self.config['output'], 'Basename: ' + filename]) + self.set_substep_status('4.1', 'OK', 'Existing archive(s) verified successfully', + ['Path: ' + self.config['output'], 'Basename: ' + filename]) else: - self.set_substep_status('4.1', 'TODO', - 'Creating new release archive(s): ' + ', '.join(str(x) for x in self.config['tar']), ['Path: ' + self.config['output'], 'Basename: ' + filename]) + self.set_substep_status('4.1', 'TODO', 'Creating new release archive(s): ' + + ', '.join(str(x) for x in self.config['tar']), [ + 'Path: ' + self.config['output'], 'Basename: ' + filename]) # Get signature filename from setting if self.config['no_armor']: @@ -554,8 +544,8 @@ def analyze_step_4(self): # Check if signature for tar exists if not os.path.isfile(tarfilepath): self.set_substep_status('4.2', 'FAIL', - 'Signature found without corresponding archive', - [sigfilepath]) + 'Signature found without corresponding archive', + [sigfilepath]) return True # Verify signature @@ -567,17 +557,15 @@ def analyze_step_4(self): or verified.fingerprint != self.config['fingerprint']: if verified.trust_text is None: verified.trust_text = 'Invalid signature' - self.set_substep_status('4.2', 'FAIL', - 'Signature could not be verified successfully with gpg', - [sigfilepath, 'Trust level: ' + verified.trust_text]) + self.set_substep_status('4.2', 'FAIL', 'Signature could not be verified ' \ + + 'successfully with gpg', [ + sigfilepath, 'Trust level: ' + verified.trust_text]) return True # Successfully verified - self.set_substep_status('4.2', 'OK', - 'Existing signature(s) verified successfully') + self.set_substep_status('4.2', 'OK', 'Existing signature(s) verified successfully') else: - self.set_substep_status('4.2', 'TODO', - 'Creating GPG signature(s) for archive(s)') + self.set_substep_status('4.2', 'TODO', 'Creating GPG signature(s) for archive(s)') # Verify all selected shasums if existant for sha in self.config['sha']: @@ -597,9 +585,8 @@ def analyze_step_4(self): if os.path.isfile(shafilepath): # Check if tar for hash exists if not os.path.isfile(tarfilepath): - self.set_substep_status('4.3', 'FAIL', - 'Message digest found without corresponding archive', - [shafilepath]) + self.set_substep_status('4.3', 'FAIL', 'Message digest found without ' \ + + 'corresponding archive', [shafilepath]) return True # Read hash and filename @@ -611,22 +598,22 @@ def analyze_step_4(self): or self.hash[sha][tarfile] != hashinfo[0] \ or os.path.basename(hashinfo[1]) != tarfile: self.set_substep_status('4.3', 'FAIL', - 'Message digest could not be successfully verified', - [shafilepath]) + 'Message digest could not be successfully verified', + [shafilepath]) return True # Successfully verified self.set_substep_status('4.3', 'OK', - 'Existing message digest(s) verified successfully') + 'Existing message digest(s) verified successfully') else: self.set_substep_status('4.3', 'TODO', - 'Creating message digest(s) for archive(s): ' + ', '.join(str(x) for x in self.config['sha'])) + 'Creating message digest(s) for archive(s): ' + + ', '.join(str(x) for x in self.config['sha'])) def analyze_step_5(self): # Check Github GPG key if self.config['github'] is True: - self.set_substep_status('5.2', 'OK', - 'Github uses well configured https') + self.set_substep_status('5.2', 'OK', 'Github uses well configured https') # Ask for Github token if self.config['token'] is None: @@ -640,22 +627,24 @@ def analyze_step_5(self): self.github = Github(self.config['token']) # Acces Github API - print(dir(GithubException)) try: self.githubuser = self.github.get_user() self.githubrepo = self.githubuser.get_repo(self.config['project']) except GithubException: - # TODO improve: https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 - self.error('Error accessing Github API for project ' + self.config['project'] + '. Wrong token?') + # TODO improve: + #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 + self.error('Error accessing Github API for project ' + self.config['project'] + + '. Wrong token?') # Check Release and its assets try: self.release = self.githubrepo.get_release(self.config['tag']) except GithubException: - # TODO improve: https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 + # TODO improve: + #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 self.newassets = self.assets self.set_substep_status('5.1', 'TODO', - 'Creating release and uploading release files to Github') + 'Creating release and uploading release files to Github') return else: # Determine which assets need to be uploaded @@ -666,21 +655,21 @@ def analyze_step_5(self): # Check if assets already uploaded if len(self.newassets) == 0: - self.set_substep_status('5.1', 'OK', - 'Release already published on Github') + self.set_substep_status('5.1', 'OK', 'Release already published on Github') else: - self.set_substep_status('5.1', 'TODO', - 'Uploading release files to Github') + self.set_substep_status('5.1', 'TODO', 'Uploading release files to Github') else: self.set_substep_status('5.1', 'NOTE', - 'Please upload the compressed archive, signature and message digest manually') + 'Please upload the compressed archive, signature and message ' \ + + 'digest manually') self.set_substep_status('5.2', 'NOTE', - 'Please configure HTTPS for your download server') + 'Please configure HTTPS for your download server') # Strong, unique, secret passphrase def step_1_1(self): - print(colors.BLUE + ':: ' + colors.RESET + 'More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + print(colors.BLUE + ':: ' + colors.RESET + + 'More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') # Key generation def step_1_2(self): @@ -702,12 +691,17 @@ def step_1_2(self): """.format(self.config['username'], self.config['email']) # Execute gpg key generation command - print(colors.BLUE + ':: ' + colors.RESET + 'We need to generate a lot of random bytes. It is a good idea to perform') - print(colors.BLUE + ':: ' + colors.RESET + 'some other action (type on the keyboard, move the mouse, utilize the') - print(colors.BLUE + ':: ' + colors.RESET + 'disks) during the prime generation; this gives the random number') - print(colors.BLUE + ':: ' + colors.RESET + 'generator a better chance to gain enough entropy.') + print(colors.BLUE + ':: ' + colors.RESET + + 'We need to generate a lot of random bytes. It is a good idea to perform') + print(colors.BLUE + ':: ' + colors.RESET + + 'some other action (type on the keyboard, move the mouse, utilize the') + print(colors.BLUE + ':: ' + colors.RESET + + 'disks) during the prime generation; this gives the random number') + print(colors.BLUE + ':: ' + colors.RESET + + 'generator a better chance to gain enough entropy.') self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - print(colors.BLUE + ':: ' + colors.RESET + 'Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) + print(colors.BLUE + ':: ' + colors.RESET + + 'Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) # Submit your key to a key server def step_2_1(self): @@ -736,7 +730,8 @@ def step_3_2(self): # Create signed git tag def step_3_3(self): - print(colors.BLUE + ':: ' + colors.RESET + 'Creating, signing and pushing tag', self.config['tag']) + print(colors.BLUE + ':: ' + colors.RESET + 'Creating, signing and pushing tag', + self.config['tag']) # Check if tag needs to be recreated force = False @@ -757,17 +752,17 @@ def step_3_3(self): try: newtag = self.repo.create_tag( self.config['tag'], - ref = ref, - message = self.config['message'], - sign = True, - local_user = self.config['fingerprint'], - force = force) + ref=ref, + message=self.config['message'], + sign=True, + local_user=self.config['fingerprint'], + force=force) except git.exc.GitCommandError: self.error("Signing tag failed.") # Push tag #try: - self.repo.remotes.origin.push(newtag, force = force) + self.repo.remotes.origin.push(newtag, force=force) # TODO check exception https://github.com/gitpython-developers/GitPython/issues/621 # except ???: # self.error("Pushing tag failed") @@ -785,7 +780,8 @@ def step_4_1(self): if not os.path.isfile(tarfilepath): print(colors.BLUE + ':: ' + colors.RESET + 'Creating', tarfilepath) with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: - self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') + self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', + format='tar') # Sign the sources def step_4_2(self): @@ -838,15 +834,15 @@ def step_4_3(self): # Calculate hash of tarfile if tarfile not in self.hash[sha]: hash_sha = hashlib.new(sha) - with open(tarfilepath, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): + with open(tarfilepath, "rb") as filestream: + for chunk in iter(lambda: filestream.read(4096), b""): hash_sha.update(chunk) self.hash[sha][tarfile] = hash_sha.hexdigest() # Write cached hash and filename print(colors.BLUE + ':: ' + colors.RESET + 'Creating', shafilepath) - with open(shafilepath, "w") as f: - f.write(self.hash[sha][tarfile] + ' ' + tarfile) + with open(shafilepath, "w") as filestream: + filestream.write(self.hash[sha][tarfile] + ' ' + tarfile) # Github def step_5_1(self): @@ -862,7 +858,8 @@ def step_5_1(self): for asset in self.newassets: assetpath = os.path.join(self.config['output'], asset) print(colors.BLUE + ':: ' + colors.RESET + 'Uploading', assetpath) - # TODO not functional see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 + # TODO not functional + # see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 # TODO change label and mime type self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") @@ -892,24 +889,33 @@ def error(self, msg): def main(arguments): - parser = argparse.ArgumentParser(description= - 'A python script that automates the process of signing git sources via GPG.') + parser = argparse.ArgumentParser(description='A python script that automates the process of ' \ + + 'signing git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') - parser.add_argument('-o', '--output', action='store', help='output path of the compressed archive, signature and message digest') - parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), help='path of the git project') - parser.add_argument('-f', '--fingerprint', action='store', help='(full) GPG fingerprint to use for signing/verifying') - parser.add_argument('-p', '--project', action='store', help='name of the project, used for archive generation') + parser.add_argument('-o', '--output', action='store', + help='output path of the compressed archive, signature and message digest') + parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), + help='path of the git project') + parser.add_argument('-f', '--fingerprint', action='store', + help='(full) GPG fingerprint to use for signing/verifying') + parser.add_argument('-p', '--project', action='store', + help='name of the project, used for archive generation') parser.add_argument('-e', '--email', action='store', help='email used for gpg key generation') - parser.add_argument('-u', '--username', action='store', help='username used for gpg key generation') - parser.add_argument('-c', '--comment', action='store', help='comment used for gpg key generation') - parser.add_argument('-k', '--keyserver', action='store', default='hkps://pgp.mit.edu', help='keyserver to use for up/downloading gpg keys') - parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') + parser.add_argument('-u', '--username', action='store', + help='username used for gpg key generation') + parser.add_argument('-k', '--keyserver', action='store', default='hkps://pgp.mit.edu', + help='keyserver to use for up/downloading gpg keys') + parser.add_argument('-n', '--no-github', action='store_false', dest='github', + help='disable Github API functionallity') parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') - parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], nargs='+', help='compression option') - parser.add_argument('-s', '--sha', choices=['sha256', 'sha384', 'sha512'], default=['sha512'], nargs='+', help='message digest option') - parser.add_argument('-b', '--no-armor', action='store_true', help='do not create ascii armored signature output') + parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], + nargs='+', help='compression option') + parser.add_argument('-s', '--sha', choices=['sha256', 'sha384', 'sha512'], default=['sha512'], + nargs='+', help='message digest option') + parser.add_argument('-b', '--no-armor', action='store_true', + help='do not create ascii armored signature output') args = parser.parse_args() From 30e21eb2af83adffbb1c9acd48c59435c66bd0f2 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 17 May 2017 21:14:19 +0200 Subject: [PATCH 31/46] Refactor class layout --- gpgit.py | 1011 +++++++++++++++++++++++++++--------------------------- 1 file changed, 497 insertions(+), 514 deletions(-) diff --git a/gpgit.py b/gpgit.py index 15710e8..a724bcd 100755 --- a/gpgit.py +++ b/gpgit.py @@ -42,18 +42,8 @@ class colors(object): RESET = "\033[0;0m" class Substep(object): - color = { - 'OK': colors.GREEN, - 'FAIL': colors.RED, - 'INFO': colors.YELLOW, - 'WARN': colors.YELLOW, - 'TODO': colors.MAGENTA, - 'NOTE': colors.BLUE, - } - - def __init__(self, number, name, funct): + def __init__(self, name, funct): # Params - self.number = number self.name = name self.funct = funct @@ -62,76 +52,28 @@ def __init__(self, number, name, funct): self.msg = 'Aborting due to previous errors' self.infos = [] - def setstatus(self, status, msg, infos): - self.status = status - self.msg = msg - self.infos = infos - - def printstatus(self): - # Check if status is known - if self.status not in self.color: - raise SystemError('Internal error. Please report this issue.') - - # Sample: "1.2 [ OK ] Key already generated" - print(colors.BOLD + ' ' + self.number, self.color[self.status] + '[' - + self.status.center(4) + ']' + colors.RESET, self.msg) - - # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" - for info in self.infos: - print(colors.BOLD + ' -> ' + self.color['INFO'] + '[INFO]' + colors.RESET, info) - - # Check for error - if self.status == 'FAIL': - return True - - def run(self): - # Run selected step function if activated - if self.status == 'TODO': - # Sample: " -> Will associate your GPG key with Github" - print(colors.BLUE + " ->", colors.BOLD + self.msg + colors.RESET) - return self.funct() - class Step(object): - def __init__(self, number, name, substeps): + def __init__(self, name, *args): # Params - self.number = number self.name = name - self.substeps = substeps - - def printstatus(self): - # Sample: "1. Generate a new GPG key" - print(colors.BOLD + self.number, self.name + colors.RESET) - err = False - for substep in self.substeps: - if substep.printstatus(): - err = True - return err - - def run(self): - # Run all substeps if enabled - # Sample: "==> 2. Publish your key" - print(colors.GREEN + "==>", colors.BOLD + self.number, self.name + colors.RESET) - for substep in self.substeps: - if substep.run(): - return True - -# Helper class to compare a stream without writing -class strmcmp(object): - def __init__(self, strmcmp): - self.strmcmp = strmcmp - self.__equal = True - def write(self, data): - if data != self.strmcmp.read(len(data)): - self.__equal = False - def equal(self): - # Check end of file too - if self.strmcmp.read(1) == b'' and self.__equal: - return True - -class GPGit(object): - version = '2.0.0' - - # RFC4880 9.1. Public-Key Algorithms + self.substeps = [] + for substep in args: + self.substeps += [substep] + + def print_exec(self, msg): + # TODO only with verbose? + print(colors.BLUE + ':: ' + colors.RESET + msg) + + def setstatus(self, subnumber, status, msg, *args): + if subnumber > 0: + self.substeps[subnumber - 1].status = status + self.substeps[subnumber - 1].msg = msg + self.substeps[subnumber - 1].infos = [] + for info in args: + self.substeps[subnumber - 1].infos += [info] + +class Step1(Step): + # RFC4880 9.1. Public-Key Algorithms gpgAlgorithmIDs = { '1': 'RSA', '2': 'RSA Encrypt-Only', @@ -146,177 +88,25 @@ class GPGit(object): gpgSecureAlgorithmIDs = ['1', '3'] gpgSecureKeyLength = ['2048', '4096'] - compressionAlgorithms = { - 'gz': gzip, - 'gzip': gzip, - 'xz': lzma, - 'bz2': bz2, - 'bzip2': bz2, - } - - def __init__(self, config): - self.Steps = [ - Step('1.', 'Generate a new GPG key', [ - Substep('1.1', 'Strong, unique, secret passphrase', self.step_1_1), - Substep('1.2', 'Key generation', self.step_1_2), - ]), - Step('2.', 'Publish your key', [ - Substep('2.1', 'Submit your key to a key server', self.step_2_1), - Substep('2.2', 'Associate GPG key with Github', self.step_2_2), - Substep('2.3', 'Publish your full fingerprint', self.step_2_3), - ]), - Step('3.', 'Usage of GPG by git', [ - Substep('3.1', 'Configure git GPG key', self.step_3_1), - Substep('3.2', 'Commit signing', self.step_3_2), - Substep('3.3', 'Create signed git tag', self.step_3_3), - ]), - Step('4.', 'Creation of a signed compressed release archive', [ - Substep('4.1', 'Create compressed archive', self.step_4_1), - Substep('4.2', 'Sign the sources', self.step_4_2), - Substep('4.3', 'Create the message digest', self.step_4_3), - ]), - Step('5.', 'Upload the release', [ - Substep('5.1', 'Github', self.step_5_1), - Substep('5.2', 'Configure HTTPS for your download server', self.step_5_2), - ]) - ] - - # Config via parameters + def __init__(self, config, gpg): + # Params self.config = config + self.gpg = gpg - # Github API - self.github = None - self.githubuser = None - self.githubrepo = None - self.release = None - self.assets = [] - self.newassets = [] - self.todo = False - - # GPG - self.gpg = gnupg.GPG() - self.gpgkey = None - - # Git - self.repo = None - - # Expand hash info list - self.hash = {} - for sha in self.config['sha']: - self.hash[sha] = {} - - def load_defaults(self): - # Create git repository instance - try: - self.repo = Repo(self.config['git_dir'], search_parent_directories=True) - except git.exc.InvalidGitRepositoryError: - self.error('Not inside a git directory: ' + self.config['git_dir']) - reader = self.repo.config_reader() - - gitconfig = [ - ['username', 'user', 'name'], - ['email', 'user', 'email'], - ['signingkey', 'user', 'signingkey'], - ['gpgsign', 'commit', 'gpgsign'], - ['output', 'user', 'gpgitoutput'], - ['token', 'user', 'githubtoken'] - ] - - # Read in git config values - for config in gitconfig: - # Create not existing keys - if config[0] not in self.config: - self.config[config[0]] = None - - # Check if gitconfig provides a setting - if self.config[config[0]] is None and reader.has_option(config[1], config[2]): - val = reader.get_value(config[1], config[2]) - if type(val) == int: - val = str(val) - self.config[config[0]] = val - - # Get default git signing key - if self.config['fingerprint'] is None and self.config['signingkey']: - self.config['fingerprint'] = self.config['signingkey'] - - # Check if Github URL is used - if self.config['github'] is True: - if 'github' not in self.repo.remotes.origin.url.lower(): - self.config['github'] = False - - # Default message - if self.config['message'] is None: - self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ - + self.version + '\nhttps://github.com/NicoHood/gpgit' - - # Default output path - if self.config['output'] is None: - self.config['output'] = os.path.join(self.repo.working_tree_dir, 'archive') - - # Check if path exists - if not os.path.isdir(self.config['output']): - # Create not existing path - print('Not a valid path: ' + self.config['output']) - try: - ret = input('Create non-existing output path? [Y/n]') - except KeyboardInterrupt: - print() - self.error('Aborted by user') - if ret == 'y' or ret == '': - os.makedirs(self.config['output']) - else: - self.error('Aborted by user') - - # Set default project name - if self.config['project'] is None: - url = self.repo.remotes.origin.url - self.config['project'] = os.path.basename(url).replace('.git', '') - - # Default config level (repository == local) - self.config['config_level'] = 'repository' - - def set_substep_status(self, number, status, msg, infos=[]): - # Flag execution of minimum one step - if status == 'TODO': - self.todo = True - - # Search for substep by number and add new data - for step in self.Steps: - for substep in step.substeps: - if substep.number == number: - # Only overwrite if entry is relevant - if substep.status != 'TODO' or status == 'FAIL': - substep.setstatus(status, msg, infos) - return - raise SystemError('Internal error. Please report this issue.') + # Initialize parent + Step.__init__(self, 'Generate a new GPG key', + Substep('Strong, unique, secret passphrase', self.substep1), + Substep('Key generation', self.substep2), + ) def analyze(self): - # Checks to execute - checks = [ - ['Analyzing gpg key', self.analyze_step_1], - ['Receiving key from keyserver', self.analyze_step_2], - ['Analyzing git settings', self.analyze_step_3], - ['Analyzing existing archives/signatures/message digests', self.analyze_step_4], - ['Analyzing server settings', self.analyze_step_5], - ] - - # Execute checks and print status - for check in checks: - print(check[0], end='...', flush=True) - err = check[1]() - print('\r\033[K', end='') - if err: - return True - - def analyze_step_1(self): # Get private keys private_keys = self.gpg.list_keys(True) for key in private_keys: # Check key algorithm gpgit support if key['algo'] not in self.gpgAlgorithmIDs: - raise SystemError('Unknown key algorithm ID: ' + key['algo']) - else: - key['algoname'] = self.gpgAlgorithmIDs[key['algo']] + return 'Unknown key algorithm ID: ' + key['algo'] + ' Please report this error.' + key['algoname'] = self.gpgAlgorithmIDs[key['algo']] # Check if a fingerprint was selected/found if self.config['fingerprint'] is None: @@ -343,7 +133,7 @@ def analyze_step_1(self): userinput = -1 except KeyboardInterrupt: print() - self.error('Aborted by user') + return 'Aborted by user' print() # Safe new fingerprint @@ -354,75 +144,113 @@ def analyze_step_1(self): if self.config['fingerprint'] is not None: # Check if the full fingerprint is used if len(self.config['fingerprint']) != 40: - self.set_substep_status('1.2', 'FAIL', 'Please specify the full fingerprint', - ['GPG ID: ' + self.config['fingerprint']]) + self.setstatus(2, 'FAIL', 'Please specify the full fingerprint', + 'GPG ID: ' + self.config['fingerprint']) return True # Find selected key for key in private_keys: if key['fingerprint'] == self.config['fingerprint']: - self.gpgkey = key + gpgkey = key break; # Check if key is available in keyring - if self.gpgkey is None: - self.set_substep_status('1.2', 'FAIL', 'Selected key is not available in keyring', - ['GPG ID: ' + self.config['fingerprint']]) + if gpgkey is None: + self.setstatus(2, 'FAIL', 'Selected key is not available in keyring', + 'GPG ID: ' + self.config['fingerprint']) return True # Check key algorithm security - if self.gpgkey['algo'] not in self.gpgSecureAlgorithmIDs \ - or self.gpgkey['length'] not in self.gpgSecureKeyLength: - self.set_substep_status('1.2', 'FAIL', 'Insecure key algorithm used: ' - + self.gpgkey['algoname'] + ' ' + self.gpgkey['length'], - ['GPG ID: ' + self.config['fingerprint']]) + if gpgkey['algo'] not in self.gpgSecureAlgorithmIDs \ + or gpgkey['length'] not in self.gpgSecureKeyLength: + self.setstatus(2, 'FAIL', 'Insecure key algorithm used: ' + + gpgkey['algoname'] + ' ' + gpgkey['length'], + 'GPG ID: ' + self.config['fingerprint']) return True # Check key algorithm security - if self.gpgkey['trust'] == 'r': - self.set_substep_status('1.2', 'FAIL', 'Selected key is revoked', - ['GPG ID: ' + self.config['fingerprint']]) + if gpgkey['trust'] == 'r': + self.setstatus(2, 'FAIL', 'Selected key is revoked', + 'GPG ID: ' + self.config['fingerprint']) return True # Use selected key - self.set_substep_status('1.2', 'OK', 'Key already generated', - [ - 'GPG key: ' + self.gpgkey['uids'][0], - 'GPG ID: [' + self.gpgkey['algoname'] + ' ' - + self.gpgkey['length'] + '] ' + self.gpgkey['fingerprint'] - + ' ' ]) + self.setstatus(2, 'OK', 'Key already generated', + 'GPG key: ' + gpgkey['uids'][0], + 'GPG ID: [' + gpgkey['algoname'] + ' ' + + gpgkey['length'] + '] ' + gpgkey['fingerprint'] + + ' ' ) # Warn about strong passphrase - self.set_substep_status('1.1', 'NOTE', 'Please use a strong, unique, secret passphrase') + self.setstatus(1, 'NOTE', 'Please use a strong, unique, secret passphrase') else: # Generate a new key - self.set_substep_status('1.2', 'TODO', 'Generating an RSA 4096 GPG key for ' + self.setstatus(2, 'TODO', 'Generating an RSA 4096 GPG key for ' + self.config['username'] + ' ' + self.config['email'] + ' valid for 1 year.') # Warn about strong passphrase - self.set_substep_status('1.1', 'TODO', 'Please use a strong, unique, secret passphrase') + self.setstatus(1, 'TODO', 'Please use a strong, unique, secret passphrase') + + # Strong, unique, secret passphrase + def substep1(self): + self.print_exec('More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + + # Key generation + def substep2(self): + return + # Generate RSA key command + # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html + input_data = """ + Key-Type: RSA + Key-Length: 4096 + Key-Usage: cert sign auth + Subkey-Type: RSA + Subkey-Length: 4096 + Subkey-Usage: encrypt + Name-Real: {0} + Name-Email: {1} + Expire-Date: 1y + Preferences: SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed + %ask-passphrase + %commit + """.format(self.config['username'], self.config['email']) + + # Execute gpg key generation command + self.print_exec('We need to generate a lot of random bytes. It is a good idea to perform') + self.print_exec('some other action (type on the keyboard, move the mouse, utilize the') + self.print_exec('disks) during the prime generation; this gives the random number') + self.print_exec('generator a better chance to gain enough entropy.') + self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) + self.print_exec('Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) + +class Step2(Step): + def __init__(self, config, gpg): + # Params + self.config = config + self.gpg = gpg + + # Initialize parent + Step.__init__(self, 'Publish your GPG key', + Substep('Send GPG key to a key server', self.substep1), + Substep('Publish full fingerprint', self.substep2), + Substep('Associate GPG key with Github', self.substep3)) - def analyze_step_2(self): + def analyze(self): # Add publish note - self.set_substep_status('2.3', 'NOTE', - 'Please publish the full GPG fingerprint on your project page') + if self.config['fingerprint'] is None: + self.setstatus(2, 'TODO', 'Please publish the full GPG fingerprint on the project page') + else: + self.setstatus(2, 'NOTE', 'Please publish the full GPG fingerprint on the project page') # Check Github GPG key if self.config['github'] is True: # TODO Will associate your GPG key with Github - self.set_substep_status('2.2', 'NOTE', 'Please associate your GPG key with Github') - else: - self.set_substep_status('2.2', 'OK', 'No Github repository used') - - if self.config['fingerprint'] is None: - self.set_substep_status('2.3', 'TODO', - 'Please publish the full GPG fingerprint on your project page') + self.setstatus(3, 'NOTE', 'Please associate your GPG key with Github') else: - self.set_substep_status('2.3', 'NOTE', - 'Please publish the full GPG fingerprint on your project page') + self.setstatus(3, 'OK', 'No Github repository used') # Only check if a fingerprint was specified if self.config['fingerprint'] is not None: @@ -431,18 +259,43 @@ def analyze_step_2(self): with time_limit(10): key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) except TimeoutException: - self.error('Keyserver timed out. Please try again alter.') + return 'Keyserver timed out. Please try again alter.' # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: - self.set_substep_status('2.1', 'OK', - 'Key already published on ' + self.config['keyserver']) + self.setstatus(1, 'OK', 'Key already published on ' + self.config['keyserver']) return # Upload key to keyserver - self.set_substep_status('2.1', 'TODO', 'Publishing key on ' + self.config['keyserver']) + self.setstatus(1, 'TODO', 'Publishing key on ' + self.config['keyserver']) - def analyze_step_3(self): + # Send GPG key to a key server + def substep1(self): + self.print_exec('Publishing key ' + self.config['fingerprint']) + self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) + + # Publish your full fingerprint + def substep2(self): + print('Your fingerprint is:', self.config['fingerprint']) + + # Associate GPG key with Github + def substep3(self): + #TODO + pass + +class Step3(Step): + def __init__(self, config, repo): + # Params + self.config = config + self.repo = repo + + # Initialize parent + Step.__init__(self, 'Use Git with GPG', + Substep('Configure Git GPG key', self.substep1), + Substep('Enable commit signing', self.substep2), + Substep('Create signed Git tag', self.substep3)) + + def analyze(self): # Check if git was already configured with the gpg key if self.config['signingkey'] != self.config['fingerprint'] \ or self.config['fingerprint'] is None: @@ -450,23 +303,21 @@ def analyze_step_3(self): if self.config['signingkey'] is None: self.config['config_level'] = 'global' - self.set_substep_status('3.1', 'TODO', 'Configuring ' + self.config['config_level'] - + ' git settings with your GPG key') + self.setstatus(1, 'TODO', 'Configuring ' + self.config['config_level'] + 'Git GPG key') else: - self.set_substep_status('3.1', 'OK', 'Git already configured with your GPG key') + self.setstatus(1, 'OK', 'Git already configured with your GPG key') # Check commit signing if self.config['gpgsign'] is True: - self.set_substep_status('3.2', 'OK', 'Commit signing already enabled') + self.setstatus(2, 'OK', 'Commit signing already enabled') else: - self.set_substep_status('3.2', 'TODO', 'Enabling ' + self.config['config_level'] - + ' git settings with commit signing') + self.setstatus(2, 'TODO', 'Enabling ' + self.config['config_level'] + ' commit signing') # Refresh tags try: self.repo.remotes.origin.fetch('--tags') except git.exc.GitCommandError: - self.error('Error fetching remote tags.') + return 'Error fetching remote tags.' # Check if tag was already created tag = self.repo.tag('refs/tags/' + self.config['tag']) @@ -477,19 +328,90 @@ def analyze_step_3(self): except git.exc.GitCommandError: if hasattr(tag.tag, 'message') \ and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: - self.set_substep_status('3.3', 'FAIL', - 'Invalid signature for tag ' + self.config['tag']) + self.setstatus(3, 'FAIL', 'Invalid signature for tag ' + self.config['tag']) return True - self.set_substep_status('3.3', 'TODO', 'Adding signature for unsigned tag: ' - + self.config['tag']) + self.setstatus(3, 'TODO', 'Signing existing tag: ' + self.config['tag']) else: - self.set_substep_status('3.3', 'OK', 'Good signature for existing tag: ' - + self.config['tag']) + self.setstatus(3, 'OK', 'Good signature for existing tag: ' + self.config['tag']) else: - self.set_substep_status('3.3', 'TODO', 'Creating signed tag ' + self.config['tag'] + self.setstatus(3, 'TODO', 'Creating signed tag ' + self.config['tag'] + ' and pushing it to the remote git') - def analyze_step_4(self): + # Configure git GPG key + def substep1(self): + # Configure git signingkey settings + with self.repo.config_writer(config_level=self.config['config_level']) as cw: + cw.set("user", "signingkey", self.config['fingerprint']) + + # Enable commit signing + def substep2(self): + # Configure git signingkey settings + with self.repo.config_writer(config_level=self.config['config_level']) as cw: + cw.set("commit", "gpgsign", True) + + # Create signed git tag + def substep3(self): + self.print_exec('Creating, signing and pushing tag ' + self.config['tag']) + + # Check if tag needs to be recreated + force = False + ref = 'HEAD' + date = '' + tag = self.repo.tag('refs/tags/' + self.config['tag']) + if tag in self.repo.tags: + force = True + ref = self.config['tag'] + if hasattr(tag.tag, 'message'): + self.config['message'] = tag.tag.message + if hasattr(tag.tag, 'tagged_date'): + date = str(tag.tag.tagged_date) + + # Create a signed tag + newtag = None + with self.repo.git.custom_environment(GIT_COMMITTER_DATE=date): + try: + newtag = self.repo.create_tag( + self.config['tag'], + ref=ref, + message=self.config['message'], + sign=True, + local_user=self.config['fingerprint'], + force=force) + except git.exc.GitCommandError: + return "Signing tag failed." + + # Push tag + # TODO catch missing exception https://github.com/gitpython-developers/GitPython/issues/621 + self.repo.remotes.origin.push(newtag, force=force) + +class Step4(Step): + compressionAlgorithms = { + 'gz': gzip, + 'gzip': gzip, + 'xz': lzma, + 'bz2': bz2, + 'bzip2': bz2, + } + + def __init__(self, config, gpg, repo, assets): + # Params + self.config = config + self.gpg = gpg + self.repo = repo + self.assets = assets + + # Expand hash info list + self.hash = {} + for sha in self.config['sha']: + self.hash[sha] = {} + + # Initialize parent + Step.__init__(self, 'Create a signed release archive', + Substep('Create compressed archive', self.substep1), + Substep('Sign the archive', self.substep2), + Substep('Create the message digest', self.substep3)) + + def analyze(self): # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -502,9 +424,8 @@ def analyze_step_4(self): if os.path.isfile(tarfilepath): # Check if tag exists if self.repo.tag('refs/tags/' + self.config['tag']) not in self.repo.tags: - self.set_substep_status('4.1', 'FAIL', - 'Archive exists but no corresponding tag!?', - [tarfilepath]) + self.setstatus(1, 'FAIL', 'Archive exists but no corresponding tag!?', + tarfilepath) return True # Verify existing archive @@ -514,22 +435,21 @@ def analyze_step_4(self): self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') if not cmptar.equal(): - self.set_substep_status('4.1', 'FAIL', - 'Existing archive differs from local source', - [tarfilepath]) + self.setstatus(1, 'FAIL', 'Existing archive differs from local source', + tarfilepath) return True except lzma.LZMAError: - self.set_substep_status('4.1', 'FAIL', 'Archive not in ' + tar + ' format', - [tarfilepath]) + self.setstatus(1, 'FAIL', 'Archive not in ' + tar + ' format', + tarfilepath) return True # Successfully verified - self.set_substep_status('4.1', 'OK', 'Existing archive(s) verified successfully', - ['Path: ' + self.config['output'], 'Basename: ' + filename]) + self.setstatus(1, 'OK', 'Existing archive(s) verified successfully', + 'Path: ' + self.config['output'], 'Basename: ' + filename) else: - self.set_substep_status('4.1', 'TODO', 'Creating new release archive(s): ' - + ', '.join(str(x) for x in self.config['tar']), [ - 'Path: ' + self.config['output'], 'Basename: ' + filename]) + self.setstatus(1, 'TODO', 'Creating new release archive(s): ' + + ', '.join(str(x) for x in self.config['tar']), + 'Path: ' + self.config['output'], 'Basename: ' + filename) # Get signature filename from setting if self.config['no_armor']: @@ -543,9 +463,8 @@ def analyze_step_4(self): if os.path.isfile(sigfilepath): # Check if signature for tar exists if not os.path.isfile(tarfilepath): - self.set_substep_status('4.2', 'FAIL', - 'Signature found without corresponding archive', - [sigfilepath]) + self.setstatus(2, 'FAIL', 'Signature found without corresponding archive', + sigfilepath) return True # Verify signature @@ -557,15 +476,14 @@ def analyze_step_4(self): or verified.fingerprint != self.config['fingerprint']: if verified.trust_text is None: verified.trust_text = 'Invalid signature' - self.set_substep_status('4.2', 'FAIL', 'Signature could not be verified ' \ - + 'successfully with gpg', [ - sigfilepath, 'Trust level: ' + verified.trust_text]) + self.setstatus(2, 'FAIL', 'Signature verification failed', + sigfilepath, 'Trust level: ' + verified.trust_text) return True # Successfully verified - self.set_substep_status('4.2', 'OK', 'Existing signature(s) verified successfully') + self.setstatus(2, 'OK', 'Existing signature(s) verified successfully') else: - self.set_substep_status('4.2', 'TODO', 'Creating GPG signature(s) for archive(s)') + self.setstatus(2, 'TODO', 'Creating GPG signature(s) for archive(s)') # Verify all selected shasums if existant for sha in self.config['sha']: @@ -585,8 +503,8 @@ def analyze_step_4(self): if os.path.isfile(shafilepath): # Check if tar for hash exists if not os.path.isfile(tarfilepath): - self.set_substep_status('4.3', 'FAIL', 'Message digest found without ' \ - + 'corresponding archive', [shafilepath]) + self.setstatus(3, 'FAIL', 'Message digest found without ' \ + + 'corresponding archive', shafilepath) return True # Read hash and filename @@ -597,178 +515,18 @@ def analyze_step_4(self): if len(hashinfo) != 2 \ or self.hash[sha][tarfile] != hashinfo[0] \ or os.path.basename(hashinfo[1]) != tarfile: - self.set_substep_status('4.3', 'FAIL', - 'Message digest could not be successfully verified', - [shafilepath]) + self.setstatus(3, 'FAIL', 'Message digest verification failed', shafilepath) return True # Successfully verified - self.set_substep_status('4.3', 'OK', - 'Existing message digest(s) verified successfully') + self.setstatus(3, 'OK', 'Existing message digest(s) verified successfully') else: - self.set_substep_status('4.3', 'TODO', - 'Creating message digest(s) for archive(s): ' - + ', '.join(str(x) for x in self.config['sha'])) + self.setstatus(3, 'TODO', 'Creating message digest(s) for archive(s): ' + + ', '.join(str(x) for x in self.config['sha'])) - def analyze_step_5(self): - # Check Github GPG key - if self.config['github'] is True: - self.set_substep_status('5.2', 'OK', 'Github uses well configured https') - - # Ask for Github token - if self.config['token'] is None: - try: - self.config['token'] = input('Enter Github token to access release API: ') - except KeyboardInterrupt: - print() - gpgit.error('Aborted by user') - - # Create Github API instance - self.github = Github(self.config['token']) - - # Acces Github API - try: - self.githubuser = self.github.get_user() - self.githubrepo = self.githubuser.get_repo(self.config['project']) - except GithubException: - # TODO improve: - #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 - self.error('Error accessing Github API for project ' + self.config['project'] - + '. Wrong token?') - - # Check Release and its assets - try: - self.release = self.githubrepo.get_release(self.config['tag']) - except GithubException: - # TODO improve: - #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 - self.newassets = self.assets - self.set_substep_status('5.1', 'TODO', - 'Creating release and uploading release files to Github') - return - else: - # Determine which assets need to be uploaded - asset_list = [x.name for x in self.release.get_assets()] - for asset in self.assets: - if asset not in asset_list: - self.newassets += [asset] - - # Check if assets already uploaded - if len(self.newassets) == 0: - self.set_substep_status('5.1', 'OK', 'Release already published on Github') - else: - self.set_substep_status('5.1', 'TODO', 'Uploading release files to Github') - - else: - self.set_substep_status('5.1', 'NOTE', - 'Please upload the compressed archive, signature and message ' \ - + 'digest manually') - self.set_substep_status('5.2', 'NOTE', - 'Please configure HTTPS for your download server') - - # Strong, unique, secret passphrase - def step_1_1(self): - print(colors.BLUE + ':: ' + colors.RESET - + 'More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') - - # Key generation - def step_1_2(self): - # Generate RSA key command - # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html - input_data = """ - Key-Type: RSA - Key-Length: 4096 - Key-Usage: cert sign auth - Subkey-Type: RSA - Subkey-Length: 4096 - Subkey-Usage: encrypt - Name-Real: {0} - Name-Email: {1} - Expire-Date: 1y - Preferences: SHA512 SHA384 SHA256 AES256 AES192 AES ZLIB BZIP2 ZIP Uncompressed - %ask-passphrase - %commit - """.format(self.config['username'], self.config['email']) - - # Execute gpg key generation command - print(colors.BLUE + ':: ' + colors.RESET - + 'We need to generate a lot of random bytes. It is a good idea to perform') - print(colors.BLUE + ':: ' + colors.RESET - + 'some other action (type on the keyboard, move the mouse, utilize the') - print(colors.BLUE + ':: ' + colors.RESET - + 'disks) during the prime generation; this gives the random number') - print(colors.BLUE + ':: ' + colors.RESET - + 'generator a better chance to gain enough entropy.') - self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - print(colors.BLUE + ':: ' + colors.RESET - + 'Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) - - # Submit your key to a key server - def step_2_1(self): - print(colors.BLUE + ':: ' + colors.RESET + 'Publishing key ' + self.config['fingerprint']) - self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) - - # Associate GPG key with Github - def step_2_2(self): - pass - - # Publish your full fingerprint - def step_2_3(self): - print('Your fingerprint is:', self.config['fingerprint']) - - # Configure git GPG key - def step_3_1(self): - # Configure git signingkey settings - with self.repo.config_writer(config_level=self.config['config_level']) as cw: - cw.set("user", "signingkey", self.config['fingerprint']) - - # Commit signing - def step_3_2(self): - # Configure git signingkey settings - with self.repo.config_writer(config_level=self.config['config_level']) as cw: - cw.set("commit", "gpgsign", True) - - # Create signed git tag - def step_3_3(self): - print(colors.BLUE + ':: ' + colors.RESET + 'Creating, signing and pushing tag', - self.config['tag']) - - # Check if tag needs to be recreated - force = False - ref = 'HEAD' - date = '' - tag = self.repo.tag('refs/tags/' + self.config['tag']) - if tag in self.repo.tags: - force = True - ref = self.config['tag'] - if hasattr(tag.tag, 'message'): - self.config['message'] = tag.tag.message - if hasattr(tag.tag, 'tagged_date'): - date = str(tag.tag.tagged_date) - - # Create a signed tag - newtag = None - with self.repo.git.custom_environment(GIT_COMMITTER_DATE=date): - try: - newtag = self.repo.create_tag( - self.config['tag'], - ref=ref, - message=self.config['message'], - sign=True, - local_user=self.config['fingerprint'], - force=force) - except git.exc.GitCommandError: - self.error("Signing tag failed.") - - # Push tag - #try: - self.repo.remotes.origin.push(newtag, force=force) - # TODO check exception https://github.com/gitpython-developers/GitPython/issues/621 - # except ???: - # self.error("Pushing tag failed") # Create compressed archive - def step_4_1(self): + def substep1(self): # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -778,13 +536,13 @@ def step_4_1(self): # Create compressed tar files if it does not exist if not os.path.isfile(tarfilepath): - print(colors.BLUE + ':: ' + colors.RESET + 'Creating', tarfilepath) + self.print_exec('Creating ' + tarfilepath) with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') # Sign the sources - def step_4_2(self): + def substep2(self): # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -802,7 +560,7 @@ def step_4_2(self): if not os.path.isfile(sigfilepath): # Sign tar file with open(tarfilepath, 'rb') as tarstream: - print(colors.BLUE + ':: ' + colors.RESET + 'Creating', sigfilepath) + self.print_exec('Creating ' + sigfilepath) signed_data = self.gpg.sign_file( tarstream, keyid=self.config['fingerprint'], @@ -812,12 +570,12 @@ def step_4_2(self): #digest_algo='SHA512' #TODO v 2.x gpg module ) if signed_data.fingerprint != self.config['fingerprint']: - self.error('Signing data failed') + return 'Signing data failed' # TODO https://tools.ietf.org/html/rfc4880#section-9.4 #print(signed_data.hash_algo) -> 8 -> SHA256 # Create the message digest - def step_4_3(self): + def substep3(self): # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -840,12 +598,85 @@ def step_4_3(self): self.hash[sha][tarfile] = hash_sha.hexdigest() # Write cached hash and filename - print(colors.BLUE + ':: ' + colors.RESET + 'Creating', shafilepath) + self.print_exec('Creating ' + shafilepath) with open(shafilepath, "w") as filestream: filestream.write(self.hash[sha][tarfile] + ' ' + tarfile) +class Step5(Step): + def __init__(self, config, assets): + # Params + self.config = config + self.assets = assets + self.newassets = [] + + # Github API + self.github = None + self.githubuser = None + self.githubrepo = None + self.release = None + + # Initialize parent + Step.__init__(self, 'Upload the release', + Substep('Configure HTTPS download server', self.substep1), + Substep('Github', self.substep2)) + + def analyze(self): + # Check Github GPG key + if self.config['github'] is True: + self.setstatus(1, 'OK', 'Github uses well configured https') + + # Ask for Github token + if self.config['token'] is None: + try: + self.config['token'] = input('Enter Github token to access release API: ') + except KeyboardInterrupt: + print() + gpgit.error('Aborted by user') + + # Create Github API instance + self.github = Github(self.config['token']) + + # Acces Github API + try: + self.githubuser = self.github.get_user() + self.githubrepo = self.githubuser.get_repo(self.config['project']) + except GithubException: + # TODO improve exception: + #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 + return 'Error accessing Github API for project ' + self.config['project'] + + # Check Release and its assets + try: + self.release = self.githubrepo.get_release(self.config['tag']) + except GithubException: + # TODO improve: + #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 + self.newassets = self.assets + self.setstatus(2, 'TODO', 'Creating release and uploading release files to Github') + return + else: + # Determine which assets need to be uploaded + asset_list = [x.name for x in self.release.get_assets()] + for asset in self.assets: + if asset not in asset_list: + self.newassets += [asset] + + # Check if assets already uploaded + if len(self.newassets) == 0: + self.setstatus(2, 'OK', 'Release already published on Github') + else: + self.setstatus(2, 'TODO', 'Uploading the release files to Github') + + else: + self.setstatus(2, 'NOTE', 'Please upload the release files manually') + self.setstatus(1, 'NOTE', 'Please configure HTTPS for your download server') + + # Configure HTTPS for your download server + def substep1(self): + pass + # Github - def step_5_1(self): + def substep2(self): # Create release if not existant if self.release is None: self.release = self.githubrepo.create_git_release( @@ -857,45 +688,189 @@ def step_5_1(self): # Upload assets for asset in self.newassets: assetpath = os.path.join(self.config['output'], asset) - print(colors.BLUE + ':: ' + colors.RESET + 'Uploading', assetpath) + self.print_exec('Uploading ' + assetpath) # TODO not functional # see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 # TODO change label and mime type self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") - # Configure HTTPS for your download server - def step_5_2(self): - pass +# Helper class to compare a stream without writing +class strmcmp(object): + def __init__(self, strmcmp): + self.strmcmp = strmcmp + self.__equal = True + def write(self, data): + if data != self.strmcmp.read(len(data)): + self.__equal = False + def equal(self): + # Check end of file too + if self.strmcmp.read(1) == b'' and self.__equal: + return True + +class GPGit(object): + version = '2.0.0' + + colormap = { + 'OK': colors.GREEN, + 'FAIL': colors.RED, + 'INFO': colors.YELLOW, + 'WARN': colors.YELLOW, + 'TODO': colors.MAGENTA, + 'NOTE': colors.BLUE, + } + + def __init__(self, config): + # Config via parameters + self.config = config + self.assets = [] + + # GPG + self.gpg = gnupg.GPG() + + # Git + self.repo = None + + # Create git repository instance + try: + self.repo = Repo(self.config['git_dir'], search_parent_directories=True) + except git.exc.InvalidGitRepositoryError: + self.error('Not inside a git directory: ' + self.config['git_dir']) + reader = self.repo.config_reader() + + gitconfig = [ + ['username', 'user', 'name'], + ['email', 'user', 'email'], + ['signingkey', 'user', 'signingkey'], + ['gpgsign', 'commit', 'gpgsign'], + ['output', 'user', 'gpgitoutput'], + ['token', 'user', 'githubtoken'] + ] + + # Read in git config values + for config in gitconfig: + # Create not existing keys + if config[0] not in self.config: + self.config[config[0]] = None + + # Check if gitconfig provides a setting + if self.config[config[0]] is None and reader.has_option(config[1], config[2]): + val = reader.get_value(config[1], config[2]) + if type(val) == int: + val = str(val) + self.config[config[0]] = val + + # Get default git signing key + if self.config['fingerprint'] is None and self.config['signingkey']: + self.config['fingerprint'] = self.config['signingkey'] + + # Check if Github URL is used + if self.config['github'] is True: + if 'github' not in self.repo.remotes.origin.url.lower(): + self.config['github'] = False + + # Default message + if self.config['message'] is None: + self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ + + self.version + '\nhttps://github.com/NicoHood/gpgit' + + # Default output path + if self.config['output'] is None: + self.config['output'] = os.path.join(self.repo.working_tree_dir, 'archive') + + # Check if path exists + if not os.path.isdir(self.config['output']): + # Create not existing path + print('Not a valid path: ' + self.config['output']) + try: + ret = input('Create non-existing output path? [Y/n]') + except KeyboardInterrupt: + print() + self.error('Aborted by user') + if ret == 'y' or ret == '': + os.makedirs(self.config['output']) + else: + self.error('Aborted by user') + + # Set default project name + if self.config['project'] is None: + url = self.repo.remotes.origin.url + self.config['project'] = os.path.basename(url).replace('.git', '') + + # Default config level (repository == local) + self.config['config_level'] = 'repository' + + # Create array fo steps to analyse and run + self.step1 = Step1(self.config, self.gpg) + self.step2 = Step2(self.config, self.gpg) + self.step3 = Step3(self.config, self.repo) + self.step4 = Step4(self.config, self.gpg, self.repo, self.assets) + self.step5 = Step5(self.config, self.assets) + self.Steps = [ self.step1, self.step2, self.step3, self.step4, self.step5 ] + + def analyze(self): + for i, step in enumerate(self.Steps, start=1): + # TODO remove return True in analyze functions to only abort on critical errors + print('Analyzing step', i, 'of', len(self.Steps), end='...', flush=True) + err_msg = step.analyze() + if err_msg: + return err_msg + print('\r\033[K', end='') def printstatus(self): - # Print the status tree - err = False - for step in self.Steps: - if step.printstatus(): - err = True - if err: - self.error('Exiting due to previous errors') - return err + todo = False + error = False + for i, step in enumerate(self.Steps, start=1): + # Sample: "1. Generate a new GPG key" + print(colors.BOLD + str(i) + '.', step.name + colors.RESET) + for j, substep in enumerate(step.substeps, start=1): + # Sample: "1.2 [ OK ] Key already generated" + print(colors.BOLD + ' ' + str(i) + '.' + str(j), self.colormap[substep.status] + '[' + + substep.status.center(4) + ']' + colors.RESET, substep.msg) + + # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" + for info in substep.infos: + print(colors.BOLD + ' -> ' + self.colormap['INFO'] + '[INFO]' + colors.RESET, info) + + # Check for error or todos + if substep.status == 'FAIL': + error = True + elif substep.status == 'TODO': + todo = True + + # Return error or todo status + if error: + return -1 + if todo: + return 1 def run(self): # Execute all steps - for step in self.Steps: - if step.run(): - self.error('Executing step failed') + for i, step in enumerate(self.Steps, start=1): + # Run all substeps if enabled + # Sample: "==> 2. Publish your key" + print(colors.GREEN + "==>", colors.BOLD + str(i) + '.', step.name + colors.RESET) + for j, substep in enumerate(step.substeps, start=1): + # Run selected step function if activated + if substep.status == 'TODO': + # Sample: " -> Will associate your GPG key with Github" + print(colors.BLUE + " ->", colors.BOLD + str(i) +'.' + str(j), substep.name + colors.RESET) + err_msg = substep.funct() + if err_msg: + return err_msg def error(self, msg): print(colors.RED + '==> Error:' + colors.RESET, msg) sys.exit(1) -def main(arguments): +def main(): parser = argparse.ArgumentParser(description='A python script that automates the process of ' \ + 'signing git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') parser.add_argument('-o', '--output', action='store', - help='output path of the compressed archive, signature and message digest') + help='output path of the archive, signature and message digest') parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), help='path of the git project') parser.add_argument('-f', '--fingerprint', action='store', @@ -920,13 +895,16 @@ def main(arguments): args = parser.parse_args() gpgit = GPGit(vars(args)) - gpgit.load_defaults() - gpgit.analyze() - gpgit.printstatus() + err_msg = gpgit.analyze() + if err_msg: + print() + gpgit.error(err_msg) + + ret = gpgit.printstatus() print() # Check if even something needs to be done - if gpgit.todo: + if ret > 0: # User selection try: ret = input('Continue with the selected operations? [Y/n]') @@ -935,12 +913,17 @@ def main(arguments): gpgit.error('Aborted by user') if ret == 'y' or ret == '': print() - if not gpgit.run(): + err_msg = gpgit.run() + if err_msg: + gpgit.error(err_msg) + else: print('Finished without errors') else: gpgit.error('Aborted by user') + elif ret < 0: + gpgit.error('Exiting due to previous errors') else: print(colors.GREEN + "==>", colors.RESET, 'Everything looks okay. Nothing to do.') if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) + sys.exit(main()) From 561f17e3f1c256f8f42f92ab7aa529c56d110adf Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 17 May 2017 21:25:16 +0200 Subject: [PATCH 32/46] Format fixes --- gpgit.py | 58 +++++++++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/gpgit.py b/gpgit.py index a724bcd..0b67102 100755 --- a/gpgit.py +++ b/gpgit.py @@ -95,9 +95,8 @@ def __init__(self, config, gpg): # Initialize parent Step.__init__(self, 'Generate a new GPG key', - Substep('Strong, unique, secret passphrase', self.substep1), - Substep('Key generation', self.substep2), - ) + Substep('Strong, unique, secret passphrase', self.substep1), + Substep('Key generation', self.substep2)) def analyze(self): # Get private keys @@ -144,9 +143,7 @@ def analyze(self): if self.config['fingerprint'] is not None: # Check if the full fingerprint is used if len(self.config['fingerprint']) != 40: - self.setstatus(2, 'FAIL', 'Please specify the full fingerprint', - 'GPG ID: ' + self.config['fingerprint']) - return True + return 'Please specify the full fingerprint. GPG ID: ' + self.config['fingerprint'] # Find selected key for key in private_keys: @@ -156,23 +153,17 @@ def analyze(self): # Check if key is available in keyring if gpgkey is None: - self.setstatus(2, 'FAIL', 'Selected key is not available in keyring', - 'GPG ID: ' + self.config['fingerprint']) - return True + return 'Selected key not found in keyring. GPG ID: ' + self.config['fingerprint'] # Check key algorithm security if gpgkey['algo'] not in self.gpgSecureAlgorithmIDs \ or gpgkey['length'] not in self.gpgSecureKeyLength: - self.setstatus(2, 'FAIL', 'Insecure key algorithm used: ' - + gpgkey['algoname'] + ' ' + gpgkey['length'], - 'GPG ID: ' + self.config['fingerprint']) - return True + return 'Insecure key algorithm used: ' + gpgkey['algoname'] + ' ' \ + + gpgkey['length'] + ' GPG ID: ' + self.config['fingerprint'] # Check key algorithm security if gpgkey['trust'] == 'r': - self.setstatus(2, 'FAIL', 'Selected key is revoked', - 'GPG ID: ' + self.config['fingerprint']) - return True + return 'Selected key is revoked. GPG ID: ' + self.config['fingerprint'] # Use selected key self.setstatus(2, 'OK', 'Key already generated', @@ -328,8 +319,7 @@ def analyze(self): except git.exc.GitCommandError: if hasattr(tag.tag, 'message') \ and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: - self.setstatus(3, 'FAIL', 'Invalid signature for tag ' + self.config['tag']) - return True + return 'Invalid signature for tag ' + self.config['tag'] self.setstatus(3, 'TODO', 'Signing existing tag: ' + self.config['tag']) else: self.setstatus(3, 'OK', 'Good signature for existing tag: ' + self.config['tag']) @@ -424,9 +414,7 @@ def analyze(self): if os.path.isfile(tarfilepath): # Check if tag exists if self.repo.tag('refs/tags/' + self.config['tag']) not in self.repo.tags: - self.setstatus(1, 'FAIL', 'Archive exists but no corresponding tag!?', - tarfilepath) - return True + return 'Archive exists without corresponding tag: ' + tarfile # Verify existing archive try: @@ -435,13 +423,9 @@ def analyze(self): self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') if not cmptar.equal(): - self.setstatus(1, 'FAIL', 'Existing archive differs from local source', - tarfilepath) - return True + return 'Existing archive differs from local source:' + tarfilepath except lzma.LZMAError: - self.setstatus(1, 'FAIL', 'Archive not in ' + tar + ' format', - tarfilepath) - return True + return 'Archive not in ' + tar + ' format: ' + tarfilepath # Successfully verified self.setstatus(1, 'OK', 'Existing archive(s) verified successfully', @@ -463,9 +447,7 @@ def analyze(self): if os.path.isfile(sigfilepath): # Check if signature for tar exists if not os.path.isfile(tarfilepath): - self.setstatus(2, 'FAIL', 'Signature found without corresponding archive', - sigfilepath) - return True + return 'Signature found without corresponding archive: ' + sigfilepath # Verify signature with open(sigfilepath, "rb") as sig: @@ -476,9 +458,8 @@ def analyze(self): or verified.fingerprint != self.config['fingerprint']: if verified.trust_text is None: verified.trust_text = 'Invalid signature' - self.setstatus(2, 'FAIL', 'Signature verification failed', - sigfilepath, 'Trust level: ' + verified.trust_text) - return True + return 'Signature verification failed: ' + sigfilepath \ + + ' Trust level: ' + verified.trust_text # Successfully verified self.setstatus(2, 'OK', 'Existing signature(s) verified successfully') @@ -503,9 +484,7 @@ def analyze(self): if os.path.isfile(shafilepath): # Check if tar for hash exists if not os.path.isfile(tarfilepath): - self.setstatus(3, 'FAIL', 'Message digest found without ' \ - + 'corresponding archive', shafilepath) - return True + return 'Message digest found without corresponding archive: ' + shafilepath # Read hash and filename with open(shafilepath, "r") as f: @@ -515,8 +494,7 @@ def analyze(self): if len(hashinfo) != 2 \ or self.hash[sha][tarfile] != hashinfo[0] \ or os.path.basename(hashinfo[1]) != tarfile: - self.setstatus(3, 'FAIL', 'Message digest verification failed', shafilepath) - return True + return 'Message digest verification failed: ' + shafilepath # Successfully verified self.setstatus(3, 'OK', 'Existing message digest(s) verified successfully') @@ -712,9 +690,8 @@ class GPGit(object): colormap = { 'OK': colors.GREEN, - 'FAIL': colors.RED, 'INFO': colors.YELLOW, - 'WARN': colors.YELLOW, + 'WARN': colors.RED, 'TODO': colors.MAGENTA, 'NOTE': colors.BLUE, } @@ -809,7 +786,6 @@ def __init__(self, config): def analyze(self): for i, step in enumerate(self.Steps, start=1): - # TODO remove return True in analyze functions to only abort on critical errors print('Analyzing step', i, 'of', len(self.Steps), end='...', flush=True) err_msg = step.analyze() if err_msg: From 3177c6c36ae2a46b30c43118adc19e7e11a5b8a9 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 17 May 2017 22:20:44 +0200 Subject: [PATCH 33/46] Style improvements and small fixes --- gpgit.py | 110 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/gpgit.py b/gpgit.py index 0b67102..3f826d7 100755 --- a/gpgit.py +++ b/gpgit.py @@ -16,7 +16,8 @@ import gnupg -class TimeoutException(Exception): pass +class TimeoutException(Exception): + pass @contextmanager def time_limit(seconds): @@ -110,7 +111,7 @@ def analyze(self): # Check if a fingerprint was selected/found if self.config['fingerprint'] is None: # Check if gpg keys are available, but not yet configured - if len(private_keys): + if private_keys: print('\r\033[K', end='') print("GPG seems to be already configured on your system but git is not.") print('Please select one of the existing keys below or generate a new one:') @@ -146,10 +147,11 @@ def analyze(self): return 'Please specify the full fingerprint. GPG ID: ' + self.config['fingerprint'] # Find selected key + gpgkey = None for key in private_keys: if key['fingerprint'] == self.config['fingerprint']: gpgkey = key - break; + break # Check if key is available in keyring if gpgkey is None: @@ -167,10 +169,8 @@ def analyze(self): # Use selected key self.setstatus(2, 'OK', 'Key already generated', - 'GPG key: ' + gpgkey['uids'][0], - 'GPG ID: [' + gpgkey['algoname'] + ' ' - + gpgkey['length'] + '] ' + gpgkey['fingerprint'] - + ' ' ) + 'GPG key: ' + gpgkey['uids'][0], 'GPG ID: [' + gpgkey['algoname'] + ' ' + + gpgkey['length'] + '] ' + gpgkey['fingerprint'] + ' ') # Warn about strong passphrase self.setstatus(1, 'NOTE', 'Please use a strong, unique, secret passphrase') @@ -178,16 +178,16 @@ def analyze(self): else: # Generate a new key self.setstatus(2, 'TODO', 'Generating an RSA 4096 GPG key for ' - + self.config['username'] - + ' ' + self.config['email'] - + ' valid for 1 year.') + + self.config['username'] + ' ' + self.config['email'] + + ' valid for 1 year.') # Warn about strong passphrase self.setstatus(1, 'TODO', 'Please use a strong, unique, secret passphrase') # Strong, unique, secret passphrase def substep1(self): - self.print_exec('More infos: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + self.print_exec('More infos:', + 'https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') # Key generation def substep2(self): @@ -215,7 +215,8 @@ def substep2(self): self.print_exec('disks) during the prime generation; this gives the random number') self.print_exec('generator a better chance to gain enough entropy.') self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - self.print_exec('Key generation finished. You new fingerprint is: ' + self.config['fingerprint']) + self.print_exec('Key generation finished. You new fingerprint is: ' + + self.config['fingerprint']) class Step2(Step): def __init__(self, config, gpg): @@ -325,7 +326,7 @@ def analyze(self): self.setstatus(3, 'OK', 'Good signature for existing tag: ' + self.config['tag']) else: self.setstatus(3, 'TODO', 'Creating signed tag ' + self.config['tag'] - + ' and pushing it to the remote git') + + ' and pushing it to the remote git') # Configure git GPG key def substep1(self): @@ -429,11 +430,11 @@ def analyze(self): # Successfully verified self.setstatus(1, 'OK', 'Existing archive(s) verified successfully', - 'Path: ' + self.config['output'], 'Basename: ' + filename) + 'Path: ' + self.config['output'], 'Basename: ' + filename) else: self.setstatus(1, 'TODO', 'Creating new release archive(s): ' - + ', '.join(str(x) for x in self.config['tar']), - 'Path: ' + self.config['output'], 'Basename: ' + filename) + + ', '.join(str(x) for x in self.config['tar']), + 'Path: ' + self.config['output'], 'Basename: ' + filename) # Get signature filename from setting if self.config['no_armor']: @@ -596,9 +597,10 @@ def __init__(self, config, assets): # Initialize parent Step.__init__(self, 'Upload the release', Substep('Configure HTTPS download server', self.substep1), - Substep('Github', self.substep2)) + Substep('Upload to Github', self.substep2)) def analyze(self): + """Analyze: Upload the release""" # Check Github GPG key if self.config['github'] is True: self.setstatus(1, 'OK', 'Github uses well configured https') @@ -640,21 +642,21 @@ def analyze(self): self.newassets += [asset] # Check if assets already uploaded - if len(self.newassets) == 0: - self.setstatus(2, 'OK', 'Release already published on Github') - else: + if self.newassets: self.setstatus(2, 'TODO', 'Uploading the release files to Github') + else: + self.setstatus(2, 'OK', 'Release already published on Github') else: self.setstatus(2, 'NOTE', 'Please upload the release files manually') self.setstatus(1, 'NOTE', 'Please configure HTTPS for your download server') - # Configure HTTPS for your download server def substep1(self): + """Configure HTTPS download server""" pass - # Github def substep2(self): + """Upload to Github""" # Create release if not existant if self.release is None: self.release = self.githubrepo.create_git_release( @@ -672,8 +674,8 @@ def substep2(self): # TODO change label and mime type self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") -# Helper class to compare a stream without writing class strmcmp(object): + """Helper class to compare a stream without writing""" def __init__(self, strmcmp): self.strmcmp = strmcmp self.__equal = True @@ -686,6 +688,7 @@ def equal(self): return True class GPGit(object): + """Class that manages GPGit steps and substeps analysis, print and execution.""" version = '2.0.0' colormap = { @@ -724,17 +727,18 @@ def __init__(self, config): ] # Read in git config values - for config in gitconfig: + for cfg in gitconfig: # Create not existing keys - if config[0] not in self.config: - self.config[config[0]] = None + if cfg[0] not in self.config: + self.config[cfg[0]] = None # Check if gitconfig provides a setting - if self.config[config[0]] is None and reader.has_option(config[1], config[2]): - val = reader.get_value(config[1], config[2]) - if type(val) == int: + if self.config[cfg[0]] is None and reader.has_option(cfg[1], cfg[2]): + val = reader.get_value(cfg[1], cfg[2]) + # TODO reading wrong values for commit.gpgsign, type() was working + if isinstance(val, int): val = str(val) - self.config[config[0]] = val + self.config[cfg[0]] = val # Get default git signing key if self.config['fingerprint'] is None and self.config['signingkey']: @@ -777,35 +781,37 @@ def __init__(self, config): self.config['config_level'] = 'repository' # Create array fo steps to analyse and run - self.step1 = Step1(self.config, self.gpg) - self.step2 = Step2(self.config, self.gpg) - self.step3 = Step3(self.config, self.repo) - self.step4 = Step4(self.config, self.gpg, self.repo, self.assets) - self.step5 = Step5(self.config, self.assets) - self.Steps = [ self.step1, self.step2, self.step3, self.step4, self.step5 ] + step1 = Step1(self.config, self.gpg) + step2 = Step2(self.config, self.gpg) + step3 = Step3(self.config, self.repo) + step4 = Step4(self.config, self.gpg, self.repo, self.assets) + step5 = Step5(self.config, self.assets) + self.steps = [step1, step2, step3, step4, step5] def analyze(self): - for i, step in enumerate(self.Steps, start=1): - print('Analyzing step', i, 'of', len(self.Steps), end='...', flush=True) + """Analze all steps and substeps for later preview printing""" + for i, step in enumerate(self.steps, start=1): + print('Analyzing step', i, 'of', len(self.steps), end='...', flush=True) err_msg = step.analyze() if err_msg: return err_msg print('\r\033[K', end='') def printstatus(self): + """Print preview list with step and substeps.""" todo = False error = False - for i, step in enumerate(self.Steps, start=1): + for i, step in enumerate(self.steps, start=1): # Sample: "1. Generate a new GPG key" print(colors.BOLD + str(i) + '.', step.name + colors.RESET) for j, substep in enumerate(step.substeps, start=1): # Sample: "1.2 [ OK ] Key already generated" - print(colors.BOLD + ' ' + str(i) + '.' + str(j), self.colormap[substep.status] + '[' - + substep.status.center(4) + ']' + colors.RESET, substep.msg) + print(colors.BOLD + ' ' + str(i) + '.' + str(j), self.colormap[substep.status] + + '[' + substep.status.center(4) + ']' + colors.RESET, substep.msg) # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" for info in substep.infos: - print(colors.BOLD + ' -> ' + self.colormap['INFO'] + '[INFO]' + colors.RESET, info) + print(colors.BOLD + ' -> ' + colors.YELLOW + '[INFO]' + colors.RESET, info) # Check for error or todos if substep.status == 'FAIL': @@ -820,8 +826,8 @@ def printstatus(self): return 1 def run(self): - # Execute all steps - for i, step in enumerate(self.Steps, start=1): + """Execute all steps + substeps.""" + for i, step in enumerate(self.steps, start=1): # Run all substeps if enabled # Sample: "==> 2. Publish your key" print(colors.GREEN + "==>", colors.BOLD + str(i) + '.', step.name + colors.RESET) @@ -829,17 +835,25 @@ def run(self): # Run selected step function if activated if substep.status == 'TODO': # Sample: " -> Will associate your GPG key with Github" - print(colors.BLUE + " ->", colors.BOLD + str(i) +'.' + str(j), substep.name + colors.RESET) + print(colors.BLUE + " ->", colors.BOLD + str(i) +'.' + str(j), + substep.name + colors.RESET) err_msg = substep.funct() if err_msg: return err_msg - def error(self, msg): - print(colors.RED + '==> Error:' + colors.RESET, msg) - sys.exit(1) + def error(self, *args): + """Print error and exit program. An optional integer param specifies the exit code.""" + status = 1 + for msg in args: + if type(msg) == int: + status = msg + else: + print(colors.RED + '==> Error:' + colors.RESET, msg) + sys.exit(status) def main(): + """Main entry point that parses configs and creates GPGit instance.""" parser = argparse.ArgumentParser(description='A python script that automates the process of ' \ + 'signing git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') From 3390798f894293aad3f26cd8e1b5e017714037b3 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Thu, 18 May 2017 19:09:57 +0200 Subject: [PATCH 34/46] Fix more pylint warnings and add docstrings --- gpgit.py | 175 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 94 insertions(+), 81 deletions(-) diff --git a/gpgit.py b/gpgit.py index 3f826d7..ea24c95 100755 --- a/gpgit.py +++ b/gpgit.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +"""A python script that automates the process of signing Git sources via GPG.""" from __future__ import print_function import os import sys @@ -17,11 +18,16 @@ class TimeoutException(Exception): + """Timeout exception for time_limit function""" pass @contextmanager def time_limit(seconds): + """Timeout helper function. Can be used as follows: with time_limit(seconds). + Nested calls with multiple time_limits will not work! + """ def signal_handler(signum, frame): + #pylint: disable=unused-argument raise TimeoutException signal.signal(signal.SIGALRM, signal_handler) signal.alarm(seconds) @@ -30,7 +36,7 @@ def signal_handler(signum, frame): finally: signal.alarm(0) -class colors(object): +class Colors(object): RED = "\033[1;31m" BLUE = "\033[1;34m" CYAN = "\033[1;36m" @@ -42,7 +48,22 @@ class colors(object): REVERSE = "\033[;7m" RESET = "\033[0;0m" +class Streamcmp(object): + """Helper class to compare a stream without writing""" + def __init__(self, strm): + self.__strm = strm + self.__equal = True + def write(self, data): + """Compare written data with input stream reading""" + if data != self.__strm.read(len(data)): + self.__equal = False + def equal(self): + """Check if both streams match completely.""" + if self.__strm.read(1) == b'' and self.__equal: + return True + class Substep(object): + """Contains name and execution functions of a Step""" def __init__(self, name, funct): # Params self.name = name @@ -54,6 +75,7 @@ def __init__(self, name, funct): self.infos = [] class Step(object): + """Holds variable number of substeps. Step1-5 inherit from this class.""" def __init__(self, name, *args): # Params self.name = name @@ -61,11 +83,13 @@ def __init__(self, name, *args): for substep in args: self.substeps += [substep] - def print_exec(self, msg): - # TODO only with verbose? - print(colors.BLUE + ':: ' + colors.RESET + msg) + @staticmethod + def verbose(*args): + """Verbose print used for substep execution""" + print(Colors.BLUE + '::' + Colors.RESET, *args) def setstatus(self, subnumber, status, msg, *args): + """Set variables of the substeps in a batch""" if subnumber > 0: self.substeps[subnumber - 1].status = status self.substeps[subnumber - 1].msg = msg @@ -74,6 +98,7 @@ def setstatus(self, subnumber, status, msg, *args): self.substeps[subnumber - 1].infos += [info] class Step1(Step): + """Generate a new GPG key""" # RFC4880 9.1. Public-Key Algorithms gpgAlgorithmIDs = { '1': 'RSA', @@ -100,6 +125,7 @@ def __init__(self, config, gpg): Substep('Key generation', self.substep2)) def analyze(self): + """Analyze: Generate a new GPG key""" # Get private keys private_keys = self.gpg.list_keys(True) for key in private_keys: @@ -184,14 +210,13 @@ def analyze(self): # Warn about strong passphrase self.setstatus(1, 'TODO', 'Please use a strong, unique, secret passphrase') - # Strong, unique, secret passphrase def substep1(self): - self.print_exec('More infos:', - 'https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') + """Strong, unique, secret passphrase""" + self.verbose('More infos:', + 'https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase') - # Key generation def substep2(self): - return + """Key generation""" # Generate RSA key command # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html input_data = """ @@ -210,15 +235,16 @@ def substep2(self): """.format(self.config['username'], self.config['email']) # Execute gpg key generation command - self.print_exec('We need to generate a lot of random bytes. It is a good idea to perform') - self.print_exec('some other action (type on the keyboard, move the mouse, utilize the') - self.print_exec('disks) during the prime generation; this gives the random number') - self.print_exec('generator a better chance to gain enough entropy.') + self.verbose('We need to generate a lot of random bytes. It is a good idea to perform') + self.verbose('some other action (type on the keyboard, move the mouse, utilize the') + self.verbose('disks) during the prime generation; this gives the random number') + self.verbose('generator a better chance to gain enough entropy.') self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - self.print_exec('Key generation finished. You new fingerprint is: ' - + self.config['fingerprint']) + self.verbose('Key generation finished. You new fingerprint is: ' + + self.config['fingerprint']) class Step2(Step): + """Publish your GPG key""" def __init__(self, config, gpg): # Params self.config = config @@ -231,6 +257,7 @@ def __init__(self, config, gpg): Substep('Associate GPG key with Github', self.substep3)) def analyze(self): + """Analyze: Publish your GPG key""" # Add publish note if self.config['fingerprint'] is None: self.setstatus(2, 'TODO', 'Please publish the full GPG fingerprint on the project page') @@ -261,21 +288,22 @@ def analyze(self): # Upload key to keyserver self.setstatus(1, 'TODO', 'Publishing key on ' + self.config['keyserver']) - # Send GPG key to a key server def substep1(self): - self.print_exec('Publishing key ' + self.config['fingerprint']) + """Send GPG key to a key server""" + self.verbose('Publishing key ' + self.config['fingerprint']) self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) - # Publish your full fingerprint def substep2(self): + """Publish your full fingerprint""" print('Your fingerprint is:', self.config['fingerprint']) - # Associate GPG key with Github def substep3(self): + """Associate GPG key with Github""" #TODO pass class Step3(Step): + """Use Git with GPG""" def __init__(self, config, repo): # Params self.config = config @@ -288,6 +316,7 @@ def __init__(self, config, repo): Substep('Create signed Git tag', self.substep3)) def analyze(self): + """Analyze: Use Git with GPG""" # Check if git was already configured with the gpg key if self.config['signingkey'] != self.config['fingerprint'] \ or self.config['fingerprint'] is None: @@ -300,7 +329,7 @@ def analyze(self): self.setstatus(1, 'OK', 'Git already configured with your GPG key') # Check commit signing - if self.config['gpgsign'] is True: + if self.config['gpgsign'].lower() == 'true': self.setstatus(2, 'OK', 'Commit signing already enabled') else: self.setstatus(2, 'TODO', 'Enabling ' + self.config['config_level'] + ' commit signing') @@ -328,21 +357,23 @@ def analyze(self): self.setstatus(3, 'TODO', 'Creating signed tag ' + self.config['tag'] + ' and pushing it to the remote git') - # Configure git GPG key def substep1(self): + """Configure git GPG key""" # Configure git signingkey settings - with self.repo.config_writer(config_level=self.config['config_level']) as cw: - cw.set("user", "signingkey", self.config['fingerprint']) + with self.repo.config_writer(config_level=self.config['config_level']) as cfgwriter: + cfgwriter.set("user", "signingkey", self.config['fingerprint']) - # Enable commit signing def substep2(self): + """Enable commit signing""" # Configure git signingkey settings - with self.repo.config_writer(config_level=self.config['config_level']) as cw: - cw.set("commit", "gpgsign", True) + # TODO not working for repository (local) setting as config group does not yet exist + # TODO also fix above? + with self.repo.config_writer(config_level=self.config['config_level']) as cfgwriter: + cfgwriter.set("commit", "gpgsign", True) - # Create signed git tag def substep3(self): - self.print_exec('Creating, signing and pushing tag ' + self.config['tag']) + """Create signed Git tag""" + self.verbose('Creating, signing and pushing tag ' + self.config['tag']) # Check if tag needs to be recreated force = False @@ -376,6 +407,7 @@ def substep3(self): self.repo.remotes.origin.push(newtag, force=force) class Step4(Step): + """Create a signed release archive""" compressionAlgorithms = { 'gz': gzip, 'gzip': gzip, @@ -403,6 +435,7 @@ def __init__(self, config, gpg, repo, assets): Substep('Create the message digest', self.substep3)) def analyze(self): + """Analyze: Create a signed release archive""" # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -420,7 +453,7 @@ def analyze(self): # Verify existing archive try: with self.compressionAlgorithms[tar].open(tarfilepath, "rb") as tarstream: - cmptar = strmcmp(tarstream) + cmptar = Streamcmp(tarstream) self.repo.archive(cmptar, treeish=self.config['tag'], prefix=filename + '/', format='tar') if not cmptar.equal(): @@ -476,8 +509,8 @@ def analyze(self): # Calculate hash of tarfile if os.path.isfile(tarfilepath): hash_sha = hashlib.new(sha) - with open(tarfilepath, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): + with open(tarfilepath, "rb") as filestream: + for chunk in iter(lambda: filestream.read(4096), b""): hash_sha.update(chunk) self.hash[sha][tarfile] = hash_sha.hexdigest() @@ -488,8 +521,8 @@ def analyze(self): return 'Message digest found without corresponding archive: ' + shafilepath # Read hash and filename - with open(shafilepath, "r") as f: - hashinfo = f.readline().split() + with open(shafilepath, "r") as filestream: + hashinfo = filestream.readline().split() # Verify hash if len(hashinfo) != 2 \ @@ -503,9 +536,8 @@ def analyze(self): self.setstatus(3, 'TODO', 'Creating message digest(s) for archive(s): ' + ', '.join(str(x) for x in self.config['sha'])) - - # Create compressed archive def substep1(self): + """Create compressed archive""" # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -515,13 +547,13 @@ def substep1(self): # Create compressed tar files if it does not exist if not os.path.isfile(tarfilepath): - self.print_exec('Creating ' + tarfilepath) + self.verbose('Creating ' + tarfilepath) with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') - # Sign the sources def substep2(self): + """Sign the archive""" # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -539,7 +571,7 @@ def substep2(self): if not os.path.isfile(sigfilepath): # Sign tar file with open(tarfilepath, 'rb') as tarstream: - self.print_exec('Creating ' + sigfilepath) + self.verbose('Creating ' + sigfilepath) signed_data = self.gpg.sign_file( tarstream, keyid=self.config['fingerprint'], @@ -553,8 +585,8 @@ def substep2(self): # TODO https://tools.ietf.org/html/rfc4880#section-9.4 #print(signed_data.hash_algo) -> 8 -> SHA256 - # Create the message digest def substep3(self): + """Create the message digest""" # Check all compression option tar files filename = self.config['project'] + '-' + self.config['tag'] for tar in self.config['tar']: @@ -577,7 +609,7 @@ def substep3(self): self.hash[sha][tarfile] = hash_sha.hexdigest() # Write cached hash and filename - self.print_exec('Creating ' + shafilepath) + self.verbose('Creating ' + shafilepath) with open(shafilepath, "w") as filestream: filestream.write(self.hash[sha][tarfile] + ' ' + tarfile) @@ -610,8 +642,7 @@ def analyze(self): try: self.config['token'] = input('Enter Github token to access release API: ') except KeyboardInterrupt: - print() - gpgit.error('Aborted by user') + return 'Aborted by user' # Create Github API instance self.github = Github(self.config['token']) @@ -668,35 +699,21 @@ def substep2(self): # Upload assets for asset in self.newassets: assetpath = os.path.join(self.config['output'], asset) - self.print_exec('Uploading ' + assetpath) + self.verbose('Uploading ' + assetpath) # TODO not functional # see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 - # TODO change label and mime type - self.release.upload_asset(assetpath, "Testlabel", "application/x-xz") - -class strmcmp(object): - """Helper class to compare a stream without writing""" - def __init__(self, strmcmp): - self.strmcmp = strmcmp - self.__equal = True - def write(self, data): - if data != self.strmcmp.read(len(data)): - self.__equal = False - def equal(self): - # Check end of file too - if self.strmcmp.read(1) == b'' and self.__equal: - return True + self.release.upload_asset(assetpath) class GPGit(object): """Class that manages GPGit steps and substeps analysis, print and execution.""" version = '2.0.0' colormap = { - 'OK': colors.GREEN, - 'INFO': colors.YELLOW, - 'WARN': colors.RED, - 'TODO': colors.MAGENTA, - 'NOTE': colors.BLUE, + 'OK': Colors.GREEN, + 'INFO': Colors.YELLOW, + 'WARN': Colors.RED, + 'TODO': Colors.MAGENTA, + 'NOTE': Colors.BLUE, } def __init__(self, config): @@ -734,11 +751,7 @@ def __init__(self, config): # Check if gitconfig provides a setting if self.config[cfg[0]] is None and reader.has_option(cfg[1], cfg[2]): - val = reader.get_value(cfg[1], cfg[2]) - # TODO reading wrong values for commit.gpgsign, type() was working - if isinstance(val, int): - val = str(val) - self.config[cfg[0]] = val + self.config[cfg[0]] = str(reader.get_value(cfg[1], cfg[2])) # Get default git signing key if self.config['fingerprint'] is None and self.config['signingkey']: @@ -803,15 +816,15 @@ def printstatus(self): error = False for i, step in enumerate(self.steps, start=1): # Sample: "1. Generate a new GPG key" - print(colors.BOLD + str(i) + '.', step.name + colors.RESET) + print(Colors.BOLD + str(i) + '.', step.name + Colors.RESET) for j, substep in enumerate(step.substeps, start=1): # Sample: "1.2 [ OK ] Key already generated" - print(colors.BOLD + ' ' + str(i) + '.' + str(j), self.colormap[substep.status] - + '[' + substep.status.center(4) + ']' + colors.RESET, substep.msg) + print(Colors.BOLD + ' ' + str(i) + '.' + str(j), self.colormap[substep.status] + + '[' + substep.status.center(4) + ']' + Colors.RESET, substep.msg) # Sample: " -> [INFO] GPG key: [rsa4096] 97312D5EB9D7AE7D0BD4307351DAE9B7C1AE9161" for info in substep.infos: - print(colors.BOLD + ' -> ' + colors.YELLOW + '[INFO]' + colors.RESET, info) + print(Colors.BOLD + ' -> ' + Colors.YELLOW + '[INFO]' + Colors.RESET, info) # Check for error or todos if substep.status == 'FAIL': @@ -830,32 +843,32 @@ def run(self): for i, step in enumerate(self.steps, start=1): # Run all substeps if enabled # Sample: "==> 2. Publish your key" - print(colors.GREEN + "==>", colors.BOLD + str(i) + '.', step.name + colors.RESET) + print(Colors.GREEN + "==>", Colors.BOLD + str(i) + '.', step.name + Colors.RESET) for j, substep in enumerate(step.substeps, start=1): # Run selected step function if activated if substep.status == 'TODO': # Sample: " -> Will associate your GPG key with Github" - print(colors.BLUE + " ->", colors.BOLD + str(i) +'.' + str(j), - substep.name + colors.RESET) + print(Colors.BLUE + " ->", Colors.BOLD + str(i) +'.' + str(j), + substep.name + Colors.RESET) err_msg = substep.funct() if err_msg: return err_msg - def error(self, *args): + @staticmethod + def error(*args): """Print error and exit program. An optional integer param specifies the exit code.""" status = 1 for msg in args: - if type(msg) == int: + if isinstance(msg, int): status = msg else: - print(colors.RED + '==> Error:' + colors.RESET, msg) + print(Colors.RED + '==> Error:' + Colors.RESET, msg) sys.exit(status) - def main(): """Main entry point that parses configs and creates GPGit instance.""" parser = argparse.ArgumentParser(description='A python script that automates the process of ' \ - + 'signing git sources via GPG.') + + 'signing Git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') @@ -913,7 +926,7 @@ def main(): elif ret < 0: gpgit.error('Exiting due to previous errors') else: - print(colors.GREEN + "==>", colors.RESET, 'Everything looks okay. Nothing to do.') + print(Colors.GREEN + "==>", Colors.RESET, 'Everything looks okay. Nothing to do.') if __name__ == '__main__': sys.exit(main()) From 2a5f869bb60a5c5606f0a54a68a6f5637985fd5d Mon Sep 17 00:00:00 2001 From: NicoHood Date: Thu, 18 May 2017 20:00:23 +0200 Subject: [PATCH 35/46] Minor fixes --- gpgit.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/gpgit.py b/gpgit.py index ea24c95..7489e6c 100755 --- a/gpgit.py +++ b/gpgit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""A python script that automates the process of signing Git sources via GPG.""" +"""A Python script that automates the process of signing Git sources via GPG.""" from __future__ import print_function import os import sys @@ -37,16 +37,16 @@ def signal_handler(signum, frame): signal.alarm(0) class Colors(object): - RED = "\033[1;31m" - BLUE = "\033[1;34m" - CYAN = "\033[1;36m" - MAGENTA = "\033[1;35m" - YELLOW = "\033[1;33m" - GREEN = "\033[1;32m" + RED = '\033[1;31m' + BLUE = '\033[1;34m' + CYAN = '\033[1;36m' + MAGENTA = '\033[1;35m' + YELLOW = '\033[1;33m' + GREEN = '\033[1;32m' UNDERLINE = '\033[4m' - BOLD = "\033[;1m" - REVERSE = "\033[;7m" - RESET = "\033[0;0m" + BOLD = '\033[;1m' + REVERSE = '\033[;7m' + RESET = '\033[0;0m' class Streamcmp(object): """Helper class to compare a stream without writing""" @@ -71,7 +71,7 @@ def __init__(self, name, funct): # Default values self.status = 'FAIL' - self.msg = 'Aborting due to previous errors' + self.msg = 'Internal error' self.infos = [] class Step(object): @@ -712,6 +712,7 @@ class GPGit(object): 'OK': Colors.GREEN, 'INFO': Colors.YELLOW, 'WARN': Colors.RED, + 'FAIL': Colors.RED, 'TODO': Colors.MAGENTA, 'NOTE': Colors.BLUE, } @@ -843,12 +844,12 @@ def run(self): for i, step in enumerate(self.steps, start=1): # Run all substeps if enabled # Sample: "==> 2. Publish your key" - print(Colors.GREEN + "==>", Colors.BOLD + str(i) + '.', step.name + Colors.RESET) + print(Colors.GREEN + '==>', Colors.BOLD + str(i) + '.', step.name + Colors.RESET) for j, substep in enumerate(step.substeps, start=1): # Run selected step function if activated if substep.status == 'TODO': # Sample: " -> Will associate your GPG key with Github" - print(Colors.BLUE + " ->", Colors.BOLD + str(i) +'.' + str(j), + print(Colors.BLUE + ' ->', Colors.BOLD + str(i) +'.' + str(j), substep.name + Colors.RESET) err_msg = substep.funct() if err_msg: @@ -867,7 +868,7 @@ def error(*args): def main(): """Main entry point that parses configs and creates GPGit instance.""" - parser = argparse.ArgumentParser(description='A python script that automates the process of ' \ + parser = argparse.ArgumentParser(description='A Python script that automates the process of ' \ + 'signing Git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) @@ -926,7 +927,7 @@ def main(): elif ret < 0: gpgit.error('Exiting due to previous errors') else: - print(Colors.GREEN + "==>", Colors.RESET, 'Everything looks okay. Nothing to do.') + print(Colors.GREEN + '==>', Colors.RESET, 'Everything looks okay. Nothing to do.') if __name__ == '__main__': sys.exit(main()) From 50acda1a641f92269dc37762e49603d19b18ffe9 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 20 May 2017 12:22:12 +0200 Subject: [PATCH 36/46] Readme updates --- Readme.md | 193 +++++++++++++++++++++--------------------------------- gpgit.py | 38 +++++------ 2 files changed, 94 insertions(+), 137 deletions(-) diff --git a/Readme.md b/Readme.md index 1179a79..25e084e 100644 --- a/Readme.md +++ b/Readme.md @@ -3,31 +3,26 @@ ![gpgit.png](img/gpgit.png) ## Introduction -As we all know, today more than ever before, it is crucial to be able to trust -our computing environments. One of the main difficulties that package -maintainers of Linux distributions face, is the difficulty to verify the -authenticity and the integrity of the source code. With GPG signatures it is -possible for packagers to verify easily and quickly source code releases. +As we all know, today more than ever before, it is crucial to be able to trust our computing environments. One of the main difficulties that package maintainers of Linux distributions face, is the difficulty to verify the authenticity and the integrity of the source code. With GPG signatures it is possible for packagers to verify source code releases quickly and easily. #### Overview of the required tasks: * Create and/or use a **[4096-bit RSA keypair][1]** for the file signing * Use a **[strong, unique, secret passphrase][2]** for the key * Upload the public key to a **[key server][3]** and **[publish the full fingerprint][4]** -* **Sign** every new git **[commit][5]** and **[tag][6]** +* **Sign** every new Git **[commit][5]** and **[tag][6]** * Create **[signed][7], [compressed][8]** (xz --best) release **archives** * Upload a **[strong message digest][9]** (sha512) of the archive * Configure **[HTTPS][10]** for your download server ### GPGit -[GPGit][11] is meant to bring GPG to the masses. It is not only a python script -that automates the process of [creating new signed git releases with GPG][12] -but also comes with a [step-by-step readme guide][13] for learning how to use -GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. +[GPGit][11] is meant to bring GPG to the masses. It is not only a Python script that automates the process of [creating new signed Git releases with GPG][12] but also comes with a [step-by-step readme guide][13] for learning how to use GPG. GPGit integrates perfectly with the [Github Release API][14] for uploading. + +The security status of Linux projects will be tracked in the [Linux Security Database][15]. Thanks for your help in making Linux projects more secure by using GPG signatures. [1]: https://github.com/NicoHood/gpgit#12-key-generation [2]: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase -[3]: https://github.com/NicoHood/gpgit#21-submit-your-key-to-a-key-server -[4]: https://github.com/NicoHood/gpgit#23-publish-your-full-fingerprint +[3]: https://github.com/NicoHood/gpgit#21-send-key-to-a-key-server +[4]: https://github.com/NicoHood/gpgit#22-publish-full-fingerprint [5]: https://github.com/NicoHood/gpgit#32-commit-signing [6]: https://github.com/NicoHood/gpgit#33-create-signed-git-tag [7]: https://github.com/NicoHood/gpgit#43-sign-the-sources @@ -38,7 +33,7 @@ GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. [12]: https://github.com/NicoHood/gpgit#script-usage [13]: https://github.com/NicoHood/gpgit#gpg-quick-start-guide [14]: https://developer.github.com/v3/repos/releases/ - +[15]: https://github.com/NicoHood/LSD ## Index * [Introduction](#introduction) @@ -51,7 +46,7 @@ GPG. GPGit integrates perfect with the [Github Release API][14] for uploading. ## Installation ### ArchLinux -You can install gpgit from [AUR](https://aur.archlinux.org/packages/gpgit/). +You can install GPGit from [AUR](https://aur.archlinux.org/packages/gpgit/). Make sure to [build in a clean chroot](https://wiki.archlinux.org/index.php/DeveloperWiki:Building_in_a_Clean_Chroot). Please give the package a vote so I can move it to the official ArchLinux [community] repository for even simpler installation. @@ -92,14 +87,14 @@ beside the tag are required.** Follow the instructions and you are good to go. For more information checkout the help page: ``` $ gpgit --help -usage: gpgit.py [-h] [-v] [-m MESSAGE] [-o OUTPUT] [-g GIT_DIR] +usage: gpgit [-h] [-v] [-m MESSAGE] [-o OUTPUT] [-g GIT_DIR] [-f FINGERPRINT] [-p PROJECT] [-e EMAIL] [-u USERNAME] - [-c COMMENT] [-k KEYSERVER] [-n] [-a] + [-k KEYSERVER] [-n] [-a] [-t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...]] [-s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...]] [-b] tag -A Python script that automates the process of signing git sources via GPG. +A Python script that automates the process of signing Git sources via GPG. positional arguments: tag Tagname @@ -110,27 +105,27 @@ optional arguments: -m MESSAGE, --message MESSAGE tag message -o OUTPUT, --output OUTPUT - output path of the compressed archive, signature and - message digest + output path of the archive, signature and message + digest -g GIT_DIR, --git-dir GIT_DIR - path of the git project + path of the Git project -f FINGERPRINT, --fingerprint FINGERPRINT (full) GPG fingerprint to use for signing/verifying -p PROJECT, --project PROJECT name of the project, used for archive generation -e EMAIL, --email EMAIL - email used for gpg key generation + email used for GPG key generation -u USERNAME, --username USERNAME - username used for gpg key generation - -c COMMENT, --comment COMMENT - comment used for gpg key generation + username used for GPG key generation -k KEYSERVER, --keyserver KEYSERVER - keyserver to use for up/downloading gpg keys + keyserver to use for up/downloading GPG keys -n, --no-github disable Github API functionallity -a, --prerelease Flag as Github prerelease - -t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...], --tar {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...] + -t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...], \ + --tar {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...] compression option - -s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...], --sha {sha256,sha384,sha512} [{sha256,sha384,sha512} ...] + -s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...], \ + --sha {sha256,sha384,sha512} [{sha256,sha384,sha512} ...] message digest option -b, --no-armor do not create ascii armored signature output ``` @@ -153,46 +148,41 @@ git config --global user.email ``` ## GPG Quick Start Guide -GPGit guides you through 5 simple steps to get your software project ready -with GPG signatures. Further details can be found below. +GPGit guides you through 5 simple steps to get your software project ready with GPG signatures. Further details can be found below. 1. [Generate a new GPG key](#1-generate-a-new-gpg-key) 1. [Strong, unique, secret passphrase](#11-strong-unique-secret-passphrase) 2. [Key generation](#12-key-generation) 2. [Publish your key](#2-publish-your-key) - 1. [Submit your key to a key server](#21-submit-your-key-to-a-key-server) - 2. [Associate GPG key with Github](#22-associate-gpg-key-with-github) - 3. [Publish your full fingerprint](#23-publish-your-full-fingerprint) -3. [Usage of GPG by git](#3-usage-of-gpg-by-git) - 1. [Configure git GPG key](#31-configure-git-gpg-key) - 2. [Commit signing](#32-commit-signing) - 3. [Create signed git tag](#33-create-signed-git-tag) -4. [Creation of a signed compressed release archive](#4-creation-of-a-signed-compressed-release-archive) + 1. [Send GPG key to a key server](#21-send-key-to-a-key-server) + 2. [Publish full fingerprint](#22-publish-full-fingerprint) + 3. [Associate GPG key with Github](#23-associate-gpg-key-with-github) +3. [Use Git with GPG](#3-usage-of-gpg-by-git) + 1. [Configure Git GPG key](#31-configure-git-gpg-key) + 2. [Enble commit signing](#32-enable-commit-signing) + 3. [Create signed Git tag](#33-create-signed-git-tag) +4. [Create a signed release archive](#4-creation-of-a-signed-compressed-release-archive) 1. [Create compressed archive](#41-create-compressed-archive) - 2. [Sign the sources](#42-create-the-message-digest) + 2. [Sign the archive](#42-create-the-message-digest) 3. [Create the message digest](#43-sign-the-sources) 5. [Upload the release](#5-upload-the-release) - 1. [Github](#51-github) - 2. [Configure HTTPS for your download server](#52-configure-https-for-your-download-server) + 1. [Configure HTTPS download server](#51-github) + 2. [Upload to Github](#52-configure-https-for-your-download-server) ### 1. Generate a new GPG key #### 1.1 Strong, unique, secret passphrase -Make sure that your new passphrase for the GPG key meets high security -standards. If the passphrase/key is compromised all of your signatures are -compromised too. +Make sure that your new passphrase for the GPG key meets high security standards. If the passphrase/key is compromised all of your signatures are compromised too. Here are a few examples how to keep a passphrase strong but easy to remember: * [How to Create a Secure Password](https://open.buffer.com/creating-a-secure-password/) * [Mooltipass](https://www.themooltipass.com/) * [Keepass](http://keepass.info/) +* [PasswordCard](https://www.passwordcard.org/en) #### 1.2 Key generation -If you don't have a GPG key yet, create a new one first. You can use RSA -(4096 bits) or ECC (Curve 25519) for a strong key. The latter one does currently -not work with Github. You want to stay with RSA for now. +If you don't have a GPG key yet, create a new one first. You can use RSA (4096 bits) or ECC (Curve 25519) for a strong key. The latter one does currently not work with Github. You want to stay with RSA for now. -**Make sure that your secret key is stored somewhere safe and use a unique -strong password.** +**Make sure that your secret key is stored somewhere safe and use a unique strong password.** Crucial key generation settings: * (1) RSA and RSA @@ -215,24 +205,18 @@ public and secret key created and signed. pub rsa4096 2017-01-04 [SC] [expires: 2018-01-04] 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6 -uid John Doe (gpgit example) +uid John Doe sub rsa4096 2017-01-04 [E] [expires: 2018-01-04] ``` -The generated key has the fingerprint `3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6` -in this example. Share it with others so they can verify your source. -[[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Create_key_pair) +The generated key has the fingerprint `3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6` in this example. Share it with others so they can verify your source. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Create_key_pair) -If you ever move your installation make sure to backup `~/.gnupg/` as it -contains the **private key** and the **revocation certificate**. Handle it with care. -[[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Revoking_a_key) +If you ever move your installation make sure to backup `~/.gnupg/` as it contains the **private key** and the **revocation certificate**. Handle it with care. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Revoking_a_key) ### 2. Publish your key -#### 2.1 Submit your key to a key server -To make the public key widely available, upload it to a key server. -Now the user can get your key by requesting the fingerprint from the keyserver: -[[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Use_a_keyserver) +#### 2.1 Send GPG key to a key server +To make the public key widely available, upload it to a key server. Now the user can get your key by requesting the fingerprint from the keyserver: [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Use_a_keyserver) ```bash # Publish key @@ -242,10 +226,11 @@ gpg --keyserver hkps://pgp.mit.edu --send-keys 6 gpg --keyserver hkps://pgp.mit.edu --recv-keys ``` +#### 2.3 Publish full fingerprint +To make it easy for everyone else to find your key it is crucial that you publish the [**full fingerprint**](https://lkml.org/lkml/2016/8/15/445) on a trusted platform, such as your website or Github. To give the key more trust other users can sign your key too. [[Read more]](https://wiki.debian.org/Keysigning) + #### 2.2 Associate GPG key with Github -To make Github display your commits as "verified" you also need to add your -public [GPG key to your Github profile](https://github.com/settings/keys). -[[Read more]](https://help.github.com/articles/generating-a-gpg-key/) +To make Github display your commits as "verified" you also need to add your public [GPG key to your Github profile](https://github.com/settings/keys). [[Read more]](https://help.github.com/articles/generating-a-gpg-key/) ```bash # List keys + full fingerprint @@ -255,17 +240,9 @@ gpg --list-secret-keys --keyid-format LONG gpg --armor --export ``` -#### 2.3 Publish your full fingerprint -To make it easy for everyone else to find your key it is crucial that you -publish the [**full fingerprint**](https://lkml.org/lkml/2016/8/15/445) on a trusted platform, such as your website or Github. -To give the key more trust other users can sign your key too. -[[Read more]](https://wiki.debian.org/Keysigning) - -### 3. Usage of GPG by git -#### 3.1 Configure git GPG key -In order to make git use your GPG key you need to set the default signing key -for git. -[[Read more]](https://help.github.com/articles/telling-git-about-your-gpg-key/) +### 3. Use Git with GPG +#### 3.1 Configure Git GPG key +In order to make Git use your GPG key you need to set the default signing key for Git. [[Read more]](https://help.github.com/articles/telling-git-about-your-gpg-key/) ```bash # List keys + full fingerprint @@ -274,20 +251,15 @@ gpg --list-secret-keys --keyid-format LONG git config --global user.signingkey ``` -#### 3.2 Commit signing -To verify the git history, git commits needs to be signed. You can manually sign -commits or enable it by default for every commit. It is recommended to globally -enable git commit signing. -[[Read more]](https://help.github.com/articles/signing-commits-using-gpg/) +#### 3.2 Enable commit signing +To verify the Git history, Git commits needs to be signed. You can manually sign commits or enable it by default for every commit. It is recommended to globally enable Git commit signing. [[Read more]](https://help.github.com/articles/signing-commits-using-gpg/) ```bash git config --global commit.gpgsign true ``` -#### 3.3 Create signed git tag -Git tags need to be created from the command line and always need a switch to -enable tag signing. -[[Read more]](https://help.github.com/articles/signing-tags-using-gpg/) +#### 3.3 Create signed Git tag +Git tags need to be created from the command line and always need a switch to enable tag signing. [[Read more]](https://help.github.com/articles/signing-tags-using-gpg/) ```bash # Creates a signed tag @@ -297,12 +269,9 @@ git tag -s mytag git tag -v mytag ``` -### 4. Creation of a signed compressed release archive +### 4. Create a signed release archive #### 4.1 Create compressed archive -You can use `git archive` to create archives of your tagged git release. It is -highly recommended to use a strong compression which is especially beneficial -for those countries with slow and unstable internet connections. -[[Read more]](https://git-scm.com/docs/git-archive) +You can use `git archive` to create archives of your tagged Git release. It is highly recommended to use a strong compression which is especially beneficial for those countries with slow and unstable internet connections. [[Read more]](https://git-scm.com/docs/git-archive) ```bash # .tar.gz @@ -318,73 +287,61 @@ git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | lzip --best > gpgit-1.0.0. git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | cmp <(xz -dc gpgit-1.0.0.tar.xz) ``` -#### 4.2 Sign the sources +#### 4.2 Sign the archive Type the filename of the tarball that you want to sign and then run: ```bash gpg --armor --detach-sign gpgit-1.0.0.tar.xz ``` -Do not blindly sign the Github source downloads unless you have compared its -content with the local files via `diff.` -[[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Make_a_detached_signature) +**Do not blindly sign the Github source downloads** unless you have compared its content with the local files via `diff.` [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Make_a_detached_signature) -To not need to retype your password every time for signing you can also use -[gpg-agent](https://wiki.archlinux.org/index.php/GnuPG#gpg-agent). +To not need to retype your password every time for signing you can also use [gpg-agent](https://wiki.archlinux.org/index.php/GnuPG#gpg-agent). -This gives you a file called `gpgit-1.0.0.tar.xz.asc` which is the GPG -signature. Release it along with your source tarball and let everyone know -to first verify the signature after downloading. -[[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Verify_a_signature) +This gives you a file called `gpgit-1.0.0.tar.xz.asc` which is the GPG signature. Release it along with your source tarball and let everyone know to first verify the signature after downloading. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Verify_a_signature) ```bash gpg --verify gpgit-1.0.0.tar.xz.asc ``` #### 4.3 Create the message digest -Message digests are used to ensure the integrity of a file. It can also serve as -checksum to verify the download. Message digests **do not** replace GPG -signatures. They rather provide and alternative simple way to verify the source. -Make sure to provide message digest over a secure channel like https. +Message digests are used to ensure the integrity of a file. It can also serve as checksum to verify the download. Message digests **do not** replace GPG signatures. They rather provide and alternative simple way to verify the source. Make sure to provide message digest over a secure channel like https. ```bash sha512 gpgit-1.0.0.tar.xz > gpgit-1.0.0.tar.xz.sha512 ``` ### 5. Upload the release -#### 5.1 Github -Create a new "Github Release" to add additional data to the tag. Then drag the -.tar.xz .sig and .sha512 file onto the release. +#### 5.1 Configure HTTPS download server +* [Why HTTPS Matters](https://developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https) +* [Let's Encrypt](https://letsencrypt.org/) +* [SSL Server Test](https://www.ssllabs.com/ssltest/) -The script also supports uploading to Github directly. Create a new Github token -first and then follow the instructions of the script. +#### 5.2 Upload to Github +Create a new "Github Release" to add additional data to the tag. Then drag the .tar.xz .sig and .sha512 files onto the release. + +The script also supports uploading to Github directly. Create a new Github token first and then follow the instructions of the script. How to generate a Github token: * Go to ["Settings - Personal access tokens"](https://github.com/settings/tokens) * Generate a new token with permissions "public_repo" and "admin:gpg_key" * Store it safely -#### 5.2 Configure HTTPS for your download server -* [Why HTTPS Matters](https://developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https) -* [Let's Encrypt](https://letsencrypt.org/) -* [SSL Server Test](https://www.ssllabs.com/ssltest/) - ## Appendix ### Email Encryption -You can also use your GPG key for email encryption -with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird#EnigMail_-_Encryption). -[[Read more]](https://www.enigmail.net/index.php/en/) +You can also use your GPG key for email encryption with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird#EnigMail_-_Encryption). [[Read more]](https://www.enigmail.net/index.php/en/) ## Contact -You can get securely in touch with me [here](http://contact.nicohood.de). -Don't hesitate to [file a bug at Github](https://github.com/NicoHood/gpgit/issues). -More cool projects from me can be found [here](http://www.nicohood.de). +You can get securely in touch with me [here](http://contact.nicohood.de). Don't hesitate to [file a bug at Github](https://github.com/NicoHood/gpgit/issues). More cool projects from me can be found [here](http://www.nicohood.de). ## Version History ``` +2.0.0 (xx.xx.2017) +* TODO + 1.2.0 (24.04.2017) * Trap on errors * Detect gpg2 -* Fix git tags pull/push +* Fix Git tags pull/push * Small code fixes * Thanks @cmaglie with #3 diff --git a/gpgit.py b/gpgit.py index 7489e6c..35ddf49 100755 --- a/gpgit.py +++ b/gpgit.py @@ -136,10 +136,10 @@ def analyze(self): # Check if a fingerprint was selected/found if self.config['fingerprint'] is None: - # Check if gpg keys are available, but not yet configured + # Check if GPG keys are available, but not yet configured if private_keys: print('\r\033[K', end='') - print("GPG seems to be already configured on your system but git is not.") + print("GPG seems to be already configured on your system but Git is not.") print('Please select one of the existing keys below or generate a new one:') print() @@ -166,7 +166,7 @@ def analyze(self): if userinput != 0: self.config['fingerprint'] = private_keys[userinput - 1]['fingerprint'] - # Validate selected gpg key + # Validate selected GPG key if self.config['fingerprint'] is not None: # Check if the full fingerprint is used if len(self.config['fingerprint']) != 40: @@ -234,7 +234,7 @@ def substep2(self): %commit """.format(self.config['username'], self.config['email']) - # Execute gpg key generation command + # Execute GPG key generation command self.verbose('We need to generate a lot of random bytes. It is a good idea to perform') self.verbose('some other action (type on the keyboard, move the mouse, utilize the') self.verbose('disks) during the prime generation; this gives the random number') @@ -317,10 +317,10 @@ def __init__(self, config, repo): def analyze(self): """Analyze: Use Git with GPG""" - # Check if git was already configured with the gpg key + # Check if Git was already configured with the GPG key if self.config['signingkey'] != self.config['fingerprint'] \ or self.config['fingerprint'] is None: - # Check if git was already configured with a different key + # Check if Git was already configured with a different key if self.config['signingkey'] is None: self.config['config_level'] = 'global' @@ -355,17 +355,17 @@ def analyze(self): self.setstatus(3, 'OK', 'Good signature for existing tag: ' + self.config['tag']) else: self.setstatus(3, 'TODO', 'Creating signed tag ' + self.config['tag'] - + ' and pushing it to the remote git') + + ' and pushing it to the remote Git') def substep1(self): - """Configure git GPG key""" - # Configure git signingkey settings + """Configure Git GPG key""" + # Configure Git signingkey settings with self.repo.config_writer(config_level=self.config['config_level']) as cfgwriter: cfgwriter.set("user", "signingkey", self.config['fingerprint']) def substep2(self): """Enable commit signing""" - # Configure git signingkey settings + # Configure Git signingkey settings # TODO not working for repository (local) setting as config group does not yet exist # TODO also fix above? with self.repo.config_writer(config_level=self.config['config_level']) as cfgwriter: @@ -578,7 +578,7 @@ def substep2(self): binary=bool(self.config['no_armor']), detach=True, output=sigfilepath, - #digest_algo='SHA512' #TODO v 2.x gpg module + #digest_algo='SHA512' #TODO v 2.x GPG module ) if signed_data.fingerprint != self.config['fingerprint']: return 'Signing data failed' @@ -728,11 +728,11 @@ def __init__(self, config): # Git self.repo = None - # Create git repository instance + # Create Git repository instance try: self.repo = Repo(self.config['git_dir'], search_parent_directories=True) except git.exc.InvalidGitRepositoryError: - self.error('Not inside a git directory: ' + self.config['git_dir']) + self.error('Not inside a Git directory: ' + self.config['git_dir']) reader = self.repo.config_reader() gitconfig = [ @@ -744,7 +744,7 @@ def __init__(self, config): ['token', 'user', 'githubtoken'] ] - # Read in git config values + # Read in Git config values for cfg in gitconfig: # Create not existing keys if cfg[0] not in self.config: @@ -754,7 +754,7 @@ def __init__(self, config): if self.config[cfg[0]] is None and reader.has_option(cfg[1], cfg[2]): self.config[cfg[0]] = str(reader.get_value(cfg[1], cfg[2])) - # Get default git signing key + # Get default Git signing key if self.config['fingerprint'] is None and self.config['signingkey']: self.config['fingerprint'] = self.config['signingkey'] @@ -876,16 +876,16 @@ def main(): parser.add_argument('-o', '--output', action='store', help='output path of the archive, signature and message digest') parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), - help='path of the git project') + help='path of the Git project') parser.add_argument('-f', '--fingerprint', action='store', help='(full) GPG fingerprint to use for signing/verifying') parser.add_argument('-p', '--project', action='store', help='name of the project, used for archive generation') - parser.add_argument('-e', '--email', action='store', help='email used for gpg key generation') + parser.add_argument('-e', '--email', action='store', help='email used for GPG key generation') parser.add_argument('-u', '--username', action='store', - help='username used for gpg key generation') + help='username used for GPG key generation') parser.add_argument('-k', '--keyserver', action='store', default='hkps://pgp.mit.edu', - help='keyserver to use for up/downloading gpg keys') + help='keyserver to use for up/downloading GPG keys') parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') From 835901899b8c72fa4ea00f30de1784b35ceaf1cf Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 20 May 2017 14:00:08 +0200 Subject: [PATCH 37/46] Update Readme --- Readme.md | 56 +++++++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/Readme.md b/Readme.md index 25e084e..7451b18 100644 --- a/Readme.md +++ b/Readme.md @@ -9,31 +9,32 @@ As we all know, today more than ever before, it is crucial to be able to trust o * Create and/or use a **[4096-bit RSA keypair][1]** for the file signing * Use a **[strong, unique, secret passphrase][2]** for the key * Upload the public key to a **[key server][3]** and **[publish the full fingerprint][4]** -* **Sign** every new Git **[commit][5]** and **[tag][6]** -* Create **[signed][7], [compressed][8]** (xz --best) release **archives** -* Upload a **[strong message digest][9]** (sha512) of the archive -* Configure **[HTTPS][10]** for your download server +* **[Sign][5]** every new Git **[commit][6]** and **[tag][7]** +* Create **[signed][8], [compressed][9]** (xz --best) release **archives** +* Upload a **[strong message digest][10]** (sha512) of the archive +* Configure **[HTTPS][11]** for your download server ### GPGit -[GPGit][11] is meant to bring GPG to the masses. It is not only a Python script that automates the process of [creating new signed Git releases with GPG][12] but also comes with a [step-by-step readme guide][13] for learning how to use GPG. GPGit integrates perfectly with the [Github Release API][14] for uploading. +[GPGit][12] is meant to bring GPG to the masses. It is not only a Python script that automates the process of [creating new signed Git releases with GPG][13] but also comes with a [step-by-step readme guide][14] for learning how to use GPG. GPGit integrates perfectly with the [Github Release API][15] for uploading. -The security status of Linux projects will be tracked in the [Linux Security Database][15]. Thanks for your help in making Linux projects more secure by using GPG signatures. +The security status of Linux projects will be tracked in the [Linux Security Database][16]. Thanks for your help in making Linux projects more secure by using GPG signatures. [1]: https://github.com/NicoHood/gpgit#12-key-generation [2]: https://github.com/NicoHood/gpgit#11-strong-unique-secret-passphrase [3]: https://github.com/NicoHood/gpgit#21-send-key-to-a-key-server [4]: https://github.com/NicoHood/gpgit#22-publish-full-fingerprint -[5]: https://github.com/NicoHood/gpgit#32-commit-signing -[6]: https://github.com/NicoHood/gpgit#33-create-signed-git-tag -[7]: https://github.com/NicoHood/gpgit#43-sign-the-sources -[8]: https://github.com/NicoHood/gpgit#41-create-compressed-archive -[9]: https://github.com/NicoHood/gpgit#42-create-the-message-digest -[10]: https://github.com/NicoHood/gpgit#52-configure-https-for-your-download-server -[11]: https://github.com/NicoHood/gpgit -[12]: https://github.com/NicoHood/gpgit#script-usage -[13]: https://github.com/NicoHood/gpgit#gpg-quick-start-guide -[14]: https://developer.github.com/v3/repos/releases/ -[15]: https://github.com/NicoHood/LSD +[5]: https://github.com/NicoHood/gpgit#31-configure-git-gpg-key +[6]: https://github.com/NicoHood/gpgit#32-commit-signing +[7]: https://github.com/NicoHood/gpgit#33-create-signed-git-tag +[8]: https://github.com/NicoHood/gpgit#42-sign-the-archive +[9]: https://github.com/NicoHood/gpgit#41-create-compressed-archive +[10]: https://github.com/NicoHood/gpgit#43-create-the-message-digest +[11]: https://github.com/NicoHood/gpgit#51-configure-https-download-server +[12]: https://github.com/NicoHood/gpgit +[13]: https://github.com/NicoHood/gpgit#script-usage +[14]: https://github.com/NicoHood/gpgit#gpg-quick-start-guide +[15]: https://github.com/NicoHood/gpgit#52-upload-to-github +[16]: https://github.com/NicoHood/LSD ## Index * [Introduction](#introduction) @@ -46,10 +47,7 @@ The security status of Linux projects will be tracked in the [Linux Security Dat ## Installation ### ArchLinux -You can install GPGit from [AUR](https://aur.archlinux.org/packages/gpgit/). -Make sure to [build in a clean chroot](https://wiki.archlinux.org/index.php/DeveloperWiki:Building_in_a_Clean_Chroot). -Please give the package a vote so I can move it to the official ArchLinux -[community] repository for even simpler installation. +You can install GPGit from [AUR](https://aur.archlinux.org/packages/gpgit/). Make sure to [build in a clean chroot](https://wiki.archlinux.org/index.php/DeveloperWiki:Building_in_a_Clean_Chroot). Please give the package a vote so I can move it to the official ArchLinux [community] repository for even simpler installation. ### Ubuntu/Debian/Other GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pypi/pip). @@ -57,7 +55,7 @@ GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pyp ```bash # Install dependencies sudo apt-get install python3 python3-pip gnupg2 git -VERSION=2.0.0 +VERSION=2.0.1 # Download and verify source wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz @@ -157,17 +155,17 @@ GPGit guides you through 5 simple steps to get your software project ready with 1. [Send GPG key to a key server](#21-send-key-to-a-key-server) 2. [Publish full fingerprint](#22-publish-full-fingerprint) 3. [Associate GPG key with Github](#23-associate-gpg-key-with-github) -3. [Use Git with GPG](#3-usage-of-gpg-by-git) +3. [Use Git with GPG](#3-use-git-with-gpg) 1. [Configure Git GPG key](#31-configure-git-gpg-key) 2. [Enble commit signing](#32-enable-commit-signing) 3. [Create signed Git tag](#33-create-signed-git-tag) -4. [Create a signed release archive](#4-creation-of-a-signed-compressed-release-archive) +4. [Create a signed release archive](#4-create-a-signed-release-archive) 1. [Create compressed archive](#41-create-compressed-archive) - 2. [Sign the archive](#42-create-the-message-digest) - 3. [Create the message digest](#43-sign-the-sources) + 2. [Sign the archive](#42-sign-the-archive) + 3. [Create the message digest](#43-create-the-message-digest) 5. [Upload the release](#5-upload-the-release) - 1. [Configure HTTPS download server](#51-github) - 2. [Upload to Github](#52-configure-https-for-your-download-server) + 1. [Configure HTTPS download server](#51-configure-https-download-server) + 2. [Upload to Github](#52-upload-to-github) ### 1. Generate a new GPG key #### 1.1 Strong, unique, secret passphrase @@ -318,7 +316,7 @@ sha512 gpgit-1.0.0.tar.xz > gpgit-1.0.0.tar.xz.sha512 #### 5.2 Upload to Github Create a new "Github Release" to add additional data to the tag. Then drag the .tar.xz .sig and .sha512 files onto the release. -The script also supports uploading to Github directly. Create a new Github token first and then follow the instructions of the script. +The script also supports [uploading to Github](https://developer.github.com/v3/repos/releases/) directly. Create a new Github token first and then follow the instructions of the script. How to generate a Github token: * Go to ["Settings - Personal access tokens"](https://github.com/settings/tokens) From d9e6aed3cf3f5093e7c7bc748ae00ee4aa894e40 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 3 Jun 2017 20:54:43 +0200 Subject: [PATCH 38/46] Some bugfixes and move parameters into git config --- Readme.md | 135 ++++++++++++++++++++------------------- gpgit.py | 184 +++++++++++++++++++++++++++++++----------------------- 2 files changed, 177 insertions(+), 142 deletions(-) diff --git a/Readme.md b/Readme.md index 7451b18..acf0de5 100644 --- a/Readme.md +++ b/Readme.md @@ -15,7 +15,7 @@ As we all know, today more than ever before, it is crucial to be able to trust o * Configure **[HTTPS][11]** for your download server ### GPGit -[GPGit][12] is meant to bring GPG to the masses. It is not only a Python script that automates the process of [creating new signed Git releases with GPG][13] but also comes with a [step-by-step readme guide][14] for learning how to use GPG. GPGit integrates perfectly with the [Github Release API][15] for uploading. +[GPGit][12] is meant to bring GPG to the masses. It is not only a Python script that automates the process of [creating new signed Git releases with GPG][13], but also a [quick-start-guide][14] for learning how to use GPG. GPGit integrates perfectly with the [Github Release API][15] for uploading. The security status of Linux projects will be tracked in the [Linux Security Database][16]. Thanks for your help in making Linux projects more secure by using GPG signatures. @@ -40,7 +40,7 @@ The security status of Linux projects will be tracked in the [Linux Security Dat * [Introduction](#introduction) * [Installation](#installation) * [Script Usage](#script-usage) -* [GPG quick start guide](#gpg-quick-start-guide) +* [GPG Quick Start Guide](#gpg-quick-start-guide) * [Appendix](#appendix) * [Contact](#contact) * [Version History](#version-history) @@ -55,7 +55,7 @@ GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pyp ```bash # Install dependencies sudo apt-get install python3 python3-pip gnupg2 git -VERSION=2.0.1 +VERSION=2.0.2 # Download and verify source wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz @@ -74,76 +74,75 @@ gpgit --help ``` ## Script Usage -The script guides you through all 5 steps of the -[GPG quick start guide](#gpg-quick-start-guide). **By default no extra arguments -beside the tag are required.** Follow the instructions and you are good to go. +The script guides you through all 5 steps of the [GPG quick start guide](#gpg-quick-start-guide). **By default no extra arguments beside the tag are required.** Follow the instructions and you are good to go. ![screenshot](img/screenshot.png) ### Parameters -For more information checkout the help page: -``` -$ gpgit --help -usage: gpgit [-h] [-v] [-m MESSAGE] [-o OUTPUT] [-g GIT_DIR] - [-f FINGERPRINT] [-p PROJECT] [-e EMAIL] [-u USERNAME] - [-k KEYSERVER] [-n] [-a] - [-t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...]] - [-s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...]] [-b] - tag - -A Python script that automates the process of signing Git sources via GPG. - -positional arguments: - tag Tagname - -optional arguments: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -m MESSAGE, --message MESSAGE - tag message - -o OUTPUT, --output OUTPUT - output path of the archive, signature and message - digest - -g GIT_DIR, --git-dir GIT_DIR - path of the Git project - -f FINGERPRINT, --fingerprint FINGERPRINT - (full) GPG fingerprint to use for signing/verifying - -p PROJECT, --project PROJECT - name of the project, used for archive generation - -e EMAIL, --email EMAIL - email used for GPG key generation - -u USERNAME, --username USERNAME - username used for GPG key generation - -k KEYSERVER, --keyserver KEYSERVER - keyserver to use for up/downloading GPG keys - -n, --no-github disable Github API functionallity - -a, --prerelease Flag as Github prerelease - -t {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...], \ - --tar {gz,gzip,xz,bz2,bzip2} [{gz,gzip,xz,bz2,bzip2} ...] - compression option - -s {sha256,sha384,sha512} [{sha256,sha384,sha512} ...], \ - --sha {sha256,sha384,sha512} [{sha256,sha384,sha512} ...] - message digest option - -b, --no-armor do not create ascii armored signature output -``` +#### -h, --help +Show help message and exit. + +#### -v, --version +Show program's version and exit. + +#### tag +Tagname of the release. E.g. `1.0.0` or `20170521` with `$(date +%Y%m%d)`. + +#### -m , --message +Use the given as the commit message. + +#### -o , --output +Output path of the archive, signature and message digest. You can also set this option via configuration. + +#### -g , --git-dir +Path to the Git project. + +#### -n, --no-github +Disable Github API functionality. Github releases need to be created manually and release assets need to be uploaded manually. GPGit will not prompt for a Github token anymore. + +#### -p, --prerelease +Flag as Github prerelease. ### Configuration -Additional configuration can be made via [git config](https://git-scm.com/docs/git-config). +Additional configuration can be made via [git config](https://git-scm.com/docs/git-config). Example usage: ```bash -# GPGit settings -git config --global user.githubtoken -git config --global user.gpgitoutput ~/gpgit +git config --global gpgit.token +git config --global gpgit.output ~/gpgit +git config --local gpgit.tar xz +``` -# GPG settings -git config --global user.signingkey -git config --global commit.gpgsign true +#### user.signingkey +Full GPG fingerprint to use for signing/verifying. + +#### gpgit.output +Output path of the archive, signature and message digest. You can also set this option via parameter. + +#### gpgit.tar +Archive compression option. Chose between "gz,gzip,xz,bz2,bzip2". Default: "xz" + +#### gpgit.sha +Message digest algorithm. chose between "sha256,sha384,sha512". Default: "sha512" + +#### gpgit.keyserver +Keyserver to use for GPG key check. Automatically set to "skip" after the first check was successfull. Default: "hkps://pgp.mit.edu" + +#### gpgit.github +Enable or disable Github functionality with "true|false". Default: "true" (enabled) + +#### gpgit.user +Username used for github uploading. + +#### gpgit.project +Project name used for github uploading and archive naming. + +#### gpgit.armor +Use ascii armored output of GPG (.asc instead of .sig) with "true|false". Default: "true" (armored output). + +#### gpgit.token +Specify the Github token for Github API release uploading. -# General settings -git config --global user.name -git config --global user.email -``` ## GPG Quick Start Guide GPGit guides you through 5 simple steps to get your software project ready with GPG signatures. Further details can be found below. @@ -334,7 +333,17 @@ You can get securely in touch with me [here](http://contact.nicohood.de). Don't ## Version History ``` 2.0.0 (xx.xx.2017) -* TODO +* Switch to Python3 from bash +* New user interface with preview +* More verification +* Better GPG usage +* More parameters +* Configurable settings via git config +* Better error traces +* Resigning a tag is now possible +* General improvements +* New logo +* Improved documentation 1.2.0 (24.04.2017) * Trap on errors diff --git a/gpgit.py b/gpgit.py index 35ddf49..2dee0db 100755 --- a/gpgit.py +++ b/gpgit.py @@ -9,6 +9,7 @@ import gzip import lzma import bz2 +from getpass import getpass import signal from contextlib import contextmanager from github import Github, GithubException @@ -139,7 +140,7 @@ def analyze(self): # Check if GPG keys are available, but not yet configured if private_keys: print('\r\033[K', end='') - print("GPG seems to be already configured on your system but Git is not.") + print('GPG seems to be already configured on your system but Git is not.') print('Please select one of the existing keys below or generate a new one:') print() @@ -276,6 +277,7 @@ def analyze(self): # Check key on keyserver try: with time_limit(10): + # TODO cannot catch error for unknown GPG key as its run in a separat thread key = self.gpg.recv_keys(self.config['keyserver'], self.config['fingerprint']) except TimeoutException: return 'Keyserver timed out. Please try again alter.' @@ -317,14 +319,10 @@ def __init__(self, config, repo): def analyze(self): """Analyze: Use Git with GPG""" - # Check if Git was already configured with the GPG key - if self.config['signingkey'] != self.config['fingerprint'] \ - or self.config['fingerprint'] is None: - # Check if Git was already configured with a different key - if self.config['signingkey'] is None: - self.config['config_level'] = 'global' - - self.setstatus(1, 'TODO', 'Configuring ' + self.config['config_level'] + 'Git GPG key') + # Check if Git was already configured with a different key + if self.config['fingerprint'] is None: + self.config['config_level'] = 'global' + self.setstatus(1, 'TODO', 'Configuring ' + self.config['config_level'] + ' Git GPG key') else: self.setstatus(1, 'OK', 'Git already configured with your GPG key') @@ -470,10 +468,10 @@ def analyze(self): 'Path: ' + self.config['output'], 'Basename: ' + filename) # Get signature filename from setting - if self.config['no_armor']: - sigfile = tarfile + '.sig' - else: + if self.config['armor']: sigfile = tarfile + '.asc' + else: + sigfile = tarfile + '.sig' self.assets += [sigfile] sigfilepath = os.path.join(self.config['output'], sigfile) @@ -562,10 +560,10 @@ def substep2(self): tarfilepath = os.path.join(self.config['output'], tarfile) # Get signature filename from setting - if self.config['no_armor']: - sigfilepath = tarfilepath + '.sig' - else: + if self.config['armor']: sigfilepath = tarfilepath + '.asc' + else: + sigfilepath = tarfilepath + '.sig' # Check if signature is existant if not os.path.isfile(sigfilepath): @@ -575,7 +573,7 @@ def substep2(self): signed_data = self.gpg.sign_file( tarstream, keyid=self.config['fingerprint'], - binary=bool(self.config['no_armor']), + binary=not bool(self.config['armor']), detach=True, output=sigfilepath, #digest_algo='SHA512' #TODO v 2.x GPG module @@ -640,7 +638,12 @@ def analyze(self): # Ask for Github token if self.config['token'] is None: try: - self.config['token'] = input('Enter Github token to access release API: ') + print('\r\033[K', end='') + print('Accessing Github API to access Github releases and assets.') + print('You can deactive Github API uploading with -n or set your', + 'Github token permanent with:') + print('git config --global user.githubtoken ') + self.config['token'] = getpass('Please enter Github token: ') except KeyboardInterrupt: return 'Aborted by user' @@ -654,7 +657,8 @@ def analyze(self): except GithubException: # TODO improve exception: #https://github.com/PyGithub/PyGithub/issues/152#issuecomment-301249927 - return 'Error accessing Github API for project ' + self.config['project'] + return 'Error accessing Github API for project ' + self.config['project'] \ + + ' with username ' + self.config['username'] + '. Wrong token supplied?' # Check Release and its assets try: @@ -667,7 +671,13 @@ def analyze(self): return else: # Determine which assets need to be uploaded - asset_list = [x.name for x in self.release.get_assets()] + try: + asset_list = [x.name for x in self.release.get_assets()] + except AttributeError: + self.config['github'] = False + self.setstatus(2, 'WARN', 'Requires PyGithub >= 1.35') + return + for asset in self.assets: if asset not in asset_list: self.newassets += [asset] @@ -706,7 +716,7 @@ def substep2(self): class GPGit(object): """Class that manages GPGit steps and substeps analysis, print and execution.""" - version = '2.0.0' + version = '2.0.2' colormap = { 'OK': Colors.GREEN, @@ -717,31 +727,62 @@ class GPGit(object): 'NOTE': Colors.BLUE, } - def __init__(self, config): - # Config via parameters - self.config = config + def __init__(self, tag, config): + # Create module instances and helpers + self.gpg = gnupg.GPG() + self.repo = None self.assets = [] - # GPG - self.gpg = gnupg.GPG() + # Config via parameters + self.config = { + 'tag': tag, + 'message': None, + 'output': None, + 'git_dir': os.getcwd(), + 'github': False, + 'prerelease': False, + } - # Git - self.repo = None + # Overwrite every default value if passed in via parameter + for param in self.config: + if param in config: + self.config[param] = config[param] + + # Load configuration + self.load_git_config() + self.load_default_config() + + # Create array fo steps to analyse and run + step1 = Step1(self.config, self.gpg) + step2 = Step2(self.config, self.gpg) + step3 = Step3(self.config, self.repo) + step4 = Step4(self.config, self.gpg, self.repo, self.assets) + step5 = Step5(self.config, self.assets) + self.steps = [step1, step2, step3, step4, step5] - # Create Git repository instance + def load_git_config(self): + """Loads configuration settings from git config. Does not overwrite existing settings.""" try: self.repo = Repo(self.config['git_dir'], search_parent_directories=True) except git.exc.InvalidGitRepositoryError: self.error('Not inside a Git directory: ' + self.config['git_dir']) reader = self.repo.config_reader() + # Array represents: config['username'], git config user.name gitconfig = [ ['username', 'user', 'name'], ['email', 'user', 'email'], - ['signingkey', 'user', 'signingkey'], + ['fingerprint', 'user', 'signingkey'], ['gpgsign', 'commit', 'gpgsign'], - ['output', 'user', 'gpgitoutput'], - ['token', 'user', 'githubtoken'] + ['output', 'gpgit', 'output'], + ['tar', 'gpgit', 'tar'], + ['sha', 'gpgit', 'sha'], + ['keyserver', 'gpgit', 'keyserver'], # TODO set to the fp once the key was checked once to speed things up + ['github', 'gpgit', 'github'], + ['username', 'gpgit', 'user'], + ['project', 'gpgit', 'project'], + ['armor', 'gpgit', 'armor'], + ['token', 'gpgit', 'token'], ] # Read in Git config values @@ -754,23 +795,30 @@ def __init__(self, config): if self.config[cfg[0]] is None and reader.has_option(cfg[1], cfg[2]): self.config[cfg[0]] = str(reader.get_value(cfg[1], cfg[2])) - # Get default Git signing key - if self.config['fingerprint'] is None and self.config['signingkey']: - self.config['fingerprint'] = self.config['signingkey'] - - # Check if Github URL is used - if self.config['github'] is True: - if 'github' not in self.repo.remotes.origin.url.lower(): - self.config['github'] = False - - # Default message - if self.config['message'] is None: - self.config['message'] = 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ - + self.version + '\nhttps://github.com/NicoHood/gpgit' + # Convert tar and sha settings into arrays + if self.config['tar'] and not isinstance(self.config['tar'], list): + self.config['tar'] = self.config['tar'].split(',') + if self.config['sha'] and not isinstance(self.config['sha'], list): + self.config['sha'] = self.config['sha'].split(',') + + def load_default_config(self): + """Autodetects missing parameters or sets default values.""" + defaults = { + 'sha': ['sha512'], + 'tar': ['xz'], + 'keyserver': 'hkps://pgp.mit.edu', + 'armor': True, + 'config_level': 'repository', + 'message': 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ + + self.version + '\nhttps://github.com/NicoHood/gpgit', + 'project': os.path.basename(self.repo.remotes.origin.url).replace('.git', ''), + 'output': os.path.join(self.repo.working_tree_dir, 'gpgit'), + } - # Default output path - if self.config['output'] is None: - self.config['output'] = os.path.join(self.repo.working_tree_dir, 'archive') + # Load default values + for val in defaults: + if val not in self.config or self.config[val] is None: + self.config[val] = defaults[val] # Check if path exists if not os.path.isdir(self.config['output']): @@ -786,21 +834,11 @@ def __init__(self, config): else: self.error('Aborted by user') - # Set default project name - if self.config['project'] is None: - url = self.repo.remotes.origin.url - self.config['project'] = os.path.basename(url).replace('.git', '') - - # Default config level (repository == local) - self.config['config_level'] = 'repository' - - # Create array fo steps to analyse and run - step1 = Step1(self.config, self.gpg) - step2 = Step2(self.config, self.gpg) - step3 = Step3(self.config, self.repo) - step4 = Step4(self.config, self.gpg, self.repo, self.assets) - step5 = Step5(self.config, self.assets) - self.steps = [step1, step2, step3, step4, step5] + # Check if Github URL is used + # TODO fix for projects that dont have a Github url + if self.config['github'] is True: + if 'github' not in self.repo.remotes.origin.url.lower(): + self.config['github'] = False def analyze(self): """Analze all steps and substeps for later preview printing""" @@ -838,6 +876,7 @@ def printstatus(self): return -1 if todo: return 1 + return 0 def run(self): """Execute all steps + substeps.""" @@ -870,35 +909,21 @@ def main(): """Main entry point that parses configs and creates GPGit instance.""" parser = argparse.ArgumentParser(description='A Python script that automates the process of ' \ + 'signing Git sources via GPG.') - parser.add_argument('tag', action='store', help='Tagname') + parser.add_argument('tag', action='store', + help='Tagname of the release. E.g. "1.0.0" or "20170521".') parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) parser.add_argument('-m', '--message', action='store', help='tag message') parser.add_argument('-o', '--output', action='store', help='output path of the archive, signature and message digest') parser.add_argument('-g', '--git-dir', action='store', default=os.getcwd(), help='path of the Git project') - parser.add_argument('-f', '--fingerprint', action='store', - help='(full) GPG fingerprint to use for signing/verifying') - parser.add_argument('-p', '--project', action='store', - help='name of the project, used for archive generation') - parser.add_argument('-e', '--email', action='store', help='email used for GPG key generation') - parser.add_argument('-u', '--username', action='store', - help='username used for GPG key generation') - parser.add_argument('-k', '--keyserver', action='store', default='hkps://pgp.mit.edu', - help='keyserver to use for up/downloading GPG keys') parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') - parser.add_argument('-t', '--tar', choices=['gz', 'gzip', 'xz', 'bz2', 'bzip2'], default=['xz'], - nargs='+', help='compression option') - parser.add_argument('-s', '--sha', choices=['sha256', 'sha384', 'sha512'], default=['sha512'], - nargs='+', help='message digest option') - parser.add_argument('-b', '--no-armor', action='store_true', - help='do not create ascii armored signature output') args = parser.parse_args() - gpgit = GPGit(vars(args)) + gpgit = GPGit(args.tag, vars(args)) err_msg = gpgit.analyze() if err_msg: print() @@ -921,6 +946,7 @@ def main(): if err_msg: gpgit.error(err_msg) else: + # TODO more colors with green arrow print('Finished without errors') else: gpgit.error('Aborted by user') From a66846947c6453f9b62231b35d7f5dd2e8315700 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 3 Jun 2017 21:02:45 +0200 Subject: [PATCH 39/46] Changed readme sections --- Readme.md | 51 +++++++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/Readme.md b/Readme.md index acf0de5..340f512 100644 --- a/Readme.md +++ b/Readme.md @@ -2,7 +2,7 @@ ![gpgit.png](img/gpgit.png) -## Introduction +# Introduction As we all know, today more than ever before, it is crucial to be able to trust our computing environments. One of the main difficulties that package maintainers of Linux distributions face, is the difficulty to verify the authenticity and the integrity of the source code. With GPG signatures it is possible for packagers to verify source code releases quickly and easily. #### Overview of the required tasks: @@ -38,12 +38,11 @@ The security status of Linux projects will be tracked in the [Linux Security Dat ## Index * [Introduction](#introduction) -* [Installation](#installation) -* [Script Usage](#script-usage) +* [GPGit Documentation](#gpgit-documentation) * [GPG Quick Start Guide](#gpg-quick-start-guide) * [Appendix](#appendix) -* [Contact](#contact) -* [Version History](#version-history) + +# GPGit Documentation ## Installation ### ArchLinux @@ -144,7 +143,7 @@ Use ascii armored output of GPG (.asc instead of .sig) with "true|false". Defaul Specify the Github token for Github API release uploading. -## GPG Quick Start Guide +# GPG Quick Start Guide GPGit guides you through 5 simple steps to get your software project ready with GPG signatures. Further details can be found below. 1. [Generate a new GPG key](#1-generate-a-new-gpg-key) @@ -166,8 +165,8 @@ GPGit guides you through 5 simple steps to get your software project ready with 1. [Configure HTTPS download server](#51-configure-https-download-server) 2. [Upload to Github](#52-upload-to-github) -### 1. Generate a new GPG key -#### 1.1 Strong, unique, secret passphrase +## 1. Generate a new GPG key +### 1.1 Strong, unique, secret passphrase Make sure that your new passphrase for the GPG key meets high security standards. If the passphrase/key is compromised all of your signatures are compromised too. Here are a few examples how to keep a passphrase strong but easy to remember: @@ -176,7 +175,7 @@ Here are a few examples how to keep a passphrase strong but easy to remember: * [Keepass](http://keepass.info/) * [PasswordCard](https://www.passwordcard.org/en) -#### 1.2 Key generation +### 1.2 Key generation If you don't have a GPG key yet, create a new one first. You can use RSA (4096 bits) or ECC (Curve 25519) for a strong key. The latter one does currently not work with Github. You want to stay with RSA for now. **Make sure that your secret key is stored somewhere safe and use a unique strong password.** @@ -210,9 +209,9 @@ The generated key has the fingerprint `3D6B9B41CCDC16D0E4A66AC461D68FF6279DF9A6` If you ever move your installation make sure to backup `~/.gnupg/` as it contains the **private key** and the **revocation certificate**. Handle it with care. [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Revoking_a_key) -### 2. Publish your key +## 2. Publish your key -#### 2.1 Send GPG key to a key server +### 2.1 Send GPG key to a key server To make the public key widely available, upload it to a key server. Now the user can get your key by requesting the fingerprint from the keyserver: [[Read more]](https://wiki.archlinux.org/index.php/GnuPG#Use_a_keyserver) ```bash @@ -223,10 +222,10 @@ gpg --keyserver hkps://pgp.mit.edu --send-keys 6 gpg --keyserver hkps://pgp.mit.edu --recv-keys ``` -#### 2.3 Publish full fingerprint +### 2.3 Publish full fingerprint To make it easy for everyone else to find your key it is crucial that you publish the [**full fingerprint**](https://lkml.org/lkml/2016/8/15/445) on a trusted platform, such as your website or Github. To give the key more trust other users can sign your key too. [[Read more]](https://wiki.debian.org/Keysigning) -#### 2.2 Associate GPG key with Github +### 2.2 Associate GPG key with Github To make Github display your commits as "verified" you also need to add your public [GPG key to your Github profile](https://github.com/settings/keys). [[Read more]](https://help.github.com/articles/generating-a-gpg-key/) ```bash @@ -237,8 +236,8 @@ gpg --list-secret-keys --keyid-format LONG gpg --armor --export ``` -### 3. Use Git with GPG -#### 3.1 Configure Git GPG key +## 3. Use Git with GPG +### 3.1 Configure Git GPG key In order to make Git use your GPG key you need to set the default signing key for Git. [[Read more]](https://help.github.com/articles/telling-git-about-your-gpg-key/) ```bash @@ -248,14 +247,14 @@ gpg --list-secret-keys --keyid-format LONG git config --global user.signingkey ``` -#### 3.2 Enable commit signing +### 3.2 Enable commit signing To verify the Git history, Git commits needs to be signed. You can manually sign commits or enable it by default for every commit. It is recommended to globally enable Git commit signing. [[Read more]](https://help.github.com/articles/signing-commits-using-gpg/) ```bash git config --global commit.gpgsign true ``` -#### 3.3 Create signed Git tag +### 3.3 Create signed Git tag Git tags need to be created from the command line and always need a switch to enable tag signing. [[Read more]](https://help.github.com/articles/signing-tags-using-gpg/) ```bash @@ -266,8 +265,8 @@ git tag -s mytag git tag -v mytag ``` -### 4. Create a signed release archive -#### 4.1 Create compressed archive +## 4. Create a signed release archive +### 4.1 Create compressed archive You can use `git archive` to create archives of your tagged Git release. It is highly recommended to use a strong compression which is especially beneficial for those countries with slow and unstable internet connections. [[Read more]](https://git-scm.com/docs/git-archive) ```bash @@ -284,7 +283,7 @@ git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | lzip --best > gpgit-1.0.0. git archive --format=tar --prefix gpgit-1.0.0 1.0.0 | cmp <(xz -dc gpgit-1.0.0.tar.xz) ``` -#### 4.2 Sign the archive +### 4.2 Sign the archive Type the filename of the tarball that you want to sign and then run: ```bash gpg --armor --detach-sign gpgit-1.0.0.tar.xz @@ -299,20 +298,20 @@ This gives you a file called `gpgit-1.0.0.tar.xz.asc` which is the GPG signature gpg --verify gpgit-1.0.0.tar.xz.asc ``` -#### 4.3 Create the message digest +### 4.3 Create the message digest Message digests are used to ensure the integrity of a file. It can also serve as checksum to verify the download. Message digests **do not** replace GPG signatures. They rather provide and alternative simple way to verify the source. Make sure to provide message digest over a secure channel like https. ```bash sha512 gpgit-1.0.0.tar.xz > gpgit-1.0.0.tar.xz.sha512 ``` -### 5. Upload the release -#### 5.1 Configure HTTPS download server +## 5. Upload the release +### 5.1 Configure HTTPS download server * [Why HTTPS Matters](https://developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https) * [Let's Encrypt](https://letsencrypt.org/) * [SSL Server Test](https://www.ssllabs.com/ssltest/) -#### 5.2 Upload to Github +### 5.2 Upload to Github Create a new "Github Release" to add additional data to the tag. Then drag the .tar.xz .sig and .sha512 files onto the release. The script also supports [uploading to Github](https://developer.github.com/v3/repos/releases/) directly. Create a new Github token first and then follow the instructions of the script. @@ -322,9 +321,9 @@ How to generate a Github token: * Generate a new token with permissions "public_repo" and "admin:gpg_key" * Store it safely -## Appendix +# Appendix -### Email Encryption +## Email Encryption You can also use your GPG key for email encryption with [enigmail and thunderbird](https://wiki.archlinux.org/index.php/thunderbird#EnigMail_-_Encryption). [[Read more]](https://www.enigmail.net/index.php/en/) ## Contact From f412651f659887dc9e00aff923e664545e8b6ff0 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 3 Jun 2017 21:05:05 +0200 Subject: [PATCH 40/46] Fixed readme headlines --- Readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Readme.md b/Readme.md index 340f512..2b6637e 100644 --- a/Readme.md +++ b/Readme.md @@ -150,7 +150,7 @@ GPGit guides you through 5 simple steps to get your software project ready with 1. [Strong, unique, secret passphrase](#11-strong-unique-secret-passphrase) 2. [Key generation](#12-key-generation) 2. [Publish your key](#2-publish-your-key) - 1. [Send GPG key to a key server](#21-send-key-to-a-key-server) + 1. [Send GPG key to a key server](#21-send-gpg-key-to-a-key-server) 2. [Publish full fingerprint](#22-publish-full-fingerprint) 3. [Associate GPG key with Github](#23-associate-gpg-key-with-github) 3. [Use Git with GPG](#3-use-git-with-gpg) @@ -222,10 +222,10 @@ gpg --keyserver hkps://pgp.mit.edu --send-keys 6 gpg --keyserver hkps://pgp.mit.edu --recv-keys ``` -### 2.3 Publish full fingerprint +### 2.2 Publish full fingerprint To make it easy for everyone else to find your key it is crucial that you publish the [**full fingerprint**](https://lkml.org/lkml/2016/8/15/445) on a trusted platform, such as your website or Github. To give the key more trust other users can sign your key too. [[Read more]](https://wiki.debian.org/Keysigning) -### 2.2 Associate GPG key with Github +### 2.3 Associate GPG key with Github To make Github display your commits as "verified" you also need to add your public [GPG key to your Github profile](https://github.com/settings/keys). [[Read more]](https://help.github.com/articles/generating-a-gpg-key/) ```bash From 87d5a1fa6e88cdea895fc3b4c96ad39b55d60ebf Mon Sep 17 00:00:00 2001 From: NicoHood Date: Sat, 3 Jun 2017 21:06:08 +0200 Subject: [PATCH 41/46] Remove appendix from index --- Readme.md | 1 - 1 file changed, 1 deletion(-) diff --git a/Readme.md b/Readme.md index 2b6637e..e172582 100644 --- a/Readme.md +++ b/Readme.md @@ -40,7 +40,6 @@ The security status of Linux projects will be tracked in the [Linux Security Dat * [Introduction](#introduction) * [GPGit Documentation](#gpgit-documentation) * [GPG Quick Start Guide](#gpg-quick-start-guide) -* [Appendix](#appendix) # GPGit Documentation From 5406babbaeebb5455685f6ba0e38d4d1bc18fba8 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 13 Jun 2017 20:20:37 +0200 Subject: [PATCH 42/46] Prerelease 2.0.4 --- Readme.md | 2 +- gpgit.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Readme.md b/Readme.md index e172582..8c950df 100644 --- a/Readme.md +++ b/Readme.md @@ -53,7 +53,7 @@ GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pyp ```bash # Install dependencies sudo apt-get install python3 python3-pip gnupg2 git -VERSION=2.0.2 +VERSION=2.0.4 # Download and verify source wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz diff --git a/gpgit.py b/gpgit.py index 2dee0db..631fcc1 100755 --- a/gpgit.py +++ b/gpgit.py @@ -753,12 +753,12 @@ def __init__(self, tag, config): self.load_default_config() # Create array fo steps to analyse and run - step1 = Step1(self.config, self.gpg) - step2 = Step2(self.config, self.gpg) - step3 = Step3(self.config, self.repo) - step4 = Step4(self.config, self.gpg, self.repo, self.assets) - step5 = Step5(self.config, self.assets) - self.steps = [step1, step2, step3, step4, step5] + self.step1 = Step1(self.config, self.gpg) + self.step2 = Step2(self.config, self.gpg) + self.step3 = Step3(self.config, self.repo) + self.step4 = Step4(self.config, self.gpg, self.repo, self.assets) + self.step5 = Step5(self.config, self.assets) + self.steps = [self.step1, self.step2, self.step3, self.step4, self.step5] def load_git_config(self): """Loads configuration settings from git config. Does not overwrite existing settings.""" @@ -919,7 +919,7 @@ def main(): help='path of the Git project') parser.add_argument('-n', '--no-github', action='store_false', dest='github', help='disable Github API functionallity') - parser.add_argument('-a', '--prerelease', action='store_true', help='Flag as Github prerelease') + parser.add_argument('-p', '--prerelease', action='store_true', help='Flag as Github prerelease') args = parser.parse_args() From c8fc2b943310bac31b4e89954557496c9b625d01 Mon Sep 17 00:00:00 2001 From: NicoHood Date: Wed, 14 Jun 2017 19:37:21 +0200 Subject: [PATCH 43/46] Small fixes for prerelease 2.0.5 --- Readme.md | 2 +- gpgit.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Readme.md b/Readme.md index 8c950df..d3521f8 100644 --- a/Readme.md +++ b/Readme.md @@ -53,7 +53,7 @@ GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pyp ```bash # Install dependencies sudo apt-get install python3 python3-pip gnupg2 git -VERSION=2.0.4 +VERSION=2.0.5 # Download and verify source wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz diff --git a/gpgit.py b/gpgit.py index 631fcc1..437784f 100755 --- a/gpgit.py +++ b/gpgit.py @@ -203,6 +203,10 @@ def analyze(self): self.setstatus(1, 'NOTE', 'Please use a strong, unique, secret passphrase') else: + # Check if Git username and email is set + if not self.config['username']or not self.config['email']: + return 'Please set your email and username with: "git config --global user.email " and "git config --global user.name "' + # Generate a new key self.setstatus(2, 'TODO', 'Generating an RSA 4096 GPG key for ' + self.config['username'] + ' ' + self.config['email'] @@ -327,7 +331,7 @@ def analyze(self): self.setstatus(1, 'OK', 'Git already configured with your GPG key') # Check commit signing - if self.config['gpgsign'].lower() == 'true': + if self.config['gpgsign'] and self.config['gpgsign'].lower() == 'true': self.setstatus(2, 'OK', 'Commit signing already enabled') else: self.setstatus(2, 'TODO', 'Enabling ' + self.config['config_level'] + ' commit signing') @@ -716,7 +720,7 @@ def substep2(self): class GPGit(object): """Class that manages GPGit steps and substeps analysis, print and execution.""" - version = '2.0.2' + __version__ = '2.0.5' colormap = { 'OK': Colors.GREEN, @@ -911,7 +915,7 @@ def main(): + 'signing Git sources via GPG.') parser.add_argument('tag', action='store', help='Tagname of the release. E.g. "1.0.0" or "20170521".') - parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.version) + parser.add_argument('-v', '--version', action='version', version='GPGit ' + GPGit.__version__) parser.add_argument('-m', '--message', action='store', help='tag message') parser.add_argument('-o', '--output', action='store', help='output path of the archive, signature and message digest') From f21e0c0cd5f3da419713401fee350ce39f18c85a Mon Sep 17 00:00:00 2001 From: NicoHood Date: Mon, 19 Jun 2017 21:34:41 +0200 Subject: [PATCH 44/46] i18n for strings --- gpgit.py | 62 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/gpgit.py b/gpgit.py index 437784f..dfe85ae 100755 --- a/gpgit.py +++ b/gpgit.py @@ -196,7 +196,7 @@ def analyze(self): # Use selected key self.setstatus(2, 'OK', 'Key already generated', - 'GPG key: ' + gpgkey['uids'][0], 'GPG ID: [' + gpgkey['algoname'] + ' ' + 'GPG key: {}'.format(gpgkey['uids'][0]), 'GPG ID: [' + gpgkey['algoname'] + ' ' + gpgkey['length'] + '] ' + gpgkey['fingerprint'] + ' ') # Warn about strong passphrase @@ -208,9 +208,9 @@ def analyze(self): return 'Please set your email and username with: "git config --global user.email " and "git config --global user.name "' # Generate a new key - self.setstatus(2, 'TODO', 'Generating an RSA 4096 GPG key for ' - + self.config['username'] + ' ' + self.config['email'] - + ' valid for 1 year.') + self.setstatus(2, 'TODO', + 'Generating an RSA 4096 GPG key for {} {} valid for 1 year.'.format( \ + self.config['username'], self.config['email'])) # Warn about strong passphrase self.setstatus(1, 'TODO', 'Please use a strong, unique, secret passphrase') @@ -245,8 +245,8 @@ def substep2(self): self.verbose('disks) during the prime generation; this gives the random number') self.verbose('generator a better chance to gain enough entropy.') self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - self.verbose('Key generation finished. You new fingerprint is: ' - + self.config['fingerprint']) + self.verbose('Key generation finished. You new fingerprint is: {}'.format( + self.config['fingerprint'])) class Step2(Step): """Publish your GPG key""" @@ -288,20 +288,20 @@ def analyze(self): # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: - self.setstatus(1, 'OK', 'Key already published on ' + self.config['keyserver']) + self.setstatus(1, 'OK', 'Key already published on {}'.format(self.config['keyserver'])) return # Upload key to keyserver - self.setstatus(1, 'TODO', 'Publishing key on ' + self.config['keyserver']) + self.setstatus(1, 'TODO', 'Publishing key on {}'.format(self.config['keyserver'])) def substep1(self): """Send GPG key to a key server""" - self.verbose('Publishing key ' + self.config['fingerprint']) + self.verbose('Publishing key {}'.format(self.config['fingerprint'])) self.gpg.send_keys(self.config['keyserver'], self.config['fingerprint']) def substep2(self): """Publish your full fingerprint""" - print('Your fingerprint is:', self.config['fingerprint']) + print('Your fingerprint is: {}'.format(self.config['fingerprint'])) def substep3(self): """Associate GPG key with Github""" @@ -326,7 +326,7 @@ def analyze(self): # Check if Git was already configured with a different key if self.config['fingerprint'] is None: self.config['config_level'] = 'global' - self.setstatus(1, 'TODO', 'Configuring ' + self.config['config_level'] + ' Git GPG key') + self.setstatus(1, 'TODO', 'Configuring {} Git GPG key'.format(self.config['config_level'])) else: self.setstatus(1, 'OK', 'Git already configured with your GPG key') @@ -334,7 +334,7 @@ def analyze(self): if self.config['gpgsign'] and self.config['gpgsign'].lower() == 'true': self.setstatus(2, 'OK', 'Commit signing already enabled') else: - self.setstatus(2, 'TODO', 'Enabling ' + self.config['config_level'] + ' commit signing') + self.setstatus(2, 'TODO', 'Enabling {} commit signing'.format(self.config['config_level'])) # Refresh tags try: @@ -352,12 +352,13 @@ def analyze(self): if hasattr(tag.tag, 'message') \ and '-----BEGIN PGP SIGNATURE-----' in tag.tag.message: return 'Invalid signature for tag ' + self.config['tag'] - self.setstatus(3, 'TODO', 'Signing existing tag: ' + self.config['tag']) + self.setstatus(3, 'TODO', 'Signing existing tag: {}'.format(self.config['tag'])) else: - self.setstatus(3, 'OK', 'Good signature for existing tag: ' + self.config['tag']) + self.setstatus(3, 'OK', 'Good signature for existing tag: {}'.format(self.config['tag'])) else: - self.setstatus(3, 'TODO', 'Creating signed tag ' + self.config['tag'] - + ' and pushing it to the remote Git') + self.setstatus(3, 'TODO', + 'Creating signed tag {} and pushing it to the remote Git'.format( \ + self.config['tag'])) def substep1(self): """Configure Git GPG key""" @@ -375,7 +376,7 @@ def substep2(self): def substep3(self): """Create signed Git tag""" - self.verbose('Creating, signing and pushing tag ' + self.config['tag']) + self.verbose('Creating, signing and pushing tag {}'.format(self.config['tag'])) # Check if tag needs to be recreated force = False @@ -465,11 +466,11 @@ def analyze(self): # Successfully verified self.setstatus(1, 'OK', 'Existing archive(s) verified successfully', - 'Path: ' + self.config['output'], 'Basename: ' + filename) + 'Path: {}'.format(self.config['output']), 'Basename: {}'.format(filename)) else: - self.setstatus(1, 'TODO', 'Creating new release archive(s): ' - + ', '.join(str(x) for x in self.config['tar']), - 'Path: ' + self.config['output'], 'Basename: ' + filename) + self.setstatus(1, 'TODO', 'Creating new release archive(s): {}'.format( \ + ', '.join(str(x) for x in self.config['tar'])), + 'Path: {}'.format(self.config['output']), 'Basename: {}'.format(filename)) # Get signature filename from setting if self.config['armor']: @@ -535,8 +536,9 @@ def analyze(self): # Successfully verified self.setstatus(3, 'OK', 'Existing message digest(s) verified successfully') else: - self.setstatus(3, 'TODO', 'Creating message digest(s) for archive(s): ' - + ', '.join(str(x) for x in self.config['sha'])) + self.setstatus(3, 'TODO', + 'Creating message digest(s) for archive(s): {}'.format( \ + ', '.join(str(x) for x in self.config['sha']))) def substep1(self): """Create compressed archive""" @@ -549,7 +551,7 @@ def substep1(self): # Create compressed tar files if it does not exist if not os.path.isfile(tarfilepath): - self.verbose('Creating ' + tarfilepath) + self.verbose('Creating {}'.format(tarfilepath)) with self.compressionAlgorithms[tar].open(tarfilepath, 'wb') as tarstream: self.repo.archive(tarstream, treeish=self.config['tag'], prefix=filename + '/', format='tar') @@ -573,7 +575,7 @@ def substep2(self): if not os.path.isfile(sigfilepath): # Sign tar file with open(tarfilepath, 'rb') as tarstream: - self.verbose('Creating ' + sigfilepath) + self.verbose('Creating {}'.format(sigfilepath)) signed_data = self.gpg.sign_file( tarstream, keyid=self.config['fingerprint'], @@ -611,7 +613,7 @@ def substep3(self): self.hash[sha][tarfile] = hash_sha.hexdigest() # Write cached hash and filename - self.verbose('Creating ' + shafilepath) + self.verbose('Creating {}'.format(shafilepath)) with open(shafilepath, "w") as filestream: filestream.write(self.hash[sha][tarfile] + ' ' + tarfile) @@ -713,7 +715,7 @@ def substep2(self): # Upload assets for asset in self.newassets: assetpath = os.path.join(self.config['output'], asset) - self.verbose('Uploading ' + assetpath) + self.verbose('Uploading {}'.format(assetpath)) # TODO not functional # see https://github.com/PyGithub/PyGithub/pull/525#issuecomment-301132357 self.release.upload_asset(assetpath) @@ -814,7 +816,7 @@ def load_default_config(self): 'armor': True, 'config_level': 'repository', 'message': 'Release ' + self.config['tag'] + '\n\nCreated with GPGit ' \ - + self.version + '\nhttps://github.com/NicoHood/gpgit', + + self.__version__ + '\nhttps://github.com/NicoHood/gpgit', 'project': os.path.basename(self.repo.remotes.origin.url).replace('.git', ''), 'output': os.path.join(self.repo.working_tree_dir, 'gpgit'), } @@ -827,7 +829,7 @@ def load_default_config(self): # Check if path exists if not os.path.isdir(self.config['output']): # Create not existing path - print('Not a valid path: ' + self.config['output']) + print('Not a valid path: {}'.format(self.config['output'])) try: ret = input('Create non-existing output path? [Y/n]') except KeyboardInterrupt: @@ -847,7 +849,7 @@ def load_default_config(self): def analyze(self): """Analze all steps and substeps for later preview printing""" for i, step in enumerate(self.steps, start=1): - print('Analyzing step', i, 'of', len(self.steps), end='...', flush=True) + print('Analyzing step {} of {}...'.format(i, len(self.steps)), end='', flush=True) err_msg = step.analyze() if err_msg: return err_msg From 558bbef072994a30e50e61187c40f6ecf1c1e86c Mon Sep 17 00:00:00 2001 From: NicoHood Date: Mon, 19 Jun 2017 21:41:11 +0200 Subject: [PATCH 45/46] Fix line length --- gpgit.py | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/gpgit.py b/gpgit.py index dfe85ae..0d9d1a0 100755 --- a/gpgit.py +++ b/gpgit.py @@ -196,7 +196,8 @@ def analyze(self): # Use selected key self.setstatus(2, 'OK', 'Key already generated', - 'GPG key: {}'.format(gpgkey['uids'][0]), 'GPG ID: [' + gpgkey['algoname'] + ' ' + 'GPG key: {}'.format(gpgkey['uids'][0]), + 'GPG ID: [' + gpgkey['algoname'] + ' ' + gpgkey['length'] + '] ' + gpgkey['fingerprint'] + ' ') # Warn about strong passphrase @@ -205,12 +206,13 @@ def analyze(self): else: # Check if Git username and email is set if not self.config['username']or not self.config['email']: - return 'Please set your email and username with: "git config --global user.email " and "git config --global user.name "' + return 'Please set your email and username with: ' \ + + '"git config --global user.email " and ' \ + + '"git config --global user.name "' # Generate a new key - self.setstatus(2, 'TODO', - 'Generating an RSA 4096 GPG key for {} {} valid for 1 year.'.format( \ - self.config['username'], self.config['email'])) + self.setstatus(2, 'TODO', 'Generating an RSA 4096 GPG key for {} {} valid for 1 year.' \ + .format(self.config['username'], self.config['email'])) # Warn about strong passphrase self.setstatus(1, 'TODO', 'Please use a strong, unique, secret passphrase') @@ -245,8 +247,8 @@ def substep2(self): self.verbose('disks) during the prime generation; this gives the random number') self.verbose('generator a better chance to gain enough entropy.') self.config['fingerprint'] = str(self.gpg.gen_key(input_data)) - self.verbose('Key generation finished. You new fingerprint is: {}'.format( - self.config['fingerprint'])) + self.verbose('Key generation finished. You new fingerprint is: {}' \ + .format(self.config['fingerprint'])) class Step2(Step): """Publish your GPG key""" @@ -288,7 +290,8 @@ def analyze(self): # Found key on keyserver if self.config['fingerprint'] in key.fingerprints: - self.setstatus(1, 'OK', 'Key already published on {}'.format(self.config['keyserver'])) + self.setstatus(1, 'OK', 'Key already published on {}' \ + .format(self.config['keyserver'])) return # Upload key to keyserver @@ -326,7 +329,8 @@ def analyze(self): # Check if Git was already configured with a different key if self.config['fingerprint'] is None: self.config['config_level'] = 'global' - self.setstatus(1, 'TODO', 'Configuring {} Git GPG key'.format(self.config['config_level'])) + self.setstatus(1, 'TODO', 'Configuring {} Git GPG key' \ + .format(self.config['config_level'])) else: self.setstatus(1, 'OK', 'Git already configured with your GPG key') @@ -334,7 +338,8 @@ def analyze(self): if self.config['gpgsign'] and self.config['gpgsign'].lower() == 'true': self.setstatus(2, 'OK', 'Commit signing already enabled') else: - self.setstatus(2, 'TODO', 'Enabling {} commit signing'.format(self.config['config_level'])) + self.setstatus(2, 'TODO', 'Enabling {} commit signing' \ + .format(self.config['config_level'])) # Refresh tags try: @@ -354,11 +359,11 @@ def analyze(self): return 'Invalid signature for tag ' + self.config['tag'] self.setstatus(3, 'TODO', 'Signing existing tag: {}'.format(self.config['tag'])) else: - self.setstatus(3, 'OK', 'Good signature for existing tag: {}'.format(self.config['tag'])) + self.setstatus(3, 'OK', 'Good signature for existing tag: {}' \ + .format(self.config['tag'])) else: - self.setstatus(3, 'TODO', - 'Creating signed tag {} and pushing it to the remote Git'.format( \ - self.config['tag'])) + self.setstatus(3, 'TODO', 'Creating signed tag {} and pushing it to the remote Git' \ + .format(self.config['tag'])) def substep1(self): """Configure Git GPG key""" @@ -466,11 +471,13 @@ def analyze(self): # Successfully verified self.setstatus(1, 'OK', 'Existing archive(s) verified successfully', - 'Path: {}'.format(self.config['output']), 'Basename: {}'.format(filename)) + 'Path: {}'.format(self.config['output']), + 'Basename: {}'.format(filename)) else: - self.setstatus(1, 'TODO', 'Creating new release archive(s): {}'.format( \ - ', '.join(str(x) for x in self.config['tar'])), - 'Path: {}'.format(self.config['output']), 'Basename: {}'.format(filename)) + self.setstatus(1, 'TODO', 'Creating new release archive(s): {}' \ + .format(', '.join(str(x) for x in self.config['tar'])), + 'Path: {}'.format(self.config['output']), + 'Basename: {}'.format(filename)) # Get signature filename from setting if self.config['armor']: @@ -536,9 +543,8 @@ def analyze(self): # Successfully verified self.setstatus(3, 'OK', 'Existing message digest(s) verified successfully') else: - self.setstatus(3, 'TODO', - 'Creating message digest(s) for archive(s): {}'.format( \ - ', '.join(str(x) for x in self.config['sha']))) + self.setstatus(3, 'TODO', 'Creating message digest(s) for archive(s): {}' \ + .format(', '.join(str(x) for x in self.config['sha']))) def substep1(self): """Create compressed archive""" From 971c4612982c500802c2d42f3685e05cf23cf50f Mon Sep 17 00:00:00 2001 From: NicoHood Date: Tue, 27 Jun 2017 16:04:56 +0200 Subject: [PATCH 46/46] Prepare for 2.0.7 Release --- Makefile | 2 +- Readme.md | 4 ++-- gpgit.py | 2 +- gpgit.sh => legacy/gpgit.sh | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename gpgit.sh => legacy/gpgit.sh (100%) diff --git a/Makefile b/Makefile index 80efb8a..d4d2ae3 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ all: @echo "Run 'make uninstall' for uninstallation." install: - install -Dm755 gpgit.sh $(DESTDIR)$(PREFIX)/bin/gpgit + install -Dm755 gpgit.py $(DESTDIR)$(PREFIX)/bin/gpgit install -Dm644 Readme.md $(DESTDIR)$(PREFIX)/share/doc/gpgit/Readme.md uninstall: diff --git a/Readme.md b/Readme.md index d3521f8..46daa66 100644 --- a/Readme.md +++ b/Readme.md @@ -53,7 +53,7 @@ GPGit dependencies can be easily installed via [pip](https://pypi.python.org/pyp ```bash # Install dependencies sudo apt-get install python3 python3-pip gnupg2 git -VERSION=2.0.5 +VERSION=2.0.7 # Download and verify source wget https://github.com/NicoHood/gpgit/releases/download/${VERSION}/gpgit-${VERSION}.tar.xz @@ -330,7 +330,7 @@ You can get securely in touch with me [here](http://contact.nicohood.de). Don't ## Version History ``` -2.0.0 (xx.xx.2017) +2.0.7 (27.06.2017) * Switch to Python3 from bash * New user interface with preview * More verification diff --git a/gpgit.py b/gpgit.py index 0d9d1a0..b5c44c7 100755 --- a/gpgit.py +++ b/gpgit.py @@ -728,7 +728,7 @@ def substep2(self): class GPGit(object): """Class that manages GPGit steps and substeps analysis, print and execution.""" - __version__ = '2.0.5' + __version__ = '2.0.7' colormap = { 'OK': Colors.GREEN, diff --git a/gpgit.sh b/legacy/gpgit.sh similarity index 100% rename from gpgit.sh rename to legacy/gpgit.sh