chiark / gitweb /
keys/tripe-keys.in: Remove unrecognized files from `base-dir'.
[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
c2f28e4b 36import time as T
c77687d5 37import filecmp as FC
060ca767 38from cStringIO import StringIO
39from errno import *
40from stat import *
41
fd42a1e5 42###--------------------------------------------------------------------------
060ca767 43### Useful regular expressions
44
fd42a1e5 45## Match a comment or blank line.
c77687d5 46rx_comment = RX.compile(r'^\s*(#|$)')
fd42a1e5
MW
47
48## Match a KEY = VALUE assignment.
c77687d5 49rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
fd42a1e5
MW
50
51## Match a ${KEY} substitution.
c77687d5 52rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
fd42a1e5
MW
53
54## Match a @TAG@ substitution.
c77687d5 55rx_atsubst = RX.compile(r'@([-\w]+)@')
fd42a1e5
MW
56
57## Match a single non-alphanumeric character.
c77687d5 58rx_nonalpha = RX.compile(r'\W')
fd42a1e5
MW
59
60## Match the literal string "<SEQ>".
c77687d5 61rx_seq = RX.compile(r'\<SEQ\>')
060ca767 62
6005ef9b
MW
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
fd42a1e5
MW
69###--------------------------------------------------------------------------
70### Utility functions.
060ca767 71
fd42a1e5 72## Exceptions.
060ca767 73class SubprocessError (Exception): pass
74class VerifyError (Exception): pass
75
fd42a1e5 76## Program name and identification.
060ca767 77quis = OS.path.basename(SYS.argv[0])
78PACKAGE = "@PACKAGE@"
79VERSION = "@VERSION@"
80
81def moan(msg):
fd42a1e5 82 """Report MSG to standard error."""
060ca767 83 SYS.stderr.write('%s: %s\n' % (quis, msg))
84
85def die(msg, rc = 1):
fd42a1e5 86 """Report MSG to standard error, and exit with code RC."""
060ca767 87 moan(msg)
88 SYS.exit(rc)
89
90def subst(s, rx, map):
fd42a1e5
MW
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 """
060ca767 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
6005ef9b
MW
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
060ca767 121def rmtree(path):
fd42a1e5 122 """Delete the directory tree given by PATH."""
060ca767 123 try:
c77687d5 124 st = OS.lstat(path)
060ca767 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):
fd42a1e5 142 """Delete the named FILE if it exists; otherwise do nothing."""
060ca767 143 try:
144 OS.unlink(file)
145 except OSError, err:
146 if err.errno == ENOENT: return
147 raise
148
149def run(args):
fd42a1e5
MW
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 """
060ca767 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
6005ef9b 166 print '+ %s' % ' '.join([shell_quotify(arg) for arg in args])
8cae2567 167 SYS.stdout.flush()
060ca767 168 rc = OS.spawnvp(OS.P_WAIT, args[0], args)
169 if rc != 0:
170 raise SubprocessError, rc
171
172def hexhyphens(bytes):
fd42a1e5
MW
173 """
174 Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
175 """
060ca767 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):
fd42a1e5
MW
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 """
060ca767 188 h = C.gchashes[conf['fingerprint-hash']]()
189 k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
190 return h.done()
191
fd42a1e5
MW
192###--------------------------------------------------------------------------
193### The configuration file.
060ca767 194
fd42a1e5 195## Exceptions.
060ca767 196class ConfigFileError (Exception): pass
fd42a1e5
MW
197
198## The configuration dictionary.
060ca767 199conf = {}
200
fd42a1e5
MW
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)
060ca767 209
060ca767 210def conf_read(f):
fd42a1e5
MW
211 """
212 Read the file F and insert assignments into the configuration dictionary.
213 """
060ca767 214 lno = 0
215 for line in file(f):
216 lno += 1
c77687d5 217 if rx_comment.match(line): continue
060ca767 218 if line[-1] == '\n': line = line[:-1]
c77687d5 219 match = rx_keyval.match(line)
060ca767 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
060ca767 225def conf_defaults():
fd42a1e5
MW
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 """
c77687d5 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}'),
060ca767 238 ('conf-file', '${base-dir}tripe-keys.conf'),
b14ccd2f 239 ('upload-hook', ': run upload hook'),
060ca767 240 ('kx', 'dh'),
ca3aaaeb 241 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
060ca767 242 'ec': '-Cnist-p256'}[conf['kx']]),
243 ('kx-expire', 'now + 1 year'),
c2f28e4b 244 ('kx-warn-days', '28'),
ca3aaaeb 245 ('cipher', 'rijndael-cbc'),
060ca767 246 ('hash', 'sha256'),
7858dfa0 247 ('master-keygen-flags', '-l'),
060ca767 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']]),
ca3aaaeb
MW
260 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
261 'dsa': '-b3072 -B256',
060ca767 262 'ec': '-Cnist-p256',
ca3aaaeb 263 'rsa': '-b3072'}[conf['sig-genalg']]),
060ca767 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
fd42a1e5
MW
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...]
060ca767 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
e04c2d50
MW
338-h, --help Show this help message.
339-v, --version Show the version number.
340-u, --usage Show pointlessly short usage string.
060ca767 341
342Subcommands available:
343"""
344 args = commands.keys()
345 args.sort()
346 for c in args:
db76b51b
MW
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)
060ca767 350
fd42a1e5
MW
351###--------------------------------------------------------------------------
352### Commands: newmaster
c77687d5 353
354def cmd_newmaster(args):
355 seq = max_master_sequence() + 1
060ca767 356 run('''key -kmaster add
357 -a${sig-genalg} !${sig-param}
7858dfa0 358 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
c77687d5 359 sig=${sig} hash=${sig-hash}''' % seq)
360 run('key -kmaster extract -f-secret repos/master.pub')
060ca767 361
fd42a1e5
MW
362###--------------------------------------------------------------------------
363### Commands: setup
364
c77687d5 365def cmd_setup(args):
366 OS.mkdir('repos')
060ca767 367 run('''key -krepos/param add
368 -a${kx}-param !${kx-param}
fc5f4823
MW
369 -eforever -tparam tripe-param
370 kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
c77687d5 371 cmd_newmaster(args)
060ca767 372
fd42a1e5
MW
373###--------------------------------------------------------------------------
374### Commands: upload
375
060ca767 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)
c77687d5 383 if (f.startswith('master') or f.startswith('peer-')) \
384 and f.endswith('.old'):
060ca767 385 OS.unlink(ff)
386 continue
c77687d5 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)
838e5ce7
MW
423
424 ## Remove files in the base-dir which don't correspond to ones we just
425 ## committed
426 allow = {}
427 basedir = conf['base-dir']
428 bdl = len(basedir)
429 for base in commit:
430 if base.startswith(basedir): allow[base[bdl:]] = 1
431 for found in OS.listdir(basedir):
432 if found not in allow: OS.remove(OS.path.join(basedir, found))
c77687d5 433 finally:
434 OS.chdir(cwd)
e04c2d50 435 rmtree('tmp')
b14ccd2f 436 run('sh -c ${upload-hook}')
060ca767 437
fd42a1e5
MW
438###--------------------------------------------------------------------------
439### Commands: rebuild
440
441def cmd_rebuild(args):
442 zap('keyring.pub')
443 for i in OS.listdir('repos'):
444 if i.startswith('peer-') and i.endswith('.pub'):
445 run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
446
447###--------------------------------------------------------------------------
448### Commands: update
449
060ca767 450def cmd_update(args):
451 cwd = OS.getcwd()
452 rmtree('tmp')
453 try:
454
455 ## Fetch a new distribution
456 OS.mkdir('tmp')
457 OS.chdir('tmp')
c77687d5 458 seq = int(conf['master-sequence'])
162fcf48
MW
459 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
460 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
060ca767 461 run('tar xfz tripe-keys.tar.gz')
462
463 ## Verify the signature
c77687d5 464 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
465 got = fingerprint('repos/master.pub', 'master-%d' % seq)
060ca767 466 if want != got: raise VerifyError
c77687d5 467 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
468 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
060ca767 469
470 ## OK: update our copy
471 OS.chdir(cwd)
472 if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
473 OS.rename('tmp/repos', 'repos')
f56dbbc4 474 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
c77687d5 475 moan('configuration file changed: recommend running another update')
476 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
060ca767 477 rmtree('repos.old')
478
479 finally:
480 OS.chdir(cwd)
481 rmtree('tmp')
482 cmd_rebuild(args)
483
fd42a1e5
MW
484###--------------------------------------------------------------------------
485### Commands: generate TAG
060ca767 486
487def cmd_generate(args):
488 tag, = args
489 keyring_pub = 'peer-%s.pub' % tag
490 zap('keyring'); zap(keyring_pub)
491 run('key -kkeyring merge repos/param')
fc5f4823 492 run('key -kkeyring add -a${kx} -pparam -e${kx-expire} -t%s tripe' %
c77687d5 493 tag)
ca6eb20c 494 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
060ca767 495
fd42a1e5
MW
496###--------------------------------------------------------------------------
497### Commands: clean
498
060ca767 499def cmd_clean(args):
500 rmtree('repos')
501 rmtree('tmp')
c77687d5 502 for i in OS.listdir('.'):
503 r = i
504 if r.endswith('.old'): r = r[:-4]
505 if (r == 'master' or r == 'param' or
506 r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
507 zap(i)
060ca767 508
c2f28e4b
MW
509###--------------------------------------------------------------------------
510### Commands: check
511
24285984 512def check_key(k):
c2f28e4b
MW
513 now = T.time()
514 thresh = int(conf['kx-warn-days']) * 86400
24285984
MW
515 if k.exptime == C.KEXP_FOREVER: return None
516 elif k.exptime == C.KEXP_EXPIRE: left = -1
517 else: left = k.exptime - now
518 if left < 0:
519 return "key `%s' HAS EXPIRED" % k.tag
520 elif left < thresh:
521 if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
522 else: n, u, uu = left // 3600, 'hour', 'hours'
523 return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
524 else:
525 return None
526
527def cmd_check(args):
528 if OS.path.exists('keyring.pub'):
529 for k in C.KeyFile('keyring.pub').itervalues():
530 whinge = check_key(k)
531 if whinge is not None: print whinge
532 if OS.path.exists('master'):
533 whinges = []
534 for k in C.KeyFile('master').itervalues():
535 whinge = check_key(k)
536 if whinge is None: break
537 whinges.append(whinge)
538 else:
539 for whinge in whinges: print whinge
c2f28e4b 540
65faf8df
MW
541###--------------------------------------------------------------------------
542### Commands: mtu
543
544def cmd_mtu(args):
545 mtu, = (lambda mtu = '1500': (mtu,))(*args)
546 mtu = int(mtu)
547
548 blksz = C.gcciphers[conf['cipher']].blksz
549
550 index = conf['mac'].find('/')
551 if index == -1:
552 tagsz = C.gcmacs[conf['mac']].tagsz
553 else:
554 tagsz = int(conf['mac'][index + 1:])/8
555
556 mtu -= 20 # Minimum IP header
557 mtu -= 8 # UDP header
558 mtu -= 1 # TrIPE packet type octet
559 mtu -= tagsz # MAC tag
560 mtu -= 4 # Sequence number
561 mtu -= blksz # Initialization vector
562
563 print mtu
564
fd42a1e5
MW
565###--------------------------------------------------------------------------
566### Main driver.
060ca767 567
060ca767 568commands = {'help': (cmd_help, 0, 1, ''),
c77687d5 569 'newmaster': (cmd_newmaster, 0, 0, ''),
060ca767 570 'setup': (cmd_setup, 0, 0, ''),
571 'upload': (cmd_upload, 0, 0, ''),
572 'update': (cmd_update, 0, 0, ''),
573 'clean': (cmd_clean, 0, 0, ''),
65faf8df 574 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
c2f28e4b 575 'check': (cmd_check, 0, 0, ''),
060ca767 576 'generate': (cmd_generate, 1, 1, 'TAG'),
577 'rebuild': (cmd_rebuild, 0, 0, '')}
578
579def init():
fd42a1e5
MW
580 """
581 Load the appropriate configuration file and set up the configuration
582 dictionary.
583 """
060ca767 584 for f in ['tripe-keys.master', 'tripe-keys.conf']:
585 if OS.path.exists(f):
586 conf_read(f)
587 break
588 conf_defaults()
fd42a1e5 589
060ca767 590def main(argv):
fd42a1e5
MW
591 """
592 Main program: parse options and dispatch to appropriate command handler.
593 """
060ca767 594 try:
595 opts, args = O.getopt(argv[1:], 'hvu',
596 ['help', 'version', 'usage'])
597 except O.GetoptError, exc:
598 moan(exc)
599 usage(SYS.stderr)
600 SYS.exit(1)
601 for o, v in opts:
602 if o in ('-h', '--help'):
603 cmd_help([])
604 SYS.exit(0)
605 elif o in ('-v', '--version'):
606 version(SYS.stdout)
607 SYS.exit(0)
608 elif o in ('-u', '--usage'):
609 usage(SYS.stdout)
610 SYS.exit(0)
611 if len(argv) < 2:
612 cmd_help([])
613 else:
614 c = argv[1]
db76b51b
MW
615 try: func, min, max, help = commands[c]
616 except KeyError: die("unknown command `%s'" % c)
060ca767 617 args = argv[2:]
db76b51b
MW
618 if len(args) < min or (max is not None and len(args) > max):
619 SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
620 SYS.exit(1)
060ca767 621 func(args)
622
fd42a1e5
MW
623###----- That's all, folks --------------------------------------------------
624
625if __name__ == '__main__':
626 init()
627 main(SYS.argv)