aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKonstantin Ryabitsev <konstantin@linuxfoundation.org>2021-05-03 17:41:43 -0400
committerKonstantin Ryabitsev <konstantin@linuxfoundation.org>2021-05-03 17:41:43 -0400
commit06887d47ff183d0811823beb19573f52b2878992 (patch)
tree5ef8e2dbf1f654caa9d377a8087b91001f5a8999
downloadpatatt-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--.gitignore9
-rw-r--r--.keys/openpgp/linuxfoundation.org/konstantin/default367
-rw-r--r--COPYING18
-rw-r--r--MANIFEST.in2
-rw-r--r--README1
-rw-r--r--patatt/__init__.py868
-rw-r--r--requirements.txt1
-rw-r--r--setup.py48
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-----
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..77085e6
--- /dev/null
+++ b/COPYING
@@ -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
diff --git a/README b/README
new file mode 100644
index 0000000..fced116
--- /dev/null
+++ b/README
@@ -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'
+ ],
+ },
+)