chiark / gitweb /
keys/tripe-keys.in: Provide `kx-genalg' and `kx-param-genalg' options.
[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'),
256bc8d0
MW
241 ('kx-genalg', lambda: {'dh': 'dh',
242 'ec': 'ec'}[conf['kx']]),
243 ('kx-param-genalg', lambda: {'dh': 'dh-param',
244 'ec': 'ec-param'}[conf['kx']]),
ca3aaaeb 245 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
060ca767 246 'ec': '-Cnist-p256'}[conf['kx']]),
247 ('kx-expire', 'now + 1 year'),
c2f28e4b 248 ('kx-warn-days', '28'),
ca3aaaeb 249 ('cipher', 'rijndael-cbc'),
060ca767 250 ('hash', 'sha256'),
7858dfa0 251 ('master-keygen-flags', '-l'),
060ca767 252 ('mgf', '${hash}-mgf'),
253 ('mac', lambda: '%s-hmac/%d' %
254 (conf['hash'],
255 C.gchashes[conf['hash']].hashsz * 4)),
256 ('sig', lambda: {'dh': 'dsa', 'ec': 'ecdsa'}[conf['kx']]),
257 ('sig-fresh', 'always'),
258 ('sig-genalg', lambda: {'kcdsa': 'dh',
259 'dsa': 'dsa',
260 'rsapkcs1': 'rsa',
261 'rsapss': 'rsa',
262 'ecdsa': 'ec',
263 'eckcdsa': 'ec'}[conf['sig']]),
ca3aaaeb
MW
264 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
265 'dsa': '-b3072 -B256',
060ca767 266 'ec': '-Cnist-p256',
ca3aaaeb 267 'rsa': '-b3072'}[conf['sig-genalg']]),
060ca767 268 ('sig-hash', '${hash}'),
269 ('sig-expire', 'forever'),
270 ('fingerprint-hash', '${hash}')]:
271 try:
272 if k in conf: continue
273 if type(v) == str:
274 conf[k] = conf_subst(v)
275 else:
276 conf[k] = v()
277 except KeyError, exc:
278 if len(exc.args) == 0: raise
279 conf[k] = '<missing-var %s>' % exc.args[0]
280
fd42a1e5
MW
281###--------------------------------------------------------------------------
282### Key-management utilities.
283
284def master_keys():
285 """
286 Iterate over the master keys.
287 """
288 if not OS.path.exists('master'):
289 return
290 for k in C.KeyFile('master').itervalues():
291 if (k.type != 'tripe-keys-master' or
292 k.expiredp or
293 not k.tag.startswith('master-')):
294 continue #??
295 yield k
296
297def master_sequence(k):
298 """
299 Return the sequence number of the given master key as an integer.
300
301 No checking is done that K is really a master key.
302 """
303 return int(k.tag[7:])
304
305def max_master_sequence():
306 """
307 Find the master key with the highest sequence number and return this
308 sequence number.
309 """
310 seq = -1
311 for k in master_keys():
312 q = master_sequence(k)
313 if q > seq: seq = q
314 return seq
315
316def seqsubst(x, q):
317 """
318 Return the value of the configuration variable X, with <SEQ> replaced by
319 the value Q.
320 """
321 return rx_seq.sub(str(q), conf[x])
322
323###--------------------------------------------------------------------------
324### Commands: help [COMMAND...]
060ca767 325
326def version(fp = SYS.stdout):
327 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
328
329def usage(fp):
330 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
331
332def cmd_help(args):
333 if len(args) == 0:
334 version(SYS.stdout)
335 print
336 usage(SYS.stdout)
337 print """
338Key management utility for TrIPE.
339
340Options supported:
341
e04c2d50
MW
342-h, --help Show this help message.
343-v, --version Show the version number.
344-u, --usage Show pointlessly short usage string.
060ca767 345
346Subcommands available:
347"""
348 args = commands.keys()
349 args.sort()
350 for c in args:
db76b51b
MW
351 try: func, min, max, help = commands[c]
352 except KeyError: die("unknown command `%s'" % c)
353 print '%s%s%s' % (c, help and ' ', help)
060ca767 354
fd42a1e5
MW
355###--------------------------------------------------------------------------
356### Commands: newmaster
c77687d5 357
358def cmd_newmaster(args):
359 seq = max_master_sequence() + 1
060ca767 360 run('''key -kmaster add
361 -a${sig-genalg} !${sig-param}
7858dfa0 362 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
c77687d5 363 sig=${sig} hash=${sig-hash}''' % seq)
364 run('key -kmaster extract -f-secret repos/master.pub')
060ca767 365
fd42a1e5
MW
366###--------------------------------------------------------------------------
367### Commands: setup
368
c77687d5 369def cmd_setup(args):
370 OS.mkdir('repos')
060ca767 371 run('''key -krepos/param add
256bc8d0 372 -a${kx-param-genalg} !${kx-param}
fc5f4823
MW
373 -eforever -tparam tripe-param
374 kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
c77687d5 375 cmd_newmaster(args)
060ca767 376
fd42a1e5
MW
377###--------------------------------------------------------------------------
378### Commands: upload
379
060ca767 380def cmd_upload(args):
381
382 ## Sanitize the repository directory
383 umask = OS.umask(0); OS.umask(umask)
384 mode = 0666 & ~umask
385 for f in OS.listdir('repos'):
386 ff = OS.path.join('repos', f)
c77687d5 387 if (f.startswith('master') or f.startswith('peer-')) \
388 and f.endswith('.old'):
060ca767 389 OS.unlink(ff)
390 continue
c77687d5 391 OS.chmod(ff, mode)
392
393 rmtree('tmp')
394 OS.mkdir('tmp')
395 OS.symlink('../repos', 'tmp/repos')
396 cwd = OS.getcwd()
397 try:
398
399 ## Build the configuration file
400 seq = max_master_sequence()
401 v = {'MASTER-SEQUENCE': str(seq),
402 'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
403 'master-%d' % seq))}
404 fin = file('tripe-keys.master')
405 fout = file('tmp/tripe-keys.conf', 'w')
406 for line in fin:
407 fout.write(subst(line, rx_atsubst, v))
408 fin.close(); fout.close()
409 SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
410 commit = [conf['repos-file'], conf['conf-file']]
411
412 ## Make and sign the repository archive
413 OS.chdir('tmp')
414 run('tar chozf ${repos-file}.new .')
415 OS.chdir(cwd)
416 for k in master_keys():
417 seq = master_sequence(k)
418 sigfile = seqsubst('sig-file', seq)
419 run('''catsign -kmaster sign -abdC -kmaster-%d
420 -o%s.new ${repos-file}.new''' % (seq, sigfile))
421 commit.append(sigfile)
422
423 ## Commit the changes
424 for base in commit:
425 new = '%s.new' % base
426 OS.rename(new, base)
838e5ce7
MW
427
428 ## Remove files in the base-dir which don't correspond to ones we just
429 ## committed
430 allow = {}
431 basedir = conf['base-dir']
432 bdl = len(basedir)
433 for base in commit:
434 if base.startswith(basedir): allow[base[bdl:]] = 1
435 for found in OS.listdir(basedir):
436 if found not in allow: OS.remove(OS.path.join(basedir, found))
c77687d5 437 finally:
438 OS.chdir(cwd)
e04c2d50 439 rmtree('tmp')
b14ccd2f 440 run('sh -c ${upload-hook}')
060ca767 441
fd42a1e5
MW
442###--------------------------------------------------------------------------
443### Commands: rebuild
444
445def cmd_rebuild(args):
446 zap('keyring.pub')
447 for i in OS.listdir('repos'):
448 if i.startswith('peer-') and i.endswith('.pub'):
449 run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
450
451###--------------------------------------------------------------------------
452### Commands: update
453
060ca767 454def cmd_update(args):
455 cwd = OS.getcwd()
456 rmtree('tmp')
457 try:
458
459 ## Fetch a new distribution
460 OS.mkdir('tmp')
461 OS.chdir('tmp')
c77687d5 462 seq = int(conf['master-sequence'])
162fcf48
MW
463 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
464 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
060ca767 465 run('tar xfz tripe-keys.tar.gz')
466
467 ## Verify the signature
c77687d5 468 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
469 got = fingerprint('repos/master.pub', 'master-%d' % seq)
060ca767 470 if want != got: raise VerifyError
c77687d5 471 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
472 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
060ca767 473
474 ## OK: update our copy
475 OS.chdir(cwd)
476 if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
477 OS.rename('tmp/repos', 'repos')
f56dbbc4 478 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
c77687d5 479 moan('configuration file changed: recommend running another update')
480 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
060ca767 481 rmtree('repos.old')
482
483 finally:
484 OS.chdir(cwd)
485 rmtree('tmp')
486 cmd_rebuild(args)
487
fd42a1e5
MW
488###--------------------------------------------------------------------------
489### Commands: generate TAG
060ca767 490
491def cmd_generate(args):
492 tag, = args
493 keyring_pub = 'peer-%s.pub' % tag
494 zap('keyring'); zap(keyring_pub)
495 run('key -kkeyring merge repos/param')
256bc8d0 496 run('key -kkeyring add -a${kx-genalg} -pparam -e${kx-expire} -t%s tripe' %
c77687d5 497 tag)
ca6eb20c 498 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
060ca767 499
fd42a1e5
MW
500###--------------------------------------------------------------------------
501### Commands: clean
502
060ca767 503def cmd_clean(args):
504 rmtree('repos')
505 rmtree('tmp')
c77687d5 506 for i in OS.listdir('.'):
507 r = i
508 if r.endswith('.old'): r = r[:-4]
509 if (r == 'master' or r == 'param' or
510 r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
511 zap(i)
060ca767 512
c2f28e4b
MW
513###--------------------------------------------------------------------------
514### Commands: check
515
24285984 516def check_key(k):
c2f28e4b
MW
517 now = T.time()
518 thresh = int(conf['kx-warn-days']) * 86400
24285984
MW
519 if k.exptime == C.KEXP_FOREVER: return None
520 elif k.exptime == C.KEXP_EXPIRE: left = -1
521 else: left = k.exptime - now
522 if left < 0:
523 return "key `%s' HAS EXPIRED" % k.tag
524 elif left < thresh:
525 if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
526 else: n, u, uu = left // 3600, 'hour', 'hours'
527 return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
528 else:
529 return None
530
531def cmd_check(args):
532 if OS.path.exists('keyring.pub'):
533 for k in C.KeyFile('keyring.pub').itervalues():
534 whinge = check_key(k)
535 if whinge is not None: print whinge
536 if OS.path.exists('master'):
537 whinges = []
538 for k in C.KeyFile('master').itervalues():
539 whinge = check_key(k)
540 if whinge is None: break
541 whinges.append(whinge)
542 else:
543 for whinge in whinges: print whinge
c2f28e4b 544
65faf8df
MW
545###--------------------------------------------------------------------------
546### Commands: mtu
547
548def cmd_mtu(args):
549 mtu, = (lambda mtu = '1500': (mtu,))(*args)
550 mtu = int(mtu)
551
552 blksz = C.gcciphers[conf['cipher']].blksz
553
554 index = conf['mac'].find('/')
555 if index == -1:
556 tagsz = C.gcmacs[conf['mac']].tagsz
557 else:
558 tagsz = int(conf['mac'][index + 1:])/8
559
560 mtu -= 20 # Minimum IP header
561 mtu -= 8 # UDP header
562 mtu -= 1 # TrIPE packet type octet
563 mtu -= tagsz # MAC tag
564 mtu -= 4 # Sequence number
565 mtu -= blksz # Initialization vector
566
567 print mtu
568
fd42a1e5
MW
569###--------------------------------------------------------------------------
570### Main driver.
060ca767 571
060ca767 572commands = {'help': (cmd_help, 0, 1, ''),
c77687d5 573 'newmaster': (cmd_newmaster, 0, 0, ''),
060ca767 574 'setup': (cmd_setup, 0, 0, ''),
575 'upload': (cmd_upload, 0, 0, ''),
576 'update': (cmd_update, 0, 0, ''),
577 'clean': (cmd_clean, 0, 0, ''),
65faf8df 578 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
c2f28e4b 579 'check': (cmd_check, 0, 0, ''),
060ca767 580 'generate': (cmd_generate, 1, 1, 'TAG'),
581 'rebuild': (cmd_rebuild, 0, 0, '')}
582
583def init():
fd42a1e5
MW
584 """
585 Load the appropriate configuration file and set up the configuration
586 dictionary.
587 """
060ca767 588 for f in ['tripe-keys.master', 'tripe-keys.conf']:
589 if OS.path.exists(f):
590 conf_read(f)
591 break
592 conf_defaults()
fd42a1e5 593
060ca767 594def main(argv):
fd42a1e5
MW
595 """
596 Main program: parse options and dispatch to appropriate command handler.
597 """
060ca767 598 try:
599 opts, args = O.getopt(argv[1:], 'hvu',
600 ['help', 'version', 'usage'])
601 except O.GetoptError, exc:
602 moan(exc)
603 usage(SYS.stderr)
604 SYS.exit(1)
605 for o, v in opts:
606 if o in ('-h', '--help'):
607 cmd_help([])
608 SYS.exit(0)
609 elif o in ('-v', '--version'):
610 version(SYS.stdout)
611 SYS.exit(0)
612 elif o in ('-u', '--usage'):
613 usage(SYS.stdout)
614 SYS.exit(0)
615 if len(argv) < 2:
616 cmd_help([])
617 else:
618 c = argv[1]
db76b51b
MW
619 try: func, min, max, help = commands[c]
620 except KeyError: die("unknown command `%s'" % c)
060ca767 621 args = argv[2:]
db76b51b
MW
622 if len(args) < min or (max is not None and len(args) > max):
623 SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
624 SYS.exit(1)
060ca767 625 func(args)
626
fd42a1e5
MW
627###----- That's all, folks --------------------------------------------------
628
629if __name__ == '__main__':
630 init()
631 main(SYS.argv)