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