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