server/admin.c: Remove spurious `ping' in usage message.
[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 it under
14 ### the terms of the GNU General Public License as published by the Free
15 ### Software Foundation; either version 3 of the License, or (at your
16 ### option) any later version.
17 ###
18 ### TrIPE is distributed in the hope that it will be useful, but WITHOUT
19 ### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
20 ### FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
21 ### for more details.
22 ###
23 ### You should have received a copy of the GNU General Public License
24 ### along with TrIPE. If not, see <https://www.gnu.org/licenses/>.
25
26 ###--------------------------------------------------------------------------
27 ### External dependencies.
28
29 import catacomb as C
30 import os as OS
31 import sys as SYS
32 import re as RX
33 import getopt as O
34 import shutil as SH
35 import time as T
36 import filecmp as FC
37 from cStringIO import StringIO
38 from errno import *
39 from stat import *
40
41 ###--------------------------------------------------------------------------
42 ### Useful regular expressions
43
44 ## Match a comment or blank line.
45 rx_comment = RX.compile(r'^\s*(#|$)')
46
47 ## Match a KEY = VALUE assignment.
48 rx_keyval = RX.compile(r'^\s*([-\w]+)(?:\s+(?!=)|\s*=\s*)(|\S|\S.*\S)\s*$')
49
50 ## Match a ${KEY} substitution.
51 rx_dollarsubst = RX.compile(r'\$\{([-\w]+)\}')
52
53 ## Match a @TAG@ substitution.
54 rx_atsubst = RX.compile(r'@([-\w]+)@')
55
56 ## Match a single non-alphanumeric character.
57 rx_nonalpha = RX.compile(r'\W')
58
59 ## Match the literal string "<SEQ>".
60 rx_seq = RX.compile(r'\<SEQ\>')
61
62 ## Match a shell metacharacter.
63 rx_shmeta = RX.compile('[\\s`!"#$&*()\\[\\];\'|<>?\\\\]')
64
65 ## Match a character which needs escaping in a shell double-quoted string.
66 rx_shquote = RX.compile(r'["`$\\]')
67
68 ###--------------------------------------------------------------------------
69 ### Utility functions.
70
71 ## Exceptions.
72 class SubprocessError (Exception): pass
73 class VerifyError (Exception): pass
74
75 ## Program name and identification.
76 quis = OS.path.basename(SYS.argv[0])
77 PACKAGE = "@PACKAGE@"
78 VERSION = "@VERSION@"
79
80 def moan(msg):
81 """Report MSG to standard error."""
82 SYS.stderr.write('%s: %s\n' % (quis, msg))
83
84 def die(msg, rc = 1):
85 """Report MSG to standard error, and exit with code RC."""
86 moan(msg)
87 SYS.exit(rc)
88
89 def subst(s, rx, map):
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 """
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
106 def 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
120 def rmtree(path):
121 """Delete the directory tree given by PATH."""
122 try:
123 st = OS.lstat(path)
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
140 def zap(file):
141 """Delete the named FILE if it exists; otherwise do nothing."""
142 try:
143 OS.unlink(file)
144 except OSError, err:
145 if err.errno == ENOENT: return
146 raise
147
148 def run(args):
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 """
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
165 print '+ %s' % ' '.join([shell_quotify(arg) for arg in args])
166 SYS.stdout.flush()
167 rc = OS.spawnvp(OS.P_WAIT, args[0], args)
168 if rc != 0:
169 raise SubprocessError(rc)
170
171 def hexhyphens(bytes):
172 """
173 Convert a byte string BYTES into hex, with hyphens at each 4-byte boundary.
174 """
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
181 def fingerprint(kf, ktag):
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 """
187 h = C.gchashes[conf['fingerprint-hash']]()
188 k = C.KeyFile(kf)[ktag].fingerprint(h, '-secret')
189 return h.done()
190
191 ###--------------------------------------------------------------------------
192 ### The configuration file.
193
194 ## Exceptions.
195 class ConfigFileError (Exception): pass
196
197 ## The configuration dictionary.
198 conf = {}
199
200 def 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)
208
209 def conf_read(f):
210 """
211 Read the file F and insert assignments into the configuration dictionary.
212 """
213 lno = 0
214 for line in file(f):
215 lno += 1
216 if rx_comment.match(line): continue
217 if line[-1] == '\n': line = line[:-1]
218 match = rx_keyval.match(line)
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
224 def conf_defaults():
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 """
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}'),
237 ('conf-file', '${base-dir}tripe-keys.conf'),
238 ('upload-hook', ': run upload hook'),
239 ('kx', 'dh'),
240 ('kx-genalg', lambda: {'dh': 'dh',
241 'ec': 'ec',
242 'x25519': 'x25519',
243 'x448': 'x448'}[conf['kx']]),
244 ('kx-param-genalg', lambda: {'dh': 'dh-param',
245 'ec': 'ec-param',
246 'x25519': 'empty',
247 'x448': 'empty'}[conf['kx']]),
248 ('kx-param', lambda: {'dh': '-LS -b3072 -B256',
249 'ec': '-Cnist-p256',
250 'x25519': '',
251 'x448': ''}[conf['kx']]),
252 ('kx-attrs', lambda: {'dh': 'serialization=constlen',
253 'ec': 'serialization=constlen',
254 'x25519': '',
255 'x448': ''}[conf['kx']]),
256 ('kx-expire', 'now + 1 year'),
257 ('kx-warn-days', '28'),
258 ('bulk', 'iiv'),
259 ('cipher', lambda: conf['bulk'] == 'naclbox'
260 and 'salsa20' or 'rijndael-cbc'),
261 ('hash', 'sha256'),
262 ('master-keygen-flags', '-l'),
263 ('master-attrs', ''),
264 ('mgf', '${hash}-mgf'),
265 ('mac', lambda: conf['bulk'] == 'naclbox'
266 and 'poly1305/128'
267 or '%s-hmac/%d' %
268 (conf['hash'],
269 C.gchashes[conf['hash']].hashsz * 4)),
270 ('sig', lambda: {'dh': 'dsa',
271 'ec': 'ecdsa',
272 'x25519': 'ed25519',
273 'x448': 'ed448'}[conf['kx']]),
274 ('sig-fresh', 'always'),
275 ('sig-genalg', lambda: {'kcdsa': 'dh',
276 'dsa': 'dsa',
277 'rsapkcs1': 'rsa',
278 'rsapss': 'rsa',
279 'ecdsa': 'ec',
280 'eckcdsa': 'ec',
281 'ed25519': 'ed25519',
282 'ed448': 'ed448'}[conf['sig']]),
283 ('sig-param', lambda: {'dh': '-LS -b3072 -B256',
284 'dsa': '-b3072 -B256',
285 'ec': '-Cnist-p256',
286 'rsa': '-b3072',
287 'ed25519': '',
288 'ed448': ''}[conf['sig-genalg']]),
289 ('sig-hash', '${hash}'),
290 ('sig-expire', 'forever'),
291 ('fingerprint-hash', '${hash}')]:
292 try:
293 if k in conf: continue
294 if type(v) == str:
295 conf[k] = conf_subst(v)
296 else:
297 conf[k] = v()
298 except KeyError, exc:
299 if len(exc.args) == 0: raise
300 conf[k] = '<missing-var %s>' % exc.args[0]
301
302 ###--------------------------------------------------------------------------
303 ### Key-management utilities.
304
305 def master_keys():
306 """
307 Iterate over the master keys.
308 """
309 if not OS.path.exists('master'):
310 return
311 for k in C.KeyFile('master').itervalues():
312 if (k.type != 'tripe-keys-master' or
313 k.expiredp or
314 not k.tag.startswith('master-')):
315 continue #??
316 yield k
317
318 def master_sequence(k):
319 """
320 Return the sequence number of the given master key as an integer.
321
322 No checking is done that K is really a master key.
323 """
324 return int(k.tag[7:])
325
326 def max_master_sequence():
327 """
328 Find the master key with the highest sequence number and return this
329 sequence number.
330 """
331 seq = -1
332 for k in master_keys():
333 q = master_sequence(k)
334 if q > seq: seq = q
335 return seq
336
337 def seqsubst(x, q):
338 """
339 Return the value of the configuration variable X, with <SEQ> replaced by
340 the value Q.
341 """
342 return rx_seq.sub(str(q), conf[x])
343
344 ###--------------------------------------------------------------------------
345 ### Commands: help [COMMAND...]
346
347 def version(fp = SYS.stdout):
348 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
349
350 def usage(fp):
351 fp.write('Usage: %s SUBCOMMAND [ARGS...]\n' % quis)
352
353 def cmd_help(args):
354 if len(args) == 0:
355 version(SYS.stdout)
356 print
357 usage(SYS.stdout)
358 print """
359 Key management utility for TrIPE.
360
361 Options supported:
362
363 -h, --help Show this help message.
364 -v, --version Show the version number.
365 -u, --usage Show pointlessly short usage string.
366
367 Subcommands available:
368 """
369 args = commands.keys()
370 args.sort()
371 for c in args:
372 try: func, min, max, help = commands[c]
373 except KeyError: die("unknown command `%s'" % c)
374 print '%s%s%s' % (c, help and ' ', help)
375
376 ###--------------------------------------------------------------------------
377 ### Commands: newmaster
378
379 def cmd_newmaster(args):
380 seq = max_master_sequence() + 1
381 run('''key -kmaster add
382 -a${sig-genalg} !${sig-param}
383 -e${sig-expire} !${master-keygen-flags} -tmaster-%d tripe-keys-master
384 sig=${sig} hash=${sig-hash} !${master-attrs}''' % seq)
385 run('key -kmaster extract -f-secret repos/master.pub')
386
387 ###--------------------------------------------------------------------------
388 ### Commands: setup
389
390 def cmd_setup(args):
391 OS.mkdir('repos')
392 run('''key -krepos/param add
393 -a${kx-param-genalg} !${kx-param}
394 -eforever -tparam tripe-param
395 kx-group=${kx} mgf=${mgf} mac=${mac}
396 bulk=${bulk} cipher=${cipher} hash=${hash} ${kx-attrs}''')
397 cmd_newmaster(args)
398
399 ###--------------------------------------------------------------------------
400 ### Commands: upload
401
402 def cmd_upload(args):
403
404 ## Sanitize the repository directory
405 umask = OS.umask(0); OS.umask(umask)
406 mode = 0666 & ~umask
407 for f in OS.listdir('repos'):
408 ff = OS.path.join('repos', f)
409 if (f.startswith('master') or f.startswith('peer-')) \
410 and f.endswith('.old'):
411 OS.unlink(ff)
412 continue
413 OS.chmod(ff, mode)
414
415 rmtree('tmp')
416 OS.mkdir('tmp')
417 OS.symlink('../repos', 'tmp/repos')
418 cwd = OS.getcwd()
419 try:
420
421 ## Build the configuration file
422 seq = max_master_sequence()
423 v = {'MASTER-SEQUENCE': str(seq),
424 'HK-MASTER': hexhyphens(fingerprint('repos/master.pub',
425 'master-%d' % seq))}
426 fin = file('tripe-keys.master')
427 fout = file('tmp/tripe-keys.conf', 'w')
428 for line in fin:
429 fout.write(subst(line, rx_atsubst, v))
430 fin.close(); fout.close()
431 SH.copyfile('tmp/tripe-keys.conf', conf_subst('${conf-file}.new'))
432 commit = [conf['repos-file'], conf['conf-file']]
433
434 ## Make and sign the repository archive
435 OS.chdir('tmp')
436 run('tar chozf ${repos-file}.new .')
437 OS.chdir(cwd)
438 for k in master_keys():
439 seq = master_sequence(k)
440 sigfile = seqsubst('sig-file', seq)
441 run('''catsign -kmaster sign -abdC -kmaster-%d
442 -o%s.new ${repos-file}.new''' % (seq, sigfile))
443 commit.append(sigfile)
444
445 ## Commit the changes
446 for base in commit:
447 new = '%s.new' % base
448 OS.rename(new, base)
449
450 ## Remove files in the base-dir which don't correspond to ones we just
451 ## committed
452 allow = {}
453 basedir = conf['base-dir']
454 bdl = len(basedir)
455 for base in commit:
456 if base.startswith(basedir): allow[base[bdl:]] = 1
457 for found in OS.listdir(basedir):
458 if found not in allow: OS.remove(OS.path.join(basedir, found))
459 finally:
460 OS.chdir(cwd)
461 rmtree('tmp')
462 run('sh -c ${upload-hook}')
463
464 ###--------------------------------------------------------------------------
465 ### Commands: rebuild
466
467 def cmd_rebuild(args):
468 zap('keyring.pub')
469 for i in OS.listdir('repos'):
470 if i.startswith('peer-') and i.endswith('.pub'):
471 run('key -kkeyring.pub merge %s' % OS.path.join('repos', i))
472
473 ###--------------------------------------------------------------------------
474 ### Commands: update
475
476 def cmd_update(args):
477 cwd = OS.getcwd()
478 rmtree('tmp')
479 try:
480
481 ## Fetch a new distribution
482 OS.mkdir('tmp')
483 OS.chdir('tmp')
484 seq = int(conf['master-sequence'])
485 run('curl -sL -o tripe-keys.tar.gz ${repos-url}')
486 run('curl -sL -o tripe-keys.sig %s' % seqsubst('sig-url', seq))
487 run('tar xfz tripe-keys.tar.gz')
488
489 ## Verify the signature
490 want = C.bytes(rx_nonalpha.sub('', conf['hk-master']))
491 got = fingerprint('repos/master.pub', 'master-%d' % seq)
492 if want != got: raise VerifyError()
493 run('''catsign -krepos/master.pub verify -avC -kmaster-%d
494 -t${sig-fresh} tripe-keys.sig tripe-keys.tar.gz''' % seq)
495
496 ## OK: update our copy
497 OS.chdir(cwd)
498 if OS.path.exists('repos'): OS.rename('repos', 'repos.old')
499 OS.rename('tmp/repos', 'repos')
500 if not FC.cmp('tmp/tripe-keys.conf', 'tripe-keys.conf', False):
501 moan('configuration file changed: recommend running another update')
502 OS.rename('tmp/tripe-keys.conf', 'tripe-keys.conf')
503 rmtree('repos.old')
504
505 finally:
506 OS.chdir(cwd)
507 rmtree('tmp')
508 cmd_rebuild(args)
509
510 ###--------------------------------------------------------------------------
511 ### Commands: generate TAG
512
513 def cmd_generate(args):
514 tag, = args
515 keyring_pub = 'peer-%s.pub' % tag
516 zap('keyring'); zap(keyring_pub)
517 run('key -kkeyring merge repos/param')
518 run('key -kkeyring add -a${kx-genalg} -pparam -e${kx-expire} -t%s tripe' %
519 tag)
520 run('key -kkeyring extract -f-secret %s %s' % (keyring_pub, tag))
521
522 ###--------------------------------------------------------------------------
523 ### Commands: clean
524
525 def cmd_clean(args):
526 rmtree('repos')
527 rmtree('tmp')
528 for i in OS.listdir('.'):
529 r = i
530 if r.endswith('.old'): r = r[:-4]
531 if (r == 'master' or r == 'param' or
532 r == 'keyring' or r == 'keyring.pub' or r.startswith('peer-')):
533 zap(i)
534
535 ###--------------------------------------------------------------------------
536 ### Commands: check
537
538 def check_key(k):
539 now = T.time()
540 thresh = int(conf['kx-warn-days']) * 86400
541 if k.exptime == C.KEXP_FOREVER: return None
542 elif k.exptime == C.KEXP_EXPIRE: left = -1
543 else: left = k.exptime - now
544 if left < 0:
545 return "key `%s' HAS EXPIRED" % k.tag
546 elif left < thresh:
547 if left >= 86400: n, u, uu = left // 86400, 'day', 'days'
548 else: n, u, uu = left // 3600, 'hour', 'hours'
549 return "key `%s' EXPIRES in %d %s" % (k.tag, n, n == 1 and u or uu)
550 else:
551 return None
552
553 def cmd_check(args):
554 if OS.path.exists('keyring.pub'):
555 for k in C.KeyFile('keyring.pub').itervalues():
556 whinge = check_key(k)
557 if whinge is not None: print whinge
558 if OS.path.exists('master'):
559 whinges = []
560 for k in C.KeyFile('master').itervalues():
561 whinge = check_key(k)
562 if whinge is None: break
563 whinges.append(whinge)
564 else:
565 for whinge in whinges: print whinge
566
567 ###--------------------------------------------------------------------------
568 ### Commands: mtu
569
570 def mac_tagsz():
571 macname = conf['mac']
572 index = macname.rindex('/')
573 if index == -1: tagsz = C.gcmacs[macname].tagsz
574 else: tagsz = int(macname[index + 1:])/8
575 return tagsz
576
577 def cmd_mtu(args):
578 mtu, = (lambda mtu = '1500': (mtu,))(*args)
579 mtu = int(mtu)
580
581 mtu -= 20 # Minimum IP header
582 mtu -= 8 # UDP header
583 mtu -= 1 # TrIPE packet type octet
584
585 bulk = conf['bulk']
586
587 if bulk == 'v0':
588 blksz = C.gcciphers[conf['cipher']].blksz
589 mtu -= mac_tagsz() # MAC tag
590 mtu -= 4 # Sequence number
591 mtu -= blksz # Initialization vector
592
593 elif bulk == 'iiv':
594 mtu -= mac_tagsz() # MAC tag
595 mtu -= 4 # Sequence number
596
597 elif bulk == 'naclbox':
598 mtu -= 16 # MAC tag
599 mtu -= 4 # Sequence number
600
601 else:
602 die("Unknown bulk transform `%s'" % bulk)
603
604 print mtu
605
606 ###--------------------------------------------------------------------------
607 ### Main driver.
608
609 commands = {'help': (cmd_help, 0, 1, ''),
610 'newmaster': (cmd_newmaster, 0, 0, ''),
611 'setup': (cmd_setup, 0, 0, ''),
612 'upload': (cmd_upload, 0, 0, ''),
613 'update': (cmd_update, 0, 0, ''),
614 'clean': (cmd_clean, 0, 0, ''),
615 'mtu': (cmd_mtu, 0, 1, '[PATH-MTU]'),
616 'check': (cmd_check, 0, 0, ''),
617 'generate': (cmd_generate, 1, 1, 'TAG'),
618 'rebuild': (cmd_rebuild, 0, 0, '')}
619
620 def init():
621 """
622 Load the appropriate configuration file and set up the configuration
623 dictionary.
624 """
625 for f in ['tripe-keys.master', 'tripe-keys.conf']:
626 if OS.path.exists(f):
627 conf_read(f)
628 break
629 conf_defaults()
630
631 def main(argv):
632 """
633 Main program: parse options and dispatch to appropriate command handler.
634 """
635 try:
636 opts, args = O.getopt(argv[1:], 'hvu',
637 ['help', 'version', 'usage'])
638 except O.GetoptError, exc:
639 moan(exc)
640 usage(SYS.stderr)
641 SYS.exit(1)
642 for o, v in opts:
643 if o in ('-h', '--help'):
644 cmd_help([])
645 SYS.exit(0)
646 elif o in ('-v', '--version'):
647 version(SYS.stdout)
648 SYS.exit(0)
649 elif o in ('-u', '--usage'):
650 usage(SYS.stdout)
651 SYS.exit(0)
652 if len(argv) < 2:
653 cmd_help([])
654 else:
655 c = argv[1]
656 try: func, min, max, help = commands[c]
657 except KeyError: die("unknown command `%s'" % c)
658 args = argv[2:]
659 if len(args) < min or (max is not None and len(args) > max):
660 SYS.stderr.write('Usage: %s %s%s%s\n' % (quis, c, help and ' ', help))
661 SYS.exit(1)
662 func(args)
663
664 ###----- That's all, folks --------------------------------------------------
665
666 if __name__ == '__main__':
667 init()
668 main(SYS.argv)