chiark / gitweb /
ce9f74dc35b171bfb139d83f2c682cb4b7735785
[tripe] / keys / tripe-keys.in
1 #! @PYTHON@
2 ### -*-python-*-
3 ###
4 ### Key management and distribution
5 ###
6 ### (c) 2006 Straylight/Edgeware
7 ###
8
9 ###----- Licensing notice ---------------------------------------------------
10 ###
11 ### This file is part of Trivial IP Encryption (TrIPE).
12 ###
13 ### TrIPE is free software; you can redistribute it and/or modify
14 ### it under the terms of the GNU General Public License as published by
15 ### the Free Software Foundation; either version 2 of the License, or
16 ### (at your option) any later version.
17 ###
18 ### TrIPE is distributed in the hope that it will be useful,
19 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
20 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21 ### GNU General Public License for more details.
22 ###
23 ### You should have received a copy of the GNU General Public License
24 ### along with TrIPE; if not, write to the Free Software Foundation,
25 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
26
27 ###--------------------------------------------------------------------------
28 ### External dependencies.
29
30 import catacomb as C
31 import os as OS
32 import sys as SYS
33 import re as RX
34 import getopt as O
35 import shutil as SH
36 import time as T
37 import filecmp as FC
38 from cStringIO import StringIO
39 from errno import *
40 from stat import *
41
42 ###--------------------------------------------------------------------------
43 ### Useful regular expressions
44
45 ## Match a comment or blank line.
46 rx_comment = RX.compile(r'^\s*(#|$)')
47
48 ## Match a KEY = VALUE assignment.
49 rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
50
51 ## Match a ${KEY} substitution.
52 rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
53
54 ## Match a @TAG@ substitution.
55 rx_atsubst = RX.compile(r'@([-\w]+)@')
56
57 ## Match a single non-alphanumeric character.
58 rx_nonalpha = RX.compile(r'\W')
59
60 ## Match the literal string "<SEQ>".
61 rx_seq = RX.compile(r'\<SEQ\>')
62
63 ## Match a shell metacharacter.
64 rx_shmeta = RX.compile('[\\s`!"#$&*()\\[\\];\'|<>?\\\\]')
65
66 ## Match a character which needs escaping in a shell double-quoted string.
67 rx_shquote = RX.compile(r'["`$\\]')
68
69 ###--------------------------------------------------------------------------
70 ### Utility functions.
71
72 ## Exceptions.
73 class SubprocessError (Exception): pass
74 class VerifyError (Exception): pass
75
76 ## Program name and identification.
77 quis = OS.path.basename(SYS.argv[0])
78 PACKAGE = "@PACKAGE@"
79 VERSION = "@VERSION@"
80
81 def moan(msg):
82   """Report MSG to standard error."""
83   SYS.stderr.write('%s: %s\n' % (quis, msg))
84
85 def die(msg, rc = 1):
86   """Report MSG to standard error, and exit with code RC."""
87   moan(msg)
88   SYS.exit(rc)
89
90 def subst(s, rx, map):
91   """
92   Substitute values into a string.
93
94   Repeatedly match RX (a compiled regular expression) against the string S.
95   For each match, extract group 1, and use it as a key to index the MAP;
96   replace the match by the result.  Finally, return the fully-substituted
97   string.
98   """
99   out = StringIO()
100   i = 0
101   for m in rx.finditer(s):
102     out.write(s[i:m.start()] + map[m.group(1)])
103     i = m.end()
104   out.write(s[i:])
105   return out.getvalue()
106
107 def shell_quotify(arg):
108   """
109   Quotify ARG to keep the shell happy.
110
111   This isn't actually used for invoking commands, just for presentation
112   purposes; but correctness is still nice.
113   """
114   if not rx_shmeta.search(arg):
115     return arg
116   elif arg.find("'") == -1:
117     return "'%s'" % arg
118   else:
119     return '"%s"' % rx_shquote.sub(lambda m: '\\' + m.group(0), arg)
120
121 def rmtree(path):
122   """Delete the directory tree given by PATH."""
123   try:
124     st = OS.lstat(path)
125   except OSError, err:
126     if err.errno == ENOENT:
127       return
128     raise
129   if not S_ISDIR(st.st_mode):
130     OS.unlink(path)
131   else:
132     cwd = OS.getcwd()
133     try:
134       OS.chdir(path)
135       for i in OS.listdir('.'):
136         rmtree(i)
137     finally:
138       OS.chdir(cwd)
139     OS.rmdir(path)
140
141 def zap(file):
142   """Delete the named FILE if it exists; otherwise do nothing."""
143   try:
144     OS.unlink(file)
145   except OSError, err:
146     if err.errno == ENOENT: return
147     raise
148
149 def run(args):
150   """
151   Run a subprocess whose arguments are given by the string ARGS.
152
153   The ARGS are split at word boundaries, and then subjected to configuration
154   variable substitution (see conf_subst).  Individual argument elements
155   beginning with `!' are split again into multiple arguments at word
156   boundaries.
157   """
158   args = map(conf_subst, args.split())
159   nargs = []
160   for a in args:
161     if len(a) > 0 and a[0] != '!':
162       nargs += [a]
163     else:
164       nargs += a[1:].split()
165   args = nargs
166   print '+ %s' % ' '.join([shell_quotify(arg) for arg in args])
167   SYS.stdout.flush()
168   rc = OS.spawnvp(OS.P_WAIT, args[0], args)
169   if rc != 0:
170     raise SubprocessError, rc
171
172 def hexhyphens(bytes):
173   """
174   Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
175   """
176   out = StringIO()
177   for i in xrange(0, len(bytes)):
178     if i > 0 and i % 4 == 0: out.write('-')
179     out.write('%02x' % ord(bytes[i]))
180   return out.getvalue()
181
182 def fingerprint(kf, ktag):
183   """
184   Compute the fingerprint of a key, using the user's selected hash.
185
186   KF is the name of a keyfile; KTAG is the tag of the key.
187   """
188   h = C.gchashes[conf['fingerprint-hash']]()
189   k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
190   return h.done()
191
192 ###--------------------------------------------------------------------------
193 ### The configuration file.
194
195 ## Exceptions.
196 class ConfigFileError (Exception): pass
197
198 ## The configuration dictionary.
199 conf = {}
200
201 def conf_subst(s):
202   """
203   Apply configuration substitutions to S.
204
205   That is, for each ${KEY} in S, replace it with the current value of the
206   configuration variable KEY.
207   """
208   return subst(s, rx_dollarsubst, conf)
209
210 def conf_read(f):
211   """
212   Read the file F and insert assignments into the configuration dictionary.
213   """
214   lno = 0
215   for line in file(f):
216     lno += 1
217     if rx_comment.match(line): continue
218     if line[-1] == '\n': line = line[:-1]
219     match = rx_keyval.match(line)
220     if not match:
221       raise ConfigFileError, "%s:%d: bad line `%s'" % (f, lno, line)
222     k, v = match.groups()
223     conf[k] = conf_subst(v)
224
225 def conf_defaults():
226   """
227   Apply defaults to the configuration dictionary.
228
229   Fill in all the interesting configuration variables based on the existing
230   contents, as described in the manual.
231   """
232   for k, v in [('repos-base', 'tripe-keys.tar.gz'),
233                ('sig-base', 'tripe-keys.sig-<SEQ>'),
234                ('repos-url', '${base-url}${repos-base}'),
235                ('sig-url', '${base-url}${sig-base}'),
236                ('sig-file', '${base-dir}${sig-base}'),
237                ('repos-file', '${base-dir}${repos-base}'),
238                ('conf-file', '${base-dir}tripe-keys.conf'),
239                ('upload-hook', ': run upload hook'),
240                ('kx', 'dh'),
241                ('kx-genalg', lambda: {'dh': 'dh',
242                                       'ec': 'ec'}[conf['kx']]),
243                ('kx-param-genalg', lambda: {'dh': 'dh-param',
244                                             'ec': 'ec-param'}[conf['kx']]),
245                ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
246                                      'ec': '-Cnist-p256'}[conf['kx']]),
247                ('kx-expire', 'now + 1 year'),
248                ('kx-warn-days', '28'),
249                ('cipher', 'rijndael-cbc'),
250                ('hash', 'sha256'),
251                ('master-keygen-flags', '-l'),
252                ('mgf', '${hash}-mgf'),
253                ('mac', lambda: '%s-hmac/%d' %
254                          (conf['hash'],
255                           C.gchashes[conf['hash']].hashsz * 4)),
256                ('sig', lambda: {'dh': 'dsa', 'ec': 'ecdsa'}[conf['kx']]),
257                ('sig-fresh', 'always'),
258                ('sig-genalg', lambda: {'kcdsa': 'dh',
259                                        'dsa': 'dsa',
260                                        'rsapkcs1': 'rsa',
261                                        'rsapss': 'rsa',
262                                        'ecdsa': 'ec',
263                                        'eckcdsa': 'ec'}[conf['sig']]),
264                ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
265                                       'dsa': '-b3072 -B256',
266                                       'ec': '-Cnist-p256',
267                                       'rsa': '-b3072'}[conf['sig-genalg']]),
268                ('sig-hash', '${hash}'),
269                ('sig-expire', 'forever'),
270                ('fingerprint-hash', '${hash}')]:
271     try:
272       if k in conf: continue
273       if type(v) == str:
274         conf[k] = conf_subst(v)
275       else:
276         conf[k] = v()
277     except KeyError, exc:
278       if len(exc.args) == 0: raise
279       conf[k] = '<missing-var %s>' % exc.args[0]
280
281 ###--------------------------------------------------------------------------
282 ### Key-management utilities.
283
284 def master_keys():
285   """
286   Iterate over the master keys.
287   """
288   if not OS.path.exists('master'):
289     return
290   for k in C.KeyFile('master').itervalues():
291     if (k.type != 'tripe-keys-master' or
292         k.expiredp or
293         not k.tag.startswith('master-')):
294       continue #??
295     yield k
296
297 def master_sequence(k):
298   """
299   Return the sequence number of the given master key as an integer.
300
301   No checking is done that K is really a master key.
302   """
303   return int(k.tag[7:])
304
305 def max_master_sequence():
306   """
307   Find the master key with the highest sequence number and return this
308   sequence number.
309   """
310   seq = -1
311   for k in master_keys():
312     q = master_sequence(k)
313     if q > seq: seq = q
314   return seq
315
316 def seqsubst(x, q):
317   """
318   Return the value of the configuration variable X, with <SEQ> replaced by
319   the value Q.
320   """
321   return rx_seq.sub(str(q), conf[x])
322
323 ###--------------------------------------------------------------------------
324 ### Commands: help [COMMAND...]
325
326 def version(fp = SYS.stdout):
327   fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
328
329 def usage(fp):
330   fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
331
332 def cmd_help(args):
333   if len(args) == 0:
334     version(SYS.stdout)
335     print
336     usage(SYS.stdout)
337     print """
338 Key management utility for TrIPE.
339
340 Options supported:
341
342 -h, --help              Show this help message.
343 -v, --version           Show the version number.
344 -u, --usage             Show pointlessly short usage string.
345
346 Subcommands available:
347 """
348     args = commands.keys()
349     args.sort()
350   for c in args:
351     try: func, min, max, help = commands[c]
352     except KeyError: die("unknown command `%s'" % c)
353     print '%s%s%s' % (c, help and ' ', help)
354
355 ###--------------------------------------------------------------------------
356 ### Commands: newmaster
357
358 def cmd_newmaster(args):
359   seq = max_master_sequence() + 1
360   run('''key -kmaster add
361     -a${sig-genalg} !${sig-param}
362     -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
363     sig=${sig} hash=${sig-hash}''' % seq)
364   run('key -kmaster extract -f-secret repos/master.pub')
365
366 ###--------------------------------------------------------------------------
367 ### Commands: setup
368
369 def cmd_setup(args):
370   OS.mkdir('repos')
371   run('''key -krepos/param add
372     -a${kx-param-genalg} !${kx-param}
373     -eforever -tparam tripe-param
374     kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
375   cmd_newmaster(args)
376
377 ###--------------------------------------------------------------------------
378 ### Commands: upload
379
380 def cmd_upload(args):
381
382   ## Sanitize the repository directory
383   umask = OS.umask(0); OS.umask(umask)
384   mode = 0666 & ~umask
385   for f in OS.listdir('repos'):
386     ff = OS.path.join('repos', f)
387     if (f.startswith('master') or f.startswith('peer-')) \
388            and f.endswith('.old'):
389       OS.unlink(ff)
390       continue
391     OS.chmod(ff, mode)
392
393   rmtree('tmp')
394   OS.mkdir('tmp')
395   OS.symlink('../repos', 'tmp/repos')
396   cwd = OS.getcwd()
397   try:
398
399     ## Build the configuration file
400     seq = max_master_sequence()
401     v = {'MASTER-SEQUENCE': str(seq),
402          'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
403                                              'master-%d' % seq))}
404     fin = file('tripe-keys.master')
405     fout = file('tmp/tripe-keys.conf', 'w')
406     for line in fin:
407       fout.write(subst(line, rx_atsubst, v))
408     fin.close(); fout.close()
409     SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
410     commit = [conf['repos-file'], conf['conf-file']]
411
412     ## Make and sign the repository archive
413     OS.chdir('tmp')
414     run('tar chozf ${repos-file}.new .')
415     OS.chdir(cwd)
416     for k in master_keys():
417       seq = master_sequence(k)
418       sigfile = seqsubst('sig-file', seq)
419       run('''catsign -kmaster sign -abdC -kmaster-%d
420         -o%s.new ${repos-file}.new''' % (seq, sigfile))
421       commit.append(sigfile)
422
423     ## Commit the changes
424     for base in commit:
425       new = '%s.new' % base
426       OS.rename(new, base)
427
428     ## Remove files in the base-dir which don't correspond to ones we just
429     ## committed
430     allow = {}
431     basedir = conf['base-dir']
432     bdl = len(basedir)
433     for base in commit:
434       if base.startswith(basedir): allow[base[bdl:]] = 1
435     for found in OS.listdir(basedir):
436       if found not in allow: OS.remove(OS.path.join(basedir, found))
437   finally:
438     OS.chdir(cwd)
439     rmtree('tmp')
440   run('sh -c ${upload-hook}')
441
442 ###--------------------------------------------------------------------------
443 ### Commands: rebuild
444
445 def cmd_rebuild(args):
446   zap('keyring.pub')
447   for i in OS.listdir('repos'):
448     if i.startswith('peer-') and i.endswith('.pub'):
449       run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
450
451 ###--------------------------------------------------------------------------
452 ### Commands: update
453
454 def cmd_update(args):
455   cwd = OS.getcwd()
456   rmtree('tmp')
457   try:
458
459     ## Fetch a new distribution
460     OS.mkdir('tmp')
461     OS.chdir('tmp')
462     seq = int(conf['master-sequence'])
463     run('curl -s -o tripe-keys.tar.gz ${repos-url}')
464     run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
465     run('tar xfz tripe-keys.tar.gz')
466
467     ## Verify the signature
468     want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
469     got = fingerprint('repos/master.pub', 'master-%d' % seq)
470     if want != got: raise VerifyError
471     run('''catsign -krepos/master.pub verify -avC -kmaster-%d
472       -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
473
474     ## OK: update our copy
475     OS.chdir(cwd)
476     if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
477     OS.rename('tmp/repos', 'repos')
478     if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
479       moan('configuration file changed: recommend running another update')
480       OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
481     rmtree('repos.old')
482
483   finally:
484     OS.chdir(cwd)
485     rmtree('tmp')
486   cmd_rebuild(args)
487
488 ###--------------------------------------------------------------------------
489 ### Commands: generate TAG
490
491 def cmd_generate(args):
492   tag, = args
493   keyring_pub = 'peer-%s.pub' % tag
494   zap('keyring'); zap(keyring_pub)
495   run('key -kkeyring merge repos/param')
496   run('key -kkeyring add -a${kx-genalg} -pparam -e${kx-expire} -t%s tripe' %
497       tag)
498   run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
499
500 ###--------------------------------------------------------------------------
501 ### Commands: clean
502
503 def cmd_clean(args):
504   rmtree('repos')
505   rmtree('tmp')
506   for i in OS.listdir('.'):
507     r = i
508     if r.endswith('.old'): r = r[:-4]
509     if (r == 'master' or r == 'param' or
510         r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
511       zap(i)
512
513 ###--------------------------------------------------------------------------
514 ### Commands: check
515
516 def check_key(k):
517   now = T.time()
518   thresh = int(conf['kx-warn-days']) * 86400
519   if k.exptime == C.KEXP_FOREVER: return None
520   elif k.exptime == C.KEXP_EXPIRE: left = -1
521   else: left = k.exptime - now
522   if left < 0:
523     return "key `%s' HAS EXPIRED" % k.tag
524   elif left < thresh:
525     if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
526     else: n, u, uu = left // 3600, 'hour', 'hours'
527     return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
528   else:
529     return None
530
531 def cmd_check(args):
532   if OS.path.exists('keyring.pub'):
533     for k in C.KeyFile('keyring.pub').itervalues():
534       whinge = check_key(k)
535       if whinge is not None: print whinge
536   if OS.path.exists('master'):
537     whinges = []
538     for k in C.KeyFile('master').itervalues():
539       whinge = check_key(k)
540       if whinge is None: break
541       whinges.append(whinge)
542     else:
543       for whinge in whinges: print whinge
544
545 ###--------------------------------------------------------------------------
546 ### Commands: mtu
547
548 def cmd_mtu(args):
549   mtu, = (lambda mtu = '1500': (mtu,))(*args)
550   mtu = int(mtu)
551
552   blksz = C.gcciphers[conf['cipher']].blksz
553
554   index = conf['mac'].find('/')
555   if index == -1:
556     tagsz = C.gcmacs[conf['mac']].tagsz
557   else:
558     tagsz = int(conf['mac'][index + 1:])/8
559
560   mtu -= 20                             # Minimum IP header
561   mtu -= 8                              # UDP header
562   mtu -= 1                              # TrIPE packet type octet
563   mtu -= tagsz                          # MAC tag
564   mtu -= 4                              # Sequence number
565   mtu -= blksz                          # Initialization vector
566
567   print mtu
568
569 ###--------------------------------------------------------------------------
570 ### Main driver.
571
572 commands = {'help': (cmd_help, 0, 1, ''),
573             'newmaster': (cmd_newmaster, 0, 0, ''),
574             'setup': (cmd_setup, 0, 0, ''),
575             'upload': (cmd_upload, 0, 0, ''),
576             'update': (cmd_update, 0, 0, ''),
577             'clean': (cmd_clean, 0, 0, ''),
578             'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
579             'check': (cmd_check, 0, 0, ''),
580             'generate': (cmd_generate, 1, 1, 'TAG'),
581             'rebuild': (cmd_rebuild, 0, 0, '')}
582
583 def init():
584   """
585   Load the appropriate configuration file and set up the configuration
586   dictionary.
587   """
588   for f in ['tripe-keys.master', 'tripe-keys.conf']:
589     if OS.path.exists(f):
590       conf_read(f)
591       break
592   conf_defaults()
593
594 def main(argv):
595   """
596   Main program: parse options and dispatch to appropriate command handler.
597   """
598   try:
599     opts, args = O.getopt(argv[1:], 'hvu',
600                           ['help', 'version', 'usage'])
601   except O.GetoptError, exc:
602     moan(exc)
603     usage(SYS.stderr)
604     SYS.exit(1)
605   for o, v in opts:
606     if o in ('-h', '--help'):
607       cmd_help([])
608       SYS.exit(0)
609     elif o in ('-v', '--version'):
610       version(SYS.stdout)
611       SYS.exit(0)
612     elif o in ('-u', '--usage'):
613       usage(SYS.stdout)
614       SYS.exit(0)
615   if len(argv) < 2:
616     cmd_help([])
617   else:
618     c = argv[1]
619     try: func, min, max, help = commands[c]
620     except KeyError: die("unknown command `%s'" % c)
621     args = argv[2:]
622     if len(args) < min or (max is not None and len(args) > max):
623       SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
624       SYS.exit(1)
625     func(args)
626
627 ###----- That's all, folks --------------------------------------------------
628
629 if __name__ == '__main__':
630   init()
631   main(SYS.argv)