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