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])
225 def expiredp(me, id):
226 for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
227 if t < time_format():
230 def setexpire(me, id, when):
231 if when != C.KEXP_FOREVER:
233 cur.execute('INSERT INTO expiry VALUES (?, ?)',
234 [id, time_format(when)])
236 ###----- Crypto messing about -----------------------------------------------
238 ## Very vague security arguments...
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.
244 ## If the block size is small, then we encrypt two blocks:
245 ## C_0 = E_K(0^{n - 64} || id)
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)
257 blksz = type(me.prp).blksz
258 p = C.MP(id).storeb(blksz)
259 c = me.prp.encrypt(p)
261 c += me.prp.encrypt(c)
265 blksz = type(me.prp).blksz
267 if len(c) != blksz * 2:
269 c, c1 = c[:blksz], c[blksz:]
270 if c1 != me.prp.encrypt(c):
275 p = me.prp.decrypt(c)
283 ###----- Canonification -----------------------------------------------------
285 rx_prefix = RX.compile(r'''(?x) ^ (
291 rx_suffix = RX.compile(r'''(?ix) (
292 \( \s* was \s* : .* \) \s* |
295 rx_punct = RX.compile(r'(?x) [^\w]+ ')
297 def canon_sender(addr):
300 def canon_subject(subject):
301 subject = subject.lower()
303 m = rx_prefix.match(subject)
305 subject = subject[m.end():]
307 m = rx_suffix.search(subject)
309 subject = subject[:m.start()]
310 subject = rx_punct.sub('', subject)
313 ###----- Checking a message for validity ------------------------------------
315 class Reject (Exception): pass
317 class MessageInfo (object):
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
332 def check_subject(mi, vv):
335 subj = mi.msg['subject']
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
343 def check_nothing(me, vv):
346 def check(db, id, sender = None, msgfile = None):
348 a = AttrMultiMap(db, id)
352 raise Reject, 'unknown id'
354 raise Reject, 'expired'
359 mi.msg = EP.HeaderParser().parse(msgfile)
360 except EP.Errors.HeaderParseError:
361 raise Reject, 'unparseable header'
363 for k, vv in a.iteritems():
364 constraints.get(k, check_nothing)(mi, vv)
367 ###----- Commands -----------------------------------------------------------
369 keyfile = 'db/keyring'
371 dbfile = 'db/cryptomail.db'
377 elif x == C.KEXP_FOREVER or y == C.KEXP_EXPIRE:
379 elif y == C.KEXP_FOREVER or x == C.KEXP_EXPIRE:
384 def cmd_generate(argv):
386 opts, argv = getopt(argv, 't:c:f:',
387 ['expire=', 'timeout=', 'constraint=', 'format='])
390 kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
394 expwhen = C.KEXP_FOREVER
397 if o in ('-t', '--expire', '--timeout'):
399 expwhen = C.KEXP_FOREVER
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'):
411 if timecmp(expwhen, k.deltime) > 0:
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)).
425 commands['generate'] = \
426 (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
427 Generate a new encrypted email address token forwarding to ADDR.
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.
435 sender Envelope sender must match glob pattern.
436 subject Message subject must match glob pattern.""")
438 def cmd_initdb(argv):
440 opts, argv = getopt(argv, '', [])
448 commands['initdb'] = \
450 Initialize an attribute database.""")
452 def cmd_addrcheck(argv):
454 opts, argv = getopt(argv, '', [])
457 local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
458 k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
461 id = Crypto(k).decrypt(M.base32_decode(local))
463 raise Reject, 'decrypt failed'
464 addr = check(db, id, sender)
469 commands['addrcheck'] = \
470 (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
471 Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
474 def cmd_fwaddr(argv):
476 opts, argv = getopt(argv, '', [])
479 local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
480 k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
483 id = Crypto(k).decrypt(M.base32_decode(local))
485 raise Reject, 'decrypt failed'
486 addr = check(db, id, sender, stdin)
488 print >>stderr, '%s rejected message: %s' % (prog, msg)
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.""")
498 def cmd_cleanup(argv):
500 opts, argv = getopt(argv, '', [])
506 cur.execute('VACUUM')
508 commands['cleanup'] = \
509 (cmd_cleanup, '', """
510 Cleans up the attribute database, disposing of old records and compatifying
515 opts, argv = getopt(argv, '', [])
525 die("unknown command `%s'" % cmd)
531 print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
536 Handle encrypted email addresses.
539 -h, --help Show this help text.
540 -v, --version Show version number.
541 -u, --usage Show a usage message.
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.
548 cmds = commands.keys()
552 print ' %s %s' % (c, commands[c][1])
554 (cmd_help, '[COMMAND]', """
555 Show help for subcommand COMMAND.
558 ###----- Main program -------------------------------------------------------
562 'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
564 print '%s version 1.0.0' % prog
571 opts, argv = getopt(argv[1:],
573 ['help', 'version', 'usage',
574 'database=', 'keyring=', 'tag='])
579 if o in ('-h', '--help'):
582 elif o in ('-v', '--version'):
585 elif o in ('-u', '--usage'):
588 elif o in ('-d', '--database'):
590 elif o in ('-k', '--keyring'):
592 elif o in ('-t', '--tag'):
600 if argv[0] in commands:
608 print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
616 ty, exc, tb = exc_info()
617 moan('unhandled %s exception' % ty.__name__)
618 for file, line, func, text in TB.extract_tb(tb):
620 ' %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
621 die('%s: %s' % (ty.__name__, exc[0]))