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