From 4cc38d62fd2c8e0c541396e638214e9a62614e25 Mon Sep 17 00:00:00 2001 From: Scott Kitterman Date: Mon, 7 Mar 2011 23:25:49 -0500 Subject: [PATCH] Start with pydkim 0.3. --- ChangeLog | 18 ++ LICENSE | 17 ++ PKG-INFO | 11 + README | 50 ++++ TODO | 8 + dkim.py | 635 +++++++++++++++++++++++++++++++++++++++++++++++++ dkim.py.orig | 639 ++++++++++++++++++++++++++++++++++++++++++++++++++ dkimsend.sh | 3 + dkimsign.py | 44 ++++ dkimverify.py | 29 +++ publish.sh | 21 ++ setup.py | 38 +++ 12 files changed, 1513 insertions(+) create mode 100644 ChangeLog create mode 100644 LICENSE create mode 100644 PKG-INFO create mode 100644 README create mode 100644 TODO create mode 100644 dkim.py create mode 100644 dkim.py.orig create mode 100755 dkimsend.sh create mode 100644 dkimsign.py create mode 100644 dkimverify.py create mode 100755 publish.sh create mode 100644 setup.py diff --git a/ChangeLog b/ChangeLog new file mode 100644 index 0000000..3b1984a --- /dev/null +++ b/ChangeLog @@ -0,0 +1,18 @@ +2008-06-25 Version 0.3 + + - length parameter to sign() is now a boolean + - sign() now folds the DKIM-Signature line + - validation of all inputs + - general code cleanup + +2008-02-19 Version 0.2 + + - handle "rsa-sha1" algorithm properly + - handle multiple DKIM-Signature lines + - handle FWS around = in DKIM-Signature lines + - handle case of single canonicalization algorithm + - handle l= signature property + +2008-02-18 Version 0.1 + + - initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8919fa2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +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 diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..153350f --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,11 @@ +Metadata-Version: 1.0 +Name: pydkim +Version: 0.3 +Summary: DKIM (DomainKeys Identified Mail) +Home-page: http://hewgill.com/pydkim/ +Author: Greg Hewgill +Author-email: greg@hewgill.com +License: BSD-like +Description: pydkim is a Python library that implements DKIM (DomainKeys + Identified Mail) email signing and verification. +Platform: UNKNOWN diff --git a/README b/README new file mode 100644 index 0000000..d638456 --- /dev/null +++ b/README @@ -0,0 +1,50 @@ +pydkim - DKIM (DomainKeys Identified Mail) +http://hewgill.com/pydkim/ + +INTRODUCTION + +pydkim is a library that implements DKIM (DomainKeys Identified Mail) email +signing and verification. + +VERSION + +This is pydkim 0.3. + +REQUIREMENTS + +Python 2.5 or later is required. +The dnspython library (http://www.dnspython.org) library is required. + +INSTALLATION + +To build and install pydkim: + + python setup.py install + +USAGE + +The pydkim 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. + +For full documentation, see: http://hewgill.local/pydkim/html/ + +Two helper programs are also supplied: dkimsign.py and dkimverify.py. + +dkimsign.py is a filter that reads an RFC822 message on standard input, and +writes the same message on standard output with a DKIM-Signature line +prepended. The signing options are specified on the command line: + +dkimsign.py selector domain privatekeyfile [identity] + +The identity is optional and defaults to "@domain". + +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. + +FEEDBACK + +Bug reports may be sent to . diff --git a/TODO b/TODO new file mode 100644 index 0000000..48cefb0 --- /dev/null +++ b/TODO @@ -0,0 +1,8 @@ +Handle multiple DKIM-Signature header lines, ie. more than just the first one. + +It should be possible to remove the dependency on dnspython by using something +like dig. + +Better debug logging output. + +General code cleanup. diff --git a/dkim.py b/dkim.py new file mode 100644 index 0000000..c0157d7 --- /dev/null +++ b/dkim.py @@ -0,0 +1,635 @@ +# 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 + +import base64 +import hashlib +import re +import time + +import dns.resolver + +__all__ = [ + "Simple", + "Relaxed", + "InternalError", + "KeyFormatError", + "MessageFormatError", + "ParameterError", + "sign", + "verify", +] + +class Simple: + """Class that represents the "simple" canonicalization algorithm.""" + + name = "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 re.sub("(\r\n)*$", "\r\n", body) + +class Relaxed: + """Class that represents the "relaxed" canonicalization algorithm.""" + + name = "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(), re.sub(r"\s+", " ", re.sub("\r\n", "", x[1])).strip()+"\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 re.sub("(\r\n)*$", "\r\n", re.sub(r"[\x09\x20]+", " ", re.sub("[\\x09\\x20]+\r\n", "\r\n", body))) + +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 + +def _remove(s, t): + i = s.find(t) + assert i >= 0 + return s[:i] + s[i+len(t):] + +INTEGER = 0x02 +BIT_STRING = 0x03 +OCTET_STRING = 0x04 +NULL = 0x05 +OBJECT_IDENTIFIER = 0x06 +SEQUENCE = 0x30 + +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,), + ]) +] + +def asn1_parse(template, data): + """Parse a data structure according to ASN.1 template. + + @param template: A list of tuples comprising the ASN.1 template. + @param data: A list of bytes to parse. + + """ + + r = [] + i = 0 + for t in template: + tag = ord(data[i]) + i += 1 + if tag == t[0]: + length = ord(data[i]) + i += 1 + if length & 0x80: + n = length & 0x7f + length = 0 + for j in range(n): + length = (length << 8) | ord(data[i]) + i += 1 + if tag == INTEGER: + n = 0 + for j in range(length): + n = (n << 8) | ord(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 KeyFormatError("Unexpected tag in template: %02x" % tag) + else: + raise KeyFormatError("Unexpected tag (got %02x, expecting %02x)" % (tag, t[0])) + return r + +def asn1_length(n): + """Return a string representing a field length in ASN.1 format.""" + assert n >= 0 + if n < 0x7f: + return chr(n) + r = "" + while n > 0: + r = chr(n & 0xff) + r + n >>= 8 + return r + +def asn1_build(node): + """Build an ASN.1 data structure based on pairs of (type, data).""" + if node[0] == OCTET_STRING: + return chr(OCTET_STRING) + asn1_length(len(node[1])) + node[1] + if node[0] == NULL: + assert node[1] is None + return chr(NULL) + asn1_length(0) + elif node[0] == OBJECT_IDENTIFIER: + return chr(OBJECT_IDENTIFIER) + asn1_length(len(node[1])) + node[1] + elif node[0] == SEQUENCE: + r = "" + for x in node[1]: + r += asn1_build(x) + return chr(SEQUENCE) + asn1_length(len(r)) + r + else: + raise InternalError("Unexpected tag in template: %02x" % node[0]) + +# These values come from RFC 3447, section 9.2 Notes, page 43. +HASHID_SHA1 = "\x2b\x0e\x03\x02\x1a" +HASHID_SHA256 = "\x60\x86\x48\x01\x65\x03\x04\x02\x01" + +def str2int(s): + """Convert an octet string to an integer. Octet string assumed to represent a positive integer.""" + r = 0 + for c in s: + r = (r << 8) | ord(c) + return r + +def int2str(n, length = -1): + """Convert an integer to an octet string. Number must be positive. + + @param n: Number to convert. + @param length: Minimum length, or -1 to return the smallest number of bytes that represent the integer. + + """ + + assert n >= 0 + r = [] + while length < 0 or len(r) < length: + r.append(chr(n & 0xff)) + n >>= 8 + if length < 0 and n == 0: break + r.reverse() + assert length < 0 or len(r) == length + return r + +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("\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 re.match(r"[\x09\x20]", lines[i][0]): + headers[-1][1] += lines[i]+"\r\n" + else: + m = re.match(r"([\x21-\x7e]+?):", lines[i]) + if m is not None: + headers.append([m.group(1), lines[i][m.end(0):]+"\r\n"]) + elif lines[i].startswith("From "): + pass + else: + raise MessageFormatError("Unexpected characters in RFC822 header: %s" % lines[i]) + i += 1 + return (headers, "\r\n".join(lines[i:])) + +def dnstxt(name): + """Return a TXT record associated with a DNS name.""" + a = dns.resolver.query(name, dns.rdatatype.TXT) + for r in a.response.answer: + if r.rdtype == dns.rdatatype.TXT: + return "".join(r.items[0].strings) + return None + +def fold(header): + """Fold a header line into multiple crlf-separated lines at column 72.""" + i = header.rfind("\r\n ") + if i == -1: + pre = "" + else: + i += 3 + pre = header[:i] + header = header[i:] + while len(header) > 72: + i = header[:72].rfind(" ") + if i == -1: + j = i + else: + j = i + 1 + pre += header[:i] + "\r\n " + header = header[j:] + return pre + header + +def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple, Simple), include_headers=None, length=False, debuglog=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) + @param length: true if the l= tag should be included to indicate body length (default False) + @param debuglog: a file-like object to which debug info will be written (default None) + + """ + + (headers, body) = rfc822_parse(message) + + m = re.search("--\n(.*?)\n--", privkey, re.DOTALL) + if m is None: + raise KeyFormatError("Private key not found") + try: + pkdata = base64.b64decode(m.group(1)) + except TypeError, e: + raise KeyFormatError(str(e)) + if debuglog is not None: + print >>debuglog, " ".join("%02x" % ord(x) for x in pkdata) + pka = asn1_parse(ASN1_RSAPrivateKey, pkdata) + 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], + } + + if identity is not None and not identity.endswith(domain): + raise ParameterError("identity must end with domain") + + headers = canonicalize[0].canonicalize_headers(headers) + + if include_headers is None: + include_headers = [x[0].lower() for x in headers] + else: + include_headers = [x.lower() for x in include_headers] + sign_headers = [x for x in headers if x[0].lower() in include_headers] + + body = canonicalize[1].canonicalize_body(body) + + h = hashlib.sha256() + h.update(body) + bodyhash = base64.b64encode(h.digest()) + + sigfields = [x for x in [ + ('v', "1"), + ('a', "rsa-sha256"), + ('c', "%s/%s" % (canonicalize[0].name, canonicalize[1].name)), + ('d', domain), + ('i', identity or "@"+domain), + length and ('l', len(body)), + ('q', "dns/txt"), + ('s', selector), + ('t', str(int(time.time()))), + ('h', " : ".join(x[0] for x in sign_headers)), + ('bh', bodyhash), + ('b', ""), + ] if x] + sig = "DKIM-Signature: " + "; ".join("%s=%s" % x for x in sigfields) + + sig = fold(sig) + + if debuglog is not None: + print >>debuglog, "sign headers:", sign_headers + [("DKIM-Signature", " "+"; ".join("%s=%s" % x for x in sigfields))] + h = hashlib.sha256() + for x in sign_headers: + h.update(x[0]) + h.update(":") + h.update(x[1]) + h.update(sig) + d = h.digest() + if debuglog is not None: + print >>debuglog, "sign digest:", " ".join("%02x" % ord(x) for x in d) + + dinfo = asn1_build( + (SEQUENCE, [ + (SEQUENCE, [ + (OBJECT_IDENTIFIER, HASHID_SHA256), + (NULL, None), + ]), + (OCTET_STRING, d), + ]) + ) + modlen = len(int2str(pk['modulus'])) + if len(dinfo)+3 > modlen: + raise ParameterError("Hash too large for modulus") + sig2 = int2str(pow(str2int("\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo), pk['privateExponent'], pk['modulus']), modlen) + sig += base64.b64encode(''.join(sig2)) + + return sig + "\r\n" + +def verify(message, debuglog=None): + """Verify a DKIM signature on an RFC822 formatted message. + + @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) + @param debuglog: a file-like object to which debug info will be written (default None) + + """ + + (headers, body) = rfc822_parse(message) + + sigheaders = [x for x in headers if x[0].lower() == "dkim-signature"] + if len(sigheaders) < 1: + return False + + # Currently, we only validate the first DKIM-Signature line found. + + a = re.split(r"\s*;\s*", sigheaders[0][1].strip()) + if debuglog is not None: + print >>debuglog, "a:", a + sig = {} + for x in a: + if x: + m = re.match(r"(\w+)\s*=\s*(.*)", x, re.DOTALL) + if m is None: + if debuglog is not None: + print >>debuglog, "invalid format of signature part: %s" % x + return False + sig[m.group(1)] = m.group(2) + if debuglog is not None: + print >>debuglog, "sig:", sig + + if 'v' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing v=" + return False + if sig['v'] != "1": + if debuglog is not None: + print >>debuglog, "v= value is not 1 (%s)" % sig['v'] + return False + if 'a' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing a=" + return False + if 'b' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing b=" + return False + if re.match(r"[\s0-9A-Za-z+/]+=*$", sig['b']) is None: + if debuglog is not None: + print >>debuglog, "b= value is not valid base64 (%s)" % sig['b'] + return False + if 'bh' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing bh=" + return False + if re.match(r"[\s0-9A-Za-z+/]+=*$", sig['bh']) is None: + if debuglog is not None: + print >>debuglog, "bh= value is not valid base64 (%s)" % sig['bh'] + return False + if 'd' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing d=" + return False + if 'h' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing h=" + return False + if 'i' in sig and (not sig['i'].endswith(sig['d']) or sig['i'][-len(sig['d'])-1] not in "@."): + if debuglog is not None: + print >>debuglog, "i= domain is not a subdomain of d= (i=%s d=%d)" % (sig['i'], sig['d']) + return False + if 'l' in sig and re.match(r"\d{,76}$", sig['l']) is None: + if debuglog is not None: + print >>debuglog, "l= value is not a decimal integer (%s)" % sig['l'] + return False + if 'q' in sig and sig['q'] != "dns/txt": + if debuglog is not None: + print >>debuglog, "q= value is not dns/txt (%s)" % sig['q'] + return False + if 's' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing s=" + return False + if 't' in sig and re.match(r"\d+$", sig['t']) is None: + if debuglog is not None: + print >>debuglog, "t= value is not a decimal integer (%s)" % sig['t'] + return False + if 'x' in sig: + if re.match(r"\d+$", sig['x']) is None: + if debuglog is not None: + print >>debuglog, "x= value is not a decimal integer (%s)" % sig['x'] + return False + if int(sig['x']) < int(sig['t']): + if debuglog is not None: + print >>debuglog, "x= value is less than t= value (x=%s t=%s)" % (sig['x'], sig['t']) + return False + + m = re.match("(\w+)(?:/(\w+))?$", sig['c']) + if m is None: + if debuglog is not None: + print >>debuglog, "c= value is not in format method/method (%s)" % sig['c'] + return False + can_headers = m.group(1) + if m.group(2) is not None: + can_body = m.group(2) + else: + can_body = "simple" + + if can_headers == "simple": + canonicalize_headers = Simple + elif can_headers == "relaxed": + canonicalize_headers = Relaxed + else: + if debuglog is not None: + print >>debuglog, "Unknown header canonicalization (%s)" % can_headers + return False + + headers = canonicalize_headers.canonicalize_headers(headers) + + if can_body == "simple": + body = Simple.canonicalize_body(body) + elif can_body == "relaxed": + body = Relaxed.canonicalize_body(body) + else: + if debuglog is not None: + print >>debuglog, "Unknown body canonicalization (%s)" % can_body + return False + + if sig['a'] == "rsa-sha1": + hasher = hashlib.sha1 + hashid = HASHID_SHA1 + elif sig['a'] == "rsa-sha256": + hasher = hashlib.sha256 + hashid = HASHID_SHA256 + else: + if debuglog is not None: + print >>debuglog, "Unknown signature algorithm (%s)" % sig['a'] + return False + + if 'l' in sig: + body = body[:int(sig['l'])] + + h = hasher() + h.update(body) + bodyhash = h.digest() + if debuglog is not None: + print >>debuglog, "bh:", base64.b64encode(bodyhash) + if bodyhash != base64.b64decode(re.sub(r"\s+", "", sig['bh'])): + if debuglog is not None: + print >>debuglog, "body hash mismatch (got %s, expected %s)" % (base64.b64encode(bodyhash), sig['bh']) + return False + + s = dnstxt(sig['s']+"._domainkey."+sig['d']+".") + if not s: + return False + a = re.split(r"\s*;\s*", s) + pub = {} + for f in a: + m = re.match(r"(\w+)=(.*)", f) + if m is not None: + pub[m.group(1)] = m.group(2) + else: + if debuglog is not None: + print >>debuglog, "invalid format in _domainkey txt record" + return False + x = asn1_parse(ASN1_Object, base64.b64decode(pub['p'])) + # Not sure why the [1:] is necessary to skip a byte. + pkd = asn1_parse(ASN1_RSAPublicKey, x[0][1][1:]) + pk = { + 'modulus': pkd[0][0], + 'publicExponent': pkd[0][1], + } + modlen = len(int2str(pk['modulus'])) + if debuglog is not None: + print >>debuglog, "modlen:", modlen + + include_headers = re.split(r"\s*:\s*", sig['h']) + if debuglog is not None: + print >>debuglog, "include_headers:", include_headers + sign_headers = [] + lastindex = {} + for h in include_headers: + i = lastindex.get(h, len(headers)) + while i > 0: + i -= 1 + if h.lower() == headers[i][0].lower(): + sign_headers.append(headers[i]) + break + lastindex[h] = i + # The call to _remove() assumes that the signature b= only appears once in the signature header + sign_headers += [(x[0], x[1].rstrip()) for x in canonicalize_headers.canonicalize_headers([(sigheaders[0][0], _remove(sigheaders[0][1], sig['b']))])] + if debuglog is not None: + print >>debuglog, "verify headers:", sign_headers + + h = hasher() + for x in sign_headers: + h.update(x[0]) + h.update(":") + h.update(x[1]) + d = h.digest() + if debuglog is not None: + print >>debuglog, "verify digest:", " ".join("%02x" % ord(x) for x in d) + + dinfo = asn1_build( + (SEQUENCE, [ + (SEQUENCE, [ + (OBJECT_IDENTIFIER, hashid), + (NULL, None), + ]), + (OCTET_STRING, d), + ]) + ) + if debuglog is not None: + print >>debuglog, "dinfo:", " ".join("%02x" % ord(x) for x in dinfo) + if len(dinfo)+3 > modlen: + if debuglog is not None: + print >>debuglog, "Hash too large for modulus" + return False + sig2 = "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo + if debuglog is not None: + print >>debuglog, "sig2:", " ".join("%02x" % ord(x) for x in sig2) + print >>debuglog, sig['b'] + print >>debuglog, re.sub(r"\s+", "", sig['b']) + v = int2str(pow(str2int(base64.b64decode(re.sub(r"\s+", "", sig['b']))), pk['publicExponent'], pk['modulus']), modlen) + if debuglog is not None: + print >>debuglog, "v:", " ".join("%02x" % ord(x) for x in v) + assert len(v) == len(sig2) + # Byte-by-byte compare of signatures + return not [1 for x in zip(v, sig2) if x[0] != x[1]] + +if __name__ == "__main__": + message = """From: greg@hewgill.com\r\nSubject: test\r\n message\r\n\r\nHi.\r\n\r\nWe lost the game. Are you hungry yet?\r\n\r\nJoe.\r\n""" + print rfc822_parse(message) + sig = sign(message, "greg", "hewgill.com", open("/home/greg/.domainkeys/rsa.private").read()) + print sig + print verify(sig+message) + #print sign(open("/home/greg/tmp/message").read(), "greg", "hewgill.com", open("/home/greg/.domainkeys/rsa.private").read()) diff --git a/dkim.py.orig b/dkim.py.orig new file mode 100644 index 0000000..23618bd --- /dev/null +++ b/dkim.py.orig @@ -0,0 +1,639 @@ +# 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 + +import base64 +import hashlib +import re +import time + +import dns.resolver + +__all__ = [ + "Simple", + "Relaxed", + "InternalError", + "KeyFormatError", + "MessageFormatError", + "ParameterError", + "sign", + "verify", +] + +class Simple: + """Class that represents the "simple" canonicalization algorithm.""" + + name = "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 re.sub("(\r\n)*$", "\r\n", body) + +class Relaxed: + """Class that represents the "relaxed" canonicalization algorithm.""" + + name = "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(), re.sub(r"\s+", " ", re.sub("\r\n", "", x[1])).strip()+"\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 re.sub("(\r\n)*$", "\r\n", re.sub(r"[\x09\x20]+", " ", re.sub("[\\x09\\x20]+\r\n", "\r\n", body))) + +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 + +def _remove(s, t): + i = s.find(t) + assert i >= 0 + return s[:i] + s[i+len(t):] + +INTEGER = 0x02 +BIT_STRING = 0x03 +OCTET_STRING = 0x04 +NULL = 0x05 +OBJECT_IDENTIFIER = 0x06 +SEQUENCE = 0x30 + +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,), + ]) +] + +def asn1_parse(template, data): + """Parse a data structure according to ASN.1 template. + + @param template: A list of tuples comprising the ASN.1 template. + @param data: A list of bytes to parse. + + """ + + r = [] + i = 0 + for t in template: + tag = ord(data[i]) + i += 1 + if tag == t[0]: + length = ord(data[i]) + i += 1 + if length & 0x80: + n = length & 0x7f + length = 0 + for j in range(n): + length = (length << 8) | ord(data[i]) + i += 1 + if tag == INTEGER: + n = 0 + for j in range(length): + n = (n << 8) | ord(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 KeyFormatError("Unexpected tag in template: %02x" % tag) + else: + raise KeyFormatError("Unexpected tag (got %02x, expecting %02x)" % (tag, t[0])) + return r + +def asn1_length(n): + """Return a string representing a field length in ASN.1 format.""" + assert n >= 0 + if n < 0x7f: + return chr(n) + r = "" + while n > 0: + r = chr(n & 0xff) + r + n >>= 8 + return r + +def asn1_build(node): + """Build an ASN.1 data structure based on pairs of (type, data).""" + if node[0] == OCTET_STRING: + return chr(OCTET_STRING) + asn1_length(len(node[1])) + node[1] + if node[0] == NULL: + assert node[1] is None + return chr(NULL) + asn1_length(0) + elif node[0] == OBJECT_IDENTIFIER: + return chr(OBJECT_IDENTIFIER) + asn1_length(len(node[1])) + node[1] + elif node[0] == SEQUENCE: + r = "" + for x in node[1]: + r += asn1_build(x) + return chr(SEQUENCE) + asn1_length(len(r)) + r + else: + raise InternalError("Unexpected tag in template: %02x" % node[0]) + +# These values come from RFC 3447, section 9.2 Notes, page 43. +HASHID_SHA1 = "\x2b\x0e\x03\x02\x1a" +HASHID_SHA256 = "\x60\x86\x48\x01\x65\x03\x04\x02\x01" + +def str2int(s): + """Convert an octet string to an integer. Octet string assumed to represent a positive integer.""" + r = 0 + for c in s: + r = (r << 8) | ord(c) + return r + +def int2str(n, length = -1): + """Convert an integer to an octet string. Number must be positive. + + @param n: Number to convert. + @param length: Minimum length, or -1 to return the smallest number of bytes that represent the integer. + + """ + + assert n >= 0 + r = [] + while length < 0 or len(r) < length: + r.append(chr(n & 0xff)) + n >>= 8 + if length < 0 and n == 0: break + r.reverse() + assert length < 0 or len(r) == length + return r + +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("\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 re.match(r"[\x09\x20]", lines[i][0]): + headers[-1][1] += lines[i]+"\r\n" + else: + m = re.match(r"([\x21-\x7e]+?):", lines[i]) + if m is not None: + headers.append([m.group(1), lines[i][m.end(0):]+"\r\n"]) + elif lines[i].startswith("From "): + pass + else: + raise MessageFormatError("Unexpected characters in RFC822 header: %s" % lines[i]) + i += 1 + return (headers, "\r\n".join(lines[i:])) + +def dnstxt(name): + """Return a TXT record associated with a DNS name.""" + a = dns.resolver.query(name, dns.rdatatype.TXT) + for r in a.response.answer: + if r.rdtype == dns.rdatatype.TXT: + return "".join(r.items[0].strings) + return None + +def fold(header): + """Fold a header line into multiple crlf-separated lines at column 72.""" + i = header.rfind("\r\n ") + if i == -1: + pre = "" + else: + i += 3 + pre = header[:i] + header = header[i:] + while len(header) > 72: + i = header[:72].rfind(" ") + if i == -1: + j = i + else: + j = i + 1 + pre += header[:i] + "\r\n " + header = header[j:] + return pre + header + +def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple, Simple), include_headers=None, length=False, debuglog=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) + @param length: true if the l= tag should be included to indicate body length (default False) + @param debuglog: a file-like object to which debug info will be written (default None) + + """ + + (headers, body) = rfc822_parse(message) + + m = re.search("--\n(.*?)\n--", privkey, re.DOTALL) + if m is None: + raise KeyFormatError("Private key not found") + try: + pkdata = base64.b64decode(m.group(1)) + except TypeError, e: + raise KeyFormatError(str(e)) + if debuglog is not None: + print >>debuglog, " ".join("%02x" % ord(x) for x in pkdata) + pka = asn1_parse(ASN1_RSAPrivateKey, pkdata) + 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], + } + + if identity is not None and not identity.endswith(domain): + raise ParameterError("identity must end with domain") + + headers = canonicalize[0].canonicalize_headers(headers) + + if include_headers is None: + include_headers = [x[0].lower() for x in headers] + else: + include_headers = [x.lower() for x in include_headers] + sign_headers = [x for x in headers if x[0].lower() in include_headers] + + body = canonicalize[1].canonicalize_body(body) + + h = hashlib.sha256() + h.update(body) + bodyhash = base64.b64encode(h.digest()) + + sigfields = [x for x in [ + ('v', "1"), + ('a', "rsa-sha256"), + ('c', "%s/%s" % (canonicalize[0].name, canonicalize[1].name)), + ('d', domain), + ('i', identity or "@"+domain), + length and ('l', len(body)), + ('q', "dns/txt"), + ('s', selector), + ('t', str(int(time.time()))), + ('h', " : ".join(x[0] for x in sign_headers)), + ('bh', bodyhash), + ('b', ""), + ] if x] + sig = "DKIM-Signature: " + "; ".join("%s=%s" % x for x in sigfields) + + sig = fold(sig) + + if debuglog is not None: + print >>debuglog, "sign headers:", sign_headers + [("DKIM-Signature", " "+"; ".join("%s=%s" % x for x in sigfields))] + h = hashlib.sha256() + for x in sign_headers: + h.update(x[0]) + h.update(":") + h.update(x[1]) + h.update(sig) + d = h.digest() + if debuglog is not None: + print >>debuglog, "sign digest:", " ".join("%02x" % ord(x) for x in d) + + dinfo = asn1_build( + (SEQUENCE, [ + (SEQUENCE, [ + (OBJECT_IDENTIFIER, HASHID_SHA256), + (NULL, None), + ]), + (OCTET_STRING, d), + ]) + ) + modlen = len(int2str(pk['modulus'])) + if len(dinfo)+3 > modlen: + raise ParameterError("Hash too large for modulus") + sig2 = int2str(pow(str2int("\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo), pk['privateExponent'], pk['modulus']), modlen) + sig += base64.b64encode(''.join(sig2)) + + return sig + "\r\n" + +def verify(message, debuglog=None): + """Verify a DKIM signature on an RFC822 formatted message. + + @param message: an RFC822 formatted message (with either \\n or \\r\\n line endings) + @param debuglog: a file-like object to which debug info will be written (default None) + + """ + + (headers, body) = rfc822_parse(message) + + sigheaders = [x for x in headers if x[0].lower() == "dkim-signature"] + if len(sigheaders) < 1: + return False + + # Currently, we only validate the first DKIM-Signature line found. + + a = re.split(r"\s*;\s*", sigheaders[0][1].strip()) + if debuglog is not None: + print >>debuglog, "a:", a + sig = {} + for x in a: + if x: + m = re.match(r"(\w+)\s*=\s*(.*)", x, re.DOTALL) + if m is None: + if debuglog is not None: + print >>debuglog, "invalid format of signature part: %s" % x + return False + sig[m.group(1)] = m.group(2) + if debuglog is not None: + print >>debuglog, "sig:", sig + + if 'v' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing v=" + return False + if sig['v'] != "1": + if debuglog is not None: + print >>debuglog, "v= value is not 1 (%s)" % sig['v'] + return False + if 'a' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing a=" + return False + if 'b' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing b=" + return False + if re.match(r"[\s0-9A-Za-z+/]+=*$", sig['b']) is None: + if debuglog is not None: + print >>debuglog, "b= value is not valid base64 (%s)" % sig['b'] + return False + if 'bh' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing bh=" + return False + if re.match(r"[\s0-9A-Za-z+/]+=*$", sig['bh']) is None: + if debuglog is not None: + print >>debuglog, "bh= value is not valid base64 (%s)" % sig['bh'] + return False + if 'd' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing d=" + return False + if 'h' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing h=" + return False + if 'i' in sig and (not sig['i'].endswith(sig['d']) or sig['i'][-len(sig['d'])-1] not in "@."): + if debuglog is not None: + print >>debuglog, "i= domain is not a subdomain of d= (i=%s d=%d)" % (sig['i'], sig['d']) + return False + if 'l' in sig and re.match(r"\d{,76}$", sig['l']) is None: + if debuglog is not None: + print >>debuglog, "l= value is not a decimal integer (%s)" % sig['l'] + return False + if 'q' in sig and sig['q'] != "dns/txt": + if debuglog is not None: + print >>debuglog, "q= value is not dns/txt (%s)" % sig['q'] + return False + if 's' not in sig: + if debuglog is not None: + print >>debuglog, "signature missing s=" + return False + if 't' in sig and re.match(r"\d+$", sig['t']) is None: + if debuglog is not None: + print >>debuglog, "t= value is not a decimal integer (%s)" % sig['t'] + return False + if 'x' in sig: + if re.match(r"\d+$", sig['x']) is None: + if debuglog is not None: + print >>debuglog, "x= value is not a decimal integer (%s)" % sig['x'] + return False + if int(sig['x']) < int(sig['t']): + if debuglog is not None: + print >>debuglog, "x= value is less than t= value (x=%s t=%s)" % (sig['x'], sig['t']) + return False + + m = re.match("(\w+)(?:/(\w+))?$", sig['c']) + if m is None: + if debuglog is not None: + print >>debuglog, "c= value is not in format method/method (%s)" % sig['c'] + return False + can_headers = m.group(1) + if m.group(2) is not None: + can_body = m.group(2) + else: + can_body = "simple" + + if can_headers == "simple": + canonicalize_headers = Simple + elif can_headers == "relaxed": + canonicalize_headers = Relaxed + else: + if debuglog is not None: + print >>debuglog, "Unknown header canonicalization (%s)" % can_headers + return False + + headers = canonicalize_headers.canonicalize_headers(headers) + + if can_body == "simple": + body = Simple.canonicalize_body(body) + elif can_body == "relaxed": + body = Relaxed.canonicalize_body(body) + else: + if debuglog is not None: + print >>debuglog, "Unknown body canonicalization (%s)" % can_body + return False + + if sig['a'] == "rsa-sha1": + hasher = hashlib.sha1 + hashid = HASHID_SHA1 + elif sig['a'] == "rsa-sha256": + hasher = hashlib.sha256 + hashid = HASHID_SHA256 + else: + if debuglog is not None: + print >>debuglog, "Unknown signature algorithm (%s)" % sig['a'] + return False + + if 'l' in sig: + body = body[:int(sig['l'])] + + h = hasher() + h.update(body) + bodyhash = h.digest() + if debuglog is not None: + print >>debuglog, "bh:", base64.b64encode(bodyhash) + if bodyhash != base64.b64decode(re.sub(r"\s+", "", sig['bh'])): + if debuglog is not None: + print >>debuglog, "body hash mismatch (got %s, expected %s)" % (base64.b64encode(bodyhash), sig['bh']) + return False + + s = dnstxt(sig['s']+"._domainkey."+sig['d']+".") + if not s: + return False + a = re.split(r"\s*;\s*", s) + # Trailing ';' on signature record is valid, see RFC 4871 3.2 + # tag-list = tag-spec 0*( ";" tag-spec ) [ ";" ] + if a[-1] == '': + a.pop(-1) + pub = {} + for f in a: + m = re.match(r"(\w+)=(.*)", f) + if m is not None: + pub[m.group(1)] = m.group(2) + else: + if debuglog is not None: + print >>debuglog, "invalid format in _domainkey txt record" + return False + x = asn1_parse(ASN1_Object, base64.b64decode(pub['p'])) + # Not sure why the [1:] is necessary to skip a byte. + pkd = asn1_parse(ASN1_RSAPublicKey, x[0][1][1:]) + pk = { + 'modulus': pkd[0][0], + 'publicExponent': pkd[0][1], + } + modlen = len(int2str(pk['modulus'])) + if debuglog is not None: + print >>debuglog, "modlen:", modlen + + include_headers = re.split(r"\s*:\s*", sig['h']) + if debuglog is not None: + print >>debuglog, "include_headers:", include_headers + sign_headers = [] + lastindex = {} + for h in include_headers: + i = lastindex.get(h, len(headers)) + while i > 0: + i -= 1 + if h.lower() == headers[i][0].lower(): + sign_headers.append(headers[i]) + break + lastindex[h] = i + # The call to _remove() assumes that the signature b= only appears once in the signature header + sign_headers += [(x[0], x[1].rstrip()) for x in canonicalize_headers.canonicalize_headers([(sigheaders[0][0], _remove(sigheaders[0][1], sig['b']))])] + if debuglog is not None: + print >>debuglog, "verify headers:", sign_headers + + h = hasher() + for x in sign_headers: + h.update(x[0]) + h.update(":") + h.update(x[1]) + d = h.digest() + if debuglog is not None: + print >>debuglog, "verify digest:", " ".join("%02x" % ord(x) for x in d) + + dinfo = asn1_build( + (SEQUENCE, [ + (SEQUENCE, [ + (OBJECT_IDENTIFIER, hashid), + (NULL, None), + ]), + (OCTET_STRING, d), + ]) + ) + if debuglog is not None: + print >>debuglog, "dinfo:", " ".join("%02x" % ord(x) for x in dinfo) + if len(dinfo)+3 > modlen: + if debuglog is not None: + print >>debuglog, "Hash too large for modulus" + return False + sig2 = "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo + if debuglog is not None: + print >>debuglog, "sig2:", " ".join("%02x" % ord(x) for x in sig2) + print >>debuglog, sig['b'] + print >>debuglog, re.sub(r"\s+", "", sig['b']) + v = int2str(pow(str2int(base64.b64decode(re.sub(r"\s+", "", sig['b']))), pk['publicExponent'], pk['modulus']), modlen) + if debuglog is not None: + print >>debuglog, "v:", " ".join("%02x" % ord(x) for x in v) + assert len(v) == len(sig2) + # Byte-by-byte compare of signatures + return not [1 for x in zip(v, sig2) if x[0] != x[1]] + +if __name__ == "__main__": + message = """From: greg@hewgill.com\r\nSubject: test\r\n message\r\n\r\nHi.\r\n\r\nWe lost the game. Are you hungry yet?\r\n\r\nJoe.\r\n""" + print rfc822_parse(message) + sig = sign(message, "greg", "hewgill.com", open("/home/greg/.domainkeys/rsa.private").read()) + print sig + print verify(sig+message) + #print sign(open("/home/greg/tmp/message").read(), "greg", "hewgill.com", open("/home/greg/.domainkeys/rsa.private").read()) diff --git a/dkimsend.sh b/dkimsend.sh new file mode 100755 index 0000000..fd74028 --- /dev/null +++ b/dkimsend.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +/usr/local/bin/python2.5 /home/greg/src/pydkim/dkimsign.py greg hewgill.com /home/greg/.domainkeys/rsa.private | /usr/sbin/sendmail $* diff --git a/dkimsign.py b/dkimsign.py new file mode 100644 index 0000000..a17fbc8 --- /dev/null +++ b/dkimsign.py @@ -0,0 +1,44 @@ +#!/usr/local/bin/python2.5 + +# 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 + +import sys + +import dkim + +if len(sys.argv) < 4 or len(sys.argv) > 5: + print >>sys.stderr, "Usage: dkimsign.py selector domain privatekeyfile [identity]" + sys.exit(1) + +selector = sys.argv[1] +domain = sys.argv[2] +privatekeyfile = sys.argv[3] +if len(sys.argv) > 5: + identity = sys.argv[4] +else: + identity = None + +message = sys.stdin.read() +try: + sig = dkim.sign(message, selector, domain, open(privatekeyfile, "r").read(), identity = identity) + sys.stdout.write(sig) + sys.stdout.write(message) +except Exception, e: + print >>sys.stderr, e + sys.stdout.write(message) diff --git a/dkimverify.py b/dkimverify.py new file mode 100644 index 0000000..e58d03a --- /dev/null +++ b/dkimverify.py @@ -0,0 +1,29 @@ +#!/usr/local/bin/python2.5 + +# 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 + +import sys + +import dkim + +message = sys.stdin.read() +if not dkim.verify(message): + print "signature verification failed" + sys.exit(1) +print "signature ok" diff --git a/publish.sh b/publish.sh new file mode 100755 index 0000000..112e9be --- /dev/null +++ b/publish.sh @@ -0,0 +1,21 @@ +#!/bin/sh -e + +VERSION=`grep ^version setup.py | cut -d\" -f2` +echo $VERSION +if [ -f dist/pydkim-$VERSION.tar.gz ]; then + echo "version already exists" + exit 1 +fi +perl -pi -e "s/pydkim [0-9.]+/pydkim $VERSION./g" README +epydoc dkim +python2.5 setup.py sdist --formats gztar +MD5_GZTAR=`md5 dist/pydkim-$VERSION.tar.gz | cut -d' ' -f4` +echo $MD5_GZTAR +perl -pi -e "s/pydkim-[0-9.]+/pydkim-$VERSION./g; s/= [0-9a-f]+/= $MD5_GZTAR/" ~/www.hewgill.com/pydkim/index.html +cp -Rv html ~/www.hewgill.com/pydkim/ +cp -v dist/pydkim-$VERSION.tar.gz ~/www.hewgill.com/pydkim/ +echo "Version $VERSION successful" +echo +echo "NEXT STEPS:" +echo "- svn cp trunk tags/release-$VERSION" +echo "- python setup.py register" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f7f8ea1 --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +#!/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 + +from distutils.core import setup + +version = "0.3" + +setup( + name = "pydkim", + version = version, + description = "DKIM (DomainKeys Identified Mail)", + long_description = + """pydkim is a Python library that implements DKIM (DomainKeys +Identified Mail) email signing and verification.""", + author = "Greg Hewgill", + author_email = "greg@hewgill.com", + url = "http://hewgill.com/pydkim/", + license = "BSD-like", + py_modules = ["dkim"], + scripts = ["dkimsign.py", "dkimverify.py", "dkimsend.sh"], +)