[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