From b3225a0a2476ff8921ecffefc2bb0ee3ae4282af Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 9 Mar 2011 23:14:11 +1100 Subject: [PATCH 01/13] Extract parse_private_key. --- dkim/__init__.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index 5966d71..fa47378 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -94,6 +94,7 @@ def _remove(s, t): assert i >= 0 return s[:i] + s[i+len(t):] + def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): dinfo = asn1_build( (SEQUENCE, [ @@ -102,7 +103,7 @@ def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): (NULL, None), ]), (OCTET_STRING, digest), - ]) + ]), ) if len(dinfo)+3 > modlen: raise ParameterError("Hash too large for modulus") @@ -143,6 +144,22 @@ def parse_public_key(data): return pk +def parse_private_key(data): + pka = asn1_parse(ASN1_RSAPrivateKey, data) + 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 validate_signature_fields(sig, debuglog=None): mandatory_fields = ('v', 'a', 'b', 'bh', 'd', 'h', 's') for field in mandatory_fields: @@ -416,19 +433,7 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple 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], - } - + pk = parse_private_key(pkdata) if identity is not None and not identity.endswith(domain): raise ParameterError("identity must end with domain") From b6d3502f9e1bbe687605a064618cb20c14aac983 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 9 Mar 2011 23:23:01 +1100 Subject: [PATCH 02/13] Split ASN.1 utilities out into their own file. --- dkim/__init__.py | 110 +++++-------------------------------------- dkim/asn1.py | 119 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 99 deletions(-) create mode 100644 dkim/asn1.py diff --git a/dkim/__init__.py b/dkim/__init__.py index fa47378..efc1782 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -1,21 +1,3 @@ -# 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 @@ -23,6 +5,17 @@ import time import dns.resolver +from dkim.asn1 import ( + asn1_build, + asn1_parse, + BIT_STRING, + INTEGER, + SEQUENCE, + OBJECT_IDENTIFIER, + OCTET_STRING, + NULL, + ) + __all__ = [ "Simple", "Relaxed", @@ -208,13 +201,6 @@ def validate_signature_fields(sig, debuglog=None): return True -INTEGER = 0x02 -BIT_STRING = 0x03 -OCTET_STRING = 0x04 -NULL = 0x05 -OBJECT_IDENTIFIER = 0x06 -SEQUENCE = 0x30 - ASN1_Object = [ (SEQUENCE, [ (SEQUENCE, [ @@ -246,80 +232,6 @@ ASN1_RSAPrivateKey = [ ]) ] -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" diff --git a/dkim/asn1.py b/dkim/asn1.py new file mode 100644 index 0000000..20da2c8 --- /dev/null +++ b/dkim/asn1.py @@ -0,0 +1,119 @@ +# 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 + +__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 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 ASN1FormatError( + "Unexpected tag in template: %02x" % tag) + else: + raise ASN1FormatError( + "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 ASN1FormatError("Unexpected tag in template: %02x" % node[0]) From 6f715a1a017aa0f9a41be16699cbf166d53b6df3 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 9 Mar 2011 23:27:04 +1100 Subject: [PATCH 03/13] Extract crypto stuff to dkim.crypto. --- dkim/__init__.py | 88 ++----------------------------------- dkim/crypto.py | 110 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 84 deletions(-) create mode 100644 dkim/crypto.py diff --git a/dkim/__init__.py b/dkim/__init__.py index efc1782..3cc88cc 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -5,15 +5,10 @@ import time import dns.resolver -from dkim.asn1 import ( - asn1_build, - asn1_parse, - BIT_STRING, - INTEGER, - SEQUENCE, - OBJECT_IDENTIFIER, - OCTET_STRING, - NULL, +from dkim.crypto import ( + EMSA_PKCS1_v1_5_encode, + parse_private_key, + parse_public_key, ) __all__ = [ @@ -87,22 +82,6 @@ def _remove(s, t): assert i >= 0 return s[:i] + s[i+len(t):] - -def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): - dinfo = asn1_build( - (SEQUENCE, [ - (SEQUENCE, [ - (OBJECT_IDENTIFIER, hashid), - (NULL, None), - ]), - (OCTET_STRING, digest), - ]), - ) - if len(dinfo)+3 > modlen: - raise ParameterError("Hash too large for modulus") - return "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo - - def hash_headers(hasher, canonicalize_headers, headers, include_headers, sigheaders, sig): sign_headers = [] @@ -126,33 +105,6 @@ def hash_headers(hasher, canonicalize_headers, headers, include_headers, hasher.update(x[1]) -def parse_public_key(data): - x = asn1_parse(ASN1_Object, data) - # 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], - } - return pk - - -def parse_private_key(data): - pka = asn1_parse(ASN1_RSAPrivateKey, data) - 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 validate_signature_fields(sig, debuglog=None): mandatory_fields = ('v', 'a', 'b', 'bh', 'd', 'h', 's') for field in mandatory_fields: @@ -200,38 +152,6 @@ def validate_signature_fields(sig, debuglog=None): return False return True - -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,), - ]) -] - # 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" diff --git a/dkim/crypto.py b/dkim/crypto.py new file mode 100644 index 0000000..217318d --- /dev/null +++ b/dkim/crypto.py @@ -0,0 +1,110 @@ +# 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 +# Copyright (c) 2011 William Grant + +__all__ = [ + 'EMSA_PKCS1_v1_5_encode', + 'parse_private_key', + 'parse_public_key', + ] + +from dkim.asn1 import ( + 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,), + ]) +] + + +def parse_public_key(data): + x = asn1_parse(ASN1_Object, data) + # 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], + } + return pk + + +def parse_private_key(data): + pka = asn1_parse(ASN1_RSAPrivateKey, data) + 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 EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): + dinfo = asn1_build( + (SEQUENCE, [ + (SEQUENCE, [ + (OBJECT_IDENTIFIER, hashid), + (NULL, None), + ]), + (OCTET_STRING, digest), + ]), + ) + if len(dinfo)+3 > modlen: + raise Exception("Hash too large for modulus") # XXX: DKIMException + return "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo + From 5898094fe11fe81e687efb9bc28a1a82b850d578 Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 9 Mar 2011 23:37:59 +1100 Subject: [PATCH 04/13] Factor out RSA into perform_rsa. --- dkim/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index 3cc88cc..8d4a366 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -152,6 +152,9 @@ def validate_signature_fields(sig, debuglog=None): return False return True +def perform_rsa(input, exponent, modulus, modlen): + return int2str(pow(str2int(input), exponent, modulus), modlen) + # 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" @@ -315,7 +318,7 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple modlen = len(int2str(pk['modulus'])) encoded = EMSA_PKCS1_v1_5_encode(d, modlen, HASHID_SHA256) - sig2 = int2str(pow(str2int(encoded), pk['privateExponent'], pk['modulus']), modlen) + sig2 = perform_rsa(encoded, pk['privateExponent'], pk['modulus'], modlen) sig += base64.b64encode(''.join(sig2)) return sig + "\r\n" @@ -446,7 +449,8 @@ def verify(message, debuglog=None, dnsfunc=dnstxt): 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) + signature = base64.b64decode(re.sub(r"\s+", "", sig['b'])) + v = perform_rsa(signature, 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) From c82703cea963e6ec6cea708cad4a93496305398c Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 10 Mar 2011 00:03:15 +1100 Subject: [PATCH 05/13] Implement RSASSA-PKCS1-v1_5 in dkim.crypto, and use that in verify() and sign(). Move str2int/int2str into dkim.crypto. Verification no longer does a constant-time string compare; there is no private key involved on which a timing attack could be performed. --- dkim/__init__.py | 53 +++++++----------------------------------------- dkim/crypto.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index 8d4a366..c24144e 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -9,6 +9,8 @@ from dkim.crypto import ( EMSA_PKCS1_v1_5_encode, parse_private_key, parse_public_key, + RSASSA_PKCS1_v1_5_sign, + RSASSA_PKCS1_v1_5_verify, ) __all__ = [ @@ -152,38 +154,10 @@ def validate_signature_fields(sig, debuglog=None): return False return True -def perform_rsa(input, exponent, modulus, modlen): - return int2str(pow(str2int(input), exponent, modulus), modlen) - # 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. @@ -316,9 +290,8 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple if debuglog is not None: print >>debuglog, "sign digest:", " ".join("%02x" % ord(x) for x in d) - modlen = len(int2str(pk['modulus'])) - encoded = EMSA_PKCS1_v1_5_encode(d, modlen, HASHID_SHA256) - sig2 = perform_rsa(encoded, pk['privateExponent'], pk['modulus'], modlen) + sig2 = RSASSA_PKCS1_v1_5_sign( + d, HASHID_SHA256, pk['privateExponent'], pk['modulus']) sig += base64.b64encode(''.join(sig2)) return sig + "\r\n" @@ -430,9 +403,6 @@ def verify(message, debuglog=None, dnsfunc=dnstxt): print >>debuglog, "invalid format in _domainkey txt record" return False pk = parse_public_key(base64.b64decode(pub['p'])) - modlen = len(int2str(pk['modulus'])) - if debuglog is not None: - print >>debuglog, "modlen:", modlen include_headers = re.split(r"\s*:\s*", sig['h']) h = hasher() @@ -441,18 +411,9 @@ def verify(message, debuglog=None, dnsfunc=dnstxt): d = h.digest() if debuglog is not None: print >>debuglog, "verify digest:", " ".join("%02x" % ord(x) for x in d) + signature = base64.b64decode(re.sub(r"\s+", "", sig['b'])) try: - sig2 = EMSA_PKCS1_v1_5_encode(d, modlen, hashid) + return RSASSA_PKCS1_v1_5_verify( + d, hashid, signature, pk['publicExponent'], pk['modulus']) except ParameterError: return False - 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']) - signature = base64.b64decode(re.sub(r"\s+", "", sig['b'])) - v = perform_rsa(signature, 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]] diff --git a/dkim/crypto.py b/dkim/crypto.py index 217318d..a43bef0 100644 --- a/dkim/crypto.py +++ b/dkim/crypto.py @@ -21,6 +21,8 @@ __all__ = [ 'EMSA_PKCS1_v1_5_encode', 'parse_private_key', 'parse_public_key', + 'RSASSA_PKCS1_v1_5_sign', + 'RSASSA_PKCS1_v1_5_verify', ] from dkim.asn1 import ( @@ -108,3 +110,51 @@ def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): raise Exception("Hash too large for modulus") # XXX: DKIMException return "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo + +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 ''.join(r) + + +def perform_rsa(input, exponent, modulus, modlen): + return int2str(pow(str2int(input), exponent, modulus), modlen) + + +def RSASSA_PKCS1_v1_5_sign(digest, hashid, private_exponent, modulus): + modlen = len(int2str(modulus)) + encoded_digest = EMSA_PKCS1_v1_5_encode(digest, modlen, hashid) + return perform_rsa(encoded_digest, private_exponent, modulus, modlen) + + +def RSASSA_PKCS1_v1_5_verify(digest, hashid, signature, public_exponent, + modulus): + modlen = len(int2str(modulus)) + encoded_digest = EMSA_PKCS1_v1_5_encode(digest, modlen, hashid) + signed_digest = perform_rsa(signature, public_exponent, modulus, modlen) + return encoded_digest == signed_digest From 7526317dfe384919e34f103e1ad8617f44df729c Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 10 Mar 2011 00:03:26 +1100 Subject: [PATCH 06/13] Unexport EMSA_PKCS1_v1_5_encode; it's only used by RSASSA_PKCS1_v1_5_*. --- dkim/__init__.py | 1 - dkim/crypto.py | 1 - 2 files changed, 2 deletions(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index c24144e..7f2fc57 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -6,7 +6,6 @@ import time import dns.resolver from dkim.crypto import ( - EMSA_PKCS1_v1_5_encode, parse_private_key, parse_public_key, RSASSA_PKCS1_v1_5_sign, diff --git a/dkim/crypto.py b/dkim/crypto.py index a43bef0..c2bba28 100644 --- a/dkim/crypto.py +++ b/dkim/crypto.py @@ -18,7 +18,6 @@ # Copyright (c) 2011 William Grant __all__ = [ - 'EMSA_PKCS1_v1_5_encode', 'parse_private_key', 'parse_public_key', 'RSASSA_PKCS1_v1_5_sign', From bbe60e0646915b336cf8e7763df3858f2a99acef Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 10 Mar 2011 00:05:42 +1100 Subject: [PATCH 07/13] int2str returns a string instead of a list, so the ''.join() is redundant. --- dkim/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index 7f2fc57..963aeea 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -291,7 +291,7 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple sig2 = RSASSA_PKCS1_v1_5_sign( d, HASHID_SHA256, pk['privateExponent'], pk['modulus']) - sig += base64.b64encode(''.join(sig2)) + sig += base64.b64encode(sig2) return sig + "\r\n" From cbe755269c4129880b9a452f910a62bef6fb04f0 Mon Sep 17 00:00:00 2001 From: William Grant Date: Thu, 10 Mar 2011 09:31:03 +1100 Subject: [PATCH 08/13] Restore erroneously removed license header. --- dkim/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/dkim/__init__.py b/dkim/__init__.py index 963aeea..4845793 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -1,3 +1,22 @@ +# 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 +# Copyright (c) 2011 William Grant + import base64 import hashlib import re From 7fa823748d82480e43a620b398f60db5b4e24e18 Mon Sep 17 00:00:00 2001 From: William Grant Date: Sat, 12 Mar 2011 15:37:25 +1100 Subject: [PATCH 09/13] crypto docstrings. --- dkim/crypto.py | 75 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/dkim/crypto.py b/dkim/crypto.py index 499ad2c..74b1d16 100644 --- a/dkim/crypto.py +++ b/dkim/crypto.py @@ -68,11 +68,16 @@ ASN1_RSAPrivateKey = [ ] +class DigestTooLarge(Exception): + pass + + def parse_public_key(data): """Parse an RSA public key. - @param data: A DER-encoded X.509 subjectPublicKeyInfo + @param data: DER-encoded X.509 subjectPublicKeyInfo containing an RFC3447 RSAPublicKey. + @return: RSA public key """ x = asn1_parse(ASN1_Object, data) # Not sure why the [1:] is necessary to skip a byte. @@ -85,6 +90,11 @@ def parse_public_key(data): def parse_private_key(data): + """Parse an RSA private key. + + @param data: DER-encoded RFC3447 RSAPrivateKey. + @return: RSA private key + """ pka = asn1_parse(ASN1_RSAPrivateKey, data) pk = { 'version': pka[0][0], @@ -100,14 +110,13 @@ def parse_private_key(data): return pk -def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): - """Encode a digest with EMSA-PKCS1-v1_5. +def EMSA_PKCS1_v1_5_encode(digest, mlen, hashid): + """Encode a digest with RFC3447 EMSA-PKCS1-v1_5. - Defined in RFC3447 section 9.2. - - @param digest: A digest value to encode. - @param modlen: The desired message length. - @param hashid: The ID of the hash used to generate the digest. + @param digest: digest byte string to encode + @param mlen: desired message length + @param hashid: ID of the hash used to generate the digest + @return: encoded digest byte string """ dinfo = asn1_build( (SEQUENCE, [ @@ -117,15 +126,16 @@ def EMSA_PKCS1_v1_5_encode(digest, modlen, hashid): ]), (OCTET_STRING, digest), ])) - if len(dinfo)+3 > modlen: + if len(dinfo)+3 > mlen: raise Exception("Hash too large for modulus") # XXX: DKIMException - return "\x00\x01"+"\xff"*(modlen-len(dinfo)-3)+"\x00"+dinfo + return "\x00\x01"+"\xff"*(mlen-len(dinfo)-3)+"\x00"+dinfo def str2int(s): - """Convert an octet string to an integer. + """Convert a byte string to an integer. - Octet string assumed to represent a positive integer. + @param s: byte string representing a positive integer to convert + @return: converted integer """ r = 0 for c in s: @@ -133,14 +143,14 @@ def str2int(s): return r -def int2str(n, length = -1): - """Convert an integer to an octet string. Number must be positive. +def int2str(n, length=-1): + """Convert an integer to a byte string. - @param n: Number to convert. - @param length: Minimum length, or -1 to return the smallest number of - bytes that represent the integer. + @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 = [] while length < 0 or len(r) < length: @@ -153,11 +163,27 @@ def int2str(n, length = -1): return ''.join(r) -def perform_rsa(input, exponent, modulus, modlen): - return int2str(pow(str2int(input), exponent, modulus), modlen) +def perform_rsa(message, exponent, modulus, mlen): + """Perform RSA signing or verification. + + @param message: byte string to operate on + @param exponent: public or private key exponent + @param modulus: key modulus + @param mlen: desired output length + @return: byte string result of the operation + """ + return int2str(pow(str2int(message), exponent, modulus), mlen) def RSASSA_PKCS1_v1_5_sign(digest, hashid, private_exponent, modulus): + """Sign a digest with RFC3447 RSASSA-PKCS1-v1_5. + + @param digest: digest byte string to sign + @param hashid: ID of the hash used to generate the digest + @param private_exponent: private key exponent + @param modulus: key modulus + @return: signed digest byte string + """ modlen = len(int2str(modulus)) encoded_digest = EMSA_PKCS1_v1_5_encode(digest, modlen, hashid) return perform_rsa(encoded_digest, private_exponent, modulus, modlen) @@ -165,6 +191,15 @@ def RSASSA_PKCS1_v1_5_sign(digest, hashid, private_exponent, modulus): def RSASSA_PKCS1_v1_5_verify(digest, hashid, signature, public_exponent, modulus): + """Verify a digest signed with RFC3447 RSASSA-PKCS1-v1_5. + + @param digest: digest byte string to check + @param hashid: ID of the hash used to generate the digest + @param signature: signed digest byte string + @param public_exponent: public key exponent + @param modulus: key modulus + @return: True if the signature is valid, False otherwise + """ modlen = len(int2str(modulus)) encoded_digest = EMSA_PKCS1_v1_5_encode(digest, modlen, hashid) signed_digest = perform_rsa(signature, public_exponent, modulus, modlen) From bd7a6e69331e9035a5547ddda1e64496eb9ebb54 Mon Sep 17 00:00:00 2001 From: William Grant Date: Sat, 12 Mar 2011 15:41:02 +1100 Subject: [PATCH 10/13] Slightly improved asn1 docstrings. --- dkim/asn1.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/dkim/asn1.py b/dkim/asn1.py index 20da2c8..305cc8c 100644 --- a/dkim/asn1.py +++ b/dkim/asn1.py @@ -41,11 +41,11 @@ class ASN1FormatError(Exception): 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. + """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 """ r = [] @@ -90,7 +90,11 @@ def asn1_parse(template, data): def asn1_length(n): - """Return a string representing a field length in ASN.1 format.""" + """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 chr(n) @@ -102,7 +106,11 @@ def asn1_length(n): def asn1_build(node): - """Build an ASN.1 data structure based on pairs of (type, data).""" + """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 chr(OCTET_STRING) + asn1_length(len(node[1])) + node[1] if node[0] == NULL: From bafc0d5ea634277cc91fac76398c2731ce992b0e Mon Sep 17 00:00:00 2001 From: William Grant Date: Sat, 12 Mar 2011 15:46:58 +1100 Subject: [PATCH 11/13] Raise a DigestTooLarge exception if it won't fit within the requested message length. --- dkim/crypto.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dkim/crypto.py b/dkim/crypto.py index 74b1d16..4506da4 100644 --- a/dkim/crypto.py +++ b/dkim/crypto.py @@ -69,6 +69,7 @@ ASN1_RSAPrivateKey = [ class DigestTooLarge(Exception): + """The digest is too large to fit within the requested length.""" pass @@ -127,7 +128,7 @@ def EMSA_PKCS1_v1_5_encode(digest, mlen, hashid): (OCTET_STRING, digest), ])) if len(dinfo)+3 > mlen: - raise Exception("Hash too large for modulus") # XXX: DKIMException + raise DigestTooLarge() return "\x00\x01"+"\xff"*(mlen-len(dinfo)-3)+"\x00"+dinfo From 440dd14de07f1c289c647585e394e9cc14bd79ff Mon Sep 17 00:00:00 2001 From: William Grant Date: Sat, 12 Mar 2011 17:19:59 +1100 Subject: [PATCH 12/13] Correct crypto exception handling. --- dkim/__init__.py | 26 +++++++++++++++++++++----- dkim/crypto.py | 26 ++++++++++++++++++++------ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/dkim/__init__.py b/dkim/__init__.py index 223d7cf..3ff305c 100644 --- a/dkim/__init__.py +++ b/dkim/__init__.py @@ -25,10 +25,12 @@ import time import dns.resolver from dkim.crypto import ( + DigestTooLargeError, parse_private_key, parse_public_key, RSASSA_PKCS1_v1_5_sign, RSASSA_PKCS1_v1_5_verify, + UnparsableKeyError, ) from dkim.util import ( InvalidTagValueList, @@ -273,7 +275,11 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple raise KeyFormatError(str(e)) if debuglog is not None: print >>debuglog, " ".join("%02x" % ord(x) for x in pkdata) - pk = parse_private_key(pkdata) + try: + pk = parse_private_key(pkdata) + except UnparsableKeyError, e: + raise KeyFormatError(str(e)) + if identity is not None and not identity.endswith(domain): raise ParameterError("identity must end with domain") @@ -321,8 +327,11 @@ def sign(message, selector, domain, privkey, identity=None, canonicalize=(Simple if debuglog is not None: print >>debuglog, "sign digest:", " ".join("%02x" % ord(x) for x in d) - sig2 = RSASSA_PKCS1_v1_5_sign( - d, HASHID_SHA256, pk['privateExponent'], pk['modulus']) + try: + sig2 = RSASSA_PKCS1_v1_5_sign( + d, HASHID_SHA256, pk['privateExponent'], pk['modulus']) + except DigestTooLargeError: + raise ParameterError("digest too large for modulus") sig += base64.b64encode(sig2) return sig + "\r\n" @@ -414,7 +423,12 @@ def verify(message, debuglog=None, dnsfunc=dnstxt): pub = parse_tag_value(s) except InvalidTagValueList: return False - pk = parse_public_key(base64.b64decode(pub['p'])) + try: + pk = parse_public_key(base64.b64decode(pub['p'])) + except UnparsableKeyError, e: + if debuglog is not None: + print >>debuglog, "could not parse public key: %s" % e + return False include_headers = re.split(r"\s*:\s*", sig['h']) h = hasher() @@ -427,5 +441,7 @@ def verify(message, debuglog=None, dnsfunc=dnstxt): try: return RSASSA_PKCS1_v1_5_verify( d, hashid, signature, pk['publicExponent'], pk['modulus']) - except ParameterError: + except DigestTooLargeError: + if debuglog is not None: + print >>debuglog, "digest too large for modulus" return False diff --git a/dkim/crypto.py b/dkim/crypto.py index 4506da4..29d734a 100644 --- a/dkim/crypto.py +++ b/dkim/crypto.py @@ -18,13 +18,16 @@ # Copyright (c) 2011 William Grant __all__ = [ + 'DigestTooLargeError', 'parse_private_key', 'parse_public_key', 'RSASSA_PKCS1_v1_5_sign', 'RSASSA_PKCS1_v1_5_verify', + 'UnparsableKeyError', ] from dkim.asn1 import ( + ASN1FormatError, asn1_build, asn1_parse, BIT_STRING, @@ -68,11 +71,16 @@ ASN1_RSAPrivateKey = [ ] -class DigestTooLarge(Exception): +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. @@ -80,9 +88,12 @@ def parse_public_key(data): containing an RFC3447 RSAPublicKey. @return: RSA public key """ - x = asn1_parse(ASN1_Object, data) - # Not sure why the [1:] is necessary to skip a byte. - pkd = asn1_parse(ASN1_RSAPublicKey, x[0][1][1:]) + 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, e: + raise UnparsableKeyError(str(e)) pk = { 'modulus': pkd[0][0], 'publicExponent': pkd[0][1], @@ -96,7 +107,10 @@ def parse_private_key(data): @param data: DER-encoded RFC3447 RSAPrivateKey. @return: RSA private key """ - pka = asn1_parse(ASN1_RSAPrivateKey, data) + try: + pka = asn1_parse(ASN1_RSAPrivateKey, data) + except ASN1FormatError, e: + raise UnparsableKeyError(str(e)) pk = { 'version': pka[0][0], 'modulus': pka[0][1], @@ -128,7 +142,7 @@ def EMSA_PKCS1_v1_5_encode(digest, mlen, hashid): (OCTET_STRING, digest), ])) if len(dinfo)+3 > mlen: - raise DigestTooLarge() + raise DigestTooLargeError() return "\x00\x01"+"\xff"*(mlen-len(dinfo)-3)+"\x00"+dinfo From f1720d15f3e8e6c79b17f5ee377a4e41a2d36cd6 Mon Sep 17 00:00:00 2001 From: William Grant Date: Sun, 13 Mar 2011 18:07:21 +1100 Subject: [PATCH 13/13] Fix up copyright notice. --- dkim/asn1.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dkim/asn1.py b/dkim/asn1.py index 305cc8c..7aa4c00 100644 --- a/dkim/asn1.py +++ b/dkim/asn1.py @@ -15,6 +15,7 @@ # 3. This notice may not be removed or altered from any source distribution. # # Copyright (c) 2008 Greg Hewgill http://hewgill.com +# Copyright (c) 2011 William Grant __all__ = [ 'asn1_build',