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