diff --git a/CHANGES b/CHANGES index f4a6612..1ab97f1 100644 --- a/CHANGES +++ b/CHANGES @@ -10,8 +10,11 @@ o Correct a dictionary naming error on Duplicate message. o Add a configuration option to specify which mail header to check for recipients. -v0.3, Not yet released +v0.3, 20-Feb-2014 o Trap new nym requests that have no email addresses on the key. o Check for malformed Content-Type returned during geturl(). o Convert uppercase chars in the recipient address before validation. o Trap unexpected responses from Webservers during URL retrieval. + +v0.4, 11-Jan-2016 +o Add a userconf option to disable send acknowledgements. diff --git a/nymserv/nymserv b/nymserv/nymserv index 883764f..375aff5 100755 --- a/nymserv/nymserv +++ b/nymserv/nymserv @@ -5,7 +5,7 @@ # nymserv.py - A Basic Nymserver for delivering messages to a shared mailbox # such as alt.anonymous.messages. # -# Copyright (C) 2011 Steve Crook +# Copyright (C) 2016 Steve Crook # # 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 @@ -17,6 +17,12 @@ # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License # for more details. +from pyaxo import Axolotl +import threading +import Queue +import hashlib +import sqlite3 +from binascii import b2a_base64,a2b_base64 from email.parser import Parser from time import sleep import cStringIO @@ -43,6 +49,7 @@ import nymserv.URL_Handler from nymserv.Config import options from nymserv.Config import config +queue = Queue.Queue() class NymservError(Exception): """Base class for all Nymserver errors.""" @@ -99,7 +106,7 @@ class PoolPost: logging.debug('Connecting to %s' % host) try: peers[host] = nntplib.NNTP(host) - #peers[host].set_debuglevel(2) + # peers[host].set_debuglevel(2) logging.debug('%s: Connection established' % host) except socket.gaierror, e: logging.warn('%s: Connection error: %s' % (host, e)) @@ -135,7 +142,7 @@ class PoolPost: """ # Iterate through all the files in the pool for filename in pool_files: - # Bool set to true if any newsserver accepts the post + # Bool set to true if any newsserver accepts the post success = False # Create a fully-qualified filename to avoid any confusion fqname = os.path.join(self.poolpath, filename) @@ -256,7 +263,7 @@ class Mailbox(): continue processed = msgparse(recipient, message) if processed: - goodcnt += 1 + goodcnt += 1 else: heldkey = held.add(message) logmes = "Message processing failed. Saving as %s" % heldkey @@ -288,7 +295,7 @@ class Mailbox(): # Strip the header from its content. recipient = line.split(": ", 1)[1].lower() # Hopefully the content is an email address. - if not '@' in recipient: + if '@' not in recipient: logging.info("Invalid recipient found: %s" % recipient) continue # Split the email address and validate the domain. @@ -315,10 +322,12 @@ class Mailbox(): class UpdateUser(): def __init__(self): # Valid fields are those that are deemed user-definable. - self.valid_fields = ['symmetric', 'hsub', 'delete', 'subject'] + self.valid_fields = ['symmetric', 'hsub', 'delete', 'acksend', + 'subject', 'ephemeral', 'ratchet'] self.alternatives = {'hash-subject': 'hsub', 'subject-password': 'hsub', - 'hash-key': 'hsub'} + 'hash-key': 'hsub', + 'acknowledge-send': 'acksend'} ignore_fields = ['version'] # Public keys contain "Version: " # We ignore the next two fields to prevent warnings about MIME content. ignore_fields.append("content-type") @@ -349,15 +358,18 @@ class UpdateUser(): logging.info(logmes) continue # If we match a field:value pair, is it valid? - if not field in self.valid_fields: - if not field in self.ignore_fields: + if field not in self.valid_fields: + if field not in self.ignore_fields: logmes = "%s: Invalid field in modify " % field logmes += "request" logging.info(logmes) continue # None or False means set the field to False. - if value.lower() == 'none' or value.lower() == 'false': + vlc = value.lower() + if vlc in '0 no none false'.split(): moddict[field] = False + elif vlc in '1 yes true'.split(): + moddict[field] = True else: moddict[field] = value return moddict @@ -404,8 +416,8 @@ class PostPrep(): message += "Message-ID: " + mid + "\n" message += "Newsgroups: %s\n" % config.get('nntp', 'newsgroups') message += "Injection-Info: %s; " % config.get('nntp', 'injectinfo') - message += "mail-complaints-to=\"%s\"\n" % \ - config.get('nntp', 'contact') + message += ("mail-complaints-to=\"%s\"\n" + % config.get('nntp', 'contact')) message += "Injection-Date: " + email.utils.formatdate() + "\n" return mid, message @@ -429,26 +441,27 @@ class PostPrep(): """ mid, headers = self.news_headers(conf) - if not 'fingerprint' in conf: + if 'fingerprint' not in conf: conf.close() logging.error('User shelve contains no fingerprint key.') return False recipient = conf['fingerprint'] # If Symmetric encryption is specified, we don't need to throw the # Keyid during Asymmetric encryption. - if 'symmetric' in conf and conf['symmetric']: - logging.debug('Symmetric encryption defined, not throwing KeyID') + if ('symmetric' in conf and conf['symmetric']) or ('ephemeral' in conf and conf['ephemeral']): + logging.debug('Symmetric or Ephemeral encryption defined, not throwing KeyID') throwkid = False else: - logging.debug('No Symmetric encryption defined, throwing KeyID') + logging.debug('No Symmetric or Ephemeral encryption defined, throwing KeyID') throwkid = True logging.debug('Signing and Encrypting message for ' + recipient) try: - result, enc_payload = gpg.signcrypt(recipient, - config.get('pgp', 'key'), - config.get('pgp', 'passphrase'), - payload, - throwkid) + result, enc_payload = gpg.signcrypt( + recipient, + config.get('pgp', 'key'), + config.get('pgp', 'passphrase'), + payload, + throwkid) except IOError, e: logging.warn("Error during pre-post encryption: %s", e) result = e @@ -459,6 +472,19 @@ class PostPrep(): if 'symmetric' in conf and conf['symmetric']: logging.debug('Adding Symmetric Encryption layer') enc_payload = gpg.symmetric(conf['symmetric'], enc_payload) + if 'ephemeral' in conf and conf['ephemeral']: + dbname = os.path.join(config.get('paths', 'users'), conf['fingerprint'] + '.db') + logging.debug('Adding Ephemeral Encryption layer') + logging.debug(dbname) + a = Axolotl(conf['fingerprint'], dbname=dbname, dbpassphrase=config.get('pgp', 'passphrase')) + a.loadState(conf['fingerprint'],'b') + ciphertext = b2a_base64(a.encrypt(enc_payload)).strip() + enc_payload = '-----BEGIN PGP MESSAGE-----\n\n' + lines = [ciphertext[i:i+64] for i in xrange(0, len(ciphertext), 64)] + for line in lines: + enc_payload += line+'\n' + enc_payload += '-----END PGP MESSAGE-----\n' + a.saveState() self.pool_write(headers + '\n' + enc_payload) elif result: logging.error("GnuPG returned an error whilst attempting to " @@ -491,15 +517,17 @@ class PostPrep(): def init_logging(): logfmt = config.get('logging', 'format') datefmt = config.get('logging', 'datefmt') - loglevels = {'debug': logging.DEBUG, 'info': logging.INFO, - 'warn': logging.WARN, 'error': logging.ERROR} + loglevels = {'debug': logging.DEBUG, + 'info': logging.INFO, + 'warn': logging.WARN, + 'error': logging.ERROR} logging.getLogger().setLevel(logging.DEBUG) logfile = logging.handlers.TimedRotatingFileHandler( - os.path.join(config.get('paths', 'log'), 'nymserv.log'), - when='midnight', - interval=1, - backupCount=config.getint('logging', 'retain'), - utc=True) + os.path.join(config.get('paths', 'log'), 'nymserv.log'), + when='midnight', + interval=1, + backupCount=config.getint('logging', 'retain'), + utc=True) logfile.setLevel(loglevels[config.get('logging', 'level')]) logfile.setFormatter(logging.Formatter(logfmt, datefmt=datefmt)) logging.getLogger().addHandler(logfile) @@ -684,7 +712,7 @@ def msgparse(recipient, message): # It might be encrypted but we don't care as we're just passing on the # payload to the Nym. nymlist = gpg.emails_to_list() - if not recipient in nymlist: + if recipient not in nymlist: logging.info('Unknown recipient %s.' % recipient) return True userfile = os.path.join(config.get('paths', 'users'), @@ -949,7 +977,7 @@ def process_config(result, payload): # in the master userconf dict. moddict = updusr.make_moddict(payload) # Does the mod request include a Delete statement? - if 'delete' in moddict and moddict['delete'].lower() == 'yes': + if 'delete' in moddict and moddict['delete'] is True: logmessage = sigfor + ": Starting delete process " logmessage += "at user request." logging.info(logmessage) @@ -970,13 +998,15 @@ def process_config(result, payload): logging.debug(logmes) userconf[key] = moddict[key] modified = True + if 'ephemeral' in moddict and moddict['ephemeral'] and 'ratchet' in moddict and moddict['ratchet']: + genEphDB(userconf['fingerprint'], userconf['ephemeral'], userconf['ratchet']) if modified and not created: # Add (or update) the modified date in the user configuration userconf['modified'] = nymserv.strutils.datestr() elif not modified and not created: logging.info("%s: Nothing modified. Sending status." % sigfor) # Everyone should have their address in the user configuration - if not 'address' in userconf: + if 'address' not in userconf: logging.debug("Adding address to userconf.") userconf['address'] = sigfor if created: @@ -990,7 +1020,23 @@ def process_config(result, payload): return True +def eph_decrypt(config, fingerprint, payload): + global queue + # Remove ephemeral wrapper from payload if required + dbname = os.path.join(config.get('paths', 'users'), fingerprint + '.db') + if os.path.exists(dbname) and '-----' in payload: + try: + a = Axolotl(fingerprint, dbname=dbname, dbpassphrase=config.get('pgp', 'passphrase')) + a.loadState(fingerprint, 'b') + pieces = payload.split('-----') + payload = a.decrypt(a2b_base64(pieces[2].replace('\n',''))) + a.saveState() + except SystemExit: + payload = 'x' + queue.put(payload) + def process_send(result, payload): + global queue logging.debug('Message received for forwarding.') # We send messages for Nymholders after verifying their signature. try: @@ -1050,6 +1096,18 @@ def process_send(result, payload): # foo_name = Freeform element of email address (Foo). # foo_addy = LHS of @ in foo_email (foo). # foo_domain = RHS of @ in foo_email + + if os.path.isfile(os.path.join(config.get('paths', 'users'), fingerprint + '.db')): + # do ephemeral decrypt in thread to prevent errors if undecryptable message + epht = threading.Thread(target=eph_decrypt, args=(config,fingerprint,payload)) + epht.start() + epht.join() + payload = queue.get() + if payload == 'x': + logmes = 'Undecryptable message - unsynced axolotl database?' + logging.info(logmes) + return False + send_msg = email.message_from_string(payload) # This section checks that the From header matches the verified # signature. It's a matter for debate but currently it's enforced @@ -1078,9 +1136,9 @@ def process_send(result, payload): # Reject sending if block_sends is defined and true if 'block_sends' in userconf and userconf['block_sends']: logging.info(nym_email + ': Sending email is blocked') - #TODO Change this return to True after (if ever) it's proven. + # TODO Change this return to True after (if ever) it's proven. return False - if not 'Subject' in send_msg: + if 'Subject' not in send_msg: logging.debug('No Subject on message, creating a dummy.') send_msg['Subject'] = 'No Subject' # Check we actually have a recipient for the message @@ -1111,16 +1169,23 @@ def process_send(result, payload): recipients += ',' + send_msg['Cc'] # email message email_message(sigfor, recipients, send_msg) - suc_message = send_success_message(send_msg) - logging.info('Posting Send confirmation to ' + sigfor) - postprep.post_message(suc_message, userconf) + # This is a little kludge as acksend is a recently added option. To + # maintain default behaviour, if it's not defined, it's set to True + # so acknowledgements will be sent. + if 'acksend' not in userconf: + logging.info("Adding acksend option to userconf for %s", sigfor) + userconf['acksend'] = True + if 'acksend' in userconf and userconf['acksend']: + suc_message = send_success_message(send_msg) + logging.info('Posting Send confirmation to ' + sigfor) + postprep.post_message(suc_message, userconf) if 'sent' in userconf: userconf['sent'] += 1 else: userconf['sent'] = 1 # Get today's date as a string today = nymserv.strutils.datestr() - if not 'last_sent' in userconf: + if 'last_sent' not in userconf: # We've never sent a message, we have now! userconf['last_sent'] = today elif userconf['last_sent'] != today: @@ -1173,6 +1238,7 @@ def delete_nym(email, userconf): from os import remove keyfile = os.path.join(config.get('paths', 'users'), email + '.key') userfile = os.path.join(config.get('paths', 'users'), email + '.db') + ephdbfile = os.path.join(config.get('paths', 'users'), memcopy['fingerprint'] + '.db') if os.path.exists(userfile): logging.info('Deleting userfile: ' + userfile) remove(userfile) @@ -1185,6 +1251,10 @@ def delete_nym(email, userconf): postprep.post_message(del_message, memcopy) logging.info('%(fingerprint)s: Deleting from keyring.' % memcopy) gpg.delete_key(memcopy['fingerprint']) + # Wait until nym delete message is sent to delete the ephdbfile + if os.path.exists(ephdbfile): + logging.info('Deleting ephdbfile: ' + ephdbfile) + remove(ephdbfile) logging.info('Deletion process complete.') return True @@ -1247,7 +1317,7 @@ def cleanup(): keyfile = os.path.join(config.get('paths', 'users'), nym + '.key') if os.path.exists(userfile): userconf = shelve.open(userfile) - if not 'address' in userconf: + if 'address' not in userconf: userconf['address'] = nym if not os.path.isfile(keyfile): f = open(keyfile, 'w') @@ -1258,6 +1328,28 @@ def cleanup(): sys.exit(0) +def genEphDB(fingerprint, mkey, ratchetKey): + dbname = os.path.join(config.get('paths', 'users'), fingerprint + '.db') + if os.path.exists(dbname): + os.unlink(dbname) + alice = Axolotl(name=fingerprint, + dbname=dbname, + dbpassphrase=config.get('pgp', 'passphrase')) + + # Based on the latest protocol specification (Oct/2014), Alice is + # the one that starts ratcheting the keys. Therefore, she needs + # needs Bob's Diffie-Hellman Ratchet Key (DHR). Since the nym + # already sends the master key to the nym server to be created, + # it makes more sense to assign mode Bob (False) to the nym and + # Alice (True) to the nym server, so that the ratchet key is also + # sent in the same message. + alice.createState(other_name='b', + mkey=hashlib.sha256(mkey).digest(), + mode=True, + other_ratchetKey=a2b_base64(ratchetKey)) + alice.saveState() + + def main(): if options.cleanup: cleanup() @@ -1269,7 +1361,7 @@ def main(): expire() elif options.recipient: sys.stdout.write("Type message here. Finish with Ctrl-D.\n") - logging.info("Processing message for hardcoded recipient %s" % \ + logging.info("Processing message for hardcoded recipient %s", options.recipient) msgparse(options.recipient, sys.stdin.read()) elif options.process: @@ -1292,10 +1384,10 @@ if (__name__ == "__main__"): init_logging() # Initialize the Daemon daemon = MyDaemon( - os.path.join(config.get('paths', 'pid'), 'nymserv.pid'), - '/dev/null', - '/dev/null', - os.path.join(config.get('paths', 'log'), 'nymserv.err')) + os.path.join(config.get('paths', 'pid'), 'nymserv.pid'), + '/dev/null', + '/dev/null', + os.path.join(config.get('paths', 'log'), 'nymserv.err')) updusr = UpdateUser() pool = PoolPost(config.get('paths', 'etc'), config.get('paths', 'pool')) diff --git a/setup.py b/setup.py index 460adf2..60e8f68 100644 --- a/setup.py +++ b/setup.py @@ -26,11 +26,12 @@ name='nymserv', author='Steve Crook', author_email='steve@mixmin.net', - version='0.3', + version='0.4', packages=['nymserv', ], scripts=['nymserv/nymserv', ], license='GPLv3', url='https://github.com/crooks/nymserv', long_description=open('README').read(), + install_requires=['pyaxo>=0.4.1', ], #data_files=[('man/man1', ['man/nymserv.1'])], )