chiark / gitweb /
debian: Don't depend on sysvinit.
[tripe] / keys / tripe-keys.in
... / ...
CommitLineData
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
30import catacomb as C
31import os as OS
32import sys as SYS
33import sre as RX
34import getopt as O
35import shutil as SH
36import filecmp as FC
37from cStringIO import StringIO
38from errno import *
39from stat import *
40
41###--------------------------------------------------------------------------
42### Useful regular expressions
43
44## Match a comment or blank line.
45rx_comment = RX.compile(r'^\s*(#|$)')
46
47## Match a KEY = VALUE assignment.
48rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
49
50## Match a ${KEY} substitution.
51rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
52
53## Match a @TAG@ substitution.
54rx_atsubst = RX.compile(r'@([-\w]+)@')
55
56## Match a single non-alphanumeric character.
57rx_nonalpha = RX.compile(r'\W')
58
59## Match the literal string "<SEQ>".
60rx_seq = RX.compile(r'\<SEQ\>')
61
62###--------------------------------------------------------------------------
63### Utility functions.
64
65## Exceptions.
66class SubprocessError (Exception): pass
67class VerifyError (Exception): pass
68
69## Program name and identification.
70quis = OS.path.basename(SYS.argv[0])
71PACKAGE = "@PACKAGE@"
72VERSION = "@VERSION@"
73
74def moan(msg):
75 """Report MSG to standard error."""
76 SYS.stderr.write('%s: %s\n' % (quis, msg))
77
78def die(msg, rc = 1):
79 """Report MSG to standard error, and exit with code RC."""
80 moan(msg)
81 SYS.exit(rc)
82
83def 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
100def 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
120def 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
128def 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
150def 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
160def 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.
174class ConfigFileError (Exception): pass
175
176## The configuration dictionary.
177conf = {}
178
179def 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
188def 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
203def 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
257def 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
270def 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
278def 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
289def 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
299def version(fp = SYS.stdout):
300 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
301
302def usage(fp):
303 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
304
305def cmd_help(args):
306 if len(args) == 0:
307 version(SYS.stdout)
308 print
309 usage(SYS.stdout)
310 print """
311Key management utility for TrIPE.
312
313Options 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
319Subcommands 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
330def 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
341def 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
352def 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
407def 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
416def 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
453def 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
465def 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.
479class UsageError (Exception): pass
480
481commands = {'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
490def 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
501def 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
534if __name__ == '__main__':
535 init()
536 main(SYS.argv)