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