4 ### Encrypted email address handling
6 ### (c) 2006 Mark Wooding
9 ###----- Licensing notice ---------------------------------------------------
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.
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.
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.
25 ###----- External dependencies ----------------------------------------------
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
38 import traceback as TB
40 ###----- Database messing ---------------------------------------------------
42 class AttrDB (object):
43 def __init__(me, dbfile):
44 me.db = sqlite.connect(dbfile)
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
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)')
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')
69 def select(me, expr, args = [], cur = None):
70 if cur is None: cur = me.db.cursor()
71 cur.execute(expr, args)
78 cur.execute('''DELETE FROM attr WHERE id IN
80 FROM attr LEFT JOIN attrset
81 ON attr.id = attrset.attr
82 WHERE attrset.id ISNULL)''')
83 def check(me, cleanp = False):
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''',
91 print "attrset %d missing attr %d" % (set, attr)
95 cur.execute('DELETE FROM attrset WHERE id = ?', [set])
100 class AttrSet (object):
101 def __init__(me, db, id = None):
102 if id is None: id = db.uniqueid()
105 def insert(me, key, value):
106 cur = me.db.db.cursor()
108 cur.execute('INSERT INTO attr (key, value) VALUES (?, ?)',
110 except sqlite.OperationalError:
112 cur.execute('SELECT id FROM attr WHERE key = ? AND value = ?',
117 cur.execute('INSERT INTO attrset VALUES (?, ?)',
119 except sqlite.OperationalError:
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 = ?''',
128 cur = me.db.db.cursor()
129 cur.execute('DELETE FROM attrset WHERE id = ?', [me.id])
132 class AttrMap (AttrSet, DictMixin):
133 def __getitem__(me, key):
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 = ?''',
142 raise ValueError, 'multiple values for key %s' % key
146 def __delitem__(me, key):
147 cur = me.db.db.cursor()
148 cur.execute('''DELETE FROM attrset
151 (SELECT id FROM attr WHERE key = ?)''',
154 def __setitem__(me, key, value):
156 me.insert(key, value)
159 for k, v in me.fetch():
165 return [k for k in me]
167 class AttrMultiMap (AttrMap):
168 def __getitem__(me, key):
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 = ?''',
178 def __setitem__(me, key, values):
183 ###----- Miscellaneous utilities --------------------------------------------
185 def time_format(t = None):
189 return T.strftime('%Y-%m-%d %H:%M:%S', tm)
193 if pred(i): return True
195 def every(pred, list):
197 if not pred(i): return False
200 prog = RX.sub(r'^.*[/\\]', '', argv[0])
202 print >>stderr, '%s: %s' % (prog, msg)
207 ###----- My actual database -------------------------------------------------
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)')
220 cur.execute('''DELETE FROM attrset WHERE id IN
221 (SELECT attrset FROM expiry WHERE time < ?)''',
223 cur.execute('DELETE FROM expiry WHERE time < ?', [now])
224 cur.execute('''DELETE FROM expiry WHERE attrset IN
226 FROM expiry LEFT JOIN attrset
227 ON expiry.attrset = attrset.id
228 WHERE attrset.id ISNULL)''')
231 for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
234 def expiredp(me, id):
236 if t is not None and t < time_format():
240 def setexpire(me, id, when):
241 if when != C.KEXP_FOREVER:
243 cur.execute('INSERT INTO expiry VALUES (?, ?)',
244 [id, time_format(when)])
246 ###----- Crypto messing about -----------------------------------------------
248 ## Very vague security arguments...
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.
254 ## If the block size is small, then we encrypt two blocks:
255 ## C_0 = E_K(0^{n - 64} || id)
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)
267 blksz = type(me.prp).blksz
268 p = C.MP(id).storeb(blksz)
269 c = me.prp.encrypt(p)
271 c += me.prp.encrypt(c)
275 blksz = type(me.prp).blksz
277 if len(c) != blksz * 2:
279 c, c1 = c[:blksz], c[blksz:]
280 if c1 != me.prp.encrypt(c):
285 p = me.prp.decrypt(c)
293 ###----- Canonification -----------------------------------------------------
295 rx_prefix = RX.compile(r'''(?x) ^ (
301 rx_suffix = RX.compile(r'''(?ix) (
302 \( \s* was \s* : .* \) \s* |
305 rx_punct = RX.compile(r'(?x) [^\w]+ ')
307 def canon_sender(addr):
310 def canon_subject(subject):
311 subject = subject.lower()
313 m = rx_prefix.match(subject)
315 subject = subject[m.end():]
317 m = rx_suffix.search(subject)
319 subject = subject[:m.start()]
320 subject = rx_punct.sub('', subject)
323 ###----- Checking a message for validity ------------------------------------
325 class Reject (Exception): pass
327 class MessageInfo (object):
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
342 def check_subject(mi, vv):
345 subj = mi.msg['subject']
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
353 def check_nothing(me, vv):
356 def check(db, id, sender = None, msgfile = None):
358 a = AttrMultiMap(db, id)
362 raise Reject, 'unknown id'
364 raise Reject, 'expired'
369 mi.msg = EP.HeaderParser().parse(msgfile)
370 except EP.Errors.HeaderParseError:
371 raise Reject, 'unparseable header'
373 for k, vv in a.iteritems():
374 constraints.get(k, check_nothing)(mi, vv)
377 ###----- Commands -----------------------------------------------------------
379 keyfile = 'db/keyring'
381 dbfile = 'db/cryptomail.db'
388 elif x == C.KEXP_FOREVER or y == C.KEXP_EXPIRE:
390 elif y == C.KEXP_FOREVER or x == C.KEXP_EXPIRE:
396 return M.base32_encode(c.encrypt(id)).strip('=').lower()
398 def cmd_generate(argv):
400 opts, argv = getopt(argv, 't:c:f:i:',
401 ['expire=', 'timeout=', 'constraint=',
405 kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
409 expwhen = C.KEXP_FOREVER
412 if o in ('-t', '--expire', '--timeout'):
414 expwhen = C.KEXP_FOREVER
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'):
424 elif o in ('-i', '--info'):
428 if timecmp(expwhen, k.deltime) > 0:
438 db.setexpire(a.id, expwhen)
439 print format.replace('%', token(Crypto(k), a.id))
442 commands['generate'] = \
443 (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
444 Generate a new encrypted email address token forwarding to ADDR.
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.
452 sender Envelope sender must match glob pattern.
453 subject Message subject must match glob pattern.""")
455 def cmd_initdb(argv):
457 opts, argv = getopt(argv, '', [])
465 commands['initdb'] = \
467 Initialize an attribute database.""")
470 k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
471 id = Crypto(k).decrypt(M.base32_decode(local))
473 raise Reject, 'decrypt failed'
476 def cmd_addrcheck(argv):
478 opts, argv = getopt(argv, '', [])
481 local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
485 addr = check(db, id, sender)
490 commands['addrcheck'] = \
491 (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
492 Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
495 def cmd_fwaddr(argv):
497 opts, argv = getopt(argv, '', [])
500 if len(argv) not in (1, 2):
502 local, sender = (lambda addr, sender = None: (addr, sender))(*argv)
507 raise Reject, 'decrypt failed'
508 addr = check(db, id, sender, stdin)
510 print >>stderr, '%s rejected message: %s' % (prog, msg)
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.""")
520 ignore = {'user': 1, 'addr': 1}
528 print '\t%s: %s' % (k, v)
529 expwhen = db.expiry(a.id)
531 print '\texpires: %s' % expwhen
537 opts, argv = getopt(argv, '', [])
546 a = AttrMultiMap(db, id)
547 if user is not None and user != a.get('user', [None])[0]:
548 raise Reject, 'not your token'
550 die('unknown token (expired?)')
551 print 'addr: %s' % a['addr'][0]
556 (cmd_info, 'LOCAL', """
557 Exaimne the address token LOCAL, and print information about it to standard
560 def cmd_revoke(argv):
562 opts, argv = getopt(argv, '', [])
571 a = AttrMultiMap(db, id)
572 if user is not None and user != a.get('user', [None])[0]:
573 raise Reject, 'not your token'
575 die('unknown token (expired?)')
581 commands['revoke'] = \
582 (cmd_revoke, 'LOCAL', """
583 Revoke the token LOCAL.""")
587 opts, argv = getopt(argv, '', [])
592 c = Crypto(C.KeyFile(keyfile, C.KOPEN_READ)[tag])
595 gen = db.select('SELECT DISTINCT id FROM attrset')
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 = ?''',
602 a = AttrMultiMap(db, id)
605 a.get('addr', '<no-address>')[0],
606 (not user and ' [%s]' % a.get('user', ['<no-user>'])[0] or ''))
610 List the user's tokens and information about them.""")
612 def cmd_cleanup(argv):
614 opts, argv = getopt(argv, '', [])
620 cur.execute('VACUUM')
622 commands['cleanup'] = \
623 (cmd_cleanup, '', """
624 Cleans up the attribute database, disposing of old records and compatifying
629 opts, argv = getopt(argv, '', [])
639 die("unknown command `%s'" % cmd)
645 print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
650 Handle encrypted email addresses.
653 -h, --help Show this help text.
654 -v, --version Show version number.
655 -u, --usage Show a usage message.
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.
663 cmds = commands.keys()
667 print ' %s %s' % (c, commands[c][1])
669 (cmd_help, '[COMMAND]', """
670 Show help for subcommand COMMAND.
673 ###----- Main program -------------------------------------------------------
677 'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
679 print '%s version 1.0.0' % prog
684 global argv, user, keyfile, dbfile, tag
686 opts, argv = getopt(argv[1:],
688 ['help', 'version', 'usage',
689 'database=', 'keyring=', 'tag=', 'user='])
694 if o in ('-h', '--help'):
697 elif o in ('-v', '--version'):
700 elif o in ('-u', '--usage'):
703 elif o in ('-d', '--database'):
705 elif o in ('-k', '--keyring'):
707 elif o in ('-t', '--tag'):
709 elif o in ('-U', '--user'):
717 if argv[0] in commands:
725 print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
733 ty, exc, tb = exc_info()
734 moan('unhandled %s exception' % ty.__name__)
735 for file, line, func, text in TB.extract_tb(tb):
737 ' %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
738 die('%s: %s' % (ty.__name__, exc[0]))