chiark / gitweb /
userv: Insert the formatting stuff for a full email address.
[cryptomail] / bin / cryptomail
CommitLineData
71074336
MW
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
27import catacomb as C
28import mLib as M
29from pysqlite2 import dbapi2 as sqlite
30from UserDict import DictMixin
31from getopt import getopt, GetoptError
32from getdate import getdate
33from sys import stdin, stdout, stderr, exit, argv, exc_info
34from email import Parser as EP
35import os as OS
36import time as T
37import sre as RX
38import traceback as TB
39
40###----- Database messing ---------------------------------------------------
41
42class 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
100class 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
132class 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
167class 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
185def 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
191def any(pred, list):
192 for i in list:
193 if pred(i): return True
194 return False
195def every(pred, list):
196 for i in list:
197 if not pred(i): return False
198 return True
199
200prog = RX.sub(r'^.*[/\\]', '', argv[0])
201def moan(msg):
202 print >>stderr, '%s: %s' % (prog, msg)
203def die(msg):
204 moan(msg)
205 exit(111)
206
207###----- My actual database -------------------------------------------------
208
209class 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.
253class 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
285rx_prefix = RX.compile(r'''(?x) ^ (
286 \[ \S+ \] \s* |
287 \S{,4} : \s* |
288 \s+
289)
290''')
291rx_suffix = RX.compile(r'''(?ix) (
292 \( \s* was \s* : .* \) \s* |
293 \s+
294) $''')
295rx_punct = RX.compile(r'(?x) [^\w]+ ')
296
297def canon_sender(addr):
298 return addr.lower()
299
300def 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
315class Reject (Exception): pass
316
317class MessageInfo (object):
318 __slots__ = '''
319 sender msg
320 '''.split()
321
322constraints = {}
323
324def 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'
330constraints['sender'] = check_sender
331
332def 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'
341constraints['subject'] = check_subject
342
343def check_nothing(me, vv):
344 pass
345
346def 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
369keyfile = 'db/keyring'
370tag = 'cryptomail'
371dbfile = 'db/cryptomail.db'
372commands = {}
373
374def 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
384def 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)
010ac9cf 407 elif o in ('-f', '--format'):
71074336
MW
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()
425commands['generate'] = \
426 (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
427Generate a new encrypted email address token forwarding to ADDR.
428
429Subcommand 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
434Constraint types:
435 sender Envelope sender must match glob pattern.
436 subject Message subject must match glob pattern.""")
437
438def 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()
448commands['initdb'] = \
449 (cmd_initdb, '', """
450Initialize an attribute database.""")
451
452def 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
469commands['addrcheck'] = \
470 (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
471Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
472success.""")
473
474def 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
492commands['fwaddr'] = \
493 (cmd_fwaddr, 'LOCAL [SENDER [IGNORED ...]]', """
494Check address token LOCAL. On failure, report reason to stderr and exit
495111. On success, write forwarding address to stdout and exit 0. Expects
496the message on standard input, as a seekable file.""")
497
498def 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()
508commands['cleanup'] = \
509 (cmd_cleanup, '', """
510Cleans up the attribute database, disposing of old records and compatifying
511the file.""")
512
513def 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 """
536Handle encrypted email addresses.
537
538Help options:
539 -h, --help Show this help text.
540 -v, --version Show version number.
541 -u, --usage Show a usage message.
542
543Global 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])
553commands['help'] = \
554 (cmd_help, '[COMMAND]', """
555Show help for subcommand COMMAND.
556""")
557
558###----- Main program -------------------------------------------------------
559
560def usage(file):
561 print >>file, \
562 'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
563def version():
564 print '%s version 1.0.0' % prog
565def help():
566 cmd_help()
567
568def 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
611try:
612 main()
aeec1a4e
MW
613except SystemExit:
614 raise
615except:
71074336
MW
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]))