From 303b0782831993fd42e7e32b3e4725af34b15876 Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Wed, 21 Aug 2024 14:18:23 +1000 Subject: [PATCH 1/7] Add build/ and .venv to .gitignore --- .gitignore | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index cc49f62..354aa33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ *.swp .idea/* *__pycache__* -dist/* -*egg-info/* + +# Distribution / packaging +build/ +dist/ +*egg-info/ + +# Environments +.venv From f53dfa3977baebd5587feb5a8bdd03bebd01c73a Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Wed, 21 Aug 2024 14:18:44 +1000 Subject: [PATCH 2/7] Skip integration tests if env variables aren't set --- tests/test_identity_map_client.py | 6 ++++++ tests/test_publisher_client.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tests/test_identity_map_client.py b/tests/test_identity_map_client.py index f59b77d..9cdd850 100644 --- a/tests/test_identity_map_client.py +++ b/tests/test_identity_map_client.py @@ -7,6 +7,12 @@ from uid2_client import IdentityMapClient, IdentityMapInput, normalize_and_hash_email, normalize_and_hash_phone +@unittest.skipIf( + os.getenv("UID2_BASE_URL") == None + or os.getenv("UID2_API_KEY") == None + or os.getenv("UID2_SECRET_KEY") == None, + reason="Environment variables UID2_BASE_URL, UID2_API_KEY, and UID2_SECRET_KEY must be set", +) class IdentityMapIntegrationTests(unittest.TestCase): UID2_BASE_URL = None UID2_API_KEY = None diff --git a/tests/test_publisher_client.py b/tests/test_publisher_client.py index 090e1d0..6b06f1b 100644 --- a/tests/test_publisher_client.py +++ b/tests/test_publisher_client.py @@ -8,6 +8,12 @@ from urllib.request import HTTPError +@unittest.skipIf( + os.getenv("EUID_BASE_URL") == None + or os.getenv("EUID_API_KEY") == None + or os.getenv("EUID_SECRET_KEY") == None, + reason="Environment variables EUID_BASE_URL, EUID_API_KEY, and EUID_SECRET_KEY must be set", +) class PublisherEuidIntegrationTests(unittest.TestCase): EUID_SECRET_KEY = None @@ -61,6 +67,12 @@ def test_integration_optout_generate_token(self): self.assertFalse(token_generate_response.is_success()) self.assertIsNone(token_generate_response.get_identity()) +@unittest.skipIf( + os.getenv("UID2_BASE_URL") == None + or os.getenv("UID2_API_KEY") == None + or os.getenv("UID2_SECRET_KEY") == None, + reason="Environment variables UID2_BASE_URL, UID2_API_KEY, and UID2_SECRET_KEY must be set", +) class PublisherUid2IntegrationTests(unittest.TestCase): UID2_SECRET_KEY = None From 81d24f8b76881c0abe237f05481fe7a3aeece11c Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Wed, 21 Aug 2024 14:28:18 +1000 Subject: [PATCH 3/7] Use rstrip to remove trailing =s --- uid2_client/uid2_base64_url_coder.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/uid2_client/uid2_base64_url_coder.py b/uid2_client/uid2_base64_url_coder.py index 5c1aea3..7b2f43e 100644 --- a/uid2_client/uid2_base64_url_coder.py +++ b/uid2_client/uid2_base64_url_coder.py @@ -11,14 +11,7 @@ def encode(input): encoded_token = base64.urlsafe_b64encode(input).decode('ascii') # urlsafe_b64encode doesn't remove the '=' padding per the spec so we should remove it # as '=' is a reserved char in URL spec - count = 0 - for i in range(3): - if encoded_token[len(encoded_token) - 1 - i] == '=': - count = count + 1 - # encoded_token[:-0] will empty the whole string! - if count > 0: - return encoded_token[:-count] - return encoded_token + return encoded_token.rstrip('=') @staticmethod def decode(token): From 17cfe7faacb364d385eb7ca2a228370ae7b0f54d Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Thu, 22 Aug 2024 13:08:41 +1000 Subject: [PATCH 4/7] Fix flaky test --- tests/test_sharing_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sharing_client.py b/tests/test_sharing_client.py index 0c5f48f..9b19941 100644 --- a/tests/test_sharing_client.py +++ b/tests/test_sharing_client.py @@ -283,7 +283,7 @@ def test_expiry_in_token_matches_expiry_in_response(self): # ExpiryInTokenMatch key_sharing_response_json([master_key, site_key], identity_scope=IdentityScope.UID2, default_keyset_id=99999, token_expiry_seconds=2)) - encryption_data_response = self._client.encrypt_raw_uid_into_token(example_uid) + encryption_data_response = self._client._encrypt_raw_uid_into_token(example_uid, now=now) self.assertEqual(encryption_data_response.status, EncryptionStatus.SUCCESS) result = self._client._decrypt_token_into_raw_uid(encryption_data_response.encrypted_data, now + dt.timedelta(seconds=1)) From 9db5ef4fe6b16c74d8dfd2b28b29302e98512d7c Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Thu, 22 Aug 2024 13:12:21 +1000 Subject: [PATCH 5/7] Remove semicolon --- uid2_client/uid2_base64_url_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uid2_client/uid2_base64_url_coder.py b/uid2_client/uid2_base64_url_coder.py index 7b2f43e..658c976 100644 --- a/uid2_client/uid2_base64_url_coder.py +++ b/uid2_client/uid2_base64_url_coder.py @@ -15,7 +15,7 @@ def encode(input): @staticmethod def decode(token): - input_size_mod4 = len(token) % 4; + input_size_mod4 = len(token) % 4 if input_size_mod4 > 0: padding_needed = 4 - input_size_mod4 padding = "" From a2455fb90ef7a52d552ff614172e869bfe178362 Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Thu, 22 Aug 2024 13:39:00 +1000 Subject: [PATCH 6/7] Fix test imports --- tests/test_sharing_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_sharing_client.py b/tests/test_sharing_client.py index 9b19941..38d42ab 100644 --- a/tests/test_sharing_client.py +++ b/tests/test_sharing_client.py @@ -3,8 +3,8 @@ from unittest.mock import patch from test_utils import * -from tests.test_bidstream_client import TestBidStreamClient -from tests.test_encryption import TestEncryptionFunctions +from test_bidstream_client import TestBidStreamClient +from test_encryption import TestEncryptionFunctions from uid2_client import SharingClient, DecryptionStatus, Uid2ClientFactory from uid2_client.encryption_status import EncryptionStatus from uid2_client.refresh_response import RefreshResponse From cfd0830286efda9eb5b3872f86e72afc20ae4c38 Mon Sep 17 00:00:00 2001 From: Matt Collins Date: Thu, 22 Aug 2024 15:15:04 +1000 Subject: [PATCH 7/7] Accept both base64 and base64url tokens in the bidstream This behaviour matches that of the .NET SDK. --- tests/test_bidstream_client.py | 14 ++++++++++++++ tests/test_utils.py | 19 +++++++++---------- uid2_client/encryption.py | 18 +++++++++++++++--- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/tests/test_bidstream_client.py b/tests/test_bidstream_client.py index 110c984..463fa8e 100644 --- a/tests/test_bidstream_client.py +++ b/tests/test_bidstream_client.py @@ -280,5 +280,19 @@ def test_refresh_keys(self, mock_refresh_bidstream_keys): client_secret_bytes) + def test_decrypt_v4_token_encoded_as_base64(self): + for scope in IdentityScope: + with self.subTest(scope=scope): + self.refresh(key_bidstream_response_json_default_keys(identity_scope=scope)) + + while True: + token = generate_uid_token(scope, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4) + token = base64.b64encode(Uid2Base64UrlCoder.decode(token)).decode('utf-8') + if ("=" in token) and ("/" in token) and ("+" in token): + break + + self._decrypt_and_assert_success(token, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4, scope) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py index 528953a..afa760b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -39,19 +39,18 @@ phone_uid = "BEOGxroPLdcY7LrSiwjY52+X05V0ryELpJmoWAyXiwbZ" test_cases_all_scopes_all_versions = [ - [IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V2], - [IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3], - [IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4], - [IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V2], - [IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3], - [IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4] + [scope, version] + for scope in IdentityScope + for version in AdvertisingTokenVersion ] test_cases_all_scopes_v3_v4_versions = [ - [IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3], - [IdentityScope.UID2, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4], - [IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3], - [IdentityScope.EUID, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4] + [scope, version] + for scope in IdentityScope + for version in [ + AdvertisingTokenVersion.ADVERTISING_TOKEN_V3, + AdvertisingTokenVersion.ADVERTISING_TOKEN_V4, + ] ] YESTERDAY = now + dt.timedelta(days=-1) diff --git a/uid2_client/encryption.py b/uid2_client/encryption.py index 36e5485..df82179 100644 --- a/uid2_client/encryption.py +++ b/uid2_client/encryption.py @@ -105,12 +105,25 @@ def _decrypt_token(token, keys, domain_name, client_type, now): elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V3.value: return _decrypt_token_v3(base64.b64decode(token), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V3) elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V4.value: - # same as V3 but use Base64URL encoding - return _decrypt_token_v3(Uid2Base64UrlCoder.decode(token), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4) + # Accept either base64 or base64url encoding. + return _decrypt_token_v3(base64.b64decode(_base64url_to_base64(token)), keys, domain_name, client_type, now, AdvertisingTokenVersion.ADVERTISING_TOKEN_V4) else: return DecryptedToken.make_error(DecryptionStatus.VERSION_NOT_SUPPORTED) +def _base64url_to_base64(value): + value = value.replace('-', '+').replace('_', '/') + input_size_mod4 = len(value) % 4 + if input_size_mod4 == 0: + return value + elif input_size_mod4 == 2: + return value + '==' + elif input_size_mod4 == 3: + return value + '=' + else: + raise EncryptionError('invalid payload') + + def _token_has_valid_lifetime(keys, client_type, generated_or_now, expires, now): # generated_or_now allows "now" for token v2, since v2 does not contain a "token generated" field. # v2 therefore checks against remaining lifetime rather than total lifetime @@ -294,7 +307,6 @@ def encrypt(uid2, identity_scope, keys, keyset_id=None, **kwargs): return EncryptionDataResponse.make_error(EncryptionStatus.ENCRYPTION_FAILURE) - # DEPRECATED, DO NOT CALL def encrypt_data(data, identity_scope, **kwargs): """Encrypt arbitrary binary data.