diff options
author | Konstantin Ryabitsev <konstantin@linuxfoundation.org> | 2021-05-03 17:41:43 -0400 |
---|---|---|
committer | Konstantin Ryabitsev <konstantin@linuxfoundation.org> | 2021-05-03 17:41:43 -0400 |
commit | 06887d47ff183d0811823beb19573f52b2878992 (patch) | |
tree | 5ef8e2dbf1f654caa9d377a8087b91001f5a8999 | |
download | patatt-06887d47ff183d0811823beb19573f52b2878992.tar.gz |
Initial commit of the reference library
Docs and cleanups are necessary, as well as some tests. However, this
implements most of the features I planned for the proof-of-concept
implementation.
Signed-off-by: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
-rw-r--r-- | .gitignore | 9 | ||||
-rw-r--r-- | .keys/openpgp/linuxfoundation.org/konstantin/default | 367 | ||||
-rw-r--r-- | COPYING | 18 | ||||
-rw-r--r-- | MANIFEST.in | 2 | ||||
-rw-r--r-- | README | 1 | ||||
-rw-r--r-- | patatt/__init__.py | 868 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rw-r--r-- | setup.py | 48 |
8 files changed, 1314 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6966957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*~ +*.pyc +*.swp +__pycache__ +.venv +.idea +*.egg-info +build +dist diff --git a/.keys/openpgp/linuxfoundation.org/konstantin/default b/.keys/openpgp/linuxfoundation.org/konstantin/default new file mode 100644 index 0000000..6e8d4c3 --- /dev/null +++ b/.keys/openpgp/linuxfoundation.org/konstantin/default @@ -0,0 +1,367 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS +09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAA +AAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEH +YXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVG +R0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKj +pKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX2 +9/j5+v/aAAwDAQACEQMRAD8A+y6KKKACiiory4gtLWW6uZVihhQvI7HAVQMkn8KA +JaxvEPivw34fXdrWt2Nj6LLMAx+i9TXyl8bf2kNb1G7n0jwSz6fYqxT7SBiaUeoP +8I+nNfP13b+IPEN0097qshkckszuWLfnQB+g4+M3w4M4hXxJEzHoRE5H8q6HSvGf +hfVHVLLWbWRm+6CSufzxX53aL4dmtCHlvd7A54Y816r4JvprZESQs6AjkHnHrQB9 +tg5GRRXAfCHxBNqNlJpt05kaFBJC5OSUPBB+h/nXf0AFFFFABRRRQAUUUUAFcJ+0 +BLND8HvEbwOUk+y4BB9WUEfka5r4nftFfDnwLfzabc30upahCdssNmAwjb0ZyQAf +YZrw74tftV+F/GXgbUfDVno2oWTXgRftDOrgKHDH5RjrjHWgDwwmJrlpT97uc8fh +U6X0SOpjkAI64NctHdaHNcyTXOr3JTf8sIh8vK+7c4P4Vct77QwcW+nRXOAeZL1x +k54P3R+X60AdjYapaGVVMjOOpx2Ndb4av1O4xSlVJ43L09q85025aYDbYeHICVx+ ++vApB9e/Htz9a6zR4tZZg1rH4UYEYYJfE/iBjj6cigD6B+FfjCLSdXtpXdXib91L +g8bT1x+hr6TjdZI1kRgysAQR3Br4o8OWPiMeWWstBbJyfKmbOc/7np/kV9H/AAp8 +XTtY2+h69CLe4jUJbyrlo3UDoW/hI7ZxmgD0qio2mhVdzSoF9SwxTba6trnd9nni +l2nDbGDY/KgCaiiigAryv9qvxpd+Bfgtq+q6dKYr+4KWVtIOsbSHBYe4UMR716pX +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 +=chX+ +-----END PGP PUBLIC KEY BLOCK----- @@ -0,0 +1,18 @@ +The MIT-Zero License + +Copyright (c) 2020 by the Linux Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e72662c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include COPYING +include README.rst @@ -0,0 +1 @@ +Coming next. diff --git a/patatt/__init__.py b/patatt/__init__.py new file mode 100644 index 0000000..7a8cd1c --- /dev/null +++ b/patatt/__init__.py @@ -0,0 +1,868 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 by The Linux Foundation +# SPDX-License-Identifier: MIT-0 +# +__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>' + +import sys +import os +import re + +import hashlib +import base64 +import subprocess +import logging +import tempfile +import time +import datetime + +import urllib.parse +import email.utils +import email.header + +from pathlib import Path +from typing import Optional, Tuple +from io import BytesIO + +logger = logging.getLogger(__name__) + +# Overridable via [patatt] parameters +GPGBIN = 'gpg' + +# Hardcoded defaults +DEVSIG_HDR = b'X-Developer-Signature' +REQ_HDRS = [b'from', b'subject'] +DEFAULT_CONFIG = { + 'publickeypath': ['ref::.keys', 'ref::.local-keys'], + 'gpgusedefaultkeyring': 'yes', +} + +# My version +__VERSION__ = '0.1.0' + + +class SigningError(Exception): + def __init__(self, message: str, errors: Optional[list] = None): + super().__init__(message) + self.errors = errors + + +class ValidationError(Exception): + def __init__(self, message: str, errors: Optional[list] = None): + super().__init__(message) + self.errors = errors + + +class ConfigurationError(Exception): + def __init__(self, message: str, errors: Optional[list] = None): + super().__init__(message) + self.errors = errors + + +def get_data_dir(): + if 'XDG_DATA_HOME' in os.environ: + datahome = os.environ['XDG_DATA_HOME'] + else: + datahome = os.path.join(str(Path.home()), '.local', 'share') + datadir = os.path.join(datahome, 'patatt') + Path(datadir).mkdir(parents=True, exist_ok=True) + return datadir + + +def _run_command(cmdargs: list, stdin: bytes = None, env: Optional[dict] = None) -> Tuple[int, bytes, bytes]: + sp = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + logger.debug('Running %s', ' '.join(cmdargs)) + (output, error) = sp.communicate(input=stdin) + return sp.returncode, output, error + + +def git_run_command(gitdir: Optional[str], args: list, stdin: Optional[bytes] = None, + env: Optional[dict] = None) -> Tuple[int, bytes, bytes]: + if gitdir: + env = {'GIT_DIR': gitdir} + + args = ['git', '--no-pager'] + args + return _run_command(args, stdin=stdin, env=env) + + +def get_config_from_git(regexp: str, section: Optional[str] = None, defaults: Optional[dict] = None): + args = ['config', '-z', '--get-regexp', regexp] + ecode, out, err = git_run_command(None, args) + if defaults is None: + defaults = dict() + + if not len(out): + return defaults + + gitconfig = defaults + out = out.decode() + + for line in out.split('\x00'): + if not line: + continue + key, value = line.split('\n', 1) + try: + chunks = key.split('.') + # Drop the starting part + chunks.pop(0) + cfgkey = chunks.pop(-1).lower() + if len(chunks): + if not section: + # Ignore it + continue + # We're in a subsection + sname = '.'.join(chunks) + if sname != section: + # Not our section + continue + elif section: + # We want config from a subsection specifically + continue + + if cfgkey in gitconfig: + # Multiple entries become lists + if isinstance(gitconfig[cfgkey], str): + gitconfig[cfgkey] = [gitconfig[cfgkey]] + if value not in gitconfig[cfgkey]: + gitconfig[cfgkey].append(value) + else: + gitconfig[cfgkey] = value + except ValueError: + logger.debug('Ignoring git config entry %s', line) + + return gitconfig + + +def gpg_run_command(cmdargs: list, stdin: bytes = None) -> Tuple[int, bytes, bytes]: + cmdargs = [GPGBIN, '--batch', '--no-auto-key-retrieve', '--no-auto-check-trustdb'] + cmdargs + return _run_command(cmdargs, stdin) + + +def check_gpg_status(status: bytes) -> Tuple[bool, bool, bool, str]: + good = False + valid = False + trusted = False + signtime = '' + + gs_matches = re.search(rb'^\[GNUPG:] GOODSIG ([0-9A-F]+)\s+(.*)$', status, flags=re.M) + if gs_matches: + good = True + vs_matches = re.search(rb'^\[GNUPG:] VALIDSIG ([0-9A-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M) + if vs_matches: + valid = True + signtime = vs_matches.groups()[2].decode() + ts_matches = re.search(rb'^\[GNUPG:] TRUST_(FULLY|ULTIMATE)', status, flags=re.M) + if ts_matches: + trusted = True + + return good, valid, trusted, signtime + + +def get_git_mailinfo(payload: bytes) -> Tuple[bytes, bytes, bytes]: + with tempfile.TemporaryDirectory(suffix='.git-mailinfo') as td: + mf = os.path.join(td, 'm') + pf = os.path.join(td, 'p') + cmdargs = ['git', 'mailinfo', '--encoding=utf-8', mf, pf] + ecode, out, err = _run_command(cmdargs, stdin=payload) + if ecode > 0: + logger.debug('FAILED : Failed running git-mailinfo:') + logger.debug(err.decode()) + raise RuntimeError('Failed to run git-mailinfo: %s' % err.decode()) + + with open(mf, 'rb') as mfh: + m = mfh.read() + with open(pf, 'rb') as pfh: + p = pfh.read() + return m, p, out + + +def is_signed(headers: list): + for header in headers: + try: + left, right = header.split(b':', 1) + if left.strip().lower() == DEVSIG_HDR.lower(): + return True + except ValueError: + continue + + return False + + +def parse_message(msgdata: bytes) -> Tuple[list, bytes]: + # We use simplest parsing -- using Python's email module would be overkill + headers = list() + with BytesIO(msgdata) as fh: + while True: + line = fh.readline() + if not len(line): + break + + if not len(line.strip()): + # Keep extra LF in headers so we don't have to track LF/CRLF endings + headers.append(line) + payload = fh.read() + break + + # is it a wrapped header? + if line[0] in ("\x09", "\x20", 0x09, 0x20): + if not len(headers): + raise RuntimeError('Not a valid RFC2822 message') + # attach it to the previous header + headers[-1] += line + continue + headers.append(line) + + return headers, payload + + +def get_mailinfo_message(oheaders: list, opayload: bytes, want_hdrs: list, + maxlen: Optional[int]) -> Tuple[list, bytes, str]: + # We pre-canonicalize using git mailinfo + # use whatever lf is used in the headers + origmsg = b''.join(oheaders) + opayload + m, p, i = get_git_mailinfo(origmsg) + # Generate a new payload using m and p and canonicalize with \r\n endings, + # trimming any excess blank lines ("simple" DKIM canonicalization). + cpayload = b'' + for line in re.sub(rb'[\r\n]*$', b'', m + p).split(b'\n'): + cpayload += re.sub(rb'[\r\n]*$', b'', line) + b'\r\n' + + if maxlen: + logger.debug('Limiting payload length to %d bytes', maxlen) + cpayload = cpayload[:maxlen] + + idata = dict() + for line in re.sub(rb'[\r\n]*$', b'', i).split(b'\n'): + left, right = line.split(b':', 1) + idata[left.lower()] = right.strip() + + # Theoretically, we should always see an "Email" line + identity = idata.get(b'email', b'').decode() + + # Now substituting headers returned by mailinfo + cheaders = list() + for oheader in oheaders: + try: + left, right = oheader.split(b':', 1) + lleft = left.lower() + if lleft not in want_hdrs: + continue + if lleft == b'from': + right = b' ' + idata.get(b'author', b'') + b' <' + idata.get(b'email', b'') + b'>' + elif lleft == b'subject': + right = b' ' + idata.get(b'subject', b'') + cheaders.append(left + b':' + right) + except ValueError: + cheaders.append(oheader) + + return cheaders, cpayload, identity + + +def splitter(longstr: bytes, limit: int = 78) -> bytes: + splitstr = list() + first = True + while len(longstr) > limit: + at = limit + if first: + first = False + at -= 2 + splitstr.append(longstr[:at]) + longstr = longstr[at:] + splitstr.append(longstr) + return b' '.join(splitstr) + + +def get_git_toplevel(gitdir: str = None) -> str: + cmdargs = ['git'] + if gitdir: + cmdargs += ['--git-dir', gitdir] + cmdargs += ['rev-parse', '--show-toplevel'] + ecode, out, err = _run_command(cmdargs) + if ecode == 0: + return out.decode().strip() + return '' + + +def get_parts_from_header(hval: bytes) -> dict: + hval = re.sub(rb'\s*', b'', hval) + hdata = dict() + for chunk in hval.split(b';'): + parts = chunk.split(b'=', 1) + if len(parts) < 2: + continue + hdata[parts[0].decode()] = parts[1] + return hdata + + +def dkim_canonicalize_header(hval: bytes) -> bytes: + # We only do relaxed for headers + # o Unfold all header field continuation lines as described in + # [RFC5322]; in particular, lines with terminators embedded in + # continued header field values (that is, CRLF sequences followed by + # WSP) MUST be interpreted without the CRLF. Implementations MUST + # NOT remove the CRLF at the end of the header field value. + hval = re.sub(rb'[\r\n]', b'', hval) + # o Convert all sequences of one or more WSP characters to a single SP + # character. WSP characters here include those before and after a + # line folding boundary. + hval = re.sub(rb'\s+', b' ', hval) + # o Delete all WSP characters at the end of each unfolded header field + # value. + # o Delete any WSP characters remaining before and after the colon + # separating the header field name from the header field value. The + # colon separator MUST be retained. + hval = hval.strip() + b'\r\n' + return hval + + +def make_pkey_path(keytype: str, identity: str, selector: str) -> str: + chunks = identity.split('@', 1) + if len(chunks) != 2: + raise ValidationError('identity must include both local and domain parts') + local = chunks[0].lower() + domain = chunks[1].lower() + selector = selector.lower() + # urlencode all potentially untrusted bits to make sure nobody tries path-based badness + keypath = os.path.join(urllib.parse.quote_plus(keytype), urllib.parse.quote_plus(domain), + urllib.parse.quote_plus(local), urllib.parse.quote_plus(selector)) + + return keypath + + +def get_public_key(source: str, keytype: str, identity: str, selector: str) -> Tuple[bytes, str]: + keypath = make_pkey_path(keytype, identity, selector) + + if source.find('ref:') == 0: + gittop = get_git_toplevel() + if not gittop: + raise RuntimeError('Not in a git tree, so cannot use a ref: source') + # format is: ref:refspec:path + # or it could omit the refspec, meaning "whatever the current ref" + # but it should always have at least two ":" + chunks = source.split(':', 2) + if len(chunks) < 3: + logger.debug('ref: sources must have refspec and path, e.g.: ref:refs/heads/master:.keys') + raise ConfigurationError('Invalid ref: source: %s' % source) + # grab the key from a fully ref'ed path + ref = chunks[1] + pathtop = chunks[2] + subpath = os.path.join(pathtop, keypath) + + if not ref: + # What is our current ref? + cmdargs = ['git', 'symbolic-ref', 'HEAD'] + ecode, out, err = _run_command(cmdargs) + if ecode == 0: + ref = out.decode().strip() + + cmdargs = ['git'] + keysrc = f'{ref}:{subpath}' + cmdargs += ['show', keysrc] + ecode, out, err = _run_command(cmdargs) + if ecode == 0: + logger.debug('KEYSRC : %s', keysrc) + return out, keysrc + + # Does it exist on disk in gittop? + fullpath = os.path.join(gittop, subpath) + if os.path.exists(fullpath): + with open(fullpath, 'rb') as fh: + logger.debug('KEYSRC : %s', fullpath) + return fh.read(), fullpath + + raise KeyError('Could not find %s in %s' % (subpath, ref)) + + # It's a direct path, then + fullpath = os.path.join(source, keypath) + if os.path.exists(fullpath): + with open(fullpath, 'rb') as fh: + logger.debug('Loaded key from %s', fullpath) + return fh.read(), fullpath + + raise KeyError('Could not find %s' % fullpath) + + +def make_devsig_header(headers: list, payload: bytes, algo: str, signtime: Optional[str] = None, + identity: Optional[str] = None, selector: Optional[str] = None, maxlen: Optional[int] = None, + want_hdrs: Optional[list] = None, strict: bool = False) -> Tuple[bytes, bytes]: + if not want_hdrs: + want_hdrs = REQ_HDRS + cheaders, cpayload, cidentity = get_mailinfo_message(headers, payload, want_hdrs, maxlen) + hashed = hashlib.sha256() + hashed.update(cpayload) + bh = base64.b64encode(hashed.digest()) + + hparts = [ + b'v=1', + b'a=%s-sha256' % algo.encode(), + ] + if (identity and strict) or (not strict and identity != cidentity): + hparts.append(b'i=%s' % identity.encode()) + + if selector: + hparts.append(b's=%s' % selector.encode()) + if signtime: + hparts.append(b't=%s' % signtime.encode()) + + hparts.append(b'h=%s' % b':'.join(want_hdrs)) + hparts.append(b'l=%d' % len(cpayload)) + hparts.append(b'bh=%s' % bh) + hparts.append(b'b=') + dshval = b'; '.join(hparts) + + hashed = hashlib.sha256() + for cheader in cheaders: + try: + left, right = cheader.split(b':', 1) + hname = left.strip().lower() + if hname not in want_hdrs: + continue + except ValueError: + continue + + hashed.update(hname + b':' + dkim_canonicalize_header(right)) + hashed.update(DEVSIG_HDR.lower() + b':' + dshval) + dshdr = DEVSIG_HDR + b': ' + dshval + + return dshdr, hashed.digest() + + +def get_devsig_header_info(headers) -> Tuple[Optional[str], str, str, str, list, dict]: + from_hdr = None + hdata = None + need_hdrs = [b'from', DEVSIG_HDR.lower()] + for header in headers: + try: + left, right = header.split(b':', 1) + hname = left.strip().lower() + # We want a "from" header and a DEVSIG_HDR + if hname not in need_hdrs: + continue + if hname == b'from': + from_hdr = right + continue + hval = dkim_canonicalize_header(right) + hdata = get_parts_from_header(hval) + except ValueError: + continue + + if hdata is None: + raise ValidationError('No "%s:" header in message' % DEVSIG_HDR.decode()) + + # make sure the required headers are in the sig + if 'h' not in hdata: + raise ValidationError('h= is required but is not present in %s' % DEVSIG_HDR.decode()) + + signed_hdrs = [x.strip() for x in hdata['h'].split(b':')] + for rhdr in REQ_HDRS: + if rhdr not in signed_hdrs: + raise ValidationError('%s is a required header' % rhdr.decode()) + + if 'i' not in hdata: + # Use the identity from the from header + if not from_hdr: + raise ValidationError('No i= in %s, and no From: header!' % DEVSIG_HDR.decode()) + parts = email.utils.parseaddr(from_hdr.decode()) + identity = parts[1] + else: + identity = hdata['i'].decode() + + if 'a' in hdata: + apart = hdata['a'].decode() + if apart.startswith('ed25519'): + algo = 'ed25519' + elif apart.startswith('openpgp'): + algo = 'openpgp' + else: + raise ValidationError('Unsupported a= in %s: %s' % (DEVSIG_HDR.decode(), apart)) + else: + # Default is ed25519-sha256 + algo = 'ed25519' + + if 's' in hdata: + selector = hdata['s'].decode() + else: + selector = 'default' + + if 't' in hdata: + signtime = hdata['t'].decode() + else: + signtime = None + + return signtime, identity, selector, algo, signed_hdrs, hdata + + +def sign_ed25519(headers: list, payload: bytes, keydata: str, + identity: Optional[str] = None, selector: Optional[str] = None) -> email.header.Header: + from nacl.signing import SigningKey + from nacl.encoding import Base64Encoder + + logger.debug('SIGNING : ED25519') + signtime = str(int(time.time())) + dshdr, digest = make_devsig_header(headers, payload, algo='ed25519', signtime=signtime, + identity=identity, selector=selector) + sk = SigningKey(keydata, encoder=Base64Encoder) + bdata = sk.sign(digest, encoder=Base64Encoder) + hhdr = email.header.make_header([(dshdr + splitter(bdata), 'us-ascii')], maxlinelen=78) + return hhdr + + +def validate_ed25519(sigdata: bytes, pubkey: bytes) -> bytes: + from nacl.signing import VerifyKey + from nacl.encoding import Base64Encoder + from nacl.exceptions import BadSignatureError + + vk = VerifyKey(pubkey, encoder=Base64Encoder) + try: + return vk.verify(sigdata, encoder=Base64Encoder) + except BadSignatureError: + raise ValidationError('Failed to validate signature') + + +def sign_openpgp(headers: list, payload: bytes, keyid: Optional[str], + identity: Optional[str] = None, selector: Optional[str] = None) -> email.header.Header: + logger.debug('SIGNING : OpenPGP') + # OpenPGP header includes signing time, so we don't need to include t= + dshdr, digest = make_devsig_header(headers, payload, algo='openpgp', identity=identity, selector=selector) + gpgargs = ['-s'] + if keyid: + gpgargs += ['-u', keyid] + ecode, out, err = gpg_run_command(gpgargs, digest) + if ecode > 0: + raise SigningError('Running gpg failed', errors=err.decode().split('\n')) + + bdata = base64.b64encode(out) + hhdr = email.header.make_header([(dshdr + splitter(bdata), 'us-ascii')], maxlinelen=78) + return hhdr + + +def validate_openpgp(sigdata: bytes, pubkey: Optional[bytes]) -> Tuple[bytes, tuple]: + bsigdata = base64.b64decode(sigdata) + vrfyargs = ['--verify', '--output', '-', '--status-fd=2'] + if pubkey: + with tempfile.TemporaryFile(suffix='.patch-attest-poc') as temp_keyring: + keyringargs = ['--no-default-keyring', f'--keyring={temp_keyring}'] + gpgargs = keyringargs + ['--status-fd=1', '--import'] + ecode, out, err = gpg_run_command(gpgargs, stdin=pubkey) + # look for IMPORT_OK + if out.find(b'[GNUPG:] IMPORT_OK') < 0: + raise ValidationError('Could not import GnuPG public key') + gpgargs = keyringargs + vrfyargs + ecode, out, err = gpg_run_command(gpgargs, stdin=bsigdata) + + else: + logger.debug('Verifying using default keyring') + ecode, out, err = gpg_run_command(vrfyargs, stdin=bsigdata) + + if ecode > 0: + raise ValidationError('Failed to validate PGP signature') + + good, valid, trusted, signtime = check_gpg_status(err) + if good and valid: + return out, (good, valid, trusted, signtime) + + raise ValidationError('Failed to validate PGP signature') + + +def _load_messages(cmdargs) -> dict: + import sys + if not sys.stdin.isatty(): + messages = {'-': sys.stdin.buffer.read()} + elif len(cmdargs.msgfile): + # Load all message from the files passed to make sure they all parse correctly + messages = dict() + for msgfile in cmdargs.msgfile: + with open(msgfile, 'rb') as fh: + messages[msgfile] = fh.read() + else: + logger.critical('ERROR: Pipe a message to sign or pass filenames with individual messages') + raise RuntimeError('Nothing to do') + + return messages + + +def cmd_sign(cmdargs, config: dict) -> None: + # Do we have the signingkey defined? + usercfg = get_config_from_git(r'user\..*') + if not config.get('identity') and usercfg.get('email'): + # Use user.email + config['identity'] = usercfg.get('email') + if not config.get('signingkey'): + if usercfg.get('signingkey'): + logger.warning('NOTICE: Using pgp key %s defined by user.signingkey', usercfg.get('signingkey')) + logger.warning(' Override by setting patatt.signingkey') + config['signingkey'] = 'openpgp:%s' % usercfg.get('signingkey') + else: + logger.critical('ERROR: patatt.signingkey is not set') + logger.critical(' Perhaps you need to run genkey first?') + sys.exit(1) + + messages = _load_messages(cmdargs) + + sk = config.get('signingkey') + if sk.startswith('ed25519:'): + _sign_func = sign_ed25519 + identifier = sk[8:] + keysrc = None + if identifier.startswith('/') and os.path.exists(identifier): + keysrc = identifier + else: + # datadir/private/%s.key + ddir = get_data_dir() + skey = os.path.join(ddir, 'private', '%s.key' % identifier) + if os.path.exists(skey): + keysrc = skey + else: + # finally, try .git/%s.key + gtdir = get_git_toplevel() + if gtdir: + skey = os.path.join(gtdir, '.git', '%s.key' % identifier) + if os.path.exists(skey): + keysrc = skey + + if not keysrc: + logger.critical('ERROR: Could not find the key matching %s', identifier) + sys.exit(1) + + logger.info('Using ed25519 key: %s', keysrc) + with open(keysrc, 'r') as fh: + keydata = fh.read() + + elif sk.startswith('openpgp:'): + _sign_func = sign_openpgp + keydata = sk[8:] + else: + logger.critical('Unknown key type: %s', sk) + sys.exit(1) + + for filename, msgdata in messages.items(): + headers, payload = parse_message(msgdata) + if is_signed(headers): + logger.critical('Already signed: %s', filename) + continue + + try: + hhdr = _sign_func(headers, payload, keydata, identity=config.get('identity', ''), + selector=config.get('selector', '')) + except SigningError as ex: + logger.critical('ERROR: %s', ex) + sys.exit(1) + + dshdr = hhdr.encode().encode() + # insert it before the blank line + lf = headers.pop(-1) + headers.append(dshdr + lf) + headers.append(lf) + payload = b''.join(headers) + payload + logger.debug('--- SIGNED MESSAGE STARTS ---') + logger.debug(payload) + if filename == '-': + sys.stdout.buffer.write(payload) + else: + with open(filename, 'wb') as fh: + fh.write(payload) + + logger.info('Signed: %s', filename) + + +def validate_message(msgdata: bytes, sources: list): + headers, payload = parse_message(msgdata) + + if not is_signed(headers): + raise ValidationError('message is not signed') + + signtime, identity, selector, algo, signed_hdrs, hdata = get_devsig_header_info(headers) + + pkey = None + keysrc = None + for source in sources: + try: + pkey, keysrc = get_public_key(source, algo, identity, selector) + break + except KeyError: + pass + + if not pkey and algo == 'ed25519': + raise ValidationError('no %s public key for %s/%s' % (algo, identity, selector)) + + sdigest = None + if algo == 'ed25519': + sdigest = validate_ed25519(hdata['b'], pkey) + # signtime is required for ed25519 signatures + signtime = hdata.get('t', b'').decode() + if not signtime: + raise ValidationError('signature does not include t= signing time') + elif algo == 'openpgp': + sdigest, signtime = validate_openpgp(hdata['b'], pkey) + + if not sdigest: + raise ValidationError('faled to verify %s signature for %s/%s' % (algo, identity, selector)) + + # Now calculate our own digest and compare + dshdr, digest = make_devsig_header(headers, payload, algo, signtime=hdata.get('t', b'').decode(), + identity=hdata.get('i', b'').decode(), + selector=hdata.get('s', b'').decode(), want_hdrs=signed_hdrs, + strict=True) + if sdigest == digest: + return signtime, identity, selector, algo, keysrc + + raise ValidationError('failed to verify message content') + + +def cmd_validate(cmdargs, config: dict): + messages = _load_messages(cmdargs) + ddir = get_data_dir() + pdir = os.path.join(ddir, 'public') + sources = config.get('publickeypath', list()) + if pdir not in sources: + sources.append(pdir) + + for filename, msgdata in messages.items(): + try: + signtime, identity, selector, algo, pkey = validate_message(msgdata, sources) + logger.critical('PASS: %s', os.path.basename(filename)) + logger.info(' by : %s (%s)', identity, algo) + if pkey: + logger.info(' key: %s', pkey) + else: + logger.info(' key: in default GnuPG keyring') + except ValidationError as ex: + logger.critical('FAIL: %s', os.path.basename(filename)) + logger.critical(' err: %s', ex) + + +def cmd_gen(cmdargs, config: dict) -> None: + try: + from nacl.signing import SigningKey + except ModuleNotFoundError: + raise RuntimeError('This operation requires PyNaCl libraries') + + # Do we have the signingkey defined? + usercfg = get_config_from_git(r'user\..*') + if not config.get('identity') and usercfg.get('email'): + # Use user.email + config['identity'] = usercfg.get('email') + + identifier = cmdargs.keyname + if not identifier: + identifier = datetime.datetime.today().strftime('%Y%m%d') + + ddir = get_data_dir() + sdir = os.path.join(ddir, 'private') + pdir = os.path.join(ddir, 'public') + if not os.path.exists(sdir): + os.mkdir(sdir, mode=0o0700) + if not os.path.exists(pdir): + os.mkdir(pdir, mode=0o0755) + skey = os.path.join(sdir, '%s.key' % identifier) + pkey = os.path.join(pdir, '%s.pub' % identifier) + # Do we have a key with this identifier already present? + if os.path.exists(skey) and not cmdargs.force: + logger.critical('Key already exists: %s', skey) + logger.critical('Use a different -n or pass -f to overwrite it') + raise RuntimeError('Key already exists') + + logger.info('Generating a new ed25519 keypair') + newkey = SigningKey.generate() + + # Make sure we write it as 0600 + def priv_opener(path, flags): + return os.open(path, flags, 0o0600) + + with open(skey, 'wb', opener=priv_opener) as fh: + fh.write(base64.b64encode(bytes(newkey))) + logger.info('Wrote: %s', skey) + + with open(pkey, 'wb') as fh: + fh.write(base64.b64encode(bytes(newkey.verify_key))) + logger.info('Wrote: %s', pkey) + + # Also copy it into our local keyring + dpkey = os.path.join(pdir, make_pkey_path('ed25519', config.get('identity'), 'default')) + Path(os.path.dirname(dpkey)).mkdir(parents=True, exist_ok=True) + if not os.path.exists(dpkey): + with open(dpkey, 'wb') as fh: + fh.write(base64.b64encode(bytes(newkey.verify_key))) + logger.info('Wrote: %s', dpkey) + else: + spkey = os.path.join(pdir, make_pkey_path('ed25519', config.get('identity'), identifier)) + with open(spkey, 'wb') as fh: + fh.write(base64.b64encode(bytes(newkey.verify_key))) + logger.info('Wrote: %s', spkey) + + logger.info('Add the following to your .git/config (or global ~/.gitconfig):') + logger.info('---') + if cmdargs.section: + logger.info('[patatt "%s"]', cmdargs.section) + else: + logger.info('[patatt]') + logger.info(' signingkey = ed25519:%s', identifier) + logger.info('---') + logger.info('Next, communicate the contents of the following file to the') + logger.info('repository keyring maintainers for inclusion into the project:') + logger.info(pkey) + + +def command() -> None: + import argparse + # noinspection PyTypeChecker + parser = argparse.ArgumentParser( + prog='patatt', + description='Cryptographically attest patches before sending out', + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument('-q', '--quiet', action='store_true', default=False, + help='Only output errors to the stdout') + parser.add_argument('-d', '--debug', action='store_true', default=False, + help='Show debugging output') + parser.add_argument('-s', '--section', dest='section', default=None, + help='Use config section [patatt "sectionname"]') + + subparsers = parser.add_subparsers(help='sub-command help', dest='subcmd') + + sp_sign = subparsers.add_parser('sign', help='Cryptographically attest an RFC2822 message') + sp_sign.add_argument('msgfile', nargs='*', help='RFC2822 message files to sign') + sp_sign.set_defaults(func=cmd_sign) + + sp_val = subparsers.add_parser('validate', help='Validate a devsig-signed message') + sp_val.add_argument('msgfile', nargs='*', help='Signed RFC2822 message files to validate') + sp_val.set_defaults(func=cmd_validate) + + sp_gen = subparsers.add_parser('genkey', help='Generate a new ed25519 keypair') + sp_gen.add_argument('-n', '--keyname', default=None, + help='Name to use for the key, e.g. "workstation", or "default"') + sp_gen.add_argument('-f', '--force', action='store_true', default=False, + help='Overwrite any existing keys, if found') + sp_gen.set_defaults(func=cmd_gen) + + _args = parser.parse_args() + + logger.setLevel(logging.DEBUG) + + ch = logging.StreamHandler() + formatter = logging.Formatter('%(message)s') + ch.setFormatter(formatter) + + if _args.quiet: + ch.setLevel(logging.CRITICAL) + elif _args.debug: + ch.setLevel(logging.DEBUG) + else: + ch.setLevel(logging.INFO) + + logger.addHandler(ch) + config = get_config_from_git(r'patatt\..*', section=_args.section, defaults=DEFAULT_CONFIG) + + if 'func' not in _args: + parser.print_help() + sys.exit(1) + + try: + _args.func(_args, config) + except RuntimeError: + sys.exit(1) + + +if __name__ == '__main__': + command() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ad05ad --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyNaCl diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..3eb5a8f --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import os +import re +from setuptools import setup + +# Utility function to read the README file. +# Used for the long_description. It's nice, because now 1) we have a top level +# README file and 2) it's easier to type in the README file than to put a raw +# string in below ... + + +def read(fname): + return open(os.path.join(os.path.dirname(__file__), fname)).read() + + +def find_version(source): + version_file = read(source) + version_match = re.search(r"^__VERSION__ = ['\"]([^'\"]*)['\"]", version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +NAME = 'patatt' + +setup( + version=find_version('patatt/__init__.py'), + url='https://git.kernel.org/pub/scm/utils/patatt/patatt.git/about/', + name=NAME, + description='A simple library to add cryptographic attestation to patches sent via email', + author='Konstantin Ryabitsev', + author_email='mricon@kernel.org', + packages=['patatt'], + license='MIT-0', + long_description=read('README'), + long_description_content_type='text/x-rst', + keywords=['git', 'patches', 'attestation'], + install_requires=[ + 'pynacl', + ], + python_requires='>=3.6', + entry_points={ + 'console_scripts': [ + 'patatt=patatt:command' + ], + }, +) |