Skip to content

Commit b583777

Browse files
authored
Created ad token v4 that uses Base64URLEncoding (#5)
* Created ad token v4 that uses Base64URLEncoding * Changed ADVERTISING_TOKEN_V4 enum to 128 for readability in Base64 * removed use of datetime.utcnow/datetime.utcfromtimestamp as it's problematic when not run in utc timzone see https://blog.ganssle.io/articles/2019/11/utcnow.html * further refactoring to make it more similar to other language sdk's class structure * added cross platform unit tests and further refactoring to make it more similar to other language sdk's class structure * created uid2_client/identity_scope.py uid2_client/identity_type.py
1 parent 22bf506 commit b583777

File tree

11 files changed

+396
-142
lines changed

11 files changed

+396
-142
lines changed

tests/test_encryption.py

Lines changed: 200 additions & 115 deletions
Large diffs are not rendered by default.

tests/test_keys.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime as dt
2+
from datetime import timezone
23
import unittest
34

45
from uid2_client.keys import *
@@ -22,19 +23,19 @@ def test_key_is_active(self):
2223

2324
def test_empty_keys_collection(self):
2425
keys = EncryptionKeysCollection([])
25-
self.assertFalse(keys.valid(dt.datetime.utcnow()))
26+
self.assertFalse(keys.valid(dt.datetime.now(tz=timezone.utc)))
2627
self.assertEqual(len(keys), 0)
2728
self.assertEqual(len(keys.key_ids()), 0)
2829
self.assertEqual(len(keys.values()), 0)
2930
self.assertNotIn(123, keys)
3031
self.assertIsNone(keys.get(123))
3132
with self.assertRaises(KeyError):
3233
keys[123]
33-
self.assertIsNone(keys.get_active_site_key(22, dt.datetime.utcnow()))
34+
self.assertIsNone(keys.get_active_site_key(22, dt.datetime.now(tz=timezone.utc)))
3435

3536

3637
def test_multiple_keys_in_collection(self):
37-
now = dt.datetime.utcnow()
38+
now = dt.datetime.now(tz=timezone.utc)
3839
keys = EncryptionKeysCollection([
3940
EncryptionKey(123, 201, now - dt.timedelta(days=5), now - dt.timedelta(days=4), now - dt.timedelta(days=3), b'123456'),
4041
EncryptionKey(124, 202, now - dt.timedelta(days=1), now, now + dt.timedelta(days=1), b'234567')])
@@ -59,7 +60,7 @@ def test_multiple_keys_in_collection(self):
5960

6061

6162
def test_site_keys(self):
62-
now = dt.datetime.utcnow()
63+
now = dt.datetime.now(tz=timezone.utc)
6364
keys = EncryptionKeysCollection([
6465
EncryptionKey(122, 200, now, now - dt.timedelta(days=1), now + dt.timedelta(days=3), b'000000'),
6566
EncryptionKey(123, 201, now, now - dt.timedelta(days=5), now + dt.timedelta(days=3), b'111111'),
@@ -89,7 +90,7 @@ def test_site_keys(self):
8990

9091

9192
def test_non_site_keys(self):
92-
now = dt.datetime.utcnow()
93+
now = dt.datetime.now(tz=timezone.utc)
9394
keys = EncryptionKeysCollection([
9495
EncryptionKey(122, -1, now, now - dt.timedelta(days=9), now + dt.timedelta(days=3), b'000000'),
9596
EncryptionKey(123, -1, now, now - dt.timedelta(days=5), now + dt.timedelta(days=3), b'111111'),
@@ -103,7 +104,7 @@ def test_non_site_keys(self):
103104

104105

105106
def test_all_keys_in_collection_expired(self):
106-
now = dt.datetime.utcnow()
107+
now = dt.datetime.now(tz=timezone.utc)
107108
keys = EncryptionKeysCollection([
108109
EncryptionKey(123, 201, now, now - dt.timedelta(days=5), now - dt.timedelta(days=3), b'123456'),
109110
EncryptionKey(124, 202, now, now - dt.timedelta(days=1), now - dt.timedelta(days=1), b'234567')])

tests/uid2_token_generator.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import base64
2+
3+
from datetime import timezone
4+
import os
5+
from uid2_client import encryption_block_size
6+
from uid2_client.advertising_token_version import AdvertisingTokenVersion
7+
from uid2_client.encryption import _encrypt_data_v1, _encrypt_gcm, _PayloadType
8+
from uid2_client.identity_scope import IdentityScope
9+
from uid2_client.identity_type import IdentityType
10+
from uid2_client.keys import *
11+
from uid2_client.uid2_base64_url_coder import Uid2Base64UrlCoder
12+
13+
14+
class Params:
15+
def __init__(self, expiry=dt.datetime.now(tz=timezone.utc) + dt.timedelta(hours=1),
16+
identity_scope=IdentityScope.UID2.value, identity_type=IdentityType.Email.value):
17+
self.identity_scope = identity_scope
18+
self.identity_type = identity_type
19+
self.token_expiry = expiry
20+
if not isinstance(expiry, dt.datetime):
21+
self.token_expiry = dt.datetime.now(tz=timezone.utc) + expiry
22+
23+
24+
def default_params():
25+
return Params()
26+
27+
28+
class UID2TokenGenerator:
29+
@staticmethod
30+
def generate_uid2_token_v2(id_str, master_key, site_id, site_key, params = default_params(), version=2):
31+
id = bytes(id_str, 'utf-8')
32+
identity = int.to_bytes(site_id, 4, 'big')
33+
identity += int.to_bytes(len(id), 4, 'big')
34+
identity += id
35+
# old privacy_bits
36+
identity += int.to_bytes(0, 4, 'big')
37+
identity += int.to_bytes(int((dt.datetime.now(tz=timezone.utc) - dt.timedelta(hours=1)).timestamp()) * 1000, 8,
38+
'big')
39+
identity_iv = bytes([10, 11, 12, 13, 14, 15, 16, 1, 2, 3, 4, 5, 6, 7, 8, 9])
40+
expiry = params.token_expiry
41+
master_payload = int.to_bytes(int(expiry.timestamp()) * 1000, 8, 'big')
42+
master_payload += _encrypt_data_v1(identity, key=site_key, iv=identity_iv)
43+
master_iv = bytes([21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36])
44+
45+
token = int.to_bytes(version, 1, 'big')
46+
token += _encrypt_data_v1(master_payload, key=master_key, iv=master_iv)
47+
48+
return base64.b64encode(token).decode('ascii')
49+
50+
@staticmethod
51+
def generate_uid2_token_v3(id_str, master_key, site_id, site_key, params = default_params()):
52+
return UID2TokenGenerator.generate_uid2_token_with_debug_info(id_str, master_key, site_id, site_key, params,
53+
AdvertisingTokenVersion.ADVERTISING_TOKEN_V3.value)
54+
55+
@staticmethod
56+
def generate_uid2_token_v4(id_str, master_key, site_id, site_key, params = default_params()):
57+
return UID2TokenGenerator.generate_uid2_token_with_debug_info(id_str, master_key, site_id, site_key, params,
58+
AdvertisingTokenVersion.ADVERTISING_TOKEN_V4.value)
59+
60+
@staticmethod
61+
def generate_uid2_token_with_debug_info(id_str, master_key, site_id, site_key, params, version):
62+
id = base64.b64decode(id_str)
63+
64+
site_payload = int.to_bytes(site_id, 4, 'big')
65+
site_payload += int.to_bytes(0, 8, 'big') # publisher id
66+
site_payload += int.to_bytes(0, 4, 'big') # client key id
67+
68+
site_payload += int.to_bytes(0, 4, 'big') # privacy bits
69+
site_payload += int.to_bytes(int((dt.datetime.now(tz=timezone.utc) - dt.timedelta(hours=1)).timestamp()) * 1000,
70+
8, 'big') # established
71+
site_payload += int.to_bytes(int((dt.datetime.now(tz=timezone.utc) - dt.timedelta(hours=1)).timestamp()) * 1000,
72+
8, 'big') # refreshed
73+
site_payload += id
74+
75+
expiry = params.token_expiry
76+
master_payload = int.to_bytes(int(expiry.timestamp()) * 1000, 8, 'big')
77+
master_payload += int.to_bytes(int((dt.datetime.now(tz=timezone.utc)).timestamp()) * 1000, 8, 'big') # created
78+
79+
master_payload += int.to_bytes(0, 4, 'big') # operator site id
80+
master_payload += int.to_bytes(0, 1, 'big') # operator type
81+
master_payload += int.to_bytes(0, 4, 'big') # operator version
82+
master_payload += int.to_bytes(0, 4, 'big') # operator key id
83+
84+
master_payload += int.to_bytes(site_key.key_id, 4, 'big')
85+
master_payload += _encrypt_gcm(site_payload, None, site_key.secret)
86+
87+
token = int.to_bytes((params.identity_scope << 4 | params.identity_type << 2), 1, 'big')
88+
token += int.to_bytes(version, 1, 'big')
89+
token += int.to_bytes(master_key.key_id, 4, 'big')
90+
token += _encrypt_gcm(master_payload, None, master_key.secret)
91+
92+
if version == AdvertisingTokenVersion.ADVERTISING_TOKEN_V4.value:
93+
return Uid2Base64UrlCoder.encode(token)
94+
else:
95+
return base64.b64encode(token).decode('ascii')
96+
97+
@staticmethod
98+
def encrypt_data_v2(data, key, site_id, now):
99+
iv = os.urandom(encryption_block_size)
100+
result = int.to_bytes(_PayloadType.ENCRYPTED_DATA.value, 1, 'big')
101+
result += int.to_bytes(1, 1, 'big') # version
102+
result += int.to_bytes(int(now.timestamp() * 1000), 8, 'big')
103+
result += int.to_bytes(site_id, 4, 'big')
104+
result += _encrypt_data_v1(data, key, iv)
105+
return base64.b64encode(result).decode('ascii')

uid2_client/__init__.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,4 @@
1313
from .encryption import *
1414
from .keys import *
1515

16-
from enum import Enum
1716

18-
19-
class IdentityScope(Enum):
20-
"""Enum for types of unified ID"""
21-
UID2 = 0
22-
EUID = 1
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
class AdvertisingTokenVersion(Enum):
4+
# showing as "AHA..." in the Base64 Encoding (Base64 'H' is 000111 and 112 is 01110000)
5+
ADVERTISING_TOKEN_V3 = 112
6+
# showing as "AIA..." in the Base64URL Encoding ('H' is followed by 'I' hence
7+
# this choice for the next token version) (Base64 'I' is 001000 and 128 is 10000000)
8+
ADVERTISING_TOKEN_V4 = 128
9+

uid2_client/auto_refresh.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77

88
import datetime as dt
9+
from datetime import timezone
910
import sys
1011
import threading
1112

@@ -131,7 +132,7 @@ def _make_error_result(self, err):
131132

132133

133134
def _make_success_result(self, keys):
134-
return EncryptionKeysAutoRefreshResult(keys, None, dt.datetime.utcnow())
135+
return EncryptionKeysAutoRefreshResult(keys, None, dt.datetime.now(tz=timezone.utc))
135136

136137

137138
def __enter__(self):

uid2_client/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import base64
1010
import datetime as dt
11+
from datetime import timezone
1112
import json
1213
import os
1314
import urllib.request as request
@@ -17,7 +18,7 @@
1718

1819

1920
def _make_dt(timestamp):
20-
return dt.datetime.utcfromtimestamp(timestamp)
21+
return dt.datetime.fromtimestamp(timestamp, tz=timezone.utc)
2122

2223

2324
class Uid2Client:
@@ -63,7 +64,7 @@ def refresh_keys(self):
6364
Returns:
6465
EncryptionKeysCollection containing the keys
6566
"""
66-
req, nonce = self._make_v2_request(dt.datetime.utcnow())
67+
req, nonce = self._make_v2_request(dt.datetime.now(tz=timezone.utc))
6768
print(req)
6869
resp = self._post('/v2/key/latest', headers=self._auth_headers(), data=req)
6970
keys = [EncryptionKey(k['id'], k.get('site_id', -1), _make_dt(k['created']), _make_dt(k['activates']), _make_dt(k['expires']), base64.b64decode(k['secret']))

uid2_client/encryption.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77

88
import base64
99
import datetime as dt
10+
from datetime import timezone
1011
import os
1112
from Crypto.Cipher import AES
1213
from enum import Enum
1314

15+
from uid2_client.advertising_token_version import AdvertisingTokenVersion
16+
from uid2_client.uid2_base64_url_coder import Uid2Base64UrlCoder
1417

1518
encryption_block_size = AES.block_size
1619
"""int: block size for encryption routines
@@ -24,8 +27,9 @@ class _PayloadType(Enum):
2427
ENCRYPTED_DATA = 128
2528
ENCRYPTED_DATA_V3 = 96
2629

30+
base64_url_special_chars = {"-", "_"}
2731

28-
def decrypt_token(token, keys, now=dt.datetime.utcnow()):
32+
def decrypt_token(token, keys, now=dt.datetime.now(tz=timezone.utc)):
2933
"""Decrypt advertising token to extract UID2 details.
3034
3135
Args:
@@ -53,12 +57,18 @@ def _decrypt_token(token, keys, now):
5357
if not keys.valid(now):
5458
raise EncryptionError('no keys available or all keys have expired; refresh the latest keys from UID2 service')
5559

56-
token_bytes = base64.b64decode(token)
60+
header_str = token[0:4]
61+
index = next((i for i, ch in enumerate(header_str) if ch in base64_url_special_chars), None)
62+
is_base64_url_encoding = (index is not None)
63+
token_bytes = Uid2Base64UrlCoder.decode(header_str) if is_base64_url_encoding else base64.b64decode(header_str)
5764

5865
if token_bytes[0] == 2:
59-
return _decrypt_token_v2(token_bytes, keys, now)
60-
elif token_bytes[1] == 112:
61-
return _decrypt_token_v3(token_bytes, keys, now)
66+
return _decrypt_token_v2(base64.b64decode(token), keys, now)
67+
elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V3.value:
68+
return _decrypt_token_v3(base64.b64decode(token), keys, now)
69+
elif token_bytes[1] == AdvertisingTokenVersion.ADVERTISING_TOKEN_V4.value:
70+
# same as V3 but use Base64URL encoding
71+
return _decrypt_token_v3(Uid2Base64UrlCoder.decode(token), keys, now)
6272
else:
6373
raise EncryptionError('token version not supported')
6474

@@ -73,7 +83,7 @@ def _decrypt_token_v2(token_bytes, keys, now):
7383
master_payload = _decrypt(token_bytes[21:], master_iv, master_key)
7484

7585
expires_ms = int.from_bytes(master_payload[:8], 'big')
76-
expires = dt.datetime.utcfromtimestamp(expires_ms / 1000.0)
86+
expires = dt.datetime.fromtimestamp(expires_ms / 1000.0, tz=timezone.utc)
7787
if expires < now:
7888
raise EncryptionError("token expired")
7989

@@ -92,7 +102,7 @@ def _decrypt_token_v2(token_bytes, keys, now):
92102

93103
idx = 8 + id_len + 4
94104
established_ms = int.from_bytes(identity[idx:idx+8], 'big')
95-
established = dt.datetime.utcfromtimestamp(established_ms / 1000.0)
105+
established = dt.datetime.fromtimestamp(established_ms / 1000.0, tz=timezone.utc)
96106

97107
return DecryptedToken(id_str, established, site_id, site_key.site_id)
98108

@@ -106,7 +116,7 @@ def _decrypt_token_v3(token_bytes, keys, now):
106116
master_payload = _decrypt_gcm(token_bytes[6:], master_key.secret)
107117

108118
expires_ms = int.from_bytes(master_payload[:8], 'big')
109-
expires = dt.datetime.utcfromtimestamp(expires_ms / 1000.0)
119+
expires = dt.datetime.fromtimestamp(expires_ms / 1000.0, tz=timezone.utc)
110120
if expires < now:
111121
raise EncryptionError("token expired")
112122

@@ -128,7 +138,7 @@ def _decrypt_token_v3(token_bytes, keys, now):
128138
# client key id 12:16
129139
# privacy bits 16:20
130140
established_ms = int.from_bytes(site_payload[20:28], 'big')
131-
established = dt.datetime.utcfromtimestamp(established_ms / 1000.0)
141+
established = dt.datetime.fromtimestamp(established_ms / 1000.0, tz=timezone.utc)
132142
# refreshed_ms 28:36
133143

134144
id_bytes = site_payload[36:]
@@ -178,7 +188,7 @@ def encrypt_data(data, identity_scope, **kwargs):
178188
"""
179189
now = kwargs.get("now")
180190
if now is None:
181-
now = dt.datetime.utcnow()
191+
now = dt.datetime.now(tz=timezone.utc)
182192
keys = kwargs.get("keys")
183193
key = kwargs.get("key")
184194
if keys is not None and key is not None:
@@ -268,7 +278,7 @@ def _decrypt_data_v2(encrypted_bytes, keys):
268278
iv = encrypted_bytes[18:34]
269279
data = _decrypt(encrypted_bytes[34:], iv, key)
270280
encrypted_ms = int.from_bytes(encrypted_bytes[2:10], 'big')
271-
encrypted_at = dt.datetime.utcfromtimestamp(encrypted_ms / 1000.0)
281+
encrypted_at = dt.datetime.fromtimestamp(encrypted_ms / 1000.0, tz=timezone.utc)
272282
return DecryptedData(data, encrypted_at)
273283

274284

@@ -284,7 +294,7 @@ def _decrypt_data_v3(encrypted_bytes, keys):
284294

285295
payload = _decrypt_gcm(encrypted_bytes[6:], key.secret)
286296
encrypted_ms = int.from_bytes(payload[:8], 'big')
287-
encrypted_at = dt.datetime.utcfromtimestamp(encrypted_ms / 1000.0)
297+
encrypted_at = dt.datetime.fromtimestamp(encrypted_ms / 1000.0, tz=timezone.utc)
288298

289299
# site id 8:12
290300

uid2_client/identity_scope.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from enum import Enum
2+
3+
4+
class IdentityScope(Enum):
5+
"""Enum for types of unified ID"""
6+
UID2 = 0
7+
EUID = 1
8+

uid2_client/identity_type.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from enum import Enum
2+
3+
class IdentityType(Enum):
4+
"""Enum for types of ID source"""
5+
Email = 0
6+
Phone = 1
7+

0 commit comments

Comments
 (0)