chiark / gitweb /
54f8483d4b9e4b80600f5641e7fadbdcf4c939a1
[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     AttrDB.cleanup(me)
225   def expiredp(me, id):
226     for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
227       if t < time_format():
228         return True
229     return False
230   def setexpire(me, id, when):
231     if when != C.KEXP_FOREVER:
232       cur = me.db.cursor()
233       cur.execute('INSERT INTO expiry VALUES (?, ?)',
234                   [id, time_format(when)])
235
236 ###----- Crypto messing about -----------------------------------------------
237
238 ## Very vague security arguments...
239 ##
240 ## If the block size n of the PRP is large enough (128 bits) then we encrypt
241 ## id || 0^{n - 64}.  Decryption checks we have the right thing.  The
242 ## security proofs for secrecy and integrity are trivial.
243 ##
244 ## If the block size is small, then we encrypt two blocks:
245 ##   C_0 = E_K(0^{n - 64} || id)
246 ##   C_1 = E_K(C_0)
247 ## The proofs are a little more complicated, but essentially work like this.
248 ## If no 0^{n - 64} || id is ever seen as a C_0 then an adversary can't tell
249 ## the difference between this and a similar construction using independent
250 ## keys.  This other construction must provide secrecy (pushing a
251 ## nonrepeating thing through a PRF) and integrity (PRF on noncolliding
252 ## inputs).  So we win, give or take a birthday term.
253 class Crypto (object):
254   def __init__(me, key):
255     me.prp = C.gcprps[key.attr.get('prp', 'blowfish')](key.data.bin)
256   def encrypt(me, id):
257     blksz = type(me.prp).blksz
258     p = C.MP(id).storeb(blksz)
259     c = me.prp.encrypt(p)
260     if blksz < 16:
261       c += me.prp.encrypt(c)
262     return c
263   def decrypt(me, c):
264     bad = False
265     blksz = type(me.prp).blksz
266     if blksz < 16:
267       if len(c) != blksz * 2:
268         return None
269       c, c1 = c[:blksz], c[blksz:]
270       if c1 != me.prp.encrypt(c):
271         bad = True
272     else:
273       if len(c) != blksz:
274         return None
275     p = me.prp.decrypt(c)
276     id = C.MP.loadb(p)
277     if id >> 64:
278       bad = True
279     if bad:
280       return None
281     return long(id)
282
283 ###----- Canonification -----------------------------------------------------
284
285 rx_prefix = RX.compile(r'''(?x) ^ (
286   \[ \S+ \] \s* |
287   \S{,4} : \s* |
288   \s+
289 )   
290 ''')
291 rx_suffix = RX.compile(r'''(?ix) (
292   \( \s* was \s* : .* \) \s* |
293   \s+
294 ) $''')
295 rx_punct = RX.compile(r'(?x) [^\w]+ ')
296
297 def canon_sender(addr):
298   return addr.lower()
299
300 def canon_subject(subject):
301   subject = subject.lower()
302   while True:
303     m = rx_prefix.match(subject)
304     if not m: break
305     subject = subject[m.end():]
306   while True:
307     m = rx_suffix.search(subject)
308     if not m: break
309     subject = subject[:m.start()]
310   subject = rx_punct.sub('', subject)
311   return subject
312
313 ###----- Checking a message for validity ------------------------------------
314
315 class Reject (Exception): pass
316
317 class MessageInfo (object):
318   __slots__ = '''
319     sender msg
320   '''.split()
321
322 constraints = {}
323
324 def check_sender(mi, vv):
325   if mi.sender is None:
326     raise Reject, 'no sender'
327   sender = canon_sender(mi.sender)
328   if not any(lambda pat: M.match(pat.lower(), sender), vv):
329     raise Reject, 'unmatched sender'
330 constraints['sender'] = check_sender
331
332 def check_subject(mi, vv):
333   if mi.msg is None:
334     return
335   subj = mi.msg['subject']
336   if subj is None:
337     raise Reject, 'no subject'
338   subj = canon_subject(subj)
339   if not any(lambda pat: M.match(pat.lower(), subj), vv):
340     raise Reject, 'unmatched subject'
341 constraints['subject'] = check_subject
342
343 def check_nothing(me, vv):
344   pass
345   
346 def check(db, id, sender = None, msgfile = None):
347   mi = MessageInfo()
348   a = AttrMultiMap(db, id)
349   try:
350     addr = a['addr'][0]
351   except KeyError:
352     raise Reject, 'unknown id'
353   if db.expiredp(id):
354     raise Reject, 'expired'
355   if msgfile is None:
356     mi.msg = None
357   else:
358     try:
359       mi.msg = EP.HeaderParser().parse(msgfile)
360     except EP.Errors.HeaderParseError:
361       raise Reject, 'unparseable header'
362   mi.sender = sender
363   for k, vv in a.iteritems():
364     constraints.get(k, check_nothing)(mi, vv)
365   return a['addr'][0]
366
367 ###----- Commands -----------------------------------------------------------
368
369 keyfile = 'db/keyring'
370 tag = 'cryptomail'
371 dbfile = 'db/cryptomail.db'
372 commands = {}
373
374 def timecmp(x, y):
375   if x == y:
376     return 0
377   elif x == C.KEXP_FOREVER or y == C.KEXP_EXPIRE:
378     return +1
379   elif y == C.KEXP_FOREVER or x == C.KEXP_EXPIRE:
380     return +1
381   else:
382     return cmp(x, y)
383
384 def cmd_generate(argv):
385   try:
386     opts, argv = getopt(argv, 't:c:f:',
387                         ['expire=', 'timeout=', 'constraint=', 'format='])
388   except GetoptError:
389     return 1
390   kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
391   k = kr[tag]
392   db = CMDB(dbfile)
393   map = {}
394   expwhen = C.KEXP_FOREVER
395   format = '%'
396   for o, a in opts:
397     if o in ('-t', '--expire', '--timeout'):
398       if a == 'forever':
399         expwhen = C.KEXP_FOREVER
400       else:
401         expwhen = getdate(a)
402     elif o in ('-c', '--constraint'):
403       c, v = a.split('=', 1)
404       if c not in constraints:
405         die("unknown constraint `%s'", c)
406       map.setdefault(c, []).append(v)
407     elif o in ('f', '--format'):
408       format = a
409     else:
410       raise 'Barf!'
411   if timecmp(expwhen, k.deltime) > 0:
412     k.deltime = expwhen
413   if len(argv) != 1:
414     return 1
415   addr = argv[0]
416   a = AttrMultiMap(db)
417   a.update(map)
418   a['addr'] = [addr]
419   c = Crypto(k).encrypt(a.id)
420   db.setexpire(a.id, expwhen)
421   print format.replace('%', M.base32_encode(Crypto(k).encrypt(a.id)).
422                        strip('=').lower())
423   db.commit()
424   kr.save()
425 commands['generate'] = \
426   (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
427 Generate a new encrypted email address token forwarding to ADDR.
428
429 Subcommand options:
430   -t, --timeout=TIME            Address should expire at TIME.
431   -c, --constraint=TYPE=VALUE   Apply constraint on the use of the address.
432   -f, --format=STRING           Substitute token for `%' in STRING.
433
434 Constraint types:
435   sender                        Envelope sender must match glob pattern.
436   subject                       Message subject must match glob pattern.""")
437
438 def cmd_initdb(argv):
439   try:
440     opts, argv = getopt(argv, '', [])
441   except GetoptError:
442     return 1
443   try:
444     OS.unlink(dbfile)
445   except OSError:
446     pass
447   CMDB(dbfile).setup()
448 commands['initdb'] = \
449   (cmd_initdb, '', """
450 Initialize an attribute database.""")
451
452 def cmd_addrcheck(argv):
453   try:
454     opts, argv = getopt(argv, '', [])
455   except GetoptError:
456     return 1
457   local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
458   k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
459   db = CMDB(dbfile)
460   try:
461     id = Crypto(k).decrypt(M.base32_decode(local))
462     if id is None:
463       raise Reject, 'decrypt failed'
464     addr = check(db, id, sender)
465   except Reject, msg:
466     print '-%s' % msg
467     return
468   print '+%s' % addr
469 commands['addrcheck'] = \
470   (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
471 Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
472 success.""")
473
474 def cmd_fwaddr(argv):
475   try:
476     opts, argv = getopt(argv, '', [])
477   except GetoptError:
478     return 1
479   local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
480   k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
481   db = CMDB(dbfile)
482   try:
483     id = Crypto(k).decrypt(M.base32_decode(local))
484     if id is None:
485       raise Reject, 'decrypt failed'
486     addr = check(db, id, sender, stdin)
487   except Reject, msg:
488     print >>stderr, '%s rejected message: %s' % (prog, msg)
489     exit(100)
490   stdin.seek(0)
491   print addr
492 commands['fwaddr'] = \
493   (cmd_fwaddr, 'LOCAL [SENDER [IGNORED ...]]', """
494 Check address token LOCAL.  On failure, report reason to stderr and exit
495 111.  On success, write forwarding address to stdout and exit 0.  Expects
496 the message on standard input, as a seekable file.""")
497
498 def cmd_cleanup(argv):
499   try:
500     opts, argv = getopt(argv, '', [])
501   except GetoptError:
502     return 1
503   db = CMDB(dbfile)
504   db.cleanup()
505   cur = db.db.cursor()
506   cur.execute('VACUUM')
507   db.commit()
508 commands['cleanup'] = \
509   (cmd_cleanup, '', """
510 Cleans up the attribute database, disposing of old records and compatifying
511 the file.""")
512
513 def cmd_help(argv):
514   try:
515     opts, argv = getopt(argv, '', [])
516   except GetoptError:
517     return 1
518   if len(argv) == 0:
519     cmd = None
520   elif len(argv) == 1:
521     try:
522       cmd = argv[0]
523       ci = commands[cmd]
524     except KeyError:
525       die("unknown command `%s'" % cmd)
526   else:
527     return 1    
528   version()
529   print
530   if cmd:
531     print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
532     print ci[2]
533   else:
534     usage(stdout)
535     print """
536 Handle encrypted email addresses.
537
538 Help options:
539   -h, --help                    Show this help text.
540   -v, --version                 Show version number.
541   -u, --usage                   Show a usage message.
542
543 Global options:
544   -d, --database=FILE           Use FILE as the attribute database.
545   -k, --keyring=KEYRING         Use KEYRING as the keyring.
546   -t, --tag=TAG                 Use TAG as the key tag.
547 """
548     cmds = commands.keys()
549     cmds.sort()
550     print 'Subcommands:'
551     for c in cmds:
552       print '  %s %s' % (c, commands[c][1])
553 commands['help'] = \
554   (cmd_help, '[COMMAND]', """
555 Show help for subcommand COMMAND.
556 """)
557
558 ###----- Main program -------------------------------------------------------
559
560 def usage(file):
561   print >>file, \
562     'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
563 def version():
564   print '%s version 1.0.0' % prog
565 def help():
566   cmd_help()  
567
568 def main():
569   global argv
570   try:
571     opts, argv = getopt(argv[1:],
572                         'hvud:k:t:',
573                         ['help', 'version', 'usage',
574                          'database=', 'keyring=', 'tag='])
575   except GetoptError:
576     usage(stderr)
577     exit(111)
578   for o, a in opts:
579     if o in ('-h', '--help'):
580       help()
581       exit(0)
582     elif o in ('-v', '--version'):
583       version()
584       exit(0)
585     elif o in ('-u', '--usage'):
586       usage(stdout)
587       exit(0)
588     elif o in ('-d', '--database'):
589       dbfile = a
590     elif o in ('-k', '--keyring'):
591       keyfile = a
592     elif o in ('-t', '--tag'):
593       tag = a
594     else:
595       raise 'Barf!'
596   if len(argv) < 1:
597     usage(stderr)
598     exit(111)
599
600   if argv[0] in commands:
601     c = argv[0]
602     argv = argv[1:]
603   else:
604     usage(stderr)
605     exit(111)
606   cmd = commands[c]
607   if cmd[0](argv):
608     print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
609     exit(111)
610
611 try:
612   main()
613 except SystemExit:
614   raise
615 except:
616   ty, exc, tb = exc_info()
617   moan('unhandled %s exception' % ty.__name__)
618   for file, line, func, text in TB.extract_tb(tb):
619     print >>stderr, \
620           '  %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
621   die('%s: %s' % (ty.__name__, exc[0]))