chiark / gitweb /
New option to list a user's (or all) extant tokens.
[cryptomail] / bin / cryptomail
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
27 import catacomb as C
28 import mLib as M
29 from pysqlite2 import dbapi2 as sqlite
30 from UserDict import DictMixin
31 from getopt import getopt, GetoptError
32 from getdate import getdate 
33 from sys import stdin, stdout, stderr, exit, argv, exc_info
34 from email import Parser as EP
35 import os as OS
36 import time as T
37 import sre as RX
38 import traceback as TB
39
40 ###----- Database messing ---------------------------------------------------
41
42 class 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
100 class 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
132 class 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
167 class 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
185 def 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
191 def any(pred, list):
192   for i in list:
193     if pred(i): return True
194   return False
195 def every(pred, list):
196   for i in list:
197     if not pred(i): return False
198   return True
199
200 prog = RX.sub(r'^.*[/\\]', '', argv[0])
201 def moan(msg):
202   print >>stderr, '%s: %s' % (prog, msg)
203 def die(msg):
204   moan(msg)
205   exit(111)
206
207 ###----- My actual database -------------------------------------------------
208
209 class 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])
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)''')
229     AttrDB.cleanup(me)
230   def expiry(me, id):
231     for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
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
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.
263 class 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
295 rx_prefix = RX.compile(r'''(?x) ^ (
296   \[ \S+ \] \s* |
297   \S{,4} : \s* |
298   \s+
299 )   
300 ''')
301 rx_suffix = RX.compile(r'''(?ix) (
302   \( \s* was \s* : .* \) \s* |
303   \s+
304 ) $''')
305 rx_punct = RX.compile(r'(?x) [^\w]+ ')
306
307 def canon_sender(addr):
308   return addr.lower()
309
310 def 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
325 class Reject (Exception): pass
326
327 class MessageInfo (object):
328   __slots__ = '''
329     sender msg
330   '''.split()
331
332 constraints = {}
333
334 def 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'
340 constraints['sender'] = check_sender
341
342 def 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'
351 constraints['subject'] = check_subject
352
353 def check_nothing(me, vv):
354   pass
355   
356 def 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
379 keyfile = 'db/keyring'
380 tag = 'cryptomail'
381 dbfile = 'db/cryptomail.db'
382 user = None
383 commands = {}
384
385 def 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
395 def token(c, id):
396   return M.base32_encode(c.encrypt(id)).strip('=').lower()
397
398 def cmd_generate(argv):
399   try:
400     opts, argv = getopt(argv, 't:c:f:i:',
401                         ['expire=', 'timeout=', 'constraint=',
402                          'info=', 'format='])
403   except GetoptError:
404     return 1
405   kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
406   k = kr[tag]
407   db = CMDB(dbfile)
408   map = {}
409   expwhen = C.KEXP_FOREVER
410   format = '%'
411   for o, a in opts:
412     if o in ('-t', '--expire', '--timeout'):
413       if a == 'forever':
414         expwhen = C.KEXP_FOREVER
415       else:
416         expwhen = getdate(a)
417     elif o in ('-c', '--constraint'):
418       c, v = a.split('=', 1)
419       if c not in constraints:
420         die("unknown constraint `%s'", c)
421       map.setdefault(c, []).append(v)
422     elif o in ('-f', '--format'):
423       format = a
424     elif o in ('-i', '--info'):
425       map['info'] = [a]
426     else:
427       raise 'Barf!'
428   if timecmp(expwhen, k.deltime) > 0:
429     k.deltime = expwhen
430   if len(argv) != 1:
431     return 1
432   addr = argv[0]
433   a = AttrMultiMap(db)
434   a.update(map)
435   a['addr'] = [addr]
436   if user is not None:
437     a['user'] = [user]
438   db.setexpire(a.id, expwhen)
439   print format.replace('%', token(Crypto(k), a.id))
440   db.commit()
441   kr.save()
442 commands['generate'] = \
443   (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
444 Generate a new encrypted email address token forwarding to ADDR.
445
446 Subcommand options:
447   -t, --timeout=TIME            Address should expire at TIME.
448   -c, --constraint=TYPE=VALUE   Apply constraint on the use of the address.
449   -f, --format=STRING           Substitute token for `%' in STRING.
450
451 Constraint types:
452   sender                        Envelope sender must match glob pattern.
453   subject                       Message subject must match glob pattern.""")
454
455 def cmd_initdb(argv):
456   try:
457     opts, argv = getopt(argv, '', [])
458   except GetoptError:
459     return 1
460   try:
461     OS.unlink(dbfile)
462   except OSError:
463     pass
464   CMDB(dbfile).setup()
465 commands['initdb'] = \
466   (cmd_initdb, '', """
467 Initialize an attribute database.""")
468
469 def getid(local):
470   k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
471   id = Crypto(k).decrypt(M.base32_decode(local))
472   if id is None:
473     raise Reject, 'decrypt failed'
474   return id
475
476 def cmd_addrcheck(argv):
477   try:
478     opts, argv = getopt(argv, '', [])
479   except GetoptError:
480     return 1
481   local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
482   db = CMDB(dbfile)
483   try:
484     id = getid(local)
485     addr = check(db, id, sender)
486   except Reject, msg:
487     print '-%s' % msg
488     return
489   print '+%s' % addr
490 commands['addrcheck'] = \
491   (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
492 Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
493 success.""")
494
495 def cmd_fwaddr(argv):
496   try:
497     opts, argv = getopt(argv, '', [])
498   except GetoptError:
499     return 1
500   if len(argv) not in (1, 2):
501     return 1
502   local, sender = (lambda addr, sender = None: (addr, sender))(*argv)
503   db = CMDB(dbfile)
504   try:
505     id = getid(local)
506     if id is None:
507       raise Reject, 'decrypt failed'
508     addr = check(db, id, sender, stdin)
509   except Reject, msg:
510     print >>stderr, '%s rejected message: %s' % (prog, msg)
511     exit(100)
512   stdin.seek(0)
513   print addr
514 commands['fwaddr'] = \
515   (cmd_fwaddr, 'LOCAL [SENDER]', """
516 Check address token LOCAL.  On failure, report reason to stderr and exit
517 111.  On success, write forwarding address to stdout and exit 0.  Expects
518 the message on standard input, as a seekable file.""")
519
520 ignore = {'user': 1, 'addr': 1}
521 def show(db, a):
522   keys = a.keys()
523   keys.sort()
524   for k in keys:
525     if k in ignore:
526       continue
527     for v in a[k]:
528       print '\t%s: %s' % (k, v)
529   expwhen = db.expiry(a.id)
530   if expwhen:
531     print '\texpires: %s' % expwhen
532   else:
533     print '\tno-expiry'
534
535 def cmd_info(argv):
536   try:
537     opts, argv = getopt(argv, '', [])
538   except GetoptError:
539     return 1
540   if len(argv) != 1:
541     return 1
542   local = argv[0]
543   db = CMDB(dbfile)
544   try:
545     id = getid(local)
546     a = AttrMultiMap(db, id)
547     if user is not None and user != a.get('user', [None])[0]:
548       raise Reject, 'not your token'
549     if 'addr' not in a:
550       die('unknown token (expired?)')
551     print 'addr: %s' % a['addr'][0]
552     show(db, a)
553   except Reject, msg:
554     die('invalid token')
555 commands['info'] = \
556   (cmd_info, 'LOCAL', """
557 Exaimne the address token LOCAL, and print information about it to standard
558 output.""")
559
560 def cmd_revoke(argv):
561   try:
562     opts, argv = getopt(argv, '', [])
563   except GetoptError:
564     return 1
565   if len(argv) != 1:
566     return 1
567   local = argv[0]
568   db = CMDB(dbfile)
569   try:
570     id = getid(local)
571     a = AttrMultiMap(db, id)
572     if user is not None and user != a.get('user', [None])[0]:
573       raise Reject, 'not your token'
574     if 'addr' not in a:
575       die('unknown token (expired?)')
576     a.clear()
577     db.cleanup()
578     db.commit()
579   except Reject, msg:
580     die('invalid token')
581 commands['revoke'] = \
582   (cmd_revoke, 'LOCAL', """
583 Revoke the token LOCAL.""")
584
585 def cmd_list(argv):
586   try:
587     opts, argv = getopt(argv, '', [])
588   except GetoptError:
589     return 1
590   if argv:
591     return 1
592   c = Crypto(C.KeyFile(keyfile, C.KOPEN_READ)[tag])
593   db = CMDB(dbfile)
594   if not user:
595     gen = db.select('SELECT DISTINCT id FROM attrset')
596   else:
597     gen = db.select('''SELECT DISTINCT attrset.id
598                                FROM attrset, attr ON attrset.attr = attr.id
599                                WHERE attr.key = 'user' AND attr.value = ?''',
600                     [user])
601   for id, in gen:
602     a = AttrMultiMap(db, id)    
603     print '%s %s%s' % \
604           (token(c, id),
605            a.get('addr', '<no-address>')[0],
606            (not user and ' [%s]' % a.get('user', ['<no-user>'])[0] or ''))
607     show(db, a)
608 commands['list'] = \
609   (cmd_list, '', """
610 List the user's tokens and information about them.""")
611
612 def cmd_cleanup(argv):
613   try:
614     opts, argv = getopt(argv, '', [])
615   except GetoptError:
616     return 1
617   db = CMDB(dbfile)
618   db.cleanup()
619   cur = db.db.cursor()
620   cur.execute('VACUUM')
621   db.commit()
622 commands['cleanup'] = \
623   (cmd_cleanup, '', """
624 Cleans up the attribute database, disposing of old records and compatifying
625 the file.""")
626
627 def cmd_help(argv):
628   try:
629     opts, argv = getopt(argv, '', [])
630   except GetoptError:
631     return 1
632   if len(argv) == 0:
633     cmd = None
634   elif len(argv) == 1:
635     try:
636       cmd = argv[0]
637       ci = commands[cmd]
638     except KeyError:
639       die("unknown command `%s'" % cmd)
640   else:
641     return 1    
642   version()
643   print
644   if cmd:
645     print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
646     print ci[2]
647   else:
648     usage(stdout)
649     print """
650 Handle encrypted email addresses.
651
652 Help options:
653   -h, --help                    Show this help text.
654   -v, --version                 Show version number.
655   -u, --usage                   Show a usage message.
656
657 Global options:
658   -d, --database=FILE           Use FILE as the attribute database.
659   -k, --keyring=KEYRING         Use KEYRING as the keyring.
660   -t, --tag=TAG                 Use TAG as the key tag.
661   -U, --user=USER               Claim to be USER.
662 """
663     cmds = commands.keys()
664     cmds.sort()
665     print 'Subcommands:'
666     for c in cmds:
667       print '  %s %s' % (c, commands[c][1])
668 commands['help'] = \
669   (cmd_help, '[COMMAND]', """
670 Show help for subcommand COMMAND.
671 """)
672
673 ###----- Main program -------------------------------------------------------
674
675 def usage(file):
676   print >>file, \
677     'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
678 def version():
679   print '%s version 1.0.0' % prog
680 def help():
681   cmd_help()  
682
683 def main():
684   global argv, user, keyfile, dbfile, tag
685   try:
686     opts, argv = getopt(argv[1:],
687                         'hvud:k:t:U:',
688                         ['help', 'version', 'usage',
689                          'database=', 'keyring=', 'tag=', 'user='])
690   except GetoptError:
691     usage(stderr)
692     exit(111)
693   for o, a in opts:
694     if o in ('-h', '--help'):
695       help()
696       exit(0)
697     elif o in ('-v', '--version'):
698       version()
699       exit(0)
700     elif o in ('-u', '--usage'):
701       usage(stdout)
702       exit(0)
703     elif o in ('-d', '--database'):
704       dbfile = a
705     elif o in ('-k', '--keyring'):
706       keyfile = a
707     elif o in ('-t', '--tag'):
708       tag = a
709     elif o in ('-U', '--user'):
710       user = a
711     else:
712       raise 'Barf!'
713   if len(argv) < 1:
714     usage(stderr)
715     exit(111)
716
717   if argv[0] in commands:
718     c = argv[0]
719     argv = argv[1:]
720   else:
721     usage(stderr)
722     exit(111)
723   cmd = commands[c]
724   if cmd[0](argv):
725     print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
726     exit(111)
727
728 try:
729   main()
730 except SystemExit:
731   raise
732 except:
733   ty, exc, tb = exc_info()
734   moan('unhandled %s exception' % ty.__name__)
735   for file, line, func, text in TB.extract_tb(tb):
736     print >>stderr, \
737           '  %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
738   die('%s: %s' % (ty.__name__, exc[0]))