Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat: add signing of public URL #407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions 38 src/apify/_crypto.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import annotations

import base64
import hashlib
import hmac
import string
from typing import Any

from cryptography.exceptions import InvalidTag as InvalidTagException
Expand Down Expand Up @@ -153,3 +156,38 @@ def decrypt_input_secrets(private_key: rsa.RSAPrivateKey, input_data: Any) -> An
)

return input_data


CHARSET = string.digits + string.ascii_letters


def encode_base62(num: int) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method alone is perfect for writing unit test for. Could you please add one parametrized test with couple of input values.

https://docs.pytest.org/en/stable/how-to/parametrize.html#pytest-mark-parametrize

"""Encode the given number to base62."""
if num == 0:
return CHARSET[0]

res = ''
while num > 0:
num, remainder = divmod(num, 62)
res = CHARSET[remainder] + res
return res


@ignore_docs
def create_hmac_signature(secret_key: str, message: str) -> str:
"""Generate an HMAC signature and encodes it using Base62. Base62 encoding reduces the signature length.

HMAC signature is truncated to 30 characters to make it shorter.

Args:
secret_key: Secret key used for signing signatures.
message: Message to be signed.

Returns:
Base62 encoded signature.
"""
signature = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()[:30]
janbuchar marked this conversation as resolved.
Show resolved Hide resolved

decimal_signature = int(signature, 16)

return encode_base62(decimal_signature)
19 changes: 17 additions & 2 deletions 19 src/apify/apify_storage_client/_key_value_store_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
from typing import TYPE_CHECKING, Any

from typing_extensions import override
from yarl import URL

from crawlee.storage_clients._base import KeyValueStoreClient as BaseKeyValueStoreClient
from crawlee.storage_clients.models import KeyValueStoreListKeysPage, KeyValueStoreMetadata, KeyValueStoreRecord

from apify._crypto import create_hmac_signature

if TYPE_CHECKING:
from collections.abc import AsyncIterator
from contextlib import AbstractAsyncContextManager
Expand Down Expand Up @@ -89,6 +92,18 @@ async def get_public_url(self, key: str) -> str:
Args:
key: The key for which the URL should be generated.
"""
public_api_url = self._api_public_base_url
if self._client.resource_id is None:
raise ValueError('resource_id cannot be None when generating a public URL')

public_url = (
URL(self._api_public_base_url) / 'v2' / 'key-value-stores' / self._client.resource_id / 'records' / key
)

key_value_store = await self.get()

if key_value_store is not None and isinstance(key_value_store.model_extra, dict):
url_signing_secret_key = key_value_store.model_extra.get('urlSigningSecretKey')
if url_signing_secret_key:
public_url = public_url.with_query(signature=create_hmac_signature(url_signing_secret_key, key))

return f'{public_api_url}/v2/key-value-stores/{self._client.resource_id}/records/{key}'
return str(public_url)
21 changes: 15 additions & 6 deletions 21 tests/integration/test_actor_key_value_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,28 @@ async def test_generate_public_url_for_kvs_record(
run_actor: RunActorFunction,
) -> None:
async def main() -> None:
from typing import cast

from apify.apify_storage_client._key_value_store_client import KeyValueStoreClient
from apify._crypto import create_hmac_signature

async with Actor:
public_api_url = Actor.config.api_public_base_url
default_store_id = Actor.config.default_key_value_store_id
record_key = 'public-record-key'

store = await Actor.open_key_value_store()
record_url = await cast(KeyValueStoreClient, store._resource_client).get_public_url('dummy')
print(record_url)

assert record_url == f'{public_api_url}/v2/key-value-stores/{default_store_id}/records/dummy'
assert isinstance(store.storage_object.model_extra, dict)
url_signing_secret_key = store.storage_object.model_extra.get('urlSigningSecretKey')
assert url_signing_secret_key is not None

await store.set_value(record_key, {'exposedData': 'test'}, 'application/json')

record_url = await store.get_public_url(record_key)

signature = create_hmac_signature(url_signing_secret_key, record_key)
assert (
record_url
== f'{public_api_url}/v2/key-value-stores/{default_store_id}/records/{record_key}?signature={signature}'
)

actor = await make_actor(label='kvs-get-public-url', main_func=main)
run_result = await run_actor(actor)
Expand Down
32 changes: 31 additions & 1 deletion 32 tests/unit/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

import pytest

from apify._crypto import _load_public_key, crypto_random_object_id, load_private_key, private_decrypt, public_encrypt
from apify._crypto import (
_load_public_key,
create_hmac_signature,
crypto_random_object_id,
encode_base62,
load_private_key,
private_decrypt,
public_encrypt,
)

# NOTE: Uses the same keys as in:
# https://github.com/apify/apify-shared-js/blob/master/test/crypto.test.ts
Expand Down Expand Up @@ -105,3 +113,25 @@ def test_crypto_random_object_id_length_and_charset() -> None:
long_random_object_id = crypto_random_object_id(1000)
for char in long_random_object_id:
assert char in 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789'


@pytest.mark.parametrize(('test_input', 'expected'), [(0, '0'), (10, 'a'), (999999999, '15FTGf')])
def test_encode_base62(test_input: int, expected: str) -> None:
assert encode_base62(test_input) == expected


# This test ensures compatibility with the JavaScript version of the same method.
# https://github.com/apify/apify-shared-js/blob/master/packages/utilities/src/hmac.ts
def test_create_valid_hmac_signature() -> None:
# This test uses the same secret key and message as in JS tests.
secret_key = 'hmac-secret-key'
message = 'hmac-message-to-be-authenticated'
assert create_hmac_signature(secret_key, message) == 'pcVagAsudj8dFqdlg7mG'


def test_create_same_hmac() -> None:
# This test uses the same secret key and message as in JS tests.
secret_key = 'hmac-same-secret-key'
message = 'hmac-same-message-to-be-authenticated'
assert create_hmac_signature(secret_key, message) == 'FYMcmTIm3idXqleF1Sw5'
assert create_hmac_signature(secret_key, message) == 'FYMcmTIm3idXqleF1Sw5'
6 changes: 3 additions & 3 deletions 6 uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Morty Proxy This is a proxified and sanitized view of the page, visit original site.