blob: 4f84adbbc0e4ac0a85a05136e8fe81393457b301 [file]
#!/usr/bin/env python3
import sys
import os
import base64
import email
import email.header
import b4
import time
import dkim
import re
import dns.resolver
import binascii
import subprocess
import hashlib
import requests
import anybase32
import urllib.parse
from nacl.signing import SigningKey, VerifyKey
from nacl.encoding import Base64Encoder
from nacl.exceptions import BadSignatureError
from tempfile import mkstemp
from Cryptodome.Signature import pkcs1_15
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
XPH_HDR = 'X-Patch-Hashes'
XPS_HDR = 'X-Patch-Sig'
def _run_command(cmdargs, stdin=None):
sp = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
(output, error) = sp.communicate(input=stdin)
return sp.returncode, output, error
def gpg_run_command(cmdargs, stdin=None):
gpgbin = 'gpg'
cmdargs = [gpgbin, '--batch', '--no-auto-key-retrieve', '--no-auto-check-trustdb'] + cmdargs
return _run_command(cmdargs, stdin)
def get_dkim_key(domain, selector, wantkey='rsa', timeout=5):
name = f'{selector}._domainkey.{domain}.'
print(f'DNS-lookup: {name}')
keydata = None
try:
a = dns.resolver.resolve(name, dns.rdatatype.TXT, raise_on_no_answer=False, lifetime=timeout)
# Find v=DKIM1
for r in a.response.answer:
for item in r.items:
for s in item.strings:
if s.find(b'v=DKIM1') >= 0:
keydata = s.decode()
if keydata.find(wantkey) >= 0:
break
keydata = None
if keydata:
break
if keydata:
break
except dns.resolver.NXDOMAIN:
print(f'Domain {name} does not exist')
sys.exit(1)
pass
if not keydata:
print(f'Domain {name} does not contain a DKIM record')
sys.exit(1)
parts = get_parts_from_header(keydata)
if 'p' not in parts:
print(f'Domain {name} does not contain a DKIM key')
sys.exit(1)
return parts['p']
def get_wk_key(domain, selector, timeout=5):
wkurl = f'https://{domain}/.well-known/_domainkey/{selector}.txt'
print(f'Retrieving: {wkurl}')
res = requests.get(wkurl, timeout=timeout)
if res.status_code != 200:
print(f'Could not get {wkurl}')
sys.exit(1)
keydata = res.content.decode().strip()
parts = get_parts_from_header(keydata)
return parts['p']
def get_wkd_key(domain, identity, selector, timeout=5):
identity = identity.split('@')[0]
# Attempt to load from local git dir if we are in a git dir
cmdargs = ['git', 'rev-parse', '--show-toplevel']
ecode, out, err = _run_command(cmdargs)
if ecode == 0:
# urlencode domain/identity/selector to make sure nobody tries path-based badness
subpath = os.path.join(out.decode().strip(), '.devkey',
urllib.parse.quote_plus(domain),
urllib.parse.quote_plus(identity),
urllib.parse.quote_plus(selector) + '.txt',
)
try:
with open(subpath) as fh:
print(f'Loading WKD key from {subpath}')
return fh.readline().strip()
except IOError:
pass
# We use zbase32 because this is what OpenPGP uses
# I'm not convinced this is a sane choice
i = hashlib.sha1(identity.lower().encode()).digest()
zdir = anybase32.encode(i, anybase32.ZBASE32).decode()
wkurl = f'https://{domain}/.well-known/devkey/{zdir}/{selector}.txt'
print(f'Retrieving: {wkurl}')
res = requests.get(wkurl, timeout=timeout)
if res.status_code != 200:
print(f'Could not get {wkurl}')
sys.exit(1)
# For POC purposes, we are not doing caching or TOFU management,
# but this would be a required part of actual implementation
return res.content.decode().strip()
def get_b64_attestation(msg):
# b4 stores these as hexdigests, but it's more natural
# for email headers to use b64 encoded values, if only
# to save a few bytes of space
lmsg = b4.LoreMessage(msg)
lmsg.load_hashes()
att = lmsg.attestation
i = base64.b64encode(binascii.unhexlify(att.i)).decode()
m = base64.b64encode(binascii.unhexlify(att.m)).decode()
p = base64.b64encode(binascii.unhexlify(att.p)).decode()
return i, m, p
def add_hashes_header(msg):
hhdr = gen_hashes_header(msg)
msg[XPH_HDR] = hhdr
return msg
def gen_hashes_header(msg):
i, m, p = get_b64_attestation(msg)
# Hardcode to sha256 for the purposes of the POC
hparts = [
'v=1',
'h=sha256',
f'i={i}',
f'm={m}',
f'p={p}',
]
hval = '; '.join(hparts)
hhdr = email.header.make_header([(hval.encode(), 'us-ascii')], maxlinelen=78)
return hhdr
def get_parts_from_header(hstr):
hstr = re.sub(r'\s*', '', hstr)
hdata = dict()
for chunk in hstr.split(';'):
parts = chunk.split('=', 1)
if len(parts) < 2:
continue
hdata[parts[0]] = parts[1]
return hdata
def dkim_canonicalize_header(hname, hval):
hname = hname.lower()
hval = hval.strip()
hval = re.sub(r'\n', '', hval)
hval = re.sub(r'\s+', ' ', hval)
return hname, hval
def verify_attestation_hashes(msg):
hhdr = msg.get(XPH_HDR)
hdata = get_parts_from_header(str(hhdr))
adata = dict()
desc = {
'i': 'metadata',
'm': 'commit message',
'p': 'diff content',
}
adata['i'], adata['m'], adata['p'] = get_b64_attestation(msg)
for part, what in desc.items():
if hdata[part] == adata[part]:
status = 'PASS'
else:
status = 'FAIL'
print(f'{status} | {desc[part]}')
def cmd_hashes_hdr(msg):
hhdr = gen_hashes_header(msg)
print(hhdr.encode())
def cmd_dkim_sign(msg):
# We use dkimpy here as a demonstration of an external DKIM compliant
# implementation generating the DKIM-Signature tag, complete with bh=
# body hash that we don't consider for our purposes:
# - it will most certainly get mangled by mailing-list software
# - if it's canonicalized with "relaxed", we can no longer consider
# patch content to be trusted, as "relaxed" canonicalization modifies
# whitespace, which allows sneaking in maliciously modified patches
# in languages with syntactic whitespace.
msg = add_hashes_header(msg)
selector = b'example'
domain = b'example.org'
include_headers = [b'from', b'date', b'x-patch-hashes']
with open('rsa.key', 'rb') as fh:
privkey = fh.read()
dk = dkim.DKIM(msg.as_bytes())
bhdr = dk.sign(selector, domain, privkey, include_headers=include_headers)
dhdr = bhdr.decode()
msg['DKIM-Signature'] = dhdr.split(':', 1)[1]
print(msg.as_string())
def cmd_dkim_verify(msg):
# We don't use dkimpy to verify, as it will force verification of bh=, which
# we intentionally choose not to use for reasons listed above.
# However, our implementation is simplistic and doesn't cover many aspects
# of the DKIM standard, so real-life implementations need to consider using
# feature-complete DKIM verification tools, assuming they allow ignoring the
# bh= field.
dks = msg.get('dkim-signature')
ddata = get_parts_from_header(dks)
pk = base64.b64decode(get_dkim_key(ddata['d'], ddata['s']))
sig = base64.b64decode(ddata['b'])
headers = list()
for header in ddata['h'].split(':'):
# For the POC, we assume 'relaxed/'
hval = msg.get(header)
if hval is None:
# Missing headers are omitted by the DKIM RFC
continue
hname, hval = dkim_canonicalize_header(header, str(msg.get(header)))
headers.append(f'{hname}:{hval}')
# Now we add the dkim-signature header itself, without b= content
dname, dval = dkim_canonicalize_header('dkim-signature', dks)
dval = dval.rsplit('; b=')[0] + '; b='
headers.append(f'{dname}:{dval}')
payload = ('\r\n'.join(headers)).encode()
key = RSA.import_key(pk)
hashed = SHA256.new(payload)
try:
# noinspection PyTypeChecker
pkcs1_15.new(key).verify(hashed, sig)
except (ValueError, TypeError):
print('The DKIM signature did NOT verify!')
sys.exit(1)
print(f'DKIM signature verified for d={ddata["i"]} s={ddata["s"]}')
verify_attestation_hashes(msg)
def cmd_pgp_sign(msg, identity='mricon@kernel.org', selector='E63EDCA9329DD07E'):
# selector is the key id of the [C] public key, e.g. 0xE63EDCA9329DD07E.
# identity is the UID we should look for on the key
# The signature can be made with a subkey, so we embed the key ID into
# the header for lookup convenience.
# We don't embed signing time, as it's part of the PGP signature.
headers = list()
hhdr = gen_hashes_header(msg)
hhname, hhval = dkim_canonicalize_header(XPH_HDR, hhdr.encode())
headers.append(f'{hhname}:{hhval}')
hparts = [
'm=pgp',
f'i={identity}',
f's=0x{selector}',
'b=',
]
shname, shval = dkim_canonicalize_header(XPS_HDR, '; '.join(hparts))
headers.append(f'{shname}:{shval}')
payload = '\r\n'.join(headers).encode()
gpgargs = ['-b', '-u', selector]
ecode, out, err = gpg_run_command(gpgargs, payload)
if ecode > 0:
print('Running gpg failed')
print(err.decode())
sys.exit(ecode)
bdata = base64.b64encode(out)
shval += bdata.decode()
shdr = email.header.make_header([(shval.encode(), 'us-ascii')], maxlinelen=78)
msg[XPS_HDR] = shdr
print(msg.as_string())
def cmd_pgp_verify(msg):
# For the POC purposes, we ignore key lookup and assume it's in the keyring
shdr = msg.get(XPS_HDR)
sdata = get_parts_from_header(shdr)
sig = base64.b64decode(sdata['b'])
headers = list()
hhname, hhval = dkim_canonicalize_header(XPH_HDR, str(msg.get(XPH_HDR)))
headers.append(f'{hhname}:{hhval}')
# Now we add the sig header itself, without b= content
shname, shval = dkim_canonicalize_header(XPS_HDR, shdr)
shval = shval.rsplit('; b=')[0] + '; b='
headers.append(f'{shname}:{shval}')
payload = ('\r\n'.join(headers)).encode()
# We can't pass both the detached sig and the content on stdin, so
# use a temporary file
savefile = mkstemp('attpoc-pgp-verify')[1]
with open(savefile, 'wb') as fh:
fh.write(sig)
gpgargs = ['--verify', '--status-fd=1', savefile, '-']
ecode, out, err = gpg_run_command(gpgargs, stdin=payload)
os.unlink(savefile)
if ecode > 0:
print('PGP signature failed to verify')
print(err.decode())
sys.exit(1)
output = out.decode()
# We're looking for GOODSIG and VALIDSIG
# For the purposes of this POC, we're not doing the following, but normally would:
# - check UIDs to make sure that they match From:
# - check signature date to make sure its drift from Date:
# - check TRUST_ to make sure it's sufficiently trusted key
gs_matches = re.search(r'^\[GNUPG:] GOODSIG ([0-9A-F]+)\s+(.*)$', output, re.M)
vs_matches = re.search(r'^\[GNUPG:] VALIDSIG ([0-9A-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', output, re.M)
if gs_matches and vs_matches:
print('PGP signature verified: %s' % gs_matches.groups()[1])
verify_attestation_hashes(msg)
def cmd_dk_sign(msg, domain='kernel.org', identity='@kernel.org', selector='patches', keyfile='dk.key', mode='dk'):
# selector is the same as in DKIM, the leftmost part of foo._domainkey.example.org
# identity should match domain in from
headers = list()
hhdr = gen_hashes_header(msg)
msg[XPH_HDR] = hhdr
hhname, hhval = dkim_canonicalize_header(XPH_HDR, hhdr.encode())
headers.append(f'{hhname}:{hhval}')
signtime = str(int(time.time()))
hparts = [
f'm={mode}',
f'd={domain}',
f'i={identity}',
f's={selector}',
f't={signtime}',
'a=ed25519-sha256',
'b=',
]
shname, shval = dkim_canonicalize_header(XPS_HDR, '; '.join(hparts))
headers.append(f'{shname}:{shval}')
payload = '\r\n'.join(headers).encode()
hashed = hashlib.sha256()
hashed.update(payload)
try:
with open(keyfile, 'r') as fh:
sk = SigningKey(fh.read(), encoder=Base64Encoder)
except IOError:
print('Could not open %s' % keyfile)
sys.exit(1)
bdata = sk.sign(hashed.digest(), encoder=Base64Encoder)
shval += bdata.decode()
shdr = email.header.make_header([(shval.encode(), 'us-ascii')], maxlinelen=78)
msg[XPS_HDR] = shdr
print(msg.as_string())
def cmd_dk_verify(msg, mode='dk'):
shdr = msg.get(XPS_HDR)
sdata = get_parts_from_header(shdr)
headers = list()
hhname, hhval = dkim_canonicalize_header(XPH_HDR, str(msg.get(XPH_HDR)))
headers.append(f'{hhname}:{hhval}')
# Now we add the sig header itself, without b= content
shname, shval = dkim_canonicalize_header(XPS_HDR, shdr)
shval = shval.rsplit('; b=')[0] + '; b='
headers.append(f'{shname}:{shval}')
payload = ('\r\n'.join(headers)).encode()
hashed = hashlib.sha256()
hashed.update(payload)
if mode == 'dk':
pk = get_dkim_key(sdata['d'], sdata['s'], wantkey='ed25519')
elif mode == 'wk':
pk = get_wk_key(sdata['d'], sdata['s'])
elif mode == 'wkd':
pk = get_wkd_key(sdata['d'], sdata['i'], sdata['s'])
else:
print(f'Unknown mode: {mode}')
sys.exit(1)
mode = mode.upper()
vk = VerifyKey(pk, encoder=Base64Encoder)
try:
foo = vk.verify(sdata['b'].encode(), encoder=Base64Encoder)
except BadSignatureError:
print(f'{mode} signature verification FAILED for: d={sdata["d"]}, i={sdata["i"]}, s={sdata["s"]}')
sys.exit(1)
if foo != hashed.digest():
print(f'{mode} signature verification FAILED for: d={sdata["d"]}, i={sdata["i"]}, s={sdata["s"]}')
sys.exit(1)
print(f'{mode} signature verified for: d={sdata["d"]}, i={sdata["i"]}, s={sdata["s"]}')
verify_attestation_hashes(msg)
def cmd_wk_sign(msg, domain='kernel.org', identity='@kernel.org', selector='patches', keyfile='dk.key'):
cmd_dk_sign(msg, domain=domain, identity=identity, selector=selector, keyfile=keyfile, mode='wk')
def cmd_wk_verify(msg):
cmd_dk_verify(msg, mode='wk')
def cmd_wkd_sign(msg, domain='kernel.org', identity='dev@kernel.org', selector='patches', keyfile='ingit.key'):
cmd_dk_sign(msg, domain=domain, identity=identity, selector=selector, keyfile=keyfile, mode='wkd')
def cmd_wkd_verify(msg):
cmd_dk_verify(msg, mode='wkd')
def cmd_verify(msg):
# do we have a hashes header?
if not msg.get(XPH_HDR):
print(f'Message does not contain {XPH_HDR}, nothing to verify')
sys.exit(1)
# do we have a sig header?
shdr = msg.get(XPS_HDR)
if not shdr:
if not msg.get('dkim-signature'):
print(f'Message contains unsigned hashes, cannot verify')
sys.exit(1)
# Run as a plain dkim verification
cmd_dkim_verify(msg)
sys.exit(0)
sdata = get_parts_from_header(shdr)
if sdata['m'] == 'pgp':
cmd_pgp_verify(msg)
sys.exit(0)
if sdata['m'] == 'dk':
cmd_dk_verify(msg)
sys.exit(0)
if sdata['m'] == 'wk':
cmd_wk_verify(msg)
sys.exit(0)
if sdata['m'] == 'wkd':
cmd_wkd_verify(msg)
sys.exit(0)
print(f'Unknown mode: {sdata["m"]}')
sys.exit(1)
if __name__ == '__main__':
message = sys.stdin.buffer.read()
if not message:
print('Pass message on stdin')
sys.exit(1)
orig_msg = email.message_from_bytes(message)
commands = {
'hashes-hdr': cmd_hashes_hdr,
'dkim-sign': cmd_dkim_sign,
'pgp-sign': cmd_pgp_sign,
'dk-sign': cmd_dk_sign,
'wk-sign': cmd_wk_sign,
'wkd-sign': cmd_wkd_sign,
'verify': cmd_verify,
}
command = None
if len(sys.argv) > 1:
command = sys.argv[1]
if command not in commands:
print(f'Unknown command: {command}')
print('Known commands:')
for goodcmd in commands.keys():
print(f' {goodcmd}')
sys.exit(1)
execfunc = commands[command]
execfunc(orig_msg)