ce9f74dc35b171bfb139d83f2c682cb4b7735785
[tripe] / keys / tripe-keys.in
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
30 import catacomb as C
31 import os as OS
32 import sys as SYS
33 import re as RX
34 import getopt as O
35 import shutil as SH
36 import time as T
37 import filecmp as FC
38 from cStringIO import StringIO
39 from errno import *
40 from stat import *
41
42 ###--------------------------------------------------------------------------
43 ### Useful regular expressions
44
45 ## Match a comment or blank line.
46 rx_comment = RX.compile(r'^\s*(#|$)')
47
48 ## Match a KEY = VALUE assignment.
49 rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
50
51 ## Match a ${KEY} substitution.
52 rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
53
54 ## Match a @TAG@ substitution.
55 rx_atsubst = RX.compile(r'@([-\w]+)@')
56
57 ## Match a single non-alphanumeric character.
58 rx_nonalpha = RX.compile(r'\W')
59
60 ## Match the literal string "<SEQ>".
61 rx_seq = RX.compile(r'\<SEQ\>')
62
63 ## Match a shell metacharacter.
64 rx_shmeta = RX.compile('[\\s`!"#$&*()\\[\\];\'|<>?\\\\]')
65
66 ## Match a character which needs escaping in a shell double-quoted string.
67 rx_shquote = RX.compile(r'["`$\\]')
68
69 ###--------------------------------------------------------------------------
70 ### Utility functions.
71
72 ## Exceptions.
73 class SubprocessError (Exception): pass
74 class VerifyError (Exception): pass
75
76 ## Program name and identification.
77 quis = OS.path.basename(SYS.argv[0])
78 PACKAGE = "@PACKAGE@"
79 VERSION = "@VERSION@"
80
81 def moan(msg):
82 """Report MSG to standard error."""
83 SYS.stderr.write('%s: %s\n' % (quis, msg))
84
85 def die(msg, rc = 1):
86 """Report MSG to standard error, and exit with code RC."""
87 moan(msg)
88 SYS.exit(rc)
89
90 def 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
107 def 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
121 def 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
141 def 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
149 def 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
172 def 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
182 def 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.
196 class ConfigFileError (Exception): pass
197
198 ## The configuration dictionary.
199 conf = {}
200
201 def 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
210 def 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
225 def 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-genalg', lambda: {'dh': 'dh',
242 'ec': 'ec'}[conf['kx']]),
243 ('kx-param-genalg', lambda: {'dh': 'dh-param',
244 'ec': 'ec-param'}[conf['kx']]),
245 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
246 'ec': '-Cnist-p256'}[conf['kx']]),
247 ('kx-expire', 'now + 1 year'),
248 ('kx-warn-days', '28'),
249 ('cipher', 'rijndael-cbc'),
250 ('hash', 'sha256'),
251 ('master-keygen-flags', '-l'),
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']]),
264 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
265 'dsa': '-b3072 -B256',
266 'ec': '-Cnist-p256',
267 'rsa': '-b3072'}[conf['sig-genalg']]),
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
281 ###--------------------------------------------------------------------------
282 ### Key-management utilities.
283
284 def 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
297 def 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
305 def 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
316 def 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...]
325
326 def version(fp = SYS.stdout):
327 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
328
329 def usage(fp):
330 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
331
332 def cmd_help(args):
333 if len(args) == 0:
334 version(SYS.stdout)
335 print
336 usage(SYS.stdout)
337 print """
338 Key management utility for TrIPE.
339
340 Options supported:
341
342 -h, --help Show this help message.
343 -v, --version Show the version number.
344 -u, --usage Show pointlessly short usage string.
345
346 Subcommands available:
347 """
348 args = commands.keys()
349 args.sort()
350 for c in args:
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)
354
355 ###--------------------------------------------------------------------------
356 ### Commands: newmaster
357
358 def cmd_newmaster(args):
359 seq = max_master_sequence() + 1
360 run('''key -kmaster add
361 -a${sig-genalg} !${sig-param}
362 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
363 sig=${sig} hash=${sig-hash}''' % seq)
364 run('key -kmaster extract -f-secret repos/master.pub')
365
366 ###--------------------------------------------------------------------------
367 ### Commands: setup
368
369 def cmd_setup(args):
370 OS.mkdir('repos')
371 run('''key -krepos/param add
372 -a${kx-param-genalg} !${kx-param}
373 -eforever -tparam tripe-param
374 kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
375 cmd_newmaster(args)
376
377 ###--------------------------------------------------------------------------
378 ### Commands: upload
379
380 def 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)
387 if (f.startswith('master') or f.startswith('peer-')) \
388 and f.endswith('.old'):
389 OS.unlink(ff)
390 continue
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)
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))
437 finally:
438 OS.chdir(cwd)
439 rmtree('tmp')
440 run('sh -c ${upload-hook}')
441
442 ###--------------------------------------------------------------------------
443 ### Commands: rebuild
444
445 def 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
454 def 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')
462 seq = int(conf['master-sequence'])
463 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
464 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
465 run('tar xfz tripe-keys.tar.gz')
466
467 ## Verify the signature
468 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
469 got = fingerprint('repos/master.pub', 'master-%d' % seq)
470 if want != got: raise VerifyError
471 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
472 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
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')
478 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
479 moan('configuration file changed: recommend running another update')
480 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
481 rmtree('repos.old')
482
483 finally:
484 OS.chdir(cwd)
485 rmtree('tmp')
486 cmd_rebuild(args)
487
488 ###--------------------------------------------------------------------------
489 ### Commands: generate TAG
490
491 def 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')
496 run('key -kkeyring add -a${kx-genalg} -pparam -e${kx-expire} -t%s tripe' %
497 tag)
498 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
499
500 ###--------------------------------------------------------------------------
501 ### Commands: clean
502
503 def cmd_clean(args):
504 rmtree('repos')
505 rmtree('tmp')
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)
512
513 ###--------------------------------------------------------------------------
514 ### Commands: check
515
516 def check_key(k):
517 now = T.time()
518 thresh = int(conf['kx-warn-days']) * 86400
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
531 def 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
544
545 ###--------------------------------------------------------------------------
546 ### Commands: mtu
547
548 def 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
569 ###--------------------------------------------------------------------------
570 ### Main driver.
571
572 commands = {'help': (cmd_help, 0, 1, ''),
573 'newmaster': (cmd_newmaster, 0, 0, ''),
574 'setup': (cmd_setup, 0, 0, ''),
575 'upload': (cmd_upload, 0, 0, ''),
576 'update': (cmd_update, 0, 0, ''),
577 'clean': (cmd_clean, 0, 0, ''),
578 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
579 'check': (cmd_check, 0, 0, ''),
580 'generate': (cmd_generate, 1, 1, 'TAG'),
581 'rebuild': (cmd_rebuild, 0, 0, '')}
582
583 def init():
584 """
585 Load the appropriate configuration file and set up the configuration
586 dictionary.
587 """
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()
593
594 def main(argv):
595 """
596 Main program: parse options and dispatch to appropriate command handler.
597 """
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]
619 try: func, min, max, help = commands[c]
620 except KeyError: die("unknown command `%s'" % c)
621 args = argv[2:]
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)
625 func(args)
626
627 ###----- That's all, folks --------------------------------------------------
628
629 if __name__ == '__main__':
630 init()
631 main(SYS.argv)