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