chiark / gitweb /
py: New Python module for writing services and suchlike
[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
c55f0b7c 33import re as RX
060ca767 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)
8cae2567 146 SYS.stdout.flush()
060ca767 147 rc = OS.spawnvp(OS.P_WAIT, args[0], args)
148 if rc != 0:
149 raise SubprocessError, rc
150
151def hexhyphens(bytes):
fd42a1e5
MW
152 """
153 Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
154 """
060ca767 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
161def fingerprint(kf, ktag):
fd42a1e5
MW
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 """
060ca767 167 h = C.gchashes[conf['fingerprint-hash']]()
168 k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
169 return h.done()
170
fd42a1e5
MW
171###--------------------------------------------------------------------------
172### The configuration file.
060ca767 173
fd42a1e5 174## Exceptions.
060ca767 175class ConfigFileError (Exception): pass
fd42a1e5
MW
176
177## The configuration dictionary.
060ca767 178conf = {}
179
fd42a1e5
MW
180def 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)
060ca767 188
060ca767 189def conf_read(f):
fd42a1e5
MW
190 """
191 Read the file F and insert assignments into the configuration dictionary.
192 """
060ca767 193 lno = 0
194 for line in file(f):
195 lno += 1
c77687d5 196 if rx_comment.match(line): continue
060ca767 197 if line[-1] == '\n': line = line[:-1]
c77687d5 198 match = rx_keyval.match(line)
060ca767 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
060ca767 204def conf_defaults():
fd42a1e5
MW
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 """
c77687d5 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}'),
060ca767 217 ('conf-file', '${base-dir}tripe-keys.conf'),
b14ccd2f 218 ('upload-hook', ': run upload hook'),
060ca767 219 ('kx', 'dh'),
ca3aaaeb 220 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
060ca767 221 'ec': '-Cnist-p256'}[conf['kx']]),
222 ('kx-expire', 'now + 1 year'),
ca3aaaeb 223 ('cipher', 'rijndael-cbc'),
060ca767 224 ('hash', 'sha256'),
7858dfa0 225 ('master-keygen-flags', '-l'),
060ca767 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']]),
ca3aaaeb
MW
238 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
239 'dsa': '-b3072 -B256',
060ca767 240 'ec': '-Cnist-p256',
ca3aaaeb 241 'rsa': '-b3072'}[conf['sig-genalg']]),
060ca767 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
fd42a1e5
MW
255###--------------------------------------------------------------------------
256### Key-management utilities.
257
258def 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
271def 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
279def 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
290def 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...]
060ca767 299
300def version(fp = SYS.stdout):
301 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
302
303def usage(fp):
304 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
305
306def cmd_help(args):
307 if len(args) == 0:
308 version(SYS.stdout)
309 print
310 usage(SYS.stdout)
311 print """
312Key management utility for TrIPE.
313
314Options supported:
315
e04c2d50
MW
316-h, --help Show this help message.
317-v, --version Show the version number.
318-u, --usage Show pointlessly short usage string.
060ca767 319
320Subcommands 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
fd42a1e5
MW
328###--------------------------------------------------------------------------
329### Commands: newmaster
c77687d5 330
331def cmd_newmaster(args):
332 seq = max_master_sequence() + 1
060ca767 333 run('''key -kmaster add
334 -a${sig-genalg} !${sig-param}
7858dfa0 335 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
c77687d5 336 sig=${sig} hash=${sig-hash}''' % seq)
337 run('key -kmaster extract -f-secret repos/master.pub')
060ca767 338
fd42a1e5
MW
339###--------------------------------------------------------------------------
340### Commands: setup
341
c77687d5 342def cmd_setup(args):
343 OS.mkdir('repos')
060ca767 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}''')
c77687d5 348 cmd_newmaster(args)
060ca767 349
fd42a1e5
MW
350###--------------------------------------------------------------------------
351### Commands: upload
352
060ca767 353def 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)
c77687d5 360 if (f.startswith('master') or f.startswith('peer-')) \
361 and f.endswith('.old'):
060ca767 362 OS.unlink(ff)
363 continue
c77687d5 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)
e04c2d50 402 rmtree('tmp')
b14ccd2f 403 run('sh -c ${upload-hook}')
060ca767 404
fd42a1e5
MW
405###--------------------------------------------------------------------------
406### Commands: rebuild
407
408def 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
060ca767 417def 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')
c77687d5 425 seq = int(conf['master-sequence'])
162fcf48
MW
426 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
427 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
060ca767 428 run('tar xfz tripe-keys.tar.gz')
429
430 ## Verify the signature
c77687d5 431 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
432 got = fingerprint('repos/master.pub', 'master-%d' % seq)
060ca767 433 if want != got: raise VerifyError
c77687d5 434 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
435 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
060ca767 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')
c77687d5 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')
060ca767 444 rmtree('repos.old')
445
446 finally:
447 OS.chdir(cwd)
448 rmtree('tmp')
449 cmd_rebuild(args)
450
fd42a1e5
MW
451###--------------------------------------------------------------------------
452### Commands: generate TAG
060ca767 453
454def 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}' %
c77687d5 460 tag)
ca6eb20c 461 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
060ca767 462
fd42a1e5
MW
463###--------------------------------------------------------------------------
464### Commands: clean
465
060ca767 466def cmd_clean(args):
467 rmtree('repos')
468 rmtree('tmp')
c77687d5 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)
060ca767 475
fd42a1e5 476###--------------------------------------------------------------------------
65faf8df
MW
477### Commands: mtu
478
479def cmd_mtu(args):
480 mtu, = (lambda mtu = '1500': (mtu,))(*args)
481 mtu = int(mtu)
482
483 blksz = C.gcciphers[conf['cipher']].blksz
484
485 index = conf['mac'].find('/')
486 if index == -1:
487 tagsz = C.gcmacs[conf['mac']].tagsz
488 else:
489 tagsz = int(conf['mac'][index + 1:])/8
490
491 mtu -= 20 # Minimum IP header
492 mtu -= 8 # UDP header
493 mtu -= 1 # TrIPE packet type octet
494 mtu -= tagsz # MAC tag
495 mtu -= 4 # Sequence number
496 mtu -= blksz # Initialization vector
497
498 print mtu
499
500###--------------------------------------------------------------------------
fd42a1e5 501### Main driver.
060ca767 502
fd42a1e5 503## Exceptions.
060ca767 504class UsageError (Exception): pass
e04c2d50 505
060ca767 506commands = {'help': (cmd_help, 0, 1, ''),
c77687d5 507 'newmaster': (cmd_newmaster, 0, 0, ''),
060ca767 508 'setup': (cmd_setup, 0, 0, ''),
509 'upload': (cmd_upload, 0, 0, ''),
510 'update': (cmd_update, 0, 0, ''),
511 'clean': (cmd_clean, 0, 0, ''),
65faf8df 512 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
060ca767 513 'generate': (cmd_generate, 1, 1, 'TAG'),
514 'rebuild': (cmd_rebuild, 0, 0, '')}
515
516def init():
fd42a1e5
MW
517 """
518 Load the appropriate configuration file and set up the configuration
519 dictionary.
520 """
060ca767 521 for f in ['tripe-keys.master', 'tripe-keys.conf']:
522 if OS.path.exists(f):
523 conf_read(f)
524 break
525 conf_defaults()
fd42a1e5 526
060ca767 527def main(argv):
fd42a1e5
MW
528 """
529 Main program: parse options and dispatch to appropriate command handler.
530 """
060ca767 531 try:
532 opts, args = O.getopt(argv[1:], 'hvu',
533 ['help', 'version', 'usage'])
534 except O.GetoptError, exc:
535 moan(exc)
536 usage(SYS.stderr)
537 SYS.exit(1)
538 for o, v in opts:
539 if o in ('-h', '--help'):
540 cmd_help([])
541 SYS.exit(0)
542 elif o in ('-v', '--version'):
543 version(SYS.stdout)
544 SYS.exit(0)
545 elif o in ('-u', '--usage'):
546 usage(SYS.stdout)
547 SYS.exit(0)
548 if len(argv) < 2:
549 cmd_help([])
550 else:
551 c = argv[1]
552 func, min, max, help = commands[c]
553 args = argv[2:]
554 if len(args) < min or (max > 0 and len(args) > max):
555 raise UsageError, (c, help)
556 func(args)
557
fd42a1e5
MW
558###----- That's all, folks --------------------------------------------------
559
560if __name__ == '__main__':
561 init()
562 main(SYS.argv)