#! @PYTHON@ ### -*-python-*- ### ### Key management and distribution ### ### (c) 2006 Straylight/Edgeware ### ###----- Licensing notice --------------------------------------------------- ### ### This file is part of Trivial IP Encryption (TrIPE). ### ### TrIPE is free software; you can redistribute it and/or modify ### it under the terms of the GNU General Public License as published by ### the Free Software Foundation; either version 2 of the License, or ### (at your option) any later version. ### ### TrIPE is distributed in the hope that it will be useful, ### but WITHOUT ANY WARRANTY; without even the implied warranty of ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ### GNU General Public License for more details. ### ### You should have received a copy of the GNU General Public License ### along with TrIPE; if not, write to the Free Software Foundation, ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. ###-------------------------------------------------------------------------- ### External dependencies. import catacomb as C import os as OS import sys as SYS import re as RX import getopt as O import shutil as SH import time as T import filecmp as FC from cStringIO import StringIO from errno import * from stat import * ###-------------------------------------------------------------------------- ### Useful regular expressions ## Match a comment or blank line. rx_comment = RX.compile(r'^\s*(#|$)') ## Match a KEY = VALUE assignment. rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$') ## Match a ${KEY} substitution. rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}') ## Match a @TAG@ substitution. rx_atsubst = RX.compile(r'@([-\w]+)@') ## Match a single non-alphanumeric character. rx_nonalpha = RX.compile(r'\W') ## Match the literal string "". rx_seq = RX.compile(r'\') ## Match a shell metacharacter. rx_shmeta = RX.compile('[\\s`!"#$&*()\\[\\];\'|<>?\\\\]') ## Match a character which needs escaping in a shell double-quoted string. rx_shquote = RX.compile(r'["`$\\]') ###-------------------------------------------------------------------------- ### Utility functions. ## Exceptions. class SubprocessError (Exception): pass class VerifyError (Exception): pass ## Program name and identification. quis = OS.path.basename(SYS.argv[0]) PACKAGE = "@PACKAGE@" VERSION = "@VERSION@" def moan(msg): """Report MSG to standard error.""" SYS.stderr.write('%s: %s\n' % (quis, msg)) def die(msg, rc = 1): """Report MSG to standard error, and exit with code RC.""" moan(msg) SYS.exit(rc) def subst(s, rx, map): """ Substitute values into a string. Repeatedly match RX (a compiled regular expression) against the string S. For each match, extract group 1, and use it as a key to index the MAP; replace the match by the result. Finally, return the fully-substituted string. """ out = StringIO() i = 0 for m in rx.finditer(s): out.write(s[i:m.start()] + map[m.group(1)]) i = m.end() out.write(s[i:]) return out.getvalue() def shell_quotify(arg): """ Quotify ARG to keep the shell happy. This isn't actually used for invoking commands, just for presentation purposes; but correctness is still nice. """ if not rx_shmeta.search(arg): return arg elif arg.find("'") == -1: return "'%s'" % arg else: return '"%s"' % rx_shquote.sub(lambda m: '\\' + m.group(0), arg) def rmtree(path): """Delete the directory tree given by PATH.""" try: st = OS.lstat(path) except OSError, err: if err.errno == ENOENT: return raise if not S_ISDIR(st.st_mode): OS.unlink(path) else: cwd = OS.getcwd() try: OS.chdir(path) for i in OS.listdir('.'): rmtree(i) finally: OS.chdir(cwd) OS.rmdir(path) def zap(file): """Delete the named FILE if it exists; otherwise do nothing.""" try: OS.unlink(file) except OSError, err: if err.errno == ENOENT: return raise def run(args): """ Run a subprocess whose arguments are given by the string ARGS. The ARGS are split at word boundaries, and then subjected to configuration variable substitution (see conf_subst). Individual argument elements beginning with `!' are split again into multiple arguments at word boundaries. """ args = map(conf_subst, args.split()) nargs = [] for a in args: if len(a) > 0 and a[0] != '!': nargs += [a] else: nargs += a[1:].split() args = nargs print '+ %s' % ' '.join([shell_quotify(arg) for arg in args]) SYS.stdout.flush() rc = OS.spawnvp(OS.P_WAIT, args[0], args) if rc != 0: raise SubprocessError, rc def hexhyphens(bytes): """ Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary. """ out = StringIO() for i in xrange(0, len(bytes)): if i > 0 and i % 4 == 0: out.write('-') out.write('%02x' % ord(bytes[i])) return out.getvalue() def fingerprint(kf, ktag): """ Compute the fingerprint of a key, using the user's selected hash. KF is the name of a keyfile; KTAG is the tag of the key. """ h = C.gchashes[conf['fingerprint-hash']]() k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret') return h.done() ###-------------------------------------------------------------------------- ### The configuration file. ## Exceptions. class ConfigFileError (Exception): pass ## The configuration dictionary. conf = {} def conf_subst(s): """ Apply configuration substitutions to S. That is, for each ${KEY} in S, replace it with the current value of the configuration variable KEY. """ return subst(s, rx_dollarsubst, conf) def conf_read(f): """ Read the file F and insert assignments into the configuration dictionary. """ lno = 0 for line in file(f): lno += 1 if rx_comment.match(line): continue if line[-1] == '\n': line = line[:-1] match = rx_keyval.match(line) if not match: raise ConfigFileError, "%s:%d: bad line `%s'" % (f, lno, line) k, v = match.groups() conf[k] = conf_subst(v) def conf_defaults(): """ Apply defaults to the configuration dictionary. Fill in all the interesting configuration variables based on the existing contents, as described in the manual. """ for k, v in [('repos-base', 'tripe-keys.tar.gz'), ('sig-base', 'tripe-keys.sig-'), ('repos-url', '${base-url}${repos-base}'), ('sig-url', '${base-url}${sig-base}'), ('sig-file', '${base-dir}${sig-base}'), ('repos-file', '${base-dir}${repos-base}'), ('conf-file', '${base-dir}tripe-keys.conf'), ('upload-hook', ': run upload hook'), ('kx', 'dh'), ('kx-param', lambda: {'dh': '-LS -b3072 -B256', 'ec': '-Cnist-p256'}[conf['kx']]), ('kx-expire', 'now + 1 year'), ('kx-warn-days', '28'), ('cipher', 'rijndael-cbc'), ('hash', 'sha256'), ('master-keygen-flags', '-l'), ('mgf', '${hash}-mgf'), ('mac', lambda: '%s-hmac/%d' % (conf['hash'], C.gchashes[conf['hash']].hashsz * 4)), ('sig', lambda: {'dh': 'dsa', 'ec': 'ecdsa'}[conf['kx']]), ('sig-fresh', 'always'), ('sig-genalg', lambda: {'kcdsa': 'dh', 'dsa': 'dsa', 'rsapkcs1': 'rsa', 'rsapss': 'rsa', 'ecdsa': 'ec', 'eckcdsa': 'ec'}[conf['sig']]), ('sig-param', lambda: {'dh': '-LS -b3072 -B256', 'dsa': '-b3072 -B256', 'ec': '-Cnist-p256', 'rsa': '-b3072'}[conf['sig-genalg']]), ('sig-hash', '${hash}'), ('sig-expire', 'forever'), ('fingerprint-hash', '${hash}')]: try: if k in conf: continue if type(v) == str: conf[k] = conf_subst(v) else: conf[k] = v() except KeyError, exc: if len(exc.args) == 0: raise conf[k] = '' % exc.args[0] ###-------------------------------------------------------------------------- ### Key-management utilities. def master_keys(): """ Iterate over the master keys. """ if not OS.path.exists('master'): return for k in C.KeyFile('master').itervalues(): if (k.type != 'tripe-keys-master' or k.expiredp or not k.tag.startswith('master-')): continue #?? yield k def master_sequence(k): """ Return the sequence number of the given master key as an integer. No checking is done that K is really a master key. """ return int(k.tag[7:]) def max_master_sequence(): """ Find the master key with the highest sequence number and return this sequence number. """ seq = -1 for k in master_keys(): q = master_sequence(k) if q > seq: seq = q return seq def seqsubst(x, q): """ Return the value of the configuration variable X, with replaced by the value Q. """ return rx_seq.sub(str(q), conf[x]) ###-------------------------------------------------------------------------- ### Commands: help [COMMAND...] def version(fp = SYS.stdout): fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION)) def usage(fp): fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis) def cmd_help(args): if len(args) == 0: version(SYS.stdout) print usage(SYS.stdout) print """ Key management utility for TrIPE. Options supported: -h, --help Show this help message. -v, --version Show the version number. -u, --usage Show pointlessly short usage string. Subcommands available: """ args = commands.keys() args.sort() for c in args: try: func, min, max, help = commands[c] except KeyError: die("unknown command `%s'" % c) print '%s%s%s' % (c, help and ' ', help) ###-------------------------------------------------------------------------- ### Commands: newmaster def cmd_newmaster(args): seq = max_master_sequence() + 1 run('''key -kmaster add -a${sig-genalg} !${sig-param} -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master sig=${sig} hash=${sig-hash}''' % seq) run('key -kmaster extract -f-secret repos/master.pub') ###-------------------------------------------------------------------------- ### Commands: setup def cmd_setup(args): OS.mkdir('repos') run('''key -krepos/param add -a${kx}-param !${kx-param} -eforever -tparam tripe-param kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''') cmd_newmaster(args) ###-------------------------------------------------------------------------- ### Commands: upload def cmd_upload(args): ## Sanitize the repository directory umask = OS.umask(0); OS.umask(umask) mode = 0666 & ~umask for f in OS.listdir('repos'): ff = OS.path.join('repos', f) if (f.startswith('master') or f.startswith('peer-')) \ and f.endswith('.old'): OS.unlink(ff) continue OS.chmod(ff, mode) rmtree('tmp') OS.mkdir('tmp') OS.symlink('../repos', 'tmp/repos') cwd = OS.getcwd() try: ## Build the configuration file seq = max_master_sequence() v = {'MASTER-SEQUENCE': str(seq), 'HK-MASTER': hexhyphens(fingerprint('repos/master.pub', 'master-%d' % seq))} fin = file('tripe-keys.master') fout = file('tmp/tripe-keys.conf', 'w') for line in fin: fout.write(subst(line, rx_atsubst, v)) fin.close(); fout.close() SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new')) commit = [conf['repos-file'], conf['conf-file']] ## Make and sign the repository archive OS.chdir('tmp') run('tar chozf ${repos-file}.new .') OS.chdir(cwd) for k in master_keys(): seq = master_sequence(k) sigfile = seqsubst('sig-file', seq) run('''catsign -kmaster sign -abdC -kmaster-%d -o%s.new ${repos-file}.new''' % (seq, sigfile)) commit.append(sigfile) ## Commit the changes for base in commit: new = '%s.new' % base OS.rename(new, base) finally: OS.chdir(cwd) rmtree('tmp') run('sh -c ${upload-hook}') ###-------------------------------------------------------------------------- ### Commands: rebuild def cmd_rebuild(args): zap('keyring.pub') for i in OS.listdir('repos'): if i.startswith('peer-') and i.endswith('.pub'): run('key -kkeyring.pub merge %s' % OS.path.join('repos', i)) ###-------------------------------------------------------------------------- ### Commands: update def cmd_update(args): cwd = OS.getcwd() rmtree('tmp') try: ## Fetch a new distribution OS.mkdir('tmp') OS.chdir('tmp') seq = int(conf['master-sequence']) run('curl -s -o tripe-keys.tar.gz ${repos-url}') run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq)) run('tar xfz tripe-keys.tar.gz') ## Verify the signature want = C.bytes(rx_nonalpha.sub('', conf['hk-master'])) got = fingerprint('repos/master.pub', 'master-%d' % seq) if want != got: raise VerifyError run('''catsign -krepos/master.pub verify -avC -kmaster-%d -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq) ## OK: update our copy OS.chdir(cwd) if OS.path.exists('repos'): OS.rename('repos', 'repos.old') OS.rename('tmp/repos', 'repos') if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf'): moan('configuration file changed: recommend running another update') OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf') rmtree('repos.old') finally: OS.chdir(cwd) rmtree('tmp') cmd_rebuild(args) ###-------------------------------------------------------------------------- ### Commands: generate TAG def cmd_generate(args): tag, = args keyring_pub = 'peer-%s.pub' % tag zap('keyring'); zap(keyring_pub) run('key -kkeyring merge repos/param') run('key -kkeyring add -a${kx} -pparam -e${kx-expire} -t%s tripe' % tag) run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag)) ###-------------------------------------------------------------------------- ### Commands: clean def cmd_clean(args): rmtree('repos') rmtree('tmp') for i in OS.listdir('.'): r = i if r.endswith('.old'): r = r[:-4] if (r == 'master' or r == 'param' or r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')): zap(i) ###-------------------------------------------------------------------------- ### Commands: check def check_key(k): now = T.time() thresh = int(conf['kx-warn-days']) * 86400 if k.exptime == C.KEXP_FOREVER: return None elif k.exptime == C.KEXP_EXPIRE: left = -1 else: left = k.exptime - now if left < 0: return "key `%s' HAS EXPIRED" % k.tag elif left < thresh: if left >= 86400: n, u, uu = left // 86400, 'day', 'days' else: n, u, uu = left // 3600, 'hour', 'hours' return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu) else: return None def cmd_check(args): if OS.path.exists('keyring.pub'): for k in C.KeyFile('keyring.pub').itervalues(): whinge = check_key(k) if whinge is not None: print whinge if OS.path.exists('master'): whinges = [] for k in C.KeyFile('master').itervalues(): whinge = check_key(k) if whinge is None: break whinges.append(whinge) else: for whinge in whinges: print whinge ###-------------------------------------------------------------------------- ### Commands: mtu def cmd_mtu(args): mtu, = (lambda mtu = '1500': (mtu,))(*args) mtu = int(mtu) blksz = C.gcciphers[conf['cipher']].blksz index = conf['mac'].find('/') if index == -1: tagsz = C.gcmacs[conf['mac']].tagsz else: tagsz = int(conf['mac'][index + 1:])/8 mtu -= 20 # Minimum IP header mtu -= 8 # UDP header mtu -= 1 # TrIPE packet type octet mtu -= tagsz # MAC tag mtu -= 4 # Sequence number mtu -= blksz # Initialization vector print mtu ###-------------------------------------------------------------------------- ### Main driver. commands = {'help': (cmd_help, 0, 1, ''), 'newmaster': (cmd_newmaster, 0, 0, ''), 'setup': (cmd_setup, 0, 0, ''), 'upload': (cmd_upload, 0, 0, ''), 'update': (cmd_update, 0, 0, ''), 'clean': (cmd_clean, 0, 0, ''), 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'), 'check': (cmd_check, 0, 0, ''), 'generate': (cmd_generate, 1, 1, 'TAG'), 'rebuild': (cmd_rebuild, 0, 0, '')} def init(): """ Load the appropriate configuration file and set up the configuration dictionary. """ for f in ['tripe-keys.master', 'tripe-keys.conf']: if OS.path.exists(f): conf_read(f) break conf_defaults() def main(argv): """ Main program: parse options and dispatch to appropriate command handler. """ try: opts, args = O.getopt(argv[1:], 'hvu', ['help', 'version', 'usage']) except O.GetoptError, exc: moan(exc) usage(SYS.stderr) SYS.exit(1) for o, v in opts: if o in ('-h', '--help'): cmd_help([]) SYS.exit(0) elif o in ('-v', '--version'): version(SYS.stdout) SYS.exit(0) elif o in ('-u', '--usage'): usage(SYS.stdout) SYS.exit(0) if len(argv) < 2: cmd_help([]) else: c = argv[1] try: func, min, max, help = commands[c] except KeyError: die("unknown command `%s'" % c) args = argv[2:] if len(args) < min or (max is not None and len(args) > max): SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help)) SYS.exit(1) func(args) ###----- That's all, folks -------------------------------------------------- if __name__ == '__main__': init() main(SYS.argv)