[Python-modules-commits] [dkimpy] 01/05: Import dkimpy_0.6.0.orig.tar.gz
Scott Kitterman
kitterman at moszumanska.debian.org
Mon Jan 23 20:30:50 UTC 2017
This is an automated email from the git hooks/post-receive script.
kitterman pushed a commit to branch master
in repository dkimpy.
commit 7f718ac47e8898d3bb5d5e8c5663379bb994e9ed
Author: Scott Kitterman <scott at kitterman.com>
Date: Mon Jan 23 14:23:53 2017 -0500
Import dkimpy_0.6.0.orig.tar.gz
---
ChangeLog | 6 +-
MANIFEST.in | 3 +
PKG-INFO | 5 +-
README | 44 +-
arcsign.py | 66 +++
dkim/tests/__init__.py => arcverify.py | 46 +-
dkim/__init__.py | 777 ++++++++++++++++++++++++++-------
dkim/tests/__init__.py | 6 +
dkim/tests/test_arc.py | 146 +++++++
dkim/util.py | 12 +-
dknewkey.py | 81 ++++
man/{dkimsign.1 => arcsign.1} | 22 +-
man/{dkimverify.1 => arcverify.1} | 12 +-
man/dkimsign.1 | 8 +-
man/dkimverify.1 | 10 +-
man/{dkimsign.1 => dknewkey.1} | 34 +-
setup.py | 13 +-
test.py | 3 +
18 files changed, 1065 insertions(+), 229 deletions(-)
diff --git a/ChangeLog b/ChangeLog
index 3c174aa..d904c5a 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,4 +1,8 @@
-2015-12-07 Verion 0.5.6
+2017-01-23 Version 0.6.0
+ - Add capability to sign and verify ARC signatures
+ - Added new script, dknewkey.py, to generate DKIM keys
+
+2015-12-07 Version 0.5.6
- Brown paper bag release, 0.5.5 tarball inadvertently included pyc files
and other artifacts from development
diff --git a/MANIFEST.in b/MANIFEST.in
index 64678e2..c5985a6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -4,8 +4,11 @@ include dkim/tests/data/*
include README
include ChangeLog
include MANIFEST.in
+include dknewkey.py
include dkimsign.py
include dkimverify.py
+include arcsign.py
+include arcverify.py
include dnsplug.py
include man/*
include test.py
diff --git a/PKG-INFO b/PKG-INFO
index bd0ee49..9d0c223 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,13 +1,14 @@
Metadata-Version: 1.1
Name: dkimpy
-Version: 0.5.6
+Version: 0.6.0
Summary: DKIM (DomainKeys Identified Mail)
Home-page: https://launchpad.net/dkimpy
Author: Scott Kitterman
Author-email: scott at kitterman.com
License: BSD-like
Description: dkimpy is a Python library that implements DKIM (DomainKeys
- Identified Mail) email signing and verification.
+ Identified Mail) and ARC (Authenticated Received Chain) email signing and
+ verification.
Platform: UNKNOWN
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: No Input/Output (Daemon)
diff --git a/README b/README
index 962ef2a..0cb650c 100644
--- a/README
+++ b/README
@@ -11,11 +11,14 @@ signing and verification.
VERSION
-This is dkimpy 0.5.6.
+This is dkimpy 0.6.0.
REQUIREMENTS
- - Python 2.x >= 2.6, or Python 3.x >= 3.1.
+ - Python 2.x >= 2.7, or Python 3.x >= 3.5. For use with DKIM, earlier
+ python3 versions will work. ARC tests fail with python3.4. Recent
+ versions have not been tested on python < 2.7 or python3 < 3.4, but may
+ still work on python2.6 and python 3.1 - 3.3.
- dnspython or pydns. dnspython is preferred if both are present.
INSTALLATION
@@ -24,6 +27,12 @@ To build and install dkimpy:
python setup.py install
+DOCUMENTATION
+
+An online version of the package documentation can be found at:
+
+https://gathman.org/pydkim/
+
TESTING
To run dkimpy's test suite:
@@ -39,13 +48,30 @@ Alternatively, if you have testrepository installed:
testr init
testr run
+The included ARC tests are very limited. The primary testing method for ARC
+is using the ARC test suite: https://github.com/ValiMail/arc_test_suite
+
+As of 0.6.0, all tests except as_fields_b_512 pass for both python2.7 and
+python3.5. The test suite ships with test runners for dkimpy. After
+downloading the test suite, you can run the signing and validation tests like
+this:
+
+python2.7 ./testarc.py sign runners/arcsigntest.py
+python2.7 ./testarc.py validate runners/arcverifytest.py
+
+The reason for the test failure is that the ARC specification (as of 20170120)
+sets the minimum key size to 512 bits. This is operationally inappropriate,
+so dkimpy sets the default minkey=1024, the same as is used for DKIM. This
+can be overridden, but that is not recommended.
+
USAGE
The dkimpy library offers one module called dkim. The sign() function takes an
RFC822 formatted message, along with some signing options, and returns a
DKIM-Signature header line that can be prepended to the message. The verify()
function takes an RFC822 formatted message, and returns True or False depending
-on whether the signature verifies correctly.
+on whether the signature verifies correctly. There is also a DKIM class which
+can be used to perform these functions in a more modern way.
Two helper programs are also supplied: dkimsign.py and dkimverify.py.
@@ -61,6 +87,18 @@ dkimverify.py reads an RFC822 message on standard input, and returns with exit
code 0 if the signature verifies successfully. Otherwise, it returns with exit
code 1.
+As of version 0.6.0, dkimpy provides experimental support for ARC (Authenticated
+Received Chain):
+
+https://tools.ietf.org/html/draft-ietf-dmarc-arc-protocol-01
+
+This new functionality is marked experimental because the protocol is still
+under development. There are no guarantees about API stability or
+compatibility.
+
+In addition to arcsign.py and arcverify.py, the dkim module now provides
+arc_sign and arc_verify functions as well as an ARC class.
+
FEEDBACK
Bug reports may be submitted to the bug tracker for the dkimpy project on
diff --git a/arcsign.py b/arcsign.py
new file mode 100644
index 0000000..b37ac75
--- /dev/null
+++ b/arcsign.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+
+# This software is provided 'as-is', without any express or implied
+# warranty. In no event will the author be held liable for any damages
+# arising from the use of this software.
+#
+# Permission is granted to anyone to use this software for any purpose,
+# including commercial applications, and to alter it and redistribute it
+# freely, subject to the following restrictions:
+#
+# 1. The origin of this software must not be misrepresented; you must not
+# claim that you wrote the original software. If you use this software
+# in a product, an acknowledgment in the product documentation would be
+# appreciated but is not required.
+# 2. Altered source versions must be plainly marked as such, and must not be
+# misrepresented as being the original software.
+# 3. This notice may not be removed or altered from any source distribution.
+#
+# Copyright (c) 2008 Greg Hewgill http://hewgill.com
+#
+# This has been modified from the original software.
+# Copyright (c) 2011 William Grant <me at williamgrant.id.au>
+#
+# This has been modified from the original software.
+# Copyright (c) 2016 Google, Inc.
+# Contact: Brandon Long <blong at google.com>
+
+from __future__ import print_function
+
+import logging
+import re
+import sys
+
+import dkim
+
+logging.basicConfig(level=10)
+
+if len(sys.argv) != 4:
+ print("Usage: arcsign.py selector domain privatekeyfile", file=sys.stderr)
+ sys.exit(1)
+
+if sys.version_info[0] >= 3:
+ # Make sys.stdin and stdout binary streams.
+ sys.stdin = sys.stdin.detach()
+ sys.stdout = sys.stdout.detach()
+
+selector = sys.argv[1].encode('ascii')
+domain = sys.argv[2].encode('ascii')
+privatekeyfile = sys.argv[3]
+
+message = sys.stdin.read()
+
+# Pick a cv status
+cv = dkim.CV_None
+if re.search('arc-seal', message, re.IGNORECASE):
+ cv = dkim.CV_Pass
+
+#try:
+sig = dkim.arc_sign(message, selector, domain, open(privatekeyfile, "rb").read(),
+ domain + ": none", cv)
+for line in sig:
+ sys.stdout.write(line)
+sys.stdout.write(message)
+#except Exception as e:
+# print(e, file=sys.stderr)
+ #sys.stdout.write(message)
diff --git a/dkim/tests/__init__.py b/arcverify.py
similarity index 55%
copy from dkim/tests/__init__.py
copy to arcverify.py
index a7c2733..f3bc4d5 100644
--- a/dkim/tests/__init__.py
+++ b/arcverify.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+
# This software is provided 'as-is', without any express or implied
# warranty. In no event will the author be held liable for any damages
# arising from the use of this software.
@@ -14,23 +16,35 @@
# misrepresented as being the original software.
# 3. This notice may not be removed or altered from any source distribution.
#
+# Copyright (c) 2008 Greg Hewgill http://hewgill.com
+#
+# This has been modified from the original software.
# Copyright (c) 2011 William Grant <me at williamgrant.id.au>
+#
+# This has been modified from the original software.
+# Copyright (c) 2016 Google, Inc.
+# Contact: Brandon Long <blong at google.com>
+
+from __future__ import print_function
+
+import logging
+import sys
+
+import dkim
-import unittest
+if sys.version_info[0] >= 3:
+ # Make sys.stdin a binary stream.
+ sys.stdin = sys.stdin.detach()
+message = sys.stdin.read()
+verbose = '-v' in sys.argv
+if verbose:
+ logging.basicConfig(level=10)
+ d = dkim.ARC(message)
+ cv, results, comment = d.verify()
+else:
+ cv, results, comment = arc.verify(message)
-def test_suite():
- from dkim.tests import (
- test_canonicalization,
- test_crypto,
- test_dkim,
- test_util,
- )
- modules = [
- test_canonicalization,
- test_crypto,
- test_dkim,
- test_util,
- ]
- suites = [x.test_suite() for x in modules]
- return unittest.TestSuite(suites)
+print("arc verification: cv=%s %s" % (cv, comment))
+if verbose:
+ print(repr(results))
diff --git a/dkim/__init__.py b/dkim/__init__.py
index eec9992..6e7d473 100644
--- a/dkim/__init__.py
+++ b/dkim/__init__.py
@@ -18,6 +18,19 @@
#
# This has been modified from the original software.
# Copyright (c) 2011 William Grant <me at williamgrant.id.au>
+#
+# This has been modified from the original software.
+# Copyright (c) 2016 Google, Inc.
+# Contact: Brandon Long <blong at google.com>
+#
+# This has been modified from the original software.
+# Copyright (c) 2016, 2017 Scott Kitterman <scott at kitterman.com>
+#
+# This has been modified from the original software.
+# Copyright (c) 2017 Valimail Inc
+# Contact: Gene Shuman <gene at valimail.com>
+#
+
import base64
import hashlib
@@ -29,6 +42,8 @@ from dkim.canonicalization import (
CanonicalizationPolicy,
InvalidCanonicalizationPolicyError,
)
+from dkim.canonicalization import Relaxed as RelaxedCanonicalization
+
from dkim.crypto import (
DigestTooLargeError,
HASH_ALGORITHMS,
@@ -55,16 +70,49 @@ __all__ = [
"KeyFormatError",
"MessageFormatError",
"ParameterError",
+ "ValidationError",
+ "CV_Pass",
+ "CV_Fail",
+ "CV_None",
"Relaxed",
"Simple",
"DKIM",
+ "ARC",
"sign",
"verify",
+ "dkim_sign",
+ "dkim_verify",
+ "arc_sign",
+ "arc_verify",
]
Relaxed = b'relaxed' # for clients passing dkim.Relaxed
Simple = b'simple' # for clients passing dkim.Simple
+# for ARC
+CV_Pass = b'pass'
+CV_Fail = b'fail'
+CV_None = b'none'
+
+class HashThrough(object):
+ def __init__(self, hasher):
+ self.data = []
+ self.hasher = hasher
+ self.name = hasher.name
+
+ def update(self, data):
+ self.data.append(data)
+ return self.hasher.update(data)
+
+ def digest(self):
+ return self.hasher.digest()
+
+ def hexdigest(self):
+ return self.hasher.hexdigest()
+
+ def hashed(self):
+ return b''.join(self.data)
+
def bitsize(x):
"""Return size of long in bits."""
return len(bin(x)) - 2
@@ -138,29 +186,40 @@ def hash_headers(hasher, canonicalize_headers, headers, include_headers,
hasher.update(y)
return sign_headers
-def validate_signature_fields(sig):
- """Validate DKIM-Signature fields.
+def validate_signature_fields(sig, mandatory_fields=[b'v', b'a', b'b', b'bh', b'd', b'h', b's'], arc=False):
+ """Validate DKIM or ARC Signature fields.
Basic checks for presence and correct formatting of mandatory fields.
Raises a ValidationError if checks fail, otherwise returns None.
-
@param sig: A dict mapping field keys to values.
+ @param mandatory_fields: A list of non-optional fields
+ @param arc: flag to differentiate between dkim & arc
"""
- mandatory_fields = (b'v', b'a', b'b', b'bh', b'd', b'h', b's')
for field in mandatory_fields:
if field not in sig:
- raise ValidationError("signature missing %s=" % field)
+ raise ValidationError("missing %s=" % field)
+
+ if b'a' in sig and not sig[b'a'] in HASH_ALGORITHMS:
+ raise ValidationError("unknown signature algorithm: %s" % sig[b'a'])
+
+ if b'b' in sig:
+ if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'b']) is None:
+ raise ValidationError("b= value is not valid base64 (%s)" % sig[b'b'])
+ if len(re.sub(br"\s+", b"", sig[b'b'])) % 4 != 0:
+ raise ValidationError("b= value is not valid base64 (%s)" % sig[b'b'])
+
+ if b'bh' in sig:
+ if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'bh']) is None:
+ raise ValidationError("bh= value is not valid base64 (%s)" % sig[b'bh'])
+ if len(re.sub(br"\s+", b"", sig[b'bh'])) % 4 != 0:
+ raise ValidationError("bh= value is not valid base64 (%s)" % sig[b'bh'])
+
+ if b'cv' in sig and sig[b'cv'] not in (CV_Pass, CV_Fail, CV_None):
+ raise ValidationError("cv= value is not valid (%s)" % sig[b'cv'])
- if sig[b'v'] != b"1":
- raise ValidationError("v= value is not 1 (%s)" % sig[b'v'])
- if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'b']) is None:
- raise ValidationError("b= value is not valid base64 (%s)" % sig[b'b'])
- if re.match(br"[\s0-9A-Za-z+/]+=*$", sig[b'bh']) is None:
- raise ValidationError(
- "bh= value is not valid base64 (%s)" % sig[b'bh'])
# Nasty hack to support both str and bytes... check for both the
# character and integer values.
- if b'i' in sig and (
+ if not arc and b'i' in sig and (
not sig[b'i'].lower().endswith(sig[b'd'].lower()) or
sig[b'i'][-len(sig[b'd'])-1] not in ('@', '.', 64, 46)):
raise ValidationError(
@@ -171,30 +230,32 @@ def validate_signature_fields(sig):
"l= value is not a decimal integer (%s)" % sig[b'l'])
if b'q' in sig and sig[b'q'] != b"dns/txt":
raise ValidationError("q= value is not dns/txt (%s)" % sig[b'q'])
- now = int(time.time())
- slop = 36000 # 10H leeway for mailers with inaccurate clocks
- t_sign = 0
+
if b't' in sig:
if re.match(br"\d+$", sig[b't']) is None:
raise ValidationError(
- "t= value is not a decimal integer (%s)" % sig[b't'])
+ "t= value is not a decimal integer (%s)" % sig[b't'])
+ now = int(time.time())
+ slop = 36000 # 10H leeway for mailers with inaccurate clocks
t_sign = int(sig[b't'])
if t_sign > now + slop:
- raise ValidationError(
- "t= value is in the future (%s)" % sig[b't'])
+ raise ValidationError("t= value is in the future (%s)" % sig[b't'])
+
+ if b'v' in sig and sig[b'v'] != b"1":
+ raise ValidationError("v= value is not 1 (%s)" % sig[b'v'])
+
if b'x' in sig:
if re.match(br"\d+$", sig[b'x']) is None:
raise ValidationError(
- "x= value is not a decimal integer (%s)" % sig[b'x'])
+ "x= value is not a decimal integer (%s)" % sig[b'x'])
x_sign = int(sig[b'x'])
if x_sign < now - slop:
raise ValidationError(
- "x= value is past (%s)" % sig[b'x'])
- if x_sign < t_sign:
- raise ValidationError(
- "x= value is less than t= value (x=%s t=%s)" %
- (sig[b'x'], sig[b't']))
-
+ "x= value is past (%s)" % sig[b'x'])
+ if x_sign < t_sign:
+ raise ValidationError(
+ "x= value is less than t= value (x=%s t=%s)" %
+ (sig[b'x'], sig[b't']))
def rfc822_parse(message):
"""Parse a message in RFC822 format.
@@ -267,48 +328,30 @@ def fold(header):
header = header[j:]
return pre + header
-#: Hold messages and options during DKIM signing and verification.
-class DKIM(object):
- # NOTE - the first 2 indentation levels are 2 instead of 4
- # to minimize changed lines from the function only version.
-
- #: The U{RFC5322<http://tools.ietf.org/html/rfc5322#section-3.6>}
- #: complete list of singleton headers (which should
- #: appear at most once). This can be used for a "paranoid" or
- #: "strict" signing mode.
- #: Bcc in this list is in the SHOULD NOT sign list, the rest could
- #: be in the default FROZEN list, but that could also make signatures
- #: more fragile than necessary.
- #: @since: 0.5
- RFC5322_SINGLETON = (b'date',b'from',b'sender',b'reply-to',b'to',b'cc',b'bcc',
- b'message-id',b'in-reply-to',b'references')
-
- #: Header fields to protect from additions by default.
- #:
- #: The short list below is the result more of instinct than logic.
- #: @since: 0.5
- FROZEN = (b'from',b'date',b'subject')
-
- #: The rfc4871 recommended header fields to sign
- #: @since: 0.5
- SHOULD = (
- b'sender', b'reply-to', b'subject', b'date', b'message-id', b'to', b'cc',
- b'mime-version', b'content-type', b'content-transfer-encoding',
- b'content-id', b'content- description', b'resent-date', b'resent-from',
- b'resent-sender', b'resent-to', b'resent-cc', b'resent-message-id',
- b'in-reply-to', 'references', b'list-id', b'list-help', b'list-unsubscribe',
- b'list-subscribe', b'list-post', b'list-owner', b'list-archive'
- )
+def load_pk_from_dns(name, dnsfunc=get_txt):
+ s = dnsfunc(name)
+ if not s:
+ raise KeyFormatError("missing public key: %s"%name)
+ try:
+ if type(s) is str:
+ s = s.encode('ascii')
+ pub = parse_tag_value(s)
+ except InvalidTagValueList as e:
+ raise KeyFormatError(e)
+ try:
+ pk = parse_public_key(base64.b64decode(pub[b'p']))
+ keysize = bitsize(pk['modulus'])
+ except KeyError:
+ raise KeyFormatError("incomplete public key: %s" % s)
+ except (TypeError,UnparsableKeyError) as e:
+ raise KeyFormatError("could not parse public key (%s): %s" % (pub[b'p'],e))
+ return pk, keysize
- #: The rfc4871 recommended header fields not to sign.
- #: @since: 0.5
- SHOULD_NOT = (
- b'return-path',b'received',b'comments',b'keywords',b'bcc',b'resent-bcc',
- b'dkim-signature'
- )
+#: Abstract base class for holding messages and options during DKIM/ARC signing and verification.
+class DomainSigner(object):
+ # NOTE - the first 2 indentation levels are 2 instead of 4
+ # to minimize changed lines from the function only version.
- #: Create a DKIM instance to sign and verify rfc5322 messages.
- #:
#: @param message: an RFC822 formatted message to be signed or verified
#: (with either \\n or \\r\\n line endings)
#: @param logger: a logger to which debug info will be written (default None)
@@ -336,6 +379,42 @@ class DKIM(object):
#: default is 1024
self.minkey = minkey
+ #: Header fields to protect from additions by default.
+ #:
+ #: The short list below is the result more of instinct than logic.
+ #: @since: 0.5
+ FROZEN = (b'from',b'date',b'subject')
+
+ #: The rfc4871 recommended header fields to sign
+ #: @since: 0.5
+ SHOULD = (
+ b'sender', b'reply-to', b'subject', b'date', b'message-id', b'to', b'cc',
+ b'mime-version', b'content-type', b'content-transfer-encoding',
+ b'content-id', b'content- description', b'resent-date', b'resent-from',
+ b'resent-sender', b'resent-to', b'resent-cc', b'resent-message-id',
+ b'in-reply-to', 'references', b'list-id', b'list-help', b'list-unsubscribe',
+ b'list-subscribe', b'list-post', b'list-owner', b'list-archive'
+ )
+
+ #: The rfc4871 recommended header fields not to sign.
+ #: @since: 0.5
+ SHOULD_NOT = (
+ b'return-path',b'received',b'comments',b'keywords',b'bcc',b'resent-bcc',
+ b'dkim-signature'
+ )
+
+ # Doesn't seem to be used (GS)
+ #: The U{RFC5322<http://tools.ietf.org/html/rfc5322#section-3.6>}
+ #: complete list of singleton headers (which should
+ #: appear at most once). This can be used for a "paranoid" or
+ #: "strict" signing mode.
+ #: Bcc in this list is in the SHOULD NOT sign list, the rest could
+ #: be in the default FROZEN list, but that could also make signatures
+ #: more fragile than necessary.
+ #: @since: 0.5
+ RFC5322_SINGLETON = (b'date',b'from',b'sender',b'reply-to',b'to',b'cc',b'bcc',
+ b'message-id',b'in-reply-to',b'references')
+
def add_frozen(self,s):
""" Add headers not in should_not_sign to frozen_sign.
@param s: list of headers to add to frozen_sign
@@ -389,6 +468,118 @@ class DKIM(object):
@since: 0.5"""
return [x for x,y in self.headers if x.lower() not in self.should_not_sign]
+
+ # Abstract helper method to generate a tag=value header from a list of fields
+ #: @param fields: A list of key value tuples to be included in the header
+ #: @param include_headers: A list message headers to include in the b= signature computation
+ #: @param canon_policy: A canonicialization policy for b= & bh=
+ #: @param header_name: The name of the generated header
+ #: @param pk: The private key used for signature generation
+ #: @param standardize: Flag to enable 'standard' header syntax
+ def gen_header(self, fields, include_headers, canon_policy, header_name, pk, standardize=False):
+ if standardize:
+ lower = [(x,y.lower().replace(b' ', b'')) for (x,y) in fields if x != b'bh']
+ reg = [(x,y.replace(b' ', b'')) for (x,y) in fields if x == b'bh']
+ fields = lower + reg
+ fields = sorted(fields, key=(lambda x: x[0]))
+
+ header_value = b"; ".join(b"=".join(x) for x in fields)
+ if not standardize:
+ header_value = fold(header_value)
+ header_value = RE_BTAG.sub(b'\\1',header_value)
+ header = (header_name, b' ' + header_value)
+ h = HashThrough(self.hasher())
+ sig = dict(fields)
+
+ headers = canon_policy.canonicalize_headers(self.headers)
+ self.signed_headers = hash_headers(
+ h, canon_policy, headers, include_headers, header, sig)
+ self.logger.debug("sign %s headers: %r" % (header_name, h.hashed()))
+
+ try:
+ sig2 = RSASSA_PKCS1_v1_5_sign(h, pk)
+ except DigestTooLargeError:
+ raise ParameterError("digest too large for modulus")
+ # Folding b= is explicity allowed, but yahoo and live.com are broken
+ #header_value += base64.b64encode(bytes(sig2))
+ # Instead of leaving unfolded (which lets an MTA fold it later and still
+ # breaks yahoo and live.com), we change the default signing mode to
+ # relaxed/simple (for broken receivers), and fold now.
+ idx = [i for i in range(len(fields)) if fields[i][0] == b'b'][0]
+ fields[idx] = (b'b', base64.b64encode(bytes(sig2)))
+ header_value = b"; ".join(b"=".join(x) for x in fields) + b"\r\n"
+
+ if not standardize:
+ header_value = fold(header_value)
+
+ return header_value
+
+ # Abstract helper method to verify a signed header
+ #: @param sig: List of (key, value) tuples containing tag=values of the header
+ #: @param include_headers: headers to validate b= signature against
+ #: @param sig_header: (header_name, header_value)
+ #: @param dnsfunc: interface to dns
+ def verify_sig(self, sig, include_headers, sig_header, dnsfunc):
+ name = sig[b's'] + b"._domainkey." + sig[b'd'] + b"."
+ try:
+ pk, self.keysize = load_pk_from_dns(name, dnsfunc)
+ except KeyFormatError as e:
+ self.logger.error("%s" % e)
+ return False
+
+ try:
+ canon_policy = CanonicalizationPolicy.from_c_value(sig.get(b'c', b'relaxed/relaxed'))
+ except InvalidCanonicalizationPolicyError as e:
+ raise MessageFormatError("invalid c= value: %s" % e.args[0])
+
+ hasher = HASH_ALGORITHMS[sig[b'a']]
+
+ # validate body if present
+ if b'bh' in sig:
+ h = HashThrough(hasher())
+
+ body = canon_policy.canonicalize_body(self.body)
+ if b'l' in sig:
+ body = body[:int(sig[b'l'])]
+ h.update(body)
+ self.logger.debug("body hashed: %r" % h.hashed())
+ bodyhash = h.digest()
+
+ self.logger.debug("bh: %s" % base64.b64encode(bodyhash))
+ try:
+ bh = base64.b64decode(re.sub(br"\s+", b"", sig[b'bh']))
+ except TypeError as e:
+ raise MessageFormatError(str(e))
+ if bodyhash != bh:
+ raise ValidationError(
+ "body hash mismatch (got %s, expected %s)" %
+ (base64.b64encode(bodyhash), sig[b'bh']))
+
+ # address bug#644046 by including any additional From header
+ # fields when verifying. Since there should be only one From header,
+ # this shouldn't break any legitimate messages. This could be
+ # generalized to check for extras of other singleton headers.
+ if b'from' in include_headers:
+ include_headers.append(b'from')
+ h = HashThrough(hasher())
+
+ headers = canon_policy.canonicalize_headers(self.headers)
+ self.signed_headers = hash_headers(
+ h, canon_policy, headers, include_headers, sig_header, sig)
+ self.logger.debug("signed for %s: %r" % (sig_header[0], h.hashed()))
+
+ try:
+ signature = base64.b64decode(re.sub(br"\s+", b"", sig[b'b']))
+ res = RSASSA_PKCS1_v1_5_verify(h, signature, pk)
+ self.logger.debug("%s valid: %s" % (sig_header[0], res))
+ if res and self.keysize < self.minkey:
+ raise KeyFormatError("public key too small: %d" % self.keysize)
+ return res
+ except (TypeError,DigestTooLargeError) as e:
+ raise KeyFormatError("digest too large for modulus: %s"%e)
+
+#: Hold messages and options during DKIM signing and verification.
+class DKIM(DomainSigner):
#: Sign an RFC822 message and return the DKIM-Signature header line.
#:
#: The include_headers option gives full control over which header fields
@@ -404,8 +595,8 @@ class DKIM(object):
#: without breaking the signature.
#:
#: The default include_headers for this method differs from the backward
- #: compatible sign function, which signs all headers not
- #: in should_not_sign. The default list for this method can be modified
+ #: compatible sign function, which signs all headers not
+ #: in should_not_sign. The default list for this method can be modified
#: by tweaking should_sign and frozen_sign (or even should_not_sign).
#: It is only necessary to pass an include_headers list when precise control
#: is needed.
@@ -434,27 +625,28 @@ class DKIM(object):
if identity is not None and not identity.endswith(domain):
raise ParameterError("identity must end with domain")
- canon_policy = CanonicalizationPolicy.from_c_value(
- b'/'.join(canonicalize))
- headers = canon_policy.canonicalize_headers(self.headers)
+ canon_policy = CanonicalizationPolicy.from_c_value(b'/'.join(canonicalize))
if include_headers is None:
include_headers = self.default_sign_headers()
+ include_headers = tuple([x.lower() for x in include_headers])
+ # record what verify should extract
+ self.include_headers = include_headers
+
# rfc4871 says FROM is required
- if b'from' not in ( x.lower() for x in include_headers ):
+ if b'from' not in include_headers:
raise ParameterError("The From header field MUST be signed")
- # raise exception for any SHOULD_NOT headers, call can modify
+ # raise exception for any SHOULD_NOT headers, call can modify
# SHOULD_NOT if really needed.
- for x in include_headers:
- if x.lower() in self.should_not_sign:
- raise ParameterError("The %s header field SHOULD NOT be signed"%x)
+ for x in set(include_headers).intersection(self.should_not_sign):
+ raise ParameterError("The %s header field SHOULD NOT be signed"%x)
body = canon_policy.canonicalize_body(self.body)
- hasher = HASH_ALGORITHMS[self.signature_algorithm]
- h = hasher()
+ self.hasher = HASH_ALGORITHMS[self.signature_algorithm]
+ h = self.hasher()
h.update(body)
bodyhash = base64.b64encode(h.digest())
@@ -472,36 +664,16 @@ class DKIM(object):
(b'bh', bodyhash),
# Force b= to fold onto it's own line so that refolding after
# adding sig doesn't change whitespace for previous tags.
- (b'b', b'0'*60),
+ (b'b', b'0'*60),
] if x]
- include_headers = [x.lower() for x in include_headers]
- # record what verify should extract
- self.include_headers = tuple(include_headers)
- sig_value = fold(b"; ".join(b"=".join(x) for x in sigfields))
- sig_value = RE_BTAG.sub(b'\\1',sig_value)
- dkim_header = (b'DKIM-Signature', b' ' + sig_value)
- h = hasher()
- sig = dict(sigfields)
- self.signed_headers = hash_headers(
- h, canon_policy, headers, include_headers, dkim_header,sig)
- self.logger.debug("sign headers: %r" % self.signed_headers)
-
- try:
- sig2 = RSASSA_PKCS1_v1_5_sign(h, pk)
- except DigestTooLargeError:
- raise ParameterError("digest too large for modulus")
- # Folding b= is explicity allowed, but yahoo and live.com are broken
- #sig_value += base64.b64encode(bytes(sig2))
- # Instead of leaving unfolded (which lets an MTA fold it later and still
- # breaks yahoo and live.com), we change the default signing mode to
- # relaxed/simple (for broken receivers), and fold now.
- sig_value = fold(sig_value + base64.b64encode(bytes(sig2)))
+ res = self.gen_header(sigfields, include_headers, canon_policy,
+ b"DKIM-Signature", pk)
self.domain = domain
self.selector = selector
- self.signature_fields = sig
- return b'DKIM-Signature: ' + sig_value + b"\r\n"
+ self.signature_fields = dict(sigfields)
+ return b'DKIM-Signature: ' + res
#: Verify a DKIM signature.
#: @type idx: int
@@ -512,7 +684,6 @@ class DKIM(object):
#: @return: True if signature verifies or False otherwise
#: @raise DKIMException: when the message, signature, or key are badly formed
def verify(self,idx=0,dnsfunc=get_txt):
-
sigheaders = [(x,y) for x,y in self.headers if x.lower() == b"dkim-signature"]
if len(sigheaders) <= idx:
return False
@@ -524,77 +695,310 @@ class DKIM(object):
except InvalidTagValueList as e:
raise MessageFormatError(e)
- logger = self.logger
- logger.debug("sig: %r" % sig)
+ self.logger.debug("sig: %r" % sig)
validate_signature_fields(sig)
self.domain = sig[b'd']
self.selector = sig[b's']
- try:
- canon_policy = CanonicalizationPolicy.from_c_value(sig.get(b'c'))
- except InvalidCanonicalizationPolicyError as e:
- raise MessageFormatError("invalid c= value: %s" % e.args[0])
- headers = canon_policy.canonicalize_headers(self.headers)
- body = canon_policy.canonicalize_body(self.body)
+ include_headers = [x.lower() for x in re.split(br"\s*:\s*", sig[b'h'])]
+ self.include_headers = tuple(include_headers)
- try:
- hasher = HASH_ALGORITHMS[sig[b'a']]
- except KeyError as e:
- raise MessageFormatError("unknown signature algorithm: %s" % e.args[0])
+ return self.verify_sig(sig, include_headers, sigheaders[idx], dnsfunc)
- if b'l' in sig:
- body = body[:int(sig[b'l'])]
+#: Hold messages and options during ARC signing and verification.
+class ARC(DomainSigner):
+ #: Header fields used by ARC
+ ARC_HEADERS = (b'arc-seal', b'arc-message-signature', b'arc-authentication-results')
- h = hasher()
- h.update(body)
- bodyhash = h.digest()
- logger.debug("bh: %s" % base64.b64encode(bodyhash))
+ #: Regex to extract i= value from ARC headers
+ INSTANCE_RE = re.compile(br'[\s;]?i\s*=\s*(\d+)', re.MULTILINE | re.IGNORECASE)
+
+ def sorted_arc_headers(self):
+ headers = []
+ # Use relaxed canonicalization to unfold and clean up headers
+ relaxed_headers = RelaxedCanonicalization.canonicalize_headers(self.headers)
+ for x,y in relaxed_headers:
+ if x.lower() in ARC.ARC_HEADERS:
+ m = ARC.INSTANCE_RE.search(y)
+ if m is not None:
+ try:
+ i = int(m.group(1))
+ headers.append((i, (x, y)))
+ except ValueError:
+ self.logger.debug("invalid instance number %s: '%s: %s'" % (m.group(1), x, y))
+ else:
+ self.logger.debug("not instance number: '%s: %s'" % (x, y))
+
+ if len(headers) == 0:
+ return 0, []
+
+ def arc_header_key(a):
+ return [a[0], a[1][0].lower(), a[1][1].lower()]
+
+ headers = sorted(headers, key=arc_header_key)
+ headers.reverse()
+ return headers[0][0], headers
+
+ #: Sign an RFC822 message and return the list of ARC set header lines
+ #:
+ #: The include_headers option gives full control over which header fields
+ #: are signed for the ARC-Message-Signature. Note that signing a header
+ #: field that doesn't exist prevents
+ #: that field from being added without breaking the signature. Repeated
+ #: fields (such as Received) can be signed multiple times. Instances
+ #: of the field are signed from bottom to top. Signing a header field more
+ #: times than are currently present prevents additional instances
+ #: from being added without breaking the signature.
+ #:
+ #: The default include_headers for this method differs from the backward
+ #: compatible sign function, which signs all headers not
+ #: in should_not_sign. The default list for this method can be modified
+ #: by tweaking should_sign and frozen_sign (or even should_not_sign).
+ #: It is only necessary to pass an include_headers list when precise control
+ #: is needed.
+ #:
+ #: @param selector: the DKIM selector value for the signature
+ #: @param domain: the DKIM domain value for the signature
+ #: @param privkey: a PKCS#1 private key in base64-encoded text form
+ #: @param auth_results: RFC 7601 Authentication-Results header value for the message
+ #: @param chain_validation_status: CV_Pass, CV_Fail, CV_None
+ #: @param include_headers: a list of strings indicating which headers
+ #: are to be signed (default rfc4871 recommended headers)
+ #: @return: list of ARC set header fields
+ #: @raise DKIMException: when the message, include_headers, or key are badly
+ #: formed.
+ def sign(self, selector, domain, privkey, auth_results, chain_validation_status,
+ include_headers=None, timestamp=None, standardize=False):
try:
- bh = base64.b64decode(re.sub(br"\s+", b"", sig[b'bh']))
- except TypeError as e:
- raise MessageFormatError(str(e))
- if bodyhash != bh:
- raise ValidationError(
- "body hash mismatch (got %s, expected %s)" %
- (base64.b64encode(bodyhash), sig[b'bh']))
+ pk = parse_pem_private_key(privkey)
+ except UnparsableKeyError as e:
+ raise KeyFormatError(str(e))
- name = sig[b's'] + b"._domainkey." + sig[b'd'] + b"."
- s = dnsfunc(name)
- if not s:
- raise KeyFormatError("missing public key: %s"%name)
+ # Setup headers
+ if include_headers is None:
+ include_headers = self.default_sign_headers()
+
+ if b'arc-authentication-results' not in include_headers:
+ include_headers.append(b'arc-authentication-results')
+
+ include_headers = tuple([x.lower() for x in include_headers])
+
+ # record what verify should extract
+ self.include_headers = include_headers
+
+ # rfc4871 says FROM is required
+ if b'from' not in include_headers:
+ raise ParameterError("The From header field MUST be signed")
+
+ # raise exception for any SHOULD_NOT headers, call can modify
+ # SHOULD_NOT if really needed.
+ for x in set(include_headers).intersection(self.should_not_sign):
+ raise ParameterError("The %s header field SHOULD NOT be signed"%x)
+
+ max_instance, arc_headers_w_instance = self.sorted_arc_headers()
+ instance = 1
+ if len(arc_headers_w_instance) != 0:
+ instance = max_instance + 1
+
+ if instance == 1 and chain_validation_status != CV_None:
+ raise ParameterError("No existing chain found on message, cv should be none")
+ elif instance != 1 and chain_validation_status == CV_None:
+ raise ParameterError("cv=none not allowed on instance %d" % instance)
+
+ new_arc_set = []
+ arc_headers = [y for x,y in arc_headers_w_instance]
+
+ # Compute ARC-Authentication-Results
+ aar_value = b"i=%d; %s" % (instance, auth_results)
+ if aar_value[-1] != b'\n': aar_value += b'\r\n'
+
+ new_arc_set.append(b"ARC-Authentication-Results: " + aar_value)
+ self.headers.insert(0, (b"arc-authentication-results", aar_value))
+ arc_headers.insert(0, (b"ARC-Authentication-Results", aar_value))
+
+ # Compute bh=
+ canon_policy = CanonicalizationPolicy.from_c_value(b'relaxed/relaxed')
+
+ self.hasher = HASH_ALGORITHMS[self.signature_algorithm]
+ h = HashThrough(self.hasher())
+ h.update(canon_policy.canonicalize_body(self.body))
+ self.logger.debug("sign ams body hashed: %r" % h.hashed())
+ bodyhash = base64.b64encode(h.digest())
+
+ # Compute ARC-Message-Signature
+ timestamp = str(timestamp or int(time.time())).encode('ascii')
+ ams_fields = [x for x in [
+ (b'i', str(instance).encode('ascii')),
+ (b'a', self.signature_algorithm),
+ (b'c', b'relaxed/relaxed'),
+ (b'd', domain),
+ (b's', selector),
+ (b't', timestamp),
+ (b'h', b" : ".join(include_headers)),
+ (b'bh', bodyhash),
+ # Force b= to fold onto it's own line so that refolding after
+ # adding sig doesn't change whitespace for previous tags.
+ (b'b', b'0'*60),
+ ] if x]
+
+ res = self.gen_header(ams_fields, include_headers, canon_policy,
+ b"ARC-Message-Signature", pk, standardize)
+
+ new_arc_set.append(b"ARC-Message-Signature: " + res)
+ self.headers.insert(0, (b"ARC-Message-Signature", res))
... 806 lines suppressed ...
--
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/python-modules/packages/dkimpy.git
More information about the Python-modules-commits
mailing list