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