Initial commit of the system.
authorMark Wooding <mdw@distorted.org.uk>
Tue, 21 Mar 2006 10:32:42 +0000 (10:32 +0000)
committerMark Wooding <mdw@distorted.org.uk>
Tue, 21 Mar 2006 10:32:42 +0000 (10:32 +0000)
.gitignore [new file with mode: 0644]
.userv/rc [new file with mode: 0644]
Makefile [new file with mode: 0644]
bin/cryptomail [new file with mode: 0755]
config [new file with mode: 0644]
crontab [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..06f2f8c
--- /dev/null
@@ -0,0 +1,3 @@
+*.stamp
+config.files
+db
diff --git a/.userv/rc b/.userv/rc
new file mode 100644 (file)
index 0000000..dd2bd53
--- /dev/null
+++ b/.userv/rc
@@ -0,0 +1,13 @@
+### Userv configuration for cryptomail
+
+if glob service generate
+       no-suppress-args
+       execute bin/cryptomail generate
+fi
+
+if ( glob service addrcheck:cryptomail-default
+   & glob calling_user qmaild
+   )
+       no-suppress-args
+       execute bin/cryptomail addrcheck --
+fi
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..7e98d7d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+### Makefile for cryptomail
+
+PRP = twofish
+KEYSZ = 256
+USER = cryptomail
+ASUSER = become $(USER) -g$(USER) --
+
+all: config.files crontab.stamp
+
+config.files: config
+       splitconf config
+
+crontab.stamp: crontab
+       @if [ -f really-install-crontab ]; then \
+         echo "crontab crontab"; \
+         crontab crontab; \
+       else \
+         echo "(Not installing crontab.)"; \
+       fi
+       touch crontab.stamp
+
+install: db/keyring db/cryptomail.db
+
+db:
+       mkdir -p -m 700 db.new
+       chown $(USER):$(USER) db.new
+       mv db.new db
+
+db/keyring: db
+       $(ASUSER) \
+         key -k db/keyring add -abinary -b$(KEYSZ) cryptomail prp=$(PRP)
+
+db/cryptomail.db: db
+       $(ASUSER) bin/cryptomail initdb
+
+clean:
+       splitconf -d config
+       rm -f config.files crontab.stamp
+
+realclean: clean
+       rm -rf db
+
+.PHONY: clean all install
diff --git a/bin/cryptomail b/bin/cryptomail
new file mode 100755 (executable)
index 0000000..0c0ed94
--- /dev/null
@@ -0,0 +1,619 @@
+#! /usr/bin/python
+### -*-python-*-
+###
+### Encrypted email address handling
+###
+### (c) 2006 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This program is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+### 
+### This program is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+### 
+### You should have received a copy of the GNU General Public License
+### along with this program; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+###----- External dependencies ----------------------------------------------
+
+import catacomb as C
+import mLib as M
+from pysqlite2 import dbapi2 as sqlite
+from UserDict import DictMixin
+from getopt import getopt, GetoptError
+from getdate import getdate 
+from sys import stdin, stdout, stderr, exit, argv, exc_info
+from email import Parser as EP
+import os as OS
+import time as T
+import sre as RX
+import traceback as TB
+
+###----- Database messing ---------------------------------------------------
+
+class AttrDB (object):
+  def __init__(me, dbfile):
+    me.db = sqlite.connect(dbfile)
+  def setup(me):
+    cur = me.db.cursor()
+    cur.execute('''CREATE TABLE attr
+                           (id INTEGER PRIMARY KEY,
+                            key VARCHAR(64) NOT NULL,
+                            value VARCHAR(256) NOT NULL)''')
+    cur.execute('''CREATE TABLE attrset
+                           (id INTEGER NOT NULL,
+                           attr INTEGER NOT NULL)''')
+    cur.execute('''CREATE TABLE uniq
+                           (id INTEGER PRIMARY KEY AUTOINCREMENT,
+                            dummy INTEGER NOT NULL)''')
+    cur.execute('CREATE UNIQUE INDEX attr_bykv ON attr (key, value)')
+    cur.execute('CREATE INDEX attrset_byid ON attrset (id)')
+    cur.execute('CREATE INDEX attrset_byattr ON attrset (attr)')
+    cur.execute('CREATE UNIQUE INDEX attrset_all ON attrset (id, attr)')
+  def uniqueid(me):
+    cur = me.db.cursor()
+    cur.execute('INSERT INTO uniq (dummy) VALUES (0)')
+    cur.execute('SELECT MAX(id) FROM uniq')
+    id = cur.fetchone()[0]
+    cur.execute('DELETE FROM uniq')
+    me.commit()
+    return id
+  def select(me, expr, args = [], cur = None):
+    if cur is None: cur = me.db.cursor()
+    cur.execute(expr, args)
+    while True:
+      r = cur.fetchone()
+      if r is None: break
+      yield r
+  def cleanup(me):
+    cur = me.db.cursor()
+    cur.execute('''DELETE FROM attr WHERE id IN
+                           (SELECT attr.id
+                            FROM attr LEFT JOIN attrset
+                            ON attr.id = attrset.attr
+                            WHERE attrset.id ISNULL)''')
+  def check(me, cleanp = False):
+    toclean = {}
+    cur = me.db.cursor()
+    for set, attr in me.select('''SELECT attrset.id, attrset.attr
+                                          FROM attrset LEFT JOIN attr
+                                          ON attrset.attr = attr.id
+                                          WHERE attr.id ISNULL''',
+                               [], cur):
+      print "attrset %d missing attr %d" % (set, attr)
+      toclean[set] = True
+    if cleanp:
+      for set in toclean:
+        cur.execute('DELETE FROM attrset WHERE id = ?', [set])
+      me.cleanup()
+  def commit(me):
+    me.db.commit()
+
+class AttrSet (object):
+  def __init__(me, db, id = None):
+    if id is None: id = db.uniqueid()
+    me.id = id
+    me.db = db
+  def insert(me, key, value):
+    cur = me.db.db.cursor()
+    try:
+      cur.execute('INSERT INTO attr (key, value) VALUES (?, ?)',
+                  [key, value])
+    except sqlite.OperationalError:
+      pass
+    cur.execute('SELECT id FROM attr WHERE key = ? AND value = ?',
+                [key, value])
+    r = cur.fetchone()
+    attr = r[0]
+    try:
+      cur.execute('INSERT INTO attrset VALUES (?, ?)',
+                  [me.id, attr])
+    except sqlite.OperationalError:
+      pass
+  def fetch(me):
+    for r in me.db.select('''SELECT attr.key, attr.value
+                               FROM attr, attrset ON attr.id = attrset.attr
+                               WHERE attrset.id = ?''',
+                          [me.id]):
+      yield r
+  def delete(me):
+    cur = me.db.db.cursor()
+    cur.execute('DELETE FROM attrset WHERE id = ?', [me.id])
+    me.db.cleanup()
+
+class AttrMap (AttrSet, DictMixin):
+  def __getitem__(me, key):
+    it = None
+    for v, in me.db.select('''SELECT attr.value
+                                FROM attr, attrset ON attr.id = attrset.attr
+                                WHERE attrset.id = ? AND attr.key = ?''',
+                           [me.id, key]):
+      if it is None:
+        it = v
+      else:
+        raise ValueError, 'multiple values for key %s' % key
+    if it is None:
+      raise KeyError, key
+    return it
+  def __delitem__(me, key):
+    cur = me.db.db.cursor()
+    cur.execute('''DELETE FROM attrset
+                           WHERE id = ? AND
+                                 attr in
+                                   (SELECT id FROM attr WHERE key = ?)''',
+                [me.id, key])
+    me.db.cleanup()
+  def __setitem__(me, key, value):
+    me.__delitem__(key)
+    me.insert(key, value)
+  def __iter__(me):
+    set = {}
+    for k, v in me.fetch():
+      if k in set:
+        continue
+      set[k] = True
+      yield k
+  def keys(me):
+    return [k for k in me]
+
+class AttrMultiMap (AttrMap):
+  def __getitem__(me, key):
+    them = []
+    for v, in me.db.select('''SELECT attr.value
+                                FROM attr, attrset ON attr.id = attrset.attr
+                                WHERE attrset.id = ? AND attr.key = ?''',
+                           [me.id, key]):
+      them.append(v)
+    if not them:
+      raise KeyError, key
+    return them
+  def __setitem__(me, key, values):
+    me.__delitem__(key)
+    for it in values:
+      me.insert(key, it)
+
+###----- Miscellaneous utilities --------------------------------------------
+
+def time_format(t = None):
+  if t is None:
+    t = T.time()
+  tm = T.gmtime(t)
+  return T.strftime('%Y-%m-%d %H:%M:%S', tm)
+
+def any(pred, list):
+  for i in list:
+    if pred(i): return True
+  return False
+def every(pred, list):
+  for i in list:
+    if not pred(i): return False
+  return True
+
+prog = RX.sub(r'^.*[/\\]', '', argv[0])
+def moan(msg):
+  print >>stderr, '%s: %s' % (prog, msg)
+def die(msg):
+  moan(msg)
+  exit(111)
+
+###----- My actual database -------------------------------------------------
+
+class CMDB (AttrDB):
+  def setup(me):
+    AttrDB.setup(me)
+    cur = me.db.cursor()
+    cur.execute('''CREATE TABLE expiry
+                           (attrset INTEGER PRIMARY KEY,
+                            time CHAR(20) NOT NULL)''')
+    cur.execute('CREATE INDEX expiry_bytime ON expiry (time)')
+  def cleanup(me):
+    cur = me.db.cursor()
+    now = time_format()
+    cur.execute('''DELETE FROM attrset WHERE id IN
+                           (SELECT attrset FROM expiry WHERE time < ?)''',
+                [now])
+    cur.execute('DELETE FROM expiry WHERE time < ?', [now])
+    AttrDB.cleanup(me)
+  def expiredp(me, id):
+    for t, in me.select('SELECT time FROM expiry WHERE attrset = ?', [id]):
+      if t < time_format():
+        return True
+    return False
+  def setexpire(me, id, when):
+    if when != C.KEXP_FOREVER:
+      cur = me.db.cursor()
+      cur.execute('INSERT INTO expiry VALUES (?, ?)',
+                  [id, time_format(when)])
+
+###----- Crypto messing about -----------------------------------------------
+
+## Very vague security arguments...
+##
+## If the block size n of the PRP is large enough (128 bits) then we encrypt
+## id || 0^{n - 64}.  Decryption checks we have the right thing.  The
+## security proofs for secrecy and integrity are trivial.
+##
+## If the block size is small, then we encrypt two blocks:
+##   C_0 = E_K(0^{n - 64} || id)
+##   C_1 = E_K(C_0)
+## The proofs are a little more complicated, but essentially work like this.
+## If no 0^{n - 64} || id is ever seen as a C_0 then an adversary can't tell
+## the difference between this and a similar construction using independent
+## keys.  This other construction must provide secrecy (pushing a
+## nonrepeating thing through a PRF) and integrity (PRF on noncolliding
+## inputs).  So we win, give or take a birthday term.
+class Crypto (object):
+  def __init__(me, key):
+    me.prp = C.gcprps[key.attr.get('prp', 'blowfish')](key.data.bin)
+  def encrypt(me, id):
+    blksz = type(me.prp).blksz
+    p = C.MP(id).storeb(blksz)
+    c = me.prp.encrypt(p)
+    if blksz < 16:
+      c += me.prp.encrypt(c)
+    return c
+  def decrypt(me, c):
+    bad = False
+    blksz = type(me.prp).blksz
+    if blksz < 16:
+      if len(c) != blksz * 2:
+        return None
+      c, c1 = c[:blksz], c[blksz:]
+      if c1 != me.prp.encrypt(c):
+        bad = True
+    else:
+      if len(c) != blksz:
+        return None
+    p = me.prp.decrypt(c)
+    id = C.MP.loadb(p)
+    if id >> 64:
+      bad = True
+    if bad:
+      return None
+    return long(id)
+
+###----- Canonification -----------------------------------------------------
+
+rx_prefix = RX.compile(r'''(?x) ^ (
+  \[ \S+ \] \s* |
+  \S{,4} : \s* |
+  \s+
+)   
+''')
+rx_suffix = RX.compile(r'''(?ix) (
+  \( \s* was \s* : .* \) \s* |
+  \s+
+) $''')
+rx_punct = RX.compile(r'(?x) [^\w]+ ')
+
+def canon_sender(addr):
+  return addr.lower()
+
+def canon_subject(subject):
+  subject = subject.lower()
+  while True:
+    m = rx_prefix.match(subject)
+    if not m: break
+    subject = subject[m.end():]
+  while True:
+    m = rx_suffix.search(subject)
+    if not m: break
+    subject = subject[:m.start()]
+  subject = rx_punct.sub('', subject)
+  return subject
+
+###----- Checking a message for validity ------------------------------------
+
+class Reject (Exception): pass
+
+class MessageInfo (object):
+  __slots__ = '''
+    sender msg
+  '''.split()
+
+constraints = {}
+
+def check_sender(mi, vv):
+  if mi.sender is None:
+    raise Reject, 'no sender'
+  sender = canon_sender(mi.sender)
+  if not any(lambda pat: M.match(pat.lower(), sender), vv):
+    raise Reject, 'unmatched sender'
+constraints['sender'] = check_sender
+
+def check_subject(mi, vv):
+  if mi.msg is None:
+    return
+  subj = mi.msg['subject']
+  if subj is None:
+    raise Reject, 'no subject'
+  subj = canon_subject(subj)
+  if not any(lambda pat: M.match(pat.lower(), subj), vv):
+    raise Reject, 'unmatched subject'
+constraints['subject'] = check_subject
+
+def check_nothing(me, vv):
+  pass
+  
+def check(db, id, sender = None, msgfile = None):
+  mi = MessageInfo()
+  a = AttrMultiMap(db, id)
+  try:
+    addr = a['addr'][0]
+  except KeyError:
+    raise Reject, 'unknown id'
+  if db.expiredp(id):
+    raise Reject, 'expired'
+  if msgfile is None:
+    mi.msg = None
+  else:
+    try:
+      mi.msg = EP.HeaderParser().parse(msgfile)
+    except EP.Errors.HeaderParseError:
+      raise Reject, 'unparseable header'
+  mi.sender = sender
+  for k, vv in a.iteritems():
+    constraints.get(k, check_nothing)(mi, vv)
+  return a['addr'][0]
+
+###----- Commands -----------------------------------------------------------
+
+keyfile = 'db/keyring'
+tag = 'cryptomail'
+dbfile = 'db/cryptomail.db'
+commands = {}
+
+def timecmp(x, y):
+  if x == y:
+    return 0
+  elif x == C.KEXP_FOREVER or y == C.KEXP_EXPIRE:
+    return +1
+  elif y == C.KEXP_FOREVER or x == C.KEXP_EXPIRE:
+    return +1
+  else:
+    return cmp(x, y)
+
+def cmd_generate(argv):
+  try:
+    opts, argv = getopt(argv, 't:c:f:',
+                        ['expire=', 'timeout=', 'constraint=', 'format='])
+  except GetoptError:
+    return 1
+  kr = C.KeyFile(keyfile, C.KOPEN_WRITE)
+  k = kr[tag]
+  db = CMDB(dbfile)
+  map = {}
+  expwhen = C.KEXP_FOREVER
+  format = '%'
+  for o, a in opts:
+    if o in ('-t', '--expire', '--timeout'):
+      if a == 'forever':
+        expwhen = C.KEXP_FOREVER
+      else:
+        expwhen = getdate(a)
+    elif o in ('-c', '--constraint'):
+      c, v = a.split('=', 1)
+      if c not in constraints:
+        die("unknown constraint `%s'", c)
+      map.setdefault(c, []).append(v)
+    elif o in ('f', '--format'):
+      format = a
+    else:
+      raise 'Barf!'
+  if timecmp(expwhen, k.deltime) > 0:
+    k.deltime = expwhen
+  if len(argv) != 1:
+    return 1
+  addr = argv[0]
+  a = AttrMultiMap(db)
+  a.update(map)
+  a['addr'] = [addr]
+  c = Crypto(k).encrypt(a.id)
+  db.setexpire(a.id, expwhen)
+  print format.replace('%', M.base32_encode(Crypto(k).encrypt(a.id)).
+                       strip('=').lower())
+  db.commit()
+  kr.save()
+commands['generate'] = \
+  (cmd_generate, '[-t TIME] [-c TYPE=VALUE] ADDR', """
+Generate a new encrypted email address token forwarding to ADDR.
+
+Subcommand options:
+  -t, --timeout=TIME           Address should expire at TIME.
+  -c, --constraint=TYPE=VALUE  Apply constraint on the use of the address.
+  -f, --format=STRING          Substitute token for `%' in STRING.
+
+Constraint types:
+  sender                       Envelope sender must match glob pattern.
+  subject                      Message subject must match glob pattern.""")
+
+def cmd_initdb(argv):
+  try:
+    opts, argv = getopt(argv, '', [])
+  except GetoptError:
+    return 1
+  try:
+    OS.unlink(dbfile)
+  except OSError:
+    pass
+  CMDB(dbfile).setup()
+commands['initdb'] = \
+  (cmd_initdb, '', """
+Initialize an attribute database.""")
+
+def cmd_addrcheck(argv):
+  try:
+    opts, argv = getopt(argv, '', [])
+  except GetoptError:
+    return 1
+  local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
+  k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
+  db = CMDB(dbfile)
+  try:
+    id = Crypto(k).decrypt(M.base32_decode(local))
+    if id is None:
+      raise Reject, 'decrypt failed'
+    addr = check(db, id, sender)
+  except Reject, msg:
+    print '-%s' % msg
+    return
+  print '+%s' % addr
+commands['addrcheck'] = \
+  (cmd_addrcheck, 'LOCAL [SENDER [IGNORED ...]]', """
+Check address token LOCAL, and report `-REASON' for failure or `+ADDR' for
+success.""")
+
+def cmd_fwaddr(argv):
+  try:
+    opts, argv = getopt(argv, '', [])
+  except GetoptError:
+    return 1
+  local, sender = (lambda addr, sender = None, *hunoz: (addr, sender))(*argv)
+  k = C.KeyFile(keyfile, C.KOPEN_READ)[tag]
+  db = CMDB(dbfile)
+  try:
+    id = Crypto(k).decrypt(M.base32_decode(local))
+    if id is None:
+      raise Reject, 'decrypt failed'
+    addr = check(db, id, sender, stdin)
+  except Reject, msg:
+    print >>stderr, '%s rejected message: %s' % (prog, msg)
+    exit(100)
+  stdin.seek(0)
+  print addr
+commands['fwaddr'] = \
+  (cmd_fwaddr, 'LOCAL [SENDER [IGNORED ...]]', """
+Check address token LOCAL.  On failure, report reason to stderr and exit
+111.  On success, write forwarding address to stdout and exit 0.  Expects
+the message on standard input, as a seekable file.""")
+
+def cmd_cleanup(argv):
+  try:
+    opts, argv = getopt(argv, '', [])
+  except GetoptError:
+    return 1
+  db = CMDB(dbfile)
+  db.cleanup()
+  cur = db.db.cursor()
+  cur.execute('VACUUM')
+  db.commit()
+commands['cleanup'] = \
+  (cmd_cleanup, '', """
+Cleans up the attribute database, disposing of old records and compatifying
+the file.""")
+
+def cmd_help(argv):
+  try:
+    opts, argv = getopt(argv, '', [])
+  except GetoptError:
+    return 1
+  if len(argv) == 0:
+    cmd = None
+  elif len(argv) == 1:
+    try:
+      cmd = argv[0]
+      ci = commands[cmd]
+    except KeyError:
+      die("unknown command `%s'" % cmd)
+  else:
+    return 1    
+  version()
+  print
+  if cmd:
+    print 'Usage: %s [-OPTIONS] %s %s' % (prog, cmd, ci[1])
+    print ci[2]
+  else:
+    usage(stdout)
+    print """
+Handle encrypted email addresses.
+
+Help options:
+  -h, --help                   Show this help text.
+  -v, --version                        Show version number.
+  -u, --usage                  Show a usage message.
+
+Global options:
+  -d, --database=FILE          Use FILE as the attribute database.
+  -k, --keyring=KEYRING                Use KEYRING as the keyring.
+  -t, --tag=TAG                        Use TAG as the key tag.
+"""
+    cmds = commands.keys()
+    cmds.sort()
+    print 'Subcommands:'
+    for c in cmds:
+      print '  %s %s' % (c, commands[c][1])
+commands['help'] = \
+  (cmd_help, '[COMMAND]', """
+Show help for subcommand COMMAND.
+""")
+
+###----- Main program -------------------------------------------------------
+
+def usage(file):
+  print >>file, \
+    'Usage: %s [-d FILE] [-k KEYRING] [-t TAG] COMMAND [ARGS...]' % prog
+def version():
+  print '%s version 1.0.0' % prog
+def help():
+  cmd_help()  
+
+def main():
+  global argv
+  try:
+    opts, argv = getopt(argv[1:],
+                        'hvud:k:t:',
+                        ['help', 'version', 'usage',
+                         'database=', 'keyring=', 'tag='])
+  except GetoptError:
+    usage(stderr)
+    exit(111)
+  for o, a in opts:
+    if o in ('-h', '--help'):
+      help()
+      exit(0)
+    elif o in ('-v', '--version'):
+      version()
+      exit(0)
+    elif o in ('-u', '--usage'):
+      usage(stdout)
+      exit(0)
+    elif o in ('-d', '--database'):
+      dbfile = a
+    elif o in ('-k', '--keyring'):
+      keyfile = a
+    elif o in ('-t', '--tag'):
+      tag = a
+    else:
+      raise 'Barf!'
+  if len(argv) < 1:
+    usage(stderr)
+    exit(111)
+
+  if argv[0] in commands:
+    c = argv[0]
+    argv = argv[1:]
+  else:
+    usage(stderr)
+    exit(111)
+  cmd = commands[c]
+  if cmd[0](argv):
+    print >>stderr, 'Usage: %s %s %s' % (prog, c, cmd[1])
+    exit(111)
+
+try:
+  main()
+except Exception:
+  ty, exc, tb = exc_info()
+  moan('unhandled %s exception' % ty.__name__)
+  for file, line, func, text in TB.extract_tb(tb):
+    print >>stderr, \
+          '  %-35s -- %.38s' % ('%s:%d (%s)' % (file, line, func), text)
+  die('%s: %s' % (ty.__name__, exc[0]))
diff --git a/config b/config
new file mode 100644 (file)
index 0000000..3566d95
--- /dev/null
+++ b/config
@@ -0,0 +1,13 @@
+### Configuration for cryptomail
+
+before = chmod +t .
+after = chmod -t .
+
+.qmail: root
+
+prefix = .qmail-
+
+portmaster: root
+
+[default] 
+| addr=$(bin/cryptomail fwaddr -- "$DEFAULT" "$SENDER") && forward "$addr"
diff --git a/crontab b/crontab
new file mode 100644 (file)
index 0000000..afe1780
--- /dev/null
+++ b/crontab
@@ -0,0 +1,7 @@
+### Cryptomail crontab
+
+SHELL=/bin/sh
+PATH=/usr/local/bin:/bin:/usr/bin
+
+# m h   dom mon dow     command
+50 03  * * 0           bin/cryptomail cleanup