chiark / gitweb /
keys/tripe-keys.in: Remove unrecognized files from `base-dir'.
[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-param', lambda: {'dh': '-LS -b3072 -B256',
242                                      'ec': '-Cnist-p256'}[conf['kx']]),
243                ('kx-expire', 'now + 1 year'),
244                ('kx-warn-days', '28'),
245                ('cipher', 'rijndael-cbc'),
246                ('hash', 'sha256'),
247                ('master-keygen-flags', '-l'),
248                ('mgf', '${hash}-mgf'),
249                ('mac', lambda: '%s-hmac/%d' %
250                          (conf['hash'],
251                           C.gchashes[conf['hash']].hashsz * 4)),
252                ('sig', lambda: {'dh': 'dsa', 'ec': 'ecdsa'}[conf['kx']]),
253                ('sig-fresh', 'always'),
254                ('sig-genalg', lambda: {'kcdsa': 'dh',
255                                        'dsa': 'dsa',
256                                        'rsapkcs1': 'rsa',
257                                        'rsapss': 'rsa',
258                                        'ecdsa': 'ec',
259                                        'eckcdsa': 'ec'}[conf['sig']]),
260                ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
261                                       'dsa': '-b3072 -B256',
262                                       'ec': '-Cnist-p256',
263                                       'rsa': '-b3072'}[conf['sig-genalg']]),
264                ('sig-hash', '${hash}'),
265                ('sig-expire', 'forever'),
266                ('fingerprint-hash', '${hash}')]:
267     try:
268       if k in conf: continue
269       if type(v) == str:
270         conf[k] = conf_subst(v)
271       else:
272         conf[k] = v()
273     except KeyError, exc:
274       if len(exc.args) == 0: raise
275       conf[k] = '<missing-var %s>' % exc.args[0]
276
277 ###--------------------------------------------------------------------------
278 ### Key-management utilities.
279
280 def master_keys():
281   """
282   Iterate over the master keys.
283   """
284   if not OS.path.exists('master'):
285     return
286   for k in C.KeyFile('master').itervalues():
287     if (k.type != 'tripe-keys-master' or
288         k.expiredp or
289         not k.tag.startswith('master-')):
290       continue #??
291     yield k
292
293 def master_sequence(k):
294   """
295   Return the sequence number of the given master key as an integer.
296
297   No checking is done that K is really a master key.
298   """
299   return int(k.tag[7:])
300
301 def max_master_sequence():
302   """
303   Find the master key with the highest sequence number and return this
304   sequence number.
305   """
306   seq = -1
307   for k in master_keys():
308     q = master_sequence(k)
309     if q > seq: seq = q
310   return seq
311
312 def seqsubst(x, q):
313   """
314   Return the value of the configuration variable X, with <SEQ> replaced by
315   the value Q.
316   """
317   return rx_seq.sub(str(q), conf[x])
318
319 ###--------------------------------------------------------------------------
320 ### Commands: help [COMMAND...]
321
322 def version(fp = SYS.stdout):
323   fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
324
325 def usage(fp):
326   fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
327
328 def cmd_help(args):
329   if len(args) == 0:
330     version(SYS.stdout)
331     print
332     usage(SYS.stdout)
333     print """
334 Key management utility for TrIPE.
335
336 Options supported:
337
338 -h, --help              Show this help message.
339 -v, --version           Show the version number.
340 -u, --usage             Show pointlessly short usage string.
341
342 Subcommands available:
343 """
344     args = commands.keys()
345     args.sort()
346   for c in args:
347     try: func, min, max, help = commands[c]
348     except KeyError: die("unknown command `%s'" % c)
349     print '%s%s%s' % (c, help and ' ', help)
350
351 ###--------------------------------------------------------------------------
352 ### Commands: newmaster
353
354 def cmd_newmaster(args):
355   seq = max_master_sequence() + 1
356   run('''key -kmaster add
357     -a${sig-genalg} !${sig-param}
358     -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
359     sig=${sig} hash=${sig-hash}''' % seq)
360   run('key -kmaster extract -f-secret repos/master.pub')
361
362 ###--------------------------------------------------------------------------
363 ### Commands: setup
364
365 def cmd_setup(args):
366   OS.mkdir('repos')
367   run('''key -krepos/param add
368     -a${kx}-param !${kx-param}
369     -eforever -tparam tripe-param
370     kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
371   cmd_newmaster(args)
372
373 ###--------------------------------------------------------------------------
374 ### Commands: upload
375
376 def cmd_upload(args):
377
378   ## Sanitize the repository directory
379   umask = OS.umask(0); OS.umask(umask)
380   mode = 0666 & ~umask
381   for f in OS.listdir('repos'):
382     ff = OS.path.join('repos', f)
383     if (f.startswith('master') or f.startswith('peer-')) \
384            and f.endswith('.old'):
385       OS.unlink(ff)
386       continue
387     OS.chmod(ff, mode)
388
389   rmtree('tmp')
390   OS.mkdir('tmp')
391   OS.symlink('../repos', 'tmp/repos')
392   cwd = OS.getcwd()
393   try:
394
395     ## Build the configuration file
396     seq = max_master_sequence()
397     v = {'MASTER-SEQUENCE': str(seq),
398          'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
399                                              'master-%d' % seq))}
400     fin = file('tripe-keys.master')
401     fout = file('tmp/tripe-keys.conf', 'w')
402     for line in fin:
403       fout.write(subst(line, rx_atsubst, v))
404     fin.close(); fout.close()
405     SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
406     commit = [conf['repos-file'], conf['conf-file']]
407
408     ## Make and sign the repository archive
409     OS.chdir('tmp')
410     run('tar chozf ${repos-file}.new .')
411     OS.chdir(cwd)
412     for k in master_keys():
413       seq = master_sequence(k)
414       sigfile = seqsubst('sig-file', seq)
415       run('''catsign -kmaster sign -abdC -kmaster-%d
416         -o%s.new ${repos-file}.new''' % (seq, sigfile))
417       commit.append(sigfile)
418
419     ## Commit the changes
420     for base in commit:
421       new = '%s.new' % base
422       OS.rename(new, base)
423
424     ## Remove files in the base-dir which don't correspond to ones we just
425     ## committed
426     allow = {}
427     basedir = conf['base-dir']
428     bdl = len(basedir)
429     for base in commit:
430       if base.startswith(basedir): allow[base[bdl:]] = 1
431     for found in OS.listdir(basedir):
432       if found not in allow: OS.remove(OS.path.join(basedir, found))
433   finally:
434     OS.chdir(cwd)
435     rmtree('tmp')
436   run('sh -c ${upload-hook}')
437
438 ###--------------------------------------------------------------------------
439 ### Commands: rebuild
440
441 def cmd_rebuild(args):
442   zap('keyring.pub')
443   for i in OS.listdir('repos'):
444     if i.startswith('peer-') and i.endswith('.pub'):
445       run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
446
447 ###--------------------------------------------------------------------------
448 ### Commands: update
449
450 def cmd_update(args):
451   cwd = OS.getcwd()
452   rmtree('tmp')
453   try:
454
455     ## Fetch a new distribution
456     OS.mkdir('tmp')
457     OS.chdir('tmp')
458     seq = int(conf['master-sequence'])
459     run('curl -s -o tripe-keys.tar.gz ${repos-url}')
460     run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
461     run('tar xfz tripe-keys.tar.gz')
462
463     ## Verify the signature
464     want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
465     got = fingerprint('repos/master.pub', 'master-%d' % seq)
466     if want != got: raise VerifyError
467     run('''catsign -krepos/master.pub verify -avC -kmaster-%d
468       -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
469
470     ## OK: update our copy
471     OS.chdir(cwd)
472     if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
473     OS.rename('tmp/repos', 'repos')
474     if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
475       moan('configuration file changed: recommend running another update')
476       OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
477     rmtree('repos.old')
478
479   finally:
480     OS.chdir(cwd)
481     rmtree('tmp')
482   cmd_rebuild(args)
483
484 ###--------------------------------------------------------------------------
485 ### Commands: generate TAG
486
487 def cmd_generate(args):
488   tag, = args
489   keyring_pub = 'peer-%s.pub' % tag
490   zap('keyring'); zap(keyring_pub)
491   run('key -kkeyring merge repos/param')
492   run('key -kkeyring add -a${kx} -pparam -e${kx-expire} -t%s tripe' %
493       tag)
494   run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
495
496 ###--------------------------------------------------------------------------
497 ### Commands: clean
498
499 def cmd_clean(args):
500   rmtree('repos')
501   rmtree('tmp')
502   for i in OS.listdir('.'):
503     r = i
504     if r.endswith('.old'): r = r[:-4]
505     if (r == 'master' or r == 'param' or
506         r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
507       zap(i)
508
509 ###--------------------------------------------------------------------------
510 ### Commands: check
511
512 def check_key(k):
513   now = T.time()
514   thresh = int(conf['kx-warn-days']) * 86400
515   if k.exptime == C.KEXP_FOREVER: return None
516   elif k.exptime == C.KEXP_EXPIRE: left = -1
517   else: left = k.exptime - now
518   if left < 0:
519     return "key `%s' HAS EXPIRED" % k.tag
520   elif left < thresh:
521     if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
522     else: n, u, uu = left // 3600, 'hour', 'hours'
523     return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
524   else:
525     return None
526
527 def cmd_check(args):
528   if OS.path.exists('keyring.pub'):
529     for k in C.KeyFile('keyring.pub').itervalues():
530       whinge = check_key(k)
531       if whinge is not None: print whinge
532   if OS.path.exists('master'):
533     whinges = []
534     for k in C.KeyFile('master').itervalues():
535       whinge = check_key(k)
536       if whinge is None: break
537       whinges.append(whinge)
538     else:
539       for whinge in whinges: print whinge
540
541 ###--------------------------------------------------------------------------
542 ### Commands: mtu
543
544 def cmd_mtu(args):
545   mtu, = (lambda mtu = '1500': (mtu,))(*args)
546   mtu = int(mtu)
547
548   blksz = C.gcciphers[conf['cipher']].blksz
549
550   index = conf['mac'].find('/')
551   if index == -1:
552     tagsz = C.gcmacs[conf['mac']].tagsz
553   else:
554     tagsz = int(conf['mac'][index + 1:])/8
555
556   mtu -= 20                             # Minimum IP header
557   mtu -= 8                              # UDP header
558   mtu -= 1                              # TrIPE packet type octet
559   mtu -= tagsz                          # MAC tag
560   mtu -= 4                              # Sequence number
561   mtu -= blksz                          # Initialization vector
562
563   print mtu
564
565 ###--------------------------------------------------------------------------
566 ### Main driver.
567
568 commands = {'help': (cmd_help, 0, 1, ''),
569             'newmaster': (cmd_newmaster, 0, 0, ''),
570             'setup': (cmd_setup, 0, 0, ''),
571             'upload': (cmd_upload, 0, 0, ''),
572             'update': (cmd_update, 0, 0, ''),
573             'clean': (cmd_clean, 0, 0, ''),
574             'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
575             'check': (cmd_check, 0, 0, ''),
576             'generate': (cmd_generate, 1, 1, 'TAG'),
577             'rebuild': (cmd_rebuild, 0, 0, '')}
578
579 def init():
580   """
581   Load the appropriate configuration file and set up the configuration
582   dictionary.
583   """
584   for f in ['tripe-keys.master', 'tripe-keys.conf']:
585     if OS.path.exists(f):
586       conf_read(f)
587       break
588   conf_defaults()
589
590 def main(argv):
591   """
592   Main program: parse options and dispatch to appropriate command handler.
593   """
594   try:
595     opts, args = O.getopt(argv[1:], 'hvu',
596                           ['help', 'version', 'usage'])
597   except O.GetoptError, exc:
598     moan(exc)
599     usage(SYS.stderr)
600     SYS.exit(1)
601   for o, v in opts:
602     if o in ('-h', '--help'):
603       cmd_help([])
604       SYS.exit(0)
605     elif o in ('-v', '--version'):
606       version(SYS.stdout)
607       SYS.exit(0)
608     elif o in ('-u', '--usage'):
609       usage(SYS.stdout)
610       SYS.exit(0)
611   if len(argv) < 2:
612     cmd_help([])
613   else:
614     c = argv[1]
615     try: func, min, max, help = commands[c]
616     except KeyError: die("unknown command `%s'" % c)
617     args = argv[2:]
618     if len(args) < min or (max is not None and len(args) > max):
619       SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
620       SYS.exit(1)
621     func(args)
622
623 ###----- That's all, folks --------------------------------------------------
624
625 if __name__ == '__main__':
626   init()
627   main(SYS.argv)