47a4417c981886a38f2acfe308ac73fd2a50c77d
[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-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
280 def 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
293 def 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
301 def 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
312 def 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
322 def version(fp = SYS.stdout):
323 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
324
325 def usage(fp):
326 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
327
328 def cmd_help(args):
329 if len(args) == 0:
330 version(SYS.stdout)
331 print
332 usage(SYS.stdout)
333 print """
334 Key management utility for TrIPE.
335
336 Options 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
342 Subcommands available:
343 """
344 args = commands.keys()
345 args.sort()
346 for c in args:
347 func, min, max, help = commands[c]
348 print '%s %s' % (c, help)
349
350 ###--------------------------------------------------------------------------
351 ### Commands: newmaster
352
353 def cmd_newmaster(args):
354 seq = max_master_sequence() + 1
355 run('''key -kmaster add
356 -a${sig-genalg} !${sig-param}
357 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
358 sig=${sig} hash=${sig-hash}''' % seq)
359 run('key -kmaster extract -f-secret repos/master.pub')
360
361 ###--------------------------------------------------------------------------
362 ### Commands: setup
363
364 def cmd_setup(args):
365 OS.mkdir('repos')
366 run('''key -krepos/param add
367 -a${kx}-param !${kx-param}
368 -eforever -tparam tripe-param
369 kx-group=${kx} cipher=${cipher} hash=${hash} mac=${mac} mgf=${mgf}''')
370 cmd_newmaster(args)
371
372 ###--------------------------------------------------------------------------
373 ### Commands: upload
374
375 def cmd_upload(args):
376
377 ## Sanitize the repository directory
378 umask = OS.umask(0); OS.umask(umask)
379 mode = 0666 & ~umask
380 for f in OS.listdir('repos'):
381 ff = OS.path.join('repos', f)
382 if (f.startswith('master') or f.startswith('peer-')) \
383 and f.endswith('.old'):
384 OS.unlink(ff)
385 continue
386 OS.chmod(ff, mode)
387
388 rmtree('tmp')
389 OS.mkdir('tmp')
390 OS.symlink('../repos', 'tmp/repos')
391 cwd = OS.getcwd()
392 try:
393
394 ## Build the configuration file
395 seq = max_master_sequence()
396 v = {'MASTER-SEQUENCE': str(seq),
397 'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
398 'master-%d' % seq))}
399 fin = file('tripe-keys.master')
400 fout = file('tmp/tripe-keys.conf', 'w')
401 for line in fin:
402 fout.write(subst(line, rx_atsubst, v))
403 fin.close(); fout.close()
404 SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
405 commit = [conf['repos-file'], conf['conf-file']]
406
407 ## Make and sign the repository archive
408 OS.chdir('tmp')
409 run('tar chozf ${repos-file}.new .')
410 OS.chdir(cwd)
411 for k in master_keys():
412 seq = master_sequence(k)
413 sigfile = seqsubst('sig-file', seq)
414 run('''catsign -kmaster sign -abdC -kmaster-%d
415 -o%s.new ${repos-file}.new''' % (seq, sigfile))
416 commit.append(sigfile)
417
418 ## Commit the changes
419 for base in commit:
420 new = '%s.new' % base
421 OS.rename(new, base)
422 finally:
423 OS.chdir(cwd)
424 rmtree('tmp')
425 run('sh -c ${upload-hook}')
426
427 ###--------------------------------------------------------------------------
428 ### Commands: rebuild
429
430 def cmd_rebuild(args):
431 zap('keyring.pub')
432 for i in OS.listdir('repos'):
433 if i.startswith('peer-') and i.endswith('.pub'):
434 run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
435
436 ###--------------------------------------------------------------------------
437 ### Commands: update
438
439 def cmd_update(args):
440 cwd = OS.getcwd()
441 rmtree('tmp')
442 try:
443
444 ## Fetch a new distribution
445 OS.mkdir('tmp')
446 OS.chdir('tmp')
447 seq = int(conf['master-sequence'])
448 run('curl -s -o tripe-keys.tar.gz ${repos-url}')
449 run('curl -s -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
450 run('tar xfz tripe-keys.tar.gz')
451
452 ## Verify the signature
453 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
454 got = fingerprint('repos/master.pub', 'master-%d' % seq)
455 if want != got: raise VerifyError
456 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
457 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
458
459 ## OK: update our copy
460 OS.chdir(cwd)
461 if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
462 OS.rename('tmp/repos', 'repos')
463 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf'):
464 moan('configuration file changed: recommend running another update')
465 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
466 rmtree('repos.old')
467
468 finally:
469 OS.chdir(cwd)
470 rmtree('tmp')
471 cmd_rebuild(args)
472
473 ###--------------------------------------------------------------------------
474 ### Commands: generate TAG
475
476 def cmd_generate(args):
477 tag, = args
478 keyring_pub = 'peer-%s.pub' % tag
479 zap('keyring'); zap(keyring_pub)
480 run('key -kkeyring merge repos/param')
481 run('key -kkeyring add -a${kx} -pparam -e${kx-expire} -t%s tripe' %
482 tag)
483 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
484
485 ###--------------------------------------------------------------------------
486 ### Commands: clean
487
488 def cmd_clean(args):
489 rmtree('repos')
490 rmtree('tmp')
491 for i in OS.listdir('.'):
492 r = i
493 if r.endswith('.old'): r = r[:-4]
494 if (r == 'master' or r == 'param' or
495 r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
496 zap(i)
497
498 ###--------------------------------------------------------------------------
499 ### Commands: check
500
501 def check_key(k):
502 now = T.time()
503 thresh = int(conf['kx-warn-days']) * 86400
504 if k.exptime == C.KEXP_FOREVER: return None
505 elif k.exptime == C.KEXP_EXPIRE: left = -1
506 else: left = k.exptime - now
507 if left < 0:
508 return "key `%s' HAS EXPIRED" % k.tag
509 elif left < thresh:
510 if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
511 else: n, u, uu = left // 3600, 'hour', 'hours'
512 return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
513 else:
514 return None
515
516 def cmd_check(args):
517 if OS.path.exists('keyring.pub'):
518 for k in C.KeyFile('keyring.pub').itervalues():
519 whinge = check_key(k)
520 if whinge is not None: print whinge
521 if OS.path.exists('master'):
522 whinges = []
523 for k in C.KeyFile('master').itervalues():
524 whinge = check_key(k)
525 if whinge is None: break
526 whinges.append(whinge)
527 else:
528 for whinge in whinges: print whinge
529
530 ###--------------------------------------------------------------------------
531 ### Commands: mtu
532
533 def cmd_mtu(args):
534 mtu, = (lambda mtu = '1500': (mtu,))(*args)
535 mtu = int(mtu)
536
537 blksz = C.gcciphers[conf['cipher']].blksz
538
539 index = conf['mac'].find('/')
540 if index == -1:
541 tagsz = C.gcmacs[conf['mac']].tagsz
542 else:
543 tagsz = int(conf['mac'][index + 1:])/8
544
545 mtu -= 20 # Minimum IP header
546 mtu -= 8 # UDP header
547 mtu -= 1 # TrIPE packet type octet
548 mtu -= tagsz # MAC tag
549 mtu -= 4 # Sequence number
550 mtu -= blksz # Initialization vector
551
552 print mtu
553
554 ###--------------------------------------------------------------------------
555 ### Main driver.
556
557 ## Exceptions.
558 class UsageError (Exception): pass
559
560 commands = {'help': (cmd_help, 0, 1, ''),
561 'newmaster': (cmd_newmaster, 0, 0, ''),
562 'setup': (cmd_setup, 0, 0, ''),
563 'upload': (cmd_upload, 0, 0, ''),
564 'update': (cmd_update, 0, 0, ''),
565 'clean': (cmd_clean, 0, 0, ''),
566 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
567 'check': (cmd_check, 0, 0, ''),
568 'generate': (cmd_generate, 1, 1, 'TAG'),
569 'rebuild': (cmd_rebuild, 0, 0, '')}
570
571 def init():
572 """
573 Load the appropriate configuration file and set up the configuration
574 dictionary.
575 """
576 for f in ['tripe-keys.master', 'tripe-keys.conf']:
577 if OS.path.exists(f):
578 conf_read(f)
579 break
580 conf_defaults()
581
582 def main(argv):
583 """
584 Main program: parse options and dispatch to appropriate command handler.
585 """
586 try:
587 opts, args = O.getopt(argv[1:], 'hvu',
588 ['help', 'version', 'usage'])
589 except O.GetoptError, exc:
590 moan(exc)
591 usage(SYS.stderr)
592 SYS.exit(1)
593 for o, v in opts:
594 if o in ('-h', '--help'):
595 cmd_help([])
596 SYS.exit(0)
597 elif o in ('-v', '--version'):
598 version(SYS.stdout)
599 SYS.exit(0)
600 elif o in ('-u', '--usage'):
601 usage(SYS.stdout)
602 SYS.exit(0)
603 if len(argv) < 2:
604 cmd_help([])
605 else:
606 c = argv[1]
607 func, min, max, help = commands[c]
608 args = argv[2:]
609 if len(args) < min or (max > 0 and len(args) > max):
610 raise UsageError, (c, help)
611 func(args)
612
613 ###----- That's all, folks --------------------------------------------------
614
615 if __name__ == '__main__':
616 init()
617 main(SYS.argv)