From 3d1c4405844a5e70dbe67a4e1511242c21bf70c6 Mon Sep 17 00:00:00 2001 From: Bob Mottram Date: Mon, 22 Feb 2021 18:20:33 +0000 Subject: [PATCH] Support for new style of http signatures --- httpsig.py | 84 +++++++++++++++++++++++++++++ tests.py | 152 +++++++++++++++++++++++++++++++++-------------------- 2 files changed, 180 insertions(+), 56 deletions(-) diff --git a/httpsig.py b/httpsig.py index d15d94654..bb2997f03 100644 --- a/httpsig.py +++ b/httpsig.py @@ -18,6 +18,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import utils as hazutils +import calendar import base64 from time import gmtime, strftime import datetime @@ -99,6 +100,89 @@ def signPostHeaders(dateStr: str, privateKeyPem: str, return signatureHeader +def signPostHeadersNew(dateStr: str, privateKeyPem: str, + nickname: str, + domain: str, port: int, + toDomain: str, toPort: int, + path: str, + httpPrefix: str, + messageBodyJsonStr: str, + algorithm: str) -> (str, str): + """Returns a raw signature strings that can be plugged into a header + as "Signature-Input" and "Signature" + used to verify the authenticity of an HTTP transmission. + See https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-01 + """ + domain = getFullDomain(domain, port) + + toDomain = getFullDomain(toDomain, toPort) + + timeFormat = "%a, %d %b %Y %H:%M:%S %Z" + if not dateStr: + currTime = gmtime() + secondsSinceEpoch = int(calendar.timegm(currTime)) + dateStr = strftime(timeFormat, currTime) + else: + currTime = datetime.datetime.strptime(dateStr, timeFormat) + secondsSinceEpoch = int(currTime.timestamp()) + keyID = httpPrefix + '://' + domain + '/users/' + nickname + '#main-key' + if not messageBodyJsonStr: + headers = { + '*request-target': f'post {path}', + '*created': str(secondsSinceEpoch), + 'host': toDomain, + 'date': dateStr, + 'content-type': 'application/json' + } + else: + bodyDigest = messageContentDigest(messageBodyJsonStr) + contentLength = len(messageBodyJsonStr) + headers = { + '*request-target': f'post {path}', + '*created': str(secondsSinceEpoch), + 'host': toDomain, + 'date': dateStr, + 'digest': f'SHA-256={bodyDigest}', + 'content-type': 'application/activity+json', + 'content-length': str(contentLength) + } + key = load_pem_private_key(privateKeyPem.encode('utf-8'), + None, backend=default_backend()) + # build a digest for signing + signedHeaderKeys = headers.keys() + signedHeaderText = '' + for headerKey in signedHeaderKeys: + signedHeaderText += f'{headerKey}: {headers[headerKey]}\n' + signedHeaderText = signedHeaderText.strip() + headerDigest = getSHA256(signedHeaderText.encode('ascii')) + + # Sign the digest. Potentially other signing algorithms can be added here. + signature = '' + if algorithm == 'rsa-sha256': + rawSignature = key.sign(headerDigest, + padding.PKCS1v15(), + hazutils.Prehashed(hashes.SHA256())) + signature = base64.b64encode(rawSignature).decode('ascii') + + sigKey = 'sig1' + # Put it into a valid HTTP signature format + signatureInputDict = { + 'keyId': keyID, + } + signatureIndexHeader = '; '.join( + [f'{k}="{v}"' for k, v in signatureInputDict.items()]) + signatureIndexHeader += '; alg=hs2019' + signatureIndexHeader += '; created=' + str(secondsSinceEpoch) + signatureIndexHeader += \ + '; ' + sigKey + '=(' + ', '.join(signedHeaderKeys) + ')' + signatureDict = { + sigKey: signature + } + signatureHeader = '; '.join( + [f'{k}=:{v}:' for k, v in signatureDict.items()]) + return signatureIndexHeader, signatureHeader + + def createSignedHeader(privateKeyPem: str, nickname: str, domain: str, port: int, toDomain: str, toPort: int, diff --git a/tests.py b/tests.py index 180dfc661..0256d0d95 100644 --- a/tests.py +++ b/tests.py @@ -13,6 +13,7 @@ import json from time import gmtime, strftime from pprint import pprint from httpsig import signPostHeaders +from httpsig import signPostHeadersNew from httpsig import verifyPostHeaders from httpsig import messageContentDigest from cache import storePersonInCache @@ -125,59 +126,59 @@ def testHttpSigNew(): 'PSXSfBDiUGhwOw76WuSSsf1D4b/vLoJ10wIDAQAB\n' + \ '-----END RSA PUBLIC KEY-----\n' - # privKey = \ - # '-----BEGIN RSA PRIVATE KEY-----\n' + \ - # 'MIIEqAIBAAKCAQEAhAKYdtoeoy8zcAcR8' + \ - # '74L8cnZxKzAGwd7v36APp7Pv6Q2jdsP\n' + \ - # 'BRrwWEBnez6d0UDKDwGbc6nxfEXAy5mbh' + \ - # 'gajzrw3MOEt8uA5txSKobBpKDeBLOsd\n' + \ - # 'JKFqMGmXCQvEG7YemcxDTRPxAleIAgYYR' + \ - # 'jTSd/QBwVW9OwNFhekro3RtlinV0a75\n' + \ - # 'jfZgkne/YiktSvLG34lw2zqXBDTC5NHRO' + \ - # 'UqGTlML4PlNZS5Ri2U4aCNx2rUPRcKI\n' + \ - # 'lE0PuKxI4T+HIaFpv8+rdV6eUgOrB2xeI' + \ - # '1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZ\n' + \ - # 'SFlQPSXSfBDiUGhwOw76WuSSsf1D4b/vL' + \ - # 'oJ10wIDAQABAoIBAG/JZuSWdoVHbi56\n' + \ - # 'vjgCgkjg3lkO1KrO3nrdm6nrgA9P9qaPj' + \ - # 'xuKoWaKO1cBQlE1pSWp/cKncYgD5WxE\n' + \ - # 'CpAnRUXG2pG4zdkzCYzAh1i+c34L6oZoH' + \ - # 'sirK6oNcEnHveydfzJL5934egm6p8DW\n' + \ - # '+m1RQ70yUt4uRc0YSor+q1LGJvGQHReF0' + \ - # 'WmJBZHrhz5e63Pq7lE0gIwuBqL8SMaA\n' + \ - # 'yRXtK+JGxZpImTq+NHvEWWCu09SCq0r83' + \ - # '8ceQI55SvzmTkwqtC+8AT2zFviMZkKR\n' + \ - # 'Qo6SPsrqItxZWRty2izawTF0Bf5S2VAx7' + \ - # 'O+6t3wBsQ1sLptoSgX3QblELY5asI0J\n' + \ - # 'YFz7LJECgYkAsqeUJmqXE3LP8tYoIjMIA' + \ - # 'KiTm9o6psPlc8CrLI9CH0UbuaA2JCOM\n' + \ - # 'cCNq8SyYbTqgnWlB9ZfcAm/cFpA8tYci9' + \ - # 'm5vYK8HNxQr+8FS3Qo8N9RJ8d0U5Csw\n' + \ - # 'DzMYfRghAfUGwmlWj5hp1pQzAuhwbOXFt' + \ - # 'xKHVsMPhz1IBtF9Y8jvgqgYHLbmyiu1\n' + \ - # 'mwJ5AL0pYF0G7x81prlARURwHo0Yf52kE' + \ - # 'w1dxpx+JXER7hQRWQki5/NsUEtv+8RT\n' + \ - # 'qn2m6qte5DXLyn83b1qRscSdnCCwKtKWU' + \ - # 'ug5q2ZbwVOCJCtmRwmnP131lWRYfj67\n' + \ - # 'B/xJ1ZA6X3GEf4sNReNAtaucPEelgR2ns' + \ - # 'N0gKQKBiGoqHWbK1qYvBxX2X3kbPDkv\n' + \ - # '9C+celgZd2PW7aGYLCHq7nPbmfDV0yHcW' + \ - # 'jOhXZ8jRMjmANVR/eLQ2EfsRLdW69bn\n' + \ - # 'f3ZD7JS1fwGnO3exGmHO3HZG+6AvberKY' + \ - # 'VYNHahNFEw5TsAcQWDLRpkGybBcxqZo\n' + \ - # '81YCqlqidwfeO5YtlO7etx1xLyqa2NsCe' + \ - # 'G9A86UjG+aeNnXEIDk1PDK+EuiThIUa\n' + \ - # '/2IxKzJKWl1BKr2d4xAfR0ZnEYuRrbeDQ' + \ - # 'YgTImOlfW6/GuYIxKYgEKCFHFqJATAG\n' + \ - # 'IxHrq1PDOiSwXd2GmVVYyEmhZnbcp8Cxa' + \ - # 'EMQoevxAta0ssMK3w6UsDtvUvYvF22m\n' + \ - # 'qQKBiD5GwESzsFPy3Ga0MvZpn3D6EJQLg' + \ - # 'snrtUPZx+z2Ep2x0xc5orneB5fGyF1P\n' + \ - # 'WtP+fG5Q6Dpdz3LRfm+KwBCWFKQjg7uTx' + \ - # 'cjerhBWEYPmEMKYwTJF5PBG9/ddvHLQ\n' + \ - # 'EQeNC8fHGg4UXU8mhHnSBt3EA10qQJfRD' + \ - # 's15M38eG2cYwB1PZpDHScDnDA0=\n' + \ - # '-----END RSA PRIVATE KEY-----' + privateKeyPem = \ + '-----BEGIN RSA PRIVATE KEY-----\n' + \ + 'MIIEqAIBAAKCAQEAhAKYdtoeoy8zcAcR8' + \ + '74L8cnZxKzAGwd7v36APp7Pv6Q2jdsP\n' + \ + 'BRrwWEBnez6d0UDKDwGbc6nxfEXAy5mbh' + \ + 'gajzrw3MOEt8uA5txSKobBpKDeBLOsd\n' + \ + 'JKFqMGmXCQvEG7YemcxDTRPxAleIAgYYR' + \ + 'jTSd/QBwVW9OwNFhekro3RtlinV0a75\n' + \ + 'jfZgkne/YiktSvLG34lw2zqXBDTC5NHRO' + \ + 'UqGTlML4PlNZS5Ri2U4aCNx2rUPRcKI\n' + \ + 'lE0PuKxI4T+HIaFpv8+rdV6eUgOrB2xeI' + \ + '1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZ\n' + \ + 'SFlQPSXSfBDiUGhwOw76WuSSsf1D4b/vL' + \ + 'oJ10wIDAQABAoIBAG/JZuSWdoVHbi56\n' + \ + 'vjgCgkjg3lkO1KrO3nrdm6nrgA9P9qaPj' + \ + 'xuKoWaKO1cBQlE1pSWp/cKncYgD5WxE\n' + \ + 'CpAnRUXG2pG4zdkzCYzAh1i+c34L6oZoH' + \ + 'sirK6oNcEnHveydfzJL5934egm6p8DW\n' + \ + '+m1RQ70yUt4uRc0YSor+q1LGJvGQHReF0' + \ + 'WmJBZHrhz5e63Pq7lE0gIwuBqL8SMaA\n' + \ + 'yRXtK+JGxZpImTq+NHvEWWCu09SCq0r83' + \ + '8ceQI55SvzmTkwqtC+8AT2zFviMZkKR\n' + \ + 'Qo6SPsrqItxZWRty2izawTF0Bf5S2VAx7' + \ + 'O+6t3wBsQ1sLptoSgX3QblELY5asI0J\n' + \ + 'YFz7LJECgYkAsqeUJmqXE3LP8tYoIjMIA' + \ + 'KiTm9o6psPlc8CrLI9CH0UbuaA2JCOM\n' + \ + 'cCNq8SyYbTqgnWlB9ZfcAm/cFpA8tYci9' + \ + 'm5vYK8HNxQr+8FS3Qo8N9RJ8d0U5Csw\n' + \ + 'DzMYfRghAfUGwmlWj5hp1pQzAuhwbOXFt' + \ + 'xKHVsMPhz1IBtF9Y8jvgqgYHLbmyiu1\n' + \ + 'mwJ5AL0pYF0G7x81prlARURwHo0Yf52kE' + \ + 'w1dxpx+JXER7hQRWQki5/NsUEtv+8RT\n' + \ + 'qn2m6qte5DXLyn83b1qRscSdnCCwKtKWU' + \ + 'ug5q2ZbwVOCJCtmRwmnP131lWRYfj67\n' + \ + 'B/xJ1ZA6X3GEf4sNReNAtaucPEelgR2ns' + \ + 'N0gKQKBiGoqHWbK1qYvBxX2X3kbPDkv\n' + \ + '9C+celgZd2PW7aGYLCHq7nPbmfDV0yHcW' + \ + 'jOhXZ8jRMjmANVR/eLQ2EfsRLdW69bn\n' + \ + 'f3ZD7JS1fwGnO3exGmHO3HZG+6AvberKY' + \ + 'VYNHahNFEw5TsAcQWDLRpkGybBcxqZo\n' + \ + '81YCqlqidwfeO5YtlO7etx1xLyqa2NsCe' + \ + 'G9A86UjG+aeNnXEIDk1PDK+EuiThIUa\n' + \ + '/2IxKzJKWl1BKr2d4xAfR0ZnEYuRrbeDQ' + \ + 'YgTImOlfW6/GuYIxKYgEKCFHFqJATAG\n' + \ + 'IxHrq1PDOiSwXd2GmVVYyEmhZnbcp8Cxa' + \ + 'EMQoevxAta0ssMK3w6UsDtvUvYvF22m\n' + \ + 'qQKBiD5GwESzsFPy3Ga0MvZpn3D6EJQLg' + \ + 'snrtUPZx+z2Ep2x0xc5orneB5fGyF1P\n' + \ + 'WtP+fG5Q6Dpdz3LRfm+KwBCWFKQjg7uTx' + \ + 'cjerhBWEYPmEMKYwTJF5PBG9/ddvHLQ\n' + \ + 'EQeNC8fHGg4UXU8mhHnSBt3EA10qQJfRD' + \ + 's15M38eG2cYwB1PZpDHScDnDA0=\n' + \ + '-----END RSA PRIVATE KEY-----' sigInput = \ 'sig1=(date); alg=rsa-sha256; keyId="test-key-b"' sig = \ @@ -199,7 +200,8 @@ def testHttpSigNew(): # 'CaB8X/I5/+HLZLGvDiezqi6/7p2Gngf5hwZ0lSdy39vyNMaaAT0tKo6nuVw0S1MVg' + \ # '1Q7MpWYZs0soHjttq0uLIA3DIbQfLiIvK6/l0BdWTU7+2uQj7lBkQAsFZHoA96ZZg' + \ # 'FquQrXRlmYOh+Hx5D9fJkXcXe5tmAg==:' - boxpath = '/foo' + nickname = 'foo' + boxpath = '/' + nickname # headers = { # "*request-target": "get " + boxpath, # "*created": "1402170695", @@ -214,11 +216,14 @@ def testHttpSigNew(): # "Signature-Input": sigInput, # "Signature": sig # } + dateStr = "Tue, 07 Jun 2014 20:51:35 GMT" + domain = "example.com" + port = 443 headers = { "*created": "1402170695", "*request-target": "post /foo?param=value&pet=dog", - "host": "example.com", - "date": "Tue, 07 Jun 2014 20:51:35 GMT", + "host": domain, + "date": dateStr, "content-type": "application/json", "digest": "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=", "content-length": "18", @@ -231,6 +236,41 @@ def testHttpSigNew(): boxpath, False, None, messageBodyJsonStr, debug, True) + # make a deliberate mistake + headers['Signature'] = headers['Signature'].replace('V', 'B') + assert not verifyPostHeaders(httpPrefix, publicKeyPem, headers, + boxpath, False, None, + messageBodyJsonStr, debug, + True) + # test signing + bodyDigest = messageContentDigest(messageBodyJsonStr) + contentLength = len(messageBodyJsonStr) + headers = { + "host": domain, + "date": dateStr, + "digest": f'SHA-256={bodyDigest}', + "content-type": "application/json", + "content-length": str(contentLength) + } + signatureIndexHeader, signatureHeader = \ + signPostHeadersNew(dateStr, privateKeyPem, nickname, + domain, port, + domain, port, + boxpath, httpPrefix, messageBodyJsonStr, + 'rsa-sha256') + assert signatureIndexHeader == \ + 'keyId="https://example.com/users/foo#main-key"; ' + \ + 'alg=hs2019; created=1402170695; ' + \ + 'sig1=(*request-target, *created, host, date, ' + \ + 'digest, content-type, content-length)' + assert signatureHeader == \ + 'sig1=:LQU1PcJILSp1Q30GWINusfftYYKfTtam7InSu2c+ZzfGC' + \ + 'bTSevRgifZFuG2asFi8ubG/uUVHiBwIxxIz1u/JyWC3lYIFgjQF' + \ + 'RFM6As2b/ytnMA0LQhNebvk05iUNsz5izSoNTp5h9J7+roWkl6l' + \ + '8d5EA7vPMTQTJZnyU1cXBlvP1MtuVAKR6MbB3Aa/iZ4XOeaNK5E' + \ + '1VuPfNFrdnizIELE3nGVoVqNNImgMY3DWhtF3vvezrcT0J2vNGZ' + \ + 'cvhBfgn/xeAsNxz67SIHMgiXvLL6TFqEI1en9dl9A3ihB6ZO6+W' + \ + 'gUoW7OobZNlPxAUkQCc2A6oVjCYOdpKdrMAXQp2TQQ==:' def _testHttpsigBase(withDigest):