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:
395 def cmd_generate(argv):
397 opts, argv = getopt(argv, 't:c:f:i:',
398 ['expire=', 'timeout=', 'constraint=',
402 kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
406 expwhen = C.KEXP_FOREVER
409 if o in ('-t', '--expire', '--timeout'):
411 expwhen = C.KEXP_FOREVER
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)
419 elif o in ('-f', '--format'):
421 elif o in ('-i', '--info'):
425 if timecmp(expwhen, k.deltime) > 0:
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)).
441 commands['generate'] = \
442 (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
443 Generate a new encrypted email address token forwarding to ADDR.
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.
451 sender Envelope sender must match glob pattern.
452 subject Message subject must match glob pattern.""")
454 def cmd_initdb(argv):
456 opts, argv = getopt(argv, '', [])
464 commands['initdb'] = \
466 Initialize an attribute database.""")
469 k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
470 id = Crypto(k).decrypt(M.base32_decode(local))
472 raise Reject, 'decrypt failed'
475 def cmd_addrcheck(argv):
477 opts, argv = getopt(argv, '', [])
480 local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
484 addr = check(db, id, sender)
489 commands['addrcheck'] = \
490 (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
491 Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
494 def cmd_fwaddr(argv):
496 opts, argv = getopt(argv, '', [])
499 if len(argv) not in (1, 2):
501 local, sender = (lambda addr, sender = None: (addr, sender))(*argv)
506 raise Reject, 'decrypt failed'
507 addr = check(db, id, sender, stdin)
509 print >>stderr, '%s rejected message: %s' % (prog, msg)
513 commands['fwaddr'] = \
514 (cmd_fwaddr, 'LOCAL [SENDER]', """
515 Check address token LOCAL. On failure, report reason to stderr and exit
516 111. On success, write forwarding address to stdout and exit 0. Expects
517 the message on standard input, as a seekable file.""")
521 opts, argv = getopt(argv, '', [])
530 a = AttrMultiMap(db, id)
531 if user is not None and user != a.get('user', [None])[0]:
532 raise Reject, 'not your token'
534 die('unknown token (expired?)')
539 print '%s: %s' % (k, v)
540 expwhen = db.expiry(id)
548 (cmd_info, 'LOCAL', """
549 Exaimne the address token LOCAL, and print information about it to standard
552 def cmd_revoke(argv):
554 opts, argv = getopt(argv, '', [])
563 a = AttrMultiMap(db, id)
564 if user is not None and user != a.get('user', [None])[0]:
565 raise Reject, 'not your token'
567 die('unknown token (expired?)')
573 commands['revoke'] = \
574 (cmd_revoke, 'LOCAL', """
575 Revoke the token LOCAL.""")
577 def cmd_cleanup(argv):
579 opts, argv = getopt(argv, '', [])
585 cur.execute('VACUUM')
587 commands['cleanup'] = \
588 (cmd_cleanup, '', """
589 Cleans up the attribute database, disposing of old records and compatifying
594 opts, argv = getopt(argv, '', [])
604 die("unknown command `%s'" % cmd)
610 print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
615 Handle encrypted email addresses.
618 -h, --help Show this help text.
619 -v, --version Show version number.
620 -u, --usage Show a usage message.
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.
626 -U, --user=USER Claim to be USER.
628 cmds = commands.keys()
632 print ' %s %s' % (c, commands[c][1])
634 (cmd_help, '[COMMAND]', """
635 Show help for subcommand COMMAND.
638 ###----- Main program -------------------------------------------------------
642 'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
644 print '%s version 1.0.0' % prog
649 global argv, user, keyfile, dbfile, tag
651 opts, argv = getopt(argv[1:],
653 ['help', 'version', 'usage',
654 'database=', 'keyring=', 'tag=', 'user='])
659 if o in ('-h', '--help'):
662 elif o in ('-v', '--version'):
665 elif o in ('-u', '--usage'):
668 elif o in ('-d', '--database'):
670 elif o in ('-k', '--keyring'):
672 elif o in ('-t', '--tag'):
674 elif o in ('-U', '--user'):
682 if argv[0] in commands:
690 print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
698 ty, exc, tb = exc_info()
699 moan('unhandled %s exception' % ty.__name__)
700 for file, line, func, text in TB.extract_tb(tb):
702 ' %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
703 die('%s: %s' % (ty.__name__, exc[0]))