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