pax_global_header00006660000000000000000000000064132024576110014513gustar00rootroot0000000000000052 comment=04181a8b9f096c785096e8a604226037ff6a1660 pysrs-pysrs-1.0.3/000077500000000000000000000000001320245761100140525ustar00rootroot00000000000000pysrs-pysrs-1.0.3/CHANGES000066400000000000000000000020301320245761100150400ustar00rootroot000000000000001.0.1 Support python3 1.0 Use daemonize instead of start.sh, which is gone from pymilter - Foundation for python milter envfrom rewriting (in progress) - Python 2.6 - Depend on pymilter for dirs, even though we don't really need it for anything else until envfrom rewriting is done. 0.30.12 Support logging recipient host, and nosrs in pysrs.cfg 0.30.11 Support SRS signing mode. 0.30.10 Support SES 0.30.9 Support sendmail socket map. 0.30.8 Use HMAC instead of straight SHA to match reference implementation. 0.30.7 Pass SRS_DOMAIN to envfrom2srs.py Put SRS rewriting rule at end of EnvFromSMTP in pysrs.m4. Fix regex for is_srs macro in pysrs.m4. 0.30.6 set alwaysrewrite=True in envfrom2srs.py since pysrs.m4 skips local domains. Incorporate m4 macro from Alain Knaff for cleaner sendmail support. 0.30.5 Make sendmail maps use config file in /etc/mail/pysrs.cfg, so there is only only source to modify to change options. Add missing import for SRS.new() 0.30.4 Move global options to package module. Rename Base.SRS to Base.Base. pysrs-pysrs-1.0.3/COPYING000066400000000000000000000013031320245761100151020ustar00rootroot00000000000000Python SRS is a rewritten version of the Mail::SRS Perl package by Shevek. AUTHOR Shevek CPAN ID: SHEVEK cpan@anarres.org http://www.anarres.org/projects/ Translated to Python by stuart@bmsi.com http://bmsi.com/python/milter.html Portions Copyright (c) 2004 Shevek. All rights reserved. Portions Copyright (c) 2004 Business Management Systems. All rights reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Python itself: All the python code is licensed under the PSF license, a copy of which is in LICENSE.python. The sendmail m4 macros (pysrs.m4 and pysrsprog.m4) are licensed under the Sendmail license, a copy of which is in LICENSE.sendmail. pysrs-pysrs-1.0.3/LICENSE.python000066400000000000000000000045351320245761100164060ustar00rootroot00000000000000PSF LICENSE AGREEMENT FOR PYTHON 2.4 ------------------------------------ 1. This LICENSE AGREEMENT is between the Python Software Foundation ("PSF"), and the Individual or Organization ("Licensee") accessing and otherwise using Python 2.4 software in source or binary form and its associated documentation. 2. Subject to the terms and conditions of this License Agreement, PSF hereby grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, analyze, test, perform and/or display publicly, prepare derivative works, distribute, and otherwise use Python 2.4 alone or in any derivative version, provided, however, that PSF's License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001, 2002, 2003, 2004 Python Software Foundation; All Rights Reserved" are retained in Python 2.4 alone or in any derivative version prepared by Licensee. 3. In the event Licensee prepares a derivative work that is based on or incorporates Python 2.4 or any part thereof, and wants to make the derivative work available to others as provided herein, then Licensee hereby agrees to include in any such work a brief summary of the changes made to Python 2.4. 4. PSF is making Python 2.4 available to Licensee on an "AS IS" basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 2.4 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 2.4 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 2.4, OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 6. This License Agreement will automatically terminate upon a material breach of its terms and conditions. 7. Nothing in this License Agreement shall be deemed to create any relationship of agency, partnership, or joint venture between PSF and Licensee. This License Agreement does not grant permission to use PSF trademarks or trade name in a trademark sense to endorse or promote products or services of Licensee, or any third party. 8. By copying, installing or otherwise using Python 2.4, Licensee agrees to be bound by the terms and conditions of this License Agreement. pysrs-pysrs-1.0.3/LICENSE.sendmail000066400000000000000000000077751320245761100166720ustar00rootroot00000000000000 SENDMAIL LICENSE The following license terms and conditions apply, unless a different license is obtained from Sendmail, Inc., 6425 Christie Ave, Fourth Floor, Emeryville, CA 94608, USA, or by electronic mail at license@sendmail.com. License Terms: Use, Modification and Redistribution (including distribution of any modified or derived work) in source and binary forms is permitted only if each of the following conditions is met: 1. Redistributions qualify as "freeware" or "Open Source Software" under one of the following terms: (a) Redistributions are made at no charge beyond the reasonable cost of materials and delivery. (b) Redistributions are accompanied by a copy of the Source Code or by an irrevocable offer to provide a copy of the Source Code for up to three years at the cost of materials and delivery. Such redistributions must allow further use, modification, and redistribution of the Source Code under substantially the same terms as this license. For the purposes of redistribution "Source Code" means the complete compilable and linkable source code of sendmail including all modifications. 2. Redistributions of source code must retain the copyright notices as they appear in each source code file, these license terms, and the disclaimer/limitation of liability set forth as paragraph 6 below. 3. Redistributions in binary form must reproduce the Copyright Notice, these license terms, and the disclaimer/limitation of liability set forth as paragraph 6 below, in the documentation and/or other materials provided with the distribution. For the purposes of binary distribution the "Copyright Notice" refers to the following language: "Copyright (c) 1998-2004 Sendmail, Inc. All rights reserved." 4. Neither the name of Sendmail, Inc. nor the University of California nor the names of their contributors may be used to endorse or promote products derived from this software without specific prior written permission. The name "sendmail" is a trademark of Sendmail, Inc. 5. All redistributions must comply with the conditions imposed by the University of California on certain embedded code, whose copyright notice and conditions for redistribution are as follows: (a) Copyright (c) 1988, 1993 The Regents of the University of California. All rights reserved. (b) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (i) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. (ii) Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. (iii) Neither the name of the University nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 6. Disclaimer/Limitation of Liability: THIS SOFTWARE IS PROVIDED BY SENDMAIL, INC. AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL SENDMAIL, INC., THE REGENTS OF THE UNIVERSITY OF CALIFORNIA OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. $Revision$, Last updated $Date$ pysrs-pysrs-1.0.3/MANIFEST.in000066400000000000000000000005221320245761100156070ustar00rootroot00000000000000include testSRS.py include testd.py include pysrs.html include envfrom2srs.py include srs2envtol.py include pysrs.py include SocketMap.py include setup.py include setup.cfg include pysrs.cfg include pysrs.rc include pysrs.rc7 include *.m4 include pysrs.spec include CHANGES include COPYING include LICENSE.python include LICENSE.sendmail pysrs-pysrs-1.0.3/README.md000066400000000000000000000005241320245761100153320ustar00rootroot00000000000000# pysrs A python library and sendmail/Exim socketmap that rewrites MAIL FROM. Often used with [python milter](http://pythonhosted.org/milter/) to reject forged bounces (that lack a valid SRS signature). The pysrs rpm now uses the standard daemonize package to replace start.sh on EL6. This is all moot with EL7 and systemd, of course. pysrs-pysrs-1.0.3/SES/000077500000000000000000000000001320245761100145045ustar00rootroot00000000000000pysrs-pysrs-1.0.3/SES/__init__.py000066400000000000000000000005311320245761100166140ustar00rootroot00000000000000# # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. __version__ = '1.0.1' __all__= [ 'new', '__version__' ] from . import ses def new(secret=None,*args,**kw): return ses.SES(secret,*args,**kw) pysrs-pysrs-1.0.3/SES/ses.py000066400000000000000000000214741320245761100156600ustar00rootroot00000000000000# # Class to sign and verify sender addresses with message ID. # # $Log$ # Revision 1.3 2011/03/03 23:52:21 customdesigned # Release 1.0 # # Revision 1.2 2010/03/17 22:05:34 customdesigned # License updates. Python code is Python license, except srsmilter.py. # M4 sendmail macros are sendmail license. # # Revision 1.1 2005/06/18 21:44:40 customdesigned # Changes since 0.30.9. Begin SES support. # # Revision 1.8 2004/08/13 17:20:22 stuart # Limit validations. # # Revision 1.7 2004/08/13 17:09:06 stuart # support server id and fixed sigs # # Revision 1.6 2004/08/13 16:25:12 stuart # bitpack function hopefully makes things clearer # # Revision 1.5 2004/08/04 21:58:11 stuart # Tolerate case smashed tag. # # Revision 1.4 2004/08/04 15:43:25 stuart # Drop Stuart's proposal. Implement Seth's correctly, but with # message id in high order bits. # # Revision 1.3 2004/08/03 13:05:20 stuart # Base 38. 1/2 day timecode and fixed length hash for Seth. # # Revision 1.2 2004/08/02 18:50:04 stuart # Implement Seth's format as well. # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. # from __future__ import print_function import time import hmac try: from hashlib import sha1 as sha except: import sha import struct DAY = 24*60*60 # size of day # default encoding chars: base 38 BASE='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' def longbits(hash,n): "Return leading n bits of hash digest converted to long." hashbits = 0 h = 0 for b in hash: if type(b) == int: h = (h << 8) + b elif type(b) == str: h = (h << 8) + ord(b) hashbits += 8 if hashbits >= n: return h >> (hashbits - n) return h def bitpack(flds,*data): bits = 0 for n,v in zip(flds,data): bits = (bits << n) | v return bits def bitunpack(flds,bits): a = [] f = list(flds[1:]) f.reverse() for n in f: mask = (1 << n) - 1 a.insert(0,bits & mask) bits >>= n a.insert(0,bits) return a # Seth's proposal is to divide message ids into a fixed length fractional day # plus a sequentially assigned id encoded variable length with leading # zero supression. This allows shorter ids for low volume sites without # additional configuration. The entire bitstring consisting of # HMAC,ts,msgid is encoded as a block. # # If turns out to be much simpler to put the variable length msgid at # the high order end, so I depart from Seth on that point, and encode # the bitstring msgid,ts,HMAC as a block. That way we don't have to # worry about calculating the bit size of the encoded block based on # the number of encoded chars. class SES(object): def __init__(self,secret,hashbits=80,expiration=10,fbits=2,chars=BASE, nservers=1,server=0,maxval=3): if type(secret) == str: self.secret = (secret,) else: self.secret = secret self.hashbits = hashbits self.chars = chars self.last_id = 0 self.frac_day = DAY >> fbits self.last_ts = int(time.time() / self.frac_day) tsbits = 1 while (1 << tsbits) - 1 <= expiration: tsbits += 1 tsbits += fbits # bits needed for timecode self.expiration = expiration << fbits # expiration in frac days self.flds = (0,tsbits,hashbits) self.tcmask = (1 << tsbits) - 1 self.nservers = nservers self.server = server self.valtrack = {} # track validation attempts self.maxval = maxval # maximum times a sig can be validated def timecode_as_secs(self,tc): "Return timecode converted to format compatible with time.time()." return tc * self.frac_day def get_timecode(self,s=None): "Return timecode from time.time() compatible value or current time." if s is None: s = time.time() return int(s / self.frac_day) def warn(self,*msg): print('WARNING:',' '.join(msg), file=sys.stderr) def set_secret(self,*args): """ses.set_secret(new,old,...) Add a new secret to the rewriter. When an address is returned, all secrets are tried to see if the hash can be validated. Don't use "foo", "secret", "password", "10downing", "god" or "wednesday" as your secret.""" self.secret = args def get_secret(self): "Return the list of secrets. These are secret. Don't publish them." return self.secret def create_message_id(self): "Assign timestamped message id. Return timecode,msgid" # FIXME: synchronize for multithreading, make persistent ts = self.get_timecode() if ts == self.last_ts: # if still same fractional day msgid = self.last_id + 1 # assign next sequential id else: msgid = 1 self.last_ts,self.last_id = ts,msgid return ts,msgid * self.nservers + self.server def encode(self,bits): "Convert sig bits to base n chars." chars = self.chars base = len(chars) t = [] while bits > 0: bits,c = divmod(bits,base) t.append(chars[c]) t.reverse() return ''.join(t) def decode(self,s): "Convert encoded chars to sig bits." chars = self.chars if chars == chars.upper(): s = s.upper() base = len(chars) m = 0 for c in s: m = m * base + chars.index(c) return m def hash_create(self,*data): """ses.hash_create(data,...) Returns a cryptographic hash of all data in data as a long with self.hashbits bits. Any piece of data encoded into an address which must remain inviolate should be hashed, so that when the address is reversed, we can check that this data has not been tampered with. You must provide at least one piece of data to this method (otherwise this system is both cryptographically weak and there may be collision problems with sender addresses).""" secret = self.get_secret() assert secret, "Cannot create a cryptographic MAC without a secret" h = hmac.new(secret[0].encode(),b'',sha) for i in data: h.update(i) return longbits(h.digest(),self.hashbits) def hash_verify(self,hash,*data): """ses.hash_verify(hash,data,...) Verify that data has not been tampered with, given the cryptographic hash previously output by srs->hash_create(). Returns True or False. All known secrets are tried in order to see if the hash was created with an old secret.""" secret = self.get_secret() assert secret, "Cannot verify a cryptographic MAC without a secret" hashes = [] for s in secret: h = hmac.new(s.encode(), b'',sha) for i in data: h.update(i) if hash == longbits(h.digest(),self.hashbits): return True return False; def sig_create(self,msgid,ts,h): """Return encoded signature. msgid - long integer id unique for timecode ts - 32 bit timecode: day fractions since epoch h - long with high order self.hashbits bits of hash digest""" if ts: return self.encode(bitpack(self.flds,msgid,ts % self.tcmask,h)) else: return self.encode(bitpack(self.flds,msgid,self.tcmask,h)) def sig_extract(self,sig,ts): """Return msgid,timecode,hash extracted from sig. sig - encoded sig returned by sig_create ts - the current timecode """ msgid,tc,hash = bitunpack(self.flds,self.decode(sig)) tcmask = self.tcmask if tc != tcmask: tc = ts // tcmask * tcmask + int(tc) if tc > ts: tc -= tcmask else: tc = 0 return msgid,tc,hash def sign(self,address,msgid=None): """Return signed address. if msgid is supplied, a fixed signature is generated.""" local,domain = address.split('@',1) if msgid: # fixed sig ts = 0 else: ts,msgid = self.create_message_id() h = self.hash_create(struct.pack('>QQ',ts,msgid),local.encode(),b'@',domain.lower().encode()) t = self.sig_create(msgid,ts,h) return 'SES=%s=%s@%s' % (t,local,domain) def verify(self,address): """Return unsigned_address,timecode,message_id. Return (address,) unchanged if signature is invalid.""" if address.upper().startswith('SES='): try: local,domain = address.split('@',1) tag,sig,user = local.split('=') except ValueError: raise ValueError("Invalid SES signature format: %s" % local) ts = self.get_timecode() msgid,tc,h = self.sig_extract(sig,ts) if not tc or ts - tc < self.expiration and self.hash_verify(h, struct.pack('>QQ',tc,msgid),user.encode(),b'@',domain.lower().encode()): if tc: # count validations self.valtrack[msgid] = cnt = self.valtrack.get(msgid,0) + 1 if cnt > self.maxval: raise RuntimeError( "Too many validations of signature: %s" % address) return user + '@' + domain,tc,msgid return address, if __name__ == '__main__': import sys ses = SES('shhhh!') for a in sys.argv[1:]: if a.startswith('-m'): ses.last_id = int(a[2:]) elif a.startswith('SES'): print(ses.verify(a)) else: print(ses.sign(a)) pysrs-pysrs-1.0.3/SES/testses.py000066400000000000000000000066131320245761100165560ustar00rootroot00000000000000# Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. # import unittest from . import ses import time class SESTestCase(unittest.TestCase): def setUp(self): self.ses = ses.SES('shhh!') def testEncode(self): for bits in (0,1,1234,123738493059846347859040389523479): s = self.ses.encode(bits) self.assertEqual(bits,self.ses.decode(s)) def testTimecode(self): tc = self.ses.get_timecode() for tc in (0,1,50000,60000,tc): secs = self.ses.timecode_as_secs(tc) self.assertEqual(tc,self.ses.get_timecode(secs)) def testHash(self): data = (b'now', b'is', b'the', b'time') h = self.ses.hash_create(*data) self.assertTrue(self.ses.hash_verify(h,*data)) self.assertTrue(not self.ses.hash_verify(h, b'some', b'other', b'data')) def testMessageID(self): while True: tc,msgid = self.ses.create_message_id() tc2,msgid2 = self.ses.create_message_id() if tc2 == tc: break # message ID increments when timecode is unchanged self.assertEqual(msgid+1,msgid2) # message ID resets when timecode changes self.ses.last_ts -= 1 tc2,msgid2 = self.ses.create_message_id() self.assertEqual(tc,tc2) self.assertEqual(1,msgid2) def testSigpack(self): tc = 50000 h = self.ses.hash_create(b'some',b'data') for msgid in (1,100000,12345657423784): sig = self.ses.sig_create(msgid,tc,h) ts = tc + 30 msgid2,tc2,h2 = self.ses.sig_extract(sig,ts) self.assertTrue(tc2 <= ts) self.assertEqual(msgid,msgid2) self.assertEqual(tc,tc2) self.assertEqual(h,h2) ts = tc + 200 msgid2,tc2,h2 = self.ses.sig_extract(sig,ts) self.assertTrue(tc2 <= ts) self.assertEqual(msgid,msgid2) self.assertNotEqual(tc,tc2) self.assertEqual(h,h2) def get_timecode(self): "Provide a deterministic timecode for testing." return self.timecode def testSign(self): self.ses.get_timecode = self.get_timecode self.timecode = 60000 a = 'mickey@Mouse.com' sig = self.ses.sign(a) self.assertTrue(sig.endswith(a)) self.assertTrue(sig.startswith('SES=')) res = self.ses.verify(sig) self.assertEqual(res,(a,self.timecode,self.ses.last_id)) res2 = self.ses.verify(sig.lower()) self.assertEqual(res2,(a.lower(),res[1],res[2])) def testValtrack(self): a = 'mickey@Mouse.com' sig = self.ses.sign(a) res = self.ses.verify(sig) res = self.ses.verify(sig) res = self.ses.verify(sig) try: res = self.ses.verify(sig) self.fail("Failed to limit validations") except: pass def testFixed(self): self.ses.get_timecode = self.get_timecode self.timecode = 60000 a = 'mickey@Mouse.com' # sigs are normaly always unique sig = self.ses.sign(a) sig2 = self.ses.sign(a) self.assertNotEqual(sig,sig2) # but passing a msgid generates a fixed sig msgid = 12345678 sig = self.ses.sign(a,msgid) sig2 = self.ses.sign(a,msgid) self.assertEqual(sig,sig2) # that is unchanging with time as well self.timecode = 70000 sig = self.ses.sign(a,msgid) self.assertEqual(sig,sig2) a2,tc,msgid2 = self.ses.verify(sig) self.assertEqual(a2,a) self.assertEqual(tc,0) self.assertEqual(msgid2,msgid) if __name__ == '__main__': unittest.main() pysrs-pysrs-1.0.3/SRS/000077500000000000000000000000001320245761100145215ustar00rootroot00000000000000pysrs-pysrs-1.0.3/SRS/Base.py000066400000000000000000000253501320245761100157520ustar00rootroot00000000000000# $Log$ # Revision 1.4 2011/03/03 23:46:49 customdesigned # Release 1.0 # # Revision 1.3 2008/02/13 18:20:18 customdesigned # Handle quoted localpart. # # Revision 1.2 2006/02/16 05:16:59 customdesigned # Support SRS signing mode. # # Revision 1.1.1.2 2005/06/03 04:13:55 customdesigned # Support sendmail socketmap # # Revision 1.3 2004/06/09 00:29:25 stuart # Use hmac instead of straight sha # # Revision 1.2 2004/03/22 18:20:19 stuart # Missing import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. from __future__ import print_function import time import hmac try: from hashlib import sha1 as sha except: import sha import base64 import re import SRS import sys BASE26 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' BASE32 = BASE26 + '234567' BASE64 = BASE26 + BASE26.lower() + '0123456789+/' # We have two options. We can either encode an send date or an expiry # date. If we encode a send date, we have the option of changing # the expiry date later. If we encode an expiry date, we can send # different expiry dates for different sources/targets, and we don't # have to store them. # Do NOT use BASE64 since the timestamp_check routine now explicit # smashes case in the timestamp just in case there was a problem. BASE = BASE32 # This checks for more than one bit set in the size. # i.e. is the size a power of 2? base = len(BASE) if base & (base - 1): raise ValueError("Invalid base array of size %d" % base) PRECISION = 60 * 60 * 24 # One day TICKSLOTS = base * base # Two chars def parse_addr(sender): quotes = '' try: pos = sender.rindex('@') senduser = sender[:pos] sendhost = sender[pos+1:] if senduser.startswith('"') and senduser.endswith('"'): senduser = senduser[1:-1] quotes = '"' except ValueError: raise ValueError("Sender '%s' must contain exactly one @" % sender) return quotes,senduser,sendhost class Base(object): def __init__(self,secret=None,maxage=SRS.SRSMAXAGE, hashlength=SRS.SRSHASHLENGTH, hashmin=None,separator='=',alwaysrewrite=False,ignoretimestamp=False, allowunsafesrs=False): if type(secret) == str: self.secret = (secret,) else: self.secret = secret self.maxage = maxage self.hashlength =hashlength if hashmin: self.hashmin = hashmin else: self.hashmin = hashlength self.separator = separator if not separator in ('-','+','='): raise ValueError('separator must be = - or +, not %s' % separator) self.alwaysrewrite = alwaysrewrite self.ignoretimestamp = ignoretimestamp self.allowunsafesrs = allowunsafesrs self.srs0re = re.compile(r'^%s[-+=]' % SRS.SRS0TAG,re.IGNORECASE) self.srs1re = re.compile(r'^%s[-+=]' % SRS.SRS1TAG,re.IGNORECASE) #self.ses0re = re.compile(r'^%s[-+=]' % SRS.SES0TAG,re.IGNORECASE) def warn(self,*msg): print('WARNING: ',' '.join(msg), file=sys.stderr) def sign(self,sender): """srsaddress = srs.sign(sender) Map a sender address into the same sender and a cryptographic cookie. Returns an SRS address to use for preventing bounce abuse. There are alternative subclasses, some of which will return SRS compliant addresses, some will simply return non-SRS but valid RFC821 addresses. """ quotes,senduser,sendhost = parse_addr(sender) # Subclasses may override the compile() method. srsdata = self.compile(sendhost,senduser,srshost=sendhost) return '%s%s%s@%s' % (quotes,srsdata,quotes,sendhost) def forward(self,sender,alias,sign=False): """srsaddress = srs.forward(sender, alias) Map a sender address into a new sender and a cryptographic cookie. Returns an SRS address to use as the new sender. There are alternative subclasses, some of which will return SRS compliant addresses, some will simply return non-SRS but valid RFC821 addresses. """ quotes,senduser,sendhost = parse_addr(sender) # We don't require alias to be a full address, just a domain will do aliashost = alias.split('@')[-1] if aliashost.lower() == sendhost.lower() and not self.alwaysrewrite: return '%s%s%s@%s' % (quotes,senduser,quotes,sendhost) # Subclasses may override the compile() method. if sign: srsdata = self.compile(sendhost,senduser,srshost=aliashost) else: srsdata = self.compile(sendhost,senduser) return '%s%s%s@%s' % (quotes,srsdata,quotes,aliashost) def reverse(self,address): """sender = srs->reverse(srsaddress) Reverse the mapping to get back the original address. Validates all cryptographic and timestamp information. Returns the original sender address. This method will die if the address cannot be reversed.""" quotes,user,host = parse_addr(address) sendhost,senduser = self.parse(user,srshost=host) return '%s%s%s@%s' % (quotes,senduser,quotes,sendhost) def compile(self,sendhost,senduser): """srsdata = srs.compile(host,user) This method, designed to be overridden by subclasses, takes as parameters the original host and user and must compile a new username for the SRS transformed address. It is expected that this new username will be joined on SRS.SRSSEP, and will contain a hash generated from self.hash_create(...), and possibly a timestamp generated by self.timestamp_create().""" raise NotImplementedError() def parse(self,srsuser): """host,user = srs.parse(srsuser) This method, designed to be overridden by subclasses, takes an SRS-transformed username as an argument, and must reverse the transformation produced by compile(). It is required to verify any hash and timestamp in the parsed data, using self.hash_verify(hash, ...) and self->timestamp_check(timestamp).""" raise NotImplementedError() def timestamp_create(self,ts=None): """timestamp = srs.timestamp_create(time) Return a two character timestamp representing 'today', or time if given. time is a Unix timestamp (seconds since the aeon). This Python function has been designed to be agnostic as to base, and in practice, base32 is used since it can be reversed even if a remote MTA smashes case (in violation of RFC2821 section 2.4). The agnosticism means that the Python uses division instead of rightshift, but in Python that doesn't matter. C implementors should implement this operation as a right shift by 5.""" if not ts: ts = time.time() # Since we only mask in the bottom few bits anyway, we # don't need to take this modulo anything (e.g. @BASE^2). ts = int(ts // PRECISION) # print "Time is $time\n"; mask = base - 1 out = BASE[ts & mask] ts //= base # Use right shift. return BASE[ts & mask]+out def timestamp_check(self,timestamp): """srs.timestamp_check(timestamp) Return True if a timestamp is valid, False otherwise. There are 4096 possible timestamps, used in a cycle. At any time, $srs->{MaxAge} timestamps in this cycle are valid, the last one being today. A timestamp from the future is not valid, neither is a timestamp from too far into the past. Of course if you go far enough into the future, the cycle wraps around, and there are valid timestamps again, but the likelihood of a random timestamp being valid is 4096/$srs->{MaxAge}, which is usually quite small: 1 in 132 by default.""" if self.ignoretimestamp: return True ts = 0 for d in timestamp.upper(): # LOOK OUT - USE BASE32 ts = ts * base + BASE.find(d) now = (time.time() // PRECISION) % TICKSLOTS # print "Time is %d, Now is %d" % (ts,now) while now < ts: now += TICKSLOTS if now <= ts + self.maxage: return True return False def time_check(self,ts): """srs.time_check(time) Similar to srs.timestamp_check(timestamp), but takes a Unix time, and checks that an alias created at that Unix time is still valid. This is designed for use by subclasses with storage backends.""" return time.time() <= (ts + (self.maxage * PRECISION)) def hash_create(self,*data): """srs.hash_create(data,...) Returns a cryptographic hash of all data in data. Any piece of data encoded into an address which must remain inviolate should be hashed, so that when the address is reversed, we can check that this data has not been tampered with. You must provide at least one piece of data to this method (otherwise this system is both cryptographically weak and there may be collision problems with sender addresses).""" secret = self.get_secret() assert secret, "Cannot create a cryptographic MAC without a secret" h = hmac.new(secret[0].encode(),b'',sha) for i in data: h.update(i.lower()) hash = base64.encodestring(h.digest()) return hash[:self.hashlength] def hash_verify(self,hash,*data): """srs.hash_verify(hash,data,...) Verify that data has not been tampered with, given the cryptographic hash previously output by srs->hash_create(). Returns True or False. All known secrets are tried in order to see if the hash was created with an old secret.""" if len(hash) < self.hashmin: return False secret = self.get_secret() assert secret, "Cannot create a cryptographic MAC without a secret" hashes = [] for s in secret: h = hmac.new(s.encode(),b'',sha) for i in data: h.update(i.lower()) valid = base64.encodestring(h.digest())[:len(hash)] # We test all case sensitive matches before case insensitive # matches. While the risk of a case insensitive collision is # quite low, we might as well be careful. if valid == hash: return True hashes.append(valid) # lowercase it later hash = hash.lower() for h in hashes: if hash == h.lower(): self.warn("""SRS: Case insensitive hash match detected. Someone smashed case in the local-part.""") return True return False; def set_secret(self,*args): """srs.set_secret(new,old,...) Add a new secret to the rewriter. When an address is returned, all secrets are tried to see if the hash can be validated. Don't use "foo", "secret", "password", "10downing", "god" or "wednesday" as your secret.""" self.secret = args def get_secret(self): "Return the list of secrets. These are secret. Don't publish them." return self.secret def separator(self): """srs.separator() Return the initial separator, which follows the SRS tag. This is only used as the initial separator, for the convenience of administrators who wish to make srs0 and srs1 users on their mail servers and require to use + or - as the user delimiter. All other separators in the SRS address must be C<=>.""" return self.separator pysrs-pysrs-1.0.3/SRS/DB.py000066400000000000000000000045621320245761100153670ustar00rootroot00000000000000# $Log$ # Revision 1.1.1.1 2005/06/03 04:13:18 customdesigned # Initial import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. try: import bsddb3 as bsddb except: import bsddb import time import SRS from .Base import Base from pickle import dumps, loads class DB(Base): """A MLDBM based Sender Rewriting Scheme SYNOPSIS from SRS.DB import DB srs = DB(Database='/var/run/srs.db', ...) DESCRIPTION See Base.py for details of the standard SRS subclass interface. This module provides the methods compile() and parse(). This module requires one extra parameter to the constructor, a filename for a Berkeley DB_File database. BUGS This code relies on not getting collisions in the cryptographic hash. This can and should be fixed. The database is not garbage collected.""" def __init__(self,database='/var/run/srs.db',hashlength=24,*args,**kw): Base.__init__(self,hashlength=hashlength,*args,**kw) assert database, "No database specified for SRS.DB" self.dbm = bsddb.btopen(database,'c') def compile(self,sendhost,senduser,srshost=None): ts = time.time() data = dumps((ts,sendhost,senduser)) # We rely on not getting collisions in this hash. hash = self.hash_create(sendhost.encode(),senduser.encode()) self.dbm[hash] = data # Note that there are 4 fields here and that sendhost may # not contain a + sign. Therefore, we do not need to escape # + signs anywhere in order to reverse this transformation. return SRS.SRS0TAG + self.separator + hash.decode() def parse(self,user,srshost=None): user,m = self.srs0re.subn('',user,1) assert m, "Reverse address does not match %s." % self.srs0re.pattern hash = user data = self.dbm[hash.encode()] ts,sendhost,senduser = loads(data) assert self.hash_verify(hash.encode(),sendhost.encode(),senduser.encode()), "Invalid hash" assert self.time_check(ts), "Invalid timestamp" return (sendhost, senduser) pysrs-pysrs-1.0.3/SRS/Daemon.py000077500000000000000000000051571320245761100163110ustar00rootroot00000000000000# Exim compatible socket server for SRS # $Log$ # Revision 1.3 2004/08/26 03:31:38 stuart # Introduce sendmail socket map # # Revision 1.2 2004/03/23 18:46:38 stuart # support commandline args for key # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. from .Guarded import Guarded import os import os.path import socketserver SRSSOCKET = '/tmp/srsd'; class EximHandler(socketserver.StreamRequestHandler): def handle(self): srs = self.server.srs sock = self.rfile try: line = self.rfile.readline() # print("Read '%s' on %s\n" % (line.strip(),self.request)) args = line.decode().split() cmd = args.pop(0).upper() if cmd == 'FORWARD': res = srs.forward(*args) elif cmd == 'REVERSE': res = srs.reverse(*args) else: raise ValueError("Invalid command %s" % cmd) except Exception as x: res = "ERROR: %s"%x self.wfile.write((res+'\n').encode()) class Daemon(object): def __init__(self,secret=None,secretfile=None,socket=SRSSOCKET, *args,**kw): secrets = [] if secret: secrets += secret if secretfile and os.path.exists(secretfile): assert os.path.isfile(secretfile) and os.access(secretfile,os.R_OK), \ "Secret file $secretfile not readable" FH = open(secretfile) for ln in FH: if not ln: continue if ln.startswith('#'): continue secrets += ln FH.close() assert secrets, \ """No secret or secretfile given. Use --secret or --secretfile, and ensure the secret file is not empty.""" # Preserve the pertinent original arguments, mostly for fun. self.secret = secret self.secretfile = secretfile self.socket = socket self.srs = Guarded(secret=secrets,*args,**kw) try: os.unlink(socket) except: pass self.server = socketserver.UnixStreamServer(socket,EximHandler) self.server.srs = self.srs def run(self): self.server.serve_forever() def main(args): from getopt import getopt opts,args = getopt(args,'',['secret=','secretfile=']) kw = dict([(opt[2:],val) for opt,val in opts]) server = Daemon(*args,**kw) server.run() if __name__ == '__main__': import sys main(sys.argv[1:]) pysrs-pysrs-1.0.3/SRS/Guarded.py000066400000000000000000000067561320245761100164640ustar00rootroot00000000000000# $Log$ # Revision 1.1.1.1 2005/06/03 04:13:18 customdesigned # Initial import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import re import SRS from .Shortcut import Shortcut class Guarded(Shortcut): """This is the default subclass of SRS. An instance of this subclass is actually constructed when "new SRS" is called. Note that allowing variable separators after the SRS\d token means that we must preserve this separator in the address for a possible reversal. SRS1 does not need to understand the SRS0 address, just preserve it, on the assumption that it is valid and that the host doing the final reversal will perform cryptographic tests. It may therefore strip just the string SRS0 and not the separator. This explains the appearance of a double separator in SRS1=. See Mail::SRS for details of the standard SRS subclass interface. This module provides the methods compile() and parse(). It operates without store, and guards against gaming the shortcut system.""" def __init__(self,*args,**kw): self.srs0rek = re.compile(r'^%s(?=[-+=])' % SRS.SRS0TAG,re.IGNORECASE) Shortcut.__init__(self,*args,**kw) def compile(self,sendhost,senduser,srshost=None): senduser,m = self.srs1re.subn('',senduser,1) if m: # We could do a sanity check. After all, it might NOT be # an SRS address, unlikely though that is. We are in the # presence of malicious agents. However, since we don't need # to interpret it, it doesn't matter if it isn't an SRS # address. Our malicious SRS0 party gets back the garbage # he spat out. # Actually, it turns out that we can simplify this # function considerably, although it should be borne in mind # that this address is not opaque to us, even though we didn't # actually process or generate it. # hash, srshost, srsuser undef,srshost,srsuser = senduser.split(SRS.SRSSEP,2) hash = self.hash_create(srshost.encode(),srsuser.encode()) return SRS.SRS1TAG + self.separator + \ SRS.SRSSEP.join((hash.decode(),srshost,srsuser)) senduser,m = self.srs0rek.subn('',senduser,1) if m: hash = self.hash_create(sendhost.encode(), senduser.encode()) return SRS.SRS1TAG + self.separator + \ SRS.SRSSEP.join((hash.decode(),sendhost,senduser)) return Shortcut.compile(self,sendhost,senduser,srshost=srshost) def parse(self,user,srshost=None): user,m = self.srs1re.subn('',user,1) if m: hash,srshost,srsuser = user.split(SRS.SRSSEP, 2)[-3:] if hash.find('.') >= 0: assert self.allowunsafesrs, \ "Hashless SRS1 address received when AllowUnsafeSrs is not set" # Reconstruct the parameters as they were in the old format. srsuser = srshost + SRS.SRSSEP + srsuser srshost = hash else: assert srshost and srsuser, "Invalid SRS1 address" assert self.hash_verify(hash.encode(),srshost.encode(),srsuser.encode()), "Invalid hash" return srshost, SRS.SRS0TAG + srsuser return Shortcut.parse(self,user,srshost=srshost) pysrs-pysrs-1.0.3/SRS/Reversible.py000066400000000000000000000027121320245761100171770ustar00rootroot00000000000000# $Log$ # Revision 1.1.1.1 2005/06/03 04:13:18 customdesigned # Initial import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import SRS from .Shortcut import Shortcut class Reversible(Shortcut): """A fully reversible Sender Rewriting Scheme See SRS for details of the standard SRS subclass interface. This module provides the methods compile() and parse(). It operates without store.""" def compile(self,sendhost,senduser,srshost=None): timestamp = self.timestamp_create() # This has to be done in compile, because we might need access # to it for storing in a database. hash = self.hash_create(timestamp.encode(),sendhost.encode(),senduser.encode()) if sendhost == srshost: sendhost = '' # Note that there are 4 fields here and that sendhost may # not contain a + sign. Therefore, we do not need to escape # + signs anywhere in order to reverse this transformation. return SRS.SRS0TAG + self.separator + \ SRS.SRSSEP.join((hash.decode(),timestamp,sendhost,senduser)) pysrs-pysrs-1.0.3/SRS/Shortcut.py000066400000000000000000000064501320245761100167130ustar00rootroot00000000000000# $Log$ # Revision 1.1.1.1 2005/06/03 04:13:18 customdesigned # Initial import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import SRS from .Base import Base class Shortcut(Base): """SRS.Shortcut - A shortcutting Sender Rewriting Scheme SYNOPSIS import SRS.Shortcut srs = SRS.Shortcut(...) DESCRIPTION WARNING: Using the simple Shortcut strategy is a very bad idea. Use the Guarded strategy instead. The weakness in the Shortcut strategy is documented at http://www.anarres.org/projects/srs/ See Mail::SRS for details of the standard SRS subclass interface. This module provides the methods compile() and parse(). It operates without store, and shortcuts around all middleman resenders.""" def compile(self,sendhost,senduser,srshost=None): senduser,m = self.srs0re.subn('',senduser,1) if m: # This duplicates effort in Guarded.pm but makes this file work # standalone. # We just do the split because this was hashed with someone # else's secret key and we can't check it. # hash, timestamp, host, user undef,undef,sendhost,senduser = senduser.split(SRS.SRSSEP,3) # We should do a sanity check. After all, it might NOT be # an SRS address, unlikely though that is. We are in the # presence of malicious agents. However, this code is # never reached if the Guarded subclass is used. else: senduser,m = self.srs1re.subn('',senduser,1) if m: # This should never be hit in practice. It would be bad. # Introduce compatibility with the guarded format? # SRSHOST, hash, timestamp, host, user sendhost,senduser = senduser.split(SRS.SRSSEP,5)[-2:] timestamp = self.timestamp_create() hash = self.hash_create(timestamp.encode(), sendhost.encode(), senduser.encode()) if sendhost == srshost: sendhost = '' # Note that there are 5 fields here and that sendhost may # not contain a valid separator. Therefore, we do not need to # escape separators anywhere in order to reverse this # transformation. return SRS.SRS0TAG + self.separator + \ SRS.SRSSEP.join((hash.decode(),timestamp,sendhost,senduser)) def parse(self,user,srshost=None): user,m = self.srs0re.subn('',user,1) # We should deal with SRS1 addresses here, just in case? assert m, "Reverse address does not match %s." % self.srs0re.pattern # The 4 here matches the number of fields we encoded above. If # there are more separators, then they belong in senduser anyway. hash,timestamp,sendhost,senduser = user.split(SRS.SRSSEP,3)[-4:] if not sendhost and srshost: sendhost = srshost # Again, this must match as above. assert self.hash_verify(hash.encode(),timestamp.encode(),sendhost.encode(),senduser.encode()), "Invalid hash" assert self.timestamp_check(timestamp), "Invalid timestamp" return sendhost,senduser pysrs-pysrs-1.0.3/SRS/__init__.py000066400000000000000000000031311320245761100166300ustar00rootroot00000000000000# $Log$ # Revision 1.3 2006/02/16 05:21:25 customdesigned # Support SRS signing mode. # # Revision 1.2 2005/08/11 23:35:32 customdesigned # SES support. # # Revision 1.1.1.2 2005/06/03 04:13:56 customdesigned # Support sendmail socketmap # # Revision 1.6 2004/08/26 03:31:38 stuart # Introduce sendmail socket map # # Revision 1.5 2004/06/09 00:32:05 stuart # Release 0.30.8 # # Revision 1.4 2004/03/24 23:59:42 stuart # Release 0.30.7 # # Revision 1.3 2004/03/23 20:36:39 stuart # Version 0.30.6 # # Revision 1.2 2004/03/22 18:20:19 stuart # Missing import # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. __version__ = '1.0.1' __all__= [ 'Base', 'Guarded', 'Shortcut', 'Reversible', 'Daemon', 'DB', 'new', 'SRS0TAG', 'SRS1TAG', 'SRSSEP', 'SRSHASHLENGTH', 'SRSMAXAGE', '__version__' ] SRS0TAG = 'SRS0' SRS1TAG = 'SRS1' SRSSEP = '=' SRSHASHLENGTH = 4 SRSMAXAGE = 21 #from Base import SRS #from Guarded import Guarded #from Shortcut import Shortcut #from Reversible import Reversible #from Daemon import Daemon #from DB import DB from . import Guarded def new(secret=None,*args,**kw): return Guarded.Guarded(secret,*args,**kw) pysrs-pysrs-1.0.3/SocketMap.py000077500000000000000000000052631320245761100163230ustar00rootroot00000000000000# Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. # # Base class for sendmail socket servers try: import socketserver except: import SocketServer as socketserver class MapError(Exception): def __init__(self,code,reason): self.code = code self.reason = reason class Handler(socketserver.StreamRequestHandler): def write(self,s): "write netstring to socket" self.wfile.write('%d:%s,' % (len(s),s)) self.log(s) def _readlen(self,maxlen=8): "read netstring length from socket" n = "" file = self.rfile ch = file.read(1) while ch != ":": if not ch: raise EOFError if not ch in "0123456789": raise ValueError if len(n) >= maxlen: raise OverflowError n += ch ch = file.read(1) return int(n) def read(self, maxlen=None): "Read a netstring from the socket, and return the extracted netstring." n = self._readlen() if maxlen and n > maxlen: raise OverflowError file = self.rfile s = file.read(n) ch = file.read(1) if ch == ',': return s if ch == "": raise EOFError raise ValueError def handle(self): #self.log("connect") while True: try: line = self.read() self.log(line) args = line.split(' ',1) map = args.pop(0).replace('-','_') meth = getattr(self, '_handle_' + map, None) if not map: raise ValueError("Unrecognized map: %s" % map) res = meth(*args) self.write('OK ' + res) except EOFError: #self.log("Ending connection") return except MapError as x: if code in ('PERM','TIMEOUT','NOTFOUND','OK','TEMP'): self.write("%s %s"%(x.code,x.reason)) else: self.write("%s %s %s"%('PERM',x.code,x.reason)) except LookupError as x: self.write("NOTFOUND") except Exception as x: #print x self.write("TEMP %s"%x) # PERM,TIMEOUT # Application should subclass SocketMap.Daemon, and define # a _handler_map_name method for each sendmail socket map handled # by this server. The socket is a unix domain socket which must match # the socket defined in sendmail.cf. # # Socket maps in sendmail.cf look like this: # Kmy_map socket local:/tmp/sockd class Daemon(object): def __init__(self,socket,handlerfactory): self.socket = socket try: os.unlink(socket) except: pass self.server = socketserver.ThreadingUnixStreamServer(socket,handlerfactory) self.server.daemon = self def run(self): self.server.serve_forever() pysrs-pysrs-1.0.3/envfrom2srs.py000066400000000000000000000040011320245761100167050ustar00rootroot00000000000000#!/usr/bin/python2.3 # sendmail program map for SRS # # Use only if absolutely necessary. It is *very* inefficient and # a security risk. # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import SRS import re from ConfigParser import ConfigParser, DuplicateSectionError # get SRS parameters from milter configuration cp = ConfigParser({ 'secret': 'shhhh!', 'maxage': '8', 'hashlength': '8', 'fwdomain': 'mydomain.com', 'separator': '=' }) cp.read(["/etc/mail/pysrs.cfg"]) try: cp.add_section('srs') except DuplicateSectionError: pass srs = SRS.new( secret=cp.get('srs','secret'), maxage=cp.getint('srs','maxage'), hashlength=cp.getint('srs','hashlength'), separator=cp.get('srs','separator'), alwaysrewrite=True # pysrs.m4 can skip calling us for local domains ) fwdomain = cp.get('srs','fwdomain') del cp # Our original envelope-from may look funny on entry # of this Ruleset: # # admin<@asarian-host.net.> # # We need to preprocess it some: def forward(old_address): if old_address == '<@>': return old_address use_address = re.compile(r'[<>]').sub('',old_address) use_address = re.compile(r'\.$').sub('',use_address) # Ok, first check whether we already have a signed SRS address; # if so, just return the old address: we do not want to double-sign # by accident! # # Else, gimme a valid SRS signed address, munge it back the way # sendmail wants it at this point; or just return the old address, # in case nothing went. try: new_address = srs.reverse(use_address) return old_address except: try: new_address = srs.forward(use_address,fwdomain) return new_address.replace('@','<@',1)+'.>' except: return old_address if __name__ == "__main__": import sys # No funny business in our output, please sys.stderr.close() if len(sys.argv) > 2: fwdomain = sys.argv[-2] print(forward(sys.argv[-1])) pysrs-pysrs-1.0.3/makefile000066400000000000000000000003411320245761100155500ustar00rootroot00000000000000web: doxygen rsync -ravK doc/html/ spidey2.bmsi.com:/Public/pymilter VERSION=1.0.3 PKG=pysrs-$(VERSION) SRCTAR=$(PKG).tar.gz $(SRCTAR): git archive --format=tar.gz --prefix=$(PKG)/ -o $(SRCTAR) $(PKG) srctar: $(SRCTAR) pysrs-pysrs-1.0.3/pysrs.cfg000066400000000000000000000016761320245761100157250ustar00rootroot00000000000000# sample SRS configuration [srs] ;secret="shhhh!" ;maxage=21 ;hashlength=5 # if defined, SRS uses a database for opaque rewriting ;database=/var/log/milter/srsdata # sign these domains using SES to prevent forged bounces instead of SRS ;ses = localdomain1.com, localdomain2.org # sign these domains using SRS in signing mode to prevent forged bounces ;sign = localdomain1.com, localdomain2.org # rewrite all other domains to this domain using SRS ;fwdomain = mydomain.com # reject unsigned mail to these domains in pymilter (used by pymilter) ;srs = otherdomain.com # do not rewrite mail to these domains ;nosrs = braindeadmail.com # [srsmilter] ;datadir=/var/lib/milter socketname = /var/run/milter/srsmilter miltername = pysrsfilter # reject DSNs to unsigned recipients (bounce spam) reject_spoofed = true ;trusted_relay = 1.2.3.4 internal_connect = 192.168.*.*,127.0.0.1,::1 # Enable outgoing SRS via CHGFROM (see code for limitations) miltersrs = false pysrs-pysrs-1.0.3/pysrs.html000066400000000000000000000072241320245761100161250ustar00rootroot00000000000000 Python SRS

Viewable With Any Browser Your vote? I Disagree I Agree

Sender Rewriting Scheme in Python

This web page is written by Stuart D. Gathman
and
originally sponsored by Business Management Systems, Inc.
Last updated Oct 17, 2017

This is a Python implementation of the Sender Rewriting Scheme. It is a fairly direct translation of the draft implementation in Perl by Shevek. It includes a test suite, which currently checks four levels of forwarding and subsequent reversal for the Guarded, DB, and Reversible implementations.

  • SRS.Daemon.Daemon() provides a simple socket daemon suitable for use with the Exim mailer.
  • RPM now includes a sendmail socketmap daemon. The program map is no longer recommended. It is slow and a security risk. Prior to socketmaps, it was all that was available for a custom map. Socketmap is available in sendmail 8.13. Use the supplied sendmail m4 hack with sendmail.mc to install the socketmap.
  • For best results, use with Python milter to reject unsigned recipients.

Sendmail integration

Add the following lines to your /etc/mail/sendmail.mc (RedHat / Fedora) after any MAILER():
dnl #
dnl # File listing domains we do not SRS encode for when sending to
dnl #
define(`NO_SRS_FILE',`/etc/mail/no-srs-mailers')dnl
dnl #
dnl # Uncomment the following if you do not wish to SRS encode mail from
dnl # local domains.  Only non-local domains need to be SRS encoded to
dnl # satisfy SPF.  But encoding all outgoing mail can detect bounce forgeries.
dnl #
dnl define(`NO_SRS_FROM_LOCAL')dnl
dnl #
HACK(`pysrs',`/var/run/milter/pysrs')dnl
If you cannot install a version of sendmail with socketmap support, then the original program map is still available as HACK(pysrsprog).
  • NO_SRS_FILE is the path of a file containing the recipient MTA's for which you won't do SRS (typically, primary MXes for which you are secondary). Just leave this away, if you are secondary for nobody. The no-srs-mailers file is a simple text file which has one recipient MTA per line.
  • The argument to pysrs is the socket where the socketmap daemon is listening. This must match /etc/mail/pysrs.cfg or the default of /var/run/milter/pysrs.
  • NO_SRS_FROM_LOCAL : if this is set (define line present), then no SRS is done if sender is local (i.e. his domain is in /etc/mail/local-host-names)
  • The argument to pysrsprog is the domain that your SRS addresses bear (i.e. if your SRS addresses are srs0=mumble-jumble-toto@mydomain.com, then the argument is mydomain.com). This overrides fwdomain in /etc/mail/pysrs.cfg.

Downloads

Goto Github repo for latest source. pysrs-pysrs-1.0.3/pysrs.m4000066400000000000000000000035701320245761100155010ustar00rootroot00000000000000divert(-1) # # Copyright (c) 2004 Alain Knaff (derived from work by asarian-host.net) # All rights reserved. # Copyright (c) 1988, 1993 # The Regents of the University of California. All rights reserved. # Portions Copyright (c) 2004-2009 # Business Management Systems, Inc All rights reserved. # # By using this file, you agree to the terms and conditions set # forth in the LICENSE file which can be found at the top level of # the sendmail distribution. # # divert(0) VERSIONID(`$Id$') ifdef(`_MAILER_DEFINED_',,`errprint(`*** WARNING: MAILER() should be before HACK(pysrs) ')') ifdef(`_ARG_',,`errprint(`*** WARNING: HACK(pysrs,sockname) requires sockname ')') ifelse(defn(`_ARG_'),`',,`define(`SRS_SOCKET',_ARG_)') LOCAL_CONFIG # Forward SRS map Kmake_srs socket SRS_SOCKET # Reverse SRS map Kreverse_srs socket SRS_SOCKET # "To" address is SRS Kis_srs regex ^MakeSrs $1 make SRS LOCAL_RULESETS SIsSrs # Answers YES or NO whether the address in parameter is srs or not R$* $: $( is_srs $1 $) R$@ $@ YES R$* $@ NO SMakeSrs ifdef(`NO_SRS_FROM_LOCAL',`dnl # # Prevent SRS encapsulation if "From" address is local # (With a local from address, the forwarder mail will pass any SPF checks # anyways, so why bother with SRS?) R$* < @ $=w > $* $@ $1 < @ $2 > $3 R$* < @ $=w . > $* $@ $1 < @ $2 . > $3 ')dnl R$* $: $&h $| $1 ifdef(`NO_SRS_FILE',`dnl # # If destination mailer is in non-SRS list, do not apply SRS # This is intended for handling communication between secondary MX and # primary MX R$={noSrsMailers} $| $* $@ $2 ')dnl #R$* $| $* $: $2 R$* $: $(make_srs $1 $) SReverseSrs R$* $: $1 $>IsSrs $1 R$* NO $@ $1 R$* YES $@ $(reverse_srs $1 $) LOCAL_RULE_0 R$* $: $>ReverseSrs $1 pysrs-pysrs-1.0.3/pysrs.py000077500000000000000000000116661320245761100156210ustar00rootroot00000000000000#!/usr/bin/python2 # Sendmail socket server daemon # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. from __future__ import print_function import SRS import SES import re import os try: from configparser import ConfigParser, DuplicateSectionError except: from ConfigParser import ConfigParser, DuplicateSectionError import SocketMap import time import sys class SRSHandler(SocketMap.Handler): def log(self,*msg): # print "%s [%d]" % (time.strftime('%Y%b%d %H:%M:%S'),self.id), print("%s" % (time.strftime('%Y%b%d %H:%M:%S'),), end=' ') for i in msg: print(i, end=' ') print() sys.stdout.flush() bracketRE = re.compile(r'[<>]') traildotRE = re.compile(r'\.$') # Our original envelope-from may look funny on entry # of this Ruleset: # # admin<@asarian-host.net.> # # We need to preprocess it some: def _handle_make_srs(self,old_address): a = old_address.split('\x9b') if len(a) == 2: h,old_address = a self.log('h =',h) else: h = True nosrsdomain = self.server.nosrsdomain if old_address == '<@>' or not h or h in nosrsdomain: return old_address srs = self.server.srs ses = self.server.ses fwdomain = self.server.fwdomain if not fwdomain: fwdomain = self.fwdomain sesdomain = self.server.sesdomain signdomain = self.server.signdomain use_address = self.bracketRE.sub('',old_address) use_address = self.traildotRE.sub('',use_address) # Ok, first check whether we already have a signed SRS address; # if so, just return the old address: we do not want to double-sign # by accident! # # Else, gimme a valid SRS signed address, munge it back the way # sendmail wants it at this point; or just return the old address, # in case nothing went. try: new_address = srs.reverse(use_address) return old_address except: try: senduser,sendhost = use_address.split('@') shl = sendhost.lower() if shl in sesdomain: new_address = ses.sign(use_address) elif shl in signdomain: new_address = srs.sign(use_address) else: new_address = srs.forward(use_address,fwdomain) return new_address.replace('@','<@',1)+'.>' except: return old_address def _handle_reverse_srs(self,old_address): # Munge ParseLocal recipient in the same manner as required # in EnvFromSMTP. use_address = self.bracketRE.sub('',old_address) use_address = self.traildotRE.sub('',use_address) # Just try and reverse the address. If we succeed, return this # new address; else, return the old address (quoted if it was # a piped alias). srs = self.server.srs ses = self.server.ses try: a = ses.verify(use_address) if len(a) > 1: return a[0].replace('@','<@',1)+'.>' use_address = srs.reverse(use_address) while True: try: use_address = srs.reverse(use_address) except: break return use_address.replace('@','<@',1)+'.>' except: if use_address.startswith('|'): return '"%s"' % old_address else: return old_address def main(args): # get SRS parameters from milter configuration cp = ConfigParser({ 'secret': 'shhhh!', 'maxage': '8', 'hashlength': '8', 'separator': '=', 'socket': '/var/run/milter/pysrs' }) cp.read(["/etc/mail/pysrs.cfg"]) try: cp.add_section('srs') except DuplicateSectionError: pass secret = [cp.get('srs','secret')] for old in ('secret.0','secret.1', 'secret.2'): if not cp.has_option('srs',old): break secret.append(cp.get('srs',old)) srs = SRS.new(secret, maxage=cp.getint('srs','maxage'), hashlength=cp.getint('srs','hashlength'), separator=cp.get('srs','separator'), alwaysrewrite=True # pysrs.m4 can skip calling us for local domains ) ses = SES.new(secret, expiration=cp.getint('srs','maxage')) socket = cp.get('srs','socket') try: os.remove(socket) except: pass daemon = SocketMap.Daemon(socket,SRSHandler) daemon.server.fwdomain = cp.get('srs','fwdomain',None) daemon.server.sesdomain = () daemon.server.signdomain = () daemon.server.nosrsdomain = () if cp.has_option('srs','ses'): daemon.server.sesdomain = [ q.strip() for q in cp.get('srs','ses').split(',')] if cp.has_option('srs','sign'): daemon.server.signdomain = [ q.strip() for q in cp.get('srs','sign').split(',')] if cp.has_option('srs','nosrs'): daemon.server.nosrsdomain = [ q.strip() for q in cp.get('srs','nosrs').split(',')] daemon.server.srs = srs daemon.server.ses = ses print("%s pysrs startup" % time.strftime('%Y%b%d %H:%M:%S')) sys.stdout.flush() daemon.run() print("%s pysrs shutdown" % time.strftime('%Y%b%d %H:%M:%S')) if __name__ == "__main__": main(sys.argv[1:]) pysrs-pysrs-1.0.3/pysrs.rc000077500000000000000000000034711320245761100155700ustar00rootroot00000000000000#!/bin/bash # # pysrs This shell script takes care of starting and stopping pysrs. # # chkconfig: 2345 80 30 # description: Milter is a process that filters messages sent through sendmail. # processname: pysrs # config: /etc/mail/pysrs.cfg # pidfile: /var/run/milter/pysrs.pid python="python" pidof() { set - "" if set - `ps -e -o pid,cmd | grep "${python} pysrs.py"` && [ "$2" != "grep" ]; then echo $1 return 0 fi return 1 } # Source function library. . /etc/rc.d/init.d/functions test -x /usr/lib/pymilter/pysrs.py && test -x /usr/sbin/daemonize || exit 0 RETVAL=0 prog="pysrs" datadir="/var/log/milter" logdir="/var/log/milter" piddir="/var/run/milter" libdir="/usr/lib/pymilter" start() { # Start daemon. if test -s ${datadir}/${prog}.py; then workdir="${datadir}" # use data dir if it exists for debugging elif test -s ${logdir}/${prog}.py; then workdir="${logdir}" # use log dir if it exists for debugging else workdir="${libdir}" fi echo -n "Starting $prog: " daemon --check pysrs --pidfile "${piddir}/${prog}.pid" --user mail \ daemonize -a -e "${logdir}/${prog}.log" -o "${logdir}/${prog}.log" \ -c "${workdir}" -p "${piddir}/${prog}.pid" \ /usr/bin/${python} ${prog}.py RETVAL=$? echo [ $RETVAL -eq 0 ] && touch /var/lock/subsys/pysrs return $RETVAL } stop() { # Stop daemons. echo -n "Shutting down $prog: " killproc -p "${piddir}/${prog}.pid" pysrs RETVAL=$? echo [ $RETVAL -eq 0 ] && rm -f /var/lock/subsys/pysrs return $RETVAL } # See how we were called. case "$1" in start) start ;; stop) stop ;; restart|reload) stop start RETVAL=$? ;; condrestart) if [ -f /var/lock/subsys/pysrs ]; then stop start RETVAL=$? fi ;; status) status pysrs RETVAL=$? ;; *) echo "Usage: $0 {start|stop|restart|condrestart|status}" exit 1 esac exit $RETVAL pysrs-pysrs-1.0.3/pysrs.service000066400000000000000000000004251320245761100166150ustar00rootroot00000000000000[Unit] Description=Python SRS daemon Wants=network.target After=network-online.target sendmail.service [Service] Type=simple WorkingDirectory=/var/log/milter User=mail Group=mail SyslogIdentifier=pysrs ExecStart=/usr/libexec/milter/pysrs [Install] WantedBy=multi-user.target pysrs-pysrs-1.0.3/pysrs.spec000066400000000000000000000131471320245761100161140ustar00rootroot00000000000000%if 0%{?rhel} == 6 %global __python python2.6 %global sysvinit pysrs.rc %endif %global pythonbase python %global use_systemd 1 Summary: Python SRS (Sender Rewriting Scheme) library Name: %{pythonbase}-pysrs Version: 1.0.3 Release: 1%{?dist} Source0: pysrs-%{version}.tar.gz License: Python license Group: Development/Libraries BuildRoot: %{_tmppath}/%{name}-buildroot Prefix: %{_prefix} BuildArch: noarch BuildRequires: python >= 2.6 Vendor: Stuart Gathman (Perl version by Shevek) Packager: Stuart D. Gathman Requires: %{pythonbase} sendmail sendmail-cf %if %{use_systemd} # systemd macros are not defined unless systemd is present BuildRequires: systemd Requires: systemd Requires(post): systemd Requires(preun): systemd Requires(postun): systemd %else BuildRequires: ed Requires: chkconfig, daemonize %endif Url: http://pythonhosted.org/milter/pysrs.html %description Python SRS (Sender Rewriting Scheme) library. As SPF is implemented, mail forwarders must rewrite envfrom for domains they are not authorized to send from. See http://www.openspf.org/SRS for details. The Perl reference implementation is at http://srs-socketmap.info/ SRS is also useful for detecting forged DSNs (bounces). SES (Signed Envelope Sender) is a variation that is more compact for this purpose, and in conjuction with some kind of replay protection can also be used as a form of authentication. %prep %setup -n pysrs-%{version} %build %{__python} setup.py build %install %{__python} setup.py install --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES mkdir -p $RPM_BUILD_ROOT/etc/mail cp pysrs.cfg $RPM_BUILD_ROOT/etc/mail cat >$RPM_BUILD_ROOT/etc/mail/no-srs-mailers <<'EOF' # no-srs-mailers - list hosts (RHS) we should not SRS encode for when we # send to them. E.g. primary MX servers for which we are a secondary. # NOTE - mailertable can change the RHS for delivery purposes, you # must match the mailertable RHS in that case. # EOF mkdir -p $RPM_BUILD_ROOT/usr/share/sendmail-cf/hack cp pysrs.m4 $RPM_BUILD_ROOT/usr/share/sendmail-cf/hack cp pysrsprog.m4 $RPM_BUILD_ROOT/usr/share/sendmail-cf/hack # We use same log dir as milter since we also are a sendmail add-on mkdir -p $RPM_BUILD_ROOT/var/log/milter mkdir -p $RPM_BUILD_ROOT%{_libexecdir}/milter cp -p pysrs.py $RPM_BUILD_ROOT%{_libexecdir}/milter/pysrs cp -p srsmilter.py $RPM_BUILD_ROOT%{_libexecdir}/milter/srsmilter %if %{use_systemd} mkdir -p $RPM_BUILD_ROOT%{_unitdir} cp -p pysrs.service $RPM_BUILD_ROOT%{_unitdir} cp -p srsmilter.service $RPM_BUILD_ROOT%{_unitdir} %else mkdir -p $RPM_BUILD_ROOT/etc/rc.d/init.d cp %{sysvinit} $RPM_BUILD_ROOT/etc/rc.d/init.d/pysrs ed $RPM_BUILD_ROOT/etc/rc.d/init.d/pysrs <<'EOF' /^python=/ c python="%{__python}" . w q EOF %endif # logfile rotation mkdir -p $RPM_BUILD_ROOT/etc/logrotate.d cat >$RPM_BUILD_ROOT/etc/logrotate.d/pysrs <<'EOF' /var/log/milter/pysrs.log { copytruncate compress } EOF %clean rm -rf $RPM_BUILD_ROOT %if %{use_systemd} %post %systemd_post pysrs.service %postun %systemd_postun_with_restart pysrs.service %preun %systemd_preun pysrs.service %else %post #echo "Syntax of HACK(pysrs) has changed. Update sendmail.mc." /sbin/chkconfig --add pysrs %preun if [ $1 = 0 ]; then /sbin/chkconfig --del pysrs fi %endif %files -f INSTALLED_FILES %doc COPYING LICENSE.python LICENSE.sendmail CHANGES %defattr(-,root,root) %config(noreplace) /etc/mail/pysrs.cfg %config(noreplace) /etc/mail/no-srs-mailers /etc/logrotate.d/pysrs /usr/share/sendmail-cf/hack/* %{_libexecdir}/milter/pysrs %{_libexecdir}/milter/srsmilter %if %{use_systemd} %{_unitdir}/* %else /etc/rc.d/init.d/pysrs %endif %changelog * Mon Nov 13 2017 Stuart Gathman 1.0.3-1 - Include srsmilter * Fri Nov 3 2017 Stuart Gathman 1.0.2-1 - Fix daemon to run in python2 - Move daemons to /usr/libexec/milter so they get bin_t selinux label * Tue Oct 17 2017 Stuart Gathman 1.0.1-1 - Initial python3 port * Fri Sep 15 2017 Stuart Gathman 1.0-5 - Port to EL7 and systemd * Sat Mar 1 2014 Stuart Gathman 1.0-4 - Fix initscript error * Fri Feb 28 2014 Stuart Gathman 1.0-3 - Use daemonize instead of start.sh, which is gone from pymilter * Wed May 20 2009 Stuart Gathman 1.0-1 - Foundation for python milter envfrom rewriting (in progress) - Python 2.6 - Depend on pymilter for dirs, even though we don't really need it for anything else until envfrom rewriting is done. * Tue Jan 16 2007 Stuart Gathman 0.30.12-1 - Support logging recipient host, and nosrs in pysrs.cfg * Wed Feb 15 2006 Stuart Gathman 0.30.11-1 - support SRS signing mode * Tue Jul 05 2005 Stuart Gathman 0.30.10-1 - support SES * Sun Sep 19 2004 Stuart Gathman 0.30.9-2 - chkconfig --add pysrs * Thu Aug 26 2004 Stuart Gathman 0.30.9-1 - Sendmail Socketmap Daemon * Wed Mar 24 2004 Stuart Gathman 0.30.8-1 - Use HMAC instead of straight sha * Wed Mar 24 2004 Stuart Gathman 0.30.7-1 - Pass SRS_DOMAIN to envfrom2srs.py * Wed Mar 24 2004 Stuart Gathman 0.30.6-4 - Put SRS rewriting rule at end of EnvFromSMTP in pysrs.m4 * Tue Mar 23 2004 Stuart Gathman 0.30.6-3 - Fix regex for is_srs macro in pysrs.m4 * Tue Mar 23 2004 Stuart Gathman 0.30.6-2 - set alwaysrewrite=True in envfrom2srs.py since pysrs.m4 skips local domains - Incorporate m4 macro from Alain Knaff for cleaner sendmail support * Mon Mar 22 2004 Stuart Gathman 0.30.5-1 - Make sendmail map use config in /etc/mail/pysrs.cfg pysrs-pysrs-1.0.3/pysrsprog.m4000066400000000000000000000037431320245761100163730ustar00rootroot00000000000000divert(-1) # # Copyright (c) 2004 Alain Knaff (derived from work by asarian-host.net) # All rights reserved. # Copyright (c) 1988, 1993 # The Regents of the University of California. All rights reserved. # # By using this file, you agree to the terms and conditions set # forth in the LICENSE file which can be found at the top level of # the sendmail distribution. # # divert(0) VERSIONID(`$Id$') ifdef(`_MAILER_DEFINED_',,`errprint(`*** WARNING: MAILER() should be before HACK(pysrs) ')') ifelse(defn(`_ARG_'),`',,`define(`SRS_DOMAIN',_ARG_)') LOCAL_CONFIG # Forward SRS program map Kmake_srs program /usr/bin/envfrom2srs.py ifdef(`SRS_DOMAIN',SRS_DOMAIN) # Reverse SRS program map Kreverse_srs program /usr/bin/srs2envtol.py # "To" address is SRS ifdef(`SRS_DOMAIN',`dnl Kis_srs regex CONCAT(`^?$') ',`dnl Kis_srs regex ^MakeSrs $1 make SRS LOCAL_RULESETS SIsSrs # Answers YES or NO whether the address in parameter is srs or not R$* $: $( is_srs $1 $) R$@ $@ YES R$* $@ NO SMakeSrs # # Prevent SRS encapsulation if "To" address is SRS R$* $: $1 $>IsSrs $&u R$* YES $@ $1 R$* NO $: $1 ifdef(`NO_SRS_FROM_LOCAL',`dnl # # Prevent SRS encapsulation if "From" address is local # (With a local from address, the forwarder mail will pass any SPF checks # anyways, so why bother with SRS?) R$* < @ $=w > $* $@ $1 < @ $2 > $3 R$* < @ $=w . > $* $@ $1 < @ $2 . > $3 ')dnl ifdef(`NO_SRS_FILE',`dnl # # If destination mailer is in non-SRS list, do not apply SRS # This is intended for handling communication between secondary MX and # primary MX R$* $: $&h. $| $1 R$={noSrsMailers} $| $* $@ $2 R$* $| $* $: $2 ')dnl R$* $: $(make_srs $1 $) SReverseSrs R$* $: $1 $>IsSrs $1 R$* NO $@ $1 R$* YES $@ $(reverse_srs $1 $) LOCAL_RULE_0 R$* $: $>ReverseSrs $1 pysrs-pysrs-1.0.3/setup.cfg000066400000000000000000000002141320245761100156700ustar00rootroot00000000000000[install] compile = 1 optimize = 1 [bdist_rpm] group = Development/Libraries python=python packager=Stuart D. Gathman pysrs-pysrs-1.0.3/setup.py000066400000000000000000000035531320245761100155720ustar00rootroot00000000000000#! /usr/bin/env python # # $Id$ # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import sys,os sys.path.insert(0,os.getcwd()) from distutils.core import setup import SRS setup( #-- Package description name = 'pysrs', license = 'Python license', version = '1.0.3', description = 'Python SRS (Sender Rewriting Scheme) library', long_description = """Python SRS (Sender Rewriting Scheme) library. As SPF is implemented, MTAs that check SPF must account for any forwarders. One way to handle forwarding is to have the forwarding MTA rewrite envfrom to a domain they are authorized to use. See http://www.openspf.org/SRS for details. The Perl reference implementation and a C implementation are at http://www.libsrs2.org/ """, author = 'Stuart Gathman (Perl version by Shevek)', author_email = 'stuart@gathman.org', url = 'http://pythonhosted.org/milter/pysrs.html', py_modules = ['SocketMap'], packages = ['SRS','SES'], scripts = ['envfrom2srs.py','srs2envtol.py'], keywords = ['SPF','SRS','SES'], classifiers = [ 'Development Status :: 5 - Production/Stable', 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: Python License (CNRI Python License)', 'Natural Language :: English', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Communications :: Email', 'Topic :: Communications :: Email :: Mail Transport Agents', 'Topic :: Software Development :: Libraries :: Python Modules' ] ) pysrs-pysrs-1.0.3/srs2envtol.py000066400000000000000000000032541320245761100165510ustar00rootroot00000000000000#!/usr/bin/python2.3 # sendmail program map for SRS # # Use only if absolutely necessary. It is *very* inefficient and # a security risk. # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import SRS import re from ConfigParser import ConfigParser, DuplicateSectionError # get SRS parameters from milter configuration cp = ConfigParser({ 'secret': 'shhhh!', 'maxage': '8', 'hashlength': '8', 'separator': '=' }) cp.read(["/etc/mail/pysrs.cfg"]) try: cp.add_section('srs') except DuplicateSectionError: pass srs = SRS.new( secret=cp.get('srs','secret'), maxage=cp.getint('srs','maxage'), hashlength=cp.getint('srs','hashlength'), separator=cp.get('srs','separator') ) srs.warn = lambda x: x # ignore case smash warning del cp def reverse(old_address): # Munge ParseLocal recipient in the same manner as required # in EnvFromSMTP. use_address = re.compile(r'[<>]').sub('',old_address) use_address = re.compile(r'\.$').sub('',use_address) # Just try and reverse the address. If we succeed, return this # new address; else, return the old address (quoted if it was # a piped alias). try: use_address = srs.reverse(use_address) while True: try: use_address = srs.reverse(use_address) except: break return use_address.replace('@','<@',1)+'.>' except: if use_address.startswith('|'): return '"%s"' % old_address else: return old_address if __name__ == "__main__": import sys # No funny business in our output, please sys.stderr.close() print(reverse(sys.argv[1])) pysrs-pysrs-1.0.3/srsmilter.py000077500000000000000000000221671320245761100164630ustar00rootroot00000000000000#!/usr/bin/python2 # # A simple SRS milter for Sendmail-8.14/Postfix-? # # NOTE: use with pysrs socketmap and sendmail-cf macro to handle # multiple recipients. # # The logical problem is that a milter gets to change MFROM only once for # multiple recipients. When there is a conflict between recipients, we # either have to punt (all SRS or all no-SRS) or resubmit some of the # recipients to "split" the message. # # The sendmail cf package, in contrast, gets invoked for every recipient. # http://www.sendmail.org/doc/sendmail-current/libmilter/docs/installation.html # Author: Stuart D. Gathman # Copyright 2007 Business Management Systems, Inc. # Copyright 2017 Stuart D. Gathman # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import SRS import SES import sys import Milter import syslog import re from Milter.config import MilterConfigParser from Milter.utils import iniplist,parse_addr syslog.openlog('srsmilter',0,syslog.LOG_MAIL) class Config(object): "Hold configuration options." def __init__(conf,cfglist): cp = MilterConfigParser() cp.read(cfglist) if cp.has_option('srsmilter','datadir'): os.chdir(cp.get('srsmilter','datadir')) # FIXME: side effect! conf.socketname = cp.getdefault('srsmilter','socketname', '/var/run/milter/srsmilter') conf.miltername = cp.getdefault('srsmilter','name','pysrsfilter') conf.trusted_relay = cp.getlist('srsmilter','trusted_relay') conf.miltersrs = cp.getboolean('srsmilter','miltersrs') conf.internal_connect = cp.getlist('srsmilter','internal_connect') conf.srs_reject_spoofed = cp.getboolean('srsmilter','reject_spoofed') conf.trusted_forwarder = cp.getlist('srs','trusted_forwarder') conf.secret = cp.getdefault('srs','secret','shhhh!') conf.maxage = cp.getintdefault('srs','maxage',21) conf.hashlength = cp.getintdefault('srs','hashlength',5) conf.separator = cp.getdefault('srs','separator','=') conf.database = cp.getdefault('srs','database') conf.nosrsdomain = cp.getlist('srs','nosrs') # no SRS rcpt conf.banned_users = cp.getlist('srs','banned_users') conf.srs_domain = set(cp.getlist('srs','srs')) # check rcpt conf.sesdomain = set(cp.getlist('srs','ses')) # sign from with ses conf.signdomain = set(cp.getlist('srs','sign')) # sign from with srs conf.fwdomain = cp.getdefault('srs','fwdomain',None) # forwarding domain if conf.database: global SRS import SRS.DB conf.srs = SRS.DB.DB(database=conf.database,secret=conf.secret, maxage=conf.maxage,hashlength=conf.hashlength,separator=conf.separator) else: conf.srs = SRS.Guarded.Guarded(secret=conf.secret, maxage=conf.maxage,hashlength=conf.hashlength,separator=conf.separator) if SES: conf.ses = SES.new(secret=conf.secret,expiration=conf.maxage) conf.srs_domain = set(conf.sesdomain) conf.srs_domain.update(conf.srs_domain) else: conf.srs_domain = set(conf.srs_domain) conf.srs_domain.update(conf.signdomain) if conf.fwdomain: conf.srs_domain.add(conf.fwdomain) class srsMilter(Milter.Base): "Milter to check SRS. Each connection gets its own instance." def log(self,*msg): syslog.syslog('[%d] %s' % (self.id,' '.join([str(m) for m in msg]))) def __init__(self): self.mailfrom = None self.id = Milter.uniqueID() # we don't want config used to change during a connection self.conf = config bracketRE = re.compile(r'^<|>$|\.>$') srsre = re.compile(r'^SRS[01][+-=]',re.IGNORECASE) def make_srs(self,old_address): h = self.receiver nosrsdomain = self.conf.nosrsdomain if old_address == '<>' or not h or h in nosrsdomain: return old_address srs = self.conf.srs ses = self.conf.ses fwdomain = self.conf.fwdomain sesdomain = self.conf.sesdomain signdomain = self.conf.signdomain use_address = self.bracketRE.sub('',old_address) # Ok, first check whether we already have a signed SRS address; # if so, just return the old address: we do not want to double-sign # by accident! # # Else, gimme a valid SRS signed address, munge it back the way # sendmail wants it at this point; or just return the old address, # in case nothing went. try: new_address = srs.reverse(use_address) return old_address except: try: senduser,sendhost = use_address.split('@') shl = sendhost.lower() if shl in sesdomain: new_address = ses.sign(use_address) elif shl in signdomain: new_address = srs.sign(use_address) else: new_address = srs.forward(use_address,fwdomain) return '<%s>'%new_address except: return old_address @Milter.noreply def connect(self,hostname,unused,hostaddr): self.internal_connection = False self.trusted_relay = False # sometimes people put extra space in sendmail config, so we strip self.receiver = self.getsymval('j').strip() if hostaddr and len(hostaddr) > 0: ipaddr = hostaddr[0] if iniplist(ipaddr,self.conf.internal_connect): self.internal_connection = True if iniplist(ipaddr,self.conf.trusted_relay): self.trusted_relay = True else: ipaddr = '' self.connectip = ipaddr if self.internal_connection: connecttype = 'INTERNAL' else: connecttype = 'EXTERNAL' if self.trusted_relay: connecttype += ' TRUSTED' self.log("connect from %s at %s %s" % (hostname,hostaddr,connecttype)) return Milter.CONTINUE @Milter.noreply def envfrom(self,f,*str): self.log("mail from",f,str) self.mailfrom = f t = parse_addr(f) if len(t) == 2: t[1] = t[1].lower() self.canon_from = '@'.join(t) self.srsrcpt = [] self.nosrsrcpt = [] self.redirect_list = [] self.discard_list = [] self.is_bounce = (f == '<>' or t[0].lower() in self.conf.banned_users) self.data_allowed = True return Milter.CONTINUE ## Accumulate deleted recipients to be applied in eom callback. def del_recipient(self,rcpt): rcpt = rcpt.lower() if not rcpt in self.discard_list: self.discard_list.append(rcpt) ## Accumulate added recipients to be applied in eom callback. def add_recipient(self,rcpt,params): rcpt = rcpt.lower() if not rcpt in (r[0] for r in self.redirect_list): self.redirect_list.append((rcpt,params)) def envrcpt(self,to,*params): conf = self.conf t = parse_addr(to) if len(t) == 2: t[1] = t[1].lower() user,domain = t if self.is_bounce and domain in conf.srs_domain: # require valid signed recipient oldaddr = '@'.join(parse_addr(to)) try: if conf.ses: newaddr = ses.verify(oldaddr) else: newaddr = oldaddr, if len(newaddr) > 1: newaddr = newaddr[0] self.log("ses rcpt:",newaddr) else: newaddr = srs.reverse(oldaddr) self.log("srs rcpt:",newaddr) self.del_recipient(to) self.add_recipient('<%s>',newaddr,params) except: # no valid SRS signature if not (self.internal_connection or self.trusted_relay): # reject specific recipients with bad sig if self.srsre.match(oldaddr): self.log("REJECT: srs spoofed:",oldaddr) self.setreply('550','5.7.1','Invalid SRS signature') return Milter.REJECT if oldaddr.startswith('SES='): self.log("REJECT: ses spoofed:",oldaddr) self.setreply('550','5.7.1','Invalid SES signature') return Milter.REJECT # reject message for any missing sig self.data_allowed = not conf.srs_reject_spoofed else: # sign "outgoing" from if domain in self.conf.nosrsdomain: self.nosrsrcpt.append(to) else: self.srsrcpt.append(to) else: # no SRS for unqualified recipients self.nosrsrcpt.append(to) return Milter.CONTINUE def data(self): if not self.data_allowed: return Milter.REJECT return Milter.CONTINUE def eom(self): # apply recipient changes for to in self.discard_list: self.delrcpt(to) for to,p in self.redirect_list: self.addrcpt(to,p) # optionally, do outgoing SRS for all recipients if self.conf.miltersrs and self.srsrcpt: newaddr = self.make_srs(self.canon_from) if newaddr != self.canon_from: self.chgfrom(newaddr) return Milter.CONTINUE if __name__ == "__main__": global config config = Config(['pysrs.cfg','/etc/mail/pysrs.cfg']) Milter.factory = srsMilter if config.miltersrs: flags = Milter.CHGFROM + Milter.DELRCPT else: flags = Milter.DELRCPT Milter.set_flags(Milter.CHGFROM + Milter.DELRCPT) miltername = config.miltername socketname = config.socketname print("""To use this with sendmail, add the following to sendmail.cf: O InputMailFilters=%s X%s, S=local:%s See the sendmail README for libmilter. sample srsmilter startup""" % (miltername,miltername,socketname)) sys.stdout.flush() Milter.runmilter(miltername,socketname,240) print("srsmilter shutdown") pysrs-pysrs-1.0.3/srsmilter.service000066400000000000000000000004351320245761100174620ustar00rootroot00000000000000[Unit] Description=Python SRS milter Wants=network.target After=network-online.target sendmail.service [Service] Type=simple WorkingDirectory=/var/log/milter User=mail Group=mail SyslogIdentifier=srsmilter ExecStart=/usr/libexec/milter/srsmilter [Install] WantedBy=multi-user.target pysrs-pysrs-1.0.3/testSRS.py000066400000000000000000000201131320245761100157700ustar00rootroot00000000000000# $Log$ # Revision 1.2 2006/02/16 05:16:58 customdesigned # Support SRS signing mode. # # Revision 1.1.1.2 2005/06/03 04:13:55 customdesigned # Support sendmail socketmap # # Revision 1.4 2004/08/26 03:31:38 stuart # Introduce sendmail socket map # # Revision 1.3 2004/03/25 00:02:21 stuart # FIXME where case smash test depends on day # # Revision 1.2 2004/03/22 18:20:00 stuart # Read config for sendmail maps from /etc/mail/pysrs.cfg # # Revision 1.1.1.1 2004/03/19 05:23:13 stuart # Import to CVS # # # AUTHOR # Shevek # CPAN ID: SHEVEK # cpan@anarres.org # http://www.anarres.org/projects/ # # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # # Copyright (c) 2017 Stuart Gathman All rights reserved. # Portions Copyright (c) 2004 Shevek. All rights reserved. # Portions Copyright (c) 2004,2006 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import unittest import Milter from Milter.test import TestBase from SRS.Guarded import Guarded from SRS.DB import DB from SRS.Reversible import Reversible from SRS.Daemon import Daemon import srsmilter import SRS import threading import socket try: from StringIO import StringIO except: from io import StringIO class TestMilter(TestBase,srsmilter.srsMilter): def __init__(self): TestBase.__init__(self) srsmilter.config = srsmilter.Config(['pysrs.cfg']) srsmilter.srsMilter.__init__(self) self.setsymval('j','test.milter.org') class SRSMilterTestCase(unittest.TestCase): msg = '''From: good@example.com Subject: test test ''' ## Test rejecting bounce spam def testReject(self): milter = TestMilter() milter.conf.srs_domain = set(['example.com']) milter.conf.srs_reject_spoofed = False fp = StringIO(self.msg) rc = milter.connect('testReject',ip='192.0.3.1') self.assertEqual(rc,Milter.CONTINUE) rc = milter.feedFile(fp,sender='',rcpt='good@example.org') self.assertEqual(rc,Milter.CONTINUE) milter.conf.srs_reject_spoofed = True fp.seek(0) rc = milter.feedFile(fp,sender='',rcpt='bad@example.com') self.assertEqual(rc,Milter.REJECT) milter.close() ## Test SRS coding of MAIL FROM def testSign(self): milter = TestMilter() milter.conf.signdomain = set(['example.com']) milter.conf.miltersrs = True fp = StringIO(self.msg) rc = milter.connect('testSign',ip='192.0.3.1') self.assertEqual(rc,Milter.CONTINUE) fp.seek(0) rc = milter.feedFile(fp,sender='good@example.com',rcpt='good@example.org') self.assertEqual(rc,Milter.CONTINUE) s = milter.conf.srs.reverse(milter._sender[1:-1]) self.assertEqual(s,'good@example.com') # check that it doesn't happen when disabled milter.conf.miltersrs = False fp.seek(0) rc = milter.feedFile(fp,sender='good@example.com',rcpt='good@example.org') self.assertEqual(rc,Milter.CONTINUE) self.assertEqual(milter._sender,'') milter.close() class SRSTestCase(unittest.TestCase): def setUp(self): # make sure user modified tag works SRS.SRS0TAG = 'ALT0' SRS.SRS1TAG = 'ALT1' def warn(self,*msg): self.case_smashed = True # There and back again def testGuarded(self): srs = Guarded() self.assertRaises(AssertionError,srs.forward, 'mouse@disney.com','mydomain.com') srs.set_secret('shhhh!') srs.separator = '+' sender = '"Blah blah"@orig.com' srsaddr = srs.forward(sender,sender) self.assertEqual(srsaddr,sender) srsaddr = srs.forward(sender,'second.com') self.assertTrue(srsaddr.startswith('"'+SRS.SRS0TAG),srsaddr) srsaddr1 = srs.forward(srsaddr,'third.com') #print srsaddr1 self.assertTrue(srsaddr1.startswith('"'+SRS.SRS1TAG)) srsaddr2 = srs.forward(srsaddr1,'fourth.com') #print srsaddr2 self.assertTrue(srsaddr2.startswith('"'+SRS.SRS1TAG)) addr = srs.reverse(srsaddr2) self.assertEqual(srsaddr,addr) addr = srs.reverse(srsaddr1) self.assertEqual(srsaddr,addr) addr = srs.reverse(srsaddr) self.assertEqual(sender,addr) def testSign(self): srs = Guarded() srs.set_secret('shhhh!') srs.separator = '+' sender = 'mouse@orig.com' sig = srs.sign(sender) addr = srs.reverse(sig) self.assertEqual(sender,addr) sender = 'mouse@ORIG.com' sig = srs.sign(sender) addr = srs.reverse(sig) self.assertEqual(sender,addr) addr = srs.reverse(sig.lower()) self.assertEqual(sender.lower(),addr) def testCaseSmash(self): srs = SRS.new(secret='shhhhh!',separator='+') # FIXME: whether case smashing occurs depends on what day it is. sender = 'mouse@fickle1.com' srsaddr = srs.forward(sender,'second.com') self.assertTrue(srsaddr.startswith(SRS.SRS0TAG)) self.case_smashed = False srs.warn = self.warn addr = srs.reverse(srsaddr.lower()) self.assertTrue(self.case_smashed) # check that warn was called self.assertEqual(sender,addr) def testReversible(self): srs = Reversible() self.assertRaises(AssertionError,srs.forward, 'mouse@disney.com','mydomain.com') srs.set_secret('shhhh!') srs.separator = '+' sender = 'mouse@orig.com' srsaddr = srs.forward(sender,sender) self.assertEqual(srsaddr,sender) srsaddr = srs.forward(sender,'second.com') #print srsaddr self.assertTrue(srsaddr.startswith(SRS.SRS0TAG)) srsaddr1 = srs.forward(srsaddr,'third.com') #print srsaddr1 self.assertTrue(srsaddr1.startswith(SRS.SRS0TAG)) srsaddr2 = srs.forward(srsaddr1,'fourth.com') #print srsaddr2 self.assertTrue(srsaddr2.startswith(SRS.SRS0TAG)) addr = srs.reverse(srsaddr2) self.assertEqual(srsaddr1,addr) addr = srs.reverse(srsaddr1) self.assertEqual(srsaddr,addr) addr = srs.reverse(srsaddr) self.assertEqual(sender,addr) def testDB(self,database='/tmp/srstest'): srs = DB(database=database) self.assertRaises(AssertionError,srs.forward, 'mouse@disney.com','mydomain.com') srs.set_secret('shhhh!') sender = 'mouse@orig.com' srsaddr = srs.forward(sender,sender) self.assertEqual(srsaddr,sender) srsaddr = srs.forward(sender,'second.com') #print(srsaddr) self.assertTrue(srsaddr.startswith(SRS.SRS0TAG)) srsaddr1 = srs.forward(srsaddr,'third.com') #print(srsaddr1) self.assertTrue(srsaddr1.startswith(SRS.SRS0TAG)) srsaddr2 = srs.forward(srsaddr1,'fourth.com') #print(srsaddr2) self.assertTrue(srsaddr2.startswith(SRS.SRS0TAG)) addr = srs.reverse(srsaddr2) self.assertEqual(srsaddr1,addr) addr = srs.reverse(srsaddr1) self.assertEqual(srsaddr,addr) addr = srs.reverse(srsaddr) self.assertEqual(sender,addr) def run2(self): # handle two requests self.daemon.server.handle_request() self.daemon.server.handle_request() def sendcmd(self,*args): sock = socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) sock.connect(self.sockname) sock.send(b' '.join(args)+b'\n') res = sock.recv(128).strip() sock.close() return res def testExim(self,sockname='/tmp/srsd',secret="shhhh!"): self.sockname = sockname self.daemon = Daemon(socket=sockname,secret=secret) server = threading.Thread(target=self.run2,name='srsd') server.start() sender = b'mouse@orig.com' srsaddr = self.sendcmd(b'FORWARD',sender,b'second.com') addr = self.sendcmd(b'REVERSE',srsaddr) server.join() self.assertEqual(sender,addr) def testProgMap(self): import envfrom2srs import srs2envtol orig = 'mickey<@orig.com.>' newaddr = envfrom2srs.forward(orig) self.assertTrue(newaddr.endswith('.>')) addr2 = srs2envtol.reverse(newaddr) self.assertEqual(addr2,orig) # check case smashing by braindead mailers self.case_smashed = False srs2envtol.srs.warn = self.warn addr2 = srs2envtol.reverse(newaddr.lower()) self.assertEqual(addr2,orig) self.assertTrue(self.case_smashed) def suite(): s = unittest.makeSuite(SRSTestCase,'test') s.addTest(makeSuite(SRSMilterTestCase,'test')) #s.addTest(doctest.DocTestSuite(bms)) return s if __name__ == '__main__': unittest.main() pysrs-pysrs-1.0.3/testd.py000066400000000000000000000006251320245761100155520ustar00rootroot00000000000000# Simple test daemon # # Copyright (c) 2004-2010 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. from socket import * import sys sock = socket(AF_UNIX,SOCK_STREAM) sock.connect('/tmp/srsd') sock.send(b' '.join(sys.argv[1:])+b'\n') res = sock.recv(128).strip() print(res) sock.close()