chiark / gitweb /
cryptomail: Implement info and revoke commands.
[cryptomail] / bin / cryptomail
CommitLineData
71074336
MW
1#! /usr/bin/python
2### -*-python-*-
3###
4### Encrypted email address handling
5###
6### (c) 2006 Mark Wooding
7###
8
9###----- Licensing notice ---------------------------------------------------
10###
11### This program is free software; you can redistribute it and/or modify
12### it under the terms of the GNU General Public License as published by
13### the Free Software Foundation; either version 2 of the License, or
14### (at your option) any later version.
15###
16### This program is distributed in the hope that it will be useful,
17### but WITHOUT ANY WARRANTY; without even the implied warranty of
18### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19### GNU General Public License for more details.
20###
21### You should have received a copy of the GNU General Public License
22### along with this program; if not, write to the Free Software Foundation,
23### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
24
25###----- External dependencies ----------------------------------------------
26
27import catacomb as C
28import mLib as M
29from pysqlite2 import dbapi2 as sqlite
30from UserDict import DictMixin
31from getopt import getopt, GetoptError
32from getdate import getdate
33from sys import stdin, stdout, stderr, exit, argv, exc_info
34from email import Parser as EP
35import os as OS
36import time as T
37import sre as RX
38import traceback as TB
39
40###----- Database messing ---------------------------------------------------
41
42class AttrDB (object):
43 def __init__(me, dbfile):
44 me.db = sqlite.connect(dbfile)
45 def setup(me):
46 cur = me.db.cursor()
47 cur.execute('''CREATE TABLE attr
48 (id INTEGER PRIMARY KEY,
49 key VARCHAR(64) NOT NULL,
50 value VARCHAR(256) NOT NULL)''')
51 cur.execute('''CREATE TABLE attrset
52 (id INTEGER NOT NULL,
53 attr INTEGER NOT NULL)''')
54 cur.execute('''CREATE TABLE uniq
55 (id INTEGER PRIMARY KEY AUTOINCREMENT,
56 dummy INTEGER NOT NULL)''')
57 cur.execute('CREATE UNIQUE INDEX attr_bykv ON attr (key, value)')
58 cur.execute('CREATE INDEX attrset_byid ON attrset (id)')
59 cur.execute('CREATE INDEX attrset_byattr ON attrset (attr)')
60 cur.execute('CREATE UNIQUE INDEX attrset_all ON attrset (id, attr)')
61 def uniqueid(me):
62 cur = me.db.cursor()
63 cur.execute('INSERT INTO uniq (dummy) VALUES (0)')
64 cur.execute('SELECT MAX(id) FROM uniq')
65 id = cur.fetchone()[0]
66 cur.execute('DELETE FROM uniq')
67 me.commit()
68 return id
69 def select(me, expr, args = [], cur = None):
70 if cur is None: cur = me.db.cursor()
71 cur.execute(expr, args)
72 while True:
73 r = cur.fetchone()
74 if r is None: break
75 yield r
76 def cleanup(me):
77 cur = me.db.cursor()
78 cur.execute('''DELETE FROM attr WHERE id IN
79 (SELECT attr.id
80 FROM attr LEFT JOIN attrset
81 ON attr.id = attrset.attr
82 WHERE attrset.id ISNULL)''')
83 def check(me, cleanp = False):
84 toclean = {}
85 cur = me.db.cursor()
86 for set, attr in me.select('''SELECT attrset.id, attrset.attr
87 FROM attrset LEFT JOIN attr
88 ON attrset.attr = attr.id
89 WHERE attr.id ISNULL''',
90 [], cur):
91 print "attrset %d missing attr %d" % (set, attr)
92 toclean[set] = True
93 if cleanp:
94 for set in toclean:
95 cur.execute('DELETE FROM attrset WHERE id = ?', [set])
96 me.cleanup()
97 def commit(me):
98 me.db.commit()
99
100class AttrSet (object):
101 def __init__(me, db, id = None):
102 if id is None: id = db.uniqueid()
103 me.id = id
104 me.db = db
105 def insert(me, key, value):
106 cur = me.db.db.cursor()
107 try:
108 cur.execute('INSERT INTO attr (key, value) VALUES (?, ?)',
109 [key, value])
110 except sqlite.OperationalError:
111 pass
112 cur.execute('SELECT id FROM attr WHERE key = ? AND value = ?',
113 [key, value])
114 r = cur.fetchone()
115 attr = r[0]
116 try:
117 cur.execute('INSERT INTO attrset VALUES (?, ?)',
118 [me.id, attr])
119 except sqlite.OperationalError:
120 pass
121 def fetch(me):
122 for r in me.db.select('''SELECT attr.key, attr.value
123 FROM attr, attrset ON attr.id = attrset.attr
124 WHERE attrset.id = ?''',
125 [me.id]):
126 yield r
127 def delete(me):
128 cur = me.db.db.cursor()
129 cur.execute('DELETE FROM attrset WHERE id = ?', [me.id])
130 me.db.cleanup()
131
132class AttrMap (AttrSet, DictMixin):
133 def __getitem__(me, key):
134 it = None
135 for v, in me.db.select('''SELECT attr.value
136 FROM attr, attrset ON attr.id = attrset.attr
137 WHERE attrset.id = ? AND attr.key = ?''',
138 [me.id, key]):
139 if it is None:
140 it = v
141 else:
142 raise ValueError, 'multiple values for key %s' % key
143 if it is None:
144 raise KeyError, key
145 return it
146 def __delitem__(me, key):
147 cur = me.db.db.cursor()
148 cur.execute('''DELETE FROM attrset
149 WHERE id = ? AND
150 attr in
151 (SELECT id FROM attr WHERE key = ?)''',
152 [me.id, key])
153 me.db.cleanup()
154 def __setitem__(me, key, value):
155 me.__delitem__(key)
156 me.insert(key, value)
157 def __iter__(me):
158 set = {}
159 for k, v in me.fetch():
160 if k in set:
161 continue
162 set[k] = True
163 yield k
164 def keys(me):
165 return [k for k in me]
166
167class AttrMultiMap (AttrMap):
168 def __getitem__(me, key):
169 them = []
170 for v, in me.db.select('''SELECT attr.value
171 FROM attr, attrset ON attr.id = attrset.attr
172 WHERE attrset.id = ? AND attr.key = ?''',
173 [me.id, key]):
174 them.append(v)
175 if not them:
176 raise KeyError, key
177 return them
178 def __setitem__(me, key, values):
179 me.__delitem__(key)
180 for it in values:
181 me.insert(key, it)
182
183###----- Miscellaneous utilities --------------------------------------------
184
185def time_format(t = None):
186 if t is None:
187 t = T.time()
188 tm = T.gmtime(t)
189 return T.strftime('%Y-%m-%d %H:%M:%S', tm)
190
191def any(pred, list):
192 for i in list:
193 if pred(i): return True
194 return False
195def every(pred, list):
196 for i in list:
197 if not pred(i): return False
198 return True
199
200prog = RX.sub(r'^.*[/\\]', '', argv[0])
201def moan(msg):
202 print >>stderr, '%s: %s' % (prog, msg)
203def die(msg):
204 moan(msg)
205 exit(111)
206
207###----- My actual database -------------------------------------------------
208
209class CMDB (AttrDB):
210 def setup(me):
211 AttrDB.setup(me)
212 cur = me.db.cursor()
213 cur.execute('''CREATE TABLE expiry
214 (attrset INTEGER PRIMARY KEY,
215 time CHAR(20) NOT NULL)''')
216 cur.execute('CREATE INDEX expiry_bytime ON expiry (time)')
217 def cleanup(me):
218 cur = me.db.cursor()
219 now = time_format()
220 cur.execute('''DELETE FROM attrset WHERE id IN
221 (SELECT attrset FROM expiry WHERE time < ?)''',
222 [now])
223 cur.execute('DELETE FROM expiry WHERE time < ?', [now])
e345b51f
MW
224 cur.execute('''DELETE FROM expiry WHERE attrset IN
225 (SELECT attrset
226 FROM expiry LEFT JOIN attrset
227 ON expiry.attrset = attrset.id
228 WHERE attrset.id ISNULL)''')
71074336 229 AttrDB.cleanup(me)
e345b51f 230 def expiry(me, id):
71074336 231 for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
e345b51f
MW
232 return t
233 return None
234 def expiredp(me, id):
235 t = me.expiry(id)
236 if t is not None and t < time_format():
237 return True
238 else:
239 return False
71074336
MW
240 def setexpire(me, id, when):
241 if when != C.KEXP_FOREVER:
242 cur = me.db.cursor()
243 cur.execute('INSERT INTO expiry VALUES (?, ?)',
244 [id, time_format(when)])
245
246###----- Crypto messing about -----------------------------------------------
247
248## Very vague security arguments...
249##
250## If the block size n of the PRP is large enough (128 bits) then we encrypt
251## id || 0^{n - 64}. Decryption checks we have the right thing. The
252## security proofs for secrecy and integrity are trivial.
253##
254## If the block size is small, then we encrypt two blocks:
255## C_0 = E_K(0^{n - 64} || id)
256## C_1 = E_K(C_0)
257## The proofs are a little more complicated, but essentially work like this.
258## If no 0^{n - 64} || id is ever seen as a C_0 then an adversary can't tell
259## the difference between this and a similar construction using independent
260## keys. This other construction must provide secrecy (pushing a
261## nonrepeating thing through a PRF) and integrity (PRF on noncolliding
262## inputs). So we win, give or take a birthday term.
263class Crypto (object):
264 def __init__(me, key):
265 me.prp = C.gcprps[key.attr.get('prp', 'blowfish')](key.data.bin)
266 def encrypt(me, id):
267 blksz = type(me.prp).blksz
268 p = C.MP(id).storeb(blksz)
269 c = me.prp.encrypt(p)
270 if blksz < 16:
271 c += me.prp.encrypt(c)
272 return c
273 def decrypt(me, c):
274 bad = False
275 blksz = type(me.prp).blksz
276 if blksz < 16:
277 if len(c) != blksz * 2:
278 return None
279 c, c1 = c[:blksz], c[blksz:]
280 if c1 != me.prp.encrypt(c):
281 bad = True
282 else:
283 if len(c) != blksz:
284 return None
285 p = me.prp.decrypt(c)
286 id = C.MP.loadb(p)
287 if id >> 64:
288 bad = True
289 if bad:
290 return None
291 return long(id)
292
293###----- Canonification -----------------------------------------------------
294
295rx_prefix = RX.compile(r'''(?x) ^ (
296 \[ \S+ \] \s* |
297 \S{,4} : \s* |
298 \s+
299)
300''')
301rx_suffix = RX.compile(r'''(?ix) (
302 \( \s* was \s* : .* \) \s* |
303 \s+
304) $''')
305rx_punct = RX.compile(r'(?x) [^\w]+ ')
306
307def canon_sender(addr):
308 return addr.lower()
309
310def canon_subject(subject):
311 subject = subject.lower()
312 while True:
313 m = rx_prefix.match(subject)
314 if not m: break
315 subject = subject[m.end():]
316 while True:
317 m = rx_suffix.search(subject)
318 if not m: break
319 subject = subject[:m.start()]
320 subject = rx_punct.sub('', subject)
321 return subject
322
323###----- Checking a message for validity ------------------------------------
324
325class Reject (Exception): pass
326
327class MessageInfo (object):
328 __slots__ = '''
329 sender msg
330 '''.split()
331
332constraints = {}
333
334def check_sender(mi, vv):
335 if mi.sender is None:
336 raise Reject, 'no sender'
337 sender = canon_sender(mi.sender)
338 if not any(lambda pat: M.match(pat.lower(), sender), vv):
339 raise Reject, 'unmatched sender'
340constraints['sender'] = check_sender
341
342def check_subject(mi, vv):
343 if mi.msg is None:
344 return
345 subj = mi.msg['subject']
346 if subj is None:
347 raise Reject, 'no subject'
348 subj = canon_subject(subj)
349 if not any(lambda pat: M.match(pat.lower(), subj), vv):
350 raise Reject, 'unmatched subject'
351constraints['subject'] = check_subject
352
353def check_nothing(me, vv):
354 pass
355
356def check(db, id, sender = None, msgfile = None):
357 mi = MessageInfo()
358 a = AttrMultiMap(db, id)
359 try:
360 addr = a['addr'][0]
361 except KeyError:
362 raise Reject, 'unknown id'
363 if db.expiredp(id):
364 raise Reject, 'expired'
365 if msgfile is None:
366 mi.msg = None
367 else:
368 try:
369 mi.msg = EP.HeaderParser().parse(msgfile)
370 except EP.Errors.HeaderParseError:
371 raise Reject, 'unparseable header'
372 mi.sender = sender
373 for k, vv in a.iteritems():
374 constraints.get(k, check_nothing)(mi, vv)
375 return a['addr'][0]
376
377###----- Commands -----------------------------------------------------------
378
379keyfile = 'db/keyring'
380tag = 'cryptomail'
381dbfile = 'db/cryptomail.db'
e345b51f 382user = None
71074336
MW
383commands = {}
384
385def timecmp(x, y):
386 if x == y:
387 return 0
388 elif x == C.KEXP_FOREVER or y == C.KEXP_EXPIRE:
389 return +1
390 elif y == C.KEXP_FOREVER or x == C.KEXP_EXPIRE:
391 return +1
392 else:
393 return cmp(x, y)
394
395def cmd_generate(argv):
396 try:
e345b51f
MW
397 opts, argv = getopt(argv, 't:c:f:i:',
398 ['expire=', 'timeout=', 'constraint=',
399 'info=', 'format='])
71074336
MW
400 except GetoptError:
401 return 1
402 kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
403 k = kr[tag]
404 db = CMDB(dbfile)
405 map = {}
406 expwhen = C.KEXP_FOREVER
407 format = '%'
408 for o, a in opts:
409 if o in ('-t', '--expire', '--timeout'):
410 if a == 'forever':
411 expwhen = C.KEXP_FOREVER
412 else:
413 expwhen = getdate(a)
414 elif o in ('-c', '--constraint'):
415 c, v = a.split('=', 1)
416 if c not in constraints:
417 die("unknown constraint `%s'", c)
418 map.setdefault(c, []).append(v)
010ac9cf 419 elif o in ('-f', '--format'):
71074336 420 format = a
e345b51f
MW
421 elif o in ('-i', '--info'):
422 map['info'] = [a]
71074336
MW
423 else:
424 raise 'Barf!'
425 if timecmp(expwhen, k.deltime) > 0:
426 k.deltime = expwhen
427 if len(argv) != 1:
428 return 1
429 addr = argv[0]
430 a = AttrMultiMap(db)
431 a.update(map)
432 a['addr'] = [addr]
e345b51f
MW
433 if user is not None:
434 a['user'] = [user]
71074336
MW
435 c = Crypto(k).encrypt(a.id)
436 db.setexpire(a.id, expwhen)
437 print format.replace('%', M.base32_encode(Crypto(k).encrypt(a.id)).
438 strip('=').lower())
439 db.commit()
440 kr.save()
441commands['generate'] = \
442 (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
443Generate a new encrypted email address token forwarding to ADDR.
444
445Subcommand options:
446 -t, --timeout=TIME Address should expire at TIME.
447 -c, --constraint=TYPE=VALUE Apply constraint on the use of the address.
448 -f, --format=STRING Substitute token for `%' in STRING.
449
450Constraint types:
451 sender Envelope sender must match glob pattern.
452 subject Message subject must match glob pattern.""")
453
454def cmd_initdb(argv):
455 try:
456 opts, argv = getopt(argv, '', [])
457 except GetoptError:
458 return 1
459 try:
460 OS.unlink(dbfile)
461 except OSError:
462 pass
463 CMDB(dbfile).setup()
464commands['initdb'] = \
465 (cmd_initdb, '', """
466Initialize an attribute database.""")
467
e345b51f
MW
468def getid(local):
469 k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
470 id = Crypto(k).decrypt(M.base32_decode(local))
471 if id is None:
472 raise Reject, 'decrypt failed'
473 return id
474
71074336
MW
475def cmd_addrcheck(argv):
476 try:
477 opts, argv = getopt(argv, '', [])
478 except GetoptError:
479 return 1
480 local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
71074336
MW
481 db = CMDB(dbfile)
482 try:
e345b51f 483 id = getid(local)
71074336
MW
484 addr = check(db, id, sender)
485 except Reject, msg:
486 print '-%s' % msg
487 return
488 print '+%s' % addr
489commands['addrcheck'] = \
490 (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
491Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
492success.""")
493
494def cmd_fwaddr(argv):
495 try:
496 opts, argv = getopt(argv, '', [])
497 except GetoptError:
498 return 1
e345b51f
MW
499 if len(argv) not in (1, 2):
500 return 1
501 local, sender = (lambda addr, sender = None: (addr, sender))(*argv)
71074336
MW
502 db = CMDB(dbfile)
503 try:
e345b51f 504 id = getid(local)
71074336
MW
505 if id is None:
506 raise Reject, 'decrypt failed'
507 addr = check(db, id, sender, stdin)
508 except Reject, msg:
509 print >>stderr, '%s rejected message: %s' % (prog, msg)
510 exit(100)
511 stdin.seek(0)
512 print addr
513commands['fwaddr'] = \
e345b51f 514 (cmd_fwaddr, 'LOCAL [SENDER]', """
71074336
MW
515Check address token LOCAL. On failure, report reason to stderr and exit
516111. On success, write forwarding address to stdout and exit 0. Expects
517the message on standard input, as a seekable file.""")
518
e345b51f
MW
519def cmd_info(argv):
520 try:
521 opts, argv = getopt(argv, '', [])
522 except GetoptError:
523 return 1
524 if len(argv) != 1:
525 return 1
526 local = argv[0]
527 db = CMDB(dbfile)
528 try:
529 id = getid(local)
530 a = AttrMultiMap(db, id)
531 if user is not None and user != a.get('user', [None])[0]:
532 raise Reject, 'not your token'
533 if 'addr' not in a:
534 die('unknown token (expired?)')
535 keys = a.keys()
536 keys.sort()
537 for k in keys:
538 for v in a[k]:
539 print '%s: %s' % (k, v)
540 expwhen = db.expiry(id)
541 if expwhen:
542 print 'expires: %s'
543 else:
544 print 'no-expiry'
545 except Reject, msg:
546 die('invalid token')
547commands['info'] = \
548 (cmd_info, 'LOCAL', """
549Exaimne the address token LOCAL, and print information about it to standard
550output.""")
551
552def cmd_revoke(argv):
553 try:
554 opts, argv = getopt(argv, '', [])
555 except GetoptError:
556 return 1
557 if len(argv) != 1:
558 return 1
559 local = argv[0]
560 db = CMDB(dbfile)
561 try:
562 id = getid(local)
563 a = AttrMultiMap(db, id)
564 if user is not None and user != a.get('user', [None])[0]:
565 raise Reject, 'not your token'
566 if 'addr' not in a:
567 die('unknown token (expired?)')
568 a.clear()
569 db.cleanup()
570 db.commit()
571 except Reject, msg:
572 die('invalid token')
573commands['revoke'] = \
574 (cmd_revoke, 'LOCAL', """
575Revoke the token LOCAL.""")
576
71074336
MW
577def cmd_cleanup(argv):
578 try:
579 opts, argv = getopt(argv, '', [])
580 except GetoptError:
581 return 1
582 db = CMDB(dbfile)
583 db.cleanup()
584 cur = db.db.cursor()
585 cur.execute('VACUUM')
586 db.commit()
587commands['cleanup'] = \
588 (cmd_cleanup, '', """
589Cleans up the attribute database, disposing of old records and compatifying
590the file.""")
591
592def cmd_help(argv):
593 try:
594 opts, argv = getopt(argv, '', [])
595 except GetoptError:
596 return 1
597 if len(argv) == 0:
598 cmd = None
599 elif len(argv) == 1:
600 try:
601 cmd = argv[0]
602 ci = commands[cmd]
603 except KeyError:
604 die("unknown command `%s'" % cmd)
605 else:
606 return 1
607 version()
608 print
609 if cmd:
610 print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
611 print ci[2]
612 else:
613 usage(stdout)
614 print """
615Handle encrypted email addresses.
616
617Help options:
618 -h, --help Show this help text.
619 -v, --version Show version number.
620 -u, --usage Show a usage message.
621
622Global options:
623 -d, --database=FILE Use FILE as the attribute database.
624 -k, --keyring=KEYRING Use KEYRING as the keyring.
625 -t, --tag=TAG Use TAG as the key tag.
e345b51f 626 -U, --user=USER Claim to be USER.
71074336
MW
627"""
628 cmds = commands.keys()
629 cmds.sort()
630 print 'Subcommands:'
631 for c in cmds:
632 print ' %s %s' % (c, commands[c][1])
633commands['help'] = \
634 (cmd_help, '[COMMAND]', """
635Show help for subcommand COMMAND.
636""")
637
638###----- Main program -------------------------------------------------------
639
640def usage(file):
641 print >>file, \
642 'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
643def version():
644 print '%s version 1.0.0' % prog
645def help():
646 cmd_help()
647
648def main():
e345b51f 649 global argv, user, keyfile, dbfile, tag
71074336
MW
650 try:
651 opts, argv = getopt(argv[1:],
e345b51f 652 'hvud:k:t:U:',
71074336 653 ['help', 'version', 'usage',
e345b51f 654 'database=', 'keyring=', 'tag=', 'user='])
71074336
MW
655 except GetoptError:
656 usage(stderr)
657 exit(111)
658 for o, a in opts:
659 if o in ('-h', '--help'):
660 help()
661 exit(0)
662 elif o in ('-v', '--version'):
663 version()
664 exit(0)
665 elif o in ('-u', '--usage'):
666 usage(stdout)
667 exit(0)
668 elif o in ('-d', '--database'):
669 dbfile = a
670 elif o in ('-k', '--keyring'):
671 keyfile = a
672 elif o in ('-t', '--tag'):
673 tag = a
e345b51f
MW
674 elif o in ('-U', '--user'):
675 user = a
71074336
MW
676 else:
677 raise 'Barf!'
678 if len(argv) < 1:
679 usage(stderr)
680 exit(111)
681
682 if argv[0] in commands:
683 c = argv[0]
684 argv = argv[1:]
685 else:
686 usage(stderr)
687 exit(111)
688 cmd = commands[c]
689 if cmd[0](argv):
690 print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
691 exit(111)
692
693try:
694 main()
aeec1a4e
MW
695except SystemExit:
696 raise
697except:
71074336
MW
698 ty, exc, tb = exc_info()
699 moan('unhandled %s exception' % ty.__name__)
700 for file, line, func, text in TB.extract_tb(tb):
701 print >>stderr, \
702 ' %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
703 die('%s: %s' % (ty.__name__, exc[0]))