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