From 470cf6fb80f7690155ba99c7af8dc5be1dda5ed7 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 23:39:57 +0300 Subject: [PATCH 1/2] Replace vendored dkim with dkimpy package - Remove emails/packages/dkim/ (vendored fork of dkimpy 0.5.3) - Use upstream dkimpy package (1.1.8) from PyPI - Add dkimpy to install_requires in setup.py - Update imports in signers.py, exc.py, test_dkim.py - Pass PEM bytes instead of pre-parsed key dict to dkim.sign() - Fix test dnsfunc lambda to accept timeout kwarg - Remove mypy exclusion for emails/packages/ Closes #196 --- emails/exc.py | 2 +- emails/packages/__init__.py | 0 emails/packages/dkim/LICENSE.txt | 17 - emails/packages/dkim/README.txt | 3 - emails/packages/dkim/__init__.py | 612 ----------------------- emails/packages/dkim/__main__.py | 7 - emails/packages/dkim/asn1.py | 139 ----- emails/packages/dkim/canonicalization.py | 134 ----- emails/packages/dkim/crypto.py | 272 ---------- emails/packages/dkim/dnsplug.py | 85 ---- emails/packages/dkim/util.py | 78 --- emails/signers.py | 29 +- emails/testsuite/message/test_dkim.py | 6 +- setup.cfg | 6 +- setup.py | 4 +- 15 files changed, 13 insertions(+), 1381 deletions(-) delete mode 100644 emails/packages/__init__.py delete mode 100644 emails/packages/dkim/LICENSE.txt delete mode 100644 emails/packages/dkim/README.txt delete mode 100644 emails/packages/dkim/__init__.py delete mode 100644 emails/packages/dkim/__main__.py delete mode 100644 emails/packages/dkim/asn1.py delete mode 100644 emails/packages/dkim/canonicalization.py delete mode 100644 emails/packages/dkim/crypto.py delete mode 100644 emails/packages/dkim/dnsplug.py delete mode 100644 emails/packages/dkim/util.py diff --git a/emails/exc.py b/emails/exc.py index f091139..149ae8e 100644 --- a/emails/exc.py +++ b/emails/exc.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .packages.dkim import DKIMException +from dkim import DKIMException class HTTPLoaderError(Exception): diff --git a/emails/packages/__init__.py b/emails/packages/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/emails/packages/dkim/LICENSE.txt b/emails/packages/dkim/LICENSE.txt deleted file mode 100644 index 97af4c0..0000000 --- a/emails/packages/dkim/LICENSE.txt +++ /dev/null @@ -1,17 +0,0 @@ -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 \ No newline at end of file diff --git a/emails/packages/dkim/README.txt b/emails/packages/dkim/README.txt deleted file mode 100644 index 7aeac2f..0000000 --- a/emails/packages/dkim/README.txt +++ /dev/null @@ -1,3 +0,0 @@ -This is dkimpy 0.5.3 with @lavr's patch for caching parsed key. - -Fork of https://code.launchpad.net/~diane-trout/dkimpy/python3 \ No newline at end of file diff --git a/emails/packages/dkim/__init__.py b/emails/packages/dkim/__init__.py deleted file mode 100644 index d276fcb..0000000 --- a/emails/packages/dkim/__init__.py +++ /dev/null @@ -1,612 +0,0 @@ -# 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 - -import base64 -import hashlib -import logging -import re -import time - -from .canonicalization import ( - CanonicalizationPolicy, - InvalidCanonicalizationPolicyError, - ) -from .crypto import ( - DigestTooLargeError, - HASH_ALGORITHMS, - parse_pem_private_key, - parse_public_key, - RSASSA_PKCS1_v1_5_sign, - RSASSA_PKCS1_v1_5_verify, - UnparsableKeyError, - ) -try: - from .dnsplug import get_txt -except: - def get_txt(s): - raise RuntimeError("DKIM.verify requires DNS or dnspython module") -from .util import ( - get_default_logger, - InvalidTagValueList, - parse_tag_value, - ) - -__all__ = [ - "DKIMException", - "InternalError", - "KeyFormatError", - "MessageFormatError", - "ParameterError", - "Relaxed", - "Simple", - "DKIM", - "sign", - "verify", -] - -Relaxed = b'relaxed' # for clients passing dkim.Relaxed -Simple = b'simple' # for clients passing dkim.Simple - -def bitsize(x): - """Return size of long in bits.""" - return len(bin(x)) - 2 - -class DKIMException(Exception): - """Base class for DKIM errors.""" - pass - -class InternalError(DKIMException): - """Internal error in dkim module. Should never happen.""" - pass - -class KeyFormatError(DKIMException): - """Key format error while parsing an RSA public or private key.""" - pass - -class MessageFormatError(DKIMException): - """RFC822 message format error.""" - pass - -class ParameterError(DKIMException): - """Input parameter error.""" - pass - -class ValidationError(DKIMException): - """Validation error.""" - pass - -def select_headers(headers, include_headers): - """Select message header fields to be signed/verified. - - >>> h = [('from','biz'),('foo','bar'),('from','baz'),('subject','boring')] - >>> i = ['from','subject','to','from'] - >>> select_headers(h,i) - [('from', 'baz'), ('subject', 'boring'), ('from', 'biz')] - >>> h = [('From','biz'),('Foo','bar'),('Subject','Boring')] - >>> i = ['from','subject','to','from'] - >>> select_headers(h,i) - [('From', 'biz'), ('Subject', 'Boring')] - """ - sign_headers = [] - lastindex = {} - for h in include_headers: - assert h == h.lower() - i = lastindex.get(h, len(headers)) - while i > 0: - i -= 1 - if h == headers[i][0].lower(): - sign_headers.append(headers[i]) - break - lastindex[h] = i - return sign_headers - -FWS = br'(?:\r?\n\s+)?' -RE_BTAG = re.compile(br'([;\s]b'+FWS+br'=)(?:'+FWS+br'[a-zA-Z0-9+/=])*(?:\r?\n\Z)?') - -def hash_headers(hasher, canonicalize_headers, headers, include_headers, - sigheader, sig): - """Update hash for signed message header fields.""" - sign_headers = select_headers(headers,include_headers) - # The call to _remove() assumes that the signature b= only appears - # once in the signature header - cheaders = canonicalize_headers.canonicalize_headers( - [(sigheader[0], RE_BTAG.sub(b'\\1',sigheader[1]))]) - # the dkim sig is hashed with no trailing crlf, even if the - # canonicalization algorithm would add one. - for x,y in sign_headers + [(x, y.rstrip()) for x,y in cheaders]: - hasher.update(x) - hasher.update(b":") - hasher.update(y) - return sign_headers - -def validate_signature_fields(sig): - """Validate DKIM-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. - """ - 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) - - 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 ( - not sig[b'i'].endswith(sig[b'd']) or - sig[b'i'][-len(sig[b'd'])-1] not in ('@', '.', 64, 46)): - raise ValidationError( - "i= domain is not a subdomain of d= (i=%s d=%s)" % - (sig[b'i'], sig[b'd'])) - if b'l' in sig and re.match(br"\d{,76}$", sig[b'l']) is None: - raise ValidationError( - "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']) - if b't' in sig and re.match(br"\d+$", sig[b't']) is None: - raise ValidationError( - "t= value is not a decimal integer (%s)" % sig[b't']) - 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']) - if b't' in sig and int(sig[b'x']) < int(sig[b't']): - 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. - - @param message: The message in RFC822 format. Either CRLF or LF is an accepted line separator. - @return: Returns a tuple of (headers, body) where headers is a list of (name, value) pairs. - The body is a CRLF-separated string. - """ - headers = [] - lines = re.split(b"\r?\n", message) - i = 0 - while i < len(lines): - if len(lines[i]) == 0: - # End of headers, return what we have plus the body, excluding the blank line. - i += 1 - break - if lines[i][0] in ("\x09", "\x20", 0x09, 0x20): - headers[-1][1] += lines[i]+b"\r\n" - else: - m = re.match(br"([\x21-\x7e]+?):", lines[i]) - if m is not None: - headers.append([m.group(1), lines[i][m.end(0):]+b"\r\n"]) - elif lines[i].startswith(b"From "): - pass - else: - raise MessageFormatError("Unexpected characters in RFC822 header: %s" % lines[i]) - i += 1 - return (headers, b"\r\n".join(lines[i:])) - - - -def fold(header): - """Fold a header line into multiple crlf-separated lines at column 72. - - >>> fold(b'foo') - 'foo' - >>> fold(b'foo '+b'foo'*24).splitlines()[0] - 'foo ' - >>> fold(b'foo'*25).splitlines()[-1] - ' foo' - >>> len(fold(b'foo'*25).splitlines()[0]) - 72 - """ - i = header.rfind(b"\r\n ") - if i == -1: - pre = b"" - else: - i += 3 - pre = header[:i] - header = header[i:] - while len(header) > 72: - i = header[:72].rfind(b" ") - if i == -1: - j = 72 - else: - j = i + 1 - pre += header[:j] + b"\r\n " - 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} - #: 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 = ('date','from','sender','reply-to','to','cc','bcc', - 'message-id','in-reply-to','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 = ('from','date','subject') - - #: The rfc4871 recommended header fields to sign - #: @since: 0.5 - SHOULD = ( - 'sender', 'reply-to', 'subject', 'date', 'message-id', 'to', 'cc', - 'mime-version', 'content-type', 'content-transfer-encoding', 'content-id', - 'content- description', 'resent-date', 'resent-from', 'resent-sender', - 'resent-to', 'resent-cc', 'resent-message-id', 'in-reply-to', 'references', - 'list-id', 'list-help', 'list-unsubscribe', 'list-subscribe', 'list-post', - 'list-owner', 'list-archive' - ) - - #: The rfc4871 recommended header fields not to sign. - #: @since: 0.5 - SHOULD_NOT = ( - 'return-path', 'received', 'comments', 'keywords', 'bcc', 'resent-bcc', - 'dkim-signature' - ) - - #: 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) - #: @param signature_algorithm: the signing algorithm to use when signing - def __init__(self,message=None,logger=None,signature_algorithm=b'rsa-sha256', - minkey=1024): - self.set_message(message) - if logger is None: - logger = get_default_logger() - self.logger = logger - if signature_algorithm not in HASH_ALGORITHMS: - raise ParameterError( - "Unsupported signature algorithm: "+signature_algorithm) - self.signature_algorithm = signature_algorithm - #: Header fields which should be signed. Default from RFC4871 - self.should_sign = set(DKIM.SHOULD) - #: Header fields which should not be signed. The default is from RFC4871. - #: Attempting to sign these headers results in an exception. - #: If it is necessary to sign one of these, it must be removed - #: from this list first. - self.should_not_sign = set(DKIM.SHOULD_NOT) - #: Header fields to sign an extra time to prevent additions. - self.frozen_sign = set(DKIM.FROZEN) - #: Minimum public key size. Shorter keys raise KeyFormatError. The - #: default is 1024 - self.minkey = minkey - - 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 - @since: 0.5 - - >>> dkim = DKIM() - >>> dkim.add_frozen(DKIM.RFC5322_SINGLETON) - >>> sorted(dkim.frozen_sign) - ['cc', 'date', 'from', 'in-reply-to', 'message-id', 'references', 'reply-to', 'sender', 'subject', 'to'] - """ - self.frozen_sign.update(x.lower() for x in s - if x.lower() not in self.should_not_sign) - - #: Load a new message to be signed or verified. - #: @param message: an RFC822 formatted message to be signed or verified - #: (with either \\n or \\r\\n line endings) - #: @since: 0.5 - def set_message(self,message): - if message: - self.headers, self.body = rfc822_parse(message) - else: - self.headers, self.body = [],'' - #: The DKIM signing domain last signed or verified. - self.domain = None - #: The DKIM key selector last signed or verified. - self.selector = 'default' - #: Signature parameters of last sign or verify. To parse - #: a DKIM-Signature header field that you have in hand, - #: use L{dkim.util.parse_tag_value}. - self.signature_fields = {} - #: The list of headers last signed or verified. Each header - #: is a name,value tuple. FIXME: The headers are canonicalized. - #: This could be more useful as original headers. - self.signed_headers = [] - #: The public key size last verified. - self.keysize = 0 - - def default_sign_headers(self): - """Return the default list of headers to sign: those in should_sign or - frozen_sign, with those in frozen_sign signed an extra time to prevent - additions. - @since: 0.5""" - hset = self.should_sign | self.frozen_sign - include_headers = [ x for x,y in self.headers - if x.lower() in hset ] - return include_headers + [ x for x in include_headers - if x.lower() in self.frozen_sign] - - def all_sign_headers(self): - """Return header list of all existing headers not in should_not_sign. - @since: 0.5""" - return [x for x,y in self.headers if x.lower() not in self.should_not_sign] - - #: Sign an RFC822 message and return the DKIM-Signature header line. - #: - #: The include_headers option gives full control over which header fields - #: are signed. 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 length option allows the message body to be appended to by MTAs - #: enroute (e.g. mailing lists that append unsubscribe information) - #: 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 identity: the DKIM identity value for the signature - #: (default "@"+domain) - #: @param canonicalize: the canonicalization algorithms to use - #: (default (Simple, Simple)) - #: @param include_headers: a list of strings indicating which headers - #: are to be signed (default rfc4871 recommended headers) - #: @param length: true if the l= tag should be included to indicate - #: body length signed (default False). - #: @return: DKIM-Signature header field terminated by '\r\n' - #: @raise DKIMException: when the message, include_headers, or key are badly - #: formed. - def sign(self, selector, domain, privkey, identity=None, - canonicalize=(b'relaxed',b'simple'), include_headers=None, length=False): - - if isinstance(privkey, dict): - # Dirty patch by github:lavr to use pre-compiled key - pk = privkey - else: - try: - pk = parse_pem_private_key(privkey) - except UnparsableKeyError as e: - raise KeyFormatError(str(e)) - - 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) - - if include_headers is None: - include_headers = self.default_sign_headers() - - # rfc4871 says FROM is required - if b'from' not in ( x.lower() for x 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 include_headers: - if x.lower() in 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() - h.update(body) - bodyhash = base64.b64encode(h.digest()) - - sigfields = [x for x in [ - (b'v', b"1"), - (b'a', self.signature_algorithm), - (b'c', canon_policy.to_c_value()), - (b'd', domain), - (b'i', identity or b"@"+domain), - length and (b'l', len(body)), - (b'q', b"dns/txt"), - (b's', selector), - (b't', str(int(time.time())).encode('ascii')), - (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] - 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))) - - self.domain = domain - self.selector = selector - self.signature_fields = sig - return b'DKIM-Signature: ' + sig_value + b"\r\n" - - #: Verify a DKIM signature. - #: @type idx: int - #: @param idx: which signature to verify. The first (topmost) signature is 0. - #: @type dnsfunc: callable - #: @param dnsfunc: an option function to lookup TXT resource records - #: for a DNS domain. The default uses dnspython or pydns. - #: @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 - - # By default, we validate the first DKIM-Signature line found. - try: - sig = parse_tag_value(sigheaders[idx][1]) - self.signature_fields = sig - except InvalidTagValueList as e: - raise MessageFormatError(e) - - logger = self.logger - 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) - - try: - hasher = HASH_ALGORITHMS[sig[b'a']] - except KeyError as e: - raise MessageFormatError("unknown signature algorithm: %s" % e.args[0]) - - if b'l' in sig: - body = body[:int(sig[b'l'])] - - h = hasher() - h.update(body) - bodyhash = h.digest() - 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'])) - - name = sig[b's'] + b"._domainkey." + sig[b'd'] + b"." - s = dnsfunc(name) - if not s: - raise KeyFormatError("missing public key: %s"%name) - try: - pub = parse_tag_value(s) - except InvalidTagValueList as e: - raise KeyFormatError(e) - try: - pk = parse_public_key(base64.b64decode(pub[b'p'])) - self.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)) - include_headers = [x.lower() for x in re.split(br"\s*:\s*", sig[b'h'])] - self.include_headers = tuple(include_headers) - # 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 = hasher() - self.signed_headers = hash_headers( - h, canon_policy, headers, include_headers, sigheaders[idx], sig) - try: - signature = base64.b64decode(re.sub(br"\s+", b"", sig[b'b'])) - res = RSASSA_PKCS1_v1_5_verify(h, signature, pk) - 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) - -def sign(message, selector, domain, privkey, identity=None, - canonicalize=(b'relaxed', b'simple'), - signature_algorithm=b'rsa-sha256', - include_headers=None, length=False, logger=None): - """Sign an RFC822 message and return the DKIM-Signature header line. - @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) - @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 identity: the DKIM identity value for the signature (default "@"+domain) - @param canonicalize: the canonicalization algorithms to use (default (Simple, Simple)) - @param include_headers: a list of strings indicating which headers are to be signed (default all headers not listed as SHOULD NOT sign) - @param length: true if the l= tag should be included to indicate body length (default False) - @param logger: a logger to which debug info will be written (default None) - @return: DKIM-Signature header field terminated by \\r\\n - @raise DKIMException: when the message, include_headers, or key are badly formed. - """ - - d = DKIM(message,logger=logger) - if not include_headers: - include_headers = d.all_sign_headers() - return d.sign(selector, domain, privkey, identity=identity, canonicalize=canonicalize, include_headers=include_headers, length=length) - -def verify(message, logger=None, dnsfunc=get_txt, minkey=1024): - """Verify the first (topmost) DKIM signature on an RFC822 formatted message. - @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) - @param logger: a logger to which debug info will be written (default None) - @return: True if signature verifies or False otherwise - """ - d = DKIM(message,logger=logger,minkey=minkey) - try: - return d.verify(dnsfunc=dnsfunc) - except DKIMException as x: - if logger is not None: - logger.error("%s" % x) - return False diff --git a/emails/packages/dkim/__main__.py b/emails/packages/dkim/__main__.py deleted file mode 100644 index 1e0a02f..0000000 --- a/emails/packages/dkim/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -import unittest -import doctest -import dkim -from tests import test_suite - -doctest.testmod(dkim) -unittest.TextTestRunner().run(test_suite()) diff --git a/emails/packages/dkim/asn1.py b/emails/packages/dkim/asn1.py deleted file mode 100644 index 0bb8bbd..0000000 --- a/emails/packages/dkim/asn1.py +++ /dev/null @@ -1,139 +0,0 @@ -# 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 - -__all__ = [ - 'asn1_build', - 'asn1_parse', - 'ASN1FormatError', - 'BIT_STRING', - 'INTEGER', - 'SEQUENCE', - 'OBJECT_IDENTIFIER', - 'OCTET_STRING', - 'NULL', - ] - -INTEGER = 0x02 -BIT_STRING = 0x03 -OCTET_STRING = 0x04 -NULL = 0x05 -OBJECT_IDENTIFIER = 0x06 -SEQUENCE = 0x30 - - -class ASN1FormatError(Exception): - pass - - -def asn1_parse(template, data): - """Parse a data structure according to an ASN.1 template. - - @param template: tuples comprising the ASN.1 template - @param data: byte string data to parse - @return: decoded structure - """ - data = bytearray(data) - r = [] - i = 0 - try: - for t in template: - tag = data[i] - i += 1 - if tag == t[0]: - length = data[i] - i += 1 - if length & 0x80: - n = length & 0x7f - length = 0 - for j in range(n): - length = (length << 8) | data[i] - i += 1 - if tag == INTEGER: - n = 0 - for j in range(length): - n = (n << 8) | data[i] - i += 1 - r.append(n) - elif tag == BIT_STRING: - r.append(data[i:i+length]) - i += length - elif tag == NULL: - assert length == 0 - r.append(None) - elif tag == OBJECT_IDENTIFIER: - r.append(data[i:i+length]) - i += length - elif tag == SEQUENCE: - r.append(asn1_parse(t[1], data[i:i+length])) - i += length - else: - raise ASN1FormatError( - "Unexpected tag in template: %02x" % tag) - else: - raise ASN1FormatError( - "Unexpected tag (got %02x, expecting %02x)" % (tag, t[0])) - return r - except IndexError: - raise ASN1FormatError("Data truncated at byte %d"%i) - -def asn1_length(n): - """Return a string representing a field length in ASN.1 format. - - @param n: integer field length - @return: ASN.1 field length - """ - assert n >= 0 - if n < 0x7f: - return bytearray([n]) - r = bytearray() - while n > 0: - r.insert(n & 0xff) - n >>= 8 - return r - - -def asn1_encode(type, data): - length = asn1_length(len(data)) - length.insert(0, type) - length.extend(data) - return length - - -def asn1_build(node): - """Build a DER-encoded ASN.1 data structure. - - @param node: (type, data) tuples comprising the ASN.1 structure - @return: DER-encoded ASN.1 byte string - """ - if node[0] == OCTET_STRING: - return asn1_encode(OCTET_STRING, node[1]) - if node[0] == NULL: - assert node[1] is None - return asn1_encode(NULL, b'') - elif node[0] == OBJECT_IDENTIFIER: - return asn1_encode(OBJECT_IDENTIFIER, node[1]) - elif node[0] == SEQUENCE: - r = bytearray() - for x in node[1]: - r += asn1_build(x) - return asn1_encode(SEQUENCE, r) - else: - raise ASN1FormatError("Unexpected tag in template: %02x" % node[0]) diff --git a/emails/packages/dkim/canonicalization.py b/emails/packages/dkim/canonicalization.py deleted file mode 100644 index a674e25..0000000 --- a/emails/packages/dkim/canonicalization.py +++ /dev/null @@ -1,134 +0,0 @@ -# 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 - -import re - -__all__ = [ - 'CanonicalizationPolicy', - 'InvalidCanonicalizationPolicyError', - ] - - -class InvalidCanonicalizationPolicyError(Exception): - """The c= value could not be parsed.""" - pass - - -def strip_trailing_whitespace(content): - return re.sub(b"[\t ]+\r\n", b"\r\n", content) - - -def compress_whitespace(content): - return re.sub(b"[\t ]+", b" ", content) - - -def strip_trailing_lines(content): - return re.sub(b"(\r\n)*$", b"\r\n", content) - - -def unfold_header_value(content): - return re.sub(b"\r\n", b"", content) - - -class Simple: - """Class that represents the "simple" canonicalization algorithm.""" - - name = b"simple" - - @staticmethod - def canonicalize_headers(headers): - # No changes to headers. - return headers - - @staticmethod - def canonicalize_body(body): - # Ignore all empty lines at the end of the message body. - return strip_trailing_lines(body) - - -class Relaxed: - """Class that represents the "relaxed" canonicalization algorithm.""" - - name = b"relaxed" - - @staticmethod - def canonicalize_headers(headers): - # Convert all header field names to lowercase. - # Unfold all header lines. - # Compress WSP to single space. - # Remove all WSP at the start or end of the field value (strip). - return [ - (x[0].lower().rstrip(), - compress_whitespace(unfold_header_value(x[1])).strip() + b"\r\n") - for x in headers] - - @staticmethod - def canonicalize_body(body): - # Remove all trailing WSP at end of lines. - # Compress non-line-ending WSP to single space. - # Ignore all empty lines at the end of the message body. - return strip_trailing_lines( - compress_whitespace(strip_trailing_whitespace(body))) - - -class CanonicalizationPolicy: - - def __init__(self, header_algorithm, body_algorithm): - self.header_algorithm = header_algorithm - self.body_algorithm = body_algorithm - - @classmethod - def from_c_value(cls, c): - """Construct the canonicalization policy described by a c= value. - - May raise an C{InvalidCanonicalizationPolicyError} if the given - value is invalid - - @param c: c= value from a DKIM-Signature header field - @return: a C{CanonicalizationPolicy} - """ - if c is None: - c = b'simple/simple' - m = c.split(b'/') - if len(m) not in (1, 2): - raise InvalidCanonicalizationPolicyError(c) - if len(m) == 1: - m.append(b'simple') - can_headers, can_body = m - try: - header_algorithm = ALGORITHMS[can_headers] - body_algorithm = ALGORITHMS[can_body] - except KeyError as e: - raise InvalidCanonicalizationPolicyError(e.args[0]) - return cls(header_algorithm, body_algorithm) - - def to_c_value(self): - return b'/'.join( - (self.header_algorithm.name, self.body_algorithm.name)) - - def canonicalize_headers(self, headers): - return self.header_algorithm.canonicalize_headers(headers) - - def canonicalize_body(self, body): - return self.body_algorithm.canonicalize_body(body) - - -ALGORITHMS = dict((c.name, c) for c in (Simple, Relaxed)) diff --git a/emails/packages/dkim/crypto.py b/emails/packages/dkim/crypto.py deleted file mode 100644 index 918192c..0000000 --- a/emails/packages/dkim/crypto.py +++ /dev/null @@ -1,272 +0,0 @@ -# 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 - -__all__ = [ - 'DigestTooLargeError', - 'HASH_ALGORITHMS', - 'parse_pem_private_key', - 'parse_private_key', - 'parse_public_key', - 'RSASSA_PKCS1_v1_5_sign', - 'RSASSA_PKCS1_v1_5_verify', - 'UnparsableKeyError', - ] - -import base64 -import hashlib -import re - -from .asn1 import ( - ASN1FormatError, - asn1_build, - asn1_parse, - BIT_STRING, - INTEGER, - SEQUENCE, - OBJECT_IDENTIFIER, - OCTET_STRING, - NULL, - ) - - -ASN1_Object = [ - (SEQUENCE, [ - (SEQUENCE, [ - (OBJECT_IDENTIFIER,), - (NULL,), - ]), - (BIT_STRING,), - ]) -] - -ASN1_RSAPublicKey = [ - (SEQUENCE, [ - (INTEGER,), - (INTEGER,), - ]) -] - -ASN1_RSAPrivateKey = [ - (SEQUENCE, [ - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - (INTEGER,), - ]) -] - -HASH_ALGORITHMS = { - b'rsa-sha1': hashlib.sha1, - b'rsa-sha256': hashlib.sha256, - } - -# These values come from RFC 3447, section 9.2 Notes, page 43. -HASH_ID_MAP = { - 'sha1': b"\x2b\x0e\x03\x02\x1a", - 'sha256': b"\x60\x86\x48\x01\x65\x03\x04\x02\x01", - } - - -class DigestTooLargeError(Exception): - """The digest is too large to fit within the requested length.""" - pass - - -class UnparsableKeyError(Exception): - """The data could not be parsed as a key.""" - pass - - -def parse_public_key(data): - """Parse an RSA public key. - - @param data: DER-encoded X.509 subjectPublicKeyInfo - containing an RFC3447 RSAPublicKey. - @return: RSA public key - """ - try: - # Not sure why the [1:] is necessary to skip a byte. - x = asn1_parse(ASN1_Object, data) - pkd = asn1_parse(ASN1_RSAPublicKey, x[0][1][1:]) - except ASN1FormatError as e: - raise UnparsableKeyError('Unparsable public key: ' + str(e)) - pk = { - 'modulus': pkd[0][0], - 'publicExponent': pkd[0][1], - } - return pk - - -def parse_private_key(data): - """Parse an RSA private key. - - @param data: DER-encoded RFC3447 RSAPrivateKey. - @return: RSA private key - """ - try: - pka = asn1_parse(ASN1_RSAPrivateKey, data) - except ASN1FormatError as e: - raise UnparsableKeyError('Unparsable private key: ' + str(e)) - pk = { - 'version': pka[0][0], - 'modulus': pka[0][1], - 'publicExponent': pka[0][2], - 'privateExponent': pka[0][3], - 'prime1': pka[0][4], - 'prime2': pka[0][5], - 'exponent1': pka[0][6], - 'exponent2': pka[0][7], - 'coefficient': pka[0][8], - } - return pk - - -def parse_pem_private_key(data): - """Parse a PEM RSA private key. - - @param data: RFC3447 RSAPrivateKey in PEM format. - @return: RSA private key - """ - m = re.search(b"--\n(.*?)\n--", data, re.DOTALL) - if m is None: - raise UnparsableKeyError("Private key not found") - try: - pkdata = base64.b64decode(m.group(1)) - except TypeError as e: - raise UnparsableKeyError(str(e)) - return parse_private_key(pkdata) - - -def EMSA_PKCS1_v1_5_encode(hash, mlen): - """Encode a digest with RFC3447 EMSA-PKCS1-v1_5. - - @param hash: hash object to encode - @param mlen: desired message length - @return: encoded digest byte string - """ - dinfo = asn1_build( - (SEQUENCE, [ - (SEQUENCE, [ - (OBJECT_IDENTIFIER, HASH_ID_MAP[hash.name.lower()]), - (NULL, None), - ]), - (OCTET_STRING, hash.digest()), - ])) - if len(dinfo) + 11 > mlen: - raise DigestTooLargeError() - return b"\x00\x01"+b"\xff"*(mlen-len(dinfo)-3)+b"\x00"+dinfo - - -def str2int(s): - """Convert a byte string to an integer. - - @param s: byte string representing a positive integer to convert - @return: converted integer - """ - s = bytearray(s) - r = 0 - for c in s: - r = (r << 8) | c - return r - - -def int2str(n, length=-1): - """Convert an integer to a byte string. - - @param n: positive integer to convert - @param length: minimum length - @return: converted bytestring, of at least the minimum length if it was - specified - """ - assert n >= 0 - r = bytearray() - while length < 0 or len(r) < length: - r.append(n & 0xff) - n >>= 8 - if length < 0 and n == 0: - break - r.reverse() - assert length < 0 or len(r) == length - return r - - -def rsa_decrypt(message, pk, mlen): - """Perform RSA decryption/signing - - @param message: byte string to operate on - @param pk: private key data - @param mlen: desired output length - @return: byte string result of the operation - """ - c = str2int(message) - - m1 = pow(c, pk['exponent1'], pk['prime1']) - m2 = pow(c, pk['exponent2'], pk['prime2']) - - if m1 < m2: - h = pk['coefficient'] * (m1 + pk['prime1'] - m2) % pk['prime1'] - else: - h = pk['coefficient'] * (m1 - m2) % pk['prime1'] - - return int2str(m2 + h * pk['prime2'], mlen) - - -def rsa_encrypt(message, pk, mlen): - """Perform RSA encryption/verification - - @param message: byte string to operate on - @param pk: public key data - @param mlen: desired output length - @return: byte string result of the operation - """ - m = str2int(message) - return int2str(pow(m, pk['publicExponent'], pk['modulus']), mlen) - - -def RSASSA_PKCS1_v1_5_sign(hash, private_key): - """Sign a digest with RFC3447 RSASSA-PKCS1-v1_5. - - @param hash: hash object to sign - @param private_key: private key data - @return: signed digest byte string - """ - modlen = len(int2str(private_key['modulus'])) - encoded_digest = EMSA_PKCS1_v1_5_encode(hash, modlen) - return rsa_decrypt(encoded_digest, private_key, modlen) - - -def RSASSA_PKCS1_v1_5_verify(hash, signature, public_key): - """Verify a digest signed with RFC3447 RSASSA-PKCS1-v1_5. - - @param hash: hash object to check - @param signature: signed digest byte string - @param public_key: public key data - @return: True if the signature is valid, False otherwise - """ - modlen = len(int2str(public_key['modulus'])) - encoded_digest = EMSA_PKCS1_v1_5_encode(hash, modlen) - signed_digest = rsa_encrypt(signature, public_key, modlen) - return encoded_digest == signed_digest diff --git a/emails/packages/dkim/dnsplug.py b/emails/packages/dkim/dnsplug.py deleted file mode 100644 index 8e57c47..0000000 --- a/emails/packages/dkim/dnsplug.py +++ /dev/null @@ -1,85 +0,0 @@ -# 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 - - -__all__ = [ - 'get_txt' - ] - - -def get_txt_dnspython(name): - """Return a TXT record associated with a DNS name.""" - try: - a = dns.resolver.query(name, dns.rdatatype.TXT,raise_on_no_answer=False) - for r in a.response.answer: - if r.rdtype == dns.rdatatype.TXT: - return b"".join(r.items[0].strings) - except dns.resolver.NXDOMAIN: pass - return None - - -def get_txt_pydns(name): - """Return a TXT record associated with a DNS name.""" - # Older pydns releases don't like a trailing dot. - if name.endswith('.'): - name = name[:-1] - response = DNS.DnsRequest(name, qtype='txt').req() - if not response.answers: - return None - return ''.join(response.answers[0]['data']) - -def get_txt_Milter_dns(name): - """Return a TXT record associated with a DNS name.""" - # Older pydns releases don't like a trailing dot. - if name.endswith('.'): - name = name[:-1] - sess = Session() - a = sess.dns(name,'TXT') - if a: return b''.join(a[0]) - return None - -# Prefer dnspython if it's there, otherwise use pydns. -try: - import dns.resolver - _get_txt = get_txt_dnspython -except ImportError: - try: - from Milter.dns import Session - _get_txt = get_txt_Milter_dns - except ImportError: - import DNS - DNS.DiscoverNameServers() - _get_txt = get_txt_pydns - -def get_txt(name): - """Return a TXT record associated with a DNS name. - - @param name: The bytestring domain name to look up. - """ - # pydns needs Unicode, but DKIM's d= is ASCII (already punycoded). - try: - unicode_name = name.decode('ascii') - except UnicodeDecodeError: - return None - txt = _get_txt(unicode_name) - if txt and isinstance(txt, str): - txt = txt.encode('utf-8') - return txt diff --git a/emails/packages/dkim/util.py b/emails/packages/dkim/util.py deleted file mode 100644 index 0be290b..0000000 --- a/emails/packages/dkim/util.py +++ /dev/null @@ -1,78 +0,0 @@ -# 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) 2011 William Grant - -import logging -try: - from logging import NullHandler -except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - - -__all__ = [ - 'DuplicateTag', - 'get_default_logger', - 'InvalidTagSpec', - 'InvalidTagValueList', - 'parse_tag_value', - ] - - -class InvalidTagValueList(Exception): - pass - - -class DuplicateTag(InvalidTagValueList): - pass - - -class InvalidTagSpec(InvalidTagValueList): - pass - - -def parse_tag_value(tag_list): - """Parse a DKIM Tag=Value list. - - Interprets the syntax specified by RFC4871 section 3.2. - Assumes that folding whitespace is already unfolded. - - @param tag_list: A string containing a DKIM Tag=Value list. - """ - tags = {} - tag_specs = tag_list.strip().split(b';') - # Trailing semicolons are valid. - if not tag_specs[-1]: - tag_specs.pop() - for tag_spec in tag_specs: - try: - key, value = tag_spec.split(b'=', 1) - except ValueError: - raise InvalidTagSpec(tag_spec) - if key.strip() in tags: - raise DuplicateTag(key.strip()) - tags[key.strip()] = value.strip() - return tags - - -def get_default_logger(): - """Get the default dkimpy logger.""" - logger = logging.getLogger('dkimpy') - if not logger.handlers: - logger.addHandler(NullHandler()) - return logger diff --git a/emails/signers.py b/emails/signers.py index a3b77d4..47a61e9 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -1,13 +1,12 @@ -# This module use pydkim for DKIM signature +# This module uses dkimpy for DKIM signature from __future__ import annotations import logging from email.mime.multipart import MIMEMultipart from typing import IO -from .packages import dkim -from .packages.dkim import DKIMException, UnparsableKeyError -from .packages.dkim.crypto import parse_pem_private_key +import dkim +from dkim import DKIMException, UnparsableKeyError class DKIMSigner: @@ -26,24 +25,21 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No if privkey and hasattr(privkey, 'read'): privkey = privkey.read() - # Normalize to bytes before parsing + # Normalize to bytes privkey_bytes = privkey if isinstance(privkey, bytes) else str(privkey).encode() - # Compile private key + # Validate key by attempting to parse it try: - privkey_parsed = parse_pem_private_key(privkey_bytes) + dkim.crypto.parse_pem_private_key(privkey_bytes) except UnparsableKeyError as exc: raise DKIMException(exc) - self._sign_params.update({'privkey': privkey_parsed, + self._sign_params.update({'privkey': privkey_bytes, 'domain': domain.encode(), 'selector': selector.encode()}) def get_sign_string(self, message: bytes) -> bytes | None: try: - # pydkim module parses message and privkey on each signing - # this is not optimal for mass operations - # TODO: patch pydkim or use another signing module result: bytes = dkim.sign(message=message, **self._sign_params) return result except DKIMException: @@ -57,7 +53,6 @@ def get_sign_bytes(self, message: bytes) -> bytes | None: return self.get_sign_string(message) def get_sign_header(self, message: bytes) -> tuple[str, str] | None: - # pydkim returns string, so we should split s = self.get_sign_string(message) if s: (header, value) = s.decode().split(': ', 1) @@ -70,11 +65,6 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: """ Add DKIM header to email.message """ - - # py3 pydkim requires bytes to compute dkim header - # but py3 smtplib requires str to send DATA command (# - # so we have to convert msg.as_string - dkim_header = self.get_sign_header(msg.as_string().encode()) if dkim_header: msg._headers.insert(0, dkim_header) # type: ignore[attr-defined] @@ -84,11 +74,6 @@ def sign_message_string(self, message_string: str) -> str: """ Insert DKIM header to message string """ - - # py3 pydkim requires bytes to compute dkim header - # but py3 smtplib requires str to send DATA command - # so we have to convert message_string - s = self.get_sign_string(message_string.encode()) if s: return s.decode() + message_string diff --git a/emails/testsuite/message/test_dkim.py b/emails/testsuite/message/test_dkim.py index 00127e2..87a7b89 100644 --- a/emails/testsuite/message/test_dkim.py +++ b/emails/testsuite/message/test_dkim.py @@ -7,7 +7,7 @@ from emails.exc import DKIMException from emails.utils import load_email_charsets -import emails.packages.dkim +import dkim from .helpers import common_email_data @@ -48,8 +48,8 @@ def _check_dkim(message, pub_key=PUB_KEY): def _plain_public_key(s): return b"".join([l for l in s.split(b'\n') if not l.startswith(b'---')]) message = message.as_string() - o = emails.packages.dkim.DKIM(message=message.encode()) - return o.verify(dnsfunc=lambda name: b"".join([b"v=DKIM1; p=", _plain_public_key(pub_key)])) + o = dkim.DKIM(message=message.encode()) + return o.verify(dnsfunc=lambda name, **kw: b"".join([b"v=DKIM1; p=", _plain_public_key(pub_key)])) def test_dkim(): diff --git a/setup.cfg b/setup.cfg index 727ab7c..e78e4a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ python_version = 3.10 ignore_missing_imports = true warn_return_any = true warn_unused_ignores = true -exclude = emails/(packages|testsuite)/ +exclude = emails/testsuite/ # Mixin classes access attributes defined in BaseMessage via MRO; # mypy checks each mixin in isolation and reports false attr-defined errors. @@ -12,10 +12,6 @@ exclude = emails/(packages|testsuite)/ [mypy-emails.message] disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no-any-return, assignment -# Vendored DKIM package -[mypy-emails.packages.*] -ignore_errors = true - # Uses private smtplib attrs (_have_ssl), conditional class definitions, # and overrides smtplib.SMTP.sendmail with incompatible return type [mypy-emails.backend.smtp.client] diff --git a/setup.py b/setup.py index 35801c6..1dda542 100644 --- a/setup.py +++ b/setup.py @@ -121,13 +121,11 @@ def find_version(*file_paths): 'emails.backend.smtp', 'emails.backend.inmemory', 'emails.template', - 'emails.packages', - 'emails.packages.dkim' ], package_data={'emails': ['py.typed']}, scripts=['scripts/make_rfc822.py'], python_requires='>=3.10', - install_requires=['python-dateutil', 'puremagic'], + install_requires=['python-dateutil', 'puremagic', 'dkimpy'], extras_require={ 'html': ['cssutils', 'lxml', 'chardet', 'requests', 'premailer'], }, From 15f6e55163f4d033c8dc01170349d0f98637e91d Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 23:41:06 +0300 Subject: [PATCH 2/2] .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2f169e7..100f653 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ nosetests.xml venv/ .env + +docs/plans/ +.claude/*local*