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