From b4bdbeae4399404476f62e6c1b67835b144374d2 Mon Sep 17 00:00:00 2001
From: Thomas NOEL <tnoel@entrouvert.com>
Date: Thu, 2 Apr 2020 10:44:00 +0200
Subject: [PATCH] signature: do not require nonce if not verified (#41245)

+ some fixes, sync with http://git.entrouvert.org/hobo.git/tree/hobo/signature.py
---
 passerelle/base/signature.py | 38 +++++++++++-----------
 tests/test_signature.py      | 62 ++++++++++++++++++++++++++++++++++++
 2 files changed, 82 insertions(+), 18 deletions(-)
 create mode 100644 tests/test_signature.py

diff --git a/passerelle/base/signature.py b/passerelle/base/signature.py
index 8d249fcd..d879623e 100644
--- a/passerelle/base/signature.py
+++ b/passerelle/base/signature.py
@@ -6,16 +6,20 @@ import random
 
 from django.utils import six
 from django.utils.encoding import smart_bytes
+from django.utils.http import quote, urlencode
 from django.utils.six.moves.urllib import parse as urlparse
 
+
 '''Simple signature scheme for query strings'''
-# from http://repos.entrouvert.org/portail-citoyen.git/tree/portail_citoyen/apps/data_source_plugin/signature.py
+# from http://git.entrouvert.org/hobo.git/tree/hobo/signature.py
+
 
 def sign_url(url, key, algo='sha256', timestamp=None, nonce=None):
     parsed = urlparse.urlparse(url)
     new_query = sign_query(parsed.query, key, algo, timestamp, nonce)
     return urlparse.urlunparse(parsed[:4] + (new_query,) + parsed[5:])
 
+
 def sign_query(query, key, algo='sha256', timestamp=None, nonce=None):
     if timestamp is None:
         timestamp = datetime.datetime.utcnow()
@@ -25,30 +29,37 @@ def sign_query(query, key, algo='sha256', timestamp=None, nonce=None):
     new_query = query
     if new_query:
         new_query += '&'
-    new_query += urlparse.urlencode((
+    new_query += urlencode((
         ('algo', algo),
-        ('timestamp', timestamp),
-        ('nonce', nonce)))
+        ('timestamp', timestamp)))
+    if nonce:  # we don't add nonce if it's an empty string
+        new_query += '&nonce=' + quote(nonce)
     signature = base64.b64encode(sign_string(new_query, key, algo=algo))
-    new_query += '&signature=' + urlparse.quote(signature)
+    new_query += '&signature=' + quote(signature)
     return new_query
 
-def sign_string(s, key, algo='sha256', timedelta=30):
+
+def sign_string(s, key, algo='sha256'):
     digestmod = getattr(hashlib, algo)
     if isinstance(key, six.text_type):
         key = key.encode('utf-8')
     hash = hmac.HMAC(smart_bytes(key), digestmod=digestmod, msg=smart_bytes(s))
     return hash.digest()
 
+
 def check_url(url, key, known_nonce=None, timedelta=30):
     parsed = urlparse.urlparse(url, 'https')
-    return check_query(parsed.query, key)
+    return check_query(parsed.query, key, known_nonce=known_nonce, timedelta=timedelta)
+
 
 def check_query(query, key, known_nonce=None, timedelta=30):
     parsed = urlparse.parse_qs(query)
     if not ('signature' in parsed and 'algo' in parsed and
-            'timestamp' in parsed and 'nonce' in parsed):
+            'timestamp' in parsed):
         return False
+    if known_nonce is not None:
+        if ('nonce' not in parsed) or known_nonce(parsed['nonce'][0]):
+            return False
     unsigned_query, signature_content = query.split('&signature=', 1)
     if '&' in signature_content:
         return False  # signature must be the last parameter
@@ -56,13 +67,11 @@ def check_query(query, key, known_nonce=None, timedelta=30):
     algo = parsed['algo'][0]
     timestamp = parsed['timestamp'][0]
     timestamp = datetime.datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
-    nonce = parsed['nonce']
-    if known_nonce is not None and known_nonce(nonce):
-        return False
     if abs(datetime.datetime.utcnow() - timestamp) > datetime.timedelta(seconds=timedelta):
         return False
     return check_string(unsigned_query, signature, key, algo=algo)
 
+
 def check_string(s, signature, key, algo='sha256'):
     # constant time compare
     signature2 = sign_string(s, key, algo=algo)
@@ -76,10 +85,3 @@ def check_string(s, signature, key, algo='sha256'):
         for a, b in zip(signature, signature2):
             res |= ord(a) ^ ord(b)
     return res == 0
-
-
-if __name__ == '__main__':
-    key = '12345'
-    signed_query = sign_query('NameId=_12345&orig=montpellier', key)
-    assert check_query(signed_query, key, timedelta=0) is False
-    assert check_query(signed_query, key) is True
diff --git a/tests/test_signature.py b/tests/test_signature.py
new file mode 100644
index 00000000..cbd7a447
--- /dev/null
+++ b/tests/test_signature.py
@@ -0,0 +1,62 @@
+import datetime
+
+from django.utils.six.moves.urllib import parse as urllib
+
+from passerelle.base import signature
+
+
+def test_signature():
+    KEY = 'xyz'
+    STRING = 'aaa'
+    URL = 'http://example.net/api/?coucou=zob'
+    QUERY = 'coucou=zob'
+    OTHER_KEY = 'abc'
+
+    # Passing test
+    assert signature.check_string(STRING, signature.sign_string(STRING, KEY), KEY)
+    assert signature.check_query(signature.sign_query(QUERY, KEY), KEY)
+    assert signature.check_url(signature.sign_url(URL, KEY), KEY)
+
+    # Not passing tests
+    assert not signature.check_string(STRING, signature.sign_string(STRING, KEY), OTHER_KEY)
+    assert not signature.check_query(signature.sign_query(QUERY, KEY), OTHER_KEY)
+    assert not signature.check_url(signature.sign_url(URL, KEY), OTHER_KEY)
+    assert not signature.check_url('%s&foo=bar' % signature.sign_url(URL, KEY), KEY)
+
+    # Test URL is preserved
+    assert URL in signature.sign_url(URL, KEY)
+    assert QUERY in signature.sign_query(QUERY, KEY)
+
+    # Test signed URL expected parameters
+    assert '&algo=sha256&' in signature.sign_url(URL, KEY)
+    assert '&timestamp=' in signature.sign_url(URL, KEY)
+    assert '&nonce=' in signature.sign_url(URL, KEY)
+
+    # Test unicode key conversion to UTF-8
+    assert signature.check_url(signature.sign_url(URL, u'\xe9\xe9'), b'\xc3\xa9\xc3\xa9')
+    assert signature.check_url(signature.sign_url(URL, b'\xc3\xa9\xc3\xa9'), u'\xe9\xe9')
+
+    # Test timedelta parameter
+    now = datetime.datetime.utcnow()
+    assert '&timestamp=%s' % urllib.quote(now.strftime('%Y-%m-%dT%H:%M:%SZ')) in \
+        signature.sign_url(URL, KEY, timestamp=now)
+
+    # Test nonce parameter
+    assert '&nonce=uuu&' in signature.sign_url(URL, KEY, nonce='uuu')
+    assert '&nonce=' in signature.sign_url(URL, KEY)
+    assert '&nonce=' not in signature.sign_url(URL, KEY, nonce='')
+
+    # Test known_nonce
+    def known_nonce(nonce):
+        return nonce == 'xxx'
+    assert signature.check_url(signature.sign_url(URL, KEY), KEY, known_nonce=known_nonce)
+    assert signature.check_url(signature.sign_url(URL, KEY, nonce='zzz'), KEY, known_nonce=known_nonce)
+    assert not signature.check_url(signature.sign_url(URL, KEY, nonce='xxx'), KEY, known_nonce=known_nonce)
+    assert not signature.check_url(signature.sign_url(URL, KEY, nonce=''), KEY, known_nonce=known_nonce)
+
+    # Test timedelta
+    now = (datetime.datetime.utcnow() - datetime.timedelta(seconds=20))
+    assert signature.check_url(signature.sign_url(URL, KEY, timestamp=now), KEY)
+    assert not signature.check_url(signature.sign_url(URL, KEY, timestamp=now), KEY, timedelta=10)
+    now = (datetime.datetime.utcnow() - datetime.timedelta(seconds=40))
+    assert not signature.check_url(signature.sign_url(URL, KEY, timestamp=now), KEY)
-- 
2.25.1

