#!/usr/bin/env python3 """ gpgsign.py - Helper to "GPG" sign Mylyn Gitea Release files Limitations: Too many limitations to be listed here. Usage: 1. Set GPG configuration in signature.conf file 2. run gpgsign.py Caveats: Must be run AFTER jarsign.sh to avoid gpg signature modification Generated files are similar to maven-gpg-plugin ones (default configuration) """ import logging import subprocess from pathlib import Path, PurePath from dataclasses import dataclass from typing import Tuple from enum import Enum import configparser # pylint: disable=logging-fstring-interpolation, line-too-long __PROG__ = "gpgsign" WORKSPACEDIR = Path(__file__).parent.parent # /bin/gpgsign.py ASC_DIRECTORY = "gpg" class GPGLockMode(Enum): ONCE = "once" MULTIPLE = "multiple" NEVER = "never" @dataclass class GPGConfiguration: keyname: str passphrase: str = "" verbose:bool = False skip: bool = False interactive : bool = True executable : Path = None use_agent: bool = False home_dir: str = "" secret_keyring : str = "" public_keyring : str = "" lock_mode : GPGLockMode = None # gpg lock mode, could be : None -> lock mode not used, "once", "multiple", "never" => SyntaxError args = "" # list, of additional arguments as list or tuple or comma separated as string @dataclass class Configuration: gpg: GPGConfiguration class GPGSigner: """Compute signature. """ SIGNATURE_EXTENSION : str = ".asc" def __init__(self, gpg:GPGConfiguration , output_dir: Path ): self.output_dir = output_dir self.gpg = gpg self.interactive = False def generate_signature_for_file(self, file: Path, signature: Path) -> int: """Generate a detached signature file for provided file. Args: file: Path of file to be signed signature: Path of file where the signature will be written. Returns: return code of signature application """ # Configure command line cmd = ['gpg'] if self.gpg.executable: cmd = [str(self.gpg.executable)] if self.gpg.args: if isinstance(self.gpg.args, (list, tuple)): cmd += self.gpg.args else: cmd += self.gpg.args.split(",") if self.gpg.verbose: cmd.append("-v") if self.gpg.home_dir: cmd += [ "--homedir", self.gpg.home_dir] cmd.append(f"--{'' if self.gpg.use_agent else 'no-'}use-agent") # required for subprocess input: # 1. to disable input when self.gpg.passphrase is "" (run is waiting None) # 2. run is waiting bytes for input in_passphrase=None if self.gpg.passphrase: cmd += ["--batch", "--passphrase-fd", "0"] in_passphrase = bytes(self.gpg.passphrase,"UTF-8") if self.gpg.keyname: cmd += ["--local-user", self.gpg.keyname] cmd.append("--armor") cmd.append("--detach-sign") if not self.gpg.interactive: cmd.append("--no-tty") # if self.gpg.secret_keyring or self.gpg.public_keyring: cmd.append("--no-default-keyring") if self.gpg.secret_keyring: cmd += ["--secret-keyring", self.gpg.secret_keyring] if self.gpg.public_keyring: cmd +=[ "--keyring", self.gpg.public_keyring] if self.gpg.lock_mode: cmd.append(f"--lock-{self.gpg.lock_mode}") cmd += ["--output", str(signature)] cmd.append(str(file)) logging.debug(f"GPGSigner> {cmd}") # Execute command line completed_process = subprocess.run(cmd, input=in_passphrase) logging.debug(str(completed_process)) return completed_process.returncode def generate_signature_for_artifact (self, file: Path) -> int: """Create a detached signature file for the provided file managing valide signature file destination. """ # Setup file and directory for signature signature = file.with_suffix ( file.suffix + GPGSigner.SIGNATURE_EXTENSION) if self.output_dir: # ugly but working way to get common parts for i,part in enumerate(signature.parts): try: if part == self.output_dir.parts[i]: continue except IndexError: # output_dir is smaller than signature pass break common_dir = Path(*signature.parts[:i]) if common_dir: signature = self.output_dir / signature.relative_to(common_dir) # Create destination directory logging.debug(f"GPGSigner> artifact:{file}") logging.debug(f"GPGSigner> signature:{signature}") if not signature.parent.is_dir(): signature.parent.mkdir(parents=True, exist_ok=True) if signature.exists(): signature.unlink() # Generate signature file return self.generate_signature_for_file(file, signature) def get_passphrase(self): return "" class Project: """ Project as list of artifacts """ ARTIFACTS_EXTENSIONS = ('.jar', '.xml', '.md') DEFAULT_EXCLUDES = ('.asc', ".gpg", ".sig", ".git", ".externalToolBuilders") def __init__(self, projectdir): self.projectdir = projectdir logging.debug(f"Project> {projectdir}") @property def name(self): return self.projectdir.name def get_attached_artifacts(self, iter_in_dir :Path = None) -> Path: """ Yields: artifact """ iter_in_dir = self.projectdir if iter_in_dir is None else iter_in_dir for e in iter_in_dir.iterdir(): if e.is_dir() : logging.debug(f"Project.get_artifacts> entering in {str(e)}") yield from self.get_attached_artifacts(e) if e.suffix.lower() in Project.DEFAULT_EXCLUDES: logging.debug(f"Project.get_artifacts> excluding {e}") continue if e.suffix.lower() in Project.ARTIFACTS_EXTENSIONS or e.name == 'LICENSE': logging.debug(f"Project.get_artifacts> {e}") yield e class GPGSignAttached: """Sign artifacts attached to the project.""" def __init__(self, project: Project, config: Configuration): self.project = project self.config = config def execute(self): if self.config.gpg.skip: logging.warning(f"GPGSign> Skipping GPG Signature for {self.project.name}") return logging.info(f"GPGSign> Signing {self.project.name} artifacts") signer = GPGSigner(config.gpg, self.project.projectdir / ASC_DIRECTORY) for artifact in self.project.get_attached_artifacts(): signer.generate_signature_for_artifact(artifact) if __name__ == "__main__": # pylint: disable=invalid-name import argparse import sys import os # backup working directory OLDPWD = os.path.curdir # Configure the command line arguments parser parser = argparse.ArgumentParser(prog=__PROG__, description=__PROG__ + " Sign (Java + GPG) updatesite artifacts", prefix_chars="-+") parser.add_argument("-c", "--config-file", help="Configuration file (default /signature.conf", dest="config_file", default=str((WORKSPACEDIR / "signature.conf").absolute()), required=False) parser.add_argument("-x", help="Active 'debug' level for logging", dest='debug', action='store_true', default=False, required=False) # Parse the command line args = parser.parse_args() # Configuration file config_ini = configparser.ConfigParser() config_ini.read(args.config_file) config = Configuration( gpg=GPGConfiguration( skip = config_ini.getboolean('GPG','skip', fallback=True), verbose = config_ini.getboolean('GPG','verbose', fallback=False), keyname = config_ini.get('GPG', 'keyname', fallback=None), passphrase= config_ini.get('GPG','passphrase', fallback=None), ) ) # Configure logging # see https://docs.python.org/2/howto/logging.html logging.basicConfig( format='%(asctime)s:%(levelname)s:%(message)s' if args.debug else '%(levelname)s:%(message)s', level=logging.DEBUG if args.debug else logging.INFO) siteupdate_project = Project(WORKSPACEDIR / "io.gitea.mylyn.updatesite") GPGSignAttached(siteupdate_project, config).execute() sys.exit(0) # vim: tabstop=4:shiftwidth=4:expandtab