chiark / gitweb /
debian: Don't depend on sysvinit.
[tripe] / keys / tripe-keys.in
CommitLineData
060ca767 1#! @PYTHON@
fd42a1e5
MW
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.
060ca767 29
30import catacomb as C
31import os as OS
32import sys as SYS
33import sre as RX
34import getopt as O
c77687d5 35import shutil as SH
36import filecmp as FC
060ca767 37from cStringIO import StringIO
38from errno import *
39from stat import *
40
fd42a1e5 41###--------------------------------------------------------------------------
060ca767 42### Useful regular expressions
43
fd42a1e5 44## Match a comment or blank line.
c77687d5 45rx_comment = RX.compile(r'^\s*(#|$)')
fd42a1e5
MW
46
47## Match a KEY = VALUE assignment.
c77687d5 48rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
fd42a1e5
MW
49
50## Match a ${KEY} substitution.
c77687d5 51rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
fd42a1e5
MW
52
53## Match a @TAG@ substitution.
c77687d5 54rx_atsubst = RX.compile(r'@([-\w]+)@')
fd42a1e5
MW
55
56## Match a single non-alphanumeric character.
c77687d5 57rx_nonalpha = RX.compile(r'\W')
fd42a1e5
MW
58
59## Match the literal string "<SEQ>".
c77687d5 60rx_seq = RX.compile(r'\<SEQ\>')
060ca767 61
fd42a1e5
MW
62###--------------------------------------------------------------------------
63### Utility functions.
060ca767 64
fd42a1e5 65## Exceptions.
060ca767 66class SubprocessError (Exception): pass
67class VerifyError (Exception): pass
68
fd42a1e5 69## Program name and identification.
060ca767 70quis = OS.path.basename(SYS.argv[0])
71PACKAGE = "@PACKAGE@"
72VERSION = "@VERSION@"
73
74def moan(msg):
fd42a1e5 75 """Report MSG to standard error."""
060ca767 76 SYS.stderr.write('%s: %s\n' % (quis, msg))
77
78def die(msg, rc = 1):
fd42a1e5 79 """Report MSG to standard error, and exit with code RC."""
060ca767 80 moan(msg)
81 SYS.exit(rc)
82
83def subst(s, rx, map):
fd42a1e5
MW
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 """
060ca767 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):
fd42a1e5 101 """Delete the directory tree given by PATH."""
060ca767 102 try:
c77687d5 103 st = OS.lstat(path)
060ca767 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):
fd42a1e5 121 """Delete the named FILE if it exists; otherwise do nothing."""
060ca767 122 try:
123 OS.unlink(file)
124 except OSError, err:
125 if err.errno == ENOENT: return
126 raise
127
128def run(args):
fd42a1e5
MW
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 """
060ca767 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):
fd42a1e5
MW
151 """
152 Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
153 """
060ca767 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):
fd42a1e5
MW
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 """
060ca767 166 h = C.gchashes[conf['fingerprint-hash']]()
167 k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
168 return h.done()
169
fd42a1e5
MW
170###--------------------------------------------------------------------------
171### The configuration file.
060ca767 172
fd42a1e5 173## Exceptions.
060ca767 174class ConfigFileError (Exception): pass
fd42a1e5
MW
175
176## The configuration dictionary.
060ca767 177conf = {}
178
fd42a1e5
MW
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)
060ca767 187
060ca767 188def conf_read(f):
fd42a1e5
MW
189 """
190 Read the file F and insert assignments into the configuration dictionary.
191 """
060ca767 192 lno = 0
193 for line in file(f):
194 lno += 1
c77687d5 195 if rx_comment.match(line): continue
060ca767 196 if line[-1] == '\n': line = line[:-1]
c77687d5 197 match = rx_keyval.match(line)
060ca767 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
060ca767 203def conf_defaults():
fd42a1e5
MW
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 """
c77687d5 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}'),
060ca767 216 ('conf-file', '${base-dir}tripe-keys.conf'),
b14ccd2f 217 ('upload-hook', ': run upload hook'),
060ca767 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'),
7858dfa0 224 ('master-keygen-flags', '-l'),
060ca767 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
fd42a1e5
MW
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...]
060ca767 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
e04c2d50
MW
315-h, --help Show this help message.
316-v, --version Show the version number.
317-u, --usage Show pointlessly short usage string.
060ca767 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
fd42a1e5
MW
327###--------------------------------------------------------------------------
328### Commands: newmaster
c77687d5 329
330def cmd_newmaster(args):
331 seq = max_master_sequence() + 1
060ca767 332 run('''key -kmaster add
333 -a${sig-genalg} !${sig-param}
7858dfa0 334 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
c77687d5 335 sig=${sig} hash=${sig-hash}''' % seq)
336 run('key -kmaster extract -f-secret repos/master.pub')
060ca767 337
fd42a1e5
MW
338###--------------------------------------------------------------------------
339### Commands: setup
340
c77687d5 341def cmd_setup(args):
342 OS.mkdir('repos')
060ca767 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}''')
c77687d5 347 cmd_newmaster(args)
060ca767 348
fd42a1e5
MW
349###--------------------------------------------------------------------------
350### Commands: upload
351
060ca767 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)
c77687d5 359 if (f.startswith('master') or f.startswith('peer-')) \
360 and f.endswith('.old'):
060ca767 361 OS.unlink(ff)
362 continue
c77687d5 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)
e04c2d50 401 rmtree('tmp')
b14ccd2f 402 run('sh -c ${upload-hook}')
060ca767 403
fd42a1e5
MW
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
060ca767 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')
c77687d5 424 seq = int(conf['master-sequence'])
162fcf48
MW
425 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
426 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
060ca767 427 run('tar xfz tripe-keys.tar.gz')
428
429 ## Verify the signature
c77687d5 430 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
431 got = fingerprint('repos/master.pub', 'master-%d' % seq)
060ca767 432 if want != got: raise VerifyError
c77687d5 433 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
434 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
060ca767 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')
c77687d5 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')
060ca767 443 rmtree('repos.old')
444
445 finally:
446 OS.chdir(cwd)
447 rmtree('tmp')
448 cmd_rebuild(args)
449
fd42a1e5
MW
450###--------------------------------------------------------------------------
451### Commands: generate TAG
060ca767 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}' %
c77687d5 459 tag)
ca6eb20c 460 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
060ca767 461
fd42a1e5
MW
462###--------------------------------------------------------------------------
463### Commands: clean
464
060ca767 465def cmd_clean(args):
466 rmtree('repos')
467 rmtree('tmp')
c77687d5 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)
060ca767 474
fd42a1e5
MW
475###--------------------------------------------------------------------------
476### Main driver.
060ca767 477
fd42a1e5 478## Exceptions.
060ca767 479class UsageError (Exception): pass
e04c2d50 480
060ca767 481commands = {'help': (cmd_help, 0, 1, ''),
c77687d5 482 'newmaster': (cmd_newmaster, 0, 0, ''),
060ca767 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():
fd42a1e5
MW
491 """
492 Load the appropriate configuration file and set up the configuration
493 dictionary.
494 """
060ca767 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()
fd42a1e5 500
060ca767 501def main(argv):
fd42a1e5
MW
502 """
503 Main program: parse options and dispatch to appropriate command handler.
504 """
060ca767 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
fd42a1e5
MW
532###----- That's all, folks --------------------------------------------------
533
534if __name__ == '__main__':
535 init()
536 main(SYS.argv)