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