Skip to content

Commit 70d146f

Browse files
authored
feat: add create_hmac_signature and create_storage_content_signature (#44)
This PR adds 2 new shared packages: - `create_hmac_signature` - function moved from Apify SDK. - `create_storage_content_signature` - function moved from Apify Client. Those functions will be used in both - client and SDK, this is the reason of moving it into shared package
1 parent 2ac0847 commit 70d146f

File tree

2 files changed

+123
-0
lines changed

2 files changed

+123
-0
lines changed

src/apify_shared/utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from __future__ import annotations
22

3+
import base64
34
import contextlib
5+
import hashlib
6+
import hmac
47
import io
58
import json
69
import re
10+
import string
11+
import time
712
from datetime import datetime, timezone
813
from enum import Enum
914
from typing import Any, TypeVar, cast
@@ -115,3 +120,59 @@ def parse(key: str, value: object) -> object:
115120
return {key: parse(key, value) for (key, value) in data.items()}
116121

117122
return data
123+
124+
125+
CHARSET = string.digits + string.ascii_letters
126+
127+
128+
def encode_base62(num: int) -> str:
129+
"""Encode the given number to base62."""
130+
if num == 0:
131+
return CHARSET[0]
132+
133+
res = ''
134+
while num > 0:
135+
num, remainder = divmod(num, 62)
136+
res = CHARSET[remainder] + res
137+
return res
138+
139+
140+
@ignore_docs
141+
def create_hmac_signature(secret_key: str, message: str) -> str:
142+
"""Generates an HMAC signature and encodes it using Base62. Base62 encoding reduces the signature length.
143+
144+
HMAC signature is truncated to 30 characters to make it shorter.
145+
146+
Args:
147+
secret_key (str): Secret key used for signing signatures
148+
message (str): Message to be signed
149+
150+
Returns:
151+
str: Base62 encoded signature
152+
"""
153+
signature = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()[:30]
154+
155+
decimal_signature = int(signature, 16)
156+
157+
return encode_base62(decimal_signature)
158+
159+
160+
def create_storage_content_signature(
161+
resource_id: str, url_signing_secret_key: str, expires_in_millis: int | None = None, version: int = 0
162+
) -> str:
163+
"""Create a secure signature for a resource like a dataset or key-value store.
164+
165+
This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent.
166+
The signature is created using HMAC with the provided secret key and includes
167+
the resource ID, expiration time, and version.
168+
169+
Note: expires_in_millis is optional. If not provided, the signature will not expire.
170+
171+
"""
172+
expires_at = int(time.time() * 1000) + expires_in_millis if expires_in_millis else 0
173+
174+
message_to_sign = f'{version}.{expires_at}.{resource_id}'
175+
hmac = create_hmac_signature(url_signing_secret_key, message_to_sign)
176+
177+
base64url_encoded_payload = base64.urlsafe_b64encode(f'{version}.{expires_at}.{hmac}'.encode())
178+
return base64url_encoded_payload.decode('utf-8')

tests/unit/test_utils.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from __future__ import annotations
22

3+
import base64
34
import io
45
from datetime import datetime, timezone
56
from enum import Enum
67

78
from apify_shared.utils import (
9+
create_hmac_signature,
10+
create_storage_content_signature,
11+
encode_base62,
812
filter_out_none_values_recursively,
913
filter_out_none_values_recursively_internal,
1014
ignore_docs,
@@ -146,3 +150,61 @@ def testing_function(_a: str, _b: str) -> str:
146150
return 'dummy'
147151

148152
assert testing_function is ignore_docs(testing_function)
153+
154+
155+
def test_encode_base62() -> None:
156+
assert encode_base62(0) == '0'
157+
assert encode_base62(10) == 'a'
158+
assert encode_base62(999999999) == '15FTGf'
159+
160+
161+
# This test ensures compatibility with the JavaScript version of the same method.
162+
# https://github.com/apify/apify-shared-js/blob/master/packages/utilities/src/hmac.ts
163+
def test_create_valid_hmac_signature() -> None:
164+
# This test uses the same secret key and message as in JS tests.
165+
secret_key = 'hmac-secret-key'
166+
message = 'hmac-message-to-be-authenticated'
167+
assert create_hmac_signature(secret_key, message) == 'pcVagAsudj8dFqdlg7mG'
168+
169+
170+
def test_create_same_hmac() -> None:
171+
# This test uses the same secret key and message as in JS tests.
172+
secret_key = 'hmac-same-secret-key'
173+
message = 'hmac-same-message-to-be-authenticated'
174+
assert create_hmac_signature(secret_key, message) == 'FYMcmTIm3idXqleF1Sw5'
175+
assert create_hmac_signature(secret_key, message) == 'FYMcmTIm3idXqleF1Sw5'
176+
177+
178+
# This test ensures compatibility with the JavaScript version of the same method.
179+
# https://github.com/apify/apify-shared-js/blob/master/packages/utilities/src/storages.ts
180+
def test_create_storage_content_signature() -> None:
181+
# This test uses the same parameters as in JS tests.
182+
secret_key = 'hmac-secret-key'
183+
message = 'resource-id'
184+
185+
signature = create_storage_content_signature(
186+
resource_id=message,
187+
url_signing_secret_key=secret_key,
188+
)
189+
190+
version, expires_at, hmac = base64.urlsafe_b64decode(signature).decode('utf-8').split('.')
191+
192+
assert signature == 'MC4wLjNUd2ZFRTY1OXVmU05zbVM0N2xS'
193+
assert version == '0'
194+
assert expires_at == '0'
195+
assert hmac == '3TwfEE659ufSNsmS47lR'
196+
197+
198+
def test_create_storage_content_signature_with_expiration() -> None:
199+
secret_key = 'hmac-secret-key'
200+
message = 'resource-id'
201+
202+
signature = create_storage_content_signature(
203+
resource_id=message,
204+
url_signing_secret_key=secret_key,
205+
expires_in_millis=10000,
206+
)
207+
208+
version, expires_at, hmac = base64.urlsafe_b64decode(signature).decode('utf-8').split('.')
209+
assert version == '0'
210+
assert expires_at != '0'

0 commit comments

Comments
 (0)